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:
304
src/pages/PaymentSettingsPage.tsx
Normal file
304
src/pages/PaymentSettingsPage.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Trash2, Pencil, Check, X, Loader2, CreditCard, GripVertical, ArrowUp, ArrowDown } from 'lucide-react'
|
||||
import { api, type HotelPaymentMethod } from '../lib/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'GBP', 'CNY', 'AED', 'KZT', 'BYN', 'AMD', 'GEL']
|
||||
|
||||
const METHOD_TYPES: Array<{ id: HotelPaymentMethod['type']; label: string }> = [
|
||||
{ id: 'cash', label: 'Наличные' },
|
||||
{ id: 'card', label: 'Банковская карта' },
|
||||
{ id: 'transfer', label: 'Перевод / СБП' },
|
||||
{ id: 'other', label: 'Другое' },
|
||||
]
|
||||
|
||||
const CHECKIN_OPTIONS: Array<{ id: 'none' | 'soft' | 'hard'; label: string; desc: string; color: string }> = [
|
||||
{ id: 'none', label: 'Не контролировать', desc: 'Заселение без проверки оплаты', color: 'border-slate-200 dark:border-slate-600' },
|
||||
{ id: 'soft', label: 'Предупреждение', desc: 'Показать предупреждение, но разрешить заселить', color: 'border-amber-400 dark:border-amber-500' },
|
||||
{ id: 'hard', label: 'Блокировать', desc: 'Запретить заселение пока не оплачено', color: 'border-red-400 dark:border-red-500' },
|
||||
]
|
||||
|
||||
interface EditState {
|
||||
name: string
|
||||
currency: string
|
||||
type: HotelPaymentMethod['type']
|
||||
}
|
||||
|
||||
export function PaymentSettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const slug = user?.hotelSlug ?? ''
|
||||
|
||||
const [methods, setMethods] = useState<HotelPaymentMethod[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [requireCheckin, setRequireCheckin] = useState<'none' | 'soft' | 'hard'>('none')
|
||||
const [savingCheckin, setSavingCheckin] = useState(false)
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editState, setEditState] = useState<EditState>({ name: '', currency: 'RUB', type: 'cash' })
|
||||
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [newState, setNewState] = useState<EditState>({ name: '', currency: 'RUB', type: 'cash' })
|
||||
const [addingRow, setAddingRow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
Promise.all([
|
||||
api.paymentMethods.list(slug),
|
||||
api.paymentMethods.getSettings(slug).catch(() => ({ requirePaymentCheckin: 'none' as const })),
|
||||
]).then(([ms, s]) => {
|
||||
setMethods(ms)
|
||||
setRequireCheckin(s.requirePaymentCheckin)
|
||||
}).finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
const handleCheckinChange = async (val: 'none' | 'soft' | 'hard') => {
|
||||
setRequireCheckin(val)
|
||||
setSavingCheckin(true)
|
||||
try {
|
||||
await api.paymentMethods.updateSettings(slug, val)
|
||||
} catch {
|
||||
// revert would need old value — just keep it
|
||||
} finally {
|
||||
setSavingCheckin(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newState.name.trim()) return
|
||||
setAddingRow(true)
|
||||
try {
|
||||
const m = await api.paymentMethods.create(slug, {
|
||||
name: newState.name.trim(),
|
||||
currency: newState.currency,
|
||||
type: newState.type,
|
||||
sortOrder: methods.length,
|
||||
})
|
||||
setMethods(p => [...p, m])
|
||||
setAdding(false)
|
||||
setNewState({ name: '', currency: 'RUB', type: 'cash' })
|
||||
} catch { /* ignore */ } finally {
|
||||
setAddingRow(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (m: HotelPaymentMethod) => {
|
||||
setEditingId(m.id)
|
||||
setEditState({ name: m.name, currency: m.currency, type: m.type })
|
||||
}
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
if (!editState.name.trim()) return
|
||||
try {
|
||||
const updated = await api.paymentMethods.update(slug, id, {
|
||||
name: editState.name.trim(),
|
||||
currency: editState.currency,
|
||||
type: editState.type,
|
||||
})
|
||||
setMethods(p => p.map(m => m.id === id ? updated : m))
|
||||
} catch { /* ignore */ }
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const toggleActive = async (m: HotelPaymentMethod) => {
|
||||
try {
|
||||
const updated = await api.paymentMethods.update(slug, m.id, { isActive: !m.isActive })
|
||||
setMethods(p => p.map(x => x.id === m.id ? updated : x))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Удалить способ оплаты?')) return
|
||||
await api.paymentMethods.remove(slug, id).catch(() => {})
|
||||
setMethods(p => p.filter(m => m.id !== id))
|
||||
}
|
||||
|
||||
const moveItem = async (id: string, dir: -1 | 1) => {
|
||||
const idx = methods.findIndex(m => m.id === id)
|
||||
if (idx < 0) return
|
||||
const newIdx = idx + dir
|
||||
if (newIdx < 0 || newIdx >= methods.length) return
|
||||
const updated = [...methods]
|
||||
;[updated[idx], updated[newIdx]] = [updated[newIdx], updated[idx]]
|
||||
setMethods(updated)
|
||||
// Update sort_order for both
|
||||
await Promise.all([
|
||||
api.paymentMethods.update(slug, updated[idx].id, { sortOrder: idx }),
|
||||
api.paymentMethods.update(slug, updated[newIdx].id, { sortOrder: newIdx }),
|
||||
]).catch(() => {})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 size={24} className="animate-spin text-brand-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<CreditCard size={22} className="text-brand-600" />
|
||||
Настройки оплаты
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
Способы оплаты и контроль расчётов при заселении.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Check-in payment control */}
|
||||
<div className="card p-5 space-y-3">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">Контроль оплаты при заселении</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
Что делать если гость ещё не оплатил при нажатии кнопки «Заселить»
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{CHECKIN_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => handleCheckinChange(opt.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 rounded-xl border-2 text-left transition-colors',
|
||||
requireCheckin === opt.id
|
||||
? opt.color + ' bg-slate-50 dark:bg-slate-800'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600',
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'w-4 h-4 rounded-full border-2 shrink-0 flex items-center justify-center',
|
||||
requireCheckin === opt.id ? 'border-brand-600' : 'border-slate-300 dark:border-slate-600',
|
||||
)}>
|
||||
{requireCheckin === opt.id && <div className="w-2 h-2 rounded-full bg-brand-600" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{opt.label}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{opt.desc}</p>
|
||||
</div>
|
||||
{savingCheckin && requireCheckin === opt.id && <Loader2 size={14} className="animate-spin text-brand-600 shrink-0" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment methods */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
|
||||
<span className="font-semibold text-slate-800 dark:text-slate-200 text-sm">Способы оплаты</span>
|
||||
{!adding && (
|
||||
<button onClick={() => setAdding(true)} className="btn-primary py-1.5 px-3 text-sm flex items-center gap-1.5">
|
||||
<Plus size={14} /> Добавить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{/* Add form */}
|
||||
{adding && (
|
||||
<div className="px-4 py-3 bg-brand-50 dark:bg-brand-900/10">
|
||||
<div className="grid grid-cols-[1fr_100px_160px_auto] gap-2 items-center">
|
||||
<input
|
||||
autoFocus
|
||||
value={newState.name}
|
||||
onChange={e => setNewState(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="Название (напр. Наличные USD)"
|
||||
className="input py-1.5 text-sm"
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
/>
|
||||
<select value={newState.currency} onChange={e => setNewState(p => ({ ...p, currency: e.target.value }))} className="input py-1.5 text-sm">
|
||||
{CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select value={newState.type} onChange={e => setNewState(p => ({ ...p, type: e.target.value as HotelPaymentMethod['type'] }))} className="input py-1.5 text-sm">
|
||||
{METHOD_TYPES.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={handleAdd} disabled={!newState.name.trim() || addingRow} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||
{addingRow ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
</button>
|
||||
<button onClick={() => { setAdding(false); setNewState({ name: '', currency: 'RUB', type: 'cash' }) }} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{methods.length === 0 && !adding && (
|
||||
<p className="px-4 py-8 text-center text-sm text-slate-400">
|
||||
Нет способов оплаты. Нажмите «Добавить».
|
||||
</p>
|
||||
)}
|
||||
|
||||
{methods.map((m, idx) => (
|
||||
<div key={m.id} className={cn('px-4 py-3 group', !m.isActive && 'opacity-50')}>
|
||||
{editingId === m.id ? (
|
||||
<div className="grid grid-cols-[1fr_100px_160px_auto] gap-2 items-center">
|
||||
<input
|
||||
autoFocus
|
||||
value={editState.name}
|
||||
onChange={e => setEditState(p => ({ ...p, name: e.target.value }))}
|
||||
className="input py-1.5 text-sm"
|
||||
onKeyDown={e => e.key === 'Enter' && saveEdit(m.id)}
|
||||
/>
|
||||
<select value={editState.currency} onChange={e => setEditState(p => ({ ...p, currency: e.target.value }))} className="input py-1.5 text-sm">
|
||||
{CURRENCIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<select value={editState.type} onChange={e => setEditState(p => ({ ...p, type: e.target.value as HotelPaymentMethod['type'] }))} className="input py-1.5 text-sm">
|
||||
{METHOD_TYPES.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||
</select>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => saveEdit(m.id)} className="p-1.5 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20">
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button onClick={() => setEditingId(null)} className="p-1.5 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => moveItem(m.id, -1)} disabled={idx === 0} className="p-0.5 text-slate-300 hover:text-slate-500 disabled:opacity-20">
|
||||
<ArrowUp size={11} />
|
||||
</button>
|
||||
<button onClick={() => moveItem(m.id, 1)} disabled={idx === methods.length - 1} className="p-0.5 text-slate-300 hover:text-slate-500 disabled:opacity-20">
|
||||
<ArrowDown size={11} />
|
||||
</button>
|
||||
</div>
|
||||
<GripVertical size={14} className="text-slate-300 dark:text-slate-600 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">{m.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-400">{m.currency}</span>
|
||||
<span className="ml-1 text-xs text-slate-400">·</span>
|
||||
<span className="ml-1 text-xs text-slate-400">{METHOD_TYPES.find(t => t.id === m.type)?.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => toggleActive(m)}
|
||||
className={cn('text-xs px-2 py-0.5 rounded-full font-medium transition-colors', m.isActive ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400')}
|
||||
>
|
||||
{m.isActive ? 'Активен' : 'Скрыт'}
|
||||
</button>
|
||||
<button onClick={() => startEdit(m)} className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400">
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(m.id)} className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/20 text-slate-400 hover:text-red-500">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-3 text-xs text-blue-700 dark:text-blue-400">
|
||||
Способы оплаты появляются в панели бронирования при приёме платежей. Скрытые методы не отображаются.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user