780 lines
32 KiB
TypeScript
780 lines
32 KiB
TypeScript
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>
|
||
)
|
||
}
|