import { useState, useEffect, useCallback } from 'react' import { Monitor, Plus, Trash2, Pencil, Check, X, RefreshCw, Wifi, WifiOff, Printer, CreditCard, Usb, Network, Cable, ChevronDown, ChevronRight, Copy, Clock, AlertCircle, Zap, Download, ArrowUpCircle, } from 'lucide-react' import { cn } from '../lib/utils' import { api, type Workstation, type WorkstationDevice } from '../lib/api' import { useAuth } from '../contexts/AuthContext' import { useHotelSocket } from '../hooks/useHotelSocket' // ─── Helpers ────────────────────────────────────────────────────────────────── const PURPOSE_LABELS: Record = { fiscal: 'Фискальный чек', kitchen: 'Принтер кухни', bar: 'Принтер бара', receipt: 'Чек / квитанция', other: 'Другое', } const TYPE_LABELS: Record = { kkt: 'ККТ (касса)', printer: 'Принтер', netup: 'NetUp IPTV', } function compareVersions(a: string, b: string): number { const pa = a.split('.').map(Number) const pb = b.split('.').map(Number) for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const da = pa[i] ?? 0 const db = pb[i] ?? 0 if (da !== db) return da - db } return 0 } function OnlineBadge({ online, lastSeen }: { online: boolean; lastSeen: string | null }) { if (online) { return ( Онлайн ) } const ago = lastSeen ? (() => { const diff = Date.now() - new Date(lastSeen).getTime() const m = Math.floor(diff / 60000) if (m < 60) return `${m} мин назад` const h = Math.floor(m / 60) if (h < 24) return `${h} ч назад` return `${Math.floor(h / 24)} дн назад` })() : null return ( {ago ? `Был ${ago}` : 'Нет связи'} ) } // ─── Pair Code Modal ────────────────────────────────────────────────────────── function PairCodeModal({ workstation, slug, onClose, }: { workstation: Workstation slug: string onClose: () => void }) { const [code, setCode] = useState(null) const [expiresAt, setExpiresAt] = useState(null) const [loading, setLoading] = useState(true) const [copied, setCopied] = useState(false) const [timeLeft, setTimeLeft] = useState(600) const [paired, setPaired] = useState(false) const fetchCode = useCallback(() => { setLoading(true) api.workstations.pairCode(slug, workstation.id).then(r => { setCode(r.code) setExpiresAt(r.expiresAt) setTimeLeft(600) setLoading(false) }) }, [slug, workstation.id]) // Poll workstation status — auto-close when agent connects useEffect(() => { const poll = setInterval(async () => { try { const list = await api.workstations.list(slug) const ws = list.find((w: Workstation) => w.id === workstation.id) if (ws?.agentId && ws?.isOnline) { setPaired(true) clearInterval(poll) setTimeout(onClose, 1500) } } catch { /* ignore */ } }, 3000) return () => clearInterval(poll) }, [slug, workstation.id, onClose]) useEffect(() => { fetchCode() }, [fetchCode]) useEffect(() => { if (!expiresAt) return const tick = setInterval(() => { const left = Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000)) setTimeLeft(left) if (left === 0) { clearInterval(tick) // Автоматически получаем новый код fetchCode() } }, 1000) return () => clearInterval(tick) }, [expiresAt, fetchCode]) const copy = () => { if (!code) return navigator.clipboard.writeText(code) setCopied(true) setTimeout(() => setCopied(false), 2000) } const mm = String(Math.floor(timeLeft / 60)).padStart(2, '0') const ss = String(timeLeft % 60).padStart(2, '0') return (

Подключить агента

Рабочее место: {workstation.name}

