Files
hotelsync/src/pages/PosPage.tsx

780 lines
32 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, useCallback, useRef } from 'react'
import {
CreditCard, Banknote, LogIn, LogOut, BarChart2,
Receipt, Coffee, Car, Package, Zap, Building2,
RefreshCw, AlertCircle, CheckCircle2, Clock,
Plus, Minus, Trash2, User, Printer, ArrowLeft,
TrendingUp, DollarSign, WifiOff,
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { cn, formatCurrency } from '../lib/utils'
import { useAuth } from '../contexts/AuthContext'
import {
getIdentity, kkt,
type AgentIdentity, type KktStatus, type ShiftStatus,
type CashierInfo, type ReceiptData, type ReceiptItem,
} from '../lib/agent'
// ── Types ──────────────────────────────────────────────────────────────────────
type PaymentType = 'cash' | 'card'
type PosView = 'main' | 'payment' | 'cash-io'
interface CartItem {
name: string
price: number
quantity: number
vat: 'none' | 'vat0' | 'vat10' | 'vat20'
}
interface GuestRow {
roomNumber: string
guestName: string
balance: number // сумма к оплате
bookingId: string
}
// ── Быстрые услуги ─────────────────────────────────────────────────────────────
const QUICK_SERVICES = [
{ name: 'Проживание', price: 0, icon: Building2, vat: 'none' as const, askPrice: true },
{ name: 'Завтрак', price: 750, icon: Coffee, vat: 'none' as const, askPrice: false },
{ name: 'Трансфер', price: 2500, icon: Car, vat: 'none' as const, askPrice: false },
{ name: 'Парковка/сутки',price: 500, icon: Package, vat: 'none' as const, askPrice: false },
{ name: 'Доп. услуга', price: 0, icon: Zap, vat: 'none' as const, askPrice: true },
]
// ── Helpers ────────────────────────────────────────────────────────────────────
function shiftDuration(openedAt: string): string {
const diff = Date.now() - new Date(openedAt).getTime()
const h = Math.floor(diff / 3600000)
const m = Math.floor((diff % 3600000) / 60000)
return h > 0 ? `${h} ч ${m} мин` : `${m} мин`
}
// ── Sub-components ─────────────────────────────────────────────────────────────
function AgentOffline() {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 text-center p-8">
<div className="w-16 h-16 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
<WifiOff size={28} className="text-slate-400" />
</div>
<div>
<p className="text-lg font-semibold text-slate-700 dark:text-slate-300 mb-1">Агент не подключён</p>
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-sm">
На этом компьютере не запущен HotelSync Agent.<br />
Обратитесь к системному администратору.
</p>
</div>
</div>
)
}
function ShiftClosed({ onOpen, loading }: { onOpen: () => void; loading: boolean }) {
return (
<div className="flex flex-col items-center justify-center h-full gap-6 text-center p-8">
<div className="w-20 h-20 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
<Receipt size={36} className="text-slate-400" />
</div>
<div>
<p className="text-xl font-bold text-slate-800 dark:text-slate-200 mb-2">Смена закрыта</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
Откройте смену чтобы начать принимать оплату
</p>
</div>
<button
onClick={onOpen}
disabled={loading}
className="btn-primary flex items-center gap-2 py-3 px-8 text-base"
>
{loading ? <RefreshCw size={18} className="animate-spin" /> : <LogIn size={18} />}
Открыть смену
</button>
</div>
)
}
function ShiftExpired({ onClose, loading }: { onClose: () => void; loading: boolean }) {
return (
<div className="flex flex-col items-center justify-center h-full gap-6 text-center p-8">
<div className="w-20 h-20 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<AlertCircle size={36} className="text-red-500" />
</div>
<div>
<p className="text-xl font-bold text-slate-800 dark:text-slate-200 mb-2">Смена истекла (более 24 часов)</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
Необходимо закрыть текущую смену и открыть новую
</p>
</div>
<button
onClick={onClose}
disabled={loading}
className="btn-danger flex items-center gap-2 py-3 px-8 text-base"
>
{loading ? <RefreshCw size={18} className="animate-spin" /> : <LogOut size={18} />}
Закрыть смену (Z-отчёт)
</button>
</div>
)
}
// ── Шапка с информацией о смене ────────────────────────────────────────────────
function ShiftHeader({
identity, shift, kktStatus,
onXReport, onCloseShift, onCashIn, onCashOut, onRefresh,
loadingAction,
}: {
identity: AgentIdentity
shift: ShiftStatus
kktStatus: KktStatus
onXReport: () => void
onCloseShift: () => void
onCashIn: () => void
onCashOut: () => void
onRefresh: () => void
loadingAction: string | null
}) {
const ofdColor = kktStatus.info?.ofd_status === 'ok'
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-500'
return (
<div className="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 px-6 py-3">
<div className="flex items-center gap-6 flex-wrap">
{/* Рабочее место */}
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500">Рабочее место:</span>
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">{identity.workstation_name}</span>
</div>
{/* Смена */}
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500">Смена {shift.shift_number}:</span>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
{shift.opened_at ? shiftDuration(shift.opened_at) : '—'}
</span>
</div>
{/* Наличные */}
{shift.cash_sum !== undefined && (
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-slate-500">В кассе:</span>
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(shift.cash_sum)}
</span>
</div>
)}
{/* ОФД статус */}
{kktStatus.info && (
<div className="flex items-center gap-1.5">
<span className={cn('text-xs font-medium', ofdColor)}>
ОФД: {kktStatus.info.ofd_status === 'ok' ? '✓ Подключён' : '✗ Нет связи'}
</span>
</div>
)}
{/* Кнопки */}
<div className="flex items-center gap-2 ml-auto">
<button
onClick={onCashIn}
className="btn-ghost py-1 px-3 text-xs flex items-center gap-1.5 text-emerald-700 dark:text-emerald-400"
title="Внести наличные"
>
<Plus size={13} />Внести
</button>
<button
onClick={onCashOut}
className="btn-ghost py-1 px-3 text-xs flex items-center gap-1.5 text-amber-700 dark:text-amber-400"
title="Изъять наличные"
>
<Minus size={13} />Изъять
</button>
<button
onClick={onXReport}
disabled={loadingAction === 'xreport'}
className="btn-ghost py-1 px-3 text-xs flex items-center gap-1.5"
title="X-отчёт"
>
{loadingAction === 'xreport'
? <RefreshCw size={13} className="animate-spin" />
: <BarChart2 size={13} />
}
X-отчёт
</button>
<button
onClick={onCloseShift}
disabled={loadingAction === 'close'}
className="btn-secondary py-1 px-3 text-xs flex items-center gap-1.5 text-red-600 border-red-200 hover:bg-red-50"
>
{loadingAction === 'close'
? <RefreshCw size={13} className="animate-spin" />
: <LogOut size={13} />
}
Закрыть смену
</button>
<button onClick={onRefresh} className="btn-ghost p-1.5 text-slate-400">
<RefreshCw size={14} />
</button>
</div>
</div>
</div>
)
}
// ── Диалог внесения/изъятия наличных ──────────────────────────────────────────
function CashDialog({
type, cashier, onConfirm, onClose,
}: {
type: 'in' | 'out'
cashier: CashierInfo
onConfirm: (amount: number) => Promise<void>
onClose: () => void
}) {
const [amount, setAmount] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async () => {
const val = parseFloat(amount.replace(',', '.'))
if (!val || val <= 0) { setError('Введите сумму'); return }
setLoading(true)
setError(null)
try {
await onConfirm(val)
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-sm p-6">
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100 mb-4">
{type === 'in' ? '💵 Внесение наличных' : '💴 Изъятие наличных'}
</h3>
<label className="block text-sm text-slate-500 mb-1">Сумма, </label>
<input
value={amount}
onChange={e => setAmount(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()}
className="w-full input-field text-xl font-mono text-center mb-2"
placeholder="0.00"
autoFocus
type="number"
min="0"
/>
{error && <p className="text-sm text-red-500 mb-2">{error}</p>}
<div className="flex gap-2 mt-4">
<button onClick={submit} disabled={loading} className="btn-primary flex-1 py-2.5">
{loading ? 'Выполнение...' : 'Подтвердить'}
</button>
<button onClick={onClose} className="btn-secondary px-5 py-2.5">Отмена</button>
</div>
</div>
</div>
)
}
// ── Панель чека ────────────────────────────────────────────────────────────────
function CartPanel({
cart, guest, paymentType,
onSetPayment, onRemoveItem, onChangeQty,
onPay, paying, payResult,
onBack,
}: {
cart: CartItem[]
guest: GuestRow | null
paymentType: PaymentType
onSetPayment: (t: PaymentType) => void
onRemoveItem: (i: number) => void
onChangeQty: (i: number, d: number) => void
onPay: () => void
paying: boolean
payResult: { ok: boolean; receipt_number?: string; error?: string } | null
onBack: () => void
}) {
const total = cart.reduce((s, i) => s + i.price * i.quantity, 0)
if (payResult?.ok) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4 p-8 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<CheckCircle2 size={32} className="text-emerald-600" />
</div>
<div>
<p className="text-xl font-bold text-slate-800 dark:text-slate-200">Оплата принята</p>
<p className="text-sm text-slate-500 mt-1">{formatCurrency(total)}</p>
{payResult.receipt_number && (
<p className="text-xs text-slate-400 mt-2">Чек {payResult.receipt_number}</p>
)}
</div>
<button onClick={onBack} className="btn-primary py-2.5 px-8 flex items-center gap-2">
<ArrowLeft size={16} />
Новый чек
</button>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Гость */}
{guest && (
<div className="px-4 py-3 bg-brand-50 dark:bg-brand-900/20 border-b border-brand-100 dark:border-brand-800 flex items-center gap-2">
<User size={14} className="text-brand-600 shrink-0" />
<span className="text-sm font-medium text-brand-800 dark:text-brand-300">
{guest.roomNumber} {guest.guestName}
</span>
</div>
)}
{/* Позиции */}
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{cart.length === 0 ? (
<p className="text-center text-sm text-slate-400 py-8">
Выберите услугу из списка слева
</p>
) : (
cart.map((item, i) => (
<div key={i} className="flex items-center gap-3 p-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{item.name}</p>
<p className="text-xs text-slate-500">{formatCurrency(item.price)} × {item.quantity}</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => onChangeQty(i, -1)} className="btn-ghost p-1 w-7 h-7 flex items-center justify-center">
<Minus size={12} />
</button>
<span className="text-sm font-semibold w-6 text-center">{item.quantity}</span>
<button onClick={() => onChangeQty(i, +1)} className="btn-ghost p-1 w-7 h-7 flex items-center justify-center">
<Plus size={12} />
</button>
</div>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100 w-20 text-right shrink-0">
{formatCurrency(item.price * item.quantity)}
</p>
<button onClick={() => onRemoveItem(i)} className="btn-ghost p-1 text-slate-400 hover:text-red-500">
<Trash2 size={13} />
</button>
</div>
))
)}
</div>
{/* Итого и оплата */}
{cart.length > 0 && (
<div className="border-t border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-slate-700 dark:text-slate-300">Итого</span>
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">{formatCurrency(total)}</span>
</div>
{/* Способ оплаты */}
<div className="grid grid-cols-2 gap-2">
{([['cash', 'Наличные', Banknote], ['card', 'Терминал', CreditCard]] as const).map(([t, label, Icon]) => (
<button
key={t}
onClick={() => onSetPayment(t)}
className={cn(
'flex items-center justify-center gap-2 py-3 rounded-xl border-2 font-medium text-sm transition-all',
paymentType === t
? 'border-brand-600 bg-brand-600 text-white'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-300',
)}
>
<Icon size={16} />
{label}
</button>
))}
</div>
{payResult?.error && (
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-xl">
<AlertCircle size={14} className="text-red-500 shrink-0 mt-0.5" />
<p className="text-xs text-red-600 dark:text-red-400">{payResult.error}</p>
</div>
)}
<button
onClick={onPay}
disabled={paying}
className="w-full btn-primary py-3.5 text-base flex items-center justify-center gap-2"
>
{paying
? <><RefreshCw size={16} className="animate-spin" /> Пробиваем чек...</>
: <><Printer size={16} /> Пробить чек {formatCurrency(total)}</>
}
</button>
</div>
)}
</div>
)
}
// ── Main Page ──────────────────────────────────────────────────────────────────
export function PosPage() {
const { user } = useAuth()
// ── State ──────────────────────────────────────────────────────────────────
const [agentOnline, setAgentOnline] = useState<boolean | null>(null) // null = loading
const [identity, setIdentity] = useState<AgentIdentity | null>(null)
const [kktStatus, setKktStatus] = useState<KktStatus | null>(null)
const [loadingAction, setLoadingAction] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [actionSuccess, setActionSuccess] = useState<string | null>(null)
const [cart, setCart] = useState<CartItem[]>([])
const [selectedGuest, setGuest] = useState<GuestRow | null>(null)
const [paymentType, setPaymentType] = useState<PaymentType>('cash')
const [paying, setPaying] = useState(false)
const [payResult, setPayResult] = useState<{ ok: boolean; receipt_number?: string; error?: string } | null>(null)
const [cashDialog, setCashDialog] = useState<'in' | 'out' | null>(null)
const [customService, setCustomService] = useState<{ name: string; price: string } | null>(null)
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Временные гости (mock — в будущем из API)
const [guests] = useState<GuestRow[]>([
{ roomNumber: '101', guestName: 'Дмитрий Волков', balance: 4500, bookingId: 'b1' },
{ roomNumber: '301', guestName: 'Наталья Александрова', balance: 24000, bookingId: 'b2' },
{ roomNumber: '401', guestName: 'Михаил Орлов', balance: 36000, bookingId: 'b3' },
])
// ── Кассир (из профиля) ────────────────────────────────────────────────────
const cashier: CashierInfo = { name: user?.name ?? 'Кассир' }
// ── Загрузка статуса ───────────────────────────────────────────────────────
const loadStatus = useCallback(async () => {
const id = await getIdentity()
if (!id) { setAgentOnline(false); return }
setAgentOnline(true)
setIdentity(id)
try {
const status = await kkt.status()
setKktStatus(status)
} catch {
setKktStatus(null)
}
}, [])
useEffect(() => {
loadStatus()
pollRef.current = setInterval(loadStatus, 15000)
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [loadStatus])
// ── Кассовые действия ──────────────────────────────────────────────────────
const doAction = async (key: string, fn: () => Promise<{ ok: boolean; error?: string }>, successMsg: string) => {
setLoadingAction(key)
setActionError(null)
setActionSuccess(null)
const result = await fn()
setLoadingAction(null)
if (result.ok) {
setActionSuccess(successMsg)
setTimeout(() => setActionSuccess(null), 3000)
await loadStatus()
} else {
setActionError(result.error ?? 'Ошибка')
}
}
const handleOpenShift = () => doAction('open', () => kkt.openShift(cashier), 'Смена открыта')
const handleCloseShift = () => doAction('close', () => kkt.closeShift(cashier), 'Смена закрыта, Z-отчёт распечатан')
const handleXReport = () => doAction('xreport',() => kkt.xReport(), 'X-отчёт распечатан')
const handleCashIn = async (amount: number) => {
const r = await kkt.cashIn(amount, cashier)
if (!r.ok) throw new Error(r.error)
setActionSuccess(`Внесено ${formatCurrency(amount)}`)
setTimeout(() => setActionSuccess(null), 3000)
await loadStatus()
}
const handleCashOut = async (amount: number) => {
const r = await kkt.cashOut(amount, cashier)
if (!r.ok) throw new Error(r.error)
setActionSuccess(`Изъято ${formatCurrency(amount)}`)
setTimeout(() => setActionSuccess(null), 3000)
await loadStatus()
}
// ── Чек ───────────────────────────────────────────────────────────────────
const addService = (name: string, price: number) => {
setCart(prev => {
const existing = prev.findIndex(i => i.name === name)
if (existing >= 0) {
const next = [...prev]
next[existing] = { ...next[existing], quantity: next[existing].quantity + 1 }
return next
}
return [...prev, { name, price, quantity: 1, vat: 'none' }]
})
setPayResult(null)
}
const changeQty = (idx: number, delta: number) => {
setCart(prev => {
const next = [...prev]
const q = next[idx].quantity + delta
if (q <= 0) return next.filter((_, i) => i !== idx)
next[idx] = { ...next[idx], quantity: q }
return next
})
}
const handlePay = async () => {
if (!cart.length) return
setPaying(true)
setPayResult(null)
const data: ReceiptData = {
type: 'sell',
items: cart.map(i => ({ name: i.name, quantity: i.quantity, price: i.price, vat: i.vat, payment_object: 'service' })),
total: cart.reduce((s, i) => s + i.price * i.quantity, 0),
payment_type: paymentType,
cashier,
}
const result = await kkt.printReceipt(data)
setPayResult(result)
setPaying(false)
if (result.ok) {
setCart([])
setGuest(null)
}
}
const resetCart = () => { setCart([]); setGuest(null); setPayResult(null) }
// ── Render ─────────────────────────────────────────────────────────────────
if (agentOnline === null) {
return (
<div className="flex items-center justify-center h-full">
<RefreshCw size={24} className="animate-spin text-slate-400" />
</div>
)
}
if (!agentOnline) return <AgentOffline />
const shift = kktStatus?.shift
if (!shift || shift.state === 'closed') {
return (
<div className="h-full flex flex-col">
{actionError && (
<div className="mx-6 mt-4 flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-xl text-red-600 dark:text-red-400 text-sm">
<AlertCircle size={14} /> {actionError}
</div>
)}
<ShiftClosed onOpen={handleOpenShift} loading={loadingAction === 'open'} />
</div>
)
}
if (shift.state === 'expired') {
return (
<div className="h-full flex flex-col">
<ShiftExpired onClose={handleCloseShift} loading={loadingAction === 'close'} />
</div>
)
}
// ── Смена открыта — основной интерфейс ────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Шапка смены */}
<ShiftHeader
identity={identity!}
shift={shift}
kktStatus={kktStatus!}
onXReport={handleXReport}
onCloseShift={handleCloseShift}
onCashIn={() => setCashDialog('in')}
onCashOut={() => setCashDialog('out')}
onRefresh={loadStatus}
loadingAction={loadingAction}
/>
{/* Уведомления */}
{(actionError || actionSuccess) && (
<div className={cn(
'mx-6 mt-3 flex items-center gap-2 p-3 rounded-xl text-sm',
actionError
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400'
: 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400',
)}>
{actionError
? <AlertCircle size={14} />
: <CheckCircle2 size={14} />
}
{actionError || actionSuccess}
</div>
)}
{/* Основная зона */}
<div className="flex-1 flex overflow-hidden min-h-0">
{/* Левая панель — гости и услуги */}
<div className="w-80 shrink-0 border-r border-slate-200 dark:border-slate-700 flex flex-col overflow-hidden">
{/* Гости */}
<div className="p-4 border-b border-slate-200 dark:border-slate-700">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Гости в отеле</p>
<div className="space-y-1.5 max-h-40 overflow-y-auto">
{guests.map(g => (
<button
key={g.bookingId}
onClick={() => setGuest(selectedGuest?.bookingId === g.bookingId ? null : g)}
className={cn(
'w-full text-left px-3 py-2 rounded-lg text-sm transition-colors',
selectedGuest?.bookingId === g.bookingId
? 'bg-brand-600 text-white'
: 'hover:bg-slate-100 dark:hover:bg-slate-700',
)}
>
<div className="flex justify-between items-center">
<span className="font-medium">{g.roomNumber} {g.guestName.split(' ')[0]}</span>
{g.balance > 0 && (
<span className={cn('text-xs font-semibold', selectedGuest?.bookingId === g.bookingId ? 'text-white/80' : 'text-amber-600 dark:text-amber-400')}>
{formatCurrency(g.balance)}
</span>
)}
</div>
</button>
))}
</div>
</div>
{/* Услуги */}
<div className="flex-1 overflow-y-auto p-4">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">Быстрые услуги</p>
<div className="space-y-1.5">
{QUICK_SERVICES.map(s => (
<button
key={s.name}
onClick={() => {
if (s.askPrice) {
setCustomService({ name: s.name, price: '' })
} else {
addService(s.name, s.price)
}
}}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-700 text-left transition-colors"
>
<div className="w-8 h-8 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center shrink-0">
<s.icon size={15} className="text-slate-600 dark:text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{s.name}</p>
{s.price > 0 && <p className="text-xs text-slate-500">{formatCurrency(s.price)}</p>}
</div>
<Plus size={14} className="text-slate-400 shrink-0" />
</button>
))}
{/* Произвольная сумма */}
<button
onClick={() => setCustomService({ name: 'Услуга', price: '' })}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl border-2 border-dashed border-slate-200 dark:border-slate-600 hover:border-brand-400 text-left transition-colors"
>
<Plus size={15} className="text-slate-400 ml-1.5" />
<span className="text-sm text-slate-500">Произвольная сумма</span>
</button>
</div>
</div>
</div>
{/* Правая панель — чек */}
<div className="flex-1 overflow-hidden">
<CartPanel
cart={cart}
guest={selectedGuest}
paymentType={paymentType}
onSetPayment={setPaymentType}
onRemoveItem={i => setCart(prev => prev.filter((_, idx) => idx !== i))}
onChangeQty={changeQty}
onPay={handlePay}
paying={paying}
payResult={payResult}
onBack={resetCart}
/>
</div>
</div>
{/* Диалог произвольной услуги */}
{customService && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl w-full max-w-sm p-6">
<h3 className="font-bold text-lg mb-4 text-slate-900 dark:text-slate-100">Добавить услугу</h3>
<label className="block text-sm text-slate-500 mb-1">Название</label>
<input
value={customService.name}
onChange={e => setCustomService(s => s && { ...s, name: e.target.value })}
className="w-full input-field mb-3"
/>
<label className="block text-sm text-slate-500 mb-1">Сумма, </label>
<input
value={customService.price}
onChange={e => setCustomService(s => s && { ...s, price: e.target.value })}
onKeyDown={e => {
if (e.key === 'Enter') {
const p = parseFloat(customService.price.replace(',', '.'))
if (p > 0 && customService.name.trim()) {
addService(customService.name.trim(), p)
setCustomService(null)
}
}
}}
className="w-full input-field text-xl font-mono text-center mb-4"
placeholder="0.00"
type="number"
autoFocus
/>
<div className="flex gap-2">
<button
onClick={() => {
const p = parseFloat(customService.price.replace(',', '.'))
if (p > 0 && customService.name.trim()) {
addService(customService.name.trim(), p)
setCustomService(null)
}
}}
className="btn-primary flex-1 py-2.5"
>
Добавить
</button>
<button onClick={() => setCustomService(null)} className="btn-secondary px-5">Отмена</button>
</div>
</div>
</div>
)}
{/* Диалог наличных */}
{cashDialog && (
<CashDialog
type={cashDialog}
cashier={cashier}
onConfirm={cashDialog === 'in' ? handleCashIn : handleCashOut}
onClose={() => setCashDialog(null)}
/>
)}
</div>
)
}