From 9af11df1b04b56acef2174bb64814eb51194b238 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 22:27:18 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20housekeeping=20automation=20=E2=80=94?= =?UTF-8?q?=20auto-task=20on=20checkout=20+=20room=20status=20on=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migrations/023_housekeeping_settings.sql | 7 ++ backend/src/app.ts | 2 + backend/src/routes/bookings.ts | 25 ++++++ backend/src/routes/housekeeping-settings.ts | 81 +++++++++++++++++++ backend/src/routes/housekeeping.ts | 18 ++++- backend/src/routes/ws.ts | 9 +++ src/components/bookings/BookingModal.tsx | 2 +- src/hooks/useHotelSocket.ts | 3 + src/lib/api.ts | 12 +++ src/pages/HousekeepingPage.tsx | 58 ++++++++++--- 10 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/023_housekeeping_settings.sql create mode 100644 backend/src/routes/housekeeping-settings.ts diff --git a/backend/migrations/023_housekeeping_settings.sql b/backend/migrations/023_housekeeping_settings.sql new file mode 100644 index 0000000..9306819 --- /dev/null +++ b/backend/migrations/023_housekeeping_settings.sql @@ -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() +); diff --git a/backend/src/app.ts b/backend/src/app.ts index 941a6db..1b6f14b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 } diff --git a/backend/src/routes/bookings.ts b/backend/src/routes/bookings.ts index 55d6c0c..baf17cd 100644 --- a/backend/src/routes/bookings.ts +++ b/backend/src/routes/bookings.ts @@ -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 diff --git a/backend/src/routes/housekeeping-settings.ts b/backend/src/routes/housekeeping-settings.ts new file mode 100644 index 0000000..4059722 --- /dev/null +++ b/backend/src/routes/housekeeping-settings.ts @@ -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 { + 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 => { + 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( + '/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 }>( + '/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 diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts index 8f888c7..2ecdc32 100644 --- a/backend/src/routes/housekeeping.ts +++ b/backend/src/routes/housekeeping.ts @@ -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 }, ) diff --git a/backend/src/routes/ws.ts b/backend/src/routes/ws.ts index e752260..b271429 100644 --- a/backend/src/routes/ws.ts +++ b/backend/src/routes/ws.ts @@ -6,6 +6,15 @@ import type { RawData } from 'ws' // hotel slug → set of connected streams const hotelRooms = new Map>() +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) => { diff --git a/src/components/bookings/BookingModal.tsx b/src/components/bookings/BookingModal.tsx index fe5074c..4d7aaf8 100644 --- a/src/components/bookings/BookingModal.tsx +++ b/src/components/bookings/BookingModal.tsx @@ -1286,7 +1286,7 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav {/* Итого */} - {room && total > 0 && ( + {room && nightCount > 0 && (
{room && (

diff --git a/src/hooks/useHotelSocket.ts b/src/hooks/useHotelSocket.ts index aa6bed1..0cf098e 100644 --- a/src/hooks/useHotelSocket.ts +++ b/src/hooks/useHotelSocket.ts @@ -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 } + | { type: 'housekeeping_updated'; task: Record } + | { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string } interface Options { slug: string diff --git a/src/lib/api.ts b/src/lib/api.ts index 11f908c..fd47307 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -222,6 +222,12 @@ export const api = { delete: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/housekeeping/${id}`), + + getSettings: (slug: string) => + req('GET', `/api/hotels/${slug}/housekeeping-settings`), + + saveSettings: (slug: string, data: Partial) => + req('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 diff --git a/src/pages/HousekeepingPage.tsx b/src/pages/HousekeepingPage.tsx index 1f26b4d..72a65ad 100644 --- a/src/pages/HousekeepingPage.tsx +++ b/src/pages/HousekeepingPage.tsx @@ -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([]) @@ -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: 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() {

-