{paired ? (

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

Окно закроется автоматически...

) : loading ? (
) : ( <>

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

{code}
{mm}:{ss} до истечения
  1. Установите HotelSync Agent на компьютер
  2. Запустите — появится форма ввода кода
  3. Введите код выше и нажмите «Подключить»
)}
) } // ─── Device Form ────────────────────────────────────────────────────────────── function DeviceForm({ initial, onSave, onCancel, slug, wsId, wsOnline, }: { initial?: Partial onSave: (data: Partial) => Promise onCancel: () => void slug: string wsId: string wsOnline: boolean }) { const [type, setType] = useState<'kkt' | 'printer' | 'netup'>(initial?.type ?? 'printer') const handleTypeChange = (t: 'kkt' | 'printer' | 'netup') => { setType(t) if (t === 'netup') setConnection('network') if (t === 'kkt') setConnection('com') } const [name, setName] = useState(initial?.name ?? '') const [connection, setConnection] = useState<'usb' | 'network' | 'com' | 'windows'>(initial?.connection ?? 'network') const [networkHost, setNetworkHost] = useState(initial?.networkHost ?? '') const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100)) const [comPort, setComPort] = useState(initial?.comPort ?? '') const [purpose, setPurpose] = useState(initial?.purpose ?? 'receipt') const [fiscalMode, setFiscalMode] = useState(initial?.fiscalMode !== false) const [dtoPort, setDtoPort] = useState(String(initial?.dtoPort ?? 16732)) // config keys come from API already camelCased by transformKeys const [dtoUser, setDtoUser] = useState(String((initial?.config as any)?.dtoUser ?? (initial?.config as any)?.dto_user ?? '30')) const [dtoPass, setDtoPass] = useState(String((initial?.config as any)?.dtoPass ?? (initial?.config as any)?.dto_pass ?? '30')) const [dtoDeviceId, setDtoDeviceId] = useState(String((initial?.config as any)?.dtoDeviceId ?? (initial?.config as any)?.dto_device_id ?? '')) const [saving, setSaving] = useState(false) const [availablePorts, setAvailablePorts] = useState<{ port: string; description?: string }[] | null>(null) const [portsLoading, setPortsLoading] = useState(false) const [availablePrinters, setAvailablePrinters] = useState<{ name: string; isDefault: boolean }[] | null>(null) const [printersLoading, setPrintersLoading] = useState(false) const [windowsPrinter, setWindowsPrinter] = useState(String((initial?.config as any)?.windowsPrinter ?? (initial?.config as any)?.windows_printer ?? '')) const [encoding, setEncoding] = useState<'utf8' | 'cp866'>((initial?.config as any)?.encoding ?? 'utf8') const [paperWidth, setPaperWidth] = useState(String((initial?.config as any)?.paper_width ?? 576)) const loadPorts = () => { if (!wsOnline) return setAvailablePorts(null) setPortsLoading(true) api.workstations.listPorts(slug, wsId) .then(r => setAvailablePorts(r.ports)) .catch(() => setAvailablePorts([])) .finally(() => setPortsLoading(false)) } const loadPrinters = () => { if (!wsOnline) return setAvailablePrinters(null) setPrintersLoading(true) api.workstations.listPrinters(slug, wsId) .then(r => setAvailablePrinters(r.printers)) .catch(() => setAvailablePrinters([])) .finally(() => setPrintersLoading(false)) } // Загружаем порты когда выбирается COM (availablePorts в deps чтобы не было stale closure) useEffect(() => { if (connection !== 'com' || !wsOnline || availablePorts !== null) return setPortsLoading(true) api.workstations.listPorts(slug, wsId) .then(r => setAvailablePorts(r.ports)) .catch(() => setAvailablePorts([])) .finally(() => setPortsLoading(false)) }, [connection, wsOnline, slug, wsId, availablePorts]) // Загружаем принтеры когда выбирается Windows useEffect(() => { if (connection !== 'windows' || !wsOnline || availablePrinters !== null) return setPrintersLoading(true) api.workstations.listPrinters(slug, wsId) .then(r => setAvailablePrinters(r.printers)) .catch(() => setAvailablePrinters([])) .finally(() => setPrintersLoading(false)) }, [connection, wsOnline, slug, wsId, availablePrinters]) const handleSave = async () => { if (!name.trim()) return setSaving(true) const configData: Record = {} if (type === 'kkt' && fiscalMode) { configData.dto_user = dtoUser configData.dto_pass = dtoPass if (dtoDeviceId.trim()) configData.dto_device_id = dtoDeviceId.trim() } if (type === 'printer' && connection === 'windows' && windowsPrinter.trim()) { configData.windows_printer = windowsPrinter.trim() } if (type === 'printer' && connection === 'network') { configData.encoding = encoding configData.paper_width = Number(paperWidth) || 576 } await onSave({ type, name: name.trim(), connection: type === 'kkt' ? 'com' : connection, networkHost: connection === 'network' ? networkHost.trim() : undefined, networkPort: connection === 'network' ? Number(networkPort) : undefined, comPort: connection === 'com' ? comPort.trim() : undefined, purpose: purpose as WorkstationDevice['purpose'], fiscalMode: type === 'kkt' ? fiscalMode : undefined, dtoPort: type === 'kkt' && fiscalMode ? Number(dtoPort) : undefined, config: Object.keys(configData).length ? configData : undefined, }) setSaving(false) } return (
{type !== 'netup' && (
)}
{/* KKT-specific: fiscal mode + DTO port */} {type === 'kkt' && (
{([ { id: true, label: 'Фискальный', hint: 'Чеки с ФН, ОФД' }, { id: false, label: 'Нефискальный', hint: 'Только документы' }, ] as const).map(m => ( ))}
{fiscalMode && (
setDtoPort(e.target.value)} className="w-full input text-sm font-mono" placeholder="16732" />

По умолчанию 16732

setDtoDeviceId(e.target.value)} className="w-full input text-sm font-mono" placeholder="1" />

Из /api/v2/devices

setDtoUser(e.target.value)} className="w-full input text-sm" placeholder="30" />
setDtoPass(e.target.value)} className="w-full input text-sm" placeholder="30" />
)}
)}
setName(e.target.value)} className="w-full input text-sm" placeholder={type === 'netup' ? 'NetUp сервер' : type === 'kkt' ? 'Касса №1' : 'Принтер кухни'} />
{type === 'printer' && (
{([ { id: 'network', icon: , label: 'Сеть (TCP)' }, { id: 'windows', icon: , label: 'Windows принтер' }, { id: 'com', icon: , label: 'COM-порт' }, ] as const).map(c => ( ))}
)} {connection === 'network' && (
setNetworkHost(e.target.value)} className="w-full input text-sm font-mono" placeholder={type === 'netup' ? '172.16.0.12' : '192.168.1.50'} />
setNetworkPort(e.target.value)} className="w-full input text-sm font-mono" placeholder={type === 'netup' ? '8880' : '9100'} />
{type === 'printer' && (
setPaperWidth(e.target.value)} className="w-full input text-sm font-mono" placeholder="576" />

58мм=384, 80мм=576

)}
)} {connection === 'windows' && (
{wsOnline && availablePrinters && availablePrinters.length > 0 ? ( ) : ( setWindowsPrinter(e.target.value)} className="w-full input text-sm" placeholder="Microsoft Print to PDF" /> )} {wsOnline && availablePrinters !== null && availablePrinters.length === 0 && (

Принтеры не найдены. Убедитесь что принтер установлен в Windows.

)} {!wsOnline && (

Терминал офлайн — введите имя принтера вручную

)}
)} {connection === 'com' && type !== 'kkt' && (
{wsOnline && availablePorts && availablePorts.length > 0 ? ( ) : ( setComPort(e.target.value)} className="w-full input text-sm font-mono" placeholder="COM1" /> )} {wsOnline && availablePorts !== null && availablePorts.length === 0 && (

COM-порты не найдены — нажмите ↺ для повтора

)} {!wsOnline && (

Терминал офлайн — введите порт вручную (COM1, COM3…)

)}
)}
) } // ─── Workstation Card ───────────────────────────────────────────────────────── function WorkstationCard({ ws, slug, latestRelease, onRefresh, }: { ws: Workstation slug: string latestRelease: { version: string; downloadUrl: string } | null onRefresh: () => void }) { const [expanded, setExpanded] = useState(true) const [editing, setEditing] = useState(false) const [name, setName] = useState(ws.name) const [showPairModal, setShowPairModal] = useState(false) // Sync local name state when ws.name prop changes (after onRefresh) useEffect(() => { if (!editing) setName(ws.name) }, [ws.name, editing]) const [showAddDevice, setShowAddDevice] = useState(false) const [editDeviceId, setEditDeviceId] = useState(null) const [updating, setUpdating] = useState(false) const [updateMsg, setUpdateMsg] = useState(null) const [testResults, setTestResults] = useState>({}) const [testingDeviceId, setTestingDeviceId] = useState(null) const [showPing, setShowPing] = useState(false) const [pingHost, setPingHost] = useState('') const [pinging, setPinging] = useState(false) const [pingResult, setPingResult] = useState<{ ok: boolean; output?: string; error?: string } | null>(null) const hasUpdate = !!( ws.isOnline && latestRelease && ws.agentVersion && compareVersions(latestRelease.version, ws.agentVersion) > 0 ) const saveName = async () => { if (!name.trim() || name === ws.name) { setEditing(false); return } await api.workstations.update(slug, ws.id, { name: name.trim() }) setEditing(false) onRefresh() } const deleteWs = async () => { if (!confirm(`Удалить рабочее место «${ws.name}»?`)) return await api.workstations.remove(slug, ws.id) onRefresh() } const addDevice = async (data: Partial) => { await api.workstations.addDevice(slug, ws.id, data) setShowAddDevice(false) onRefresh() } const updateDevice = async (deviceId: string, data: Partial) => { await api.workstations.updateDevice(slug, ws.id, deviceId, data) setEditDeviceId(null) onRefresh() } const deleteDevice = async (deviceId: string) => { if (!confirm('Удалить устройство?')) return await api.workstations.removeDevice(slug, ws.id, deviceId) onRefresh() } const sendUpdate = async () => { if (updating) return setUpdating(true) setUpdateMsg(null) try { await api.workstations.sendUpdate(slug, ws.id) setUpdateMsg('Команда отправлена') } catch (err) { setUpdateMsg(err instanceof Error ? err.message : 'Ошибка') } finally { setUpdating(false) setTimeout(() => setUpdateMsg(null), 4000) } } const testDevice = async (deviceId: string) => { if (!ws.isOnline) return setTestResults(prev => ({ ...prev, [deviceId]: null })) setTestingDeviceId(deviceId) try { const r = await api.workstations.testDevice(slug, ws.id, deviceId) let msg = r.message ?? r.error ?? '' // Для ккт — форматируем информацию о кассе if (r.ok && r.kkt) { const k = r.kkt as { model?: string; serial_number?: string; fn_number?: string; ofd_status?: string } const s = r.shift as { state?: string; shift_number?: number } | undefined const shiftLabel = s?.state === 'open' ? `Смена #${s.shift_number} открыта` : s?.state === 'expired' ? 'Смена истекла!' : 'Смена закрыта' msg = `${k.model ?? 'ККТ'} · СН: ${k.serial_number ?? '—'} · ФН: ${k.fn_number ?? '—'} · ${shiftLabel}` } setTestResults(prev => ({ ...prev, [deviceId]: { ok: r.ok, msg } })) } catch (err) { setTestResults(prev => ({ ...prev, [deviceId]: { ok: false, msg: err instanceof Error ? err.message : 'Ошибка' } })) } finally { setTestingDeviceId(null) setTimeout(() => setTestResults(prev => ({ ...prev, [deviceId]: null })), 8000) } } const pingNow = async () => { if (!pingHost.trim() || pinging) return setPinging(true) setPingResult(null) try { const r = await api.workstations.ping(slug, ws.id, pingHost.trim()) setPingResult(r) } catch (err) { setPingResult({ ok: false, error: err instanceof Error ? err.message : 'Ошибка' }) } finally { setPinging(false) } } const devices = ws.devices ?? [] return ( <>
{/* Header */}
{editing ? (
setName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') saveName(); if (e.key === 'Escape') setEditing(false) }} className="input py-1 text-sm w-48" autoFocus />
) : (
{ws.name}
)}
{ws.hostname && (

{ws.hostname} {ws.ipAddress ? `· ${ws.ipAddress}` : ''}

)} {ws.agentVersion && ( v{ws.agentVersion} )}
{hasUpdate && ( )} {updateMsg && ( {updateMsg} )} {!ws.agentId ? ( ) : !ws.isOnline ? ( ) : null}
{/* Devices */} {expanded && (
{devices.length === 0 && !showAddDevice && (

Устройства не добавлены

)} {devices.map(device => (
{editDeviceId === device.id ? ( updateDevice(device.id, data)} onCancel={() => setEditDeviceId(null)} slug={slug} wsId={ws.id} wsOnline={ws.isOnline} /> ) : (
{device.type === 'kkt' ? : }

{device.name}

{device.type === 'kkt' && ( {device.fiscalMode !== false ? 'Фискальный' : 'Нефискальный'} )}
{PURPOSE_LABELS[device.purpose]} · {device.connection === 'usb' ? : device.connection === 'com' ? : } {device.connection === 'usb' ? 'USB' : device.connection === 'com' ? (device.comPort ?? 'COM?') : device.networkHost ? `${device.networkHost}:${device.networkPort}` : 'IP не задан'} {device.type === 'kkt' && device.fiscalMode !== false && device.dtoPort && device.dtoPort !== 16732 && ( DTO:{device.dtoPort} )} {device.type === 'kkt' && device.fiscalMode !== false && device.config?.dto_device_id && ( #{device.config.dto_device_id} )}
{ws.isOnline && ( )}
{testResults[device.id] !== undefined && testResults[device.id] !== null && (() => { const res = testResults[device.id]! const isDtoError = !res.ok && res.msg.includes('ДТО') return (
{res.ok ? : } {res.msg || (res.ok ? 'Успешно' : 'Ошибка')} {isDtoError && (

Для работы кассы нужно установить и запустить АТОЛ ДТО 10:

  1. Скачайте бесплатно с atol.ru → Поддержка → ДТО 10
  2. Установите и запустите службу ATOL DTO Service
  3. Порт по умолчанию: 16732
)}
) })()}
)}
))} {showAddDevice ? ( setShowAddDevice(false)} slug={slug} wsId={ws.id} wsOnline={ws.isOnline} /> ) : ( )} {/* Ping tool — only when agent is online */} {ws.isOnline && (
{showPing && (
setPingHost(e.target.value)} onKeyDown={e => e.key === 'Enter' && pingNow()} placeholder="192.168.1.1 или hostname" className="input text-sm font-mono flex-1 py-1.5" autoFocus />
{pingResult && (
{pingResult.ok && pingResult.output ? (
{pingResult.output.trim()}
) : (

{pingResult.error ?? 'Ошибка'}

)}
)}
)}
)}
)}
{showPairModal && ( { setShowPairModal(false); onRefresh() }} /> )} ) } // ─── Main Page ──────────────────────────────────────────────────────────────── export function EquipmentPage() { const { user, session } = useAuth() const slug = user?.hotelSlug ?? '' const [workstations, setWorkstations] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [addingName, setAddingName] = useState('') const [showAdd, setShowAdd] = useState(false) const [adding, setAdding] = useState(false) const [latestRelease, setLatestRelease] = useState<{ version: string; downloadUrl: string } | null>(null) const [updatingAll, setUpdatingAll] = useState(false) const load = useCallback(async () => { if (!slug) return try { const data = await api.workstations.list(slug) setWorkstations(data) setError(null) } catch { setError('Не удалось загрузить данные') } finally { setLoading(false) } }, [slug]) useEffect(() => { load() }, [load]) // Auto-refresh every 30 s so online/offline status stays current useEffect(() => { const timer = setInterval(load, 30_000) return () => clearInterval(timer) }, [load]) // Instant update when agent connects or disconnects useHotelSocket({ slug, token: session?.token ?? null, onMessage: (msg) => { if (msg.type === 'workstation:online' || msg.type === 'workstation:offline') { load() } }, }) useEffect(() => { api.agents.getLatestRelease().then(setLatestRelease).catch(() => {}) }, []) const handleAdd = async () => { if (!addingName.trim()) return setAdding(true) await api.workstations.create(slug, addingName.trim()) setAddingName('') setShowAdd(false) setAdding(false) load() } const handleUpdateAll = async () => { if (updatingAll) return setUpdatingAll(true) try { await api.workstations.updateAll(slug) } catch { // ignore } finally { setUpdatingAll(false) } } const online = workstations.filter(w => w.isOnline).length const total = workstations.length const noPairs = workstations.filter(w => !w.agentId).length const anyOutdated = latestRelease !== null && workstations.some(w => w.isOnline && w.agentVersion && compareVersions(latestRelease.version, w.agentVersion) > 0, ) return (
{/* Header */}

