From b9116c427a695c557c36998157321365c38df9ea Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 23:02:30 +0300 Subject: [PATCH] feat: notifications API, auto-jobs, calendar housekeeping fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - notifications table (024 migration) — stores per-hotel notifications - GET/PATCH/POST/DELETE /api/hotels/:slug/notifications endpoints - createNotification() helper used by jobs + future hooks - jobs.ts — background tasks every 10min: auto-cancel no-shows and auto-checkout overdue stays based on hotel_settings, broadcasts WS events and creates notifications for each automated action Frontend: - CalendarPage: handle housekeeping_done WS → update room status in calendar in real-time (bug fix) - CalendarPage: when manually setting room to dirty via context menu, auto-create housekeeping task and broadcast via WS (bug fix) - NotificationsContext: replaced mock data with real API integration, polls every 60s, syncs markRead/delete to backend - SettingsPage booking section: auto-cancel no-show toggle + hours selector; auto-checkout toggle + hours selector - api.ts: notifications API methods - useHotelSocket: notification:new WS message type Co-Authored-By: Claude Sonnet 4.6 --- backend/migrations/024_notifications.sql | 13 ++ backend/src/app.ts | 5 + backend/src/jobs.ts | 148 +++++++++++++++++++++++ backend/src/routes/notifications.ts | 133 ++++++++++++++++++++ src/contexts/NotificationsContext.tsx | 135 +++++++++------------ src/hooks/useHotelSocket.ts | 1 + src/lib/api.ts | 15 +++ src/pages/CalendarPage.tsx | 17 ++- src/pages/SettingsPage.tsx | 105 ++++++++++++++++ 9 files changed, 495 insertions(+), 77 deletions(-) create mode 100644 backend/migrations/024_notifications.sql create mode 100644 backend/src/jobs.ts create mode 100644 backend/src/routes/notifications.ts diff --git a/backend/migrations/024_notifications.sql b/backend/migrations/024_notifications.sql new file mode 100644 index 0000000..b66e258 --- /dev/null +++ b/backend/migrations/024_notifications.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + booking_id UUID REFERENCES bookings(id) ON DELETE SET NULL, + room_id UUID REFERENCES rooms(id) ON DELETE SET NULL, + link TEXT, + is_read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS notifications_hotel_created_idx ON notifications(hotel_id, created_at DESC); diff --git a/backend/src/app.ts b/backend/src/app.ts index 1b6f14b..57378c4 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -29,6 +29,8 @@ import ratePeriodsRoutes from './routes/rate-periods' import rateOverridesRoutes from './routes/rate-overrides' import uploadRoutes from './routes/upload' import housekeepingSettingsRoutes from './routes/housekeeping-settings' +import notificationsRoutes from './routes/notifications' +import { startJobs } from './jobs' export async function buildApp() { const fastify = Fastify({ @@ -102,6 +104,9 @@ export async function buildApp() { await fastify.register(rateOverridesRoutes) await fastify.register(uploadRoutes) await fastify.register(housekeepingSettingsRoutes) + await fastify.register(notificationsRoutes) + + startJobs() return fastify } diff --git a/backend/src/jobs.ts b/backend/src/jobs.ts new file mode 100644 index 0000000..b9aea48 --- /dev/null +++ b/backend/src/jobs.ts @@ -0,0 +1,148 @@ +import { db } from './db' +import { broadcast } from './routes/ws' +import { createNotification } from './routes/notifications' +import { getHkSettings } from './routes/housekeeping-settings' + +const JOB_INTERVAL_MS = 10 * 60 * 1000 // every 10 minutes + +async function runAutoJobs(): Promise { + try { + await runAutoCancelNoShows() + await runAutoCheckouts() + } catch (err) { + console.error('[jobs] error running auto jobs:', err) + } +} + +async function runAutoCancelNoShows(): Promise { + // Find hotels where auto_cancel_noshow_enabled = true + const { rows: hotels } = await db.query<{ id: string; slug: string; hours: number }>(` + SELECT h.id, h.slug, + COALESCE( + (SELECT (value::text)::int FROM hotel_settings + WHERE hotel_id = h.id AND key = 'auto_cancel_noshow_hours'), + 24 + ) AS hours + FROM hotels h + WHERE EXISTS ( + SELECT 1 FROM hotel_settings + WHERE hotel_id = h.id + AND key = 'auto_cancel_noshow_enabled' + AND value = 'true'::jsonb + ) + `) + + for (const hotel of hotels) { + const { rows: bookings } = await db.query<{ + id: string; guest_name: string | null; room_id: string + }>(` + SELECT b.id, b.guest_name, b.room_id + FROM bookings b + WHERE b.hotel_id = $1 + AND b.status = 'confirmed' + AND (b.check_in::timestamp + ($2 || ' hours')::interval) < NOW() + `, [hotel.id, hotel.hours]) + + for (const b of bookings) { + const { rows } = await db.query( + `UPDATE bookings SET status = 'no_show', updated_at = NOW() + WHERE id = $1 RETURNING *`, + [b.id], + ) + if (rows[0]) { + broadcast(hotel.slug, { type: 'booking:updated', booking: rows[0] }) + } + await createNotification(hotel.id, hotel.slug, { + type: 'booking_cancelled', + title: 'Гость не заехал — бронь отменена', + body: `${b.guest_name ?? 'Гость'} не заехал вовремя. Бронирование автоматически отмечено как неявка.`, + bookingId: b.id, + link: `/${hotel.slug}/bookings`, + }).catch(() => {}) + } + } +} + +async function runAutoCheckouts(): Promise { + // Find hotels where auto_checkout_enabled = true + const { rows: hotels } = await db.query<{ id: string; slug: string; hours: number }>(` + SELECT h.id, h.slug, + COALESCE( + (SELECT (value::text)::int FROM hotel_settings + WHERE hotel_id = h.id AND key = 'auto_checkout_hours'), + 12 + ) AS hours + FROM hotels h + WHERE EXISTS ( + SELECT 1 FROM hotel_settings + WHERE hotel_id = h.id + AND key = 'auto_checkout_enabled' + AND value = 'true'::jsonb + ) + `) + + for (const hotel of hotels) { + const { rows: bookings } = await db.query<{ + id: string; guest_name: string | null; room_id: string + }>(` + SELECT b.id, b.guest_name, b.room_id + FROM bookings b + WHERE b.hotel_id = $1 + AND b.status = 'checked_in' + AND (b.check_out::timestamp + ($2 || ' hours')::interval) < NOW() + `, [hotel.id, hotel.hours]) + + for (const b of bookings) { + // Update booking to checked_out + const { rows } = await db.query( + `UPDATE bookings SET status = 'checked_out', updated_at = NOW() + WHERE id = $1 RETURNING *`, + [b.id], + ) + if (rows[0]) { + broadcast(hotel.slug, { type: 'booking:updated', booking: rows[0] }) + } + + // Auto-create housekeeping task if enabled + const hkSettings = await getHkSettings(hotel.id).catch(() => null) + if (hkSettings?.checkout_auto && b.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 *`, + [hotel.id, b.room_id, hkSettings.checkout_priority, + `Автоматическая уборка после выезда${b.guest_name ? ': ' + b.guest_name : ''}`, + today], + ) + await db.query( + `UPDATE rooms SET housekeeping_status = 'dirty' WHERE id = $1`, + [b.room_id], + ) + if (taskRows[0]) { + broadcast(hotel.slug, { type: 'housekeeping_task_created', task: taskRows[0] }) + } + } + + await createNotification(hotel.id, hotel.slug, { + type: 'booking_checkout', + title: 'Автоматическое выселение', + body: `${b.guest_name ?? 'Гость'} выселен автоматически по истечении времени проживания.`, + bookingId: b.id, + link: `/${hotel.slug}/calendar`, + }).catch(() => {}) + } + } +} + +export function startJobs(): void { + // Small delay to let DB migrations finish on startup + setTimeout(() => { + runAutoJobs().catch(console.error) + }, 15_000) + + setInterval(() => { + runAutoJobs().catch(console.error) + }, JOB_INTERVAL_MS) +} diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 0000000..443f8e1 --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,133 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' +import { broadcast } from './ws' + +type SlugParam = { Params: { slug: string } } +type SlugIdParam = { Params: { slug: string; id: string } } + +export interface NotificationRow { + id: string + hotel_id: string + type: string + title: string + body: string + booking_id: string | null + room_id: string | null + link: string | null + is_read: boolean + created_at: string +} + +export async function createNotification( + hotelId: string, + hotelSlug: string, + data: { + type: string + title: string + body: string + bookingId?: string | null + roomId?: string | null + link?: string | null + }, +): Promise { + const { rows } = await db.query( + `INSERT INTO notifications (hotel_id, type, title, body, booking_id, room_id, link) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [hotelId, data.type, data.title, data.body, + data.bookingId ?? null, data.roomId ?? null, data.link ?? null], + ) + const notif = rows[0] + broadcast(hotelSlug, { type: 'notification:new', notification: notif }) + return notif +} + +const notifications: 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/notifications + fastify.get( + '/api/hotels/:slug/notifications', + { 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 { rows } = await db.query( + `SELECT * FROM notifications WHERE hotel_id = $1 + ORDER BY created_at DESC LIMIT 100`, + [hotelId], + ) + return rows + }, + ) + + // PATCH /api/hotels/:slug/notifications/:id — mark read + fastify.patch( + '/api/hotels/:slug/notifications/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = 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 { rows } = await db.query( + `UPDATE notifications SET is_read = true WHERE id = $1 AND hotel_id = $2 RETURNING *`, + [id, hotelId], + ) + if (!rows[0]) return reply.code(404).send({ error: 'Not found' }) + return rows[0] + }, + ) + + // POST /api/hotels/:slug/notifications/read-all + fastify.post( + '/api/hotels/:slug/notifications/read-all', + { 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' }) + + await db.query( + `UPDATE notifications SET is_read = true WHERE hotel_id = $1 AND is_read = false`, + [hotelId], + ) + return { ok: true } + }, + ) + + // DELETE /api/hotels/:slug/notifications/:id + fastify.delete( + '/api/hotels/:slug/notifications/:id', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug, id } = 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 { rowCount } = await db.query( + `DELETE FROM notifications WHERE id = $1 AND hotel_id = $2`, + [id, hotelId], + ) + if (!rowCount) return reply.code(404).send({ error: 'Not found' }) + return reply.code(204).send() + }, + ) +} + +export default notifications diff --git a/src/contexts/NotificationsContext.tsx b/src/contexts/NotificationsContext.tsx index 4ee74ac..3b67afd 100644 --- a/src/contexts/NotificationsContext.tsx +++ b/src/contexts/NotificationsContext.tsx @@ -1,4 +1,6 @@ -import { createContext, useContext, useState, type ReactNode } from 'react' +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' +import { useAuth } from './AuthContext' +import { api } from '../lib/api' export type NotifType = | 'booking_new' @@ -22,105 +24,86 @@ export interface Notification { link?: string } -const INITIAL_NOTIFICATIONS: Notification[] = [ - { - id: 'n1', type: 'booking_new', - title: 'Новое бронирование', - body: 'Алексей Смирнов забронировал номер 205 (Делюкс) на 14–18 марта', - time: new Date(Date.now() - 4 * 60000).toISOString(), - isRead: false, link: '/bookings', - }, - { - id: 'n2', type: 'channel_error', - title: 'Ошибка синхронизации', - body: 'Booking.com: не удалось обновить доступность. Проверьте подключение.', - time: new Date(Date.now() - 18 * 60000).toISOString(), - isRead: false, link: '/channels', - }, - { - id: 'n3', type: 'booking_checkin', - title: 'Заезд сегодня', - body: 'Мария Иванова · номер 101 · заезд в 14:00', - time: new Date(Date.now() - 45 * 60000).toISOString(), - isRead: false, link: '/bookings', - }, - { - id: 'n4', type: 'review', - title: 'Новый отзыв требует модерации', - body: 'Гость оставил оценку 2/5 — отзыв ждёт вашего ответа', - time: new Date(Date.now() - 2 * 3600000).toISOString(), - isRead: false, link: '/reviews', - }, - { - id: 'n5', type: 'housekeeping', - title: 'Уборка завершена', - body: 'Горничная Козлова Н. завершила уборку номеров 101, 102, 203', - time: new Date(Date.now() - 3 * 3600000).toISOString(), - isRead: true, link: '/housekeeping', - }, - { - id: 'n6', type: 'payment', - title: 'Оплата получена', - body: 'Иван Петров оплатил бронирование #B-2847 — 12 400 ₽', - time: new Date(Date.now() - 5 * 3600000).toISOString(), - isRead: true, - }, - { - id: 'n7', type: 'booking_cancelled', - title: 'Бронирование отменено', - body: 'Дмитрий Волков отменил бронь номера 304 на 20–22 марта', - time: new Date(Date.now() - 26 * 3600000).toISOString(), - isRead: true, link: '/bookings', - }, - { - id: 'n8', type: 'booking_checkout', - title: 'Выезд завершён', - body: 'Семья Соколовых выехала из номера 206. Нужна уборка.', - time: new Date(Date.now() - 28 * 3600000).toISOString(), - isRead: true, link: '/housekeeping', - }, - { - id: 'n9', type: 'system', - title: 'Обновление системы', - body: 'HotelSync обновлён до версии 0.2.0. Что нового — в журнале изменений.', - time: new Date(Date.now() - 3 * 86400000).toISOString(), - isRead: true, - }, -] - interface NotificationsContextValue { notifications: Notification[] addNotification: (n: Omit) => void markRead: (id: string) => void markAllRead: () => void removeNotif: (id: string) => void + refresh: () => void } const NotificationsContext = createContext(null) -export function NotificationsProvider({ children }: { children: ReactNode }) { - const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS) +function toNotif(raw: Record): Notification { + return { + id: String(raw.id), + type: (raw.type as NotifType) ?? 'system', + title: String(raw.title ?? ''), + body: String(raw.body ?? ''), + time: String(raw.createdAt ?? raw.created_at ?? new Date().toISOString()), + isRead: Boolean(raw.isRead ?? raw.is_read ?? false), + link: raw.link ? String(raw.link) : undefined, + } +} - const addNotification = (n: Omit) => { +export function NotificationsProvider({ children }: { children: ReactNode }) { + const { user } = useAuth() + const slug = user?.hotelSlug ?? '' + + const [notifications, setNotifications] = useState([]) + + const refresh = useCallback(() => { + if (!slug) return + api.notifications.list(slug) + .then(rows => setNotifications(rows.map(toNotif))) + .catch(() => {}) + }, [slug]) + + // Load on mount / slug change + useEffect(() => { + refresh() + }, [refresh]) + + // Poll every 60s for new notifications + useEffect(() => { + if (!slug) return + const timer = setInterval(refresh, 60_000) + return () => clearInterval(timer) + }, [slug, refresh]) + + const addNotification = useCallback((n: Omit) => { setNotifications(prev => [{ ...n, - id: `n-${Date.now()}`, + id: `local-${Date.now()}`, time: new Date().toISOString(), isRead: false, }, ...prev]) - } + }, []) - const markRead = (id: string) => + const markRead = useCallback((id: string) => { setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n)) + if (slug && !id.startsWith('local-')) { + api.notifications.markRead(slug, id).catch(() => {}) + } + }, [slug]) - const markAllRead = () => + const markAllRead = useCallback(() => { setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))) + if (slug) { + api.notifications.markAllRead(slug).catch(() => {}) + } + }, [slug]) - const removeNotif = (id: string) => + const removeNotif = useCallback((id: string) => { setNotifications(prev => prev.filter(n => n.id !== id)) + if (slug && !id.startsWith('local-')) { + api.notifications.delete(slug, id).catch(() => {}) + } + }, [slug]) return ( - + {children} ) diff --git a/src/hooks/useHotelSocket.ts b/src/hooks/useHotelSocket.ts index 0cf098e..cdb7118 100644 --- a/src/hooks/useHotelSocket.ts +++ b/src/hooks/useHotelSocket.ts @@ -10,6 +10,7 @@ export type WsMessage = | { type: 'housekeeping_task_created'; task: Record } | { type: 'housekeeping_updated'; task: Record } | { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string } + | { type: 'notification:new'; notification: Record } interface Options { slug: string diff --git a/src/lib/api.ts b/src/lib/api.ts index c2d245d..94be701 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -419,6 +419,21 @@ export const api = { req<{ count: number }>('POST', `/api/hotels/${slug}/rate-overrides/bulk`, { overrides }), }, + // ── Notifications ───────────────────────────────────────────────────────── + notifications: { + list: (slug: string) => + req[]>('GET', `/api/hotels/${slug}/notifications`), + + markRead: (slug: string, id: string) => + req>('PATCH', `/api/hotels/${slug}/notifications/${id}`), + + markAllRead: (slug: string) => + req<{ ok: boolean }>('POST', `/api/hotels/${slug}/notifications/read-all`), + + delete: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/notifications/${id}`), + }, + // ── Rate Periods ───────────────────────────────────────────────────────── ratePeriods: { list: (slug: string) => diff --git a/src/pages/CalendarPage.tsx b/src/pages/CalendarPage.tsx index 485c65f..39c17f6 100644 --- a/src/pages/CalendarPage.tsx +++ b/src/pages/CalendarPage.tsx @@ -65,6 +65,10 @@ export function CalendarPage() { setBookings(prev => prev.map(b => b.id === msg.booking.id ? msg.booking : b)) } else if (msg.type === 'booking:deleted') { setBookings(prev => prev.filter(b => b.id !== msg.bookingId)) + } else if (msg.type === 'housekeeping_done') { + setRooms(prev => prev.map(r => + r.id === msg.roomId ? { ...r, housekeepingStatus: msg.roomStatus as Room['housekeepingStatus'] } : r + )) } }, []) @@ -153,10 +157,21 @@ export function CalendarPage() { try { const updated = await api.rooms.update(slug, roomId, patch) setRooms(prev => prev.map(r => r.id === updated.id ? updated : r)) + // When manually marking room as dirty → auto-create a housekeeping task + if (patch.housekeepingStatus === 'dirty') { + const today = new Date().toISOString().slice(0, 10) + const task = await api.housekeeping.create(slug, { + room_id: roomId, + type: 'regular', + priority: 'medium', + due_date: today, + }) + send({ type: 'housekeeping_task_created', task: task as unknown as Record }) + } } catch (err) { console.error('Failed to update room:', err) } - }, [slug]) + }, [slug, send]) const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => { try { diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 57ac9d0..6291ee9 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -80,6 +80,12 @@ export function SettingsPage() { const [lateCheckoutEnabled, setLateCheckoutEnabled] = useState(false) const [hotelSettingsLoaded, setHotelSettingsLoaded] = useState(false) + // Automation settings + const [autoCancelNoShow, setAutoCancelNoShow] = useState(false) + const [autoCancelNoShowHours, setAutoCancelNoShowHours] = useState(24) + const [autoCheckout, setAutoCheckout] = useState(false) + const [autoCheckoutHours, setAutoCheckoutHours] = useState(12) + useEffect(() => { if (!slug || hotelSettingsLoaded) return api.hotelSettings.get(slug) @@ -87,6 +93,10 @@ export function SettingsPage() { setRequireGuestDocs(Boolean(s.require_guest_docs)) setEarlyCheckinEnabled(Boolean(s.early_checkin_enabled)) setLateCheckoutEnabled(Boolean(s.late_checkout_enabled)) + setAutoCancelNoShow(Boolean(s.auto_cancel_noshow_enabled)) + if (s.auto_cancel_noshow_hours) setAutoCancelNoShowHours(Number(s.auto_cancel_noshow_hours)) + setAutoCheckout(Boolean(s.auto_checkout_enabled)) + if (s.auto_checkout_hours) setAutoCheckoutHours(Number(s.auto_checkout_hours)) setHotelSettingsLoaded(true) }) .catch(console.error) @@ -122,6 +132,36 @@ export function SettingsPage() { } } + const toggleAutoCancelNoShow = async () => { + const next = !autoCancelNoShow + setAutoCancelNoShow(next) + try { + await api.hotelSettings.update(slug, { auto_cancel_noshow_enabled: next }) + } catch { + setAutoCancelNoShow(!next) + } + } + + const saveAutoCancelNoShowHours = async (hours: number) => { + setAutoCancelNoShowHours(hours) + await api.hotelSettings.update(slug, { auto_cancel_noshow_hours: hours }).catch(() => {}) + } + + const toggleAutoCheckout = async () => { + const next = !autoCheckout + setAutoCheckout(next) + try { + await api.hotelSettings.update(slug, { auto_checkout_enabled: next }) + } catch { + setAutoCheckout(!next) + } + } + + const saveAutoCheckoutHours = async (hours: number) => { + setAutoCheckoutHours(hours) + await api.hotelSettings.update(slug, { auto_checkout_hours: hours }).catch(() => {}) + } + // Guest settings const [guestTags, setGuestTags] = useState([ { id: 'vip', label: 'VIP', color: '#F59E0B' }, @@ -345,6 +385,71 @@ export function SettingsPage() { + {/* Divider */} +
+

Автоматизация

+ + {/* Auto-cancel no-show */} +
+
+
+

Автоотмена незаехавших броней

+

+ Автоматически отмечает «Неявка», если гость не заехал +

+
+ +
+ {autoCancelNoShow && ( +
+

Отменять через

+ +

после даты заезда

+
+ )} +
+ + {/* Auto-checkout */} +
+
+
+

Автовыселение по истечении проживания

+

+ Если менеджер не выселил гостя вручную — выселяет автоматически +

+
+ +
+ {autoCheckout && ( +
+

Выселять через

+ +

после даты выезда

+
+ )} +
+
+