Files
hotelsync/src/pages/HousekeepingPage.tsx

968 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>): Record<string, unknown> {
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 (
<button
onClick={onChange}
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
>
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
</button>
)
}
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<string, string> = {
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<string, string> = {
urgent: 'Срочно', high: 'Срочно', medium: 'Обычный', low: 'Низкий',
}
const TYPE_COLORS: Record<string, string> = {
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<string, string> = {
cleaning: 'Уборка', turnover: 'Уборка при выезде', inspection: 'Проверка', maintenance: 'Ремонт',
}
export function HousekeepingPage() {
const { user, session } = useAuth()
const slug = user?.hotelSlug ?? ''
const [activeTasks, setActiveTasks] = useState<HousekeepingTask[]>([])
const [historyTasks, setHistoryTasks] = useState<HousekeepingTask[]>([])
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<string, unknown>)
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<string, unknown>)
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 extends keyof typeof plans>(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<string, unknown> = { 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<string, string>
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<string, unknown> })
} 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 (
<div className="flex items-center justify-center h-64">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
)
}
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex-1">
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Уборка и обслуживание</h1>
{activeTab === 'tasks' && (
<div className="flex items-center gap-3 mt-1.5">
<div className="flex-1 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
<div
className="h-2 rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: total ? `${(done / total) * 100}%` : '0%' }}
/>
</div>
<span className="text-sm text-slate-600 dark:text-slate-400 shrink-0">
{done} / {total} завершено
</span>
</div>
)}
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-200 dark:border-slate-700">
{([
{ id: 'tasks' as const, label: 'Задачи', icon: ListChecks },
{ id: 'history' as const, label: 'История', icon: History },
{ id: 'plans' as const, label: 'Планы уборки', icon: Settings2 },
]).map(t => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
className={cn(
'flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === t.id
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
<t.icon size={14} />
{t.label}
</button>
))}
</div>
{/* ── Tasks tab ── */}
{activeTab === 'tasks' && (
<>
{activeTasks.length === 0 ? (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
Активных задач нет
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(['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 (
<div key={col.id} className="space-y-3">
<div className="flex items-center gap-2">
<Icon size={16} className={col.color} />
<h3 className="font-semibold text-slate-700 dark:text-slate-300">{col.label}</h3>
<span className="ml-auto text-xs font-bold px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-700 text-slate-600 dark:text-slate-400">
{colTasks.length}
</span>
</div>
<div className="space-y-2.5">
{colTasks.map(task => (
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={(id, note, sev, photos) => addMaintenanceReport(id, note, sev, photos)} />
))}
{colTasks.length === 0 && (
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
<p className="text-sm text-slate-400 dark:text-slate-500">Пусто</p>
</div>
)}
</div>
</div>
)
})}
</div>
)}
</>
)}
{/* ── History tab ── */}
{activeTab === 'history' && (
<div className="space-y-4">
{/* Date picker */}
<div className="flex items-center gap-2">
<button
onClick={() => setHistoryDate(format(subDays(parseISO(historyDate), 1), 'yyyy-MM-dd'))}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500"
>
<ChevronLeft size={16} />
</button>
<input
type="date"
value={historyDate}
onChange={e => setHistoryDate(e.target.value)}
className="input text-sm w-auto"
/>
<button
onClick={() => setHistoryDate(format(addDays(parseISO(historyDate), 1), 'yyyy-MM-dd'))}
disabled={historyDate >= format(new Date(), 'yyyy-MM-dd')}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500 disabled:opacity-30"
>
<ChevronRight size={16} />
</button>
<span className="text-sm text-slate-500 dark:text-slate-400">
{format(parseISO(historyDate), 'd MMMM yyyy', { locale: ru })}
</span>
</div>
{historyLoading ? (
<div className="flex justify-center py-12">
<Loader2 size={20} className="animate-spin text-brand-600" />
</div>
) : historyTasks.length === 0 ? (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
Завершённых задач за этот день нет
</div>
) : (
<div className="space-y-2.5">
{historyTasks.map(task => (
<HistoryCard key={task.id} task={task} />
))}
</div>
)}
</div>
)}
{/* ── Plans tab ── */}
{activeTab === 'plans' && (
<div className="max-w-2xl space-y-5">
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Уборка при выезде</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Автоматически создавать задачу на уборку, как только гость освобождает номер
</p>
</div>
<Toggle on={plans.checkoutAuto} onChange={() => setP('checkoutAuto', !plans.checkoutAuto)} />
</div>
{plans.checkoutAuto && (
<div className="pl-1 space-y-3 border-t border-slate-100 dark:border-slate-700 pt-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет задачи</label>
<div className="flex gap-2">
{([['high', 'Срочно'], ['medium', 'Обычный']] as const).map(([v, l]) => (
<button
key={v}
onClick={() => setP('checkoutPriority', v)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
plans.checkoutPriority === v
? v === 'high' ? 'bg-red-500 border-red-500 text-white' : 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
{l}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-700 dark:text-slate-300">Инспекция после уборки</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Создавать задачу проверки для старшей горничной</p>
</div>
<Toggle on={plans.checkoutInspection} onChange={() => setP('checkoutInspection', !plans.checkoutInspection)} />
</div>
</div>
)}
</div>
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Ежедневная уборка</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Регулярная уборка в номерах с проживающими гостями</p>
</div>
<Toggle on={plans.dailyEnabled} onChange={() => setP('dailyEnabled', !plans.dailyEnabled)} />
</div>
{plans.dailyEnabled && (
<div className="pl-1 space-y-3 border-t border-slate-100 dark:border-slate-700 pt-3">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Каждые N дней</label>
<div className="flex gap-1.5 flex-wrap">
{[1, 2, 3, 7].map(n => (
<button
key={n}
onClick={() => setP('dailyIntervalDays', n)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
plans.dailyIntervalDays === n
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
{n === 1 ? 'Ежедневно' : `${n} дня`}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Начинать с дня заезда </label>
<input
type="number" min={1} max={10}
className="input w-20 text-sm"
value={plans.dailyStartFromDay}
onChange={e => setP('dailyStartFromDay', Math.max(1, parseInt(e.target.value) || 1))}
/>
<p className="text-xs text-slate-400 mt-1">Уборка начнётся на {plans.dailyStartFromDay}-й день</p>
</div>
</div>
</div>
)}
</div>
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Уборка по запросу гостя</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Гость может запросить уборку через QR-код</p>
</div>
<Toggle on={plans.onDemandEnabled} onChange={() => setP('onDemandEnabled', !plans.onDemandEnabled)} />
</div>
{plans.onDemandEnabled && (
<div className="pl-1 border-t border-slate-100 dark:border-slate-700 pt-3">
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Приоритет</label>
<div className="flex gap-2">
{([['high', 'Срочно'], ['medium', 'Обычный'], ['low', 'Низкий']] as const).map(([v, l]) => (
<button
key={v}
onClick={() => setP('onDemandPriority', v)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
plans.onDemandPriority === v
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
{l}
</button>
))}
</div>
</div>
)}
</div>
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Генеральная уборка</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Глубокая уборка с чисткой мебели, мытьём окон</p>
</div>
<Toggle on={plans.deepCleanEnabled} onChange={() => setP('deepCleanEnabled', !plans.deepCleanEnabled)} />
</div>
{plans.deepCleanEnabled && (
<div className="pl-1 border-t border-slate-100 dark:border-slate-700 pt-3 flex items-center gap-3">
<label className="text-xs font-medium text-slate-600 dark:text-slate-400 shrink-0">Проводить каждые</label>
<input
type="number" min={1} max={30}
className="input w-20 text-sm"
value={plans.deepCleanEveryDays}
onChange={e => setP('deepCleanEveryDays', Math.max(1, parseInt(e.target.value) || 7))}
/>
<span className="text-xs text-slate-500 dark:text-slate-400">дней</span>
</div>
)}
</div>
<div className="card p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Проверка после уборки</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">После завершения любой уборки создавать задачу инспекции</p>
</div>
<Toggle on={plans.inspectionAfterClean} onChange={() => setP('inspectionAfterClean', !plans.inspectionAfterClean)} />
</div>
</div>
<div className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Автоназначение горничной</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Автоматически назначать горничную при создании задачи</p>
</div>
<Toggle on={plans.autoAssign} onChange={() => setP('autoAssign', !plans.autoAssign)} />
</div>
{plans.autoAssign && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-3 space-y-2">
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Стратегия распределения (обычные задачи)</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">Срочные задачи всегда назначаются первой свободной горничной</p>
<div className="space-y-1.5">
{([
{ value: 'round_robin', label: 'По кругу', desc: 'Кому сегодня назначено меньше задач' },
{ value: 'least_loaded', label: 'Наименее загруженная', desc: 'У кого сейчас меньше активных задач' },
{ value: 'first_free', label: 'Первая свободная', desc: 'Кто раньше завершила последнюю задачу' },
] as const).map(opt => (
<button
key={opt.value}
onClick={() => setP('autoAssignStrategy', opt.value)}
className={cn(
'w-full text-left px-3 py-2 rounded-lg border text-xs transition-colors',
plans.autoAssignStrategy === opt.value
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-blue-200',
)}
>
<span className="font-medium">{opt.label}</span>
<span className="text-slate-400 dark:text-slate-500 ml-2">{opt.desc}</span>
</button>
))}
</div>
</div>
)}
</div>
<div className="card p-4">
<div className="flex items-center justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Обязательное фото при завершении тех. задачи</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Технический специалист должен прикрепить фото перед тем как отметить задачу выполненной</p>
</div>
<Toggle on={plans.requireCompletionPhoto} onChange={() => setP('requireCompletionPhoto', !plans.requireCompletionPhoto)} />
</div>
</div>
<div className="card p-4 space-y-3">
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Экстренное закрытие номера</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
При отчёте о критической поломке (статус «Экстренно!») номер автоматически закрывается для бронирования
</p>
</div>
<div className="flex items-center gap-3 border-t border-slate-100 dark:border-slate-700 pt-3">
<label className="text-sm text-slate-700 dark:text-slate-300 shrink-0">Закрывать на</label>
<div className="flex gap-1.5 flex-wrap">
{[1, 2, 3, 5, 7, 14].map(n => (
<button
key={n}
onClick={() => setP('emergencyCloseDays', n)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
plans.emergencyCloseDays === n
? 'bg-red-600 border-red-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
{n === 1 ? '1 день' : n < 5 ? `${n} дня` : `${n} дней`}
</button>
))}
</div>
</div>
</div>
<div className="flex justify-end">
<button onClick={savePlans} disabled={planSaving} className="btn-primary">
{planSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
{planSaved ? 'Сохранено!' : 'Сохранить планы'}
</button>
</div>
</div>
)}
</div>
)
}
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<string[]>([])
const [photoUploading, setPhotoUploading] = useState(false)
const photoInputRef = useRef<HTMLInputElement>(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<string, string>
return (
<div className={cn(
'card p-3.5 space-y-2.5 transition-all',
task.roomBlocked && 'ring-2 ring-red-400 dark:ring-red-600',
)}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
{task.roomNumber}
</span>
{task.roomBlocked && (
<span className="flex items-center gap-0.5 text-[10px] font-bold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30 px-1.5 py-0.5 rounded">
<BanIcon size={10} /> Закрыт
</span>
)}
</div>
<Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}>
{PRIORITY_LABELS[task.priority] ?? task.priority}
</Badge>
</div>
<div className="flex gap-1.5 flex-wrap">
<Badge className={TYPE_COLORS[task.type] ?? TYPE_COLORS.cleaning}>
{TYPE_LABELS[task.type] ?? task.type}
</Badge>
{task.dueDate && (
<span className="text-[11px] text-slate-400 dark:text-slate-500 self-center">
до {format(parseISO(task.dueDate), 'd MMM', { locale: ru })}
</span>
)}
</div>
{task.notes && (
<p className="text-xs text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-700/40 rounded-lg p-2">
{task.notes}
</p>
)}
{task.maintenanceNote && sev && (
<div className={cn('rounded-lg p-2.5 border space-y-1', sev.bg, sev.border)}>
<div className="flex items-center gap-1.5">
<AlertTriangle size={11} className={sev.text} />
<span className={cn('text-[10px] font-bold uppercase tracking-wide', sev.text)}>{sev.label}</span>
{task.roomBlocked && (
<span className={cn('text-[10px] font-semibold ml-auto', sev.text)}>Номер закрыт</span>
)}
</div>
<p className={cn('text-xs', sev.text)}>{task.maintenanceNote}</p>
</div>
)}
{/* Assignee */}
{(task.assignedToName || taskAny.assigneeName) && (
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
<User size={11} />
<span>{task.assignedToName || taskAny.assigneeName}</span>
</div>
)}
{reportOpen && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
<Wrench size={11} /> Сообщить о неисправности
</p>
<button onClick={() => setReportOpen(false)} className="text-slate-400 hover:text-slate-600">
<XIcon size={13} />
</button>
</div>
<div className="flex gap-1.5">
{(Object.entries(SEVERITY_CONFIG) as [typeof severity, typeof SEVERITY_CONFIG['low']][]).map(([key, cfg]) => (
<button
key={key}
onClick={() => setSeverity(key)}
className={cn(
'flex-1 text-xs py-1.5 rounded-lg font-medium border transition-colors',
severity === key
? `${cfg.bg} ${cfg.text} ${cfg.border}`
: 'bg-white dark:bg-slate-700 text-slate-500 dark:text-slate-400 border-slate-200 dark:border-slate-600',
)}
>
{cfg.label}
</button>
))}
</div>
{severity === 'high' && (
<p className="text-[11px] text-red-600 dark:text-red-400 flex items-center gap-1 bg-red-50 dark:bg-red-900/20 rounded-lg px-2 py-1.5">
<AlertTriangle size={11} className="shrink-0" />
Номер будет закрыт для бронирования до устранения поломки
</p>
)}
<textarea
autoFocus
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-orange-400"
rows={2}
placeholder="Опишите неисправность..."
value={reportText}
onChange={e => setReportText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) submitReport() }}
/>
{/* Photo upload */}
<input
ref={photoInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) { uploadReportPhoto(f); e.target.value = '' } }}
/>
<div className="flex flex-wrap gap-1.5 items-center">
{reportPhotos.map((url, i) => (
<div key={i} className="relative group">
<img src={url} alt="" className="w-12 h-12 object-cover rounded border border-slate-200 dark:border-slate-600" />
<button
onClick={() => setReportPhotos(prev => prev.filter((_, j) => j !== i))}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100"
><XIcon size={9} /></button>
</div>
))}
<button
onClick={() => photoInputRef.current?.click()}
disabled={photoUploading}
className="w-12 h-12 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 flex flex-col items-center justify-center gap-0.5 text-slate-400 hover:border-orange-400 hover:text-orange-500 transition-colors text-[10px]"
>
{photoUploading ? <Loader2 size={13} className="animate-spin" /> : <Camera size={13} />}
{!photoUploading && 'Фото'}
</button>
</div>
<button
onClick={submitReport}
disabled={!reportText.trim() || photoUploading}
className={cn(
'w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg text-white font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed',
severity === 'high' ? 'bg-red-600 hover:bg-red-700' :
severity === 'medium' ? 'bg-amber-500 hover:bg-amber-600' :
'bg-emerald-600 hover:bg-emerald-700',
)}
>
<Send size={11} /> Отправить технической службе
</button>
</div>
)}
{completingOpen && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
<CheckCircle2 size={11} className="text-emerald-500" /> Завершение уборки
</p>
<button onClick={() => setCompletingOpen(false)} className="text-slate-400 hover:text-slate-600">
<XIcon size={13} />
</button>
</div>
<textarea
autoFocus
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-emerald-400"
rows={2}
placeholder="Комментарий (необязательно)..."
value={completionComment}
onChange={e => setCompletionComment(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && e.ctrlKey) {
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
setCompletingOpen(false)
}
}}
/>
<button
onClick={() => {
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
setCompletingOpen(false)
}}
className="w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium transition-colors"
>
<CheckCircle2 size={11} /> Завершить уборку
</button>
</div>
)}
<div className="flex gap-1.5 pt-1">
{task.status === 'pending' && (
<button
onClick={() => onStatusChange(task.id, 'in_progress')}
className="flex-1 text-xs py-1.5 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors"
>
Начать
</button>
)}
{task.status === 'in_progress' && !completingOpen && (
<button
onClick={() => { setCompletingOpen(true); setReportOpen(false) }}
className="flex-1 text-xs py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 font-medium hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
>
Завершить
</button>
)}
{!reportOpen && !completingOpen && (
<button
onClick={() => setReportOpen(true)}
title="Сообщить о неисправности"
className={cn(
'text-xs py-1.5 px-2.5 rounded-lg font-medium transition-colors flex items-center gap-1 shrink-0',
task.maintenanceNote
? `${sev?.bg} ${sev?.text}`
: 'bg-slate-50 dark:bg-slate-700 text-slate-500 dark:text-slate-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 hover:text-orange-600',
)}
>
<Wrench size={11} />
</button>
)}
</div>
</div>
)
}
function HistoryCard({ task }: { task: HousekeepingTask }) {
const taskAny = task as unknown as Record<string, string>
const isMaintenance = task.type === 'maintenance' || (taskAny.category === 'maintenance')
const isActive = task.status === 'pending' || task.status === 'in_progress'
const completedTime = task.completedAt
? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: null
// Duration: started_at → completed_at
const durationMin = task.startedAt && task.completedAt
? Math.round((new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime()) / 60000)
: null
const photos = task.photos ?? []
return (
<div className={cn('card p-3.5', isMaintenance && 'border-l-4 border-l-orange-400 dark:border-l-orange-500', !isActive && 'opacity-90')}>
<div className="flex items-start gap-3">
{isMaintenance
? <Wrench size={16} className={cn('shrink-0 mt-0.5', isActive ? 'text-orange-400' : 'text-orange-500')} />
: <CheckCircle2 size={16} className="text-emerald-500 shrink-0 mt-0.5" />
}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-slate-900 dark:text-slate-100">{task.roomNumber}</span>
<Badge className={TYPE_COLORS[task.type] ?? TYPE_COLORS.cleaning}>
{TYPE_LABELS[task.type] ?? task.type}
</Badge>
<Badge className={PRIORITY_COLORS[task.priority] ?? PRIORITY_COLORS.medium}>
{PRIORITY_LABELS[task.priority] ?? task.priority}
</Badge>
{isActive && isMaintenance && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
{task.status === 'in_progress' ? 'В работе' : 'Ожидает'}
</span>
)}
</div>
{isActive && isMaintenance && (
<p className="text-[11px] text-amber-700 dark:text-amber-400 mt-1 flex items-center gap-1">
<AlertTriangle size={10} className="shrink-0" /> Замечание по номеру тех. задача не выполнена
</p>
)}
{task.notes && (
<p className="text-xs text-slate-600 dark:text-slate-300 mt-1.5 bg-slate-50 dark:bg-slate-700/40 rounded-lg px-2 py-1.5">
{task.notes}
</p>
)}
{task.resolutionNotes && (
<p className="text-xs text-emerald-700 dark:text-emerald-300 mt-1.5 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg px-2 py-1.5 flex items-start gap-1.5">
<CheckCircle2 size={11} className="shrink-0 mt-0.5" />
{task.resolutionNotes}
</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
{(task.assignedToName || taskAny.assigneeName) && (
<span className="flex items-center gap-1">
<User size={11} />
{task.assignedToName || taskAny.assigneeName}
</span>
)}
{completedTime && (
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 size={11} />
{isMaintenance ? 'Выполнено' : 'Завершено'} в {completedTime}
</span>
)}
{durationMin !== null && durationMin > 0 && (
<span className="flex items-center gap-1 text-slate-400 dark:text-slate-500">
<Clock size={11} />
{durationMin < 60
? `${durationMin} мин`
: `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
</span>
)}
</div>
{photos.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{photos.map(url => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<img
src={url}
alt=""
className="w-14 h-14 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-80 transition-opacity"
/>
</a>
))}
</div>
)}
</div>
</div>
</div>
)
}