diff --git a/src/index.css b/src/index.css index 54cc01e..c974257 100644 --- a/src/index.css +++ b/src/index.css @@ -82,6 +82,14 @@ disabled:opacity-50 disabled:cursor-not-allowed; } + .btn-danger { + @apply inline-flex items-center gap-2 px-4 py-2 rounded-lg + bg-red-600 hover:bg-red-700 + text-white text-sm font-medium + transition-colors duration-150 + disabled:opacity-50 disabled:cursor-not-allowed; + } + .btn-secondary { @apply inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-white dark:bg-slate-800 diff --git a/src/lib/agent.ts b/src/lib/agent.ts new file mode 100644 index 0000000..337d1a9 --- /dev/null +++ b/src/lib/agent.ts @@ -0,0 +1,129 @@ +/** + * Клиент для общения с HotelSync Agent на localhost:9000 + * Все кассовые операции идут напрямую через агент, минуя сервер + */ + +const AGENT_URL = 'http://localhost:9000' +const TIMEOUT = 30000 // 30 секунд для кассовых операций + +async function agentReq( + method: 'GET' | 'POST', + path: string, + body?: object, + timeout = TIMEOUT, +): Promise { + const res = await fetch(`${AGENT_URL}${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(timeout), + }) + return res.json() as Promise +} + +// ── Типы ────────────────────────────────────────────────────────────────────── + +export interface AgentIdentity { + agent_id: string + workstation_id: string + workstation_name: string + hostname: string + error?: string +} + +export interface ShiftStatus { + state: 'closed' | 'open' | 'expired' + shift_number: number + opened_at?: string + duration_min?: number + cash_sum?: number + receipt_count?: number + cashier_name?: string +} + +export interface KktInfo { + serial_number: string + model: string + fn_number: string + fn_expires_at?: string + ofd_status: 'ok' | 'error' | 'no_connection' + version: string +} + +export interface KktStatus { + shift?: ShiftStatus + info?: KktInfo + dto_connected: boolean + dto_version?: string +} + +export interface CashierInfo { + name: string + inn?: string +} + +export interface ReceiptItem { + name: string + quantity: number + price: number + vat?: 'none' | 'vat0' | 'vat10' | 'vat20' + payment_object?: 'commodity' | 'service' +} + +export interface ReceiptData { + type: 'sell' | 'sell_return' + items: ReceiptItem[] + total: number + payment_type: 'cash' | 'card' | 'prepaid' + cashier?: CashierInfo + customer_email?: string + customer_phone?: string + tax_system?: number +} + +export interface AgentResult { + ok: boolean + error?: string + receipt_number?: string + fiscal_sign?: string + shift_number?: number +} + +// ── API ─────────────────────────────────────────────────────────────────────── + +/** Проверяет что агент запущен и возвращает рабочее место. Timeout 500ms. */ +export async function getIdentity(): Promise { + try { + const data = await agentReq('GET', '/identity', undefined, 500) + if (data.error) return null + return data + } catch { + return null + } +} + +export const kkt = { + status: () => + agentReq('GET', '/kkt/status', undefined, 5000), + + openShift: (cashier: CashierInfo, tax_system = 1) => + agentReq('POST', '/kkt/shift/open', { cashier, tax_system }), + + closeShift: (cashier: CashierInfo) => + agentReq('POST', '/kkt/shift/close', { cashier }), + + xReport: () => + agentReq('POST', '/kkt/report/x', {}), + + cashIn: (amount: number, cashier: CashierInfo) => + agentReq('POST', '/kkt/cash-in', { amount, cashier }), + + cashOut: (amount: number, cashier: CashierInfo) => + agentReq('POST', '/kkt/cash-out', { amount, cashier }), + + printReceipt: (data: ReceiptData) => + agentReq('POST', '/kkt/receipt', data), + + printReturn: (data: ReceiptData) => + agentReq('POST', '/kkt/return', data), +} diff --git a/src/pages/PosPage.tsx b/src/pages/PosPage.tsx index 811ab70..ec49714 100644 --- a/src/pages/PosPage.tsx +++ b/src/pages/PosPage.tsx @@ -1,897 +1,779 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { - CreditCard, Banknote, Clock, LogIn, LogOut, CheckCircle2, - Receipt, Zap, Building2, Coffee, Car, Package, ChevronRight, - User, Printer, BarChart2, Settings2, Wifi, WifiOff, - FileText, RefreshCw, + 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 ───────────────────────────────────────────────────────────────────── +// ── Types ────────────────────────────────────────────────────────────────────── -interface RoomBalance { - roomId: string +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 - checkIn: string - checkOut: string - totalAmount: number - paidAmount: number - nights: number + guestName: string + balance: number // сумма к оплате + bookingId: string } -type PaymentMethod = 'cash' | 'terminal' -type ChargePurpose = 'stay' | 'breakfast' | 'transfer' | 'parking' | 'extra' -type PosTab = 'main' | 'report' | 'atol' -type AtolConnection = 'usb' | 'tcp' | 'com' -type TaxSystem = 'osn' | 'usn_income' | 'usn_expense' | 'eshn' | 'psn' +// ── Быстрые услуги ───────────────────────────────────────────────────────────── -interface ReceiptRecord { - id: string - roomNumber: string - guestName: string - purposeLabel: string - amount: number - method: PaymentMethod - createdAt: Date - shiftId: string -} - -// ── Mock data ────────────────────────────────────────────────────────────────── - -const MOCK_BALANCES: RoomBalance[] = [ - { roomId: 'r1', roomNumber: '101', guestName: 'Дмитрий Волков', checkIn: '2026-03-10', checkOut: '2026-03-12', totalAmount: 9000, paidAmount: 4500, nights: 2 }, - { roomId: 'r2', roomNumber: '201', guestName: 'Игорь Соколов', checkIn: '2026-03-09', checkOut: '2026-03-11', totalAmount: 14000, paidAmount: 14000, nights: 2 }, - { roomId: 'r3', roomNumber: '301', guestName: 'Наталья Александрова', checkIn: '2026-03-11', checkOut: '2026-03-15', totalAmount: 48000, paidAmount: 24000, nights: 4 }, - { roomId: 'r4', roomNumber: '401', guestName: 'Михаил Орлов', checkIn: '2026-03-10', checkOut: '2026-03-13', totalAmount: 36000, paidAmount: 0, nights: 3 }, +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 }, ] -const PURPOSE_LABELS: Record = { - stay: 'Проживание', - breakfast: 'Завтрак', - transfer: 'Трансфер', - parking: 'Парковка', - extra: 'Доп. услуга', +// ── 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} мин` } -const QUICK_CHARGES: { purpose: ChargePurpose; label: string; amount: number | null; Icon: React.ElementType }[] = [ - { purpose: 'stay', label: 'Проживание', amount: null, Icon: Building2 }, - { purpose: 'breakfast', label: 'Завтрак', amount: 650, Icon: Coffee }, - { purpose: 'transfer', label: 'Трансфер', amount: 2500, Icon: Car }, - { purpose: 'parking', label: 'Парковка', amount: 500, Icon: Package }, - { purpose: 'extra', label: 'Доп. услуга', amount: null, Icon: Zap }, -] - -const SEED_RECEIPTS: ReceiptRecord[] = [ - { id: 'rc1', roomNumber: '201', guestName: 'Игорь Соколов', purposeLabel: 'Проживание', amount: 14000, method: 'terminal', createdAt: new Date(Date.now() - 3600000 * 3), shiftId: 'shift-today' }, - { id: 'rc2', roomNumber: '101', guestName: 'Дмитрий Волков', purposeLabel: 'Проживание', amount: 4500, method: 'cash', createdAt: new Date(Date.now() - 3600000 * 2), shiftId: 'shift-today' }, - { id: 'rc3', roomNumber: '301', guestName: 'Наталья Александрова', purposeLabel: 'Проживание', amount: 24000, method: 'terminal', createdAt: new Date(Date.now() - 3600000), shiftId: 'shift-today' }, - { id: 'rc4', roomNumber: '101', guestName: 'Дмитрий Волков', purposeLabel: 'Завтрак', amount: 1300, method: 'cash', createdAt: new Date(Date.now() - 1800000), shiftId: 'shift-today' }, -] - -const TAX_SYSTEM_LABELS: Record = { - osn: 'ОСН', - usn_income: 'УСН доход', - usn_expense:'УСН расход', - eshn: 'ЕСХН', - psn: 'ПСН', -} - -// ── Subcomponents ───────────────────────────────────────────────────────────── - -function MethodIcon({ method, size = 11 }: { method: PaymentMethod; size?: number }) { - if (method === 'cash') return - return -} - -// ── Component ───────────────────────────────────────────────────────────────── - -export function PosPage() { - // Core state - const [shiftOpen, setShiftOpen] = useState(true) - const [shiftStartTime] = useState(new Date(Date.now() - 3600000 * 3)) - const [shiftTimer, setShiftTimer] = useState('') - const [receipts, setReceipts] = useState(SEED_RECEIPTS) - const [selectedRoom, setSelectedRoom] = useState(MOCK_BALANCES[0]) - const [purpose, setPurpose] = useState('stay') - const [amount, setAmount] = useState(0) - const [method, setMethod] = useState('terminal') - const [notes, setNotes] = useState('') - const [printFiscal, setPrintFiscal] = useState(true) - const [lastReceipt, setLastReceipt] = useState(null) - const [activeTab, setActiveTab] = useState('main') - - // Atol settings state - const [atolConnected, setAtolConnected] = useState(false) - const [atolChecking, setAtolChecking] = useState(false) - const [atolConnType, setAtolConnType] = useState('usb') - const [atolIp, setAtolIp] = useState('192.168.1.100') - const [atolPort, setAtolPort] = useState('9100') - const [atolComPort, setAtolComPort] = useState('COM1') - const [atolBaud, setAtolBaud] = useState('115200') - const [atolCashier, setAtolCashier] = useState('Елена Смирнова') - const [atolTax, setAtolTax] = useState('osn') - const [atolAutoClose, setAtolAutoClose] = useState(false) - const [atolSaved, setAtolSaved] = useState(false) - - // Report state - const [closeConfirm, setCloseConfirm] = useState(false) - - const shiftId = 'shift-today' - - // Update amount when room or purpose changes - useEffect(() => { - if (!selectedRoom) { setAmount(0); return } - if (purpose === 'stay') { - setAmount(Math.max(0, selectedRoom.totalAmount - selectedRoom.paidAmount)) - } else { - const qc = QUICK_CHARGES.find(q => q.purpose === purpose) - setAmount(qc?.amount ?? 0) - } - }, [selectedRoom, purpose]) - - // Shift timer - useEffect(() => { - if (!shiftOpen) return - const tick = () => { - const diff = Math.floor((Date.now() - shiftStartTime.getTime()) / 1000) - const h = Math.floor(diff / 3600) - const m = Math.floor((diff % 3600) / 60) - const s = diff % 60 - setShiftTimer(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`) - } - tick() - const id = setInterval(tick, 1000) - return () => clearInterval(id) - }, [shiftOpen, shiftStartTime]) - - const shiftReceipts = receipts.filter(r => r.shiftId === shiftId) - const shiftRevenue = shiftReceipts.reduce((s, r) => s + r.amount, 0) - const cashRevenue = shiftReceipts.filter(r => r.method === 'cash').reduce((s, r) => s + r.amount, 0) - const termRevenue = shiftReceipts.filter(r => r.method === 'terminal').reduce((s, r) => s + r.amount, 0) - const cashCount = shiftReceipts.filter(r => r.method === 'cash').length - const termCount = shiftReceipts.filter(r => r.method === 'terminal').length - - const handlePay = () => { - if (!selectedRoom || amount <= 0 || !shiftOpen) return - const rec: ReceiptRecord = { - id: `rc-${Date.now()}`, - roomNumber: selectedRoom.roomNumber, - guestName: selectedRoom.guestName, - purposeLabel: PURPOSE_LABELS[purpose], - amount, - method, - createdAt: new Date(), - shiftId, - } - setReceipts(prev => [rec, ...prev]) - setLastReceipt(rec) - setNotes('') - } - - const handleAtolCheck = () => { - setAtolChecking(true) - setTimeout(() => { - setAtolChecking(false) - setAtolConnected(true) - }, 1000) - } - - const handleAtolSave = () => { - setAtolSaved(true) - setTimeout(() => setAtolSaved(false), 2000) - } - - const handleCloseShift = () => { - setCloseConfirm(false) - setShiftOpen(false) - setActiveTab('main') - } - - const balance = selectedRoom ? Math.max(0, selectedRoom.totalAmount - selectedRoom.paidAmount) : 0 - const isPaid = selectedRoom ? selectedRoom.paidAmount >= selectedRoom.totalAmount : false - - const TABS: { key: PosTab; label: string; Icon: React.ElementType }[] = [ - { key: 'main', label: 'Касса', Icon: Receipt }, - { key: 'report', label: 'X/Z-отчёт', Icon: BarChart2 }, - { key: 'atol', label: 'АТОЛ', Icon: Settings2 }, - ] +// ── Sub-components ───────────────────────────────────────────────────────────── +function AgentOffline() { return ( -
- - {/* ── Desktop-only notice on mobile ── */} -
-
- -
-
-

Касса — только десктоп

-

- Откройте приложение на компьютере или планшете для работы с кассой -

-
+
+
+
- - {/* ── Full POS UI (desktop only) ── */} -
- - {/* ── Top bar ── */} -
-
-
-

Касса

-

- {format(new Date(), 'd MMMM yyyy', { locale: ru })} · Елена Смирнова -

-
-
- {shiftOpen && ( -
- - {shiftTimer} -
- )} - -
-
- - {/* Tab bar */} - {shiftOpen && ( -
- {TABS.map(t => ( - - ))} -
- )} +
+

Агент не подключён

+

+ На этом компьютере не запущен HotelSync Agent.
+ Обратитесь к системному администратору. +

- - {/* ── Shift closed screen ── */} - {!shiftOpen ? ( -
- -
-

Смена закрыта

-

Откройте смену для начала работы

-
- -
- ) : ( - <> - {/* ══════════════════════ TAB: main ══════════════════════ */} - {activeTab === 'main' && ( -
- - {/* ── Заезды: horizontal scroll on mobile, sidebar on desktop ── */} -
-
-

Заезды

-
- {/* Mobile: horizontal scroll row */} -
- {MOCK_BALANCES.map(rb => { - const debt = Math.max(0, rb.totalAmount - rb.paidAmount) - const paid = debt === 0 - const sel = selectedRoom?.roomId === rb.roomId - return ( - - ) - })} -
- {/* Desktop: vertical list */} -
- {MOCK_BALANCES.map(rb => { - const debt = Math.max(0, rb.totalAmount - rb.paidAmount) - const paid = debt === 0 - const sel = selectedRoom?.roomId === rb.roomId - return ( - - ) - })} -
-
- - {/* ── Center: payment form ── */} -
- {selectedRoom ? ( - <> - {/* Guest info */} -
-
-
- -
-
-

{selectedRoom.guestName}

-

- Номер {selectedRoom.roomNumber} · {selectedRoom.nights} {selectedRoom.nights === 1 ? 'ночь' : selectedRoom.nights < 5 ? 'ночи' : 'ночей'} -

-
-
-

К оплате

-

- {formatCurrency(balance)} -

-
-
- {/* Balance bar */} -
-
- Оплачено {formatCurrency(selectedRoom.paidAmount)} - Всего {formatCurrency(selectedRoom.totalAmount)} -
-
-
-
-
-
- - {/* Quick charge buttons */} -
-

Назначение платежа

-
- {QUICK_CHARGES.map(qc => ( - - ))} -
-
- - {/* Amount */} -
- - setAmount(Math.max(0, parseInt(e.target.value) || 0))} - placeholder="0" - /> -
- - {/* Payment method */} -
-

Способ оплаты

-
- {([ - { key: 'cash', label: 'Наличные', Icon: Banknote, cls: 'emerald' }, - { key: 'terminal', label: 'Терминал', Icon: CreditCard, cls: 'blue' }, - ] as const).map(m => ( - - ))} -
-
- - {/* Fiscal receipt (ATOL) */} -
-
- -
-

- Фискальный чек (АТОЛ) -

-

- {atolConnected ? 'Регистратор подключён · автопечать чека' : 'Регистратор не подключён'} -

-
-
- -
- - {/* Notes */} -
- - setNotes(e.target.value)} - /> -
- - {/* Pay button */} - - - ) : ( -
-

Выберите гостя из списка слева

-
- )} -
- - {/* ── Right sidebar: Чеки (w-64) ── */} -
- {/* Shift totals */} -
-
-
- - Чеки -
- - {shiftReceipts.length} - -
-
-
- Наличные - {formatCurrency(cashRevenue)} -
-
- Терминал - {formatCurrency(termRevenue)} -
-
- Итого - {formatCurrency(shiftRevenue)} -
-
-
- - {/* Receipt list — compact rows */} -
- {shiftReceipts.map(r => ( -
- {format(r.createdAt, 'HH:mm')} - №{r.roomNumber} - {formatCurrency(r.amount)} - -
- ))} -
-
-
- )} - - {/* ══════════════════════ TAB: report ══════════════════════ */} - {activeTab === 'report' && ( -
-
- {/* X-report card */} -
- {/* Header */} -
-

X-ОТЧЁТ (сменный)

-

Гранд Палас

-

Кассир: Елена Смирнова

-

{format(new Date(), 'd MMMM yyyy, HH:mm', { locale: ru })}

-
- - Смена №1 · открыта {format(shiftStartTime, 'HH:mm')} · {shiftTimer} -
-
- - {/* Revenue table */} -
- - - - - - - - - - - - - - - - - - - - - - - - - -
Способ оплатыЧековСумма
- - Наличные - {cashCount}{formatCurrency(cashRevenue)}
- - Терминал - {termCount}{formatCurrency(termRevenue)}
ИТОГО{shiftReceipts.length}{formatCurrency(shiftRevenue)}
- - {/* Fiscal register info */} -
- - Фискальный регистратор АТОЛ: {atolConnected ? подключён : не подключён} -
- - {/* Refunds */} -
-
- Возвраты - {formatCurrency(0)} -
-
- Чистая выручка - {formatCurrency(shiftRevenue)} -
-
- - {/* Footer note */} -
-

- Следующий шаг: закрыть смену (Z-отчёт) для передачи данных в ФНС -

-
-
-
- - {/* Actions */} -
- - -
-
-
- )} - - {/* ══════════════════════ TAB: atol ══════════════════════ */} - {activeTab === 'atol' && ( -
-
- - {/* Connection status */} -
-
-

Подключение к АТОЛ

-
- - {atolConnected - ? Подключено - : Нет связи - } -
-
- - - - {/* Connection type */} -
-

Тип подключения

-
- {([ - { key: 'usb', label: 'USB' }, - { key: 'tcp', label: 'TCP/IP' }, - { key: 'com', label: 'COM-порт' }, - ] as const).map(ct => ( - - ))} -
-
- - {/* TCP/IP fields */} - {atolConnType === 'tcp' && ( -
-
- - setAtolIp(e.target.value)} - placeholder="192.168.1.100" - /> -
-
- - setAtolPort(e.target.value)} - placeholder="9100" - /> -
-
- )} - - {/* COM port fields */} - {atolConnType === 'com' && ( -
-
- - -
-
- - -
-
- )} -
- - {/* Cashier & tax */} -
-

Параметры кассира

-
- - setAtolCashier(e.target.value)} - placeholder="Фамилия Имя" - /> -
-
-

