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