feat: minibar — category dropdown, учёт остатков (приходы/списания/инвентаризация/отчёт)

- MinibarSettingsPage: поле категории → выпадающий список с существующими + «Новая...»
- Кнопка «Учёт и остатки» ведёт на /settings/minibar/stock
- MinibarStockPage: 5 вкладок — Остатки, Приходы, Списания, Инвентаризация, Отчёт
- api.ts: добавлены методы и типы для stock/receipts/writeoffs/inventory/report

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:36:40 +03:00
parent 5b46175c91
commit 1f4def0a34
7 changed files with 1340 additions and 26 deletions

View File

@@ -46,6 +46,7 @@ import { WiFiPage } from './pages/WiFiPage'
import { TTLockPage } from './pages/TTLockPage'
import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage'
import { MinibarSettingsPage } from './pages/MinibarSettingsPage'
import { MinibarStockPage } from './pages/MinibarStockPage'
import { DepositSettingsPage } from './pages/DepositSettingsPage'
import { DepositHistoryPage } from './pages/DepositHistoryPage'
import { PayDepositPage } from './pages/PayDepositPage'
@@ -105,7 +106,8 @@ export default function App() {
<Route path="/wifi" element={<WiFiPage />} />
<Route path="/ttlock" element={<TTLockPage />} />
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
<Route path="/settings/minibar/stock" element={<MinibarStockPage />} />
<Route path="/settings/deposit" element={<DepositSettingsPage />} />
<Route path="/settings/deposit/history" element={<DepositHistoryPage />} />
</Route>

View File

@@ -186,7 +186,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/minibar', '/settings/deposit'],
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'],
devGroup: ['/api-docs'],
}

View File

