fix: housekeeping tasks not appearing + add history tab + fix task type

- Fix root cause: CalendarPage created tasks with type='regular' which violated DB CHECK constraint (allowed: cleaning/turnover/inspection/maintenance/amenities) → INSERT silently failed. Fixed to type='cleaning'.
- Fix: HousekeepingPage now loads ALL active tasks (status=active = pending+in_progress) without date filter — tasks appear regardless of when they were created
- Backend: support ?status=active filter (translates to IN ('pending','in_progress'))
- Add "История" tab in HousekeepingPage: shows completed tasks for a selected date with prev/next navigation, assignee and completion time displayed
- WS handler: when task becomes done, move it from active list instead of keeping in place

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 10:49:06 +03:00
parent e51d776b3a
commit 66009d345f
3 changed files with 163 additions and 47 deletions

View File

@@ -32,7 +32,11 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
let idx = 2
const { status, date, category } = request.query
if (status) { conditions.push(`t.status = $${idx}`); values.push(status); idx++ }
if (status === 'active') {
conditions.push(`t.status IN ('pending', 'in_progress')`)
} else if (status) {
conditions.push(`t.status = $${idx}`); values.push(status); idx++
}
if (date) { conditions.push(`t.due_date = $${idx}`); values.push(date); idx++ }
if (category) { conditions.push(`t.category = $${idx}`); values.push(category); idx++ }

View File

