feat: настройки оплаты — способы оплаты + контроль при заселении

- Миграция 064: таблица hotel_payment_methods, поле require_payment_checkin
- Бэкенд: CRUD /api/hotels/:slug/payment-methods, /api/hotels/:slug/payment-settings
- PaymentSettingsPage (/settings/payments): управление способами оплаты
  (название, валюта, тип, активность, сортировка), контроль при заселении (нет/мягкий/жёсткий)
- BookingDetailPanel: динамические методы оплаты вместо захардкоженных
- Кнопка «Заселить»: hard — заблокирована при балансе > 0; soft — предупреждение с выбором

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 16:12:38 +03:00
parent 45a8cf7c10
commit f94d4723d5
8 changed files with 632 additions and 24 deletions

View File

@@ -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, type BookingPayment, type DepositPreset } from '../../lib/api'
import { api, type BookingGuest, type BookingGuestPayload, type GuestApiType, type BookingDeposit, type DepositSettings, type BookingPayment, type DepositPreset, type HotelPaymentMethod } from '../../lib/api'
import { getIdentity, type AgentIdentity } from '../../lib/agent'
const fmtDate = (iso: string) =>
@@ -891,10 +891,15 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
const [paymentsLoaded, setPaymentsLoaded] = useState(false)
const [showPayForm, setShowPayForm] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [payMethod, setPayMethod] = useState<'cash' | 'card' | 'transfer'>('cash')
const [payMethodId, setPayMethodId] = useState<string>('')
const [payNote, setPayNote] = useState('')
const [payAdding, setPayAdding] = useState(false)
// Hotel payment methods & settings
const [hotelPayMethods, setHotelPayMethods] = useState<HotelPaymentMethod[]>([])
const [requirePaymentCheckin, setRequirePaymentCheckin] = useState<'none' | 'soft' | 'hard'>('none')
const [checkinPayWarning, setCheckinPayWarning] = useState(false)
// Discount
const [discountId, setDiscountId] = useState('')
@@ -907,6 +912,20 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
.finally(() => setPaymentsLoaded(true))
}, [slug, booking.id, paymentsLoaded])
// Load hotel payment methods & settings once
useEffect(() => {
if (!slug) return
Promise.all([
api.paymentMethods.list(slug).catch(() => [] as HotelPaymentMethod[]),
api.paymentMethods.getSettings(slug).catch(() => ({ requirePaymentCheckin: 'none' as const })),
]).then(([ms, s]) => {
const active = ms.filter(m => m.isActive)
setHotelPayMethods(active)
if (active.length > 0) setPayMethodId(active[0].id)
setRequirePaymentCheckin(s.requirePaymentCheckin)
})
}, [slug])
// Toast
const [toast, setToast] = useState('')
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
@@ -931,9 +950,11 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
if (!slug) return
const amt = parseFloat(payAmount)
if (!amt || amt <= 0) return
// Use method name for storage (keeps backward compat)
const methodName = hotelPayMethods.find(m => m.id === payMethodId)?.name ?? payMethodId
setPayAdding(true)
try {
const payment = await api.payments.add(slug, booking.id, amt, payMethod, payNote || undefined)
const payment = await api.payments.add(slug, booking.id, amt, methodName, payNote || undefined)
setPayments(prev => [...prev, payment])
onUpdate(booking.id, { paidAmount: payments.reduce((s, p) => s + Number(p.amount), 0) + amt })
setPayAmount(''); setPayNote('')
@@ -1703,21 +1724,40 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
</div>
<div>
<label className={lbl}>Способ оплаты</label>
<div className="grid grid-cols-3 gap-1.5">
{METHODS.map(m => (
<button
key={m.id} type="button" onClick={() => setPayMethod(m.id)}
className={cn(
'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
payMethod === m.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
<m.icon size={14} /> {m.label}
</button>
))}
</div>
{hotelPayMethods.length > 0 ? (
<div className={cn('grid gap-1.5', hotelPayMethods.length <= 3 ? 'grid-cols-3' : 'grid-cols-2')}>
{hotelPayMethods.map(m => (
<button
key={m.id} type="button" onClick={() => setPayMethodId(m.id)}
className={cn(
'flex flex-col items-center gap-1 py-2 px-1 rounded-lg border text-xs font-medium transition-colors text-center leading-tight',
payMethodId === m.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
{m.type === 'cash' ? <Banknote size={14} /> : m.type === 'card' ? <CreditCard size={14} /> : m.type === 'transfer' ? <Building2 size={14} /> : <CreditCard size={14} />}
<span>{m.name}{m.currency !== 'RUB' ? ` (${m.currency})` : ''}</span>
</button>
))}
</div>
) : (
<div className="grid grid-cols-3 gap-1.5">
{METHODS.map(m => (
<button
key={m.id} type="button" onClick={() => setPayMethodId(m.id)}
className={cn(
'flex flex-col items-center gap-1 py-2 rounded-lg border text-xs font-medium transition-colors',
payMethodId === m.id
? 'bg-brand-600 border-brand-600 text-white'
: 'bg-white dark:bg-slate-700 border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300',
)}
>
<m.icon size={14} /> {m.label}
</button>
))}
</div>
)}
</div>
<div>
<label className={lbl}>Комментарий</label>
@@ -1741,12 +1781,16 @@ 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) ?? METHODS[0]
// Try hotel methods first, fallback to legacy METHODS
const hm = hotelPayMethods.find(m => m.name === p.method)
const legacyM = METHODS.find(m => m.id === p.method)
const Icon = hm ? (hm.type === 'cash' ? Banknote : hm.type === 'card' ? CreditCard : Building2) : (legacyM?.icon ?? Banknote)
const label = hm?.name ?? legacyM?.label ?? p.method
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" />
<Icon size={13} className="text-slate-400 shrink-0" />
<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="flex-1 text-slate-600 dark:text-slate-300 text-xs truncate">{p.note || label}</span>
<span className="font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">{formatCurrency(Number(p.amount))}</span>
</div>
)
@@ -1830,8 +1874,49 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
</p>
</div>
)}
{/* Payment warning for soft mode */}
{checkinPayWarning && balance > 0 && (
<div className="rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 space-y-2">
<div className="flex items-start gap-2">
<AlertTriangle size={14} className="text-amber-600 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 dark:text-amber-300 font-medium">
Гость не оплатил проживание. Остаток: <strong>{formatCurrency(balance)}</strong>
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => { setCheckinPayWarning(false); setTab('payment') }}
className="btn-primary flex-1 text-xs py-1.5 justify-center"
>
Принять оплату
</button>
<button
onClick={() => {
setCheckinPayWarning(false)
if (isEarlyArrival) {
setPayments(prev => [...prev, {
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')
}}
className="btn-secondary flex-1 text-xs py-1.5 justify-center text-amber-700 dark:text-amber-400"
>
Заселить без оплаты
</button>
</div>
</div>
)}
<button
onClick={() => {
if (requirePaymentCheckin === 'hard' && balance > 0) return
if (requirePaymentCheckin === 'soft' && balance > 0) {
setCheckinPayWarning(true)
return
}
if (isEarlyArrival) {
setPayments(prev => [...prev, {
id: `early-${Date.now()}`, hotelId: '', bookingId: booking.id,
@@ -1842,9 +1927,13 @@ export function BookingDetailPanel({ booking, room, rooms, allBookings, slug, on
}
setStatus('checked_in')
}}
className="w-full btn-primary justify-center"
disabled={requirePaymentCheckin === 'hard' && balance > 0}
className="w-full btn-primary justify-center disabled:opacity-50 disabled:cursor-not-allowed"
title={requirePaymentCheckin === 'hard' && balance > 0 ? `Необходимо принять оплату (остаток: ${formatCurrency(balance)})` : undefined}
>
<CheckCircle size={15} /> {isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'}
<CheckCircle size={15} />
{isEarlyArrival ? `Заселить (+ ${formatCurrency(room!.earlyCheckinFee!)})` : 'Заселить'}
{requirePaymentCheckin === 'hard' && balance > 0 && <span className="ml-1 text-xs opacity-75">· не оплачено</span>}
</button>
{slug && (
<button

View File

@@ -186,7 +186,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'],
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/payments', '/settings/minibar', '/settings/minibar/stock', '/settings/deposit'],
devGroup: ['/api-docs'],
}
@@ -421,6 +421,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
<NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />
<NavItem to="/settings/payments" icon={CreditCard} label="Способы оплаты" {...navItemProps} />
<NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />
<NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />
</div>