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:
2026-04-06 11:47:51 +03:00
parent 754f03b7fa
commit a4fc4a0972
8 changed files with 481 additions and 33 deletions

View File

@@ -47,6 +47,7 @@ import { TTLockPage } from './pages/TTLockPage'
import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage'
import { MinibarSettingsPage } from './pages/MinibarSettingsPage'
import { DepositSettingsPage } from './pages/DepositSettingsPage'
import { DepositHistoryPage } from './pages/DepositHistoryPage'
import { PayDepositPage } from './pages/PayDepositPage'
import { ModuleGuard } from './components/ModuleGuard'
@@ -105,7 +106,8 @@ export default function App() {
<Route path="/ttlock" element={<TTLockPage />} />
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
<Route path="/settings/deposit" element={<DepositSettingsPage />} />
<Route path="/settings/deposit" element={<DepositSettingsPage />} />
<Route path="/settings/deposit/history" element={<DepositHistoryPage />} />
</Route>
<Route path="/" element={<Navigate to="/login" replace />} />

View File

@@ -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} ` : ''}&bull;&bull;&bull;&bull; {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 && (

View File

@@ -764,6 +764,12 @@ export const api = {
captured_amount: capturedAmount,
reason,
}),
history: (slug: string) =>
req<BookingDeposit[]>('GET', `/api/hotels/${slug}/deposits`),
cancel: (slug: string, bookingId: string) =>
req<void>('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/deposit`),
},
}
@@ -1380,9 +1386,17 @@ export interface BookingDeposit {
capturedAmount: number | null
retentionReason: string | null
guestEmailSent: boolean
cardLast4: string | null
cardBrand: string | null
createdAt: string
paidAt: string | null
releasedAt: string | null
// history enrichment
guestName?: string
guestEmail?: string
checkIn?: string
checkOut?: string
roomNumber?: string
}
function toHotelPayload(h: HotelPayload): Record<string, unknown> {

View File

@@ -0,0 +1,165 @@
import { useState, useEffect } from 'react'
import { Loader2, ShieldCheck, CreditCard, Banknote, RefreshCw } from 'lucide-react'
import { api, type BookingDeposit } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import { cn, formatCurrency } from '../lib/utils'
import { format } from 'date-fns'
const STATUS_LABEL: Record<string, { label: string; cls: string }> = {
hold_created: { label: 'Ожидает оплаты', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
hold_confirmed: { label: 'Холд подтверждён', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
paid_cash: { label: 'Наличными', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' },
captured: { label: 'Списано', cls: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' },
refunded: { label: 'Возвращён', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
cancelled: { label: 'Отменён', cls: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400' },
}
export function DepositHistoryPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [deposits, setDeposits] = useState<BookingDeposit[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<string>('all')
const load = () => {
if (!slug) return
setLoading(true)
api.deposits.history(slug)
.then(setDeposits)
.catch(() => setDeposits([]))
.finally(() => setLoading(false))
}
useEffect(() => { load() }, [slug])
const filtered = filter === 'all'
? deposits
: deposits.filter(d => d.status === filter)
return (
<div className="p-4 md:p-6 space-y-5 max-w-4xl">
<div className="flex items-center justify-between flex-wrap gap-2">
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<ShieldCheck size={22} className="text-brand-600" />
История депозитов
</h1>
<button
onClick={load}
className="btn-secondary flex items-center gap-1.5 text-sm"
>
<RefreshCw size={14} />
Обновить
</button>
</div>
{/* Filter tabs */}
<div className="flex gap-1 flex-wrap">
{[
{ id: 'all', label: 'Все' },
{ id: 'hold_created', label: 'Ожидают' },
{ id: 'hold_confirmed', label: 'Холд' },
{ id: 'paid_cash', label: 'Наличные' },
{ id: 'captured', label: 'Списаны' },
{ id: 'refunded', label: 'Возвращены' },
].map(f => (
<button
key={f.id}
onClick={() => setFilter(f.id)}
className={cn(
'px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
filter === f.id
? 'bg-brand-600 text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600',
)}
>
{f.label}
</button>
))}
</div>
{loading ? (
<div className="flex justify-center py-10">
<Loader2 size={24} className="animate-spin text-brand-600" />
</div>
) : filtered.length === 0 ? (
<p className="text-sm text-slate-400 dark:text-slate-500 py-8 text-center">
Нет депозитов
</p>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 dark:border-slate-700 text-xs text-slate-500 dark:text-slate-400 uppercase tracking-wide">
<th className="text-left px-4 py-3 font-medium">Дата</th>
<th className="text-left px-4 py-3 font-medium">Номер</th>
<th className="text-left px-4 py-3 font-medium">Гость</th>
<th className="text-left px-4 py-3 font-medium">Метод</th>
<th className="text-right px-4 py-3 font-medium">Сумма</th>
<th className="text-left px-4 py-3 font-medium">Статус</th>
<th className="text-right px-4 py-3 font-medium">Удержано</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50 dark:divide-slate-800">
{filtered.map(dep => {
const st = STATUS_LABEL[dep.status] ?? { label: dep.status, cls: 'bg-slate-100 text-slate-600' }
return (
<tr key={dep.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors">
<td className="px-4 py-3 text-slate-600 dark:text-slate-300 whitespace-nowrap">
{format(new Date(dep.createdAt), 'dd.MM.yyyy')}
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-200 font-medium whitespace-nowrap">
{dep.roomNumber ? `${dep.roomNumber}` : '—'}
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-200">
<div>{dep.guestName ?? '—'}</div>
{dep.guestEmail && (
<div className="text-xs text-slate-400">{dep.guestEmail}</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
{dep.paymentMethod === 'yookassa_hold' ? (
<div className="flex items-center gap-1.5 text-slate-600 dark:text-slate-300">
<CreditCard size={13} />
<span>
{dep.cardBrand ? `${dep.cardBrand} ` : ''}
{dep.cardLast4 ? `•••• ${dep.cardLast4}` : 'ЮКасса'}
</span>
</div>
) : dep.paymentMethod === 'cash' ? (
<div className="flex items-center gap-1.5 text-slate-600 dark:text-slate-300">
<Banknote size={13} />
<span>Наличные</span>
</div>
) : '—'}
</td>
<td className="px-4 py-3 text-right font-medium text-slate-900 dark:text-slate-100 whitespace-nowrap">
{formatCurrency(dep.amount)}
</td>
<td className="px-4 py-3">
<span className={cn('inline-flex items-center text-xs px-2 py-0.5 rounded-full font-medium', st.cls)}>
{st.label}
</span>
{dep.retentionReason && (
<div className="text-xs text-slate-400 mt-0.5 max-w-[160px] truncate" title={dep.retentionReason}>
{dep.retentionReason}
</div>
)}
</td>
<td className="px-4 py-3 text-right text-slate-600 dark:text-slate-300 whitespace-nowrap">
{dep.capturedAmount != null && dep.capturedAmount > 0
? formatCurrency(dep.capturedAmount)
: dep.status === 'refunded' ? '0 ₽' : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Save, Eye, EyeOff, Loader2, ShieldCheck, Printer } from 'lucide-react'
import { Save, Eye, EyeOff, Loader2, ShieldCheck, Printer, History, Info } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { Link } from 'react-router-dom'
import { api, type DepositSettings } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils'
@@ -84,14 +85,23 @@ export function DepositSettingsPage() {
return (
<div className="p-4 md:p-6 space-y-6 max-w-2xl">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<ShieldCheck size={22} className="text-brand-600" />
Депозит
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Настройте предоплату или залог при заселении гостей.
</p>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<ShieldCheck size={22} className="text-brand-600" />
Депозит
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Настройте предоплату или залог при заселении гостей.
</p>
</div>
<Link
to="/settings/deposit/history"
className="btn-secondary flex items-center gap-1.5 text-sm shrink-0"
>
<History size={15} />
История платежей
</Link>
</div>
{error && (
@@ -174,6 +184,26 @@ export function DepositSettingsPage() {
</div>
</div>
{/* Webhook notice */}
{shopId && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 space-y-1.5">
<p className="text-xs font-semibold text-blue-800 dark:text-blue-300 flex items-center gap-1.5">
<Info size={13} /> Настройте webhook в ЮКасса
</p>
<p className="text-xs text-blue-700 dark:text-blue-400">
Чтобы статус депозита обновлялся автоматически после оплаты, добавьте URL уведомлений в личном кабинете ЮКасса (Настройки HTTP-уведомления):
</p>
<code className="block text-xs font-mono bg-blue-100 dark:bg-blue-900/40 text-blue-900 dark:text-blue-200 rounded px-2 py-1 break-all select-all">
https://api.hotelsync.ru/api/webhooks/yookassa
</code>
<p className="text-xs text-blue-600 dark:text-blue-400">
Подписки: <strong>payment.waiting_for_capture</strong>, <strong>payment.succeeded</strong>, <strong>payment.canceled</strong>
</p>
</div>
</div>
)}
{/* QR code section — only when shopId is configured */}
{shopId && (
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">