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:
@@ -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<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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user