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:
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CalendarDays, BookOpen, BedDouble, Globe, Settings,
|
||||
FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound,
|
||||
TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, Wifi, KeyRound,
|
||||
ListChecks, ShoppingCart, ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
import { useModules } from '../../contexts/ModulesContext'
|
||||
@@ -185,7 +186,7 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'],
|
||||
service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)],
|
||||
management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'],
|
||||
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock'],
|
||||
settingsGroup:['/modules', '/settings', '/billing', '/equipment', '/wifi', '/ttlock', '/settings/checklists', '/settings/minibar', '/settings/deposit'],
|
||||
devGroup: ['/api-docs'],
|
||||
}
|
||||
|
||||
@@ -417,8 +418,11 @@ export function Sidebar({ open, onClose }: SidebarProps) {
|
||||
<NavItem to="/settings" icon={Settings} label="Настройки" {...navItemProps} />
|
||||
<NavItem to="/billing" icon={CreditCard} label="Тарифы и оплата" {...navItemProps} />
|
||||
<NavItem to="/equipment" icon={Monitor} label="Оборудование" {...navItemProps} />
|
||||
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
||||
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
|
||||
<NavItem to="/wifi" icon={Wifi} label="WiFi авторизация" {...navItemProps} />
|
||||
<NavItem to="/ttlock" icon={KeyRound} label="Эл. замки TTLock" {...navItemProps} />
|
||||
<NavItem to="/settings/checklists" icon={ListChecks} label="Чек-листы уборки" {...navItemProps} />
|
||||
<NavItem to="/settings/minibar" icon={ShoppingCart} label="Минибар" {...navItemProps} />
|
||||
<NavItem to="/settings/deposit" icon={ShieldCheck} label="Депозит" {...navItemProps} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user