Files
hotelsync/src/pages/DepositSettingsPage.tsx
HotelSync 57531f2022 feat: long-stay deposit charge + refunds + auto-confirm widget + white page fix
- 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>
2026-04-07 20:30:26 +03:00

367 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}