feat: housekeeping automation — auto-task on checkout + room status on done
Backend: - Migration 023: housekeeping_settings table (checkout_auto, checkout_priority, inspection_after_clean) - New route: GET/PATCH /api/hotels/:slug/housekeeping-settings - bookings.ts: on checked_out → auto-create turnover task + mark room dirty + broadcast WS - housekeeping.ts: on task done → update room status (inspect|clean per setting) + broadcast WS - ws.ts: export broadcast() for use in other routes Frontend: - HousekeepingPage: load/save settings to API, live Loader on save button - useHotelSocket: add housekeeping WS message types - HousekeepingPage: subscribe to WS — new tasks appear instantly without refresh - BookingModal: show total section when room + nights selected (not just when total > 0) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1286,7 +1286,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
|
||||
</div>
|
||||
|
||||
{/* Итого */}
|
||||
{room && total > 0 && (
|
||||
{room && nightCount > 0 && (
|
||||
<div className="ml-auto text-right shrink-0">
|
||||
{room && (
|
||||
<p className="text-xs text-slate-400 mb-0.5">
|
||||
|
||||
@@ -7,6 +7,9 @@ export type WsMessage =
|
||||
| { type: 'booking:created'; booking: Booking }
|
||||
| { type: 'booking:updated'; booking: Booking }
|
||||
| { type: 'booking:deleted'; bookingId: string }
|
||||
| { type: 'housekeeping_task_created'; task: Record<string, unknown> }
|
||||
| { type: 'housekeeping_updated'; task: Record<string, unknown> }
|
||||
| { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string }
|
||||
|
||||
interface Options {
|
||||
slug: string
|
||||
|
||||
@@ -222,6 +222,12 @@ export const api = {
|
||||
|
||||
delete: (slug: string, id: string) =>
|
||||
req<void>('DELETE', `/api/hotels/${slug}/housekeeping/${id}`),
|
||||
|
||||
getSettings: (slug: string) =>
|
||||
req<HkSettings>('GET', `/api/hotels/${slug}/housekeeping-settings`),
|
||||
|
||||
saveSettings: (slug: string, data: Partial<HkSettings>) =>
|
||||
req<HkSettings>('PATCH', `/api/hotels/${slug}/housekeeping-settings`, data),
|
||||
},
|
||||
|
||||
// ── Channels ──────────────────────────────────────────────────────────────
|
||||
@@ -526,6 +532,12 @@ export interface HkPayload {
|
||||
status?: string; assignee_id?: string; notes?: string; due_date?: string
|
||||
}
|
||||
|
||||
export interface HkSettings {
|
||||
checkout_auto: boolean
|
||||
checkout_priority: string
|
||||
inspection_after_clean: boolean
|
||||
}
|
||||
|
||||
export interface HotelPayload {
|
||||
name?: string; address?: string; phone?: string
|
||||
timezone?: string; currency?: string
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
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 { 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'
|
||||
@@ -49,7 +52,7 @@ const TYPE_LABELS = {
|
||||
}
|
||||
|
||||
export function HousekeepingPage() {
|
||||
const { user } = useAuth()
|
||||
const { user, session } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
|
||||
const [tasks, setTasks] = useState<HousekeepingTask[]>([])
|
||||
@@ -57,11 +60,32 @@ export function HousekeepingPage() {
|
||||
const [activeTab, setActiveTab] = useState<'tasks' | '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
|
||||
setTasks(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
|
||||
setTasks(prev => prev.map(t => t.id === task.id ? task : t))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useHotelSocket({ slug, token: session?.token ?? null, onMessage: handleWsMessage })
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
api.housekeeping.list(slug, { date: format(new Date(), 'yyyy-MM-dd') })
|
||||
.then(setTasks)
|
||||
.catch(console.error)
|
||||
Promise.all([
|
||||
api.housekeeping.list(slug, { date: format(new Date(), 'yyyy-MM-dd') }),
|
||||
api.housekeeping.getSettings(slug).catch(() => null),
|
||||
]).then(([t, s]) => {
|
||||
setTasks(t)
|
||||
if (s) setPlans(prev => ({
|
||||
...prev,
|
||||
checkoutAuto: s.checkout_auto,
|
||||
checkoutPriority: s.checkout_priority as 'high' | 'medium',
|
||||
inspectionAfterClean: s.inspection_after_clean,
|
||||
}))
|
||||
}).catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
@@ -77,9 +101,23 @@ export function HousekeepingPage() {
|
||||
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 [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,
|
||||
}
|
||||
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']) => {
|
||||
@@ -368,8 +406,8 @@ export function HousekeepingPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button onClick={savePlans} className="btn-primary">
|
||||
<Save size={14} />
|
||||
<button onClick={savePlans} disabled={planSaving} className="btn-primary">
|
||||
{planSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{planSaved ? 'Сохранено!' : 'Сохранить планы'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user