feat: deposit auto-apply to room payment on checkout

- Add activeDeposit state to BookingDetailPanel via DepositWidget callback
- On checkout, if active deposit (hold_confirmed/paid_cash) exists and balance > 0:
  show checkbox 'Зачесть депозит в счёт оплаты' with editable amount
- On confirm: capture deposit via /deposit/release + add booking_payment record
- Default apply amount = min(deposit.amount, outstanding balance)

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

View File

@@ -59,7 +59,7 @@ const DOCUMENTS = [
type ReleaseItem = { id: string; name: string; amount: string }
function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) {
function DepositWidget({ slug, bookingId, onDepositChange }: { slug: string; bookingId: string; onDepositChange?: (d: BookingDeposit | null) => void }) {
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
const [depositLoading, setDepositLoading] = useState(true)
@@ -75,6 +75,8 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
const [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; recordedPrice: number; currentPrice: number; lineTotal: number }>>([])
const [minibarTotal, setMinibarTotal] = useState(0)
useEffect(() => { onDepositChange?.(deposit) }, [deposit]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
Promise.all([
api.deposits.getSettings(slug),
@@ -734,6 +736,13 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const [earlyOutMethod, setEarlyOutMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [earlyOutSaving, setEarlyOutSaving] = useState(false)
// Active deposit (populated via DepositWidget callback)
const [activeDeposit, setActiveDeposit] = useState<BookingDeposit | null>(null)
// Deposit → room payment apply at checkout
const [depositApply, setDepositApply] = useState(false)
const [depositApplyAmt, setDepositApplyAmt] = useState('')
const [cardIssuing, setCardIssuing] = useState(false)
const [cardEncoders, setCardEncoders] = useState<Array<{ id: string; name: string }>>([])
const [cardEncoderPickerOpen, setCardEncoderPickerOpen] = useState(false)
@@ -777,6 +786,21 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
if (earlyOutDate > booking.checkOut) { showToast('Дата не может быть позже плановой'); return }
setEarlyOutSaving(true)
try {
// Apply deposit to room payment if requested
if (depositApply && activeDeposit && slug) {
const applyAmt = parseFloat(depositApplyAmt) || 0
if (applyAmt > 0) {
const methodLabel = activeDeposit.paymentMethod === 'yookassa_hold' ? 'Депозит (ЮКасса)' : 'Депозит (наличные)'
await api.deposits.release(slug, booking.id, applyAmt, 'Зачёт в счёт оплаты проживания')
const payment = await api.payments.add(slug, booking.id, applyAmt, methodLabel, 'Зачёт страхового депозита')
setPayments(prev => {
const next = [...prev, payment]
onUpdate(booking.id, { paidAmount: next.reduce((s, p) => s + Number(p.amount), 0) })
return next
})
}
}
const origNights = differenceInDays(new Date(booking.checkOut), new Date(booking.checkIn))
const actualNights = differenceInDays(new Date(earlyOutDate), new Date(booking.checkIn))
const refundNights = origNights - actualNights
@@ -791,7 +815,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
}
await onUpdate(booking.id, { checkOut: earlyOutDate, status: 'checked_out' })
setEarlyOutOpen(false)
setDepositApply(false)
showToast('Гость выселен')
} catch {
showToast('Ошибка при выселении')
} finally {
setEarlyOutSaving(false)
}
@@ -1803,7 +1830,7 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
{/* Правая колонка — депозит */}
{slug && (
<div>
<DepositWidget slug={slug} bookingId={booking.id} />
<DepositWidget slug={slug} bookingId={booking.id} onDepositChange={setActiveDeposit} />
</div>
)}
</div>
@@ -1974,6 +2001,14 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
}
setEarlyOutOpen(true)
setEarlyOutDate(checkOutDate)
// Init deposit apply state
if (activeDeposit && (activeDeposit.status === 'hold_confirmed' || activeDeposit.status === 'paid_cash') && balance > 0) {
setDepositApply(true)
setDepositApplyAmt(String(Math.min(activeDeposit.amount, balance)))
} else {
setDepositApply(false)
setDepositApplyAmt('')
}
}}
className="w-full btn-primary justify-center bg-emerald-600 hover:bg-emerald-700"
>
@@ -2043,6 +2078,46 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
{refundNights <= 0 && (
<p className="text-xs text-slate-500 dark:text-slate-400">Возврат не требуется</p>
)}
{/* Deposit apply to room payment */}
{activeDeposit && (activeDeposit.status === 'hold_confirmed' || activeDeposit.status === 'paid_cash') && (
<div className="rounded-lg border border-violet-200 dark:border-violet-700 bg-violet-50 dark:bg-violet-900/20 p-2.5 space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={depositApply}
onChange={e => {
setDepositApply(e.target.checked)
if (e.target.checked && !depositApplyAmt) {
setDepositApplyAmt(String(Math.min(activeDeposit.amount, balance > 0 ? balance : activeDeposit.amount)))
}
}}
className="rounded"
/>
<span className="text-xs font-medium text-violet-800 dark:text-violet-300">
Зачесть депозит {formatCurrency(activeDeposit.amount)} в счёт оплаты
</span>
</label>
{depositApply && (
<div className="flex items-center gap-2">
<span className="text-[11px] text-violet-600 dark:text-violet-400 shrink-0">Сумма:</span>
<input
type="number"
min={0}
max={activeDeposit.amount}
step={1}
value={depositApplyAmt}
onChange={e => setDepositApplyAmt(e.target.value)}
className="input py-0.5 text-xs w-28"
/>
<span className="text-[11px] text-violet-500 dark:text-violet-400">
{activeDeposit.paymentMethod === 'yookassa_hold' ? '(ЮКасса)' : '(наличные)'}
</span>
</div>
)}
</div>
)}
<div className="flex gap-2">
<button
onClick={() => setEarlyOutOpen(false)}