feat: deposit presets UI, back button in history, onFocus select on inputs

- DepositSettingsPage: deduction presets management (add/edit/delete inline)
- DepositHistoryPage: "← Назад" link back to settings
- Amount inputs: onFocus selects all text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 13:03:20 +03:00
parent 1ab24dfe27
commit d686f5abe6
2 changed files with 191 additions and 91 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Loader2, ShieldCheck, CreditCard, Banknote, RefreshCw } from 'lucide-react' import { Loader2, ShieldCheck, CreditCard, Banknote, RefreshCw, ArrowLeft } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api, type BookingDeposit } from '../lib/api' import { api, type BookingDeposit } from '../lib/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { cn, formatCurrency } from '../lib/utils' import { cn, formatCurrency } from '../lib/utils'
@@ -40,14 +41,17 @@ export function DepositHistoryPage() {
return ( return (
<div className="p-4 md:p-6 space-y-5 max-w-4xl"> <div className="p-4 md:p-6 space-y-5 max-w-4xl">
<div className="flex items-center justify-between flex-wrap gap-2"> <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"> <div className="flex items-center gap-3">
<ShieldCheck size={22} className="text-brand-600" /> <Link to="/settings/deposit" className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 transition-colors">
История депозитов <ArrowLeft size={16} />
</h1> Назад
<button </Link>
onClick={load} <h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
className="btn-secondary flex items-center gap-1.5 text-sm" <ShieldCheck size={22} className="text-brand-600" />
> История депозитов
</h1>
</div>
<button onClick={load} className="btn-secondary flex items-center gap-1.5 text-sm">
<RefreshCw size={14} /> <RefreshCw size={14} />
Обновить Обновить
</button> </button>

View File

@@ -1,10 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Save, Eye, EyeOff, Loader2, ShieldCheck, Printer, History, Info } from 'lucide-react' import { Save, Eye, EyeOff, Loader2, ShieldCheck, Printer, History, Info, Plus, Trash2, Check } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api, type DepositSettings } from '../lib/api' import { api, type DepositSettings, type DepositPreset } from '../lib/api'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { cn } from '../lib/utils' import { cn, formatCurrency } from '../lib/utils'
function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) { function Toggle({ on, onChange }: { on: boolean; onChange: () => void }) {
return ( return (
@@ -33,24 +33,33 @@ export function DepositSettingsPage() {
const [secretKey, setSecretKey] = useState('') const [secretKey, setSecretKey] = useState('')
const [showSecret, setShowSecret] = useState(false) 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(() => { useEffect(() => {
if (!slug) return if (!slug) return
api.deposits.getSettings(slug) Promise.all([
.then(s => { api.deposits.getSettings(slug),
setSettings(s) api.deposits.getPresets(slug).catch(() => []),
setIsEnabled(s.isEnabled) ]).then(([s, p]) => {
setAmount(String(s.amount)) setSettings(s)
setShopId(s.yookassaShopId ?? '') setIsEnabled(s.isEnabled)
setSecretKey(s.yookassaSecretKey ?? '') setAmount(String(s.amount))
}) setShopId(s.yookassaShopId ?? '')
.catch(() => { setSecretKey(s.yookassaSecretKey ?? '')
// No settings yet — defaults setPresets(p)
setIsEnabled(false) }).catch(() => {
setAmount('5000') setIsEnabled(false)
setShopId('') setAmount('5000')
setSecretKey('') setShopId('')
}) setSecretKey('')
.finally(() => setLoading(false)) }).finally(() => setLoading(false))
}, [slug]) }, [slug])
const handleSave = async () => { const handleSave = async () => {
@@ -75,6 +84,47 @@ export function DepositSettingsPage() {
} }
} }
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) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -95,10 +145,7 @@ export function DepositSettingsPage() {
Настройте предоплату или залог при заселении гостей. Настройте предоплату или залог при заселении гостей.
</p> </p>
</div> </div>
<Link <Link to="/settings/deposit/history" className="btn-secondary flex items-center gap-1.5 text-sm shrink-0">
to="/settings/deposit/history"
className="btn-secondary flex items-center gap-1.5 text-sm shrink-0"
>
<History size={15} /> <History size={15} />
История платежей История платежей
</Link> </Link>
@@ -110,8 +157,8 @@ export function DepositSettingsPage() {
</div> </div>
)} )}
{/* Main settings card */}
<div className="card p-5 space-y-5"> <div className="card p-5 space-y-5">
{/* Enable toggle */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="font-semibold text-slate-900 dark:text-slate-100">Включить депозит</p> <p className="font-semibold text-slate-900 dark:text-slate-100">Включить депозит</p>
@@ -125,66 +172,47 @@ export function DepositSettingsPage() {
{isEnabled && ( {isEnabled && (
<> <>
<div className="border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4"> <div className="border-t border-slate-100 dark:border-slate-700 pt-4 space-y-4">
{/* Amount */}
<div> <div>
<label className="form-label">Сумма депозита, руб.</label> <label className="form-label">Сумма депозита, руб.</label>
<input <input
type="number" type="number" min="0" step="100"
min="0" value={amount} onChange={e => setAmount(e.target.value)}
step="100" onFocus={e => e.target.select()}
value={amount}
onChange={e => setAmount(e.target.value)}
className="input w-48" className="input w-48"
/> />
</div> </div>
{/* YooKassa section */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm">ЮКасса (опционально)</p> <p className="font-medium text-slate-800 dark:text-slate-200 text-sm">ЮКасса (опционально)</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5"> <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Укажите реквизиты ЮКасса для создания холда (предавторизации) оплаты. Без этих данных доступен только наличный депозит. Укажите реквизиты ЮКасса для создания холда (предавторизации) оплаты.
</p> </p>
</div> </div>
<div> <div>
<label className="form-label">ID магазина (shopId)</label> <label className="form-label">ID магазина (shopId)</label>
<input <input value={shopId} onChange={e => setShopId(e.target.value)} placeholder="123456" className="input w-full" />
value={shopId}
onChange={e => setShopId(e.target.value)}
placeholder="123456"
className="input w-full"
/>
</div> </div>
<div> <div>
<label className="form-label">Секретный ключ</label> <label className="form-label">Секретный ключ</label>
<div className="relative"> <div className="relative">
<input <input
type={showSecret ? 'text' : 'password'} type={showSecret ? 'text' : 'password'}
value={secretKey} value={secretKey} onChange={e => setSecretKey(e.target.value)}
onChange={e => setSecretKey(e.target.value)}
placeholder={settings?.yookassaSecretKey ? '••••••••' : 'live_xxxx...'} placeholder={settings?.yookassaSecretKey ? '••••••••' : 'live_xxxx...'}
className="input w-full pr-10" className="input w-full pr-10"
/> />
<button <button type="button" onClick={() => setShowSecret(s => !s)} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
type="button"
onClick={() => setShowSecret(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showSecret ? <EyeOff size={15} /> : <Eye size={15} />} {showSecret ? <EyeOff size={15} /> : <Eye size={15} />}
</button> </button>
</div> </div>
{settings?.yookassaSecretKey && ( {settings?.yookassaSecretKey && (
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">Ключ уже сохранён. Оставьте поле пустым, чтобы не менять.</p>
Ключ уже сохранён. Оставьте поле пустым, чтобы не менять.
</p>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* Webhook notice */}
{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">
<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"> <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">
@@ -192,7 +220,7 @@ export function DepositSettingsPage() {
<Info size={13} /> Настройте webhook в ЮКасса <Info size={13} /> Настройте webhook в ЮКасса
</p> </p>
<p className="text-xs text-blue-700 dark:text-blue-400"> <p className="text-xs text-blue-700 dark:text-blue-400">
Чтобы статус депозита обновлялся автоматически после оплаты, добавьте URL уведомлений в личном кабинете ЮКасса (Настройки HTTP-уведомления): Чтобы статус депозита обновлялся автоматически, добавьте URL в личном кабинете ЮКасса (Настройки HTTP-уведомления):
</p> </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"> <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 https://api.hotelsync.ru/api/webhooks/yookassa
@@ -204,29 +232,20 @@ export function DepositSettingsPage() {
</div> </div>
)} )}
{/* 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">
<p className="font-medium text-slate-800 dark:text-slate-200 text-sm mb-1">QR-код для гостей</p> <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 className="text-xs text-slate-500 dark:text-slate-400 mb-4">
Распечатайте и разместите на стойке ресепшена. Гость сканирует код и оплачивает депозит картой. Распечатайте и разместите на стойке ресепшена.
</p> </p>
<div className="print-qr-section flex flex-col items-center gap-4"> <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"> <div className="p-4 bg-white rounded-xl border border-slate-200 dark:border-slate-600 inline-block">
<QRCodeSVG <QRCodeSVG value={`https://app.hotelsync.ru/${slug}/pay`} size={160} level="M" />
value={`https://app.hotelsync.ru/${slug}/pay`}
size={160}
level="M"
/>
</div> </div>
<p className="text-xs text-slate-400 font-mono break-all text-center"> <p className="text-xs text-slate-400 font-mono break-all text-center">
https://app.hotelsync.ru/{slug}/pay https://app.hotelsync.ru/{slug}/pay
</p> </p>
<button <button type="button" onClick={() => window.print()} className="btn-secondary flex items-center gap-2">
type="button"
onClick={() => window.print()}
className="btn-secondary flex items-center gap-2"
>
<Printer size={15} /> <Printer size={15} />
Распечатать QR-код Распечатать QR-код
</button> </button>
@@ -234,16 +253,8 @@ export function DepositSettingsPage() {
<style>{` <style>{`
@media print { @media print {
body > * { display: none !important; } body > * { display: none !important; }
.print-qr-section, .print-qr-section, .print-qr-section * { display: flex !important; }
.print-qr-section * { display: flex !important; } .print-qr-section { position: fixed; inset: 0; align-items: center; justify-content: center; flex-direction: column; gap: 1rem; }
.print-qr-section {
position: fixed;
inset: 0;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1rem;
}
button { display: none !important; } button { display: none !important; }
} }
`}</style> `}</style>
@@ -254,18 +265,103 @@ export function DepositSettingsPage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
onClick={handleSave}
disabled={saving}
className="btn-primary flex items-center gap-2"
>
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />} {saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{saved ? 'Сохранено!' : 'Сохранить'} {saved ? 'Сохранено!' : 'Сохранить'}
</button> </button>
{saved && ( {saved && <span className="text-sm text-emerald-600 dark:text-emerald-400">Настройки сохранены</span>}
<span className="text-sm text-emerald-600 dark:text-emerald-400">Настройки сохранены</span>
)}
</div> </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> </div>
) )
} }