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:
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
Стратегия автоматического расселения
|
||||
|
||||
Reference in New Issue
Block a user