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