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

@@ -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<T>(
const data: unknown = await res.json()
if (!res.ok) {
const msg = (data as Record<string, string>)?.error ?? 'Request failed'
throw new ApiError(res.status, msg)
const d = data as Record<string, unknown>
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<ChecklistTemplate>('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<void>('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[]

View File

@@ -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() {
Добавить
</button>
</div>
{/* Require before complete toggle */}
<div
className={cn(
'flex items-center justify-between mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 px-1 py-1.5 rounded-lg cursor-pointer select-none',
tpl.requireBeforeComplete
? 'bg-amber-50 dark:bg-amber-900/10'
: 'hover:bg-slate-50 dark:hover:bg-slate-800/50',
)}
onClick={() => toggleRequire(tpl)}
>
<div className="flex items-center gap-2">
<ShieldAlert size={14} className={tpl.requireBeforeComplete ? 'text-amber-500' : 'text-slate-400'} />
<div>
<p className={cn('text-xs font-medium', tpl.requireBeforeComplete ? 'text-amber-700 dark:text-amber-400' : 'text-slate-600 dark:text-slate-400')}>
Требовать выполнения перед завершением уборки
</p>
<p className="text-xs text-slate-400">Горничная не сможет закрыть задачу, пока не отметит все пункты</p>
</div>
</div>
{tpl.requireBeforeComplete
? <ToggleRight size={18} className="text-amber-500 shrink-0" />
: <ToggleLeft size={18} className="text-slate-300 dark:text-slate-600 shrink-0" />}
</div>
</div>
</div>
))}

View File

@@ -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<void>
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<HTMLInputElement>(null)
const [completingOpen, setCompletingOpen] = useState(false)
const [completionComment, setCompletionComment] = useState('')
const [completionErrors, setCompletionErrors] = useState<string[]>([])
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 }: {
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300 flex items-center gap-1">
<CheckCircle2 size={11} className="text-emerald-500" /> Завершение уборки
</p>
<button onClick={() => setCompletingOpen(false)} className="text-slate-400 hover:text-slate-600">
<button onClick={() => { setCompletingOpen(false); setCompletionErrors([]) }} className="text-slate-400 hover:text-slate-600">
<XIcon size={13} />
</button>
</div>
{completionErrors.length > 0 && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-2 space-y-0.5">
{completionErrors.map((e, i) => (
<p key={i} className="text-xs text-red-700 dark:text-red-300 flex items-start gap-1">
<AlertTriangle size={11} className="mt-0.5 shrink-0" /> {e}
</p>
))}
</div>
)}
<textarea
autoFocus
className="w-full text-xs border border-slate-200 dark:border-slate-600 rounded-lg p-2 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 resize-none focus:outline-none focus:border-emerald-400"
@@ -899,21 +936,15 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
placeholder="Комментарий (необязательно)..."
value={completionComment}
onChange={e => setCompletionComment(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && e.ctrlKey) {
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
setCompletingOpen(false)
}
}}
onKeyDown={e => { if (e.key === 'Enter' && e.ctrlKey) handleComplete() }}
/>
<button
onClick={() => {
onStatusChange(task.id, 'done', completionComment.trim() || undefined)
setCompletingOpen(false)
}}
className="w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium transition-colors"
onClick={handleComplete}
disabled={completionSaving}
className="w-full flex items-center justify-center gap-1 text-xs py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
<CheckCircle2 size={11} /> Завершить уборку
{completionSaving ? <Loader2 size={11} className="animate-spin" /> : <CheckCircle2 size={11} />}
Завершить уборку
</button>
</div>
)}
@@ -1053,6 +1084,25 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
))}
</div>
)}
{/* Minibar confirmed button */}
{!minibarLoading && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-2">
<button
onClick={minibarCheckedLocal ? undefined : handleMinibarChecked}
disabled={minibarCheckedLocal}
className={cn(
'w-full text-xs py-1.5 rounded-lg font-medium flex items-center justify-center gap-1 transition-colors',
minibarCheckedLocal
? 'bg-emerald-50 dark:bg-emerald-900/10 text-emerald-600 dark:text-emerald-400 cursor-default'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/10 hover:text-emerald-600',
)}
>
<CheckCircle2 size={11} />
{minibarCheckedLocal ? 'Минибар проверен ✓' : 'Подтвердить проверку минибара'}
</button>
</div>
)}
</div>
)}
</div>

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart } from 'lucide-react'
import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart, ShieldAlert, ToggleLeft, ToggleRight } from 'lucide-react'
import { api, type MinibarItem } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils'
interface EditRow {
name: string
@@ -60,15 +61,32 @@ export function MinibarSettingsPage() {
const [adding, setAdding] = useState(false)
const [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '' })
const [addingRow, setAddingRow] = useState(false)
const [requireMinibarCheck, setRequireMinibarCheck] = useState(false)
useEffect(() => {
if (!slug) return
api.minibar.listItems(slug)
.then(setItems)
Promise.all([
api.minibar.listItems(slug),
api.checklists.getHousekeepingRules(slug).catch(() => ({ requireMinibarCheck: false })),
])
.then(([itemList, rules]) => {
setItems(itemList)
setRequireMinibarCheck(rules.requireMinibarCheck)
})
.catch(() => setError('Не удалось загрузить позиции минибара'))
.finally(() => setLoading(false))
}, [slug])
const toggleRequireMinibar = async () => {
const next = !requireMinibarCheck
setRequireMinibarCheck(next)
try {
await api.checklists.updateHousekeepingRules(slug, { requireMinibarCheck: next })
} catch {
setRequireMinibarCheck(!next) // revert
}
}
const handleAdd = async (v: EditRow) => {
if (!v.name.trim()) return
setAddingRow(true)
@@ -242,6 +260,39 @@ export function MinibarSettingsPage() {
)}
</div>
</div>
{/* Require minibar check setting */}
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="px-6 py-4 bg-slate-50 dark:bg-slate-800/50">
<h2 className="font-semibold text-slate-800 dark:text-slate-200">Контроль уборки</h2>
</div>
<div className="p-6 bg-white dark:bg-slate-900">
<div
className={cn(
'flex items-center justify-between p-4 rounded-xl border-2 cursor-pointer select-none transition-colors',
requireMinibarCheck
? 'border-amber-400 bg-amber-50 dark:bg-amber-900/10'
: 'border-slate-200 dark:border-slate-700 hover:border-slate-300',
)}
onClick={toggleRequireMinibar}
>
<div className="flex items-center gap-3">
<ShieldAlert size={18} className={requireMinibarCheck ? 'text-amber-500' : 'text-slate-400'} />
<div>
<p className={cn('font-medium text-sm', requireMinibarCheck ? 'text-amber-700 dark:text-amber-400' : 'text-slate-700 dark:text-slate-300')}>
Требовать проверки минибара перед завершением уборки
</p>
<p className="text-xs text-slate-400 mt-0.5">
Горничная обязана подтвердить проверку минибара (даже если ничего не потреблено)
</p>
</div>
</div>
{requireMinibarCheck
? <ToggleRight size={22} className="text-amber-500 shrink-0" />
: <ToggleLeft size={22} className="text-slate-300 dark:text-slate-600 shrink-0" />}
</div>
</div>
</div>
</div>
)
}

View File

@@ -225,6 +225,7 @@ export interface HousekeepingTask {
photos?: string[]
resolutionNotes?: string
resolutionPhotos?: string[]
minibarChecked?: boolean
}
// ─── API Docs ─────────────────────────────────────────────────────────────────