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