Оборудование

Рабочие места, кассы и принтеры

{anyOutdated && ( )} {latestRelease ? ( Скачать агент v{latestRelease.version} ) : ( Агент недоступен )}
{/* Stats */} {total > 0 && (
{[ { label: 'Рабочих мест', value: total, icon: Monitor, color: 'text-slate-600 dark:text-slate-400' }, { label: 'Онлайн', value: online, icon: Wifi, color: 'text-emerald-600' }, { label: 'Не настроено', value: noPairs, icon: WifiOff, color: noPairs > 0 ? 'text-amber-600' : 'text-slate-400' }, ].map(s => (

{s.value}

{s.label}

))}
)} {/* Add form */} {showAdd && (

Новое рабочее место

setAddingName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setShowAdd(false) }} className="flex-1 input" placeholder="Ресепшн 1 / Бар / Касса SPA" autoFocus />
)} {/* Content */} {loading ? (
{[1,2].map(i => (
))}
) : error ? (
{error}
) : workstations.length === 0 ? (

Рабочих мест пока нет

Создайте рабочее место и подключите к нему агент Windows

) : (
{workstations.map(ws => ( ))}
)} {/* Info block */}

Как подключить агента

  1. Создайте рабочее место и нажмите «Подключить агента»
  2. Получите 6-значный код (действует 10 минут)
  3. Установите HotelSync Agent на компьютер и введите код
  4. Добавьте кассы и принтеры к рабочему месту
) }