import { useState, useEffect, useCallback, useRef } from 'react' import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight, Camera } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { useHotelSocket } from '../hooks/useHotelSocket' import type { WsMessage } from '../hooks/useHotelSocket' import { api } from '../lib/api' import type { HkSettings } from '../lib/api' import type { HousekeepingTask } from '../types' import { cn } from '../lib/utils' import { Badge } from '../components/ui/Badge' import { useNotifications } from '../contexts/NotificationsContext' import { format, subDays, addDays, parseISO } from 'date-fns' import { ru } from 'date-fns/locale' // Normalize snake_case WS task objects to camelCase 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, resolutionNotes: raw.resolutionNotes ?? raw.resolution_notes, resolutionPhotos: raw.resolutionPhotos ?? raw.resolution_photos, } } function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) { return ( ) } type Column = 'pending' | 'in_progress' | 'done' const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: string }[] = [ { id: 'pending', label: 'Ожидают', icon: Clock, color: 'text-amber-600 dark:text-amber-400' }, { id: 'in_progress', label: 'В процессе', icon: Sparkles, color: 'text-blue-600 dark:text-blue-400' }, { id: 'done', label: 'Готово', icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400' }, ] const PRIORITY_COLORS: Record = { urgent: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', high: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', medium: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300', low: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300', } const PRIORITY_LABELS: Record = { urgent: 'Срочно', high: 'Срочно', medium: 'Обычный', low: 'Низкий', } 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 TYPE_LABELS: Record = { cleaning: 'Уборка', turnover: 'Уборка при выезде', inspection: 'Проверка', maintenance: 'Ремонт', } export function HousekeepingPage() { const { user, session } = useAuth() const slug = user?.hotelSlug ?? '' const [activeTasks, setActiveTasks] = useState([]) const [historyTasks, setHistoryTasks] = useState([]) const [historyDate, setHistoryDate] = useState(format(new Date(), 'yyyy-MM-dd')) const [loading, setLoading] = useState(true) const [historyLoading, setHistoryLoading] = useState(false) const [activeTab, setActiveTab] = useState<'tasks' | 'history' | 'plans'>('tasks') const [planSaved, setPlanSaved] = useState(false) const handleWsMessage = useCallback((msg: WsMessage) => { if (msg.type === 'housekeeping_task_created') { const task = normalizeTask(msg.task as Record) if (task.category !== 'maintenance') { setActiveTasks(prev => prev.some(t => t.id === task.id) ? prev : [task as unknown as HousekeepingTask, ...prev]) } } else if (msg.type === 'housekeeping_updated') { const task = normalizeTask(msg.task as Record) if (task.category !== 'maintenance') { if (task.status === 'done') { setActiveTasks(prev => prev.filter(t => t.id !== task.id)) } else { setActiveTasks(prev => prev.map(t => t.id === task.id ? task as unknown as HousekeepingTask : t)) } } } }, []) const { send } = useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage }) // Load active tasks (all pending/in_progress — no date filter) useEffect(() => { if (!slug) return Promise.all([ api.housekeeping.list(slug, { status: 'active', category: 'housekeeping' }), api.housekeeping.getSettings(slug).catch(() => null), ]).then(([t, s]) => { setActiveTasks(t) if (s) { // API response goes through transformKeys → camelCase const ss = s as unknown as { checkoutAuto: boolean; checkoutPriority: string inspectionAfterClean: boolean; requireCompletionPhoto: boolean; emergencyCloseDays: number autoAssign: boolean; autoAssignStrategy: string } setPlans(prev => ({ ...prev, checkoutAuto: ss.checkoutAuto, checkoutPriority: ss.checkoutPriority as 'high' | 'medium', inspectionAfterClean: ss.inspectionAfterClean, requireCompletionPhoto: ss.requireCompletionPhoto ?? false, emergencyCloseDays: ss.emergencyCloseDays ?? 1, autoAssign: ss.autoAssign ?? false, autoAssignStrategy: (ss.autoAssignStrategy ?? 'least_loaded') as 'round_robin' | 'least_loaded' | 'first_free', })) } }).catch(console.error) .finally(() => setLoading(false)) }, [slug]) // Load history when tab opens or date changes useEffect(() => { if (activeTab !== 'history' || !slug) return setHistoryLoading(true) Promise.all([ api.housekeeping.list(slug, { status: 'done', date: historyDate }), api.housekeeping.list(slug, { category: 'maintenance', status: 'active', date: historyDate }), ]).then(([done, activeMaint]) => { const merged = [...done] for (const t of activeMaint) { if (!merged.some(x => x.id === t.id)) merged.push(t) } setHistoryTasks(merged) }) .catch(console.error) .finally(() => setHistoryLoading(false)) }, [activeTab, historyDate, slug]) // Cleaning plan settings const [plans, setPlans] = useState({ checkoutAuto: true, checkoutPriority: 'high' as 'high' | 'medium', checkoutInspection: true, dailyEnabled: true, dailyIntervalDays: 1, dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low', deepCleanEnabled: false, deepCleanEveryDays: 7, inspectionAfterClean: true, autoAssign: false, autoAssignStrategy: 'least_loaded' as 'round_robin' | 'least_loaded' | 'first_free', requireCompletionPhoto: false, emergencyCloseDays: 1, }) const setP = (k: K, v: typeof plans[K]) => setPlans(prev => ({ ...prev, [k]: v })) const [planSaving, setPlanSaving] = useState(false) const savePlans = async () => { setPlanSaving(true) try { const settings: HkSettings = { checkout_auto: plans.checkoutAuto, checkout_priority: plans.checkoutPriority, inspection_after_clean: plans.inspectionAfterClean, require_completion_photo: plans.requireCompletionPhoto, emergency_close_days: plans.emergencyCloseDays, auto_assign: plans.autoAssign, auto_assign_strategy: plans.autoAssignStrategy, } await api.housekeeping.saveSettings(slug, settings) setPlanSaved(true) setTimeout(() => setPlanSaved(false), 2000) } catch (err) { console.error('Failed to save settings:', err) } finally { setPlanSaving(false) } } const updateStatus = async (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => { try { const payload: Record = { status } if (resolutionNotes) payload.resolution_notes = resolutionNotes const updated = await api.housekeeping.update(slug, id, payload) if (updated.status === 'done') { setActiveTasks(prev => prev.filter(t => t.id !== id)) if (activeTab === 'history' && updated.completedAt?.startsWith(historyDate)) { setHistoryTasks(prev => [updated, ...prev]) } } else { setActiveTasks(prev => prev.map(t => t.id === id ? updated : t)) } } catch (err) { console.error('Failed to update task:', err) } } const { addNotification } = useNotifications() const addMaintenanceReport = async (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => { const task = activeTasks.find(t => t.id === id) const tAny = task as unknown as Record const priority = severity === 'high' ? 'urgent' : severity === 'medium' ? 'medium' : 'low' try { const newTask = await api.housekeeping.create(slug, { room_id: tAny?.roomId || undefined, type: 'maintenance', priority, notes: note, due_date: format(new Date(), 'yyyy-MM-dd'), category: 'maintenance', photos, }) send({ type: 'housekeeping_task_created', task: newTask as unknown as Record }) } catch (err) { console.error('Failed to create maintenance task:', err) } const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно' addNotification({ type: 'maintenance', title: severity === 'high' ? `⚠️ Экстренная поломка — Номер ${task?.roomNumber}` : `Поломка в номере ${task?.roomNumber}`, body: `[${severityLabel}] ${note}`, link: '/technical', }) } const total = activeTasks.length const done = activeTasks.filter(t => t.status === 'done').length if (loading) { return (
) } return (
{/* Header */}

Уборка и обслуживание

{activeTab === 'tasks' && (
{done} / {total} завершено
)}
{/* Tabs */}
{([ { id: 'tasks' as const, label: 'Задачи', icon: ListChecks }, { id: 'history' as const, label: 'История', icon: History }, { id: 'plans' as const, label: 'Планы уборки', icon: Settings2 }, ]).map(t => ( ))}
{/* ── Tasks tab ── */} {activeTab === 'tasks' && ( <> {activeTasks.length === 0 ? (
Активных задач нет
) : (
{(['pending', 'in_progress'] as Column[]).map(colId => { const col = COLUMNS.find(c => c.id === colId)! const colTasks = activeTasks.filter(t => t.status === colId) const Icon = col.icon return (

{col.label}

{colTasks.length}
{colTasks.map(task => ( addMaintenanceReport(id, note, sev, photos)} /> ))} {colTasks.length === 0 && (

Пусто

)}
) })}
)} )} {/* ── History tab ── */} {activeTab === 'history' && (
{/* Date picker */}
setHistoryDate(e.target.value)} className="input text-sm w-auto" /> {format(parseISO(historyDate), 'd MMMM yyyy', { locale: ru })}
{historyLoading ? (
) : historyTasks.length === 0 ? (
Завершённых задач за этот день нет
) : (
{historyTasks.map(task => ( ))}
)}
)} {/* ── Plans tab ── */} {activeTab === 'plans' && (

Уборка при выезде

Автоматически создавать задачу на уборку, как только гость освобождает номер

setP('checkoutAuto', !plans.checkoutAuto)} />
{plans.checkoutAuto && (
{([['high', 'Срочно'], ['medium', 'Обычный']] as const).map(([v, l]) => ( ))}

Инспекция после уборки

Создавать задачу проверки для старшей горничной

setP('checkoutInspection', !plans.checkoutInspection)} />
)}

