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