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:
2026-03-23 22:27:18 +03:00
parent ce20c6545d
commit 9af11df1b0
10 changed files with 205 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS housekeeping_settings (
hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE,
checkout_auto BOOLEAN NOT NULL DEFAULT true,
checkout_priority VARCHAR(10) NOT NULL DEFAULT 'high',
inspection_after_clean BOOLEAN NOT NULL DEFAULT true,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -28,6 +28,7 @@ import tariffsRoutes from './routes/tariffs'
import ratePeriodsRoutes from './routes/rate-periods'
import rateOverridesRoutes from './routes/rate-overrides'
import uploadRoutes from './routes/upload'
import housekeepingSettingsRoutes from './routes/housekeeping-settings'
export async function buildApp() {
const fastify = Fastify({
@@ -100,6 +101,7 @@ export async function buildApp() {
await fastify.register(ratePeriodsRoutes)
await fastify.register(rateOverridesRoutes)
await fastify.register(uploadRoutes)
await fastify.register(housekeepingSettingsRoutes)
return fastify
}

View File

@@ -1,6 +1,8 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { notifyNetupCheckin, notifyNetupCheckout } from './netup'
import { getHkSettings } from './housekeeping-settings'
import { broadcast } from './ws'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
@@ -175,6 +177,29 @@ const bookings: FastifyPluginAsync = async (fastify) => {
notifyNetupCheckin(hotelId, updated.room_id, updated.guest_name, updated.id).catch(() => {})
} else if (request.body.status === 'checked_out') {
notifyNetupCheckout(hotelId, updated.room_id).catch(() => {})
// Auto-create housekeeping task if enabled
const hkSettings = await getHkSettings(hotelId).catch(() => null)
if (hkSettings?.checkout_auto && updated.room_id) {
const today = new Date().toISOString().slice(0, 10)
const { rows: taskRows } = await db.query(
`INSERT INTO housekeeping_tasks
(hotel_id, room_id, type, priority, notes, due_date)
VALUES ($1,$2,'turnover',$3,$4,$5)
RETURNING *`,
[hotelId, updated.room_id, hkSettings.checkout_priority,
`Уборка после выезда гостя${updated.guest_name ? ': ' + updated.guest_name : ''}`,
today],
)
const task = taskRows[0]
// Update room housekeeping status to 'dirty'
await db.query(
`UPDATE rooms SET housekeeping_status = 'dirty' WHERE id = $1`,
[updated.room_id],
)
// WebSocket: notify all connected clients of this hotel
broadcast(slug, { type: 'housekeeping_task_created', task })
}
}
return updated

View File

@@ -0,0 +1,81 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
type SlugParam = { Params: { slug: string } }
export interface HkSettings {
checkout_auto: boolean
checkout_priority: string
inspection_after_clean: boolean
}
const DEFAULT_SETTINGS: HkSettings = {
checkout_auto: true,
checkout_priority: 'high',
inspection_after_clean: true,
}
export async function getHkSettings(hotelId: string): Promise<HkSettings> {
const { rows } = await db.query(
'SELECT checkout_auto, checkout_priority, inspection_after_clean FROM housekeeping_settings WHERE hotel_id = $1',
[hotelId],
)
return rows[0] ?? DEFAULT_SETTINGS
}
const housekeepingSettings: FastifyPluginAsync = async (fastify) => {
const getHotelId = async (slug: string): Promise<string | null> => {
const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
return rows[0]?.id ?? null
}
const canAccess = (userSlug: string | null, role: string, slug: string) =>
role === 'super_admin' || userSlug === slug
// GET /api/hotels/:slug/housekeeping-settings
fastify.get<SlugParam>(
'/api/hotels/:slug/housekeeping-settings',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
return getHkSettings(hotelId)
},
)
// PATCH /api/hotels/:slug/housekeeping-settings
fastify.patch<SlugParam & { Body: Partial<HkSettings> }>(
'/api/hotels/:slug/housekeeping-settings',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { slug } = request.params
if (!canAccess(request.user.hotelSlug, request.user.role, slug))
return reply.code(403).send({ error: 'Forbidden' })
const hotelId = await getHotelId(slug)
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
const { checkout_auto, checkout_priority, inspection_after_clean } = request.body
await db.query(
`INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean)
VALUES ($1, $2, $3, $4)
ON CONFLICT (hotel_id) DO UPDATE SET
checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto),
checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority),
inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean),
updated_at = NOW()`,
[
hotelId,
checkout_auto ?? DEFAULT_SETTINGS.checkout_auto,
checkout_priority ?? DEFAULT_SETTINGS.checkout_priority,
inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean,
],
)
return getHkSettings(hotelId)
},
)
}
export default housekeepingSettings

View File

@@ -1,5 +1,7 @@
import { FastifyPluginAsync } from 'fastify'
import { db } from '../db'
import { getHkSettings } from './housekeeping-settings'
import { broadcast } from './ws'
type SlugParam = { Params: { slug: string } }
type SlugIdParam = { Params: { slug: string; id: string } }
@@ -118,7 +120,21 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
values,
)
if (!rows[0]) return reply.code(404).send({ error: 'Task not found' })
return rows[0]
const task = rows[0]
// When task is marked done → update room housekeeping status
if (request.body.status === 'done' && task.room_id) {
const settings = await getHkSettings(hotelId).catch(() => null)
const newRoomStatus = settings?.inspection_after_clean ? 'inspect' : 'clean'
await db.query(
`UPDATE rooms SET housekeeping_status = $1 WHERE id = $2`,
[newRoomStatus, task.room_id],
)
broadcast(slug, { type: 'housekeeping_done', taskId: task.id, roomId: task.room_id, roomStatus: newRoomStatus })
}
broadcast(slug, { type: 'housekeeping_updated', task })
return task
},
)

View File

@@ -6,6 +6,15 @@ import type { RawData } from 'ws'
// hotel slug → set of connected streams
const hotelRooms = new Map<string, Set<SocketStream>>()
export function broadcast(hotelSlug: string, message: object) {
const peers = hotelRooms.get(hotelSlug)
if (!peers) return
const payload = JSON.stringify(message)
peers.forEach(peer => {
if (peer.socket.readyState === 1) peer.socket.send(payload)
})
}
const PING_INTERVAL_MS = 25_000 // ping every 25s — keeps nginx proxy_read_timeout alive
const ws: FastifyPluginAsync = async (fastify) => {

View File

@@ -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">

View File

@@ -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

View File

@@ -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

View File

@@ -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 = () => {
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>