Ежедневная уборка

Регулярная уборка в номерах с проживающими гостями

setP('dailyEnabled', !plans.dailyEnabled)} />
{plans.dailyEnabled && (
{[1, 2, 3, 7].map(n => ( ))}
setP('dailyStartFromDay', Math.max(1, parseInt(e.target.value) || 1))} />

Уборка начнётся на {plans.dailyStartFromDay}-й день

)}

Уборка по запросу гостя

Гость может запросить уборку через QR-код

setP('onDemandEnabled', !plans.onDemandEnabled)} />
{plans.onDemandEnabled && (
{([['high', 'Срочно'], ['medium', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => ( ))}
)}

Генеральная уборка

Глубокая уборка с чисткой мебели, мытьём окон

setP('deepCleanEnabled', !plans.deepCleanEnabled)} />
{plans.deepCleanEnabled && (
setP('deepCleanEveryDays', Math.max(1, parseInt(e.target.value) || 7))} /> дней
)}

Проверка после уборки

После завершения любой уборки создавать задачу инспекции

setP('inspectionAfterClean', !plans.inspectionAfterClean)} />

Автоназначение горничной

Автоматически назначать горничную при создании задачи

setP('autoAssign', !plans.autoAssign)} />
{plans.autoAssign && (

Стратегия распределения (обычные задачи)

Срочные задачи всегда назначаются первой свободной горничной

{([ { value: 'round_robin', label: 'По кругу', desc: 'Кому сегодня назначено меньше задач' }, { value: 'least_loaded', label: 'Наименее загруженная', desc: 'У кого сейчас меньше активных задач' }, { value: 'first_free', label: 'Первая свободная', desc: 'Кто раньше завершила последнюю задачу' }, ] as const).map(opt => ( ))}
)}

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

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