Система налогообложения

-
- {(Object.keys(TAX_SYSTEM_LABELS) as TaxSystem[]).map(ts => ( - - ))} -
-
-
- - {/* Auto close toggle */} -
-
-
-

Автоматически закрывать смену

-

В конце дня авто-печать Z-отчёта и закрытие смены

-
- -
-
- - {/* Save button */} - -
-
- )} - - )} - - {/* ── Receipt success overlay ── */} - {lastReceipt && ( -
-
- -
-

Оплата принята

-

{formatCurrency(lastReceipt.amount)}

-

- {lastReceipt.method === 'cash' ? 'Наличными' : 'Терминал'} · {format(lastReceipt.createdAt, 'HH:mm')} - {printFiscal && atolConnected && · Фискальный чек распечатан} -

-
-
-
- Гость - {lastReceipt.guestName} -
-
- Номер - №{lastReceipt.roomNumber} -
-
- Назначение - {lastReceipt.purposeLabel} -
-
- -
-
- )} - - {/* ── Close shift confirmation modal ── */} - {closeConfirm && ( -
-
-
-
- -
-
-

Закрыть смену?

-

Будет напечатан Z-отчёт и смена закроется

-
-
-
-
- Итого за смену - {formatCurrency(shiftRevenue)} -
-
- Чеков - {shiftReceipts.length} -
-
-
- - -
-
-
- )} -
{/* end desktop-only wrapper */} +
+ ) +} + +function ShiftClosed({ onOpen, loading }: { onOpen: () => void; loading: boolean }) { + return ( +
+
+ +
+
+

Смена закрыта

+

+ Откройте смену чтобы начать принимать оплату +

+
+ +
+ ) +} + +function ShiftExpired({ onClose, loading }: { onClose: () => void; loading: boolean }) { + return ( +
+
+ +
+
+

Смена истекла (более 24 часов)

+

+ Необходимо закрыть текущую смену и открыть новую +

+
+ +
+ ) +} + +// ── Шапка с информацией о смене ──────────────────────────────────────────────── + +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 ( +
+
+ + {/* Рабочее место */} +
+ Рабочее место: + {identity.workstation_name} +
+ + {/* Смена */} +
+ Смена №{shift.shift_number}: + + + {shift.opened_at ? shiftDuration(shift.opened_at) : '—'} + +
+ + {/* Наличные */} + {shift.cash_sum !== undefined && ( +
+ В кассе: + + {formatCurrency(shift.cash_sum)} + +
+ )} + + {/* ОФД статус */} + {kktStatus.info && ( +
+ + ОФД: {kktStatus.info.ofd_status === 'ok' ? '✓ Подключён' : '✗ Нет связи'} + +
+ )} + + {/* Кнопки */} +
+ + + + + +
+
+
+ ) +} + +// ── Диалог внесения/изъятия наличных ────────────────────────────────────────── + +function CashDialog({ + type, cashier, onConfirm, onClose, +}: { + type: 'in' | 'out' + cashier: CashierInfo + onConfirm: (amount: number) => Promise + onClose: () => void +}) { + const [amount, setAmount] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+
+

