feat: deposit module — QR flow, PayDepositPage, BookingDetailPanel widget

- Backend: GET /api/pay/:slug (public) — returns active hold confirmation URL
- PayDepositPage: public page /:slug/pay for guests (no login required)
- DepositSettingsPage: QR code section with print button
- BookingDetailPanel: DepositWidget in payment tab
  - Наличными / ЮКасса (QR) buttons
  - hold_created: copy link + refresh
  - hold_confirmed: release form (вернуть / списать)
  - Final status badges

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:22:55 +03:00
parent b91859218a
commit 754f03b7fa
7 changed files with 470 additions and 2 deletions

View File

@@ -5,13 +5,14 @@ import {
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound,
ShieldCheck, QrCode, Copy, RefreshCw,
} from 'lucide-react'
import {
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
SOURCE_COLORS, SOURCE_LABELS, formatCurrency, nightsCount,
} from '../../lib/utils'
import type { Booking, Room } from '../../types'
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType } from '../../lib/api'
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings } from '../../lib/api'
import { getIdentity, type AgentIdentity } from '../../lib/agent'
const fmtDate = (iso: string) =>
@@ -54,6 +55,267 @@ const DOCUMENTS = [
{ id: 'act', label: 'Акт об оказании услуг', desc: 'Закрывающий документ при выезде', icon: FileCheck, always: false },
]
// ─── DepositWidget ────────────────────────────────────────────────────────────
function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string }) {
const [depositSettings, setDepositSettings] = useState<DepositSettings | null>(null)
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
const [depositLoading, setDepositLoading] = useState(true)
const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null)
const [depositReleasing, setDepositReleasing] = useState(false)
const [captureAmount, setCaptureAmount] = useState('0')
const [retentionReason, setRetentionReason] = useState('')
const [showReleaseForm, setShowReleaseForm] = useState(false)
const [yookassaMsg, setYookassaMsg] = useState<string | null>(null)
const [releaseError, setReleaseError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
api.deposits.getSettings(slug),
api.deposits.getBookingDeposit(slug, bookingId).catch(() => null),
]).then(([settings, dep]) => {
setDepositSettings(settings)
setDeposit(dep)
}).catch(() => {
// silently ignore — deposit module may not be available
}).finally(() => setDepositLoading(false))
}, [slug, bookingId])
const handleCreateCash = async () => {
setDepositCreating('cash')
try {
const dep = await api.deposits.payByCash(slug, bookingId)
setDeposit(dep)
} catch {
// ignore
} finally {
setDepositCreating(null)
}
}
const handleCreateYookassa = async () => {
setDepositCreating('yookassa')
try {
const dep = await api.deposits.createYookassaHold(slug, bookingId)
setDeposit(dep)
setYookassaMsg('QR-код активирован. Гость может сканировать QR на стойке ресепшена.')
} catch {
// ignore
} finally {
setDepositCreating(null)
}
}
const handleRefresh = async () => {
try {
const dep = await api.deposits.getBookingDeposit(slug, bookingId)
setDeposit(dep)
} catch {
// ignore
}
}
const handleRelease = async () => {
setDepositReleasing(true)
setReleaseError(null)
try {
const captured = parseFloat(captureAmount) || 0
const dep = await api.deposits.release(slug, bookingId, captured, retentionReason || undefined)
setDeposit(dep)
setShowReleaseForm(false)
} catch {
setReleaseError('Не удалось выполнить операцию')
} finally {
setDepositReleasing(false)
}
}
const copyPayLink = () => {
navigator.clipboard.writeText(`https://app.hotelsync.ru/${slug}/pay`).catch(() => {})
}
if (depositLoading) {
return <Loader2 size={14} className="animate-spin text-slate-400 mt-3" />
}
if (!depositSettings?.isEnabled) return null
const badgeBase = 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium'
return (
<div>
<p className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mt-4 mb-2 flex items-center gap-1">
<ShieldCheck size={14} /> Депозит
</p>
{yookassaMsg && (
<div className="mb-2 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 p-2 text-xs text-emerald-700 dark:text-emerald-300">
{yookassaMsg}
</div>
)}
{/* No deposit yet */}
{deposit === null && (
<div className="space-y-2">
<p className="text-xs text-slate-500 dark:text-slate-400">
Сумма: {formatCurrency(depositSettings.amount)}
</p>
<div className="flex gap-2">
<button
onClick={handleCreateCash}
disabled={depositCreating !== null}
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 disabled:opacity-50"
>
{depositCreating === 'cash' ? <Loader2 size={12} className="animate-spin" /> : <Banknote size={12} />}
Наличными
</button>
{depositSettings.yookassaShopId && (
<button
onClick={handleCreateYookassa}
disabled={depositCreating !== null}
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 disabled:opacity-50"
>
{depositCreating === 'yookassa' ? <Loader2 size={12} className="animate-spin" /> : <QrCode size={12} />}
ЮКасса (QR)
</button>
)}
</div>
</div>
)}
{/* hold_created — awaiting card payment */}
{deposit?.status === 'hold_created' && (
<div className="space-y-2">
<span className={cn(badgeBase, 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400')}>
Ожидает оплаты по карте
</span>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-xs text-slate-500 font-mono truncate flex-1">
https://app.hotelsync.ru/{slug}/pay
</span>
<button
onClick={copyPayLink}
className="shrink-0 p-1 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400 hover:text-slate-600 transition-colors"
title="Скопировать ссылку"
>
<Copy size={12} />
</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>
)}
{/* hold_confirmed — held on card */}
{deposit?.status === 'hold_confirmed' && (
<div className="space-y-2">
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
Холд подтверждён
</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>
)}
{/* paid_cash */}
{deposit?.status === 'paid_cash' && (
<div className="space-y-2">
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
Наличными
</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>
)}
{/* 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>
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
Сумма удержания (0 = полный возврат),
</label>
<input
type="number"
min="0"
step="100"
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>
<textarea
value={retentionReason}
onChange={e => setRetentionReason(e.target.value)}
rows={2}
className="input text-sm w-full resize-none"
placeholder="Повреждение имущества…"
/>
</div>
{releaseError && (
<p className="text-xs text-red-600 dark:text-red-400">{releaseError}</p>
)}
<div className="flex gap-2">
<button
onClick={() => { setShowReleaseForm(false); setReleaseError(null) }}
className="btn-secondary flex-1 justify-center text-xs py-1.5"
>
Отмена
</button>
<button
onClick={handleRelease}
disabled={depositReleasing}
className="btn-primary flex-1 justify-center text-xs py-1.5 flex items-center gap-1"
>
{depositReleasing && <Loader2 size={11} className="animate-spin" />}
Подтвердить
</button>
</div>
</div>
)}
{/* Final statuses */}
{deposit?.status === 'captured' && (
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
Списано {formatCurrency(deposit.capturedAmount ?? 0)}
</span>
)}
{deposit?.status === 'refunded' && (
<span className={cn(badgeBase, 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400')}>
Возвращён
</span>
)}
{deposit?.status === 'cancelled' && (
<span className={cn(badgeBase, 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300')}>
Отменён
</span>
)}
</div>
)
}
// ─── Component ────────────────────────────────────────────────────────────────
interface BookingDetailPanelProps {
@@ -1309,6 +1571,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
</div>
</div>
)}
{/* Deposit widget */}
{slug && (
<DepositWidget slug={slug} bookingId={booking.id} />
)}
</div>
)}