diff --git a/backend/migrations/060_require_completion.sql b/backend/migrations/060_require_completion.sql new file mode 100644 index 0000000..1095f01 --- /dev/null +++ b/backend/migrations/060_require_completion.sql @@ -0,0 +1,13 @@ +-- Настройка "требовать выполнения чек-листа перед завершением уборки" +ALTER TABLE checklist_templates + ADD COLUMN IF NOT EXISTS require_before_complete BOOLEAN NOT NULL DEFAULT false; + +-- Настройка "требовать проверки минибара перед завершением уборки" (уровень отеля) +CREATE TABLE IF NOT EXISTS hotel_housekeeping_rules ( + hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE, + require_minibar_check BOOLEAN NOT NULL DEFAULT false +); + +-- Флаг "горничная подтвердила проверку минибара" на задаче уборки +ALTER TABLE housekeeping_tasks + ADD COLUMN IF NOT EXISTS minibar_checked BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/src/routes/checklists.ts b/backend/src/routes/checklists.ts index 18cc5b7..b0acd8d 100644 --- a/backend/src/routes/checklists.ts +++ b/backend/src/routes/checklists.ts @@ -83,7 +83,7 @@ const checklists: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const allowed = ['name', 'task_type', 'is_active', 'sort_order'] + const allowed = ['name', 'task_type', 'is_active', 'sort_order', 'require_before_complete'] const updates: string[] = [] const values: unknown[] = [] let idx = 1 @@ -369,4 +369,48 @@ const checklists: FastifyPluginAsync = async (fastify) => { ) } + // ── GET /api/hotels/:slug/housekeeping-rules ────────────────────────────── + fastify.get( + '/api/hotels/:slug/housekeeping-rules', + { 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( + 'SELECT require_minibar_check FROM hotel_housekeeping_rules WHERE hotel_id = $1', + [hotelId], + ) + return { requireMinibarCheck: rows[0]?.require_minibar_check ?? false } + }, + ) + + // ── PATCH /api/hotels/:slug/housekeeping-rules ───────────────────────────── + fastify.patch( + '/api/hotels/:slug/housekeeping-rules', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { slug } = request.params + if (!canAccess(request.user.hotelSlug, request.user.role, slug) || !isManager(request.user.role)) { + return reply.code(403).send({ error: 'Forbidden' }) + } + const hotelId = await getHotelId(slug) + if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) + + const { require_minibar_check } = request.body + await db.query( + `INSERT INTO hotel_housekeeping_rules (hotel_id, require_minibar_check) + VALUES ($1, $2) + ON CONFLICT (hotel_id) DO UPDATE SET require_minibar_check = $2`, + [hotelId, require_minibar_check ?? false], + ) + return { requireMinibarCheck: require_minibar_check ?? false } + }, + ) +} + export default checklists diff --git a/backend/src/routes/housekeeping.ts b/backend/src/routes/housekeeping.ts index 02a4c04..c487170 100644 --- a/backend/src/routes/housekeeping.ts +++ b/backend/src/routes/housekeeping.ts @@ -159,7 +159,67 @@ const housekeeping: FastifyPluginAsync = async (fastify) => { updates.push(`completed_at = NULL`) } + // Also allow updating minibar_checked flag + if ((request.body as Record).minibar_checked !== undefined) { + updates.push(`minibar_checked = $${idx}`) + values.push((request.body as Record).minibar_checked) + idx++ + } + if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' }) + + // ── Validate completion requirements ────────────────────────────────── + if (request.body.status === 'done') { + // Get current task (need task_type, minibar_checked, room_id) + const { rows: taskRows } = await db.query( + `SELECT t.*, t.minibar_checked FROM housekeeping_tasks t WHERE t.id = $1 AND t.hotel_id = $2`, + [id, hotelId], + ) + const currentTask = taskRows[0] + if (!currentTask) return reply.code(404).send({ error: 'Task not found' }) + + // Merge minibar_checked from request body (may be set in same request) + const minibarCheckedNow = (request.body as Record).minibar_checked ?? currentTask.minibar_checked + + const errors: string[] = [] + + // 1. Check checklist requirements + const { rows: templates } = await db.query( + `SELECT id, name FROM checklist_templates + WHERE hotel_id = $1 AND is_active = true AND require_before_complete = true + AND (task_type IS NULL OR task_type = $2)`, + [hotelId, currentTask.type ?? null], + ) + + for (const tpl of templates) { + const { rows: items } = await db.query( + `SELECT ci.id, ci.text, + cc.id IS NOT NULL AS is_completed + FROM checklist_items ci + LEFT JOIN checklist_completions cc ON cc.item_id = ci.id AND cc.task_id = $1 + WHERE ci.template_id = $2 + ORDER BY ci.sort_order`, + [id, tpl.id], + ) + const unchecked = items.filter((i: Record) => !i.is_completed) + if (unchecked.length > 0) { + errors.push(`Чек-лист «${tpl.name}»: не выполнено ${unchecked.length} из ${items.length} пунктов`) + } + } + + // 2. Check minibar requirement + const { rows: rules } = await db.query( + `SELECT require_minibar_check FROM hotel_housekeeping_rules WHERE hotel_id = $1`, + [hotelId], + ) + if (rules[0]?.require_minibar_check && !minibarCheckedNow) { + errors.push('Минибар не проверен') + } + + if (errors.length > 0) { + return reply.code(422).send({ error: errors.join('; '), errors }) + } + } values.push(id, hotelId) const { rows: updated } = await db.query( diff --git a/src/lib/api.ts b/src/lib/api.ts index a27631e..93ce70a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,8 +7,10 @@ const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://ap // ── Errors ────────────────────────────────────────────────────────────────── export class ApiError extends Error { - constructor(public status: number, message: string) { + errors?: string[] + constructor(public status: number, message: string, errors?: string[]) { super(message) + this.errors = errors } } @@ -115,8 +117,10 @@ async function req( const data: unknown = await res.json() if (!res.ok) { - const msg = (data as Record)?.error ?? 'Request failed' - throw new ApiError(res.status, msg) + const d = data as Record + const msg = (d?.error as string) ?? 'Request failed' + const errors = Array.isArray(d?.errors) ? (d.errors as string[]) : undefined + throw new ApiError(res.status, msg, errors) } return transformKeys(data) as T @@ -652,12 +656,13 @@ export const api = { task_type: data.taskType ?? null, }), - updateTemplate: (slug: string, templateId: string, data: { name?: string; taskType?: string | null; isActive?: boolean; sortOrder?: number }) => + updateTemplate: (slug: string, templateId: string, data: { name?: string; taskType?: string | null; isActive?: boolean; sortOrder?: number; requireBeforeComplete?: boolean }) => req('PATCH', `/api/hotels/${slug}/checklist-templates/${templateId}`, { name: data.name, task_type: data.taskType, is_active: data.isActive, sort_order: data.sortOrder, + require_before_complete: data.requireBeforeComplete, }), deleteTemplate: (slug: string, templateId: string) => @@ -686,6 +691,14 @@ export const api = { uncompleteItem: (slug: string, taskId: string, itemId: string) => req('DELETE', `/api/hotels/${slug}/housekeeping/${taskId}/checklist/${itemId}/complete`), + + getHousekeepingRules: (slug: string) => + req<{ requireMinibarCheck: boolean }>('GET', `/api/hotels/${slug}/housekeeping-rules`), + + updateHousekeepingRules: (slug: string, data: { requireMinibarCheck: boolean }) => + req<{ requireMinibarCheck: boolean }>('PATCH', `/api/hotels/${slug}/housekeeping-rules`, { + require_minibar_check: data.requireMinibarCheck, + }), }, // ── Minibar ─────────────────────────────────────────────────────────────── @@ -842,6 +855,7 @@ export interface HkPayload { status?: string; assignee_id?: string; notes?: string; due_date?: string category?: string; photos?: string[] resolution_notes?: string; resolution_photos?: string[] + minibar_checked?: boolean } export interface HkSettings { @@ -1280,6 +1294,7 @@ export interface ChecklistTemplate { name: string taskType: string | null isActive: boolean + requireBeforeComplete: boolean sortOrder: number createdAt: string items: ChecklistItem[] diff --git a/src/pages/ChecklistSettingsPage.tsx b/src/pages/ChecklistSettingsPage.tsx index cc9511b..f6fbdb7 100644 --- a/src/pages/ChecklistSettingsPage.tsx +++ b/src/pages/ChecklistSettingsPage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, Check, X, Loader2, ListChecks, ToggleLeft, ToggleRight } from 'lucide-react' +import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, Check, X, Loader2, ListChecks, ToggleLeft, ToggleRight, ShieldAlert } from 'lucide-react' import { api, type ChecklistTemplate, type ChecklistItem } from '../lib/api' import { useAuth } from '../contexts/AuthContext' import { cn } from '../lib/utils' @@ -97,6 +97,13 @@ export function ChecklistSettingsPage() { } catch { /* ignore */ } } + const toggleRequire = async (t: ChecklistTemplate) => { + try { + await api.checklists.updateTemplate(slug, t.id, { requireBeforeComplete: !t.requireBeforeComplete }) + setTemplates(prev => prev.map(x => x.id === t.id ? { ...x, requireBeforeComplete: !x.requireBeforeComplete } : x)) + } catch { /* ignore */ } + } + const deleteTemplate = async (id: string) => { if (!confirm('Удалить шаблон вместе со всеми пунктами?')) return try { @@ -323,6 +330,30 @@ export function ChecklistSettingsPage() { Добавить + + {/* Require before complete toggle */} +
toggleRequire(tpl)} + > +
+ +
+

