feat: notifications API, auto-jobs, calendar housekeeping fixes

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 23:02:30 +03:00
parent b631a281d2
commit b9116c427a
9 changed files with 495 additions and 77 deletions

View File

@@ -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 (Делюкс) на 1418 марта',
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 на 2022 марта',
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<Notification, 'id' | 'time' | 'isRead'>) => void
markRead: (id: string) => void
markAllRead: () => void
removeNotif: (id: string) => void
refresh: () => void
}
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
export function NotificationsProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>(INITIAL_NOTIFICATIONS)
function toNotif(raw: Record<string, unknown>): 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<Notification, 'id' | 'time' | 'isRead'>) => {
export function NotificationsProvider({ children }: { children: ReactNode }) {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [notifications, setNotifications] = useState<Notification[]>([])
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<Notification, 'id' | 'time' | 'isRead'>) => {
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 (
<NotificationsContext.Provider value={{ notifications, addNotification, markRead, markAllRead, removeNotif }}>
<NotificationsContext.Provider value={{ notifications, addNotification, markRead, markAllRead, removeNotif, refresh }}>
{children}
</NotificationsContext.Provider>
)

View File

@@ -10,6 +10,7 @@ export type WsMessage =
| { type: 'housekeeping_task_created'; task: Record<string, unknown> }
| { type: 'housekeeping_updated'; task: Record<string, unknown> }
| { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string }
| { type: 'notification:new'; notification: Record<string, unknown> }
interface Options {
slug: string

View File

@@ -419,6 +419,21 @@ export const api = {
req<{ count: number }>('POST', `/api/hotels/${slug}/rate-overrides/bulk`, { overrides }),
},
// ── Notifications ─────────────────────────────────────────────────────────
notifications: {
list: (slug: string) =>
req<Record<string, unknown>[]>('GET', `/api/hotels/${slug}/notifications`),
markRead: (slug: string, id: string) =>
req<Record<string, unknown>>('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<void>('DELETE', `/api/hotels/${slug}/notifications/${id}`),
},
// ── Rate Periods ─────────────────────────────────────────────────────────
ratePeriods: {
list: (slug: string) =>

View File

@@ -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<string, unknown> })
}
} catch (err) {
console.error('Failed to update room:', err)
}
}, [slug])
}, [slug, send])
const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => {
try {

View File

@@ -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() {
<Toggle on={lateCheckoutEnabled} onChange={toggleLateCheckout} />
</div>
{/* Divider */}
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200 mb-3">Автоматизация</h3>
{/* Auto-cancel no-show */}
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Автоотмена незаехавших броней</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Автоматически отмечает «Неявка», если гость не заехал
</p>
</div>
<Toggle on={autoCancelNoShow} onChange={toggleAutoCancelNoShow} />
</div>
{autoCancelNoShow && (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<p className="text-sm text-slate-600 dark:text-slate-400 flex-1">Отменять через</p>
<select
className="input w-auto text-sm py-1"
value={autoCancelNoShowHours}
onChange={e => saveAutoCancelNoShowHours(Number(e.target.value))}
>
<option value={6}>6 часов</option>
<option value={12}>12 часов</option>
<option value={24}>24 часа</option>
<option value={48}>48 часов</option>
<option value={72}>72 часа</option>
</select>
<p className="text-xs text-slate-400">после даты заезда</p>
</div>
)}
</div>
{/* Auto-checkout */}
<div className="space-y-2">
<div className="flex items-center justify-between p-3 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Автовыселение по истечении проживания</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Если менеджер не выселил гостя вручную выселяет автоматически
</p>
</div>
<Toggle on={autoCheckout} onChange={toggleAutoCheckout} />
</div>
{autoCheckout && (
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<p className="text-sm text-slate-600 dark:text-slate-400 flex-1">Выселять через</p>
<select
className="input w-auto text-sm py-1"
value={autoCheckoutHours}
onChange={e => saveAutoCheckoutHours(Number(e.target.value))}
>
<option value={2}>2 часа</option>
<option value={4}>4 часа</option>
<option value={6}>6 часов</option>
<option value={12}>12 часов</option>
<option value={24}>24 часа</option>
</select>
<p className="text-xs text-slate-400">после даты выезда</p>
</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Стратегия автоматического расселения