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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user