feat: minibar category rename + history card checklist/minibar details

- MinibarSettingsPage: Категории section — inline rename updates all items
- HistoryCard: expand button loads checklist + minibar on demand
- Minibar ✓ badge shown on history cards when minibarChecked

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 23:27:30 +03:00
parent cc7c29abc9
commit 8e6d66bef8
2 changed files with 158 additions and 9 deletions

View File

@@ -371,7 +371,7 @@ export function HousekeepingPage() {
) : ( ) : (
<div className="space-y-2.5"> <div className="space-y-2.5">
{historyTasks.map(task => ( {historyTasks.map(task => (
<HistoryCard key={task.id} task={task} /> <HistoryCard key={task.id} task={task} slug={slug} />
))} ))}
</div> </div>
)} )}
@@ -1153,21 +1153,43 @@ function TaskCard({ task, onStatusChange, onReport, slug }: {
) )
} }
function HistoryCard({ task }: { task: HousekeepingTask }) { function HistoryCard({ task, slug }: { task: HousekeepingTask; slug: string }) {
const taskAny = task as unknown as Record<string, string> const taskAny = task as unknown as Record<string, string>
const isMaintenance = task.type === 'maintenance' || (taskAny.category === 'maintenance') const isMaintenance = task.type === 'maintenance' || (taskAny.category === 'maintenance')
const isActive = task.status === 'pending' || task.status === 'in_progress' const isActive = task.status === 'pending' || task.status === 'in_progress'
const [expanded, setExpanded] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [checklist, setChecklist] = useState<TaskChecklist | null>(null)
const [consumptions, setConsumptions] = useState<MinibarConsumption[] | null>(null)
const toggleExpand = async () => {
if (!expanded && checklist === null) {
setDetailLoading(true)
try {
const [cl, cons] = await Promise.all([
api.checklists.getTaskChecklist(slug, task.id),
api.minibar.getTaskConsumptions(slug, task.id),
])
setChecklist(cl)
setConsumptions(cons)
} catch { /* ignore */ } finally {
setDetailLoading(false)
}
}
setExpanded(e => !e)
}
const completedTime = task.completedAt const completedTime = task.completedAt
? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) ? new Date(task.completedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
: null : null
// Duration: started_at → completed_at
const durationMin = task.startedAt && task.completedAt const durationMin = task.startedAt && task.completedAt
? Math.round((new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime()) / 60000) ? Math.round((new Date(task.completedAt).getTime() - new Date(task.startedAt).getTime()) / 60000)
: null : null
const photos = task.photos ?? [] const photos = task.photos ?? []
const minibarTotal = consumptions?.reduce((s, c) => s + Number(c.pricePerUnit) * c.quantity, 0) ?? 0
return ( return (
<div className={cn('card p-3.5', isMaintenance && 'border-l-4 border-l-orange-400 dark:border-l-orange-500', !isActive && 'opacity-90')}> <div className={cn('card p-3.5', isMaintenance && 'border-l-4 border-l-orange-400 dark:border-l-orange-500', !isActive && 'opacity-90')}>
@@ -1228,20 +1250,88 @@ function HistoryCard({ task }: { task: HousekeepingTask }) {
: `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`} : `${Math.floor(durationMin / 60)} ч ${durationMin % 60} мин`}
</span> </span>
)} )}
{task.minibarChecked && (
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<ShoppingCart size={11} /> Минибар
</span>
)}
</div> </div>
{photos.length > 0 && ( {photos.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{photos.map(url => ( {photos.map(url => (
<a key={url} href={url} target="_blank" rel="noreferrer"> <a key={url} href={url} target="_blank" rel="noreferrer">
<img <img src={url} alt="" className="w-14 h-14 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-80 transition-opacity" />
src={url}
alt=""
className="w-14 h-14 object-cover rounded-lg border border-slate-200 dark:border-slate-600 hover:opacity-80 transition-opacity"
/>
</a> </a>
))} ))}
</div> </div>
)} )}
{/* Expand button */}
<button
onClick={toggleExpand}
className="mt-2 flex items-center gap-1 text-[11px] text-slate-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
>
{detailLoading
? <Loader2 size={11} className="animate-spin" />
: <ChevronRight size={11} className={cn('transition-transform', expanded && 'rotate-90')} />}
{expanded ? 'Скрыть детали' : 'Чек-лист и минибар'}
</button>
{/* Expanded details */}
{expanded && !detailLoading && (
<div className="mt-2 space-y-2.5 border-t border-slate-100 dark:border-slate-700 pt-2.5">
{/* Checklist */}
{checklist && checklist.items.length > 0 && (() => {
const done = checklist.items.filter(i => i.completedAt).length
const total = checklist.items.length
return (
<div className="space-y-1">
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide flex items-center gap-1">
<ListChecks size={10} />
Чек-лист
<span className={cn('ml-1 px-1.5 py-0.5 rounded text-[10px]', done === total ? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300' : 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400')}>
{done}/{total}
</span>
</p>
{checklist.items.map(item => (
<div key={item.id} className="flex items-center gap-1.5 text-xs">
<div className={cn('w-3.5 h-3.5 rounded-sm border flex items-center justify-center shrink-0', item.completedAt ? 'bg-emerald-500 border-emerald-500' : 'border-slate-300 dark:border-slate-500')}>
{item.completedAt && <CheckCircle2 size={9} className="text-white" />}
</div>
<span className={cn(item.completedAt ? 'line-through text-slate-400' : 'text-slate-600 dark:text-slate-300')}>{item.text}</span>
</div>
))}
</div>
)
})()}
{checklist && checklist.items.length === 0 && (
<p className="text-xs text-slate-400">Чек-лист не настроен</p>
)}
{/* Minibar */}
<div className="space-y-1">
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide flex items-center gap-1">
<ShoppingCart size={10} /> Минибар
</p>
{consumptions && consumptions.length > 0 ? (
<>
{consumptions.map(c => (
<div key={c.id} className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
<span className="flex-1">{c.itemName} × {c.quantity}</span>
<span className="shrink-0 font-medium">{(Number(c.pricePerUnit) * c.quantity).toFixed(0)} руб.</span>
</div>
))}
<div className="flex items-center justify-between text-xs font-semibold text-slate-700 dark:text-slate-300 pt-1 border-t border-slate-100 dark:border-slate-700">
<span>Итого</span>
<span>{minibarTotal.toFixed(0)} руб.</span>
</div>
</>
) : (
<p className="text-xs text-slate-400">{task.minibarChecked ? 'Проверен, расхода нет' : 'Нет данных'}</p>
)}
</div>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart, ShieldAlert, ToggleLeft, ToggleRight } from 'lucide-react' import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart, ShieldAlert, ToggleLeft, ToggleRight, Tag } from 'lucide-react'
import { api, type MinibarItem } from '../lib/api' import { api, type MinibarItem } from '../lib/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
@@ -62,6 +62,8 @@ export function MinibarSettingsPage() {
const [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '' }) const [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '' })
const [addingRow, setAddingRow] = useState(false) const [addingRow, setAddingRow] = useState(false)
const [requireMinibarCheck, setRequireMinibarCheck] = useState(false) const [requireMinibarCheck, setRequireMinibarCheck] = useState(false)
const [editingCat, setEditingCat] = useState<string | null>(null)
const [editCatValue, setEditCatValue] = useState('')
useEffect(() => { useEffect(() => {
if (!slug) return if (!slug) return
@@ -87,6 +89,19 @@ export function MinibarSettingsPage() {
} }
} }
const renameCategory = async (oldName: string, newName: string) => {
const trimmed = newName.trim()
if (!trimmed || trimmed === oldName) { setEditingCat(null); return }
const toUpdate = items.filter(i => i.category === oldName)
try {
for (const item of toUpdate) {
await api.minibar.updateItem(slug, item.id, { name: item.name, price: item.price, category: trimmed })
}
setItems(prev => prev.map(i => i.category === oldName ? { ...i, category: trimmed } : i))
} catch { /* ignore */ }
setEditingCat(null)
}
const handleAdd = async (v: EditRow) => { const handleAdd = async (v: EditRow) => {
if (!v.name.trim()) return if (!v.name.trim()) return
setAddingRow(true) setAddingRow(true)
@@ -261,6 +276,50 @@ export function MinibarSettingsPage() {
</div> </div>
</div> </div>
{/* Category management */}
{items.length > 0 && (() => {
const cats = [...new Set(items.map(i => i.category).filter(Boolean) as string[])]
if (cats.length === 0) return null
return (
<div className="card overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex items-center gap-2">
<Tag size={14} className="text-slate-500" />
<span className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Категории</span>
</div>
<div className="p-4 space-y-1.5">
{cats.map(cat => (
<div key={cat} className="flex items-center gap-2 group px-1 py-1 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">
{editingCat === cat ? (
<>
<input
autoFocus
value={editCatValue}
onChange={e => setEditCatValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') renameCategory(cat, editCatValue); if (e.key === 'Escape') setEditingCat(null) }}
className="input flex-1 py-1 text-sm"
/>
<button onClick={() => renameCategory(cat, editCatValue)} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"><Check size={13} /></button>
<button onClick={() => setEditingCat(null)} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"><X size={13} /></button>
</>
) : (
<>
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300">{cat}</span>
<span className="text-xs text-slate-400">{items.filter(i => i.category === cat).length} поз.</span>
<button
onClick={() => { setEditingCat(cat); setEditCatValue(cat) }}
className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Pencil size={13} />
</button>
</>
)}
</div>
))}
</div>
</div>
)
})()}
{/* Require minibar check setting */} {/* Require minibar check setting */}
<div className="border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden"> <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"> <div className="px-6 py-4 bg-slate-50 dark:bg-slate-800/50">