Activate documents module, reorganize sidebar, add cleaning plans to housekeeping

Sidebar restructure:
- Основное: Шахматка, Бронирования, Номера, Категории номеров, Доступность
- Модули: Уборка + all active addon modules (including Documents)
- Управление: План этажей, Каналы, Модули, Настройки
- Разработчикам: API

ModulesContext: documents module now active by default (version bump to 3)

HousekeepingPage: added «Планы уборки» tab with:
- Уборка при выезде: auto task creation, priority (high/normal), inspection toggle
- Ежедневная уборка: interval (every 1/2/3/7 days), start from day N of stay
- Уборка по запросу: guest-initiated via QR, priority selection
- Генеральная уборка: deep clean every N days toggle
- Проверка после уборки: inspection task after any cleaning
- Автоназначение горничной: assign based on shift schedule

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 22:38:00 +03:00
parent de5b56c577
commit 401825c01e
3 changed files with 333 additions and 76 deletions

View File

@@ -44,9 +44,9 @@ export function Sidebar({ open, onClose }: SidebarProps) {
const isModuleActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial' const isModuleActive = (id: string) => statuses[id] === 'active' || statuses[id] === 'trial'
// Core modules with dedicated nav positions (not shown in generic module list) // Core modules with dedicated nav positions (not shown in generic module list)
const CORE_MODULE_IDS = ['housekeeping', 'channel-manager'] const CORE_MODULE_IDS = ['channel-manager']
// Extra module items for the "Модули" sidebar section (excluding core ones) // Module items for the "Модули" sidebar section
const activeModuleItems = MODULES_DATA.filter( const activeModuleItems = MODULES_DATA.filter(
m => m.sidebarItem && m => m.sidebarItem &&
!m.sidebarItem.showForHousekeeper && !m.sidebarItem.showForHousekeeper &&
@@ -112,14 +112,26 @@ export function Sidebar({ open, onClose }: SidebarProps) {
</> </>
) : ( ) : (
<> <>
{/* ── Основное ── */}
<p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider"> <p className="px-3 pt-2 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Основное Основное
</p> </p>
<NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} /> <NavItem to="/calendar" icon={CalendarDays} label="Шахматка" onClick={onClose} />
{!isHousekeeper && ( {!isHousekeeper && (
<>
<NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} /> <NavItem to="/bookings" icon={BookOpen} label="Бронирования" onClick={onClose} />
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
<NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
</>
)}
{/* ── Модули ── */}
{(isModuleActive('housekeeping') || (!isHousekeeper && activeModuleItems.length > 0)) && (
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Модули
</p>
)} )}
{/* Housekeeping — controlled by module status, visible to all roles */}
{isModuleActive('housekeeping') && ( {isModuleActive('housekeeping') && (
<NavItem <NavItem
to="/housekeeping" to="/housekeeping"
@@ -128,16 +140,23 @@ export function Sidebar({ open, onClose }: SidebarProps) {
onClick={onClose} onClick={onClose}
/> />
)} )}
{!isHousekeeper && activeModuleItems.map(m => (
<NavItem
key={m.id}
to={m.sidebarItem!.path}
icon={m.sidebarItem!.icon}
label={m.sidebarItem!.label}
onClick={onClose}
/>
))}
{/* ── Управление ── */}
{!isHousekeeper && ( {!isHousekeeper && (
<> <>
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider"> <p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Управление Управление
</p> </p>
<NavItem to="/rooms" icon={BedDouble} label="Номера" onClick={onClose} />
<NavItem to="/room-categories" icon={LayoutGrid} label="Категории номеров" onClick={onClose} />
<NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} /> <NavItem to="/floor-map" icon={Map} label="План этажей" onClick={onClose} />
<NavItem to="/availability" icon={CalendarRange} label="Доступность" onClick={onClose} />
{isModuleActive('channel-manager') && ( {isModuleActive('channel-manager') && (
<NavItem <NavItem
to="/channels" to="/channels"
@@ -149,24 +168,6 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} /> <NavItem to="/modules" icon={Puzzle} label="Модули" onClick={onClose} />
<NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} /> <NavItem to="/settings" icon={Settings} label="Настройки" onClick={onClose} />
{/* Active addon modules */}
{activeModuleItems.length > 0 && (
<>
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Модули
</p>
{activeModuleItems.map(m => (
<NavItem
key={m.id}
to={m.sidebarItem!.path}
icon={m.sidebarItem!.icon}
label={m.sidebarItem!.label}
onClick={onClose}
/>
))}
</>
)}
<p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider"> <p className="px-3 pt-3 pb-1 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Разработчикам Разработчикам
</p> </p>

View File

@@ -16,6 +16,7 @@ const DEFAULT_STATUSES: Record<string, ModuleStatus> = {
'room-service': 'active', 'room-service': 'active',
'rental': 'active', 'rental': 'active',
'migration': 'active', 'migration': 'active',
'documents': 'active',
} }
interface ModulesContextValue { interface ModulesContextValue {
@@ -26,7 +27,7 @@ interface ModulesContextValue {
const ModulesContext = createContext<ModulesContextValue | null>(null) const ModulesContext = createContext<ModulesContextValue | null>(null)
const MODULES_VERSION = '2' const MODULES_VERSION = '3'
export function ModulesProvider({ children }: { children: React.ReactNode }) { export function ModulesProvider({ children }: { children: React.ReactNode }) {
const [statuses, setStatuses] = useState<Record<string, ModuleStatus>>(() => { const [statuses, setStatuses] = useState<Record<string, ModuleStatus>>(() => {

View File

@@ -1,10 +1,21 @@
import { useState } from 'react' import { useState } from 'react'
import { CheckCircle2, Clock, AlertCircle, Sparkles, User } from 'lucide-react' import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save } from 'lucide-react'
import { MOCK_HK_TASKS } from '../data/mockData' import { MOCK_HK_TASKS } from '../data/mockData'
import type { HousekeepingTask } from '../types' import type { HousekeepingTask } from '../types'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge' 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' type Column = 'pending' | 'in_progress' | 'done'
const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: string }[] = [ const COLUMNS: { id: Column; label: string; icon: React.ElementType; color: string }[] = [
@@ -35,6 +46,36 @@ const TYPE_LABELS = {
export function HousekeepingPage() { export function HousekeepingPage() {
const [tasks, setTasks] = useState<HousekeepingTask[]>(MOCK_HK_TASKS) 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']) => { const updateStatus = (id: string, status: HousekeepingTask['status']) => {
setTasks(prev => prev.map(t => setTasks(prev => prev.map(t =>
@@ -50,47 +91,65 @@ export function HousekeepingPage() {
return ( return (
<div className="p-4 md:p-6 space-y-5"> <div className="p-4 md:p-6 space-y-5">
{/* Header */} {/* Header */}
<div> <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> <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 items-center gap-3 mt-1.5">
<div className="flex-1 bg-slate-200 dark:bg-slate-700 rounded-full h-2"> <div className="flex-1 bg-slate-200 dark:bg-slate-700 rounded-full h-2">
<div <div
className="h-2 rounded-full bg-emerald-500 transition-all duration-500" className="h-2 rounded-full bg-emerald-500 transition-all duration-500"
style={{ width: `${(done / total) * 100}%` }} style={{ width: total ? `${(done / total) * 100}%` : '0%' }}
/> />
</div> </div>
<span className="text-sm text-slate-600 dark:text-slate-400 shrink-0"> <span className="text-sm text-slate-600 dark:text-slate-400 shrink-0">
{done} / {total} завершено {done} / {total} завершено
</span> </span>
</div> </div>
)}
</div>
</div> </div>
{/* Kanban columns */} {/* 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"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{COLUMNS.map(col => { {COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.status === col.id) const colTasks = tasks.filter(t => t.status === col.id)
const Icon = col.icon const Icon = col.icon
return ( return (
<div key={col.id} className="space-y-3"> <div key={col.id} className="space-y-3">
{/* Column header */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon size={16} className={col.color} /> <Icon size={16} className={col.color} />
<h3 className="font-semibold text-slate-700 dark:text-slate-300"> <h3 className="font-semibold text-slate-700 dark:text-slate-300">{col.label}</h3>
{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"> <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} {colTasks.length}
</span> </span>
</div> </div>
{/* Cards */}
<div className="space-y-2.5"> <div className="space-y-2.5">
{colTasks.map(task => ( {colTasks.map(task => (
<TaskCard <TaskCard key={task.id} task={task} onStatusChange={updateStatus} />
key={task.id}
task={task}
onStatusChange={updateStatus}
/>
))} ))}
{colTasks.length === 0 && ( {colTasks.length === 0 && (
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center"> <div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
@@ -102,6 +161,202 @@ export function HousekeepingPage() {
) )
})} })}
</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> </div>
) )
} }