- Widget white page fix: fallback photo when real rooms have no photos array - Long-stay deposits: if booking > threshold days and toggle on → full charge instead of hold (bypasses YooKassa 7-day limit) - DepositSettingsPage: toggle + day threshold input for long-stay full payment - Refund endpoint: POST /deposit/refund supports full and partial refunds via YooKassa refunds API - DepositWidget: refund UI for yookassa_charge deposits (full/partial, shows remaining amount) - partially_refunded status support with badge and "Вернуть ещё" button - Auto-confirm: webhook now auto-confirms booking status on payment.succeeded for widget bookings - PaymentSettingsPage: auto-confirm toggle per gateway (shown when booking-widget module active) - Migration 067: long_stay columns on hotel_deposit_settings, auto_confirm_on_payment on gateways, refunded_amount on deposits Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
367 lines
16 KiB
TypeScript
367 lines
16 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { Save, Eye, EyeOff, Loader2, ShieldCheck, Printer, History, Info, Plus, Trash2, Check } from 'lucide-react'
|
||
import { QRCodeSVG } from 'qrcode.react'
|
||
import { Link } from 'react-router-dom'
|
||
import { api, type DepositSettings, type DepositPreset } from '../lib/api'
|
||
import { useAuth } from '../contexts/AuthContext'
|
||
import { cn, formatCurrency } from '../lib/utils'
|
||
|
||
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
|
||
return (
|
||
<button
|
||
onClick={onChange}
|
||
className={cn('relative w-10 rounded-full transition-colors shrink-0 h-[22px]', on ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600')}
|
||
>
|
||
<div className={cn('absolute top-0.5 rounded-full bg-white shadow-sm transition-transform w-[18px] h-[18px]', on ? 'left-[20px]' : 'left-0.5')} />
|
||
</button>
|
||
)
|
||
}
|
||
|
||
export function DepositSettingsPage() {
|
||
const { user } = useAuth()
|
||
const slug = user?.hotelSlug ?? ''
|
||
|
||
const [settings, setSettings] = useState<DepositSettings | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [saved, setSaved] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const [isEnabled, setIsEnabled] = useState(false)
|
||
const [amount, setAmount] = useState('5000')
|
||
const [releaseRequiresCheckout, setReleaseRequiresCheckout] = useState(false)
|
||
const [longStayFullPayment, setLongStayFullPayment] = useState(false)
|
||
const [longStayThresholdDays, setLongStayThresholdDays] = useState('7')
|
||
const [shopId, setShopId] = useState('')
|
||
const [secretKey, setSecretKey] = useState('')
|
||
const [showSecret, setShowSecret] = useState(false)
|
||
|
||
// Deduction presets
|
||
const [presets, setPresets] = useState<DepositPreset[]>([])
|
||
const [newPresetName, setNewPresetName] = useState('')
|
||
const [newPresetAmount, setNewPresetAmount] = useState('')
|
||
const [addingPreset, setAddingPreset] = useState(false)
|
||
const [editingPreset, setEditingPreset] = useState<string | null>(null)
|
||
const [editName, setEditName] = useState('')
|
||
const [editAmount, setEditAmount] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (!slug) return
|
||
Promise.all([
|
||
api.deposits.getSettings(slug),
|
||
api.deposits.getPresets(slug).catch(() => []),
|
||
]).then(([s, p]) => {
|
||
setSettings(s)
|
||
setIsEnabled(s.isEnabled)
|
||
setAmount(String(s.amount))
|
||
setReleaseRequiresCheckout(s.releaseRequiresCheckout ?? false)
|
||
setLongStayFullPayment(s.longStayFullPayment ?? false)
|
||
setLongStayThresholdDays(String(s.longStayThresholdDays ?? 7))
|
||
setShopId(s.yookassaShopId ?? '')
|
||
setSecretKey(s.yookassaSecretKey ?? '')
|
||
setPresets(p)
|
||
}).catch(() => {
|
||
setIsEnabled(false)
|
||
setAmount('5000')
|
||
setShopId('')
|
||
setSecretKey('')
|
||
}).finally(() => setLoading(false))
|
||
}, [slug])
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
setError(null)
|
||
try {
|
||
const payload: Record<string, unknown> = {
|
||
is_enabled: isEnabled,
|
||
amount: parseFloat(amount) || 5000,
|
||
release_requires_checkout: releaseRequiresCheckout,
|
||
long_stay_full_payment: longStayFullPayment,
|
||
long_stay_threshold_days: parseInt(longStayThresholdDays) || 7,
|
||
}
|
||
if (shopId) payload.yookassa_shop_id = shopId
|
||
if (secretKey && secretKey !== '••••••••') payload.yookassa_secret_key = secretKey
|
||
|
||
const updated = await api.deposits.updateSettings(slug, payload)
|
||
setSettings(updated)
|
||
setSaved(true)
|
||
setTimeout(() => setSaved(false), 2000)
|
||
} catch {
|
||
setError('Не удалось сохранить настройки')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleAddPreset = async () => {
|
||
const name = newPresetName.trim()
|
||
const amt = parseFloat(newPresetAmount)
|
||
if (!name || !amt || amt <= 0) return
|
||
setAddingPreset(true)
|
||
try {
|
||
const p = await api.deposits.createPreset(slug, name, amt)
|
||
setPresets(prev => [...prev, p])
|
||
setNewPresetName('')
|
||
setNewPresetAmount('')
|
||
} catch {
|
||
// ignore
|
||
} finally {
|
||
setAddingPreset(false)
|
||
}
|
||
}
|
||
|
||
const handleDeletePreset = async (id: string) => {
|
||
await api.deposits.deletePreset(slug, id).catch(() => {})
|
||
setPresets(prev => prev.filter(p => p.id !== id))
|
||
}
|
||
|
||
const startEdit = (p: DepositPreset) => {
|
||
setEditingPreset(p.id)
|
||
setEditName(p.name)
|
||
setEditAmount(String(p.amount))
|
||
}
|
||
|
||
const saveEdit = async (id: string) => {
|
||
const name = editName.trim()
|
||
const amt = parseFloat(editAmount)
|
||
if (!name || !amt) return
|
||
try {
|
||
const updated = await api.deposits.updatePreset(slug, id, { name, amount: amt })
|
||
setPresets(prev => prev.map(p => p.id === id ? updated : p))
|
||
} catch {
|
||
// ignore
|
||
}
|
||
setEditingPreset(null)
|
||
}
|
||
|
||
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 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 && (
|
||
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-700 dark:text-red-300">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* Main settings card */}
|
||
<div className="card p-5 space-y-5">
|
||
<div className="flex items-center justify-between">
|
||
<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>
|
||
<Toggle on={isEnabled} onChange={() => setIsEnabled(v => !v)} />
|
||
</div>
|
||
|
||
{isEnabled && (
|
||
<>
|
||
<div className="border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4">
|
||
<div>
|
||
<label className="form-label">Сумма депозита, руб.</label>
|
||
<input
|
||
type="number" min="0" step="100"
|
||
value={amount} onChange={e => setAmount(e.target.value)}
|
||
onFocus={e => e.target.select()}
|
||
className="input w-48"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Запретить возврат депозита до выезда</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||
Кнопки «Вернуть» и «Списать» будут заблокированы, пока гость не выехал
|
||
</p>
|
||
</div>
|
||
<Toggle on={releaseRequiresCheckout} onChange={() => setReleaseRequiresCheckout(v => !v)} />
|
||
</div>
|
||
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">Длинная бронь — полная оплата</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||
Для броней длиннее указанного кол-ва ночей снимать депозит сразу (не замораживать). Обход 7-дневного лимита ЮКасса
|
||
</p>
|
||
{longStayFullPayment && (
|
||
<div className="flex items-center gap-2 mt-2">
|
||
<span className="text-xs text-slate-600 dark:text-slate-300">Порог:</span>
|
||
<input
|
||
type="number" min="1" max="365"
|
||
value={longStayThresholdDays}
|
||
onChange={e => setLongStayThresholdDays(e.target.value)}
|
||
onFocus={e => e.target.select()}
|
||
className="input w-20 text-sm"
|
||
/>
|
||
<span className="text-xs text-slate-500">ночей</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<Toggle on={longStayFullPayment} onChange={() => setLongStayFullPayment(v => !v)} />
|
||
</div>
|
||
|
||
<div className="rounded-lg bg-violet-50 dark:bg-violet-900/10 border border-violet-200 dark:border-violet-700 p-3 space-y-1.5">
|
||
<p className="text-xs font-semibold text-violet-800 dark:text-violet-300">ЮКасса настраивается в одном месте</p>
|
||
<p className="text-xs text-violet-700 dark:text-violet-400">
|
||
Реквизиты ЮКасса (Shop ID и секретный ключ) теперь управляются централизованно.
|
||
Перейдите в <a href="/settings/payments" className="underline font-medium">Настройки оплаты → Онлайн-оплата</a> и добавьте шлюз один раз — он будет работать для депозита, онлайн-бронирования и Room Service.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
|
||
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm mb-1">QR-код для гостей</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-4">
|
||
Распечатайте и разместите на стойке ресепшена.
|
||
</p>
|
||
<div className="print-qr-section flex flex-col items-center gap-4">
|
||
<div className="p-4 bg-white rounded-xl border border-slate-200 dark:border-slate-600 inline-block">
|
||
<QRCodeSVG value={`https://app.hotelsync.ru/${slug}/pay`} size={160} level="M" />
|
||
</div>
|
||
<p className="text-xs text-slate-400 font-mono break-all text-center">
|
||
https://app.hotelsync.ru/{slug}/pay
|
||
</p>
|
||
<button type="button" onClick={() => window.print()} className="btn-secondary flex items-center gap-2">
|
||
<Printer size={15} />
|
||
Распечатать QR-код
|
||
</button>
|
||
</div>
|
||
<style>{`
|
||
@media print {
|
||
body > * { display: none !important; }
|
||
.print-qr-section, .print-qr-section * { display: flex !important; }
|
||
.print-qr-section { position: fixed; inset: 0; align-items: center; justify-content: center; flex-direction: column; gap: 1rem; }
|
||
button { display: none !important; }
|
||
}
|
||
`}</style>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||
{saved ? 'Сохранено!' : 'Сохранить'}
|
||
</button>
|
||
{saved && <span className="text-sm text-emerald-600 dark:text-emerald-400">Настройки сохранены</span>}
|
||
</div>
|
||
|
||
{/* Deduction presets */}
|
||
{isEnabled && (
|
||
<div className="card p-5 space-y-4">
|
||
<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>
|
||
|
||
{/* Existing presets */}
|
||
{presets.length > 0 && (
|
||
<div className="space-y-2">
|
||
{presets.map(p => (
|
||
<div key={p.id} className="flex items-center gap-2">
|
||
{editingPreset === p.id ? (
|
||
<>
|
||
<input
|
||
value={editName} onChange={e => setEditName(e.target.value)}
|
||
className="input text-sm flex-1"
|
||
placeholder="Название"
|
||
onFocus={e => e.target.select()}
|
||
/>
|
||
<input
|
||
value={editAmount} onChange={e => setEditAmount(e.target.value)}
|
||
onFocus={e => e.target.select()}
|
||
type="number" min="0" step="100"
|
||
className="input text-sm w-28 text-right"
|
||
placeholder="Сумма"
|
||
/>
|
||
<span className="text-xs text-slate-400">₽</span>
|
||
<button onClick={() => saveEdit(p.id)} className="p-1 rounded text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-colors">
|
||
<Check size={15} />
|
||
</button>
|
||
<button onClick={() => setEditingPreset(null)} className="p-1 rounded text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
||
<Trash2 size={13} />
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span
|
||
className="flex-1 text-sm text-slate-700 dark:text-slate-200 cursor-pointer hover:text-brand-600 transition-colors"
|
||
onClick={() => startEdit(p)}
|
||
>
|
||
{p.name}
|
||
</span>
|
||
<span className="text-sm text-slate-500 dark:text-slate-400 cursor-pointer" onClick={() => startEdit(p)}>
|
||
{formatCurrency(p.amount)}
|
||
</span>
|
||
<button
|
||
onClick={() => handleDeletePreset(p.id)}
|
||
className="p-1 rounded text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||
>
|
||
<Trash2 size={13} />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Add new preset */}
|
||
<div className="flex items-center gap-2 pt-1 border-t border-slate-100 dark:border-slate-700">
|
||
<input
|
||
value={newPresetName} onChange={e => setNewPresetName(e.target.value)}
|
||
className="input text-sm flex-1"
|
||
placeholder="Название (напр. Штраф за курение)"
|
||
onKeyDown={e => e.key === 'Enter' && handleAddPreset()}
|
||
/>
|
||
<input
|
||
value={newPresetAmount} onChange={e => setNewPresetAmount(e.target.value)}
|
||
onFocus={e => e.target.select()}
|
||
type="number" min="0" step="100"
|
||
className="input text-sm w-28 text-right"
|
||
placeholder="Сумма"
|
||
onKeyDown={e => e.key === 'Enter' && handleAddPreset()}
|
||
/>
|
||
<span className="text-xs text-slate-400 shrink-0">₽</span>
|
||
<button
|
||
onClick={handleAddPreset}
|
||
disabled={addingPreset || !newPresetName.trim() || !newPresetAmount}
|
||
className="btn-primary flex items-center gap-1 text-sm px-3 py-1.5 shrink-0 disabled:opacity-50"
|
||
>
|
||
{addingPreset ? <Loader2 size={13} className="animate-spin" /> : <Plus size={13} />}
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|