feat: emergency room closure for urgent maintenance tasks
- Backend housekeeping POST: when priority=urgent + category=maintenance + room_id → auto sets room status=maintenance with maintenance_from/to dates → closes room for emergency_close_days (default 1 day) → broadcasts room_updated WS event - housekeeping-settings: add emergency_close_days column (migration 030, default 1) - TechnicalPage: rename "Срочно" → "Экстренно!", show "🔒 Номер закрыт" badge for urgent active tasks - HousekeepingPage plans tab: new "Экстренное закрытие номера" setting with day picker (1/2/3/5/7/14 days) - api.ts HkSettings: add emergency_close_days field Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
backend/migrations/030_emergency_close_days.sql
Normal file
1
backend/migrations/030_emergency_close_days.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE housekeeping_settings ADD COLUMN IF NOT EXISTS emergency_close_days INTEGER NOT NULL DEFAULT 1;
|
||||||
@@ -8,6 +8,7 @@ export interface HkSettings {
|
|||||||
checkout_priority: string
|
checkout_priority: string
|
||||||
inspection_after_clean: boolean
|
inspection_after_clean: boolean
|
||||||
require_completion_photo: boolean
|
require_completion_photo: boolean
|
||||||
|
emergency_close_days: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: HkSettings = {
|
const DEFAULT_SETTINGS: HkSettings = {
|
||||||
@@ -15,11 +16,12 @@ const DEFAULT_SETTINGS: HkSettings = {
|
|||||||
checkout_priority: 'high',
|
checkout_priority: 'high',
|
||||||
inspection_after_clean: true,
|
inspection_after_clean: true,
|
||||||
require_completion_photo: false,
|
require_completion_photo: false,
|
||||||
|
emergency_close_days: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHkSettings(hotelId: string): Promise<HkSettings> {
|
export async function getHkSettings(hotelId: string): Promise<HkSettings> {
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
'SELECT checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo FROM housekeeping_settings WHERE hotel_id = $1',
|
'SELECT checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days FROM housekeeping_settings WHERE hotel_id = $1',
|
||||||
[hotelId],
|
[hotelId],
|
||||||
)
|
)
|
||||||
return rows[0] ?? DEFAULT_SETTINGS
|
return rows[0] ?? DEFAULT_SETTINGS
|
||||||
@@ -59,15 +61,16 @@ const housekeepingSettings: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo } = request.body
|
const { checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days } = request.body
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo)
|
`INSERT INTO housekeeping_settings (hotel_id, checkout_auto, checkout_priority, inspection_after_clean, require_completion_photo, emergency_close_days)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
ON CONFLICT (hotel_id) DO UPDATE SET
|
ON CONFLICT (hotel_id) DO UPDATE SET
|
||||||
checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto),
|
checkout_auto = COALESCE(EXCLUDED.checkout_auto, housekeeping_settings.checkout_auto),
|
||||||
checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority),
|
checkout_priority = COALESCE(EXCLUDED.checkout_priority, housekeeping_settings.checkout_priority),
|
||||||
inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean),
|
inspection_after_clean = COALESCE(EXCLUDED.inspection_after_clean, housekeeping_settings.inspection_after_clean),
|
||||||
require_completion_photo = COALESCE(EXCLUDED.require_completion_photo, housekeeping_settings.require_completion_photo),
|
require_completion_photo = COALESCE(EXCLUDED.require_completion_photo, housekeeping_settings.require_completion_photo),
|
||||||
|
emergency_close_days = COALESCE(EXCLUDED.emergency_close_days, housekeeping_settings.emergency_close_days),
|
||||||
updated_at = NOW()`,
|
updated_at = NOW()`,
|
||||||
[
|
[
|
||||||
hotelId,
|
hotelId,
|
||||||
@@ -75,6 +78,7 @@ const housekeepingSettings: FastifyPluginAsync = async (fastify) => {
|
|||||||
checkout_priority ?? DEFAULT_SETTINGS.checkout_priority,
|
checkout_priority ?? DEFAULT_SETTINGS.checkout_priority,
|
||||||
inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean,
|
inspection_after_clean ?? DEFAULT_SETTINGS.inspection_after_clean,
|
||||||
require_completion_photo ?? DEFAULT_SETTINGS.require_completion_photo,
|
require_completion_photo ?? DEFAULT_SETTINGS.require_completion_photo,
|
||||||
|
emergency_close_days ?? DEFAULT_SETTINGS.emergency_close_days,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return getHkSettings(hotelId)
|
return getHkSettings(hotelId)
|
||||||
|
|||||||
@@ -81,6 +81,24 @@ const housekeeping: FastifyPluginAsync = async (fastify) => {
|
|||||||
[hotelId, room_id ?? null, type, priority,
|
[hotelId, room_id ?? null, type, priority,
|
||||||
assignee_id ?? null, notes ?? null, due_date ?? null, category, photos],
|
assignee_id ?? null, notes ?? null, due_date ?? null, category, photos],
|
||||||
)
|
)
|
||||||
|
// If priority=urgent and room_id → close the room for emergency_close_days
|
||||||
|
if (priority === 'urgent' && room_id && category === 'maintenance') {
|
||||||
|
const settings = await getHkSettings(hotelId).catch(() => null)
|
||||||
|
const closeDays = settings?.emergency_close_days ?? 1
|
||||||
|
const from = new Date().toISOString().slice(0, 10)
|
||||||
|
const toDate = new Date()
|
||||||
|
toDate.setDate(toDate.getDate() + closeDays)
|
||||||
|
const to = toDate.toISOString().slice(0, 10)
|
||||||
|
await db.query(
|
||||||
|
`UPDATE rooms SET status = 'maintenance', maintenance_from = $2, maintenance_to = $3 WHERE id = $1`,
|
||||||
|
[room_id, from, to],
|
||||||
|
)
|
||||||
|
broadcast(slug, {
|
||||||
|
type: 'room_updated',
|
||||||
|
room: { id: room_id, status: 'maintenance', maintenanceFrom: from, maintenanceTo: to },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT t.*, r.number AS room_number, r.type AS room_type, u.name AS assignee_name
|
`SELECT t.*, r.number AS room_number, r.type AS room_type, u.name AS assignee_name
|
||||||
FROM housekeeping_tasks t
|
FROM housekeeping_tasks t
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ export interface HkSettings {
|
|||||||
checkout_priority: string
|
checkout_priority: string
|
||||||
inspection_after_clean: boolean
|
inspection_after_clean: boolean
|
||||||
require_completion_photo?: boolean
|
require_completion_photo?: boolean
|
||||||
|
emergency_close_days?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HotelPayload {
|
export interface HotelPayload {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export function HousekeepingPage() {
|
|||||||
checkoutPriority: s.checkout_priority as 'high' | 'medium',
|
checkoutPriority: s.checkout_priority as 'high' | 'medium',
|
||||||
inspectionAfterClean: s.inspection_after_clean,
|
inspectionAfterClean: s.inspection_after_clean,
|
||||||
requireCompletionPhoto: s.require_completion_photo ?? false,
|
requireCompletionPhoto: s.require_completion_photo ?? false,
|
||||||
|
emergencyCloseDays: s.emergency_close_days ?? 1,
|
||||||
}))
|
}))
|
||||||
}).catch(console.error)
|
}).catch(console.error)
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
@@ -136,6 +137,7 @@ export function HousekeepingPage() {
|
|||||||
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
|
dailyStartFromDay: 1, onDemandEnabled: true, onDemandPriority: 'medium' as 'high' | 'medium' | 'low',
|
||||||
deepCleanEnabled: false, deepCleanEveryDays: 7,
|
deepCleanEnabled: false, deepCleanEveryDays: 7,
|
||||||
inspectionAfterClean: true, autoAssign: false, requireCompletionPhoto: false,
|
inspectionAfterClean: true, autoAssign: false, requireCompletionPhoto: false,
|
||||||
|
emergencyCloseDays: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
|
const setP = <K extends keyof typeof plans>(k: K, v: typeof plans[K]) =>
|
||||||
@@ -150,6 +152,7 @@ export function HousekeepingPage() {
|
|||||||
checkout_priority: plans.checkoutPriority,
|
checkout_priority: plans.checkoutPriority,
|
||||||
inspection_after_clean: plans.inspectionAfterClean,
|
inspection_after_clean: plans.inspectionAfterClean,
|
||||||
require_completion_photo: plans.requireCompletionPhoto,
|
require_completion_photo: plans.requireCompletionPhoto,
|
||||||
|
emergency_close_days: plans.emergencyCloseDays,
|
||||||
}
|
}
|
||||||
await api.housekeeping.saveSettings(slug, settings)
|
await api.housekeeping.saveSettings(slug, settings)
|
||||||
setPlanSaved(true)
|
setPlanSaved(true)
|
||||||
@@ -524,6 +527,34 @@ export function HousekeepingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-900 dark:text-slate-100">Экстренное закрытие номера</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
При отчёте о критической поломке (статус «Экстренно!») номер автоматически закрывается для бронирования
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 border-t border-slate-100 dark:border-slate-700 pt-3">
|
||||||
|
<label className="text-sm text-slate-700 dark:text-slate-300 shrink-0">Закрывать на</label>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{[1, 2, 3, 5, 7, 14].map(n => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setP('emergencyCloseDays', n)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors',
|
||||||
|
plans.emergencyCloseDays === n
|
||||||
|
? 'bg-red-600 border-red-600 text-white'
|
||||||
|
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{n === 1 ? '1 день' : n < 5 ? `${n} дня` : `${n} дней`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={savePlans} disabled={planSaving} className="btn-primary">
|
<button onClick={savePlans} disabled={planSaving} className="btn-primary">
|
||||||
{planSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
{planSaving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ function normalizeTask(raw: Record<string, unknown>): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<string, { label: string; color: string }> = {
|
const PRIORITY_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
urgent: { label: 'Срочно', color: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border border-red-200 dark:border-red-800' },
|
urgent: { label: 'Экстренно!', color: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border border-red-200 dark:border-red-800' },
|
||||||
medium: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800' },
|
medium: { label: 'Средний', color: 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border border-yellow-200 dark:border-yellow-800' },
|
||||||
low: { label: 'Низкий', color: 'text-slate-500 bg-slate-100 dark:bg-slate-700 dark:text-slate-400 border border-slate-200 dark:border-slate-600' },
|
low: { label: 'Низкий', color: 'text-slate-500 bg-slate-100 dark:bg-slate-700 dark:text-slate-400 border border-slate-200 dark:border-slate-600' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
const STATUS_LABELS: Record<string, { label: string; icon: React.ElementType; color: string }> = {
|
||||||
@@ -217,6 +217,11 @@ export function TechnicalPage() {
|
|||||||
)}>
|
)}>
|
||||||
{sMeta.label}
|
{sMeta.label}
|
||||||
</span>
|
</span>
|
||||||
|
{String(taskRecord.priority) === 'urgent' && !isDone && (taskRecord.roomNumber || taskRecord.room_number) && (
|
||||||
|
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded-md shrink-0 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 border border-red-200 dark:border-red-700">
|
||||||
|
🔒 Номер закрыт
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex-wrap">
|
||||||
<span className="font-medium text-slate-700 dark:text-slate-300">{roomLabel}</span>
|
<span className="font-medium text-slate-700 dark:text-slate-300">{roomLabel}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user