feat: checklists, minibar and deposit modules
- DB migrations 057-059: checklist_templates/items/completions, minibar_items/consumptions, hotel_deposit_settings, booking_deposits - Backend routes: checklists (templates CRUD + task completions), minibar (items + consumptions), deposit (settings, cash/yookassa hold, release/capture) - YooKassa service for hold/capture/cancel payments - Frontend: ChecklistSettingsPage, MinibarSettingsPage, DepositSettingsPage - HousekeepingPage: task cards now show checklist + minibar panel with checkboxes and quantity buttons - BookingModal: minibar charges summary + deposit management (cash/YooKassa/release) for existing bookings - Sidebar + App.tsx: new routes /settings/checklists, /settings/minibar, /settings/deposit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
332
src/pages/ChecklistSettingsPage.tsx
Normal file
332
src/pages/ChecklistSettingsPage.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Trash2, ChevronUp, ChevronDown, Pencil, Check, X, Loader2, ListChecks, ToggleLeft, ToggleRight } from 'lucide-react'
|
||||
import { api, type ChecklistTemplate, type ChecklistItem } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const TASK_TYPE_LABELS: Record<string, string> = {
|
||||
checkout: 'После выезда',
|
||||
daily: 'Ежедневная',
|
||||
deep: 'Генеральная',
|
||||
}
|
||||
|
||||
function InlineEdit({ value, onSave, onCancel }: {
|
||||
value: string
|
||||
onSave: (v: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [v, setV] = useState(value)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 flex-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={v}
|
||||
onChange={e => setV(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') onSave(v.trim())
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}}
|
||||
className="input flex-1 py-1 text-sm"
|
||||
/>
|
||||
<button onClick={() => onSave(v.trim())} className="p-1 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button onClick={onCancel} className="p-1 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChecklistSettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
|
||||
const [templates, setTemplates] = useState<ChecklistTemplate[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// New template form
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newType, setNewType] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
// Editing states
|
||||
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
|
||||
const [editingItemId, setEditingItemId] = useState<string | null>(null)
|
||||
const [newItemText, setNewItemText] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
api.checklists.listTemplates(slug)
|
||||
.then(t => setTemplates(t))
|
||||
.catch(() => setError('Не удалось загрузить шаблоны'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
const addTemplate = async () => {
|
||||
if (!newName.trim()) return
|
||||
setAdding(true)
|
||||
try {
|
||||
const tpl = await api.checklists.createTemplate(slug, {
|
||||
name: newName.trim(),
|
||||
taskType: newType || undefined,
|
||||
})
|
||||
setTemplates(prev => [...prev, tpl])
|
||||
setNewName('')
|
||||
setNewType('')
|
||||
} catch {
|
||||
setError('Не удалось создать шаблон')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateTemplateName = async (id: string, name: string) => {
|
||||
try {
|
||||
const updated = await api.checklists.updateTemplate(slug, id, { name })
|
||||
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: updated.name } : t))
|
||||
} catch { /* ignore */ }
|
||||
setEditingTemplateId(null)
|
||||
}
|
||||
|
||||
const toggleActive = async (t: ChecklistTemplate) => {
|
||||
try {
|
||||
const updated = await api.checklists.updateTemplate(slug, t.id, { isActive: !t.isActive })
|
||||
setTemplates(prev => prev.map(x => x.id === t.id ? { ...x, isActive: updated.isActive } : x))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const deleteTemplate = async (id: string) => {
|
||||
if (!confirm('Удалить шаблон вместе со всеми пунктами?')) return
|
||||
try {
|
||||
await api.checklists.deleteTemplate(slug, id)
|
||||
setTemplates(prev => prev.filter(t => t.id !== id))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const addItem = async (templateId: string) => {
|
||||
const text = newItemText[templateId]?.trim()
|
||||
if (!text) return
|
||||
try {
|
||||
const item = await api.checklists.addItem(slug, templateId, { text })
|
||||
setTemplates(prev => prev.map(t => t.id === templateId
|
||||
? { ...t, items: [...t.items, item] }
|
||||
: t,
|
||||
))
|
||||
setNewItemText(prev => ({ ...prev, [templateId]: '' }))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const updateItemText = async (templateId: string, item: ChecklistItem, text: string) => {
|
||||
try {
|
||||
const updated = await api.checklists.updateItem(slug, templateId, item.id, { text })
|
||||
setTemplates(prev => prev.map(t => t.id === templateId
|
||||
? { ...t, items: t.items.map(i => i.id === item.id ? { ...i, text: updated.text } : i) }
|
||||
: t,
|
||||
))
|
||||
} catch { /* ignore */ }
|
||||
setEditingItemId(null)
|
||||
}
|
||||
|
||||
const deleteItem = async (templateId: string, itemId: string) => {
|
||||
try {
|
||||
await api.checklists.deleteItem(slug, templateId, itemId)
|
||||
setTemplates(prev => prev.map(t => t.id === templateId
|
||||
? { ...t, items: t.items.filter(i => i.id !== itemId) }
|
||||
: t,
|
||||
))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const moveItem = async (templateId: string, itemId: string, dir: 'up' | 'down') => {
|
||||
const tpl = templates.find(t => t.id === templateId)
|
||||
if (!tpl) return
|
||||
const idx = tpl.items.findIndex(i => i.id === itemId)
|
||||
if (dir === 'up' && idx === 0) return
|
||||
if (dir === 'down' && idx === tpl.items.length - 1) return
|
||||
|
||||
const newItems = [...tpl.items]
|
||||
const swapIdx = dir === 'up' ? idx - 1 : idx + 1
|
||||
;[newItems[idx], newItems[swapIdx]] = [newItems[swapIdx], newItems[idx]]
|
||||
|
||||
// Update sort_order for both
|
||||
const a = newItems[idx]
|
||||
const b = newItems[swapIdx]
|
||||
const sortA = a.sortOrder
|
||||
const sortB = b.sortOrder
|
||||
|
||||
setTemplates(prev => prev.map(t => t.id === templateId ? { ...t, items: newItems } : t))
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
api.checklists.updateItem(slug, templateId, a.id, { sortOrder: sortA }),
|
||||
api.checklists.updateItem(slug, templateId, b.id, { sortOrder: sortB }),
|
||||
])
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<ListChecks size={22} className="text-brand-600" />
|
||||
Шаблоны чек-листов
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Настройте пункты проверки для задач уборки. Шаблон применяется автоматически по типу задачи.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add template form */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<h3 className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Новый шаблон</h3>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
placeholder="Название шаблона"
|
||||
className="input flex-1"
|
||||
onKeyDown={e => e.key === 'Enter' && addTemplate()}
|
||||
/>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={e => setNewType(e.target.value)}
|
||||
className="input w-44"
|
||||
>
|
||||
<option value="">Любой тип</option>
|
||||
<option value="checkout">После выезда</option>
|
||||
<option value="daily">Ежедневная</option>
|
||||
<option value="deep">Генеральная</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={addTemplate}
|
||||
disabled={!newName.trim() || adding}
|
||||
className="btn-primary flex items-center gap-1.5"
|
||||
>
|
||||
{adding ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Templates list */}
|
||||
<div className="space-y-4">
|
||||
{templates.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400 dark:text-slate-500">
|
||||
Нет шаблонов. Создайте первый шаблон чек-листа.
|
||||
</div>
|
||||
)}
|
||||
{templates.map(tpl => (
|
||||
<div key={tpl.id} className={cn(
|
||||
'border rounded-xl overflow-hidden',
|
||||
tpl.isActive
|
||||
? 'border-slate-200 dark:border-slate-700'
|
||||
: 'border-dashed border-slate-200 dark:border-slate-700 opacity-60',
|
||||
)}>
|
||||
{/* Template header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-slate-50 dark:bg-slate-800/50">
|
||||
{editingTemplateId === tpl.id ? (
|
||||
<InlineEdit
|
||||
value={tpl.name}
|
||||
onSave={name => updateTemplateName(tpl.id, name)}
|
||||
onCancel={() => setEditingTemplateId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-slate-800 dark:text-slate-200 flex-1">{tpl.name}</span>
|
||||
{tpl.taskType && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-300">
|
||||
{TASK_TYPE_LABELS[tpl.taskType] ?? tpl.taskType}
|
||||
</span>
|
||||
)}
|
||||
{!tpl.taskType && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
|
||||
Любой тип
|
||||
</span>
|
||||
)}
|
||||
<button onClick={() => setEditingTemplateId(tpl.id)} className="p-1.5 rounded hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-400">
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button onClick={() => toggleActive(tpl)} className={cn('p-1.5 rounded text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600', tpl.isActive ? 'text-brand-600' : 'text-slate-400')}>
|
||||
{tpl.isActive ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
|
||||
</button>
|
||||
<button onClick={() => deleteTemplate(tpl.id)} className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="p-4 bg-white dark:bg-slate-900 space-y-1.5">
|
||||
{tpl.items.map((item, idx) => (
|
||||
<div key={item.id} className="flex items-center gap-2 group">
|
||||
<span className="text-slate-400 text-xs w-5 text-right shrink-0">{idx + 1}.</span>
|
||||
{editingItemId === item.id ? (
|
||||
<InlineEdit
|
||||
value={item.text}
|
||||
onSave={text => updateItemText(tpl.id, item, text)}
|
||||
onCancel={() => setEditingItemId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300">{item.text}</span>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => moveItem(tpl.id, item.id, 'up')} disabled={idx === 0} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 disabled:opacity-30">
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button onClick={() => moveItem(tpl.id, item.id, 'down')} disabled={idx === tpl.items.length - 1} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 disabled:opacity-30">
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
<button onClick={() => setEditingItemId(item.id)} className="p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400">
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button onClick={() => deleteItem(tpl.id, item.id)} className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add item */}
|
||||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||
<input
|
||||
value={newItemText[tpl.id] ?? ''}
|
||||
onChange={e => setNewItemText(prev => ({ ...prev, [tpl.id]: e.target.value }))}
|
||||
placeholder="Новый пункт..."
|
||||
className="input flex-1 py-1.5 text-sm"
|
||||
onKeyDown={e => e.key === 'Enter' && addItem(tpl.id)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addItem(tpl.id)}
|
||||
disabled={!newItemText[tpl.id]?.trim()}
|
||||
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
src/pages/DepositSettingsPage.tsx
Normal file
194
src/pages/DepositSettingsPage.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, Eye, EyeOff, Loader2, ShieldCheck } from 'lucide-react'
|
||||
import { api, type DepositSettings } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||||
>
|
||||
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function DepositSettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
|
||||
const [settings, setSettings] = useState<DepositSettings | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(false)
|
||||
const [amount, setAmount] = useState('5000')
|
||||
const [shopId, setShopId] = useState('')
|
||||
const [secretKey, setSecretKey] = useState('')
|
||||
const [showSecret, setShowSecret] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
api.deposits.getSettings(slug)
|
||||
.then(s => {
|
||||
setSettings(s)
|
||||
setIsEnabled(s.isEnabled)
|
||||
setAmount(String(s.amount))
|
||||
setShopId(s.yookassaShopId ?? '')
|
||||
setSecretKey(s.yookassaSecretKey ?? '')
|
||||
})
|
||||
.catch(() => {
|
||||
// No settings yet — defaults
|
||||
setIsEnabled(false)
|
||||
setAmount('5000')
|
||||
setShopId('')
|
||||
setSecretKey('')
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
is_enabled: isEnabled,
|
||||
amount: parseFloat(amount) || 5000,
|
||||
}
|
||||
if (shopId) payload.yookassa_shop_id = shopId
|
||||
if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey
|
||||
|
||||
const updated = await api.deposits.updateSettings(slug, payload)
|
||||
setSettings(updated)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
} catch {
|
||||
setError('Не удалось сохранить настройки')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<ShieldCheck size={22} className="text-brand-600" />
|
||||
Депозит
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Настройте предоплату или залог при заселении гостей.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card p-5 space-y-5">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Включить депозит</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
При включении на странице бронирования появятся кнопки для управления депозитом
|
||||
</p>
|
||||
</div>
|
||||
<Toggle on={isEnabled} onChange={() => setIsEnabled(v => !v)} />
|
||||
</div>
|
||||
|
||||
{isEnabled && (
|
||||
<>
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4">
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label className="form-label">Сумма депозита, руб.</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="input w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* YooKassa section */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm">ЮКасса (опционально)</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Укажите реквизиты ЮКасса для создания холда (предавторизации) оплаты. Без этих данных доступен только наличный депозит.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">ID магазина (shopId)</label>
|
||||
<input
|
||||
value={shopId}
|
||||
onChange={e => setShopId(e.target.value)}
|
||||
placeholder="123456"
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="form-label">Секретный ключ</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={secretKey}
|
||||
onChange={e => setSecretKey(e.target.value)}
|
||||
placeholder={settings?.yookassaSecretKey ? '••••••••' : 'live_xxxx...'}
|
||||
className="input w-full pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecret(s => !s)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showSecret ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
{settings?.yookassaSecretKey && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Ключ уже сохранён. Оставьте поле пустым, чтобы не менять.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
{saved ? 'Сохранено!' : 'Сохранить'}
|
||||
</button>
|
||||
{saved && (
|
||||
<span className="text-sm text-emerald-600 dark:text-emerald-400">Настройки сохранены</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight, Camera } from 'lucide-react'
|
||||
import { CheckCircle2, Clock, Sparkles, User, ListChecks, Settings2, Save, Wrench, X as XIcon, Send, AlertTriangle, BanIcon, Loader2, History, ChevronLeft, ChevronRight, Camera, ShoppingCart, Plus, Minus } from 'lucide-react'
|
||||
import type { TaskChecklist, MinibarItem, MinibarConsumption } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useHotelSocket } from '../hooks/useHotelSocket'
|
||||
import type { WsMessage } from '../hooks/useHotelSocket'
|
||||
@@ -313,7 +314,7 @@ export function HousekeepingPage() {
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{colTasks.map(task => (
|
||||
<TaskCard key={task.id} task={task} onStatusChange={updateStatus} onReport={(id, note, sev, photos) => addMaintenanceReport(id, note, sev, photos)} />
|
||||
<TaskCard key={task.id} task={task} slug={slug} onStatusChange={updateStatus} onReport={(id, note, sev, photos) => addMaintenanceReport(id, note, sev, photos)} />
|
||||
))}
|
||||
{colTasks.length === 0 && (
|
||||
<div className="rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-700 py-8 text-center">
|
||||
@@ -622,8 +623,9 @@ const SEVERITY_CONFIG = {
|
||||
high: { label: 'Экстренно!', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', border: 'border-red-200 dark:border-red-800', dot: 'bg-red-500' },
|
||||
}
|
||||
|
||||
function TaskCard({ task, onStatusChange, onReport }: {
|
||||
function TaskCard({ task, onStatusChange, onReport, slug }: {
|
||||
task: HousekeepingTask
|
||||
slug: string
|
||||
onStatusChange: (id: string, status: HousekeepingTask['status'], resolutionNotes?: string) => void
|
||||
onReport: (id: string, note: string, severity: 'low' | 'medium' | 'high', photos: string[]) => void
|
||||
}) {
|
||||
@@ -636,6 +638,87 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
||||
const [completingOpen, setCompletingOpen] = useState(false)
|
||||
const [completionComment, setCompletionComment] = useState('')
|
||||
|
||||
// Checklist + Minibar detail panel
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailTab, setDetailTab] = useState<'checklist' | 'minibar'>('checklist')
|
||||
const [checklist, setChecklist] = useState<TaskChecklist | null>(null)
|
||||
const [checklistLoading, setChecklistLoading] = useState(false)
|
||||
const [minibarItems, setMinibarItems] = useState<MinibarItem[]>([])
|
||||
const [consumptions, setConsumptions] = useState<MinibarConsumption[]>([])
|
||||
const [minibarLoading, setMinibarLoading] = useState(false)
|
||||
const [minibarQty, setMinibarQty] = useState<Record<string, number>>({})
|
||||
const [minibarSaving, setMinibarSaving] = useState(false)
|
||||
|
||||
const openDetail = async (tab: 'checklist' | 'minibar') => {
|
||||
setDetailTab(tab)
|
||||
setDetailOpen(true)
|
||||
if (tab === 'checklist' && !checklist) {
|
||||
setChecklistLoading(true)
|
||||
try {
|
||||
const cl = await api.checklists.getTaskChecklist(slug, task.id)
|
||||
setChecklist(cl)
|
||||
} catch { /* ignore */ } finally {
|
||||
setChecklistLoading(false)
|
||||
}
|
||||
}
|
||||
if (tab === 'minibar' && 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 toggleChecklistItem = async (itemId: string, completed: boolean) => {
|
||||
if (!checklist) return
|
||||
try {
|
||||
if (completed) {
|
||||
await api.checklists.uncompleteItem(slug, task.id, itemId)
|
||||
setChecklist(prev => prev ? {
|
||||
...prev,
|
||||
items: prev.items.map(i => i.id === itemId ? { ...i, completedAt: null, completedBy: null } : i),
|
||||
} : null)
|
||||
} else {
|
||||
await api.checklists.completeItem(slug, task.id, itemId)
|
||||
setChecklist(prev => prev ? {
|
||||
...prev,
|
||||
items: prev.items.map(i => i.id === itemId ? { ...i, completedAt: new Date().toISOString(), completedBy: 'Вы' } : i),
|
||||
} : null)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const submitMinibar = async () => {
|
||||
const entries = Object.entries(minibarQty).filter(([, q]) => q > 0)
|
||||
if (entries.length === 0) return
|
||||
setMinibarSaving(true)
|
||||
try {
|
||||
const newCons: MinibarConsumption[] = []
|
||||
for (const [itemId, qty] of entries) {
|
||||
const c = await api.minibar.addConsumption(slug, task.id, { itemId, quantity: qty })
|
||||
newCons.push(c)
|
||||
}
|
||||
setConsumptions(prev => [...prev, ...newCons])
|
||||
setMinibarQty({})
|
||||
} catch { /* ignore */ } finally {
|
||||
setMinibarSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteConsumption = async (id: string) => {
|
||||
try {
|
||||
await api.minibar.deleteConsumption(slug, id)
|
||||
setConsumptions(prev => prev.filter(c => c.id !== id))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const uploadReportPhoto = async (file: File) => {
|
||||
setPhotoUploading(true)
|
||||
try {
|
||||
@@ -835,6 +918,146 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail panel: Checklist + Minibar */}
|
||||
{detailOpen && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-2.5 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setDetailTab('checklist'); if (!checklist) openDetail('checklist') }}
|
||||
className={cn(
|
||||
'text-xs py-1 px-2.5 rounded-md font-medium transition-colors flex items-center gap-1',
|
||||
detailTab === 'checklist'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
|
||||
)}
|
||||
>
|
||||
<ListChecks size={11} /> Чек-лист
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDetailTab('minibar'); if (minibarItems.length === 0) openDetail('minibar') }}
|
||||
className={cn(
|
||||
'text-xs py-1 px-2.5 rounded-md font-medium transition-colors flex items-center gap-1',
|
||||
detailTab === 'minibar'
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300',
|
||||
)}
|
||||
>
|
||||
<ShoppingCart size={11} /> Минибар
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setDetailOpen(false)} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">
|
||||
<XIcon size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Checklist tab */}
|
||||
{detailTab === 'checklist' && (
|
||||
<div className="space-y-1.5">
|
||||
{checklistLoading && <div className="flex justify-center py-4"><Loader2 size={16} className="animate-spin text-brand-600" /></div>}
|
||||
{!checklistLoading && checklist && checklist.items.length === 0 && (
|
||||
<p className="text-xs text-slate-400 text-center py-3">Шаблон чек-листа не настроен</p>
|
||||
)}
|
||||
{!checklistLoading && checklist && checklist.items.map(item => {
|
||||
const done = !!item.completedAt
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => toggleChecklistItem(item.id, done)}
|
||||
className={cn(
|
||||
'w-full text-left flex items-start gap-2 p-1.5 rounded-lg transition-colors',
|
||||
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',
|
||||
)}>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minibar tab */}
|
||||
{detailTab === 'minibar' && (
|
||||
<div className="space-y-2">
|
||||
{minibarLoading && <div className="flex justify-center py-4"><Loader2 size={16} className="animate-spin text-brand-600" /></div>}
|
||||
|
||||
{!minibarLoading && minibarItems.length === 0 && (
|
||||
<p className="text-xs text-slate-400 text-center py-3">Позиции минибара не настроены</p>
|
||||
)}
|
||||
|
||||
{!minibarLoading && minibarItems.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{minibarItems.map(item => {
|
||||
const qty = minibarQty[item.id] ?? 0
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-2 py-1">
|
||||
<span className="flex-1 text-xs text-slate-700 dark:text-slate-300 truncate">{item.name}</span>
|
||||
<span className="text-xs text-slate-400 w-14 text-right shrink-0">{Number(item.price).toFixed(0)} руб.</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => setMinibarQty(prev => ({ ...prev, [item.id]: Math.max(0, (prev[item.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 dark:hover:bg-slate-600 disabled:opacity-30"
|
||||
disabled={qty === 0}
|
||||
>
|
||||
<Minus size={10} />
|
||||
</button>
|
||||
<span className="w-6 text-center text-xs font-medium text-slate-700 dark:text-slate-300">{qty}</span>
|
||||
<button
|
||||
onClick={() => setMinibarQty(prev => ({ ...prev, [item.id]: (prev[item.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 dark:hover:bg-slate-600"
|
||||
>
|
||||
<Plus size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{Object.values(minibarQty).some(q => q > 0) && (
|
||||
<button
|
||||
onClick={submitMinibar}
|
||||
disabled={minibarSaving}
|
||||
className="w-full text-xs py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium mt-2 flex items-center justify-center gap-1"
|
||||
>
|
||||
{minibarSaving ? <Loader2 size={11} className="animate-spin" /> : <Send size={11} />}
|
||||
Записать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recorded consumptions */}
|
||||
{consumptions.length > 0 && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-2 space-y-1">
|
||||
<p className="text-[10px] font-semibold text-slate-500 uppercase tracking-wide">Записано</p>
|
||||
{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 truncate">{c.itemName} × {c.quantity}</span>
|
||||
<span className="shrink-0">{(Number(c.pricePerUnit) * c.quantity).toFixed(0)} руб.</span>
|
||||
<button onClick={() => deleteConsumption(c.id)} className="text-slate-300 hover:text-red-500">
|
||||
<XIcon size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1.5 pt-1">
|
||||
{task.status === 'pending' && (
|
||||
<button
|
||||
@@ -852,6 +1075,15 @@ function TaskCard({ task, onStatusChange, onReport }: {
|
||||
Завершить
|
||||
</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)}
|
||||
|
||||
247
src/pages/MinibarSettingsPage.tsx
Normal file
247
src/pages/MinibarSettingsPage.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart } from 'lucide-react'
|
||||
import { api, type MinibarItem } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
interface EditRow {
|
||||
name: string
|
||||
price: string
|
||||
category: string
|
||||
}
|
||||
|
||||
function EditForm({ initial, onSave, onCancel }: {
|
||||
initial: EditRow
|
||||
onSave: (v: EditRow) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [v, setV] = useState(initial)
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={v.name}
|
||||
onChange={e => setV(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="Название"
|
||||
className="input flex-1 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={v.price}
|
||||
onChange={e => setV(p => ({ ...p, price: e.target.value }))}
|
||||
placeholder="Цена"
|
||||
className="input w-24 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={v.category}
|
||||
onChange={e => setV(p => ({ ...p, category: e.target.value }))}
|
||||
placeholder="Категория"
|
||||
className="input w-32 py-1 text-sm"
|
||||
/>
|
||||
<button onClick={() => onSave(v)} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button onClick={onCancel} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MinibarSettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
|
||||
const [items, setItems] = useState<MinibarItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '' })
|
||||
const [addingRow, setAddingRow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
api.minibar.listItems(slug)
|
||||
.then(setItems)
|
||||
.catch(() => setError('Не удалось загрузить позиции минибара'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
const handleAdd = async (v: EditRow) => {
|
||||
if (!v.name.trim()) return
|
||||
setAddingRow(true)
|
||||
try {
|
||||
const item = await api.minibar.createItem(slug, {
|
||||
name: v.name.trim(),
|
||||
price: parseFloat(v.price) || 0,
|
||||
category: v.category.trim() || undefined,
|
||||
})
|
||||
setItems(prev => [...prev, item])
|
||||
setAdding(false)
|
||||
setAddForm({ name: '', price: '', category: '' })
|
||||
} catch {
|
||||
setError('Не удалось добавить позицию')
|
||||
} finally {
|
||||
setAddingRow(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (id: string, v: EditRow) => {
|
||||
try {
|
||||
const updated = await api.minibar.updateItem(slug, id, {
|
||||
name: v.name.trim(),
|
||||
price: parseFloat(v.price) || 0,
|
||||
category: v.category.trim() || undefined,
|
||||
})
|
||||
setItems(prev => prev.map(i => i.id === id ? { ...i, ...updated } : i))
|
||||
} catch { /* ignore */ }
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Скрыть позицию из минибара?')) return
|
||||
try {
|
||||
await api.minibar.deleteItem(slug, id)
|
||||
setItems(prev => prev.filter(i => i.id !== id))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped = items.reduce<Record<string, MinibarItem[]>>((acc, item) => {
|
||||
const cat = item.category ?? 'Без категории'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(item)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<ShoppingCart size={22} className="text-brand-600" />
|
||||
Минибар
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Настройте позиции минибара. Горничные смогут отмечать потреблённые гостем товары во время уборки.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
|
||||
<span className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Позиции минибара</span>
|
||||
{!adding && (
|
||||
<button onClick={() => setAdding(true)} className="btn-primary py-1.5 px-3 text-sm flex items-center gap-1.5">
|
||||
<Plus size={14} />
|
||||
Добавить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[1fr_100px_120px_64px] gap-2 text-xs font-medium text-slate-500 dark:text-slate-400 px-1">
|
||||
<span>Название</span>
|
||||
<span>Цена, руб.</span>
|
||||
<span>Категория</span>
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
{/* Add row */}
|
||||
{adding && (
|
||||
<div className="grid grid-cols-[1fr_100px_120px_64px] gap-2 items-center bg-brand-50 dark:bg-brand-900/10 rounded-lg px-1 py-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={addForm.name}
|
||||
onChange={e => setAddForm(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="Название"
|
||||
className="input py-1 text-sm"
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd(addForm)}
|
||||
/>
|
||||
<input
|
||||
type="number" min="0" step="0.01"
|
||||
value={addForm.price}
|
||||
onChange={e => setAddForm(p => ({ ...p, price: e.target.value }))}
|
||||
placeholder="0.00"
|
||||
className="input py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
value={addForm.category}
|
||||
onChange={e => setAddForm(p => ({ ...p, category: e.target.value }))}
|
||||
placeholder="Напитки..."
|
||||
className="input py-1 text-sm"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => handleAdd(addForm)} disabled={!addForm.name.trim() || addingRow} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||
{addingRow ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
</button>
|
||||
<button onClick={() => { setAdding(false); setAddForm({ name: '', price: '', category: '' }) }} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group items */}
|
||||
{Object.entries(grouped).map(([cat, catItems]) => (
|
||||
<div key={cat} className="space-y-1">
|
||||
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide px-1 pt-1">
|
||||
{cat}
|
||||
</div>
|
||||
{catItems.map(item => (
|
||||
<div key={item.id} className="grid grid-cols-[1fr_100px_120px_64px] gap-2 items-center group px-1 py-1.5 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||
{editingId === item.id ? (
|
||||
<>
|
||||
<div className="col-span-4">
|
||||
<EditForm
|
||||
initial={{ name: item.name, price: String(item.price), category: item.category ?? '' }}
|
||||
onSave={v => handleUpdate(item.id, v)}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-slate-700 dark:text-slate-300">{item.name}</span>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">{Number(item.price).toFixed(2)}</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{item.category ?? '—'}</span>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => setEditingId(item.id)} className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400">
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(item.id)} className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{items.length === 0 && !adding && (
|
||||
<div className="text-center py-10 text-slate-400 dark:text-slate-500 text-sm">
|
||||
Позиции не добавлены. Нажмите «Добавить» для создания первой позиции.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user