From 14f0b8e0f2aea3c087ff5b2160891a3b9ae60856 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Tue, 24 Mar 2026 11:32:11 +0300 Subject: [PATCH] feat: room history tab + tech task resolution comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RoomModal: new "История" tab showing all housekeeping and maintenance tasks for the room - Groups tasks by date (newest first), expandable cards - Shows: assignee, start time, end time, duration (started_at → completed_at) - For maintenance: description, problem/solution photos with lightbox viewer - Timing grid: start / completion timestamps - Backend GET /housekeeping: add room_id filter param - TechnicalPage CompletionModal: add resolution comment field saved to task notes - api.ts: housekeeping.list() accepts roomId param Co-Authored-By: Claude Sonnet 4.6 --- backend/src/routes/housekeeping.ts | 7 +- src/components/rooms/RoomModal.tsx | 272 ++++++++++++++++++++++++++++- src/lib/api.ts | 3 +- src/pages/RoomsPage.tsx | 1 + src/pages/TechnicalPage.tsx | 74 +++++--- 5 files changed, 320 insertions(+), 37 deletions(-) diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts index 1d6c629..c377629 100644 --- a/backend/src/routes/housekeeping.ts +++ b/backend/src/routes/housekeeping.ts @@ -16,7 +16,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { role === 'super_admin' || userSlug === slug // ── GET /api/hotels/:slug/housekeeping ───────────────────────────────────── - fastify.get( + fastify.get( '/api/hotels/:slug/housekeeping', { onRequest: [fastify.authenticate] }, async (request, reply) => { @@ -31,7 +31,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { const values: unknown[] = [hotelId] let idx = 2 - const { status, date, category } = request.query + const { status, date, category, room_id } = request.query if (status === 'active') { conditions.push(`t.status IN ('pending', 'in_progress')`) } else if (status) { @@ -39,6 +39,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { } if (date) { conditions.push(`t.due_date = $${idx}`); values.push(date); idx++ } if (category) { conditions.push(`t.category = $${idx}`); values.push(category); idx++ } + if (room_id) { conditions.push(`t.room_id = $${idx}`); values.push(room_id); idx++ } const { rows } = await db.query( `SELECT t.*, @@ -50,7 +51,7 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { WHERE ${conditions.join(' AND ')} ORDER BY CASE t.priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END, - t.created_at`, + COALESCE(t.completed_at, t.created_at) DESC`, values, ) return rows diff --git a/src/components/rooms/RoomModal.tsx b/src/components/rooms/RoomModal.tsx index b531bb8..24aa44e 100644 --- a/src/components/rooms/RoomModal.tsx +++ b/src/components/rooms/RoomModal.tsx @@ -1,10 +1,13 @@ -import { useState, useRef } from 'react' +import { useState, useRef, useEffect } from 'react' import { Modal } from '../ui/Modal' import { cn } from '../../lib/utils' -import type { Room, RoomStatus, HousekeepingStatus, BedType, BedItem, ExtraPlace, ChildPolicy } from '../../types' -import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight, Plus, Minus, Baby, Trash2 } from 'lucide-react' +import type { Room, RoomStatus, HousekeepingStatus, BedType, BedItem, ExtraPlace, ChildPolicy, HousekeepingTask } from '../../types' +import { ImagePlus, X as XIcon, ChevronLeft, ChevronRight, Plus, Minus, Baby, Trash2, History, CheckCircle2, Wrench, Clock, User, AlertTriangle, Loader2 } from 'lucide-react' import { useAmenities } from '../../contexts/AmenitiesContext' import { useBedTypes } from '../../contexts/BedTypesContext' +import { api } from '../../lib/api' +import { format } from 'date-fns' +import { ru } from 'date-fns/locale' const ROOM_TYPES = ['Стандарт', 'Делюкс', 'Полулюкс', 'Люкс', 'Пентхаус', 'Апартаменты', 'Другой'] @@ -32,20 +35,21 @@ const HK_STATUSES: { value: HousekeepingStatus; label: string }[] = [ // ── Tab type ─────────────────────────────────────────────────────────────────── -type ModalTab = 'main' | 'places' | 'description' | 'photos' +type ModalTab = 'main' | 'places' | 'description' | 'photos' | 'history' interface RoomModalProps { open: boolean room?: Room existingNumbers?: string[] categories?: { id: string; name: string }[] + slug?: string onClose: () => void onSave: (room: Room) => void onDelete?: () => void error?: string | null } -export function RoomModal({ open, room, existingNumbers = [], categories = [], onClose, onSave, onDelete, error }: RoomModalProps) { +export function RoomModal({ open, room, existingNumbers = [], categories = [], slug, onClose, onSave, onDelete, error }: RoomModalProps) { const isEdit = !!room const [tab, setTab] = useState('main') @@ -179,6 +183,7 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], o { key: 'places', label: 'Места' }, { key: 'description', label: 'Описание' }, { key: 'photos', label: `Фото${photos.length > 0 ? ` (${photos.length})` : ''}` }, + ...(isEdit && slug ? [{ key: 'history' as ModalTab, label: 'История' }] : []), ] return ( @@ -701,6 +706,263 @@ export function RoomModal({ open, room, existingNumbers = [], categories = [], o

)} + + {/* ── History tab ── */} + {tab === 'history' && isEdit && slug && ( + + )} ) } + +// ─── Room History Tab ───────────────────────────────────────────────────────── + +const TYPE_LABELS: Record = { + cleaning: 'Уборка', turnover: 'Уборка при выезде', inspection: 'Проверка', + maintenance: 'Неисправность', +} +const TYPE_COLORS: Record = { + cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + turnover: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', + inspection: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + maintenance: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', +} +const STATUS_LABELS: Record = { + pending: 'Ожидает', in_progress: 'В работе', done: 'Завершено', cancelled: 'Отменено', +} +const STATUS_COLORS: Record = { + pending: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300', + in_progress: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + done: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', + cancelled: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400', +} + +function RoomHistoryTab({ slug, roomId }: { slug: string; roomId: string }) { + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [expandedTask, setExpandedTask] = useState(null) + const [lightboxPhoto, setLightboxPhoto] = useState(null) + + useEffect(() => { + api.housekeeping.list(slug, { roomId }) + .then(setTasks) + .catch(console.error) + .finally(() => setLoading(false)) + }, [slug, roomId]) + + if (loading) { + return ( +
+ +
+ ) + } + + if (tasks.length === 0) { + return ( +
+ +

История задач пуста

+
+ ) + } + + // Group by date + const grouped = tasks.reduce>((acc, t) => { + const taskAny = t as unknown as Record + const dateStr = t.completedAt ?? taskAny.completed_at ?? t.dueDate ?? taskAny.due_date ?? taskAny.created_at ?? '' + const day = dateStr ? dateStr.slice(0, 10) : 'unknown' + if (!acc[day]) acc[day] = [] + acc[day].push(t) + return acc + }, {}) + + const sortedDays = Object.keys(grouped).sort((a, b) => b.localeCompare(a)) + + return ( +
+ {/* Lightbox */} + {lightboxPhoto && ( +
setLightboxPhoto(null)} + > + + +
+ )} + + {sortedDays.map(day => { + let dayLabel: string + try { + dayLabel = format(new Date(day), 'd MMMM yyyy', { locale: ru }) + } catch { + dayLabel = day + } + + return ( +
+

+ {dayLabel} +

+
+ {grouped[day].map(task => { + 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 startTime = startedAt + ? new Date(startedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) + : null + const endTime = completedAt + ? new Date(completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) + : null + const durationMin = startedAt && completedAt + ? Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 60000) + : null + + return ( +
+ {/* Header row */} + + + {/* Expanded details */} + {isExpanded && ( +
+ {task.notes && ( +
+

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

+

+ {task.notes} +

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

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

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

Начало

+

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

+
+ )} + {completedAt && ( +
+

Завершено

+

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

+
+ )} +
+ )} +
+ )} +
+ ) + })} +
+
+ ) + })} +
+ ) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index eac4c33..55bf72d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -215,11 +215,12 @@ export const api = { // ── Housekeeping ────────────────────────────────────────────────────────── housekeeping: { - list: (slug: string, params?: { date?: string; status?: string; category?: string }) => { + list: (slug: string, params?: { date?: string; status?: string; category?: string; roomId?: string }) => { const qs = new URLSearchParams() if (params?.date) qs.set('date', params.date) if (params?.status) qs.set('status', params.status) if (params?.category) qs.set('category', params.category) + if (params?.roomId) qs.set('room_id', params.roomId) const q = qs.toString() return req('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`) }, diff --git a/src/pages/RoomsPage.tsx b/src/pages/RoomsPage.tsx index 703a0dd..28947f3 100644 --- a/src/pages/RoomsPage.tsx +++ b/src/pages/RoomsPage.tsx @@ -241,6 +241,7 @@ export function RoomsPage() { open={modalOpen} room={editingRoom} existingNumbers={rooms.map(r => r.number)} + slug={slug} onClose={() => { setModalOpen(false); setSaveError(null) }} onSave={handleSave} onDelete={editingRoom ? () => handleDelete(editingRoom.id) : undefined} diff --git a/src/pages/TechnicalPage.tsx b/src/pages/TechnicalPage.tsx index 4a145a1..8b7ab9b 100644 --- a/src/pages/TechnicalPage.tsx +++ b/src/pages/TechnicalPage.tsx @@ -296,14 +296,13 @@ export function TechnicalPage() { setCompletingTask(null)} - onConfirm={async (completionPhotos) => { + onConfirm={async (completionPhotos, comment) => { const task = tasks.find(t => t.id === completingTask) const currentPhotos = task?.photos ?? [] const allPhotos = [...currentPhotos, ...completionPhotos] - const updated = await api.housekeeping.update(slug, completingTask, { - status: 'done', - photos: allPhotos, - }) + const patch: import('../lib/api').HkPayload = { status: 'done', photos: allPhotos } + if (comment.trim()) patch.notes = comment.trim() + const updated = await api.housekeeping.update(slug, completingTask, patch) setTasks(prev => prev.map(t => t.id === completingTask ? updated : t)) setCompletingTask(null) }} @@ -439,9 +438,10 @@ function CompletionModal({ }: { requirePhoto: boolean onClose: () => void - onConfirm: (photos: string[]) => Promise + onConfirm: (photos: string[], comment: string) => Promise }) { const [photos, setPhotos] = useState([]) + const [comment, setComment] = useState('') const [uploading, setUploading] = useState(false) const [saving, setSaving] = useState(false) const inputRef = useRef(null) @@ -459,7 +459,7 @@ function CompletionModal({ const handleConfirm = async () => { if (requirePhoto && photos.length === 0) return setSaving(true) - try { await onConfirm(photos) } catch { /* ignore */ } finally { setSaving(false) } + try { await onConfirm(photos, comment) } catch { /* ignore */ } finally { setSaving(false) } } return ( @@ -468,9 +468,22 @@ function CompletionModal({

{requirePhoto ? 'Прикрепите фото выполненной работы — это обязательно по настройкам отеля.' - : 'Вы можете прикрепить фото выполненной работы (необязательно).'} + : 'Опишите что было сделано и при необходимости прикрепите фото.'}

+
+ +