diff --git a/src/components/bookings/BookingDetailPanel.tsx b/src/components/bookings/BookingDetailPanel.tsx index 8c8251f..4a69786 100644 --- a/src/components/bookings/BookingDetailPanel.tsx +++ b/src/components/bookings/BookingDetailPanel.tsx @@ -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>([]) + 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 )} + {/* ── Счёт (фолио) ─────────────────────────────────────────────────── */} + {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 ( +
+ + {/* Charges */} +
+
+ Начисления +
+
+ {/* Room */} +
+
+

🏨 Проживание

+

{nights} {nights === 1 ? 'ночь' : nights < 5 ? 'ночи' : 'ночей'} · {room?.number ?? '—'}

+
+ {formatCurrency(baseTotal)} +
+ {discountAmt > 0 && ( +
+

Скидка «{selDiscount?.name}»

+ −{formatCurrency(discountAmt)} +
+ )} + {/* Minibar lines */} + {billingMinibar.map((item, i) => ( +
+
+

🍹 {item.itemName}

+

{item.quantity} шт. × {formatCurrency(item.pricePerUnit)}

+
+ {formatCurrency(item.total)} +
+ ))} + {billingMinibar.length === 0 && minibarTotal === 0 && !billingLoaded && ( +
Загрузка...
+ )} + {/* Total charges */} +
+ Итого начислено + {formatCurrency(charges)} +
+
+
+ + {/* Payments */} +
+
+ Оплаты +
+
+ {payments.length === 0 && ( +

Оплат пока нет

+ )} + {payments.map(p => ( +
+
+

+ {Number(p.amount) < 0 ? '↩ ' : '✓ '} + {p.method} +

+ {p.note &&

{p.note}

} +

{p.createdAt ? format(new Date(p.createdAt), 'dd.MM.yyyy HH:mm') : ''}

+
+ + {Number(p.amount) < 0 ? '−' : '+'}{formatCurrency(Math.abs(Number(p.amount)))} + +
+ ))} +
+ Итого оплачено + {formatCurrency(paidPositive)} +
+
+
+ + {/* Deposit */} + {depositBadge && ( +
+ + {depositBadge} +
+ )} + + {/* Balance */} +
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' + }`}> + {billingBalance > 0 ? 'К оплате' : 'Переплата'} + {billingBalance > 0 ? formatCurrency(billingBalance) : `+${formatCurrency(-billingBalance)}`} +
+ +
+ ) + })()} + {/* ── Документы ────────────────────────────────────────────────────── */} {tab === 'docs' && (