From c83a9ca714b47d7e5536ff6e51b59810627b0c69 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 24 Mar 2026 11:45:24 +0300 Subject: [PATCH] feat: task problem/solution split + maintenance icons on rooms/calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TechnicalPage: redesign task cards with separate "Задача" and "Решение" sections - "Задача": problem description + problem photos (editable before done) - "Решение": technician resolution comment + resolution photos (green section) - Resolution comment in CompletionModal now saves to resolution_notes field - Resolution photos save to resolution_photos (no longer mixed with problem photos) - RoomModal history tab: maintenance entries show separate problem/resolution sections - Backend: add resolution_notes, resolution_photos to PATCH allowed fields - Migration 029: resolution_notes TEXT + resolution_photos TEXT[] columns - RoomsPage: load active maintenance tasks, show 🔧 неисправность badge on room cards - CalendarPage: load active maintenance tasks, show 🔧 неиспр. label in calendar row - BookingCalendar: activeMaintenance prop, WS handler updates maintenance set on task changes - CalendarPage WS: handle housekeeping_task_created to update activeMaintenance set Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/029_task_resolution.sql | 2 + backend/src/routes/housekeeping.ts | 2 +- src/components/calendar/BookingCalendar.tsx | 7 +- src/components/rooms/RoomModal.tsx | 103 +++++++------ src/lib/api.ts | 1 + src/pages/CalendarPage.tsx | 29 ++++ src/pages/HousekeepingPage.tsx | 14 +- src/pages/RoomsPage.tsx | 31 +++- src/pages/TechnicalPage.tsx | 153 +++++++++++++------- src/types/index.ts | 2 + 10 files changed, 237 insertions(+), 107 deletions(-) create mode 100644 backend/migrations/029_task_resolution.sql diff --git a/backend/migrations/029_task_resolution.sql b/backend/migrations/029_task_resolution.sql new file mode 100644 index 0000000..273fff6 --- /dev/null +++ b/backend/migrations/029_task_resolution.sql @@ -0,0 +1,2 @@ +ALTER TABLE housekeeping_tasks ADD COLUMN IF NOT EXISTS resolution_notes TEXT; +ALTER TABLE housekeeping_tasks ADD COLUMN IF NOT EXISTS resolution_photos TEXT[] NOT NULL DEFAULT '{}'; diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts index c377629..b553909 100644 --- a/backend/src/routes/housekeeping.ts +++ b/backend/src/routes/housekeeping.ts @@ -105,7 +105,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date','category','photos'] + const allowed = ['room_id','type','status','priority','assignee_id','notes','due_date','category','photos','resolution_notes','resolution_photos'] const updates: string[] = [] const values: unknown[] = [] let idx = 1 diff --git a/src/components/calendar/BookingCalendar.tsx b/src/components/calendar/BookingCalendar.tsx index ba5dcba..0e3a959 100644 --- a/src/components/calendar/BookingCalendar.tsx +++ b/src/components/calendar/BookingCalendar.tsx @@ -32,6 +32,8 @@ interface BookingCalendarProps { onMaintenanceTaskCreate?: (roomId: string, description: string, priority: HkPriority, photos: string[]) => void /** roomId → assignee name, for tooltip on "убирается" */ cleaningAssignees?: Record + /** Set of room IDs that have active maintenance tasks */ + activeMaintenance?: Set fadingBookingIds?: Set rentalObjects?: RentalObject[] rentalBookings?: RentalBooking[] @@ -65,7 +67,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, cleaningAssignees = {}, fadingBookingIds, rentalObjects, rentalBookings, onRentalBookingCreate, locks = new Map(), onDraftStart, onDraftCancel, wsConnected, priceOverrides }: BookingCalendarProps) { +export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBookingUpdate, onBookingBulkUpdate, onRoomUpdate, onMaintenanceTaskCreate, cleaningAssignees = {}, activeMaintenance = new Set(), 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) @@ -521,6 +523,9 @@ export function BookingCalendar({ rooms, bookings, slug, onBookingCreate, onBook {room.status === 'blocked' && ( 🔒 закрыт )} + {activeMaintenance.has(room.id) && room.status !== 'maintenance' && ( + 🔧 неиспр. + )} {room.status !== 'maintenance' && room.status !== 'blocked' && (room.housekeepingStatus === 'dirty' || room.housekeepingStatus === 'cleaning') && (
{grouped[day].map(task => { - const taskAny = task as unknown as Record + const taskAny = task as unknown as Record const isMaintenance = task.type === 'maintenance' const isExpanded = expandedTask === task.id - const photos = task.photos ?? [] - const assigneeName = task.assignedToName ?? taskAny.assigneeName ?? taskAny.assignee_name - const startedAt = task.startedAt ?? taskAny.started_at - const completedAt = task.completedAt ?? taskAny.completed_at + const problemPhotos = task.photos ?? [] + const resolutionPhotos = task.resolutionPhotos ?? (taskAny.resolution_photos as string[] | undefined) ?? [] + const resolutionNotes = task.resolutionNotes ?? taskAny.resolution_notes as string | undefined + const allPhotoCount = problemPhotos.length + resolutionPhotos.length + const assigneeName = task.assignedToName ?? taskAny.assigneeName as string ?? taskAny.assignee_name as string + const startedAt = task.startedAt ?? taskAny.started_at as string + const completedAt = task.completedAt ?? taskAny.completed_at as string const startTime = startedAt ? new Date(startedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) @@ -859,9 +862,9 @@ function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) { {STATUS_LABELS[task.status ?? 'pending']} - {photos.length > 0 && ( + {allPhotoCount > 0 && ( - 📷 {photos.length} + 📷 {allPhotoCount} )}
@@ -895,59 +898,77 @@ function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) { {/* Expanded details */} {isExpanded && ( -
- {task.notes && ( -
-

- {isMaintenance ? 'Описание проблемы' : 'Замечания'} -

+
+ {/* Problem section */} +
+

+ + {isMaintenance ? 'Задача / проблема' : 'Замечания'} +

+ {task.notes ? (

{task.notes}

-
- )} - - {photos.length > 0 && ( -
-

- {isMaintenance ? 'Фото (проблема / решение)' : 'Фото'} -

-
- {photos.map((url, i) => ( - ))}
+ )} +
+ + {/* Resolution section — for maintenance tasks */} + {isMaintenance && ( +
+

+ + Решение + {completedAt && ( + + {format(new Date(completedAt), 'd MMM HH:mm', { locale: ru })} + + )} +

+ {resolutionNotes ? ( +

+ {resolutionNotes} +

+ ) : ( +

Комментарий не оставлен

+ )} + {resolutionPhotos.length > 0 && ( +
+ {resolutionPhotos.map((url, i) => ( + + ))} +
+ )}
)} - {/* Timing details */} + {/* Timing */} {(startedAt || completedAt) && ( -
+
{startedAt && (

Начало

-

+

{format(new Date(startedAt), 'd MMM HH:mm', { locale: ru })}

)} - {completedAt && ( + {completedAt && !isMaintenance && (

Завершено

-

+

{format(new Date(completedAt), 'd MMM HH:mm', { locale: ru })}

diff --git a/src/lib/api.ts b/src/lib/api.ts index 55bf72d..0b5f9f6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -557,6 +557,7 @@ export interface HkPayload { room_id?: string; type?: string; priority?: string status?: string; assignee_id?: string; notes?: string; due_date?: string category?: string; photos?: string[] + resolution_notes?: string; resolution_photos?: string[] } export interface HkSettings { diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx index c3709ac..6ec0e64 100644 --- a/src/pages/CalendarPage.tsx +++ b/src/pages/CalendarPage.tsx @@ -27,6 +27,8 @@ export function CalendarPage() { const [priceOverrides, setPriceOverrides] = useState>>({}) // roomId → assigneeName for "убирается" tooltip const [cleaningAssignees, setCleaningAssignees] = useState>({}) + // Set of room IDs with active maintenance tasks + const [activeMaintenance, setActiveMaintenance] = useState>(new Set()) useEffect(() => { if (!slug) return @@ -55,6 +57,17 @@ export function CalendarPage() { } }).catch(console.error) + // Fetch active maintenance tasks + api.housekeeping.list(slug, { status: 'active', category: 'maintenance' }) + .then(tasks => { + const ids = new Set(tasks.map(t => { + const tAny = t as unknown as Record + return tAny.roomId ?? tAny.room_id ?? '' + }).filter(Boolean)) + setActiveMaintenance(ids) + }) + .catch(() => {}) + // Fetch active HK tasks to build "who is cleaning" tooltip map api.housekeeping.list(slug, { status: 'active', category: 'housekeeping' }) .then(tasks => { @@ -90,8 +103,23 @@ export function CalendarPage() { if (msg.roomStatus !== 'cleaning') { setCleaningAssignees(prev => { const n = { ...prev }; delete n[msg.roomId]; return n }) } + } else if (msg.type === 'housekeeping_task_created') { + const t = msg.task as Record + const roomId = t.roomId ?? t.room_id + if (t.category === 'maintenance' && roomId) { + setActiveMaintenance(prev => new Set([...prev, roomId])) + } } else if (msg.type === 'housekeeping_updated') { const t = msg.task as Record + const roomId = t.roomId ?? t.room_id + // Track maintenance tasks + if (t.category === 'maintenance' && roomId) { + if (t.status === 'done' || t.status === 'cancelled') { + setActiveMaintenance(prev => { const n = new Set(prev); n.delete(roomId); return n }) + } else { + setActiveMaintenance(prev => new Set([...prev, roomId])) + } + } if (t.roomId) { if (t.status === 'in_progress' && t.assigneeName) { setCleaningAssignees(prev => ({ ...prev, [t.roomId]: t.assigneeName })) @@ -258,6 +286,7 @@ export function CalendarPage() { onRoomUpdate={handleRoomUpdate} onMaintenanceTaskCreate={handleMaintenanceTaskCreate} cleaningAssignees={cleaningAssignees} + activeMaintenance={activeMaintenance} fadingBookingIds={fadingBookings} rentalObjects={isRentalActive ? rentalObjects as unknown as import('../data/rentalData').RentalObject[] : undefined} rentalBookings={isRentalActive ? rentalBookings as unknown as RentalBooking[] : undefined} diff --git a/src/pages/HousekeepingPage.tsx b/src/pages/HousekeepingPage.tsx index 75a4881..898eda2 100644 --- a/src/pages/HousekeepingPage.tsx +++ b/src/pages/HousekeepingPage.tsx @@ -16,12 +16,14 @@ import { ru } from 'date-fns/locale' function normalizeTask(raw: Record): Record { return { ...raw, - roomNumber: raw.roomNumber ?? raw.room_number, - roomId: raw.roomId ?? raw.room_id, - assigneeName: raw.assigneeName ?? raw.assignee_name, - dueDate: raw.dueDate ?? raw.due_date, - completedAt: raw.completedAt ?? raw.completed_at, - startedAt: raw.startedAt ?? raw.started_at, + roomNumber: raw.roomNumber ?? raw.room_number, + roomId: raw.roomId ?? raw.room_id, + assigneeName: raw.assigneeName ?? raw.assignee_name, + dueDate: raw.dueDate ?? raw.due_date, + completedAt: raw.completedAt ?? raw.completed_at, + startedAt: raw.startedAt ?? raw.started_at, + resolutionNotes: raw.resolutionNotes ?? raw.resolution_notes, + resolutionPhotos: raw.resolutionPhotos ?? raw.resolution_photos, } } diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx index 28947f3..0bc33c0 100644 --- a/src/pages/RoomsPage.tsx +++ b/src/pages/RoomsPage.tsx @@ -17,6 +17,7 @@ export function RoomsPage() { const [rooms, setRooms] = useState([]) const [categories, setCategories] = useState([]) const [loading, setLoading] = useState(true) + const [activeMaintenance, setActiveMaintenance] = useState>(new Set()) const [search, setSearch] = useState('') const [floorFilter, setFloorFilter] = useState('all') const [modalOpen, setModalOpen] = useState(false) @@ -38,6 +39,16 @@ export function RoomsPage() { .then(([r, cats]) => { setRooms(r); setCategories(cats) }) .catch(console.error) .finally(() => setLoading(false)) + + api.housekeeping.list(slug, { status: 'active', category: 'maintenance' }) + .then(tasks => { + const ids = new Set(tasks.map(t => { + const tAny = t as unknown as Record + return tAny.roomId ?? tAny.room_id ?? '' + }).filter(Boolean)) + setActiveMaintenance(ids) + }) + .catch(() => {}) }, [slug]) const floors = [...new Set(rooms.map(r => r.floor))].sort() @@ -228,6 +239,7 @@ export function RoomsPage() { openEdit(room)} onContextMenu={(e) => { e.preventDefault(); setCtxMenu({ room, x: e.clientX, y: e.clientY }) }} /> @@ -273,7 +285,7 @@ export function RoomsPage() { ) } -function RoomCard({ room, onEdit, onContextMenu }: { room: Room; onEdit: () => void; onContextMenu: (e: React.MouseEvent) => void }) { +function RoomCard({ room, hasActiveMaintenance, onEdit, onContextMenu }: { room: Room; hasActiveMaintenance?: boolean; onEdit: () => void; onContextMenu: (e: React.MouseEvent) => void }) { return (
v > - {room.status === 'maintenance' && ( -
- - ремонт - + {(room.status === 'maintenance' || hasActiveMaintenance) && ( +
+ {room.status === 'maintenance' && ( + + ремонт + + )} + {hasActiveMaintenance && room.status !== 'maintenance' && ( + + неисправность + + )}
)} diff --git a/src/pages/TechnicalPage.tsx b/src/pages/TechnicalPage.tsx index 8b7ab9b..5384df3 100644 --- a/src/pages/TechnicalPage.tsx +++ b/src/pages/TechnicalPage.tsx @@ -13,12 +13,14 @@ import { Modal } from '../components/ui/Modal' function normalizeTask(raw: Record): Record { return { ...raw, - roomNumber: raw.roomNumber ?? raw.room_number, - roomId: raw.roomId ?? raw.room_id, - assigneeName: raw.assigneeName ?? raw.assignee_name, - dueDate: raw.dueDate ?? raw.due_date, - completedAt: raw.completedAt ?? raw.completed_at, - startedAt: raw.startedAt ?? raw.started_at, + roomNumber: raw.roomNumber ?? raw.room_number, + roomId: raw.roomId ?? raw.room_id, + assigneeName: raw.assigneeName ?? raw.assignee_name, + dueDate: raw.dueDate ?? raw.due_date, + completedAt: raw.completedAt ?? raw.completed_at, + startedAt: raw.startedAt ?? raw.started_at, + resolutionNotes: raw.resolutionNotes ?? raw.resolution_notes, + resolutionPhotos: raw.resolutionPhotos ?? raw.resolution_photos, } } @@ -177,51 +179,57 @@ export function TechnicalPage() {

Нет технических задач

) : filtered.map(task => { - const taskRecord = task as unknown as Record - const pMeta = PRIORITY_LABELS[taskRecord.priority ?? 'medium'] ?? PRIORITY_LABELS.medium + const taskRecord = task as unknown as Record + const pMeta = PRIORITY_LABELS[String(taskRecord.priority ?? 'medium')] ?? PRIORITY_LABELS.medium const sMeta = STATUS_LABELS[task.status ?? 'pending'] const SIcon = sMeta.icon const roomLabel = (taskRecord.roomNumber || taskRecord.room_number) ? `Номер ${taskRecord.roomNumber ?? taskRecord.room_number}` : 'Без номера' - const photos = task.photos ?? [] + const problemPhotos = task.photos ?? [] + const resolutionPhotos = task.resolutionPhotos ?? (taskRecord.resolution_photos as string[] | undefined) ?? [] + const resolutionNotes = task.resolutionNotes ?? taskRecord.resolution_notes as string | undefined const isUploading = uploadingFor === task.id + const isDone = task.status === 'done' - const startedAt = task.startedAt ?? (taskRecord as Record).started_at - const completedAt = task.completedAt ?? (taskRecord as Record).completed_at + const startedAt = task.startedAt ?? taskRecord.started_at as string | undefined + const completedAt = task.completedAt ?? taskRecord.completed_at as string | undefined const durationMin = startedAt && completedAt - ? Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 60000) + ? Math.round((new Date(completedAt as string).getTime() - new Date(startedAt as string).getTime()) / 60000) : null return ( -
-
+
+ {/* ── Card header ── */} +
-
- - {task.notes ?? task.type ?? 'Задача'} - +
{pMeta.label} + + {sMeta.label} +
-
- {roomLabel} +
+ {roomLabel} {task.dueDate && ( {format(new Date(task.dueDate), 'd MMM', { locale: ru })} )} {taskRecord.assigneeName && ( - → {taskRecord.assigneeName} + → {String(taskRecord.assigneeName)} )} {durationMin !== null && durationMin > 0 && ( - {durationMin < 60 - ? `${durationMin} мин` - : `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`} + {durationMin < 60 ? `${durationMin} мин` : `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`} )}
@@ -250,30 +258,71 @@ export function TechnicalPage() {
- {/* Photos */} - {(photos.length > 0 || true) && ( -
- {photos.map(url => ( -
- - - - + {/* ── Задача (problem) section ── */} +
+
+ + Задача +
+
+ {task.notes ? ( +

{task.notes}

+ ) : ( +

Описание не указано

+ )} + {(problemPhotos.length > 0 || !isDone) && ( +
+ {problemPhotos.map(url => ( +
+ + + + {!isDone && ( + + )} +
+ ))} + {!isDone && ( + handlePhotoUpload(task.id, file)} /> + )}
- ))} - handlePhotoUpload(task.id, file)} - /> + )} +
+
+ + {/* ── Решение (resolution) section ── */} + {(isDone || resolutionNotes || resolutionPhotos.length > 0) && ( +
+
+ + Решение + {completedAt && ( + + {format(new Date(completedAt as string), 'd MMM HH:mm', { locale: ru })} + + )} +
+
+ {resolutionNotes ? ( +

{resolutionNotes}

+ ) : ( +

Комментарий не оставлен

+ )} + {resolutionPhotos.length > 0 && ( +
+ {resolutionPhotos.map(url => ( + + + + ))} +
+ )} +
)}
@@ -297,11 +346,11 @@ export function TechnicalPage() { requirePhoto={requirePhoto} onClose={() => setCompletingTask(null)} onConfirm={async (completionPhotos, comment) => { - const task = tasks.find(t => t.id === completingTask) - const currentPhotos = task?.photos ?? [] - const allPhotos = [...currentPhotos, ...completionPhotos] - const patch: import('../lib/api').HkPayload = { status: 'done', photos: allPhotos } - if (comment.trim()) patch.notes = comment.trim() + const patch: import('../lib/api').HkPayload = { + status: 'done', + resolution_photos: completionPhotos, + } + if (comment.trim()) patch.resolution_notes = comment.trim() const updated = await api.housekeeping.update(slug, completingTask, patch) setTasks(prev => prev.map(t => t.id === completingTask ? updated : t)) setCompletingTask(null) diff --git a/src/types/index.ts b/src/types/index.ts index d277e8b..7d0801a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -215,6 +215,8 @@ export interface HousekeepingTask { completedAt?: string type: 'cleaning' | 'inspection' | 'maintenance' photos?: string[] + resolutionNotes?: string + resolutionPhotos?: string[] } // ─── API Docs ─────────────────────────────────────────────────────────────────