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
|
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 }[] = [
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
{ id: 'booking', label: 'Бронь' },
|
{ id: 'booking', label: 'Бронь' },
|
||||||
{ id: 'guest', label: 'Гости' },
|
{ id: 'guest', label: 'Гости' },
|
||||||
{ id: 'payment', label: 'Оплата' },
|
{ id: 'payment', label: 'Оплата' },
|
||||||
|
{ id: 'billing', label: 'Счёт' },
|
||||||
{ id: 'docs', 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
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.deposits.getSettings(slug),
|
api.deposits.getSettings(slug),
|
||||||
@@ -953,6 +970,25 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
})
|
})
|
||||||
}, [slug])
|
}, [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
|
// Toast
|
||||||
const [toast, setToast] = useState('')
|
const [toast, setToast] = useState('')
|
||||||
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
|
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
|
||||||
@@ -1836,6 +1872,118 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
|
|||||||
</div>
|
</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' && (
|
{tab === 'docs' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user