feat: TaskCard step-by-step UX — auto-open checklist, minibar item inline, labeled buttons

- Auto-open checklist when task is in_progress (on mount)
- Detect 'минибар' checklist item: tap opens inline minibar selection
  - Record consumptions + confirm with single button
  - Auto-checks the item + sets minibar_checked
- Replace icon-only buttons with labeled: 'Замечание' + 'Завершить уборку'
- 'Замечание' always accessible without completing cleaning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 09:56:25 +03:00
parent d6bb46a67f
commit 6feef47e51

View File

@@ -652,6 +652,25 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
const [minibarLoading, setMinibarLoading] = useState(false)
const [minibarQty, setMinibarQty] = useState<Record<string, number>>({})
const [minibarSaving, setMinibarSaving] = useState(false)
const [minibarItemId, setMinibarItemId] = useState<string | null>(null) // checklist item id named "минибар"
const [minibarItemExpanded, setMinibarItemExpanded] = useState(false)
// Auto-open checklist when task is in progress
useEffect(() => {
if (task.status === 'in_progress') {
setDetailOpen(true)
setDetailTab('checklist')
setChecklistLoading(true)
api.checklists.getTaskChecklist(slug, task.id)
.then(cl => {
setChecklist(cl)
const mb = cl.items.find(i => /минибар/i.test(i.text))
setMinibarItemId(mb?.id ?? null)
})
.catch(() => {})
.finally(() => setChecklistLoading(false))
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const openDetail = async (tab: 'checklist' | 'minibar') => {
setDetailTab(tab)
@@ -661,6 +680,8 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
try {
const cl = await api.checklists.getTaskChecklist(slug, task.id)
setChecklist(cl)
const mb = cl.items.find(i => /минибар/i.test(i.text))
setMinibarItemId(mb?.id ?? null)
} catch { /* ignore */ } finally {
setChecklistLoading(false)
}
@@ -680,6 +701,43 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
}
}
const openMinibarForItem = async () => {
setMinibarItemExpanded(true)
if (minibarItems.length === 0) {
setMinibarLoading(true)
try {
const [items, cons] = await Promise.all([
api.minibar.listItems(slug),
api.minibar.getTaskConsumptions(slug, task.id),
])
setMinibarItems(items)
setConsumptions(cons)
} catch { /* ignore */ } finally {
setMinibarLoading(false)
}
}
}
const confirmMinibarFromChecklist = async () => {
if (Object.values(minibarQty).some(q => q > 0)) await submitMinibar()
setMinibarCheckedLocal(true)
try {
await api.housekeeping.update(slug, task.id, { minibar_checked: true })
} catch { setMinibarCheckedLocal(false) }
if (minibarItemId) {
try {
await api.checklists.completeItem(slug, task.id, minibarItemId)
setChecklist(prev => prev ? {
...prev,
items: prev.items.map(i => i.id === minibarItemId
? { ...i, completedAt: new Date().toISOString(), completedBy: 'Вы' }
: i),
} : null)
} catch { /* ignore */ }
}
setMinibarItemExpanded(false)
}
const toggleChecklistItem = async (itemId: string, completed: boolean) => {
if (!checklist) return
try {
@@ -991,6 +1049,74 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
)}
{!checklistLoading && checklist && checklist.items.map(item => {
const done = !!item.completedAt
const isMbItem = item.id === minibarItemId
// Special minibar checklist item
if (isMbItem) {
return (
<div key={item.id} className={cn('rounded-lg transition-colors', done ? 'bg-emerald-50 dark:bg-emerald-900/10' : minibarItemExpanded ? 'bg-brand-50 dark:bg-brand-900/10' : '')}>
<button
onClick={() => {
if (done) {
toggleChecklistItem(item.id, true)
setMinibarItemExpanded(false)
} else {
if (minibarItemExpanded) setMinibarItemExpanded(false)
else openMinibarForItem()
}
}}
className="w-full text-left flex items-center gap-2 p-1.5"
>
<div className={cn('w-4 h-4 rounded border-2 shrink-0 flex items-center justify-center transition-colors', done ? 'bg-emerald-500 border-emerald-500' : minibarItemExpanded ? 'border-brand-400' : 'border-slate-300 dark:border-slate-500')}>
{done && <CheckCircle2 size={10} className="text-white" />}
</div>
<span className={cn('flex-1 text-xs', done ? 'line-through text-slate-400' : 'text-slate-700 dark:text-slate-300')}>{item.text}</span>
{!done && <ChevronRight size={12} className={cn('text-slate-400 transition-transform', minibarItemExpanded && 'rotate-90')} />}
{done && minibarCheckedLocal && <span className="text-[10px] text-emerald-600"> проверен</span>}
</button>
{minibarItemExpanded && !done && (
<div className="px-2 pb-2 space-y-1.5">
{minibarLoading && <div className="flex justify-center py-3"><Loader2 size={14} className="animate-spin text-brand-600" /></div>}
{!minibarLoading && minibarItems.map(mi => {
const qty = minibarQty[mi.id] ?? 0
return (
<div key={mi.id} className="flex items-center gap-2">
<span className="flex-1 text-xs text-slate-700 dark:text-slate-300 truncate">{mi.name}</span>
<span className="text-xs text-slate-400 shrink-0">{Number(mi.price).toFixed(0)} руб.</span>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => setMinibarQty(prev => ({ ...prev, [mi.id]: Math.max(0, (prev[mi.id] ?? 0) - 1) }))} disabled={qty === 0} className="w-6 h-6 rounded bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-slate-500 hover:bg-slate-200 disabled:opacity-30"><Minus size={10} /></button>
<span className="w-5 text-center text-xs font-medium text-slate-700 dark:text-slate-300">{qty}</span>
<button onClick={() => setMinibarQty(prev => ({ ...prev, [mi.id]: (prev[mi.id] ?? 0) + 1 }))} className="w-6 h-6 rounded bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-slate-500 hover:bg-slate-200"><Plus size={10} /></button>
</div>
</div>
)
})}
{consumptions.length > 0 && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-1.5 space-y-0.5">
<p className="text-[10px] text-slate-400 font-medium">Уже записано:</p>
{consumptions.map(c => (
<div key={c.id} className="flex items-center gap-2 text-xs text-slate-500">
<span className="flex-1">{c.itemName} × {c.quantity}</span>
<button onClick={() => deleteConsumption(c.id)} className="text-slate-300 hover:text-red-500"><XIcon size={10} /></button>
</div>
))}
</div>
)}
<button
onClick={confirmMinibarFromChecklist}
disabled={minibarSaving}
className="w-full text-xs py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white font-medium flex items-center justify-center gap-1 mt-1 disabled:opacity-60"
>
{minibarSaving ? <Loader2 size={11} className="animate-spin" /> : <CheckCircle2 size={11} />}
{Object.values(minibarQty).some(q => q > 0) ? 'Записать и подтвердить' : 'Ничего не потреблено — подтвердить'}
</button>
</div>
)}
</div>
)
}
// Regular checklist item
return (
<button
key={item.id}
@@ -1000,19 +1126,12 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
done ? 'bg-emerald-50 dark:bg-emerald-900/10' : 'hover:bg-slate-50 dark:hover:bg-slate-700/50',
)}
>
<div className={cn(
'w-4 h-4 rounded border-2 shrink-0 mt-0.5 flex items-center justify-center transition-colors',
done ? 'bg-emerald-500 border-emerald-500' : 'border-slate-300 dark:border-slate-500',
)}>
<div className={cn('w-4 h-4 rounded border-2 shrink-0 mt-0.5 flex items-center justify-center transition-colors', done ? 'bg-emerald-500 border-emerald-500' : 'border-slate-300 dark:border-slate-500')}>
{done && <CheckCircle2 size={10} className="text-white" />}
</div>
<div className="flex-1 min-w-0">
<span className={cn('text-xs', done ? 'line-through text-slate-400' : 'text-slate-700 dark:text-slate-300')}>
{item.text}
</span>
{done && item.completedBy && (
<span className="block text-[10px] text-slate-400 dark:text-slate-500">{item.completedBy}</span>
)}
<span className={cn('text-xs', done ? 'line-through text-slate-400' : 'text-slate-700 dark:text-slate-300')}>{item.text}</span>
{done && item.completedBy && <span className="block text-[10px] text-slate-400 dark:text-slate-500">{item.completedBy}</span>}
</div>
</button>
)
@@ -1117,36 +1236,26 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
Начать
</button>
)}
{task.status === 'in_progress' && !completingOpen && (
<button
onClick={() => { setCompletingOpen(true); setReportOpen(false) }}
className="flex-1 text-xs py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 font-medium hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors"
>
Завершить
</button>
)}
{!reportOpen && !completingOpen && (
<button
onClick={() => openDetail(detailOpen && detailTab === 'checklist' ? 'minibar' : 'checklist')}
title="Чек-лист / Минибар"
className="text-xs py-1.5 px-2.5 rounded-lg font-medium transition-colors flex items-center gap-1 shrink-0 bg-slate-50 dark:bg-slate-700 text-slate-500 dark:text-slate-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 hover:text-brand-600"
>
<ListChecks size={11} />
</button>
)}
{!reportOpen && !completingOpen && (
<button
onClick={() => setReportOpen(true)}
title="Сообщить о неисправности"
className={cn(
'text-xs py-1.5 px-2.5 rounded-lg font-medium transition-colors flex items-center gap-1 shrink-0',
task.maintenanceNote
? `${sev?.bg} ${sev?.text}`
: 'bg-slate-50 dark:bg-slate-700 text-slate-500 dark:text-slate-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 hover:text-orange-600',
)}
>
<Wrench size={11} />
</button>
{task.status === 'in_progress' && !completingOpen && !reportOpen && (
<>
<button
onClick={() => setReportOpen(true)}
className={cn(
'text-xs py-1.5 px-3 rounded-lg font-medium transition-colors flex items-center gap-1 shrink-0',
task.maintenanceNote
? `${sev?.bg} ${sev?.text} border ${sev?.border}`
: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 hover:text-orange-600',
)}
>
<Wrench size={11} /> Замечание
</button>
<button
onClick={() => { setCompletingOpen(true) }}
className="flex-1 text-xs py-1.5 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 font-medium hover:bg-emerald-100 dark:hover:bg-emerald-900/30 transition-colors flex items-center justify-center gap-1"
>
<CheckCircle2 size={11} /> Завершить уборку
</button>
</>
)}
</div>
</div>