feat: deposit — history page, card info, cancel button, webhook notice, online booking email
- Migration 060: card_last4, card_brand columns in booking_deposits - Backend: webhook saves card brand/last4 from YooKassa notification - Backend: GET /api/hotels/:slug/deposits — payment history with guest/room info - Backend: DELETE /api/hotels/:slug/bookings/:bookingId/deposit — cancel hold_created or paid_cash deposit - Backend: auto-create YooKassa hold + send deposit email for source='online' bookings - Frontend: DepositHistoryPage with filters, card display, retention reasons - Frontend: deposit cancel button for hold_created and paid_cash states - Frontend: show card last4/brand in hold_confirmed status - Frontend: release form presets (full return / hold all) + improved UX - Frontend: webhook setup instructions in DepositSettingsPage - Frontend: "История платежей" link in deposit settings header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
|
||||
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
|
||||
Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound,
|
||||
ShieldCheck, QrCode, Copy, RefreshCw,
|
||||
ShieldCheck, QrCode, Copy, RefreshCw, Ban,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
|
||||
@@ -62,6 +62,7 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
||||
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
|
||||
const [depositLoading, setDepositLoading] = useState(true)
|
||||
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('')
|
||||
@@ -130,6 +131,20 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!window.confirm('Отменить депозит?')) return
|
||||
setDepositCancelling(true)
|
||||
try {
|
||||
await api.deposits.cancel(slug, bookingId)
|
||||
setDeposit(null)
|
||||
setYookassaMsg(null)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setDepositCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyPayLink = () => {
|
||||
navigator.clipboard.writeText(`https://app.hotelsync.ru/${slug}/pay`).catch(() => {})
|
||||
}
|
||||
@@ -202,12 +217,22 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">QR-код на ресепшене активен</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<RefreshCw size={11} /> Обновить статус
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<RefreshCw size={11} /> Обновить статус
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={depositCancelling}
|
||||
className="flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{depositCancelling ? <Loader2 size={11} className="animate-spin" /> : <Ban size={11} />}
|
||||
Отменить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -229,6 +254,14 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* hold_confirmed — show card info */}
|
||||
{deposit?.status === 'hold_confirmed' && deposit.cardLast4 && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 mb-1">
|
||||
<CreditCard size={12} />
|
||||
{deposit.cardBrand ? `${deposit.cardBrand} ` : ''}•••• {deposit.cardLast4}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* paid_cash */}
|
||||
{deposit?.status === 'paid_cash' && (
|
||||
<div className="space-y-2">
|
||||
@@ -237,20 +270,53 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
||||
</span>
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
|
||||
{!showReleaseForm && (
|
||||
<button
|
||||
onClick={() => setShowReleaseForm(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
Вернуть
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowReleaseForm(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
Вернуть / Списать
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={depositCancelling}
|
||||
className="flex items-center gap-1 text-xs text-red-500 hover:text-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{depositCancelling ? <Loader2 size={11} className="animate-spin" /> : <Ban size={11} />}
|
||||
Отменить запись
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Release form */}
|
||||
{showReleaseForm && deposit && (deposit.status === 'hold_confirmed' || deposit.status === 'paid_cash') && (
|
||||
<div className="mt-2 rounded-xl border border-slate-200 dark:border-slate-600 p-3 space-y-2 bg-slate-50 dark:bg-slate-800/40">
|
||||
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">Возврат депозита</p>
|
||||
<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">
|
||||
<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')}
|
||||
>
|
||||
Полный возврат
|
||||
</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 = полный возврат), ₽
|
||||
@@ -259,19 +325,22 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
||||
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">Причина (необязательно)</label>
|
||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
|
||||
Причина{Number(captureAmount) > 0 ? ' (обязательно, отправим гостю на email)' : ' (необязательно)'}
|
||||
</label>
|
||||
<textarea
|
||||
value={retentionReason}
|
||||
onChange={e => setRetentionReason(e.target.value)}
|
||||
rows={2}
|
||||
className="input text-sm w-full resize-none"
|
||||
placeholder="Повреждение имущества…"
|
||||
placeholder="Например: повреждение имущества, штраф за курение…"
|
||||
/>
|
||||
</div>
|
||||
{releaseError && (
|
||||
|
||||
Reference in New Issue
Block a user