Initial commit: HotelSync PMS v0.1.0

- React 18 + TypeScript + Vite + Tailwind CSS
- Шахматка бронирований (drag-to-book)
- Страницы: Calendar, Bookings, Rooms, Housekeeping, Channels, API Docs, Settings
- Роли: super_admin, hotel_manager, housekeeper
- Светлая/тёмная тема
- Docker + Nginx конфигурация
- Лендинг hotelsync.ru
This commit is contained in:
2026-03-10 20:38:32 +03:00
commit 420d55d57e
45 changed files with 7274 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
import { useState } from 'react'
import { CheckCircle2, Clock, AlertCircle, Sparkles, User } 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'
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 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 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>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Уборка и обслуживание</h1>
<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: `${(done / total) * 100}%` }}
/>
</div>
<span className="text-sm text-slate-600 dark:text-slate-400 shrink-0">
{done} / {total} завершено
</span>
</div>
</div>
{/* Kanban columns */}
<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">
{/* Column header */}
<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>
{/* Cards */}
<div className="space-y-2.5">
{colTasks.map(task => (
<TaskCard
key={task.id}
task={task}
onStatusChange={updateStatus}
/>
))}
{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>
</div>
)
}
function TaskCard({ task, onStatusChange }: {
task: HousekeepingTask
onStatusChange: (id: string, status: HousekeepingTask['status']) => void
}) {
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.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>
)}
{/* 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>
)}
</div>
</div>
)
}