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:
13
backend/migrations/024_notifications.sql
Normal file
13
backend/migrations/024_notifications.sql
Normal file
@@ -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);
|
||||||
@@ -29,6 +29,8 @@ import ratePeriodsRoutes from './routes/rate-periods'
|
|||||||
import rateOverridesRoutes from './routes/rate-overrides'
|
import rateOverridesRoutes from './routes/rate-overrides'
|
||||||
import uploadRoutes from './routes/upload'
|
import uploadRoutes from './routes/upload'
|
||||||
import housekeepingSettingsRoutes from './routes/housekeeping-settings'
|
import housekeepingSettingsRoutes from './routes/housekeeping-settings'
|
||||||
|
import notificationsRoutes from './routes/notifications'
|
||||||
|
import { startJobs } from './jobs'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@@ -102,6 +104,9 @@ export async function buildApp() {
|
|||||||
await fastify.register(rateOverridesRoutes)
|
await fastify.register(rateOverridesRoutes)
|
||||||
await fastify.register(uploadRoutes)
|
await fastify.register(uploadRoutes)
|
||||||
await fastify.register(housekeepingSettingsRoutes)
|
await fastify.register(housekeepingSettingsRoutes)
|
||||||
|
await fastify.register(notificationsRoutes)
|
||||||
|
|
||||||
|
startJobs()
|
||||||
|
|
||||||
return fastify
|
return fastify
|
||||||
}
|
}
|
||||||
|
|||||||
148
backend/src/jobs.ts
Normal file
148
backend/src/jobs.ts
Normal file
@@ -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<void> {
|
||||||
|
try {
|
||||||
|
await runAutoCancelNoShows()
|
||||||
|
await runAutoCheckouts()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[jobs] error running auto jobs:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAutoCancelNoShows(): Promise<void> {
|
||||||
|
// 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<void> {
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
133
backend/src/routes/notifications.ts
Normal file
133
backend/src/routes/notifications.ts
Normal file
@@ -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<NotificationRow> {
|
||||||
|
const { rows } = await db.query<NotificationRow>(
|
||||||
|
`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<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/notifications
|
||||||
|
fastify.get<SlugParam>(
|
||||||
|
'/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<NotificationRow>(
|
||||||
|
`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<SlugIdParam>(
|
||||||
|
'/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<NotificationRow>(
|
||||||
|
`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<SlugParam>(
|
||||||
|
'/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<SlugIdParam>(
|
||||||
|
'/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
|
||||||
@@ -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 =
|
export type NotifType =
|
||||||
| 'booking_new'
|
| 'booking_new'
|
||||||
@@ -22,105 +24,86 @@ export interface Notification {
|
|||||||
link?: string
|
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 {
|
interface NotificationsContextValue {
|
||||||
notifications: Notification[]
|
notifications: Notification[]
|
||||||
addNotification: (n: Omit<Notification, 'id' | 'time' | 'isRead'>) => void
|
addNotification: (n: Omit<Notification, 'id' | 'time' | 'isRead'>) => void
|
||||||
markRead: (id: string) => void
|
markRead: (id: string) => void
|
||||||
markAllRead: () => void
|
markAllRead: () => void
|
||||||
removeNotif: (id: string) => void
|
removeNotif: (id: string) => void
|
||||||
|
refresh: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
|
const NotificationsContext = createContext<NotificationsContextValue | null>(null)
|
||||||
|
|
||||||
export function NotificationsProvider({ children }: { children: ReactNode }) {
|
function toNotif(raw: Record<string, unknown>): Notification {
|
||||||
const [notifications, setNotifications] = useState<Notification[]>(INITIAL_NOTIFICATIONS)
|
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 => [{
|
setNotifications(prev => [{
|
||||||
...n,
|
...n,
|
||||||
id: `n-${Date.now()}`,
|
id: `local-${Date.now()}`,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
isRead: false,
|
isRead: false,
|
||||||
}, ...prev])
|
}, ...prev])
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const markRead = (id: string) =>
|
const markRead = useCallback((id: string) => {
|
||||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, isRead: true } : n))
|
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 })))
|
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))
|
setNotifications(prev => prev.filter(n => n.id !== id))
|
||||||
|
if (slug && !id.startsWith('local-')) {
|
||||||
|
api.notifications.delete(slug, id).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NotificationsContext.Provider value={{ notifications, addNotification, markRead, markAllRead, removeNotif }}>
|
<NotificationsContext.Provider value={{ notifications, addNotification, markRead, markAllRead, removeNotif, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</NotificationsContext.Provider>
|
</NotificationsContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type WsMessage =
|
|||||||
| { type: 'housekeeping_task_created'; task: Record<string, unknown> }
|
| { type: 'housekeeping_task_created'; task: Record<string, unknown> }
|
||||||
| { type: 'housekeeping_updated'; task: Record<string, unknown> }
|
| { type: 'housekeeping_updated'; task: Record<string, unknown> }
|
||||||
| { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string }
|
| { type: 'housekeeping_done'; taskId: string; roomId: string; roomStatus: string }
|
||||||
|
| { type: 'notification:new'; notification: Record<string, unknown> }
|
||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
slug: string
|
slug: string
|
||||||
|
|||||||
@@ -419,6 +419,21 @@ export const api = {
|
|||||||
req<{ count: number }>('POST', `/api/hotels/${slug}/rate-overrides/bulk`, { overrides }),
|
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 ─────────────────────────────────────────────────────────
|
// ── Rate Periods ─────────────────────────────────────────────────────────
|
||||||
ratePeriods: {
|
ratePeriods: {
|
||||||
list: (slug: string) =>
|
list: (slug: string) =>
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ export function CalendarPage() {
|
|||||||
setBookings(prev => prev.map(b => b.id === msg.booking.id ? msg.booking : b))
|
setBookings(prev => prev.map(b => b.id === msg.booking.id ? msg.booking : b))
|
||||||
} else if (msg.type === 'booking:deleted') {
|
} else if (msg.type === 'booking:deleted') {
|
||||||
setBookings(prev => prev.filter(b => b.id !== msg.bookingId))
|
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 {
|
try {
|
||||||
const updated = await api.rooms.update(slug, roomId, patch)
|
const updated = await api.rooms.update(slug, roomId, patch)
|
||||||
setRooms(prev => prev.map(r => r.id === updated.id ? updated : r))
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to update room:', err)
|
console.error('Failed to update room:', err)
|
||||||
}
|
}
|
||||||
}, [slug])
|
}, [slug, send])
|
||||||
|
|
||||||
const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => {
|
const handleRentalBookingCreate = useCallback(async (b: RentalBooking) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -80,6 +80,12 @@ export function SettingsPage() {
|
|||||||
const [lateCheckoutEnabled, setLateCheckoutEnabled] = useState(false)
|
const [lateCheckoutEnabled, setLateCheckoutEnabled] = useState(false)
|
||||||
const [hotelSettingsLoaded, setHotelSettingsLoaded] = 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(() => {
|
useEffect(() => {
|
||||||
if (!slug || hotelSettingsLoaded) return
|
if (!slug || hotelSettingsLoaded) return
|
||||||
api.hotelSettings.get(slug)
|
api.hotelSettings.get(slug)
|
||||||
@@ -87,6 +93,10 @@ export function SettingsPage() {
|
|||||||
setRequireGuestDocs(Boolean(s.require_guest_docs))
|
setRequireGuestDocs(Boolean(s.require_guest_docs))
|
||||||
setEarlyCheckinEnabled(Boolean(s.early_checkin_enabled))
|
setEarlyCheckinEnabled(Boolean(s.early_checkin_enabled))
|
||||||
setLateCheckoutEnabled(Boolean(s.late_checkout_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)
|
setHotelSettingsLoaded(true)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.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
|
// Guest settings
|
||||||
const [guestTags, setGuestTags] = useState([
|
const [guestTags, setGuestTags] = useState([
|
||||||
{ id: 'vip', label: 'VIP', color: '#F59E0B' },
|
{ id: 'vip', label: 'VIP', color: '#F59E0B' },
|
||||||
@@ -345,6 +385,71 @@ export function SettingsPage() {
|
|||||||
<Toggle on={lateCheckoutEnabled} onChange={toggleLateCheckout} />
|
<Toggle on={lateCheckoutEnabled} onChange={toggleLateCheckout} />
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||||
Стратегия автоматического расселения
|
Стратегия автоматического расселения
|
||||||
|
|||||||
Reference in New Issue
Block a user