@@ -740,6 +740,39 @@ export const api = {
getBookingMinibar: (slug: string, bookingId: string) =>
req<MinibarBookingCharge[]>('GET', `/api/hotels/${slug}/bookings/${bookingId}/minibar`),
getStock: (slug: string) =>
req<MinibarStockItem[]>('GET', `/api/hotels/${slug}/minibar-stock`),
getReceipts: (slug: string) =>
req<MinibarReceipt[]>('GET', `/api/hotels/${slug}/minibar-receipts`),
createReceipt: (slug: string, data: { supplier?: string; notes?: string; items: Array<{ itemId: string; quantity: number; costPrice?: number }> }) =>
req<MinibarReceipt>('POST', `/api/hotels/${slug}/minibar-receipts`, data),
getWriteoffs: (slug: string) =>
req<MinibarWriteoff[]>('GET', `/api/hotels/${slug}/minibar-writeoffs`),
createWriteoff: (slug: string, data: { reason: string; notes?: string; items: Array<{ itemId: string; quantity: number }> }) =>
req<MinibarWriteoff>('POST', `/api/hotels/${slug}/minibar-writeoffs`, data),
getInventories: (slug: string) =>
req<MinibarInventoryCheck[]>('GET', `/api/hotels/${slug}/minibar-inventory`),
createInventory: (slug: string, data: { checkedAt?: string; notes?: string }) =>
req<MinibarInventoryCheck>('POST', `/api/hotels/${slug}/minibar-inventory`, data),
getInventory: (slug: string, checkId: string) =>
req<MinibarInventoryCheck>('GET', `/api/hotels/${slug}/minibar-inventory/${checkId}`),
updateInventoryItem: (slug: string, checkId: string, itemId: string, actualQty: number) =>
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/minibar-inventory/${checkId}/items/${itemId}`, { actual_qty: actualQty }),
completeInventory: (slug: string, checkId: string) =>
req<{ ok: boolean }>('POST', `/api/hotels/${slug}/minibar-inventory/${checkId}/complete`, {}),
getReport: (slug: string, start?: string, end?: string) =>
req<MinibarReportRow[]>('GET', `/api/hotels/${slug}/minibar-report${start ? `?start=${start}&end=${end ?? ''}` : ''}`),
},
// ── Deposits ──────────────────────────────────────────────────────────────
@@ -1380,6 +1413,66 @@ export interface MinibarBookingCharge {
recordedByName: string | null
}
export interface MinibarStockItem extends MinibarItem {
stockQty: number
}
export interface MinibarReceiptItem {
id: string
itemId: string
itemName: string
quantity: number
costPrice: number | null
}
export interface MinibarReceipt {
id: string
supplier: string | null
notes: string | null
createdAt: string
items: MinibarReceiptItem[]
}
export interface MinibarWriteoffItem {
id: string
itemId: string
itemName: string
quantity: number
}
export interface MinibarWriteoff {
id: string
reason: string
notes: string | null
createdAt: string
items: MinibarWriteoffItem[]
}
export interface MinibarInventoryItem {
id: string
itemId: string
itemName: string
category: string | null
expectedQty: number
actualQty: number
}
export interface MinibarInventoryCheck {
id: string
checkedAt: string
notes: string | null
isComplete: boolean
createdAt: string
items?: MinibarInventoryItem[]
}
export interface MinibarReportRow {
name: string
category: string | null
totalQty: number
totalRevenue: number
}
// ── Deposit types ─────────────────────────────────────────────────────────────
export interface DepositPreset {

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart, ShieldAlert, ToggleLeft, ToggleRight, Tag } from 'lucide-react'
import { Plus, Trash2, Pencil, Check, X, Loader2, ShoppingCart, ShieldAlert, ToggleLeft, ToggleRight, Tag, Package } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api, type MinibarItem } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils'
@@ -8,10 +9,49 @@ interface EditRow {
name: string
price: string
category: string
newCat: string // custom category being typed
}
function EditForm({ initial, onSave, onCancel }: {
function CategorySelect({ value, onChange, categories }: {
value: string
onChange: (v: string) => void
categories: string[]
}) {
const isNew = value !== '' && !categories.includes(value)
const [showNew, setShowNew] = useState(isNew)
return (
<div className="flex gap-1 w-36">
{showNew ? (
<input
autoFocus
value={value}
onChange={e => onChange(e.target.value)}
placeholder="Новая категория"
className="input py-1 text-sm flex-1"
onBlur={() => { if (!value.trim()) { setShowNew(false); onChange('') } }}
/>
) : (
<select
value={value}
onChange={e => {
if (e.target.value === '__new__') { setShowNew(true); onChange('') }
else onChange(e.target.value)
}}
className="input py-1 text-sm flex-1"
>
<option value="">Без категории</option>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
<option value="__new__">+ Новая...</option>
</select>
)}
</div>
)
}
function EditForm({ initial, categories, onSave, onCancel }: {
initial: EditRow
categories: string[]
onSave: (v: EditRow) => void
onCancel: () => void
}) {
@@ -34,12 +74,7 @@ function EditForm({ initial, onSave, onCancel }: {
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"
/>
<CategorySelect value={v.category} onChange={cat => setV(p => ({ ...p, category: cat }))} categories={categories} />
<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>
@@ -59,7 +94,7 @@ export function MinibarSettingsPage() {
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 [addForm, setAddForm] = useState<EditRow>({ name: '', price: '', category: '', newCat: '' })
const [addingRow, setAddingRow] = useState(false)
const [requireMinibarCheck, setRequireMinibarCheck] = useState(false)
const [editingCat, setEditingCat] = useState<string | null>(null)
@@ -113,7 +148,7 @@ export function MinibarSettingsPage() {
})
setItems(prev => [...prev, item])
setAdding(false)
setAddForm({ name: '', price: '', category: '' })
setAddForm({ name: '', price: '', category: '', newCat: '' })
} catch {
setError('Не удалось добавить позицию')
} finally {
@@ -141,6 +176,9 @@ export function MinibarSettingsPage() {
} catch { /* ignore */ }
}
// Existing categories for dropdown
const categories = [...new Set(items.map(i => i.category).filter(Boolean) as string[])]
// Group by category
const grouped = items.reduce<Record<string, MinibarItem[]>>((acc, item) => {
const cat = item.category ?? 'Без категории'
@@ -159,14 +197,20 @@ export function MinibarSettingsPage() {
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 className="flex items-start justify-between gap-4 flex-wrap">
<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>
<Link to="/settings/minibar/stock" className="btn-secondary flex items-center gap-1.5 text-sm shrink-0">
<Package size={15} />
Учёт и остатки
</Link>
</div>
{error && (
@@ -213,17 +257,16 @@ export function MinibarSettingsPage() {
placeholder="0.00"
className="input py-1 text-sm"
/>
<input
<CategorySelect
value={addForm.category}
onChange={e => setAddForm(p => ({ ...p, category: e.target.value }))}
placeholder="Напитки..."
className="input py-1 text-sm"
onChange={cat => setAddForm(p => ({ ...p, category: cat }))}
categories={categories}
/>
<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">
<button onClick={() => { setAdding(false); setAddForm({ name: '', price: '', category: '', newCat: '' }) }} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
<X size={14} />
</button>
</div>
@@ -242,7 +285,8 @@ export function MinibarSettingsPage() {
<>
<div className="col-span-4">
<EditForm
initial={{ name: item.name, price: String(item.price), category: item.category ?? '' }}
initial={{ name: item.name, price: String(item.price), category: item.category ?? '', newCat: '' }}
categories={categories}
onSave={v => handleUpdate(item.id, v)}
onCancel={() => setEditingId(null)}
/>

View File

@@ -0,0 +1,668 @@
import { useState, useEffect, useCallback } from 'react'
import { ArrowLeft, Loader2, Package, Plus, Trash2, ChevronDown, ChevronUp, Check, RefreshCw } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api, type MinibarItem, type MinibarStockItem, type MinibarReceipt, type MinibarWriteoff, type MinibarInventoryCheck, type MinibarReportRow } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import { cn, formatCurrency } from '../lib/utils'
import { format, startOfMonth, endOfMonth, subMonths } from 'date-fns'
type Tab = 'stock' | 'receipts' | 'writeoffs' | 'inventory' | 'report'
const WRITEOFF_REASONS = ['Порча', 'Истёк срок годности', 'Брак', 'Недостача', 'Личное потребление персонала', 'Другое']
// ── Stock tab ─────────────────────────────────────────────────────────────────
function StockTab({ slug }: { slug: string }) {
const [stock, setStock] = useState<MinibarStockItem[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.minibar.getStock(slug).then(setStock).catch(() => {}).finally(() => setLoading(false))
}, [slug])
if (loading) return <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div>
const grouped = stock.reduce<Record<string, MinibarStockItem[]>>((acc, item) => {
const cat = item.category ?? 'Без категории'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
return acc
}, {})
return (
<div className="space-y-4">
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide">
<th className="text-left px-4 py-3 font-medium">Позиция</th>
<th className="text-right px-4 py-3 font-medium">Цена</th>
<th className="text-right px-4 py-3 font-medium">Остаток</th>
<th className="text-right px-4 py-3 font-medium">Сумма</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{Object.entries(grouped).map(([cat, items]) => (
<>
<tr key={`cat-${cat}`} className="bg-slate-50 dark:bg-slate-800/50">
<td colSpan={4} className="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
{cat}
</td>
</tr>
{items.map(item => (
<tr key={item.id} className={cn('hover:bg-slate-50 dark:hover:bg-slate-800/40', item.stockQty <= 0 && 'opacity-50')}>
<td className="px-4 py-2.5 text-slate-700 dark:text-slate-200">{item.name}</td>
<td className="px-4 py-2.5 text-right text-slate-500 dark:text-slate-400">{formatCurrency(item.price)}</td>
<td className={cn('px-4 py-2.5 text-right font-semibold', item.stockQty <= 0 ? 'text-red-500' : 'text-slate-800 dark:text-slate-100')}>
{item.stockQty}
</td>
<td className="px-4 py-2.5 text-right text-slate-500 dark:text-slate-400">
{formatCurrency(item.stockQty * Number(item.price))}
</td>
</tr>
))}
</>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-slate-200 dark:border-slate-600">
<td colSpan={3} className="px-4 py-3 text-sm font-semibold text-slate-700 dark:text-slate-200">Итого</td>
<td className="px-4 py-3 text-right font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(stock.reduce((s, i) => s + i.stockQty * Number(i.price), 0))}
</td>
</tr>
</tfoot>
</table>
</div>
{stock.length === 0 && (
<p className="text-center text-sm text-slate-400 py-8">Нет позиций. Сначала добавьте товары в настройках минибара.</p>
)}
</div>
)
}
// ── Receipts tab ──────────────────────────────────────────────────────────────
function ReceiptsTab({ slug, items }: { slug: string; items: MinibarItem[] }) {
const [receipts, setReceipts] = useState<MinibarReceipt[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [supplier, setSupplier] = useState('')
const [notes, setNotes] = useState('')
const [lines, setLines] = useState<Array<{ itemId: string; quantity: string; costPrice: string }>>([{ itemId: '', quantity: '', costPrice: '' }])
const [saving, setSaving] = useState(false)
const load = useCallback(() => {
setLoading(true)
api.minibar.getReceipts(slug).then(setReceipts).catch(() => {}).finally(() => setLoading(false))
}, [slug])
useEffect(() => { load() }, [load])
const addLine = () => setLines(p => [...p, { itemId: '', quantity: '', costPrice: '' }])
const removeLine = (i: number) => setLines(p => p.filter((_, idx) => idx !== i))
const handleSubmit = async () => {
const valid = lines.filter(l => l.itemId && parseFloat(l.quantity) > 0)
if (valid.length === 0) return
setSaving(true)
try {
const r = await api.minibar.createReceipt(slug, {
supplier: supplier.trim() || undefined,
notes: notes.trim() || undefined,
items: valid.map(l => ({ itemId: l.itemId, quantity: parseFloat(l.quantity), costPrice: l.costPrice ? parseFloat(l.costPrice) : undefined })),
})
setReceipts(p => [r, ...p])
setShowForm(false)
setSupplier(''); setNotes('')
setLines([{ itemId: '', quantity: '', costPrice: '' }])
} catch { /* ignore */ } finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<button onClick={() => setShowForm(p => !p)} className="btn-primary flex items-center gap-1.5 text-sm">
<Plus size={14} />
Приход
</button>
</div>
{showForm && (
<div className="card p-4 space-y-4">
<p className="font-semibold text-slate-800 dark:text-slate-200">Новый приход</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="form-label">Поставщик</label>
<input value={supplier} onChange={e => setSupplier(e.target.value)} placeholder="ООО Напитки" className="input w-full" />
</div>
<div>
<label className="form-label">Примечание</label>
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Накладная №..." className="input w-full" />
</div>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[1fr_80px_100px_32px] gap-2 text-xs text-slate-500 dark:text-slate-400">
<span>Позиция</span><span>Кол-во</span><span>Себест., руб.</span><span></span>
</div>
{lines.map((line, i) => (
<div key={i} className="grid grid-cols-[1fr_80px_100px_32px] gap-2 items-center">
<select value={line.itemId} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, itemId: e.target.value } : l))} className="input text-sm py-1">
<option value="">Выберите...</option>
{items.map(it => <option key={it.id} value={it.id}>{it.name}</option>)}
</select>
<input type="number" min="1" value={line.quantity} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, quantity: e.target.value } : l))} placeholder="0" className="input text-sm py-1 text-right" />
<input type="number" min="0" step="0.01" value={line.costPrice} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, costPrice: e.target.value } : l))} placeholder="0.00" className="input text-sm py-1 text-right" />
<button onClick={() => removeLine(i)} disabled={lines.length === 1} className="p-1 rounded text-slate-300 hover:text-red-500 disabled:invisible">
<Trash2 size={13} />
</button>
</div>
))}
<button onClick={addLine} className="text-xs text-brand-600 hover:underline flex items-center gap-1">
<Plus size={12} /> Добавить строку
</button>
</div>
<div className="flex gap-2">
<button onClick={handleSubmit} disabled={saving} className="btn-primary flex items-center gap-1.5 text-sm">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Сохранить приход
</button>
<button onClick={() => setShowForm(false)} className="btn-secondary text-sm">Отмена</button>
</div>
</div>
)}
{loading ? <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div> : (
<div className="space-y-2">
{receipts.length === 0 && <p className="text-center text-sm text-slate-400 py-8">Нет приходов</p>}
{receipts.map(r => (
<div key={r.id} className="card overflow-hidden">
<button onClick={() => setExpanded(p => p === r.id ? null : r.id)} className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<div className="flex items-center gap-3 text-left">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{format(new Date(r.createdAt), 'dd.MM.yyyy HH:mm')}</span>
{r.supplier && <span className="text-sm text-slate-500">{r.supplier}</span>}
<span className="text-xs text-slate-400">{r.items.length} поз.</span>
</div>
{expanded === r.id ? <ChevronUp size={15} className="text-slate-400" /> : <ChevronDown size={15} className="text-slate-400" />}
</button>
{expanded === r.id && (
<div className="border-t border-slate-100 dark:border-slate-700 px-4 pb-3">
{r.notes && <p className="text-xs text-slate-400 mt-2 mb-2">{r.notes}</p>}
<table className="w-full text-sm mt-2">
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{r.items.map(it => (
<tr key={it.id}>
<td className="py-1.5 text-slate-700 dark:text-slate-300">{it.itemName}</td>
<td className="py-1.5 text-right text-slate-500">{it.quantity} шт.</td>
{it.costPrice != null && <td className="py-1.5 text-right text-slate-400">{formatCurrency(it.costPrice)} / шт.</td>}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
// ── Writeoffs tab ─────────────────────────────────────────────────────────────
function WriteoffsTab({ slug, items }: { slug: string; items: MinibarItem[] }) {
const [writeoffs, setWriteoffs] = useState<MinibarWriteoff[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [reason, setReason] = useState(WRITEOFF_REASONS[0])
const [customReason, setCustomReason] = useState('')
const [notes, setNotes] = useState('')
const [lines, setLines] = useState<Array<{ itemId: string; quantity: string }>>([{ itemId: '', quantity: '' }])
const [saving, setSaving] = useState(false)
useEffect(() => {
api.minibar.getWriteoffs(slug).then(setWriteoffs).catch(() => {}).finally(() => setLoading(false))
}, [slug])
const handleSubmit = async () => {
const finalReason = reason === 'Другое' ? customReason.trim() : reason
if (!finalReason) return
const valid = lines.filter(l => l.itemId && parseFloat(l.quantity) > 0)
if (valid.length === 0) return
setSaving(true)
try {
const w = await api.minibar.createWriteoff(slug, {
reason: finalReason,
notes: notes.trim() || undefined,
items: valid.map(l => ({ itemId: l.itemId, quantity: parseFloat(l.quantity) })),
})
setWriteoffs(p => [w, ...p])
setShowForm(false)
setReason(WRITEOFF_REASONS[0]); setCustomReason(''); setNotes('')
setLines([{ itemId: '', quantity: '' }])
} catch { /* ignore */ } finally {
setSaving(false)
}
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<button onClick={() => setShowForm(p => !p)} className="btn-primary flex items-center gap-1.5 text-sm">
<Plus size={14} />
Списание
</button>
</div>
{showForm && (
<div className="card p-4 space-y-4">
<p className="font-semibold text-slate-800 dark:text-slate-200">Новое списание</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="form-label">Причина</label>
<select value={reason} onChange={e => setReason(e.target.value)} className="input w-full">
{WRITEOFF_REASONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
{reason === 'Другое' && (
<input value={customReason} onChange={e => setCustomReason(e.target.value)} placeholder="Укажите причину" className="input w-full mt-2" />
)}
</div>
<div>
<label className="form-label">Примечание</label>
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Необязательно" className="input w-full" />
</div>
</div>
<div className="space-y-2">
<div className="grid grid-cols-[1fr_80px_32px] gap-2 text-xs text-slate-500 dark:text-slate-400">
<span>Позиция</span><span>Кол-во</span><span></span>
</div>
{lines.map((line, i) => (
<div key={i} className="grid grid-cols-[1fr_80px_32px] gap-2 items-center">
<select value={line.itemId} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, itemId: e.target.value } : l))} className="input text-sm py-1">
<option value="">Выберите...</option>
{items.map(it => <option key={it.id} value={it.id}>{it.name}</option>)}
</select>
<input type="number" min="1" value={line.quantity} onChange={e => setLines(p => p.map((l, idx) => idx === i ? { ...l, quantity: e.target.value } : l))} placeholder="0" className="input text-sm py-1 text-right" />
<button onClick={() => setLines(p => p.filter((_, idx) => idx !== i))} disabled={lines.length === 1} className="p-1 rounded text-slate-300 hover:text-red-500 disabled:invisible">
<Trash2 size={13} />
</button>
</div>
))}
<button onClick={() => setLines(p => [...p, { itemId: '', quantity: '' }])} className="text-xs text-brand-600 hover:underline flex items-center gap-1">
<Plus size={12} /> Добавить строку
</button>
</div>
<div className="flex gap-2">
<button onClick={handleSubmit} disabled={saving} className="btn-primary flex items-center gap-1.5 text-sm">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Сохранить списание
</button>
<button onClick={() => setShowForm(false)} className="btn-secondary text-sm">Отмена</button>
</div>
</div>
)}
{loading ? <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div> : (
<div className="space-y-2">
{writeoffs.length === 0 && <p className="text-center text-sm text-slate-400 py-8">Нет списаний</p>}
{writeoffs.map(w => (
<div key={w.id} className="card overflow-hidden">
<button onClick={() => setExpanded(p => p === w.id ? null : w.id)} className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<div className="flex items-center gap-3 text-left">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{format(new Date(w.createdAt), 'dd.MM.yyyy HH:mm')}</span>
<span className="text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 px-2 py-0.5 rounded-full">{w.reason}</span>
<span className="text-xs text-slate-400">{w.items.length} поз.</span>
</div>
{expanded === w.id ? <ChevronUp size={15} className="text-slate-400" /> : <ChevronDown size={15} className="text-slate-400" />}
</button>
{expanded === w.id && (
<div className="border-t border-slate-100 dark:border-slate-700 px-4 pb-3">
{w.notes && <p className="text-xs text-slate-400 mt-2">{w.notes}</p>}
<table className="w-full text-sm mt-2">
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{w.items.map(it => (
<tr key={it.id}>
<td className="py-1.5 text-slate-700 dark:text-slate-300">{it.itemName}</td>
<td className="py-1.5 text-right text-slate-500">{it.quantity} шт.</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
// ── Inventory tab ─────────────────────────────────────────────────────────────
function InventoryTab({ slug }: { slug: string }) {
const [checks, setChecks] = useState<MinibarInventoryCheck[]>([])
const [loading, setLoading] = useState(true)
const [activeCheck, setActiveCheck] = useState<MinibarInventoryCheck | null>(null)
const [creating, setCreating] = useState(false)
const [completing, setCompleting] = useState(false)
const [checkedAt, setCheckedAt] = useState(() => new Date().toISOString().slice(0, 10))
const load = useCallback(() => {
setLoading(true)
api.minibar.getInventories(slug).then(setChecks).catch(() => {}).finally(() => setLoading(false))
}, [slug])
useEffect(() => { load() }, [load])
const openCheck = async (id: string) => {
const data = await api.minibar.getInventory(slug, id)
setActiveCheck(data)
}
const createCheck = async () => {
setCreating(true)
try {
const c = await api.minibar.createInventory(slug, { checkedAt })
const full = await api.minibar.getInventory(slug, c.id)
setActiveCheck(full)
setChecks(p => [c, ...p])
} catch { /* ignore */ } finally {
setCreating(false)
}
}
const updateQty = async (checkId: string, itemId: string, qty: number) => {
await api.minibar.updateInventoryItem(slug, checkId, itemId, qty).catch(() => {})
setActiveCheck(prev => prev ? {
...prev,
items: prev.items?.map(it => it.itemId === itemId ? { ...it, actualQty: qty } : it),
} : prev)
}
const completeCheck = async () => {
if (!activeCheck) return
if (!confirm('Завершить инвентаризацию? Остатки будут обновлены по фактическим данным.')) return
setCompleting(true)
try {
await api.minibar.completeInventory(slug, activeCheck.id)
setActiveCheck(prev => prev ? { ...prev, isComplete: true } : prev)
setChecks(prev => prev.map(c => c.id === activeCheck.id ? { ...c, isComplete: true } : c))
} catch { /* ignore */ } finally {
setCompleting(false)
}
}
if (activeCheck) {
const grouped = (activeCheck.items ?? []).reduce<Record<string, typeof activeCheck.items>>((acc, it) => {
const cat = it!.category ?? 'Без категории'
if (!acc[cat]) acc[cat] = []
acc[cat]!.push(it)
return acc
}, {})
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<button onClick={() => setActiveCheck(null)} className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
<ArrowLeft size={15} /> К списку
</button>
<div className="flex items-center gap-2">
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', activeCheck.isComplete ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
{activeCheck.isComplete ? 'Завершена' : 'Открыта'}
</span>
{!activeCheck.isComplete && (
<button onClick={completeCheck} disabled={completing} className="btn-primary text-sm flex items-center gap-1.5">
{completing ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
Завершить инвентаризацию
</button>
)}
</div>
</div>
<div className="card overflow-hidden">
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 text-sm text-slate-600 dark:text-slate-400">
Дата: <strong>{format(new Date(activeCheck.checkedAt), 'dd.MM.yyyy')}</strong>
{activeCheck.notes && <span className="ml-3">{activeCheck.notes}</span>}
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide">
<th className="text-left px-4 py-3 font-medium">Позиция</th>
<th className="text-right px-4 py-3 font-medium">Ожидается</th>
<th className="text-right px-4 py-3 font-medium">Факт</th>
<th className="text-right px-4 py-3 font-medium">Расхождение</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{Object.entries(grouped).map(([cat, catItems]) => (
<>
<tr key={`cat-${cat}`} className="bg-slate-50 dark:bg-slate-800/50">
<td colSpan={4} className="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">{cat}</td>
</tr>
{catItems!.map(it => {
const diff = it!.actualQty - it!.expectedQty
return (
<tr key={it!.itemId}>
<td className="px-4 py-2 text-slate-700 dark:text-slate-200">{it!.itemName}</td>
<td className="px-4 py-2 text-right text-slate-500">{it!.expectedQty}</td>
<td className="px-4 py-2 text-right">
{activeCheck.isComplete ? (
<span className="font-semibold">{it!.actualQty}</span>
) : (
<input
type="number" min="0"
defaultValue={it!.actualQty}
onBlur={e => updateQty(activeCheck.id, it!.itemId, parseInt(e.target.value) || 0)}
className="input text-sm py-0.5 w-20 text-right ml-auto"
/>
)}
</td>
<td className={cn('px-4 py-2 text-right font-medium', diff < 0 ? 'text-red-500' : diff > 0 ? 'text-amber-500' : 'text-slate-400')}>
{diff === 0 ? '—' : (diff > 0 ? '+' : '') + diff}
</td>
</tr>
)
})}
</>
))}
</tbody>
</table>
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="card p-4 flex items-end gap-3">
<div>
<label className="form-label">Дата инвентаризации</label>
<input type="date" value={checkedAt} onChange={e => setCheckedAt(e.target.value)} className="input" />
</div>
<button onClick={createCheck} disabled={creating} className="btn-primary flex items-center gap-1.5 text-sm">
{creating ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
Начать инвентаризацию
</button>
</div>
{loading ? <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div> : (
<div className="space-y-2">
{checks.length === 0 && <p className="text-center text-sm text-slate-400 py-8">Нет инвентаризаций</p>}
{checks.map(c => (
<button key={c.id} onClick={() => openCheck(c.id)} className="card w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{format(new Date(c.checkedAt), 'dd.MM.yyyy')}</span>
{c.notes && <span className="text-sm text-slate-500">{c.notes}</span>}
</div>
<span className={cn('text-xs px-2 py-0.5 rounded-full font-medium', c.isComplete ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
{c.isComplete ? 'Завершена' : 'Открыта'}
</span>
</button>
))}
</div>
)}
</div>
)
}
// ── Report tab ────────────────────────────────────────────────────────────────
function ReportTab({ slug }: { slug: string }) {
const now = new Date()
const [start, setStart] = useState(() => format(startOfMonth(now), 'yyyy-MM-dd'))
const [end, setEnd] = useState(() => format(endOfMonth(now), 'yyyy-MM-dd'))
const [rows, setRows] = useState<MinibarReportRow[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(() => {
setLoading(true)
api.minibar.getReport(slug, start, format(new Date(end + 'T23:59:59'), 'yyyy-MM-dd') + 'T23:59:59').then(setRows).catch(() => {}).finally(() => setLoading(false))
}, [slug, start, end])
useEffect(() => { load() }, [load])
const setPreset = (months: number) => {
const d = months === 0 ? now : subMonths(now, months - 1)
setStart(format(startOfMonth(d), 'yyyy-MM-dd'))
setEnd(format(endOfMonth(d), 'yyyy-MM-dd'))
}
const grouped = rows.reduce<Record<string, MinibarReportRow[]>>((acc, r) => {
const cat = r.category ?? 'Без категории'
if (!acc[cat]) acc[cat] = []
acc[cat].push(r)
return acc
}, {})
const totalRevenue = rows.reduce((s, r) => s + Number(r.totalRevenue), 0)
const totalQty = rows.reduce((s, r) => s + Number(r.totalQty), 0)
return (
<div className="space-y-4">
<div className="card p-4 flex items-end gap-3 flex-wrap">
<div className="flex gap-1.5">
{[['Тек. месяц', 0], ['Пред. месяц', 1], ['2 мес. назад', 2]].map(([label, n]) => (
<button key={n} onClick={() => setPreset(Number(n))} className="btn-secondary text-xs py-1 px-2">{label}</button>
))}
</div>
<div className="flex items-end gap-2">
<div>
<label className="form-label">С</label>
<input type="date" value={start} onChange={e => setStart(e.target.value)} className="input" />
</div>
<div>
<label className="form-label">По</label>
<input type="date" value={end} onChange={e => setEnd(e.target.value)} className="input" />
</div>
<button onClick={load} className="btn-secondary flex items-center gap-1.5 text-sm">
<RefreshCw size={14} />
</button>
</div>
</div>
{loading ? <div className="flex justify-center py-10"><Loader2 size={22} className="animate-spin text-brand-600" /></div> : (
<>
{rows.length === 0 ? (
<p className="text-center text-sm text-slate-400 py-8">Нет данных за выбранный период</p>
) : (
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide">
<th className="text-left px-4 py-3 font-medium">Позиция</th>
<th className="text-right px-4 py-3 font-medium">Кол-во</th>
<th className="text-right px-4 py-3 font-medium">Выручка</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{Object.entries(grouped).map(([cat, catRows]) => (
<>
<tr key={`cat-${cat}`} className="bg-slate-50 dark:bg-slate-800/50">
<td colSpan={3} className="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">{cat}</td>
</tr>
{catRows.map((r, i) => (
<tr key={i} className="hover:bg-slate-50 dark:hover:bg-slate-800/40">
<td className="px-4 py-2.5 text-slate-700 dark:text-slate-200">{r.name}</td>
<td className="px-4 py-2.5 text-right text-slate-600 dark:text-slate-300">{Number(r.totalQty)}</td>
<td className="px-4 py-2.5 text-right font-medium text-slate-900 dark:text-slate-100">{formatCurrency(Number(r.totalRevenue))}</td>
</tr>
))}
</>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-slate-200 dark:border-slate-600">
<td className="px-4 py-3 text-sm font-semibold text-slate-700 dark:text-slate-200">Итого</td>
<td className="px-4 py-3 text-right font-semibold text-slate-800 dark:text-slate-100">{totalQty}</td>
<td className="px-4 py-3 text-right font-semibold text-slate-900 dark:text-slate-100">{formatCurrency(totalRevenue)}</td>
</tr>
</tfoot>
</table>
</div>
)}
</>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export function MinibarStockPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [tab, setTab] = useState<Tab>('stock')
const [items, setItems] = useState<MinibarItem[]>([])
useEffect(() => {
if (!slug) return
api.minibar.listItems(slug).then(setItems).catch(() => {})
}, [slug])
const tabs: Array<{ id: Tab; label: string }> = [
{ id: 'stock', label: 'Остатки' },
{ id: 'receipts', label: 'Приходы' },
{ id: 'writeoffs', label: 'Списания' },
{ id: 'inventory', label: 'Инвентаризация' },
{ id: 'report', label: 'Отчёт' },
]
return (
<div className="p-4 md:p-6 space-y-5 max-w-4xl">
<div className="flex items-center gap-3 flex-wrap">
<Link to="/settings/minibar" className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
<ArrowLeft size={16} />
Назад
</Link>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Package size={22} className="text-brand-600" />
Учёт минибара
</h1>
</div>
{/* Tabs */}
<div className="flex gap-1 flex-wrap border-b border-slate-200 dark:border-slate-700">
{tabs.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.id
? 'border-brand-600 text-brand-600'
: 'border-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{t.label}
</button>
))}
</div>
{tab === 'stock' && <StockTab slug={slug} />}
{tab === 'receipts' && <ReceiptsTab slug={slug} items={items} />}
{tab === 'writeoffs' && <WriteoffsTab slug={slug} items={items} />}
{tab === 'inventory' && <InventoryTab slug={slug} />}
{tab === 'report' && <ReportTab slug={slug} />}
</div>
)
}