diff --git a/backend/migrations/027_hk_require_photo.sql b/backend/migrations/027_hk_require_photo.sql new file mode 100644 index 0000000..69a2b1d --- /dev/null +++ b/backend/migrations/027_hk_require_photo.sql @@ -0,0 +1,2 @@ +ALTER TABLE housekeeping_settings + ADD COLUMN IF NOT EXISTS require_completion_photo BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/src/routes/housekeeping-settings.ts b/backend/src/routes/housekeeping-settings.ts index 4059722..8b4e36d 100644 --- a/backend/src/routes/housekeeping-settings.ts +++ b/backend/src/routes/housekeeping-settings.ts @@ -7,17 +7,19 @@ export interface HkSettings { checkout_auto: boolean checkout_priority: string inspection_after_clean: boolean + require_completion_photo: boolean } const DEFAULT_SETTINGS: HkSettings = { checkout_auto: true, checkout_priority: 'high', inspection_after_clean: true, + require_completion_photo: false, } export async function getHkSettings(hotelId: string): Promise { const { rows } = await db.query( - 'SELECT checkout_auto, checkout_priority, inspection_after_clean FROM housekeeping_settings WHERE hotel_id = $1', + 'SELECT checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo FROM housekeeping_settings WHERE hotel_id = $1', [hotelId], ) return rows[0] ?? DEFAULT_SETTINGS @@ -57,20 +59,22 @@ const housekeepingSettings: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { checkout_auto, checkout_priority, inspection_after_clean } = request.body + const { checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo } = request.body await db.query( - `INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean) - VALUES ($1, $2, $3, $4) + `INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (hotel_id) DO UPDATE SET - checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto), - checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority), - inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean), - updated_at = NOW()`, + checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto), + checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority), + inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean), + require_completion_photo = COALESCE(EXCLUDED.require_completion_photo, housekeeping_settings.require_completion_photo), + updated_at = NOW()`, [ hotelId, - checkout_auto ?? DEFAULT_SETTINGS.checkout_auto, - checkout_priority ?? DEFAULT_SETTINGS.checkout_priority, - inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean, + checkout_auto ?? DEFAULT_SETTINGS.checkout_auto, + checkout_priority ?? DEFAULT_SETTINGS.checkout_priority, + inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean, + require_completion_photo ?? DEFAULT_SETTINGS.require_completion_photo, ], ) return getHkSettings(hotelId) diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index dd0ccb9..ba5dcba 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -29,7 +29,9 @@ interface BookingCalendarProps { onBookingUpdate: (id: string, b: Partial) => void onBookingBulkUpdate?: (updates: Array<{ id: string; data: Partial }>) => void onRoomUpdate?: (roomId: string, patch: Partial, priority?: HkPriority) => void - onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority) => void + onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void + /** roomId → assignee name, for tooltip on "убирается" */ + cleaningAssignees?: Record fadingBookingIds?: Set rentalObjects?: RentalObject[] rentalBookings?: RentalBooking[] @@ -63,7 +65,7 @@ function getRoomTypeColor(type: string): string { return map[type] ?? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' } -export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) { +export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, cleaningAssignees = {}, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) { const [startDate, setStartDate] = useState(() => startOfDay(new Date())) const [visibleDays, setVisibleDays] = useState(DAYS_VISIBLE) @@ -88,8 +90,8 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook onRoomUpdate?.(roomId, { housekeepingStatus: status }, priority) } - const handleCtxMaintenanceTask = (roomId: string, description: string, priority: HkPriority) => { - onMaintenanceTaskCreate?.(roomId, description, priority) + const handleCtxMaintenanceTask = (roomId: string, description: string, priority: HkPriority, photos: string[]) => { + onMaintenanceTaskCreate?.(roomId, description, priority, photos) } // Cell hover price tooltip @@ -521,7 +523,12 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook )} {room.status !== 'maintenance' && room.status !== 'blocked' && (room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') && ( - + {room.housekeepingStatus === 'dirty' ? '🧹 грязный' : '🧹 убирается'} )} diff --git a/src/components/rooms/RoomContextMenu.tsx b/src/components/rooms/RoomContextMenu.tsx index 59b4e26..499aeee 100644 --- a/src/components/rooms/RoomContextMenu.tsx +++ b/src/components/rooms/RoomContextMenu.tsx @@ -1,9 +1,10 @@ import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' -import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon, CalendarDays, AlertTriangle, Zap } from 'lucide-react' +import { Wrench, Ban, CheckCircle, Sparkles, ClipboardList, Pencil, ChevronRight, X as XIcon, CalendarDays, AlertTriangle, Zap, Camera, Loader2 } from 'lucide-react' import { format } from 'date-fns' import type { Room, RoomStatus, HousekeepingStatus } from '../../types' import { cn } from '../../lib/utils' +import { api } from '../../lib/api' const today = () => format(new Date(), 'yyyy-MM-dd') @@ -16,7 +17,7 @@ interface RoomContextMenuProps { onClose: () => void onStatusChange: (roomId: string, status: RoomStatus, maintenanceFrom?: string | null, maintenanceTo?: string | null) => void onHkStatusChange: (roomId: string, status: HousekeepingStatus, priority?: HkPriority) => void - onMaintenanceTask?: (roomId: string, description: string, priority: HkPriority) => void + onMaintenanceTask?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void onEdit?: (room: Room) => void } @@ -43,6 +44,9 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu const [pendingHkStatus, setPendingHkStatus] = useState(null) const [techDesc, setTechDesc] = useState('') const [techPriority, setTechPriority] = useState('medium') + const [techPhotos, setTechPhotos] = useState([]) + const [photoUploading, setPhotoUploading] = useState(false) + const photoInputRef = useRef(null) useEffect(() => { const handler = (e: MouseEvent) => { @@ -98,9 +102,19 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu onClose() } + const handleTechPhotoUpload = async (file: File) => { + setPhotoUploading(true) + try { + const url = await api.upload.photo(file, 'tasks') + setTechPhotos(prev => [...prev, url]) + } catch { /* ignore */ } finally { + setPhotoUploading(false) + } + } + const handleTechSubmit = () => { if (onMaintenanceTask && techDesc.trim()) { - onMaintenanceTask(room.id, techDesc.trim(), techPriority) + onMaintenanceTask(room.id, techDesc.trim(), techPriority, techPhotos) } onClose() } @@ -274,10 +288,38 @@ export function RoomContextMenu({ room, x, y, onClose, onStatusChange, onHkStatu ))} + {/* Photo upload */} +
+ { const f = e.target.files?.[0]; if (f) { handleTechPhotoUpload(f); e.target.value = '' } }} + /> +
+ {techPhotos.map((url, i) => ( +
+ + +
+ ))} + +
+
{colTasks.map(task => ( - + addMaintenanceReport(id, note, sev, photos)} /> ))} {colTasks.length === 0 && (
@@ -486,6 +500,16 @@ export function HousekeepingPage() {
+
+
+
+

Обязательное фото при завершении тех. задачи

+

Технический специалист должен прикрепить фото перед тем как отметить задачу выполненной

+
+ setP('requireCompletionPhoto', !plans.requireCompletionPhoto)} /> +
+
+
+
+ ))} + + + + ))} + + + + {requirePhoto && photos.length === 0 && ( +

+ Необходимо прикрепить хотя бы одно фото +

+ )} + +
+ + +
+ + + ) +}