Files
hotelsync/src/pages/EquipmentPage.tsx
HotelSync f837a84db5 feat: TTLock encoder per workstation (Option A)
- Migration 051: add ttlock_com_port to workstations, drop com_port/workstation_id from hotel_ttlock_config
- Backend workstations PATCH: accepts ttlock_com_port alongside name
- Backend ttlock: test/issue-card use per-workstation com_port; auto-select online encoder if workstationId not specified
- TTLockPage Step 2: per-workstation table with COM port input, Save, Test buttons per row
- BookingDetailPanel: auto-issue if 1 encoder online, picker modal if multiple
- api.ts: TTLockConfig/Update cleaned up, Workstation gets ttlockComPort, issueCard/testEncoder updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 23:40:29 +03:00

1243 lines
54 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 } 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<string, string> = {
fiscal: 'Фискальный чек',
kitchen: 'Принтер кухни',
bar: 'Принтер бара',
receipt: 'Чек / квитанция',
other: 'Другое',
}
const TYPE_LABELS: Record<string, string> = {
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 (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Онлайн
</span>
)
}
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 (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
{ago ? `Был ${ago}` : 'Нет связи'}
</span>
)
}
// ─── Pair Code Modal ──────────────────────────────────────────────────────────
function PairCodeModal({
workstation,
slug,
onClose,
}: {
workstation: Workstation
slug: string
onClose: () => void
}) {
const [code, setCode] = useState<string | null>(null)
const [expiresAt, setExpiresAt] = useState<string | null>(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 (
<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-md p-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Подключить агента
</h2>
<button onClick={onClose} className="btn-ghost p-1.5"><X size={16} /></button>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400 mb-6">
Рабочее место: <span className="font-semibold text-slate-900 dark:text-slate-100">{workstation.name}</span>
</p>
{paired ? (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<div className="w-14 h-14 rounded-full bg-emerald-100 dark:bg-emerald-900/30 flex items-center justify-center">
<Check size={28} className="text-emerald-600 dark:text-emerald-400" />
</div>
<p className="text-base font-semibold text-slate-900 dark:text-slate-100">Агент подключён!</p>
<p className="text-sm text-slate-500">Окно закроется автоматически...</p>
</div>
) : loading ? (
<div className="flex justify-center py-8">
<RefreshCw size={24} className="animate-spin text-brand-600" />
</div>
) : (
<>
<div className="bg-slate-50 dark:bg-slate-900 rounded-xl p-6 text-center mb-4 relative">
<p className="text-xs text-slate-500 mb-3 uppercase tracking-wide font-medium">Код подключения</p>
<span className="text-5xl font-mono font-bold tracking-[0.25em] text-slate-900 dark:text-slate-100 whitespace-nowrap">
{code}
</span>
<button onClick={copy} className="absolute top-3 right-3 btn-ghost p-2 text-slate-400" title="Скопировать">
{copied ? <Check size={15} className="text-emerald-500" /> : <Copy size={15} />}
</button>
</div>
<div className="flex items-center justify-center gap-2 text-sm mb-6">
<Clock size={14} className={cn('text-slate-400', timeLeft < 60 && 'text-red-500')} />
<span className={cn('font-mono font-medium', timeLeft < 60 ? 'text-red-500' : 'text-slate-600 dark:text-slate-400')}>
{mm}:{ss}
</span>
<span className="text-slate-500">до истечения</span>
</div>
<ol className="text-sm text-slate-600 dark:text-slate-400 space-y-2 list-decimal list-inside">
<li>Установите <strong>HotelSync Agent</strong> на компьютер</li>
<li>Запустите появится форма ввода кода</li>
<li>Введите код выше и нажмите «Подключить»</li>
</ol>
</>
)}
<button onClick={onClose} className="mt-6 w-full btn-secondary py-2.5 flex justify-center">
Закрыть
</button>
</div>
</div>
)
}
// ─── Device Form ──────────────────────────────────────────────────────────────
function DeviceForm({
initial,
onSave,
onCancel,
slug,
wsId,
wsOnline,
}: {
initial?: Partial<WorkstationDevice>
onSave: (data: Partial<WorkstationDevice>) => Promise<void>
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<string, unknown> = {}
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 (
<div className="border border-brand-200 dark:border-brand-800 rounded-xl p-4 bg-brand-50/30 dark:bg-brand-900/10 space-y-3">
<div className={cn('grid gap-3', type === 'netup' ? 'grid-cols-1' : 'grid-cols-2')}>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Тип устройства</label>
<select
value={type}
onChange={e => handleTypeChange(e.target.value as 'kkt' | 'printer' | 'netup')}
className="w-full input text-sm"
>
<option value="kkt">ККТ (касса)</option>
<option value="printer">Принтер</option>
<option value="netup">NetUp IPTV</option>
</select>
</div>
{type !== 'netup' && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Назначение</label>
<select
value={purpose}
onChange={e => setPurpose(e.target.value as 'fiscal' | 'kitchen' | 'bar' | 'receipt' | 'other')}
className="w-full input text-sm"
>
{Object.entries(PURPOSE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
)}
</div>
{/* KKT-specific: fiscal mode + DTO port */}
{type === 'kkt' && (
<div className="space-y-3 border border-amber-200 dark:border-amber-800 rounded-xl p-3 bg-amber-50/40 dark:bg-amber-900/10">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Режим кассы</label>
<div className="flex gap-2">
{([
{ id: true, label: 'Фискальный', hint: 'Чеки с ФН, ОФД' },
{ id: false, label: 'Нефискальный', hint: 'Только документы' },
] as const).map(m => (
<button
key={String(m.id)}
type="button"
onClick={() => setFiscalMode(m.id)}
className={cn(
'flex-1 py-2 px-3 rounded-lg border text-sm font-medium transition-colors text-left',
fiscalMode === m.id
? 'border-amber-500 bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300'
: 'border-slate-300 dark:border-slate-600 text-slate-500 hover:border-slate-400',
)}
>
<div>{m.label}</div>
<div className="text-xs opacity-60 font-normal">{m.hint}</div>
</button>
))}
</div>
</div>
{fiscalMode && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Порт АТОЛ ДТО</label>
<input
value={dtoPort}
onChange={e => setDtoPort(e.target.value)}
className="w-full input text-sm font-mono"
placeholder="16732"
/>
<p className="text-xs text-slate-400 mt-1">По умолчанию 16732</p>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">ID устройства в ДТО</label>
<input
value={dtoDeviceId}
onChange={e => setDtoDeviceId(e.target.value)}
className="w-full input text-sm font-mono"
placeholder="1"
/>
<p className="text-xs text-slate-400 mt-1">Из /api/v2/devices</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Логин ДТО</label>
<input
value={dtoUser}
onChange={e => setDtoUser(e.target.value)}
className="w-full input text-sm"
placeholder="30"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Пароль ДТО</label>
<input
type="password"
value={dtoPass}
onChange={e => setDtoPass(e.target.value)}
className="w-full input text-sm"
placeholder="30"
/>
</div>
</div>
</div>
)}
</div>
)}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Название</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full input text-sm"
placeholder={type === 'netup' ? 'NetUp сервер' : type === 'kkt' ? 'Касса №1' : 'Принтер кухни'}
/>
</div>
{type === 'printer' && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Подключение</label>
<div className="flex gap-2 flex-wrap">
{([
{ id: 'network', icon: <Network size={14} />, label: 'Сеть (TCP)' },
{ id: 'windows', icon: <Monitor size={14} />, label: 'Windows принтер' },
{ id: 'com', icon: <Cable size={14} />, label: 'COM-порт' },
] as const).map(c => (
<button
key={c.id}
onClick={() => setConnection(c.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
connection === c.id
? 'bg-brand-600 border-brand-600 text-white'
: 'border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:border-brand-400',
)}
>
{c.icon}{c.label}
</button>
))}
</div>
</div>
)}
{connection === 'network' && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="block text-xs font-medium text-slate-500 mb-1">IP адрес / хост</label>
<input
value={networkHost}
onChange={e => setNetworkHost(e.target.value)}
className="w-full input text-sm font-mono"
placeholder={type === 'netup' ? '172.16.0.12' : '192.168.1.50'}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Порт</label>
<input
value={networkPort}
onChange={e => setNetworkPort(e.target.value)}
className="w-full input text-sm font-mono"
placeholder={type === 'netup' ? '8880' : '9100'}
/>
</div>
</div>
{type === 'printer' && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Кодировка</label>
<select
value={encoding}
onChange={e => setEncoding(e.target.value as 'utf8' | 'cp866')}
className="w-full input text-sm"
>
<option value="utf8">UTF-8</option>
<option value="cp866">CP866 (DOS Кириллица)</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Ширина ленты (точек)</label>
<input
value={paperWidth}
onChange={e => setPaperWidth(e.target.value)}
className="w-full input text-sm font-mono"
placeholder="576"
/>
<p className="text-xs text-slate-400 mt-1">58мм=384, 80мм=576</p>
</div>
</div>
)}
</div>
)}
{connection === 'windows' && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1 flex items-center gap-2">
Принтер Windows
{printersLoading && <span className="text-slate-400 font-normal">загрузка...</span>}
{wsOnline && !printersLoading && (
<button type="button" onClick={loadPrinters} className="text-brand-500 hover:text-brand-700 font-normal">
<RefreshCw size={11} />
</button>
)}
</label>
{wsOnline && availablePrinters && availablePrinters.length > 0 ? (
<select
value={windowsPrinter}
onChange={e => setWindowsPrinter(e.target.value)}
className="w-full input text-sm"
>
<option value=""> выберите принтер </option>
{availablePrinters.map(p => (
<option key={p.name} value={p.name}>
{p.name}{p.isDefault ? ' (по умолчанию)' : ''}
</option>
))}
</select>
) : (
<input
value={windowsPrinter}
onChange={e => setWindowsPrinter(e.target.value)}
className="w-full input text-sm"
placeholder="Microsoft Print to PDF"
/>
)}
{wsOnline && availablePrinters !== null && availablePrinters.length === 0 && (
<p className="text-xs text-amber-500 mt-1">Принтеры не найдены. Убедитесь что принтер установлен в Windows.</p>
)}
{!wsOnline && (
<p className="text-xs text-slate-400 mt-1">Терминал офлайн введите имя принтера вручную</p>
)}
</div>
)}
{connection === 'com' && type !== 'kkt' && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1 flex items-center gap-2">
COM-порт
{portsLoading && <span className="text-slate-400 font-normal">загрузка...</span>}
{wsOnline && !portsLoading && (
<button type="button" onClick={loadPorts} className="text-brand-500 hover:text-brand-700 font-normal">
<RefreshCw size={11} />
</button>
)}
</label>
{wsOnline && availablePorts && availablePorts.length > 0 ? (
<select
value={comPort}
onChange={e => setComPort(e.target.value)}
className="w-full input text-sm font-mono"
>
<option value=""> выберите порт </option>
{availablePorts.map(p => (
<option key={p.port} value={p.port}>
{p.port}{p.description ? `${p.description}` : ''}
</option>
))}
</select>
) : (
<input
value={comPort}
onChange={e => setComPort(e.target.value)}
className="w-full input text-sm font-mono"
placeholder="COM1"
/>
)}
{wsOnline && availablePorts !== null && availablePorts.length === 0 && (
<p className="text-xs text-amber-500 mt-1">COM-порты не найдены нажмите для повтора</p>
)}
{!wsOnline && (
<p className="text-xs text-slate-400 mt-1">Терминал офлайн введите порт вручную (COM1, COM3)</p>
)}
</div>
)}
<div className="flex gap-2 pt-1">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="btn-primary py-1.5 px-4 text-sm"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</button>
<button onClick={onCancel} className="btn-secondary py-1.5 px-4 text-sm">
Отмена
</button>
</div>
</div>
)
}
// ─── 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<string | null>(null)
const [updating, setUpdating] = useState(false)
const [updateMsg, setUpdateMsg] = useState<string | null>(null)
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; msg: string } | null>>({})
const [testingDeviceId, setTestingDeviceId] = useState<string | null>(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<WorkstationDevice>) => {
await api.workstations.addDevice(slug, ws.id, data)
setShowAddDevice(false)
onRefresh()
}
const updateDevice = async (deviceId: string, data: Partial<WorkstationDevice>) => {
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 (
<>
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4">
<button onClick={() => setExpanded(v => !v)} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<div className="w-9 h-9 rounded-xl bg-slate-100 dark:bg-slate-700 flex items-center justify-center shrink-0">
<Monitor size={18} className="text-slate-600 dark:text-slate-300" />
</div>
<div className="flex-1 min-w-0">
{editing ? (
<div className="flex items-center gap-2">
<input
value={name}
onChange={e => 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
/>
<button onClick={saveName} className="btn-ghost p-1 text-emerald-600"><Check size={14} /></button>
<button onClick={() => { setEditing(false); setName(ws.name) }} className="btn-ghost p-1"><X size={14} /></button>
</div>
) : (
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900 dark:text-slate-100 truncate">{ws.name}</span>
<button onClick={() => setEditing(true)} className="btn-ghost p-1 opacity-0 group-hover:opacity-100 text-slate-400">
<Pencil size={12} />
</button>
</div>
)}
<div className="flex items-center gap-2 mt-0.5">
{ws.hostname && (
<p className="text-xs text-slate-500 dark:text-slate-400">{ws.hostname} {ws.ipAddress ? `· ${ws.ipAddress}` : ''}</p>
)}
{ws.agentVersion && (
<span className="text-xs text-slate-400 dark:text-slate-500 font-mono">v{ws.agentVersion}</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<OnlineBadge online={ws.isOnline} lastSeen={ws.lastSeen} />
{hasUpdate && (
<button
onClick={sendUpdate}
disabled={updating}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 transition-colors disabled:opacity-50"
title={`Обновить до v${latestRelease?.version}`}
>
<ArrowUpCircle size={12} />
{updating ? '...' : `Обновить до v${latestRelease?.version}`}
</button>
)}
{updateMsg && (
<span className="text-xs text-slate-500">{updateMsg}</span>
)}
{!ws.agentId ? (
<button
onClick={() => setShowPairModal(true)}
className="btn-primary py-1.5 px-3 text-xs flex items-center gap-1.5"
>
<Wifi size={13} />
Подключить агента
</button>
) : !ws.isOnline ? (
<button
onClick={() => setShowPairModal(true)}
className="btn-ghost p-1.5 text-slate-400"
title="Переподключить агента"
>
<RefreshCw size={14} />
</button>
) : null}
<button onClick={deleteWs} className="btn-ghost p-1.5 text-slate-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
</div>
{/* Devices */}
{expanded && (
<div className="border-t border-slate-100 dark:border-slate-700 px-5 py-4 space-y-2">
{devices.length === 0 && !showAddDevice && (
<p className="text-sm text-slate-400 dark:text-slate-500 text-center py-2">
Устройства не добавлены
</p>
)}
{devices.map(device => (
<div key={device.id}>
{editDeviceId === device.id ? (
<DeviceForm
initial={device}
onSave={data => updateDevice(device.id, data)}
onCancel={() => setEditDeviceId(null)}
slug={slug}
wsId={ws.id}
wsOnline={ws.isOnline}
/>
) : (
<div>
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-700/50 group">
<div className={cn(
'w-7 h-7 rounded-lg flex items-center justify-center shrink-0',
device.type === 'kkt' ? 'bg-violet-100 dark:bg-violet-900/40' : 'bg-blue-100 dark:bg-blue-900/40',
)}>
{device.type === 'kkt'
? <CreditCard size={13} className="text-violet-600 dark:text-violet-400" />
: <Printer size={13} className="text-blue-600 dark:text-blue-400" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{device.name}</p>
{device.type === 'kkt' && (
<span className={cn(
'text-xs px-1.5 py-0.5 rounded font-medium',
device.fiscalMode !== false
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400',
)}>
{device.fiscalMode !== false ? 'Фискальный' : 'Нефискальный'}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-500">{PURPOSE_LABELS[device.purpose]}</span>
<span className="text-slate-300 dark:text-slate-600">·</span>
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
{device.connection === 'usb' ? <Usb size={10} /> : device.connection === 'com' ? <Cable size={10} /> : <Network size={10} />}
{device.connection === 'usb'
? 'USB'
: device.connection === 'com'
? (device.comPort ?? 'COM?')
: device.networkHost
? `${device.networkHost}:${device.networkPort}`
: 'IP не задан'}
</span>
{device.type === 'kkt' && device.fiscalMode !== false && device.dtoPort && device.dtoPort !== 16732 && (
<span className="text-xs text-slate-400 font-mono">DTO:{device.dtoPort}</span>
)}
{device.type === 'kkt' && device.fiscalMode !== false && device.config?.dto_device_id && (
<span className="text-xs text-slate-400 font-mono">#{device.config.dto_device_id}</span>
)}
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{ws.isOnline && (
<button
onClick={() => testDevice(device.id)}
disabled={testingDeviceId === device.id}
className="btn-ghost p-1.5 text-slate-400 hover:text-violet-500"
title="Тест"
>
{testingDeviceId === device.id
? <RefreshCw size={12} className="animate-spin" />
: <Zap size={12} />
}
</button>
)}
<button onClick={() => setEditDeviceId(device.id)} className="btn-ghost p-1.5 text-slate-400">
<Pencil size={12} />
</button>
<button onClick={() => deleteDevice(device.id)} className="btn-ghost p-1.5 text-slate-400 hover:text-red-500">
<Trash2 size={12} />
</button>
</div>
</div>
{testResults[device.id] !== undefined && testResults[device.id] !== null && (() => {
const res = testResults[device.id]!
const isDtoError = !res.ok && res.msg.includes('ДТО')
return (
<div className={cn(
'mt-1 px-3 py-2 rounded-lg text-xs font-medium flex flex-col gap-1',
res.ok
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400'
: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400',
)}>
<span className="flex items-center gap-1.5">
{res.ok ? <Check size={11} /> : <X size={11} />}
{res.msg || (res.ok ? 'Успешно' : 'Ошибка')}
</span>
{isDtoError && (
<div className="mt-0.5 pl-4 space-y-0.5 font-normal text-red-600 dark:text-red-400">
<p>Для работы кассы нужно установить и запустить <strong>АТОЛ ДТО 10</strong>:</p>
<ol className="list-decimal list-inside space-y-0.5 pl-1">
<li>Скачайте бесплатно с <a href="https://atol.ru" target="_blank" rel="noreferrer" className="underline">atol.ru</a> Поддержка ДТО 10</li>
<li>Установите и запустите службу <strong>ATOL DTO Service</strong></li>
<li>Порт по умолчанию: <strong>16732</strong></li>
</ol>
</div>
)}
</div>
)
})()}
</div>
)}
</div>
))}
{showAddDevice ? (
<DeviceForm
onSave={addDevice}
onCancel={() => setShowAddDevice(false)}
slug={slug}
wsId={ws.id}
wsOnline={ws.isOnline}
/>
) : (
<button
onClick={() => setShowAddDevice(true)}
className="w-full flex items-center justify-center gap-1.5 py-2 text-sm text-brand-600 hover:text-brand-700 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-xl transition-colors"
>
<Plus size={14} />
Добавить устройство
</button>
)}
{/* Ping tool — only when agent is online */}
{ws.isOnline && (
<div className="pt-2 border-t border-slate-100 dark:border-slate-700">
<button
onClick={() => { setShowPing(v => !v); setPingResult(null) }}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
>
<Network size={12} />
Пинг через агента
{showPing ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
</button>
{showPing && (
<div className="mt-2">
<div className="flex gap-2">
<input
value={pingHost}
onChange={e => 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
/>
<button
onClick={pingNow}
disabled={pinging || !pingHost.trim()}
className="btn-secondary py-1.5 px-3 text-sm flex items-center gap-1.5 shrink-0 h-9 whitespace-nowrap"
>
{pinging ? <RefreshCw size={13} className="animate-spin" /> : <Network size={13} />}
{pinging ? 'Пинг...' : 'Пинг'}
</button>
</div>
{pingResult && (
<div className={cn(
'mt-2 rounded-lg text-xs',
pingResult.ok
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400',
)}>
{pingResult.ok && pingResult.output ? (
<pre className="p-3 whitespace-pre-wrap font-mono text-xs overflow-x-auto">{pingResult.output.trim()}</pre>
) : (
<p className="p-3 flex items-center gap-1.5"><X size={11} />{pingResult.error ?? 'Ошибка'}</p>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
{showPairModal && (
<PairCodeModal
workstation={ws}
slug={slug}
onClose={() => { setShowPairModal(false); onRefresh() }}
/>
)}
</>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function EquipmentPage() {
const { user, session } = useAuth()
const slug = user?.hotelSlug ?? ''
const [workstations, setWorkstations] = useState<Workstation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="p-6 max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Оборудование</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Рабочие места, кассы и принтеры
</p>
</div>
<div className="flex items-center gap-2">
{anyOutdated && (
<button
onClick={handleUpdateAll}
disabled={updatingAll}
className="inline-flex items-center gap-1.5 px-3 py-2 h-9 rounded-xl text-sm font-medium whitespace-nowrap bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 transition-colors disabled:opacity-50"
>
<ArrowUpCircle size={15} />
{updatingAll ? 'Отправка...' : 'Обновить все'}
</button>
)}
{latestRelease ? (
<a
href={latestRelease.downloadUrl}
download
className="inline-flex items-center gap-1.5 px-3 py-2 h-9 rounded-xl text-sm font-medium whitespace-nowrap bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600 transition-colors"
title={`Скачать агент v${latestRelease.version}`}
>
<Download size={15} />
Скачать агент v{latestRelease.version}
</a>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-2 h-9 rounded-xl text-sm font-medium whitespace-nowrap bg-slate-100 text-slate-400 dark:bg-slate-700 dark:text-slate-500 cursor-not-allowed">
<Download size={15} />
Агент недоступен
</span>
)}
<button
onClick={() => setShowAdd(true)}
className="btn-primary inline-flex items-center gap-2 py-2 px-4 h-9 whitespace-nowrap"
>
<Plus size={16} />
Новое рабочее место
</button>
</div>
</div>
{/* Stats */}
{total > 0 && (
<div className="grid grid-cols-3 gap-4">
{[
{ 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 => (
<div key={s.label} className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center gap-3">
<s.icon size={20} className={s.color} />
<div>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{s.value}</p>
<p className="text-xs text-slate-500">{s.label}</p>
</div>
</div>
))}
</div>
)}
{/* Add form */}
{showAdd && (
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-5">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-3">Новое рабочее место</h3>
<div className="flex gap-3">
<input
value={addingName}
onChange={e => 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
/>
<button onClick={handleAdd} disabled={adding || !addingName.trim()} className="btn-primary px-5">
{adding ? 'Создание...' : 'Создать'}
</button>
<button onClick={() => setShowAdd(false)} className="btn-secondary px-4">Отмена</button>
</div>
</div>
)}
{/* Content */}
{loading ? (
<div className="space-y-4">
{[1,2].map(i => (
<div key={i} className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 h-24 animate-pulse" />
))}
</div>
) : error ? (
<div className="flex items-center gap-3 p-4 bg-red-50 dark:bg-red-900/20 rounded-xl text-red-600 dark:text-red-400">
<AlertCircle size={16} />
<span className="text-sm">{error}</span>
</div>
) : workstations.length === 0 ? (
<div className="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-12 text-center">
<Monitor size={40} className="mx-auto text-slate-300 dark:text-slate-600 mb-3" />
<p className="font-medium text-slate-600 dark:text-slate-400 mb-1">Рабочих мест пока нет</p>
<p className="text-sm text-slate-400 dark:text-slate-500">
Создайте рабочее место и подключите к нему агент Windows
</p>
</div>
) : (
<div className="space-y-4 group">
{workstations.map(ws => (
<WorkstationCard
key={ws.id}
ws={ws}
slug={slug}
latestRelease={latestRelease}
onRefresh={load}
/>
))}
</div>
)}
{/* Info block */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-xl p-4 flex gap-3">
<AlertCircle size={16} className="text-blue-500 shrink-0 mt-0.5" />
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">Как подключить агента</p>
<ol className="space-y-0.5 text-blue-600 dark:text-blue-400 list-decimal list-inside">
<li>Создайте рабочее место и нажмите «Подключить агента»</li>
<li>Получите 6-значный код (действует 10 минут)</li>
<li>Установите HotelSync Agent на компьютер и введите код</li>
<li>Добавьте кассы и принтеры к рабочему месту</li>
</ol>
</div>
</div>
</div>
)
}