+ {type === 'in' ? '💵 Внесение наличных' : '💴 Изъятие наличных'} +

+ + 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 &&

{error}

} +
+ + +
+
+
+ ) +} + +// ── Панель чека ──────────────────────────────────────────────────────────────── + +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 ( +
+
+ +
+
+

Оплата принята

+

{formatCurrency(total)}

+ {payResult.receipt_number && ( +

Чек №{payResult.receipt_number}

+ )} +
+ +
+ ) + } + + return ( +
+ {/* Гость */} + {guest && ( +
+ + + №{guest.roomNumber} — {guest.guestName} + +
+ )} + + {/* Позиции */} +
+ {cart.length === 0 ? ( +

+ Выберите услугу из списка слева +

+ ) : ( + cart.map((item, i) => ( +
+
+

{item.name}

+

{formatCurrency(item.price)} × {item.quantity}

+
+
+ + {item.quantity} + +
+

+ {formatCurrency(item.price * item.quantity)} +

+ +
+ )) + )} +
+ + {/* Итого и оплата */} + {cart.length > 0 && ( +
+
+ Итого + {formatCurrency(total)} +
+ + {/* Способ оплаты */} +
+ {([['cash', 'Наличные', Banknote], ['card', 'Терминал', CreditCard]] as const).map(([t, label, Icon]) => ( + + ))} +
+ + {payResult?.error && ( +
+ +

{payResult.error}

+
+ )} + + +
+ )} +
+ ) +} + +// ── Main Page ────────────────────────────────────────────────────────────────── + +export function PosPage() { + const { user } = useAuth() + + // ── State ────────────────────────────────────────────────────────────────── + const [agentOnline, setAgentOnline] = useState(null) // null = loading + const [identity, setIdentity] = useState(null) + const [kktStatus, setKktStatus] = useState(null) + const [loadingAction, setLoadingAction] = useState(null) + const [actionError, setActionError] = useState(null) + const [actionSuccess, setActionSuccess] = useState(null) + + const [cart, setCart] = useState([]) + const [selectedGuest, setGuest] = useState(null) + const [paymentType, setPaymentType] = useState('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(null) + + // Временные гости (mock — в будущем из API) + const [guests] = useState([ + { 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 ( +
+ +
+ ) + } + + if (!agentOnline) return + + const shift = kktStatus?.shift + + if (!shift || shift.state === 'closed') { + return ( +
+ {actionError && ( +
+ {actionError} +
+ )} + +
+ ) + } + + if (shift.state === 'expired') { + return ( +
+ +
+ ) + } + + // ── Смена открыта — основной интерфейс ──────────────────────────────────── + + return ( +
+ {/* Шапка смены */} + setCashDialog('in')} + onCashOut={() => setCashDialog('out')} + onRefresh={loadStatus} + loadingAction={loadingAction} + /> + + {/* Уведомления */} + {(actionError || actionSuccess) && ( +
+ {actionError + ? + : + } + {actionError || actionSuccess} +
+ )} + + {/* Основная зона */} +
+ + {/* Левая панель — гости и услуги */} +
+ + {/* Гости */} +
+

Гости в отеле

+
+ {guests.map(g => ( + + ))} +
+
+ + {/* Услуги */} +
+

Быстрые услуги

+
+ {QUICK_SERVICES.map(s => ( + + ))} + + {/* Произвольная сумма */} + +
+
+
+ + {/* Правая панель — чек */} +
+ setCart(prev => prev.filter((_, idx) => idx !== i))} + onChangeQty={changeQty} + onPay={handlePay} + paying={paying} + payResult={payResult} + onBack={resetCart} + /> +
+
+ + {/* Диалог произвольной услуги */} + {customService && ( +
+
+

Добавить услугу

+ + setCustomService(s => s && { ...s, name: e.target.value })} + className="w-full input-field mb-3" + /> + + 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 + /> +
+ + +
+
+
+ )} + + {/* Диалог наличных */} + {cashDialog && ( + setCashDialog(null)} + /> + )}
) }