feat: enforce checklist/minibar completion before marking task done

- ApiError now carries errors[] array from 422 responses
- updateStatus re-throws 422 so TaskCard can surface errors
- TaskCard: minibarCheckedLocal state + 'Подтвердить проверку минибара' button
- TaskCard: async handleComplete with completionErrors display
- Backend validation errors shown inline in completion panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 23:09:03 +03:00
parent 3c1a6ccebf
commit d98389e2bc
8 changed files with 289 additions and 24 deletions

View File

@@ -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<string, unknown>).minibar_checked !== undefined) {
updates.push(`minibar_checked = $${idx}`)
values.push((request.body as Record<string, unknown>).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<string, unknown>).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<string, unknown>) => !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(