feat: deposit deductions, booking payments persistence, minibar in release form

- Migrations 061 (booking_payments) + 062 (deposit_deduction_presets)
- Backend: booking payments CRUD (GET/POST/DELETE), auto-updates paid_amount on booking
- Backend: deposit deduction presets CRUD (GET/POST/PATCH/DELETE)
- Backend: GET /deposit/minibar endpoint returns minibar consumptions for booking
- Backend: release endpoint accepts items[] for itemized email to guest
- Frontend: payments loaded from API — persist across page reloads
- Frontend: deposit release form redesigned — preset buttons, minibar auto-fill, itemized list
- Frontend: onFocus select on all amount inputs (no more manual "0" removal)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:55:42 +03:00
parent 633625e7be
commit 1ab24dfe27
7 changed files with 478 additions and 87 deletions

View File

@@ -12,7 +12,7 @@ import {
SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount,
} from '../../lib/utils'
import type { Booking, Room } from '../../types'
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings } from '../../lib/api'
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings, type BookingPayment, type DepositPreset } from '../../lib/api'
import { getIdentity, type AgentIdentity } from '../../lib/agent'
const fmtDate = (iso: string) =>
@@ -57,6 +57,8 @@ const DOCUMENTS = [
// ─── DepositWidget ────────────────────────────────────────────────────────────
type ReleaseItem = { id: string; name: string; amount: string }
function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) {
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
@@ -64,19 +66,27 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null)
const [depositCancelling, setDepositCancelling] = useState(false)
const [depositReleasing, setDepositReleasing] = useState(false)
const [captureAmount, setCaptureAmount] = useState('0')
const [retentionReason, setRetentionReason] = useState('')
const [showReleaseForm, setShowReleaseForm] = useState(false)
const [releaseItems, setReleaseItems] = useState<ReleaseItem[]>([])
const [releaseComment, setReleaseComment] = useState('')
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
const [releaseError, setReleaseError] = useState<string | null>(null)
const [presets, setPresets] = useState<DepositPreset[]>([])
const [minibarItems, setMinibarItems] = useState<Array<{ itemName: string; quantity: number; priceAtTime: number; lineTotal: number }>>([])
const [minibarTotal, setMinibarTotal] = useState(0)
useEffect(() => {
Promise.all([
api.deposits.getSettings(slug),
api.deposits.getBookingDeposit(slug, bookingId).catch(() => null),
]).then(([settings, dep]) => {
api.deposits.getPresets(slug).catch(() => []),
api.deposits.getMinibarForBooking(slug, bookingId).catch(() => ({ items: [], total: 0 })),
]).then(([settings, dep, presetList, minibar]) => {
setDepositSettings(settings)
setDeposit(dep)
setPresets(presetList)
setMinibarItems(minibar.items)
setMinibarTotal(minibar.total)
}).catch(() => {
// silently ignore — deposit module may not be available
}).finally(() => setDepositLoading(false))
@@ -116,12 +126,16 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
}
}
const releaseTotalAmount = releaseItems.reduce((s, i) => s + (parseFloat(i.amount) || 0), 0)
const handleRelease = async () => {
setDepositReleasing(true)
setReleaseError(null)
try {
const captured = parseFloat(captureAmount) || 0
const dep = await api.deposits.release(slug, bookingId, captured, retentionReason || undefined)
const items = releaseItems
.filter(i => parseFloat(i.amount) > 0)
.map(i => ({ name: i.name, amount: parseFloat(i.amount) }))
const dep = await api.deposits.release(slug, bookingId, releaseTotalAmount, releaseComment || undefined, items)
setDeposit(dep)
setShowReleaseForm(false)
} catch {
@@ -131,6 +145,14 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
}
}
const addReleaseItem = (name: string, amount: number) => {
setReleaseItems(prev => [...prev, { id: `item-${Date.now()}`, name, amount: String(amount) }])
}
const removeReleaseItem = (id: string) => {
setReleaseItems(prev => prev.filter(i => i.id !== id))
}
const handleCancel = async () => {
if (!window.confirm('Отменить депозит?')) return
setDepositCancelling(true)
@@ -295,52 +317,90 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-600 p-3 space-y-3 bg-slate-50 dark:bg-slate-800/40">
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Возврат / Удержание депозита</p>
{/* Presets */}
<div className="flex gap-1.5 flex-wrap">
{/* Quick presets from settings */}
{(presets.length > 0 || minibarTotal > 0) && (
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1.5">Добавить позицию:</p>
<div className="flex gap-1.5 flex-wrap">
{minibarTotal > 0 && (
<button
onClick={() => addReleaseItem(`Минибар (${minibarItems.length} поз.)`, minibarTotal)}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-amber-300 text-amber-700 bg-amber-50 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-700 hover:bg-amber-100 dark:hover:bg-amber-900/40 transition-colors"
>
+ Минибар {formatCurrency(minibarTotal)}
</button>
)}
{presets.map(p => (
<button
key={p.id}
onClick={() => addReleaseItem(p.name, p.amount)}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
+ {p.name} {formatCurrency(p.amount)}
</button>
))}
<button
onClick={() => addReleaseItem('Прочее', 0)}
className="px-2.5 py-1 rounded-lg text-xs font-medium border border-dashed border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
+ Прочее
</button>
</div>
</div>
)}
{/* Added items */}
{releaseItems.length > 0 && (
<div className="space-y-1.5">
{releaseItems.map(item => (
<div key={item.id} className="flex items-center gap-2">
<span className="text-xs text-slate-600 dark:text-slate-300 flex-1 truncate">{item.name}</span>
<input
type="number"
min="0"
step="100"
value={item.amount}
onFocus={e => e.target.select()}
onChange={e => setReleaseItems(prev => prev.map(i => i.id === item.id ? { ...i, amount: e.target.value } : i))}
className="input text-xs w-24 text-right"
placeholder="0"
/>
<span className="text-xs text-slate-400"></span>
<button onClick={() => removeReleaseItem(item.id)} className="text-slate-400 hover:text-red-500 transition-colors">
<X size={12} />
</button>
</div>
))}
<div className="flex justify-between items-center pt-1 border-t border-slate-200 dark:border-slate-600">
<span className="text-xs font-semibold text-slate-700 dark:text-slate-300">Итого удержание:</span>
<span className="text-xs font-bold text-slate-900 dark:text-slate-100">{formatCurrency(releaseTotalAmount)}</span>
</div>
</div>
)}
{/* No items — full return */}
{releaseItems.length === 0 && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 font-medium"> Полный возврат депозита</p>
)}
{/* Quick add if no presets */}
{presets.length === 0 && minibarTotal === 0 && (
<button
onClick={() => setCaptureAmount('0')}
className={cn('px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
captureAmount === '0'
? 'bg-emerald-600 text-white border-emerald-600'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700')}
onClick={() => addReleaseItem('Прочее', 0)}
className="text-xs text-brand-600 hover:text-brand-700 font-medium"
>
Полный возврат
+ Добавить позицию удержания
</button>
<button
onClick={() => setCaptureAmount(String(deposit.amount))}
className={cn('px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
captureAmount === String(deposit.amount)
? 'bg-red-600 text-white border-red-600'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700')}
>
Удержать всё ({formatCurrency(deposit.amount)})
</button>
</div>
)}
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
Сумма удержания (0 = полный возврат),
</label>
<input
type="number"
min="0"
step="100"
max={deposit.amount}
value={captureAmount}
onChange={e => setCaptureAmount(e.target.value)}
className="input text-sm w-full"
/>
</div>
<div>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
Причина{Number(captureAmount) > 0 ? ' (обязательно, отправим гостю на email)' : ' (необязательно)'}
</label>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">Комментарий (отправим гостю на email)</label>
<textarea
value={retentionReason}
onChange={e => setRetentionReason(e.target.value)}
value={releaseComment}
onChange={e => setReleaseComment(e.target.value)}
rows={2}
className="input text-sm w-full resize-none"
placeholder="Например: повреждение имущества, штраф за курение…"
placeholder="Например: нарушение правил отеля…"
/>
</div>
{releaseError && (
@@ -348,7 +408,7 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
)}
<div className="flex gap-2">
<button
onClick={() => { setShowReleaseForm(false); setReleaseError(null) }}
onClick={() => { setShowReleaseForm(false); setReleaseError(null); setReleaseItems([]) }}
className="btn-secondary flex-1 justify-center text-xs py-1.5"
>
Отмена
@@ -359,7 +419,7 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
className="btn-primary flex-1 justify-center text-xs py-1.5 flex items-center gap-1"
>
{depositReleasing && <Loader2 size={11} className="animate-spin" />}
Подтвердить
{releaseTotalAmount > 0 ? `Списать ${formatCurrency(releaseTotalAmount)}` : 'Вернуть депозит'}
</button>
</div>
</div>
@@ -689,11 +749,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
if (refundNights > 0) {
const refundAmt = Math.round(refundNights * booking.totalAmount / origNights)
setPayments(prev => [...prev, {
id: `refund-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: -refundAmt,
method: earlyOutMethod,
id: `refund-${Date.now()}`, hotelId: '', bookingId: booking.id,
amount: -refundAmt, method: earlyOutMethod,
note: `Возврат за ${refundNights} неиспользованных ${refundNights === 1 ? 'ночь' : refundNights < 5 ? 'ночи' : 'ночей'}`,
createdAt: new Date().toISOString(), createdByName: null,
}])
}
await onUpdate(booking.id, { checkOut: earlyOutDate, status: 'checked_out' })
@@ -793,20 +852,27 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const setP = <K extends keyof PassportData>(k: K, v: PassportData[K]) =>
setPassport(prev => ({ ...prev, [k]: v }))
// Payments
const [payments, setPayments] = useState<Payment[]>(() =>
booking.paidAmount > 0
? [{ id: 'init', date: booking.createdAt, amount: booking.paidAmount, method: 'card', note: 'Предоплата при бронировании' }]
: []
)
// Payments — loaded from API
const [payments, setPayments] = useState<BookingPayment[]>([])
const [paymentsLoaded, setPaymentsLoaded] = useState(false)
const [showPayForm, setShowPayForm] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [payNote, setPayNote] = useState('')
const [payAdding, setPayAdding] = useState(false)
// Discount
const [discountId, setDiscountId] = useState('')
// Load payments from API when payment tab is opened
useEffect(() => {
if (!slug || paymentsLoaded) return
api.payments.list(slug, booking.id)
.then(setPayments)
.catch(() => {})
.finally(() => setPaymentsLoaded(true))
}, [slug, booking.id, paymentsLoaded])
// Toast
const [toast, setToast] = useState('')
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
@@ -822,22 +888,28 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
: Math.min(baseTotal, selDiscount.value)
: 0
const finalTotal = baseTotal - discountAmt
const totalPaid = payments.reduce((s, p) => s + p.amount, 0)
const totalPaid = payments.reduce((s, p) => s + Number(p.amount), 0)
const balance = finalTotal - totalPaid
const setStatus = (status: typeof booking.status) => onUpdate(booking.id, { status })
const addPayment = () => {
const addPayment = async () => {
if (!slug) return
const amt = parseFloat(payAmount)
if (!amt || amt <= 0) return
setPayments(prev => [...prev, {
id: `p-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: amt, method: payMethod, note: payNote,
}])
setPayAmount(''); setPayNote('')
setShowPayForm(false)
showToast(`✓ Оплата ${formatCurrency(amt)} принята`)
setPayAdding(true)
try {
const payment = await api.payments.add(slug, booking.id, amt, payMethod, payNote || undefined)
setPayments(prev => [...prev, payment])
onUpdate(booking.id, { paidAmount: payments.reduce((s, p) => s + Number(p.amount), 0) + amt })
setPayAmount(''); setPayNote('')
setShowPayForm(false)
showToast(`✓ Оплата ${formatCurrency(amt)} принята`)
} catch {
showToast('Ошибка при сохранении оплаты')
} finally {
setPayAdding(false)
}
}
const printDoc = (label: string) => showToast(`🖨 «${label}» отправлен на печать`)
@@ -1588,7 +1660,9 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
<div>
<label className={lbl}>Сумма, </label>
<input type="number" className="input text-sm" value={payAmount}
onChange={e => setPayAmount(e.target.value)} placeholder="0" min="0" />
onChange={e => setPayAmount(e.target.value)}
onFocus={e => e.target.select()}
placeholder="0" min="0" />
</div>
<div>
<label className={lbl}>Способ оплаты</label>
@@ -1615,8 +1689,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
</div>
<div className="flex gap-2">
<button onClick={() => setShowPayForm(false)} className="btn-secondary flex-1 justify-center text-sm py-2">Отмена</button>
<button onClick={addPayment} disabled={!payAmount || parseFloat(payAmount) <= 0}
className="btn-primary flex-1 justify-center text-sm py-2">Принять</button>
<button onClick={addPayment} disabled={!payAmount || parseFloat(payAmount) <= 0 || payAdding}
className="btn-primary flex-1 justify-center text-sm py-2 flex items-center gap-1">
{payAdding && <Loader2 size={13} className="animate-spin" />}
Принять
</button>
</div>
</div>
)}
@@ -1627,13 +1704,13 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
<p className={lbl + ' mt-1'}>История платежей</p>
<div className="space-y-1.5">
{payments.map(p => {
const M = METHODS.find(m => m.id === p.method)!
const M = METHODS.find(m => m.id === p.method) ?? METHODS[0]
return (
<div key={p.id} className="flex items-center gap-2 text-sm px-2.5 py-2 rounded-lg bg-slate-50 dark:bg-slate-700/40">
<M.icon size={13} className="text-slate-400 shrink-0" />
<span className="text-slate-400 text-xs shrink-0">{p.date}</span>
<span className="text-slate-400 text-xs shrink-0">{format(new Date(p.createdAt), 'dd.MM.yyyy')}</span>
<span className="flex-1 text-slate-600 dark:text-slate-300 text-xs truncate">{p.note || M.label}</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(p.amount)}</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(Number(p.amount))}</span>
</div>
)
})}
@@ -1717,11 +1794,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
onClick={() => {
if (isEarlyArrival) {
setPayments(prev => [...prev, {
id: `early-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: room!.earlyCheckinFee!,
method: 'cash' as const,
id: `early-${Date.now()}`, hotelId: '', bookingId: booking.id,
amount: room!.earlyCheckinFee!, method: 'cash' as const,
note: `Ранний заезд (заезд до ${hotelCheckInTime})`,
createdAt: new Date().toISOString(), createdByName: null,
}])
}
setStatus('checked_in')
@@ -1761,11 +1837,10 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
onClick={() => {
if (isLateCheckout) {
setPayments(prev => [...prev, {
id: `late-${Date.now()}`,
date: new Date().toLocaleDateString('ru-RU'),
amount: room!.lateCheckoutFee!,
method: 'cash' as const,
id: `late-${Date.now()}`, hotelId: '', bookingId: booking.id,
amount: room!.lateCheckoutFee!, method: 'cash' as const,
note: `Поздний выезд (выезд после ${hotelCheckOutTime})`,
createdAt: new Date().toISOString(), createdByName: null,
}])
}
setEarlyOutOpen(true)