Files
hotelsync/src/pages/HousekeepingPage.tsx
HotelSync f62ccdd3f7 Add occupancy analytics, housekeeping maintenance notes, and booking widget form flow
- DynamicPricingPage: add 'Нагрузка' tab with monthly occupancy bar chart (2025/2026 data), year selector, summary stats, and editable occupancy-based pricing rules (threshold ranges → price modifiers)
- HousekeepingPage: add maintenance note button (wrench icon) on task cards — housekeeper can report broken items with a description that gets sent to technical service; notes displayed in orange badge
- BookingWidgetPage: fix 'Забронировать' button with full form flow (browse → form → success), add configurable form fields (required/optional/custom), add additional services toggle in settings and at checkout, add extra beds + children count in guest selector with auto-pricing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:51:31 +03:00

503 lines
23 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 } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send } from 'lucide-react'
import { MOCK_HK_TASKS } from '../data/mockData'
import type { HousekeepingTask } from '../types'
import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
return (
<button
onClick={onChange}
className={cn('relative w-10 h-5.5 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 w-4.5 h-4.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 = {
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
normal: '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 = { high: 'Срочно', normal: 'Обычный', low: 'Низкий' }
const TYPE_COLORS = {
cleaning: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-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 = {
cleaning: 'Уборка',
inspection: 'Проверка',
maintenance: 'Ремонт',
}
export function HousekeepingPage() {
const [tasks, setTasks] = useState<HousekeepingTask[]>(MOCK_HK_TASKS)
const [activeTab, setActiveTab] = useState<'tasks' | 'plans'>('tasks')
const [planSaved, setPlanSaved] = useState(false)
// Cleaning plan settings
const [plans, setPlans] = useState({
checkoutAuto: true,
checkoutPriority: 'high' as 'high' | 'normal',
checkoutInspection: true,
dailyEnabled: true,
dailyIntervalDays: 1,
dailyStartFromDay: 1,
onDemandEnabled: true,
onDemandPriority: 'normal' as 'high' | 'normal' | 'low',
deepCleanEnabled: false,
deepCleanEveryDays: 7,
inspectionAfterClean: true,
autoAssign: false,
})
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
setPlans(prev => ({ ...prev, [k]: v }))
const savePlans = () => {
setPlanSaved(true)
setTimeout(() => setPlanSaved(false), 2000)
}
const updateStatus = (id: string, status: HousekeepingTask['status']) => {
setTasks(prev => prev.map(t =>
t.id === id
? { ...t, status, completedAt: status === 'done' ? new Date().toISOString() : undefined }
: t,
))
}
const addMaintenanceNote = (id: string, note: string) => {
setTasks(prev => prev.map(t => t.id === id ? { ...t, maintenanceNote: note } : t))
}
const total = tasks.length
const done = tasks.filter(t => t.status === 'done').length
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: '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' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.status === col.id)
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} onMaintenanceNote={addMaintenanceNote} />
))}
{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>
)}
{/* ── Plans tab ── */}
{activeTab === 'plans' && (
<div className="max-w-2xl space-y-5">
{/* Checkout cleaning */}
<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', 'Срочно'], ['normal', 'Обычный']] 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>
{/* Daily cleaning */}
<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>
{/* On-demand cleaning */}
<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', 'Срочно'], ['normal', 'Обычный'], ['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>
{/* Deep cleaning */}
<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>
{/* Inspection after cleaning */}
<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>
{/* Auto assign */}
<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.autoAssign} onChange={() => setP('autoAssign', !plans.autoAssign)} />
</div>
</div>
<div className="flex justify-end">
<button onClick={savePlans} className="btn-primary">
<Save size={14} />
{planSaved ? 'Сохранено!' : 'Сохранить планы'}
</button>
</div>
</div>
)}
</div>
)
}
function TaskCard({ task, onStatusChange, onMaintenanceNote }: {
task: HousekeepingTask
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
onMaintenanceNote: (id: string, note: string) => void
}) {
const [reportOpen, setReportOpen] = useState(false)
const [reportText, setReportText] = useState('')
const submitReport = () => {
if (!reportText.trim()) return
onMaintenanceNote(task.id, reportText.trim())
setReportText('')
setReportOpen(false)
}
return (
<div className={cn(
'card p-3.5 space-y-2.5 transition-all',
task.status === 'done' && 'opacity-70',
)}>
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
{task.roomNumber}
</span>
<Badge className={PRIORITY_COLORS[task.priority]}>
{PRIORITY_LABELS[task.priority]}
</Badge>
</div>
<div className="flex gap-1.5 flex-wrap">
<Badge className={TYPE_COLORS[task.type]}>
{TYPE_LABELS[task.type]}
</Badge>
</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 && (
<div className="flex items-start gap-1.5 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-2">
<Wrench size={11} className="text-orange-500 mt-0.5 shrink-0" />
<p className="text-xs text-orange-700 dark:text-orange-300">{task.maintenanceNote}</p>
</div>
)}
{task.assignedToName && (
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
<User size={11} />
{task.assignedToName}
</div>
)}
{task.completedAt && (
<p className="text-xs text-emerald-600 dark:text-emerald-400">
Завершено в {new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
{/* Maintenance report form */}
{reportOpen && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-2 space-y-1.5">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-orange-600 dark:text-orange-400 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>
<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() }}
/>
<button
onClick={submitReport}
disabled={!reportText.trim()}
className="w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Send size={11} /> Отправить технической службе
</button>
</div>
)}
{/* Actions */}
<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' && (
<button
onClick={() => onStatusChange(task.id, 'done')}
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>
)}
{task.status === 'done' && (
<button
onClick={() => onStatusChange(task.id, 'pending')}
className="flex-1 text-xs py-1.5 rounded-lg bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-400 font-medium hover:bg-slate-100 dark:hover:bg-slate-600 transition-colors"
>
Вернуть
</button>
)}
{!reportOpen && (
<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',
task.maintenanceNote
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
: '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>
)
}