feat: checklists, minibar and deposit modules

- DB migrations 057-059: checklist_templates/items/completions, minibar_items/consumptions, hotel_deposit_settings, booking_deposits
- Backend routes: checklists (templates CRUD + task completions), minibar (items + consumptions), deposit (settings, cash/yookassa hold, release/capture)
- YooKassa service for hold/capture/cancel payments
- Frontend: ChecklistSettingsPage, MinibarSettingsPage, DepositSettingsPage
- HousekeepingPage: task cards now show checklist + minibar panel with checkboxes and quantity buttons
- BookingModal: minibar charges summary + deposit management (cash/YooKassa/release) for existing bookings
- Sidebar + App.tsx: new routes /settings/checklists, /settings/minibar, /settings/deposit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 19:24:53 +03:00
parent ed2dfd4c7f
commit 3c1a6ccebf
17 changed files with 2569 additions and 9 deletions

View File

@@ -1,6 +1,7 @@
import { useState, useLayoutEffect, useRef, useEffect } from 'react'
import { format, addDays, parseISO } from 'date-fns'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2, Clock, CalendarDays, User, Phone } from 'lucide-react'
import { Star, Banknote, CreditCard, AlertCircle, Map, Plus, Trash2, Tag, Minus, BedDouble, Tv2, Send, Loader2, CheckCircle2, Clock, CalendarDays, User, Phone, ShoppingCart, ShieldCheck, ExternalLink } from 'lucide-react'
import type { MinibarBookingCharge, BookingDeposit } from '../../lib/api'
import { MOCK_DISCOUNTS } from '../../pages/DiscountsPage'
import type { Discount } from '../../pages/DiscountsPage'
import { Modal } from '../ui/Modal'
@@ -247,6 +248,83 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
const [tvSent, setTvSent] = useState(false)
const [tvError, setTvError] = useState('')
// Minibar charges state (for existing bookings)
const [minibarCharges, setMinibarCharges] = useState<MinibarBookingCharge[]>([])
const [minibarLoaded, setMinibarLoaded] = useState(false)
// Deposit state (for existing bookings)
const [deposit, setDeposit] = useState<BookingDeposit | null>(null)
const [depositLoaded, setDepositLoaded] = useState(false)
const [depositSaving, setDepositSaving] = useState(false)
const [depositError, setDepositError] = useState<string | null>(null)
const [captureAmount, setCaptureAmount] = useState('')
const [captureReason, setCaptureReason] = useState('')
const [showReleaseForm, setShowReleaseForm] = useState(false)
// Load minibar + deposit when modal opens for existing booking
useEffect(() => {
if (!open || !existing?.id || !slug) return
if (!minibarLoaded) {
api.minibar.getBookingMinibar(slug, existing.id)
.then(charges => { setMinibarCharges(charges); setMinibarLoaded(true) })
.catch(() => setMinibarLoaded(true))
}
if (!depositLoaded) {
api.deposits.getBookingDeposit(slug, existing.id)
.then(d => { setDeposit(d); setDepositLoaded(true) })
.catch(() => setDepositLoaded(true))
}
}, [open, existing?.id, slug, minibarLoaded, depositLoaded])
const handlePayByCash = async () => {
if (!existing?.id || !slug) return
setDepositSaving(true)
setDepositError(null)
try {
const d = await api.deposits.payByCash(slug, existing.id)
setDeposit(d)
} catch (e) {
setDepositError(e instanceof Error ? e.message : 'Ошибка')
} finally {
setDepositSaving(false)
}
}
const handleCreateYookassaHold = async () => {
if (!existing?.id || !slug) return
setDepositSaving(true)
setDepositError(null)
try {
const d = await api.deposits.createYookassaHold(slug, existing.id)
setDeposit(d)
if (d.confirmationUrl) {
window.open(d.confirmationUrl, '_blank')
}
} catch (e) {
setDepositError(e instanceof Error ? e.message : 'Ошибка')
} finally {
setDepositSaving(false)
}
}
const handleReleaseDeposit = async () => {
if (!existing?.id || !slug) return
setDepositSaving(true)
setDepositError(null)
try {
const amount = parseFloat(captureAmount) || 0
const d = await api.deposits.release(slug, existing.id, amount, captureReason || undefined)
setDeposit(d)
setShowReleaseForm(false)
setCaptureAmount('')
setCaptureReason('')
} catch (e) {
setDepositError(e instanceof Error ? e.message : 'Ошибка')
} finally {
setDepositSaving(false)
}
}
const handleSendTv = async () => {
if (!tvMsg.trim() || !existing?.roomId || !user?.hotelSlug) return
setTvSending(true)
@@ -1377,6 +1455,145 @@ export function BookingModal({ open, draft, rooms, bookings = [], onClose, onSav
{/* end room form */}
</>)}
{/* ── Minibar charges (existing bookings only) ── */}
{existing && minibarLoaded && minibarCharges.length > 0 && (
<div className="border-t border-slate-100 dark:border-slate-700 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<ShoppingCart size={14} className="text-brand-600" />
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">Минибар</span>
</div>
<div className="space-y-1">
{minibarCharges.map(c => (
<div key={c.id} className="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-400">
<span className="flex-1 truncate">{c.itemName}</span>
<span className="shrink-0">× {c.quantity}</span>
<span className="shrink-0 font-medium">{Number(c.total).toLocaleString('ru-RU')} </span>
</div>
))}
<div className="flex items-center justify-between pt-1 border-t border-slate-100 dark:border-slate-700">
<span className="text-xs text-slate-500">Итого</span>
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{minibarCharges.reduce((s, c) => s + Number(c.total), 0).toLocaleString('ru-RU')}
</span>
</div>
</div>
</div>
)}
{/* ── Deposit section (existing bookings only) ── */}
{existing && depositLoaded && (
<div className="border-t border-slate-100 dark:border-slate-700 px-4 py-3">
<div className="flex items-center gap-2 mb-2">
<ShieldCheck size={14} className="text-brand-600" />
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">Депозит</span>
{deposit && (
<span className={cn(
'text-xs px-2 py-0.5 rounded-full font-medium',
deposit.status === 'paid_cash' || deposit.status === 'hold_confirmed' || deposit.status === 'captured'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
: deposit.status === 'refunded' || deposit.status === 'cancelled'
? 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
)}>
{deposit.status === 'pending' ? 'Ожидает'
: deposit.status === 'paid_cash' ? 'Оплачен наличными'
: deposit.status === 'hold_created' ? 'Холд создан'
: deposit.status === 'hold_confirmed' ? 'Холд подтверждён'
: deposit.status === 'captured' ? 'Списан'
: deposit.status === 'refunded' ? 'Возвращён'
: deposit.status === 'cancelled' ? 'Отменён'
: deposit.status}
</span>
)}
</div>
{depositError && (
<p className="text-xs text-red-600 dark:text-red-400 mb-2">{depositError}</p>
)}
{!deposit && (
<div className="flex flex-wrap gap-2">
<button
onClick={handlePayByCash}
disabled={depositSaving}
className="btn-secondary py-1.5 px-3 text-xs flex items-center gap-1.5"
>
{depositSaving ? <Loader2 size={12} className="animate-spin" /> : <Banknote size={12} />}
Наличные
</button>
<button
onClick={handleCreateYookassaHold}
disabled={depositSaving}
className="btn-secondary py-1.5 px-3 text-xs flex items-center gap-1.5"
>
{depositSaving ? <Loader2 size={12} className="animate-spin" /> : <CreditCard size={12} />}
ЮКасса (холд)
</button>
</div>
)}
{deposit && ['paid_cash', 'hold_created', 'hold_confirmed'].includes(deposit.status) && !showReleaseForm && (
<div className="space-y-2">
<p className="text-xs text-slate-600 dark:text-slate-400">
Сумма: <strong>{Number(deposit.amount).toLocaleString('ru-RU')} </strong>
</p>
{deposit.yookassaConfirmationUrl && deposit.status === 'hold_created' && (
<a
href={deposit.yookassaConfirmationUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-brand-600 hover:text-brand-700"
>
<ExternalLink size={12} /> Ссылка для оплаты
</a>
)}
<button
onClick={() => setShowReleaseForm(true)}
className="btn-secondary py-1.5 px-3 text-xs"
>
Завершить / вернуть
</button>
</div>
)}
{deposit && showReleaseForm && (
<div className="space-y-2">
<p className="text-xs text-slate-600 dark:text-slate-400">
Сумма депозита: {Number(deposit.amount).toLocaleString('ru-RU')}
</p>
<div>
<label className="text-xs text-slate-500 block mb-1">Сумма к удержанию (0 = полный возврат)</label>
<input
type="number" min="0" step="100"
value={captureAmount}
onChange={e => setCaptureAmount(e.target.value)}
placeholder="0"
className="input w-32 text-sm py-1"
/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-1">Причина удержания</label>
<input
value={captureReason}
onChange={e => setCaptureReason(e.target.value)}
placeholder="Порча имущества..."
className="input w-full text-sm py-1"
/>
</div>
<div className="flex gap-2">
<button onClick={handleReleaseDeposit} disabled={depositSaving} className="btn-primary py-1.5 px-3 text-xs flex items-center gap-1.5">
{depositSaving ? <Loader2 size={12} className="animate-spin" /> : <CheckCircle2 size={12} />}
Подтвердить
</button>
<button onClick={() => setShowReleaseForm(false)} className="btn-secondary py-1.5 px-3 text-xs">
Отмена
</button>
</div>
</div>
)}
</div>
)}
</div>
</Modal>