setP('requireCompletionPhoto', !plans.requireCompletionPhoto)} />

Экстренное закрытие номера

При отчёте о критической поломке (статус «Экстренно!») номер автоматически закрывается для бронирования

{[1, 2, 3, 5, 7, 14].map(n => ( ))}
)}
) } const SEVERITY_CONFIG = { low: { label: 'Не срочно', bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', border: 'border-emerald-200 dark:border-emerald-800', dot: 'bg-emerald-500' }, medium: { label: 'Средняя', bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-800', dot: 'bg-amber-500' }, high: { label: 'Экстренно!', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', border: 'border-red-200 dark:border-red-800', dot: 'bg-red-500' }, } function TaskCard({ task, onStatusChange, onReport }: { task: HousekeepingTask onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => void onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void }) { const [reportOpen, setReportOpen] = useState(false) const [reportText, setReportText] = useState('') const [severity, setSeverity] = useState<'low' | 'medium' | 'high'>('medium') const [reportPhotos, setReportPhotos] = useState([]) const [photoUploading, setPhotoUploading] = useState(false) const photoInputRef = useRef(null) const [completingOpen, setCompletingOpen] = useState(false) const [completionComment, setCompletionComment] = useState('') const uploadReportPhoto = async (file: File) => { setPhotoUploading(true) try { const url = await api.upload.photo(file, 'tasks') setReportPhotos(prev => [...prev, url]) } catch { /* ignore */ } finally { setPhotoUploading(false) } } const submitReport = () => { if (!reportText.trim()) return onReport(task.id, reportText.trim(), severity, reportPhotos) setReportText('') setReportPhotos([]) setReportOpen(false) } const sev = task.maintenanceSeverity ? SEVERITY_CONFIG[task.maintenanceSeverity] : null const taskAny = task as unknown as Record return (
№{task.roomNumber} {task.roomBlocked && ( Закрыт )}
{PRIORITY_LABELS[task.priority] ?? task.priority}
{TYPE_LABELS[task.type] ?? task.type} {task.dueDate && ( до {format(parseISO(task.dueDate), 'd MMM', { locale: ru })} )}
{task.notes && (

{task.notes}

)} {task.maintenanceNote && sev && (
{sev.label} {task.roomBlocked && ( Номер закрыт )}

{task.maintenanceNote}

)} {/* Assignee */} {(task.assignedToName || taskAny.assigneeName) && (
{task.assignedToName || taskAny.assigneeName}
)} {reportOpen && (

Сообщить о неисправности

{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => ( ))}
{severity === 'high' && (

Номер будет закрыт для бронирования до устранения поломки

)}