feat: deposit auto-poll + billing folio tab

- DepositWidget: auto-poll every 5s while hold_created, clear QR message on hold_confirmed
- Add 'Счёт' tab (billing folio): room charges + minibar lines + payments + deposit badge + balance
- Billing data loads lazily on tab open

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 20:24:03 +03:00
parent bb72a24757
commit 340b3ddec9

View File

@@ -33,12 +33,13 @@ interface Payment {
method: 'cash' | 'card' | 'transfer'; note: string
}
type Tab = 'booking' | 'guest' | 'payment' | 'docs'
type Tab = 'booking' | 'guest' | 'payment' | 'billing' | 'docs'
const TABS: { id: Tab; label: string }[] = [
{ id: 'booking', label: 'Бронь' },
{ id: 'guest', label: 'Гости' },
{ id: 'payment', label: 'Оплата' },
{ id: 'billing', label: 'Счёт' },
{ id: 'docs', label: 'Документы' },
]
@@ -77,6 +78,22 @@ function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; boo
useEffect(() => { onDepositChange?.(deposit) }, [deposit]) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-poll while waiting for YooKassa payment confirmation
useEffect(() => {
if (deposit?.status !== 'hold_created') return
const timer = setInterval(async () => {
try {
const dep = await api.deposits.getBookingDeposit(slug, bookingId)
setDeposit(dep)
if (dep.status === 'hold_confirmed') {
setYookassaMsg(null)
clearInterval(timer)
}
} catch { /* ignore */ }
}, 5000)
return () => clearInterval(timer)
}, [deposit?.status, slug, bookingId]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
Promise.all([
api.deposits.getSettings(slug),
@@ -953,6 +970,25 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
})
}, [slug])
// Billing tab
const [billingMinibar, setBillingMinibar] = useState<Array<{ itemName: string; quantity: number; pricePerUnit: number; total: number; recordedAt: string }>>([])
const [billingLoaded, setBillingLoaded] = useState(false)
useEffect(() => {
if (tab !== 'billing' || billingLoaded || !slug) return
// Ensure payments are loaded too
if (!paymentsLoaded) {
api.payments.list(slug, booking.id).then(setPayments).catch(() => {}).finally(() => setPaymentsLoaded(true))
}
api.minibar.getBookingMinibar(slug, booking.id)
.then(items => setBillingMinibar(items.map(i => ({
itemName: i.itemName, quantity: i.quantity,
pricePerUnit: i.pricePerUnit, total: i.total, recordedAt: i.recordedAt,
}))))
.catch(() => {})
.finally(() => setBillingLoaded(true))
}, [tab, slug, booking.id, billingLoaded, paymentsLoaded]) // eslint-disable-line react-hooks/exhaustive-deps
// Toast
const [toast, setToast] = useState('')
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
@@ -1836,6 +1872,118 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
</div>
)}
{/* ── Счёт (фолио) ─────────────────────────────────────────────────── */}
{tab === 'billing' && (() => {
const minibarTotal = billingMinibar.reduce((s, i) => s + i.total, 0)
const charges = finalTotal + minibarTotal
const paidPositive = payments.filter(p => Number(p.amount) > 0).reduce((s, p) => s + Number(p.amount), 0)
const paidNegative = payments.filter(p => Number(p.amount) < 0).reduce((s, p) => s + Number(p.amount), 0)
const depositBadge = activeDeposit?.status === 'hold_confirmed'
? `Удержание ${formatCurrency(activeDeposit.amount)} (ЮКасса)`
: activeDeposit?.status === 'paid_cash'
? `Депозит наличными ${formatCurrency(activeDeposit?.amount ?? 0)}`
: activeDeposit?.status === 'captured'
? `Депозит списан ${formatCurrency(activeDeposit.capturedAmount ?? 0)}`
: null
const billingBalance = charges - paidPositive + paidNegative
return (
<div className="space-y-4">
{/* Charges */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
<div className="px-3 py-2 bg-slate-50 dark:bg-slate-800/50">
<span className="text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">Начисления</span>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{/* Room */}
<div className="flex justify-between items-center px-3 py-2">
<div>
<p className="text-sm text-slate-800 dark:text-slate-200">🏨 Проживание</p>
<p className="text-xs text-slate-400">{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {room?.number ?? '—'}</p>
</div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{formatCurrency(baseTotal)}</span>
</div>
{discountAmt > 0 && (
<div className="flex justify-between items-center px-3 py-2">
<p className="text-sm text-emerald-700 dark:text-emerald-400">Скидка «{selDiscount?.name}»</p>
<span className="text-sm font-medium text-emerald-600 dark:text-emerald-400">{formatCurrency(discountAmt)}</span>
</div>
)}
{/* Minibar lines */}
{billingMinibar.map((item, i) => (
<div key={i} className="flex justify-between items-center px-3 py-2">
<div>
<p className="text-sm text-slate-800 dark:text-slate-200">🍹 {item.itemName}</p>
<p className="text-xs text-slate-400">{item.quantity} шт. × {formatCurrency(item.pricePerUnit)}</p>
</div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{formatCurrency(item.total)}</span>
</div>
))}
{billingMinibar.length === 0 && minibarTotal === 0 && !billingLoaded && (
<div className="px-3 py-2 text-xs text-slate-400">Загрузка...</div>
)}
{/* Total charges */}
<div className="flex justify-between items-center px-3 py-2 bg-slate-50 dark:bg-slate-800/30 font-semibold">
<span className="text-sm text-slate-700 dark:text-slate-300">Итого начислено</span>
<span className="text-sm text-slate-900 dark:text-slate-100">{formatCurrency(charges)}</span>
</div>
</div>
</div>
{/* Payments */}
<div className="rounded-xl border border-slate-200 dark:border-slate-600 overflow-hidden">
<div className="px-3 py-2 bg-slate-50 dark:bg-slate-800/50">
<span className="text-xs font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide">Оплаты</span>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{payments.length === 0 && (
<p className="px-3 py-3 text-xs text-slate-400">Оплат пока нет</p>
)}
{payments.map(p => (
<div key={p.id} className="flex justify-between items-center px-3 py-2">
<div>
<p className={`text-sm ${Number(p.amount) < 0 ? 'text-amber-700 dark:text-amber-400' : 'text-slate-800 dark:text-slate-200'}`}>
{Number(p.amount) < 0 ? '↩ ' : '✓ '}
{p.method}
</p>
{p.note && <p className="text-xs text-slate-400">{p.note}</p>}
<p className="text-xs text-slate-400">{p.createdAt ? format(new Date(p.createdAt), 'dd.MM.yyyy HH:mm') : ''}</p>
</div>
<span className={`text-sm font-medium ${Number(p.amount) < 0 ? 'text-amber-600 dark:text-amber-400' : 'text-emerald-600 dark:text-emerald-400'}`}>
{Number(p.amount) < 0 ? '' : '+'}{formatCurrency(Math.abs(Number(p.amount)))}
</span>
</div>
))}
<div className="flex justify-between items-center px-3 py-2 bg-slate-50 dark:bg-slate-800/30 font-semibold">
<span className="text-sm text-slate-700 dark:text-slate-300">Итого оплачено</span>
<span className="text-sm text-emerald-600 dark:text-emerald-400">{formatCurrency(paidPositive)}</span>
</div>
</div>
</div>
{/* Deposit */}
{depositBadge && (
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border border-violet-200 dark:border-violet-700 bg-violet-50 dark:bg-violet-900/10 text-xs text-violet-700 dark:text-violet-300">
<ShieldCheck size={13} className="shrink-0" />
{depositBadge}
</div>
)}
{/* Balance */}
<div className={`flex justify-between items-center px-4 py-3 rounded-xl font-bold text-base border-2 ${
billingBalance > 0
? 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/10 text-red-700 dark:text-red-400'
: 'border-emerald-300 dark:border-emerald-700 bg-emerald-50 dark:bg-emerald-900/10 text-emerald-700 dark:text-emerald-400'
}`}>
<span>{billingBalance > 0 ? 'К оплате' : 'Переплата'}</span>
<span>{billingBalance > 0 ? formatCurrency(billingBalance) : `+${formatCurrency(-billingBalance)}`}</span>
</div>
</div>
)
})()}
{/* ── Документы ────────────────────────────────────────────────────── */}
{tab === 'docs' && (
<div className="space-y-2">