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:
2
backend/migrations/060_deposit_card.sql
Normal file
2
backend/migrations/060_deposit_card.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE booking_deposits ADD COLUMN IF NOT EXISTS card_last4 VARCHAR(4);
|
||||||
|
ALTER TABLE booking_deposits ADD COLUMN IF NOT EXISTS card_brand VARCHAR(50);
|
||||||
@@ -3,6 +3,9 @@ import { db } from '../db'
|
|||||||
import { notifyNetupCheckin, notifyNetupCheckout } from './netup'
|
import { notifyNetupCheckin, notifyNetupCheckout } from './netup'
|
||||||
import { getHkSettings } from './housekeeping-settings'
|
import { getHkSettings } from './housekeeping-settings'
|
||||||
import { broadcast } from './ws'
|
import { broadcast } from './ws'
|
||||||
|
import { transporter } from '../email'
|
||||||
|
import { createHold } from '../services/yookassa'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||||
@@ -103,7 +106,69 @@ const bookings: FastifyPluginAsync = async (fastify) => {
|
|||||||
check_in, check_out, adults, children, status, source,
|
check_in, check_out, adults, children, status, source,
|
||||||
total_amount ?? 0, paid_amount, notes ?? null, tariff_id ?? null],
|
total_amount ?? 0, paid_amount, notes ?? null, tariff_id ?? null],
|
||||||
)
|
)
|
||||||
return reply.code(201).send(rows[0])
|
const booking = rows[0]
|
||||||
|
|
||||||
|
// Auto-create YooKassa hold + send deposit email for online bookings with email
|
||||||
|
if (source === 'online' && guest_email) {
|
||||||
|
try {
|
||||||
|
const { rows: depSettings } = await db.query(
|
||||||
|
`SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1 AND is_enabled = true`,
|
||||||
|
[hotelId],
|
||||||
|
)
|
||||||
|
const depCfg = depSettings[0]
|
||||||
|
if (depCfg?.yookassa_shop_id && depCfg?.yookassa_secret_key) {
|
||||||
|
const { rows: hotelRows } = await db.query(
|
||||||
|
'SELECT name, slug FROM hotels WHERE id = $1', [hotelId],
|
||||||
|
)
|
||||||
|
const hotel = hotelRows[0]
|
||||||
|
const appUrl = process.env.APP_URL ?? 'https://app.hotelsync.ru'
|
||||||
|
const payment = await createHold({
|
||||||
|
shopId: depCfg.yookassa_shop_id,
|
||||||
|
secretKey: depCfg.yookassa_secret_key,
|
||||||
|
amount: Number(depCfg.amount),
|
||||||
|
description: `Депозит — ${guest_name}`,
|
||||||
|
returnUrl: `${appUrl}/${hotel?.slug ?? slug}/bookings`,
|
||||||
|
idempotenceKey: randomUUID(),
|
||||||
|
})
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO booking_deposits
|
||||||
|
(hotel_id, booking_id, amount, status, payment_method,
|
||||||
|
yookassa_payment_id, yookassa_confirmation_url)
|
||||||
|
VALUES ($1,$2,$3,'hold_created','yookassa_hold',$4,$5)`,
|
||||||
|
[hotelId, booking.id, depCfg.amount, payment.id,
|
||||||
|
payment.confirmation?.confirmation_url ?? null],
|
||||||
|
)
|
||||||
|
const confirmUrl = payment.confirmation?.confirmation_url
|
||||||
|
if (confirmUrl) {
|
||||||
|
const checkInFmt = new Date(check_in).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
transporter.sendMail({
|
||||||
|
from: `"${hotel?.name ?? 'HotelSync'}" <${process.env.SMTP_USER ?? 'noreply@hotelsync.ru'}>`,
|
||||||
|
to: guest_email,
|
||||||
|
subject: `Страховой депозит — бронирование ${hotel?.name ?? ''}`,
|
||||||
|
text: [
|
||||||
|
`Уважаемый(ая) ${guest_name},`,
|
||||||
|
'',
|
||||||
|
`Ваше бронирование в ${hotel?.name ?? 'отеле'} на ${checkInFmt} подтверждено.`,
|
||||||
|
'',
|
||||||
|
`По условиям отеля при заселении взимается страховой депозит ${Number(depCfg.amount).toLocaleString('ru-RU')} ₽.`,
|
||||||
|
`Средства замораживаются (не списываются) и возвращаются при выезде.`,
|
||||||
|
'',
|
||||||
|
`Вы можете внести депозит заранее по ссылке:`,
|
||||||
|
confirmUrl,
|
||||||
|
'',
|
||||||
|
`Или отсканируйте QR-код на стойке ресепшена при заселении.`,
|
||||||
|
'',
|
||||||
|
`© ${hotel?.name ?? 'HotelSync'}`,
|
||||||
|
].join('\n'),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical: don't fail booking creation if deposit auto-send fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.code(201).send(booking)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -315,8 +315,99 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── DELETE /api/hotels/:slug/bookings/:bookingId/deposit ─────────────────
|
||||||
|
// Cancel/reset deposit (hold not paid yet, or cash entered by mistake)
|
||||||
|
fastify.delete<SlugBookingParam>(
|
||||||
|
'/api/hotels/:slug/bookings/:bookingId/deposit',
|
||||||
|
{ onRequest: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { slug, bookingId } = request.params
|
||||||
|
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||||
|
return reply.code(403).send({ error: 'Forbidden' })
|
||||||
|
}
|
||||||
|
const hotelId = await getHotelId(slug)
|
||||||
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
|
const { rows: depRows } = await db.query(
|
||||||
|
`SELECT * FROM booking_deposits
|
||||||
|
WHERE booking_id = $1 AND hotel_id = $2
|
||||||
|
AND status IN ('hold_created', 'paid_cash')
|
||||||
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
|
[bookingId, hotelId],
|
||||||
|
)
|
||||||
|
if (!depRows[0]) return reply.code(404).send({ error: 'No cancellable deposit' })
|
||||||
|
const dep = depRows[0]
|
||||||
|
|
||||||
|
// Try to cancel YooKassa hold if exists (ignore failures — payment may be in non-cancellable state)
|
||||||
|
if (dep.yookassa_payment_id) {
|
||||||
|
try {
|
||||||
|
const { rows: settingsRows } = await db.query(
|
||||||
|
'SELECT * FROM hotel_deposit_settings WHERE hotel_id = $1', [hotelId],
|
||||||
|
)
|
||||||
|
const settings = settingsRows[0]
|
||||||
|
if (settings?.yookassa_shop_id && settings?.yookassa_secret_key) {
|
||||||
|
await cancelPayment({
|
||||||
|
shopId: settings.yookassa_shop_id,
|
||||||
|
secretKey: settings.yookassa_secret_key,
|
||||||
|
paymentId: dep.yookassa_payment_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore — guest may not have started payment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`UPDATE booking_deposits SET status = 'cancelled', released_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[dep.id],
|
||||||
|
)
|
||||||
|
return reply.code(204).send()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── GET /api/hotels/:slug/deposits — history ─────────────────────────────
|
||||||
|
fastify.get<SlugParam>(
|
||||||
|
'/api/hotels/:slug/deposits',
|
||||||
|
{ onRequest: [fastify.authenticate] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { slug } = request.params
|
||||||
|
if (!canAccess(request.user.hotelSlug, request.user.role, slug)) {
|
||||||
|
return reply.code(403).send({ error: 'Forbidden' })
|
||||||
|
}
|
||||||
|
const hotelId = await getHotelId(slug)
|
||||||
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT d.*,
|
||||||
|
b.guest_name, b.guest_email, b.check_in, b.check_out,
|
||||||
|
r.number AS room_number
|
||||||
|
FROM booking_deposits d
|
||||||
|
JOIN bookings b ON b.id = d.booking_id
|
||||||
|
LEFT JOIN rooms r ON r.id = b.room_id
|
||||||
|
WHERE d.hotel_id = $1
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
[hotelId],
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ── POST /api/webhooks/yookassa ───────────────────────────────────────────
|
// ── POST /api/webhooks/yookassa ───────────────────────────────────────────
|
||||||
fastify.post<{ Body: { event: string; object: { id: string; status: string } } }>(
|
fastify.post<{
|
||||||
|
Body: {
|
||||||
|
event: string
|
||||||
|
object: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
payment_method?: {
|
||||||
|
type?: string
|
||||||
|
card?: { last4?: string; card_type?: string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}>(
|
||||||
'/api/webhooks/yookassa',
|
'/api/webhooks/yookassa',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { object } = request.body
|
const { object } = request.body
|
||||||
@@ -330,10 +421,20 @@ const deposit: FastifyPluginAsync = async (fastify) => {
|
|||||||
const newStatus = statusMap[object.status]
|
const newStatus = statusMap[object.status]
|
||||||
if (!newStatus) return { ok: true }
|
if (!newStatus) return { ok: true }
|
||||||
|
|
||||||
await db.query(
|
const card = object.payment_method?.card
|
||||||
`UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`,
|
if (card?.last4) {
|
||||||
[newStatus, object.id],
|
await db.query(
|
||||||
)
|
`UPDATE booking_deposits
|
||||||
|
SET status = $1, card_last4 = $2, card_brand = $3
|
||||||
|
WHERE yookassa_payment_id = $4`,
|
||||||
|
[newStatus, card.last4, card.card_type ?? null, object.id],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE booking_deposits SET status = $1 WHERE yookassa_payment_id = $2`,
|
||||||
|
[newStatus, object.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { TTLockPage } from './pages/TTLockPage'
|
|||||||
import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage'
|
import { ChecklistSettingsPage } from './pages/ChecklistSettingsPage'
|
||||||
import { MinibarSettingsPage } from './pages/MinibarSettingsPage'
|
import { MinibarSettingsPage } from './pages/MinibarSettingsPage'
|
||||||
import { DepositSettingsPage } from './pages/DepositSettingsPage'
|
import { DepositSettingsPage } from './pages/DepositSettingsPage'
|
||||||
|
import { DepositHistoryPage } from './pages/DepositHistoryPage'
|
||||||
import { PayDepositPage } from './pages/PayDepositPage'
|
import { PayDepositPage } from './pages/PayDepositPage'
|
||||||
import { ModuleGuard } from './components/ModuleGuard'
|
import { ModuleGuard } from './components/ModuleGuard'
|
||||||
|
|
||||||
@@ -105,7 +106,8 @@ export default function App() {
|
|||||||
<Route path="/ttlock" element={<TTLockPage />} />
|
<Route path="/ttlock" element={<TTLockPage />} />
|
||||||
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
|
<Route path="/settings/checklists" element={<ChecklistSettingsPage />} />
|
||||||
<Route path="/settings/minibar" element={<MinibarSettingsPage />} />
|
<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>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
|
Printer, ScanLine, Banknote, Building2, Plus, Pencil,
|
||||||
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
|
FileText, FileCheck, Receipt, IdCard, AlertTriangle,
|
||||||
Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound,
|
Trash2, Loader2, UserCheck, Baby, Search, LogIn, KeyRound,
|
||||||
ShieldCheck, QrCode, Copy, RefreshCw,
|
ShieldCheck, QrCode, Copy, RefreshCw, Ban,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
cn, BOOKING_STATUS_BADGE, BOOKING_STATUS_LABELS,
|
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 [deposit, setDeposit] = useState<BookingDeposit | null>(null)
|
||||||
const [depositLoading, setDepositLoading] = useState(true)
|
const [depositLoading, setDepositLoading] = useState(true)
|
||||||
const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null)
|
const [depositCreating, setDepositCreating] = useState<'cash' | 'yookassa' | null>(null)
|
||||||
|
const [depositCancelling, setDepositCancelling] = useState(false)
|
||||||
const [depositReleasing, setDepositReleasing] = useState(false)
|
const [depositReleasing, setDepositReleasing] = useState(false)
|
||||||
const [captureAmount, setCaptureAmount] = useState('0')
|
const [captureAmount, setCaptureAmount] = useState('0')
|
||||||
const [retentionReason, setRetentionReason] = useState('')
|
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 = () => {
|
const copyPayLink = () => {
|
||||||
navigator.clipboard.writeText(`https://app.hotelsync.ru/${slug}/pay`).catch(() => {})
|
navigator.clipboard.writeText(`https://app.hotelsync.ru/${slug}/pay`).catch(() => {})
|
||||||
}
|
}
|
||||||
@@ -202,12 +217,22 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400">QR-код на ресепшене активен</p>
|
<p className="text-xs text-slate-400">QR-код на ресепшене активен</p>
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
onClick={handleRefresh}
|
<button
|
||||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors"
|
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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -229,6 +254,14 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
|||||||
</div>
|
</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 */}
|
{/* paid_cash */}
|
||||||
{deposit?.status === 'paid_cash' && (
|
{deposit?.status === 'paid_cash' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -237,20 +270,53 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
|||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
|
<p className="text-xs text-slate-600 dark:text-slate-300">{formatCurrency(deposit.amount)}</p>
|
||||||
{!showReleaseForm && (
|
{!showReleaseForm && (
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={() => setShowReleaseForm(true)}
|
<button
|
||||||
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"
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Release form */}
|
{/* Release form */}
|
||||||
{showReleaseForm && deposit && (deposit.status === 'hold_confirmed' || deposit.status === 'paid_cash') && (
|
{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">
|
<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>
|
<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>
|
<div>
|
||||||
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
|
<label className="block text-xs text-slate-500 dark:text-slate-400 mb-0.5">
|
||||||
Сумма удержания (0 = полный возврат), ₽
|
Сумма удержания (0 = полный возврат), ₽
|
||||||
@@ -259,19 +325,22 @@ function DepositWidget({ slug, bookingId }: { slug: string; bookingId: string })
|
|||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="100"
|
step="100"
|
||||||
|
max={deposit.amount}
|
||||||
value={captureAmount}
|
value={captureAmount}
|
||||||
onChange={e => setCaptureAmount(e.target.value)}
|
onChange={e => setCaptureAmount(e.target.value)}
|
||||||
className="input text-sm w-full"
|
className="input text-sm w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<textarea
|
||||||
value={retentionReason}
|
value={retentionReason}
|
||||||
onChange={e => setRetentionReason(e.target.value)}
|
onChange={e => setRetentionReason(e.target.value)}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="input text-sm w-full resize-none"
|
className="input text-sm w-full resize-none"
|
||||||
placeholder="Повреждение имущества…"
|
placeholder="Например: повреждение имущества, штраф за курение…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{releaseError && (
|
{releaseError && (
|
||||||
|
|||||||
@@ -764,6 +764,12 @@ export const api = {
|
|||||||
captured_amount: capturedAmount,
|
captured_amount: capturedAmount,
|
||||||
reason,
|
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
|
capturedAmount: number | null
|
||||||
retentionReason: string | null
|
retentionReason: string | null
|
||||||
guestEmailSent: boolean
|
guestEmailSent: boolean
|
||||||
|
cardLast4: string | null
|
||||||
|
cardBrand: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
paidAt: string | null
|
paidAt: string | null
|
||||||
releasedAt: string | null
|
releasedAt: string | null
|
||||||
|
// history enrichment
|
||||||
|
guestName?: string
|
||||||
|
guestEmail?: string
|
||||||
|
checkIn?: string
|
||||||
|
checkOut?: string
|
||||||
|
roomNumber?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
function toHotelPayload(h: HotelPayload): Record<string, unknown> {
|
||||||
|
|||||||
165
src/pages/DepositHistoryPage.tsx
Normal file
165
src/pages/DepositHistoryPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { api, type DepositSettings } from '../lib/api'
|
import { api, type DepositSettings } from '../lib/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
@@ -84,14 +85,23 @@ export function DepositSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6 space-y-6 max-w-2xl">
|
<div className="p-4 md:p-6 space-y-6 max-w-2xl">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
<div>
|
||||||
<ShieldCheck size={22} className="text-brand-600" />
|
<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">
|
</h1>
|
||||||
Настройте предоплату или залог при заселении гостей.
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
</p>
|
Настройте предоплату или залог при заселении гостей.
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -174,6 +184,26 @@ export function DepositSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* QR code section — only when shopId is configured */}
|
||||||
{shopId && (
|
{shopId && (
|
||||||
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
|
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user