@@ -162,7 +162,7 @@ export function CalendarPage() {
const today = new Date().toISOString().slice(0, 10)
const task = await api.housekeeping.create(slug, {
room_id: roomId,
type: 'regular',
type: 'cleaning',
priority: priority ?? 'medium',
due_date: today,
category: 'housekeeping',

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2 } from 'lucide-react'
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { useHotelSocket } from '../hooks/useHotelSocket'
import type { WsMessage } from '../hooks/useHotelSocket'
@@ -9,7 +9,8 @@ import type { HousekeepingTask } from '../types'
import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
import { useNotifications } from '../contexts/NotificationsContext'
import { format } from 'date-fns'
import { format, subDays, addDays, parseISO } from 'date-fns'
import { ru } from 'date-fns/locale'
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
return (
@@ -38,51 +39,61 @@ const PRIORITY_COLORS: Record<string, string> = {
}
const PRIORITY_LABELS: Record<string, string> = {
urgent: 'Экстренно', high: 'Срочно', medium: 'Обычный', low: 'Низкий',
urgent: 'Срочно', high: 'Срочно', medium: 'Обычный', low: 'Низкий',
}
const TYPE_COLORS = {
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 = {
cleaning: 'Уборка', inspection: 'Проверка', maintenance: 'Ремонт',
const TYPE_LABELS: Record<string, string> = {
cleaning: 'Уборка', turnover: 'Уборка при выезде', inspection: 'Проверка', maintenance: 'Ремонт',
}
export function HousekeepingPage() {
const { user, session } = useAuth()
const slug = user?.hotelSlug ?? ''
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
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 [activeTab, setActiveTab] = useState<'tasks' | 'plans'>('tasks')
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 = msg.task as unknown as HousekeepingTask & { category?: string }
if (task.category !== 'maintenance') {
setTasks(prev => prev.some(t => t.id === task.id) ? prev : [task, ...prev])
setActiveTasks(prev => prev.some(t => t.id === task.id) ? prev : [task, ...prev])
}
} else if (msg.type === 'housekeeping_updated') {
const task = msg.task as unknown as HousekeepingTask & { category?: string }
if (task.category !== 'maintenance') {
setTasks(prev => prev.map(t => t.id === task.id ? task : t))
// If task is now done → remove from active, add to history if same date
if (task.status === 'done') {
setActiveTasks(prev => prev.filter(t => t.id !== task.id))
} else {
setActiveTasks(prev => prev.map(t => t.id === task.id ? task : t))
}
}
}
}, [])
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, { date: format(new Date(), 'yyyy-MM-dd'), category: 'housekeeping' }),
api.housekeeping.list(slug, { status: 'active', category: 'housekeeping' }),
api.housekeeping.getSettings(slug).catch(() => null),
]).then(([t, s]) => {
setTasks(t)
setActiveTasks(t)
if (s) setPlans(prev => ({
...prev,
checkoutAuto: s.checkout_auto,
@@ -93,6 +104,16 @@ export function HousekeepingPage() {
.finally(() => setLoading(false))
}, [slug])
// Load history when tab opens or date changes
useEffect(() => {
if (activeTab !== 'history' || !slug) return
setHistoryLoading(true)
api.housekeeping.list(slug, { status: 'done', date: historyDate, category: 'housekeeping' })
.then(setHistoryTasks)
.catch(console.error)
.finally(() => setHistoryLoading(false))
}, [activeTab, historyDate, slug])
// Cleaning plan settings
const [plans, setPlans] = useState({
checkoutAuto: true, checkoutPriority: 'high' as 'high' | 'medium',
@@ -127,7 +148,14 @@ export function HousekeepingPage() {
const updateStatus = async (id: string, status: HousekeepingTask['status']) => {
try {
const updated = await api.housekeeping.update(slug, id, { status })
setTasks(prev => prev.map(t => t.id === id ? updated : t))
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)
}
@@ -136,9 +164,9 @@ export function HousekeepingPage() {
const { addNotification } = useNotifications()
const addMaintenanceReport = (id: string, note: string, severity: 'low' | 'medium' | 'high') => {
const task = tasks.find(t => t.id === id)
const task = activeTasks.find(t => t.id === id)
const roomBlocked = severity === 'high'
setTasks(prev => prev.map(t =>
setActiveTasks(prev => prev.map(t =>
t.id === id ? { ...t, maintenanceNote: note, maintenanceSeverity: severity, roomBlocked } : t,
))
const severityLabel = severity === 'high' ? 'Экстренно' : severity === 'medium' ? 'Средняя срочность' : 'Не срочно'
@@ -152,8 +180,8 @@ export function HousekeepingPage() {
})
}
const total = tasks.length
const done = tasks.filter(t => t.status === 'done').length
const total = activeTasks.length
const done = activeTasks.filter(t => t.status === 'done').length
if (loading) {
return (
@@ -188,7 +216,8 @@ export function HousekeepingPage() {
{/* Tabs */}
<div className="flex border-b border-slate-200 dark:border-slate-700">
{([
{ id: 'tasks' as const, label: 'Задачи на сегодня', icon: ListChecks },
{ id: 'tasks' as const, label: 'Задачи', icon: ListChecks },
{ id: 'history' as const, label: 'История', icon: History },
{ id: 'plans' as const, label: 'Планы уборки', icon: Settings2 },
]).map(t => (
<button
@@ -210,14 +239,15 @@ export function HousekeepingPage() {
{/* ── Tasks tab ── */}
{activeTab === 'tasks' && (
<>
{tasks.length === 0 ? (
{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-3 gap-4">
{COLUMNS.map(col => {
const colTasks = tasks.filter(t => t.status === col.id)
<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">
@@ -246,6 +276,53 @@ export function HousekeepingPage() {
</>
)}
{/* ── 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">
@@ -444,11 +521,11 @@ function TaskCard({ task, onStatusChange, onReport }: {
}
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.status === 'done' && 'opacity-70',
task.roomBlocked && 'ring-2 ring-red-400 dark:ring-red-600',
)}>
<div className="flex items-center justify-between">
@@ -468,9 +545,14 @@ function TaskCard({ task, onStatusChange, onReport }: {
</div>
<div className="flex gap-1.5 flex-wrap">
<Badge className={TYPE_COLORS[task.type]}>
{TYPE_LABELS[task.type]}
<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 && (
@@ -492,19 +574,14 @@ function TaskCard({ task, onStatusChange, onReport }: {
</div>
)}
{task.assignedToName && (
{/* 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} />
{task.assignedToName}
<span>{task.assignedToName || taskAny.assigneeName}</span>
</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>
)}
{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">
@@ -578,14 +655,6 @@ function TaskCard({ task, onStatusChange, onReport }: {
Завершить
</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)}
@@ -604,3 +673,46 @@ function TaskCard({ task, onStatusChange, onReport }: {
</div>
)
}
function HistoryCard({ task }: { task: HousekeepingTask }) {
const taskAny = task as unknown as Record<string, string>
const completedTime = task.completedAt
? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: null
return (
<div className="card p-3.5 opacity-90">
<div className="flex items-center gap-3">
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
<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>
</div>
{task.notes && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 truncate">{task.notes}</p>
)}
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400">
{(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} />
Завершено в {completedTime}
</span>
)}
</div>
</div>
</div>
</div>
)
}