+ Требовать выполнения перед завершением уборки +

+

Горничная не сможет закрыть задачу, пока не отметит все пункты

+
+
+ {tpl.requireBeforeComplete + ? + : } +
))} diff --git a/src/pages/HousekeepingPage.tsx b/src/pages/HousekeepingPage.tsx index d6285d4..49bc4cd 100644 --- a/src/pages/HousekeepingPage.tsx +++ b/src/pages/HousekeepingPage.tsx @@ -4,7 +4,7 @@ import type { TaskChecklist, MinibarItem, MinibarConsumption } from '../lib/api' import { useAuth } from '../contexts/AuthContext' import { useHotelSocket } from '../hooks/useHotelSocket' import type { WsMessage } from '../hooks/useHotelSocket' -import { api } from '../lib/api' +import { api, ApiError } from '../lib/api' import type { HkSettings } from '../lib/api' import type { HousekeepingTask } from '../types' import { cn } from '../lib/utils' @@ -199,6 +199,7 @@ export function HousekeepingPage() { setActiveTasks(prev => prev.map(t => t.id === id ? updated : t)) } } catch (err) { + if (err instanceof ApiError && err.status === 422) throw err console.error('Failed to update task:', err) } } @@ -626,7 +627,7 @@ const SEVERITY_CONFIG = { function TaskCard({ task, onStatusChange, onReport, slug }: { task: HousekeepingTask slug: string - onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => void + onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => Promise onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void }) { const [reportOpen, setReportOpen] = useState(false) @@ -637,6 +638,9 @@ function TaskCard({ task, onStatusChange, onReport, slug }: { const photoInputRef = useRef(null) const [completingOpen, setCompletingOpen] = useState(false) const [completionComment, setCompletionComment] = useState('') + const [completionErrors, setCompletionErrors] = useState([]) + const [completionSaving, setCompletionSaving] = useState(false) + const [minibarCheckedLocal, setMinibarCheckedLocal] = useState(task.minibarChecked ?? false) // Checklist + Minibar detail panel const [detailOpen, setDetailOpen] = useState(false) @@ -719,6 +723,30 @@ function TaskCard({ task, onStatusChange, onReport, slug }: { } catch { /* ignore */ } } + const handleMinibarChecked = async () => { + setMinibarCheckedLocal(true) + try { + await api.housekeeping.update(slug, task.id, { minibar_checked: true }) + } catch { + setMinibarCheckedLocal(false) + } + } + + const handleComplete = async () => { + setCompletionErrors([]) + setCompletionSaving(true) + try { + await onStatusChange(task.id, 'done', completionComment.trim() || undefined) + setCompletingOpen(false) + } catch (err) { + if (err instanceof ApiError && err.errors) { + setCompletionErrors(err.errors) + } + } finally { + setCompletionSaving(false) + } + } + const uploadReportPhoto = async (file: File) => { setPhotoUploading(true) try { @@ -888,10 +916,19 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {

Завершение уборки

- + {completionErrors.length > 0 && ( +
+ {completionErrors.map((e, i) => ( +

+ {e} +

+ ))} +
+ )}