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