diff --git a/src/App.tsx b/src/App.tsx index 071e440..b9eaa80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -41,6 +41,7 @@ import { TvWelcomePage } from './pages/TvWelcomePage' import { TechnicalPage } from './pages/TechnicalPage' import { SchedulePage } from './pages/SchedulePage' import { BillingPage } from './pages/BillingPage' +import { EquipmentPage } from './pages/EquipmentPage' import { ModuleGuard } from './components/ModuleGuard' export default function App() { @@ -92,6 +93,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index c9b750d..9075d67 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react' import { CalendarDays, BookOpen, BedDouble, Globe, Settings, FileText, LayoutDashboard, Building2, Users, X, Hotel, Puzzle, CalendarRange, Map, LayoutGrid, UserCog, UsersRound, - TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, + TrendingUp, Tag, Award, Wrench, Utensils, CalendarClock, Zap, ChevronRight, Star, CreditCard, Monitor, } from 'lucide-react' import { useAuth } from '../../contexts/AuthContext' import { useModules } from '../../contexts/ModulesContext' @@ -185,7 +185,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { prices: ['/tariffs', '/dynamic-pricing', '/discounts', '/rental'], service: ['/housekeeping', '/technical', ...activeModuleItems.map(m => m.sidebarItem!.path)], management: ['/users', '/schedule', '/loyalty', '/maintenance', '/floor-map', '/channels'], - settingsGroup:['/modules', '/settings', '/billing'], + settingsGroup:['/modules', '/settings', '/billing', '/equipment'], devGroup: ['/api-docs'], } @@ -232,6 +232,7 @@ export function Sidebar({ open, onClose }: SidebarProps) { // settings { to: '/modules', icon: Puzzle, label: 'Модули' }, { to: '/settings', icon: Settings, label: 'Настройки' }, + { to: '/equipment', icon: Monitor, label: 'Оборудование' }, // dev { to: '/api-docs', icon: FileText, label: 'API & Документация' }, ] @@ -410,9 +411,10 @@ export function Sidebar({ open, onClose }: SidebarProps) { toggleGroup('settingsGroup')} /> {groups.settingsGroup && (
- - - + + + +
)} diff --git a/src/lib/api.ts b/src/lib/api.ts index 4783d06..ad79662 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -315,6 +315,28 @@ export const api = { req<{ room_id: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`), }, + // ── Workstations ────────────────────────────────────────────────────────── + workstations: { + list: (slug: string) => + req('GET', `/api/hotels/${slug}/workstations`), + create: (slug: string, name: string) => + req('POST', `/api/hotels/${slug}/workstations`, { name }), + update: (slug: string, id: string, name: string) => + req('PATCH', `/api/hotels/${slug}/workstations/${id}`, { name }), + remove: (slug: string, id: string) => + req('DELETE', `/api/hotels/${slug}/workstations/${id}`), + pairCode: (slug: string, id: string) => + req<{ code: string; expires_at: string }>('POST', `/api/hotels/${slug}/workstations/${id}/pair-code`), + listDevices: (slug: string, id: string) => + req('GET', `/api/hotels/${slug}/workstations/${id}/devices`), + addDevice: (slug: string, id: string, data: Partial) => + req('POST', `/api/hotels/${slug}/workstations/${id}/devices`, data), + updateDevice: (slug: string, id: string, deviceId: string, data: Partial) => + req('PATCH', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`, data), + removeDevice: (slug: string, id: string, deviceId: string) => + req('DELETE', `/api/hotels/${slug}/workstations/${id}/devices/${deviceId}`), + }, + // ── Hotels ──────────────────────────────────────────────────────────────── hotels: { get: (slug: string) => @@ -945,6 +967,31 @@ export interface ChatMessage { createdAt: string } +export interface WorkstationDevice { + id: string + workstationId: string + type: 'kkt' | 'printer' + name: string + connection: 'usb' | 'network' + networkHost?: string + networkPort?: number + purpose: 'fiscal' | 'kitchen' | 'bar' | 'receipt' | 'other' + config: Record +} + +export interface Workstation { + id: string + hotelId: string + agentId: string | null + name: string + hostname: string | null + ipAddress: string | null + isOnline: boolean + lastSeen: string | null + createdAt: string + devices: WorkstationDevice[] | null +} + function toHotelPayload(h: HotelPayload): Record { const out: Record = {} if (h.name !== undefined) out.name = h.name diff --git a/src/pages/EquipmentPage.tsx b/src/pages/EquipmentPage.tsx new file mode 100644 index 0000000..4c8a1f4 --- /dev/null +++ b/src/pages/EquipmentPage.tsx @@ -0,0 +1,630 @@ +import { useState, useEffect, useCallback } from 'react' +import { + Monitor, Plus, Trash2, Pencil, Check, X, RefreshCw, + Wifi, WifiOff, Printer, CreditCard, Usb, Network, + ChevronDown, ChevronRight, Copy, Clock, AlertCircle, +} from 'lucide-react' +import { cn } from '../lib/utils' +import { api, type Workstation, type WorkstationDevice } from '../lib/api' +import { useAuth } from '../contexts/AuthContext' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const PURPOSE_LABELS: Record = { + fiscal: 'Фискальный чек', + kitchen: 'Принтер кухни', + bar: 'Принтер бара', + receipt: 'Чек / квитанция', + other: 'Другое', +} + +const TYPE_LABELS: Record = { + kkt: 'ККТ (касса)', + printer: 'Принтер', +} + +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) + + useEffect(() => { + api.workstations.pairCode(slug, workstation.id).then(r => { + setCode(r.code) + setExpiresAt(r.expires_at) + setLoading(false) + }) + }, [slug, workstation.id]) + + 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) + }, 1000) + return () => clearInterval(tick) + }, [expiresAt]) + + 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} +

+ + {loading ? ( +
+ +
+ ) : ( + <> +
+

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

+
+ + {code?.slice(0, 3)} {code?.slice(3)} + + +
+
+ +
+ + + {mm}:{ss} + + до истечения +
+ +
    +
  1. Установите HotelSync Agent на компьютер
  2. +
  3. Запустите — появится форма ввода кода
  4. +
  5. Введите код выше и нажмите «Подключить»
  6. +
+ + )} + + +
+
+ ) +} + +// ─── Device Form ────────────────────────────────────────────────────────────── + +function DeviceForm({ + initial, + onSave, + onCancel, +}: { + initial?: Partial + onSave: (data: Partial) => Promise + onCancel: () => void +}) { + const [type, setType] = useState<'kkt' | 'printer'>(initial?.type ?? 'printer') + const [name, setName] = useState(initial?.name ?? '') + const [connection, setConnection] = useState<'usb' | 'network'>(initial?.connection ?? 'network') + const [networkHost, setNetworkHost] = useState(initial?.networkHost ?? '') + const [networkPort, setNetworkPort] = useState(String(initial?.networkPort ?? 9100)) + const [purpose, setPurpose] = useState(initial?.purpose ?? 'receipt') + const [saving, setSaving] = useState(false) + + const handleSave = async () => { + if (!name.trim()) return + setSaving(true) + await onSave({ + type, + name: name.trim(), + connection, + networkHost: connection === 'network' ? networkHost.trim() : undefined, + networkPort: connection === 'network' ? Number(networkPort) : undefined, + purpose: purpose as WorkstationDevice['purpose'], + }) + setSaving(false) + } + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+ + setName(e.target.value)} + className="w-full input-field text-sm" + placeholder="Касса №1 / Принтер кухни" + /> +
+ +
+ +
+ {(['usb', 'network'] as const).map(c => ( + + ))} +
+
+ + {connection === 'network' && ( +
+
+ + setNetworkHost(e.target.value)} + className="w-full input-field text-sm font-mono" + placeholder="192.168.1.50" + /> +
+
+ + setNetworkPort(e.target.value)} + className="w-full input-field text-sm font-mono" + placeholder="9100" + /> +
+
+ )} + +
+ + +
+
+ ) +} + +// ─── Workstation Card ───────────────────────────────────────────────────────── + +function WorkstationCard({ + ws, + slug, + onRefresh, +}: { + ws: Workstation + slug: string + onRefresh: () => void +}) { + const [expanded, setExpanded] = useState(true) + const [editing, setEditing] = useState(false) + const [name, setName] = useState(ws.name) + const [showPairModal, setShowPairModal] = useState(false) + const [showAddDevice, setShowAddDevice] = useState(false) + const [editDeviceId, setEditDeviceId] = useState(null) + + const saveName = async () => { + if (!name.trim() || name === ws.name) { setEditing(false); return } + await api.workstations.update(slug, ws.id, 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 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-field py-1 text-sm w-48" + autoFocus + /> + + +
+ ) : ( +
+ {ws.name} + +
+ )} + {ws.hostname && ( +

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

+ )} +
+ +
+ + + {!ws.agentId ? ( + + ) : ( + + )} + + +
+
+ + {/* Devices */} + {expanded && ( +
+ {devices.length === 0 && !showAddDevice && ( +

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

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

{device.name}

+
+ {PURPOSE_LABELS[device.purpose]} + · + + {device.connection === 'usb' ? : } + {device.connection === 'usb' ? 'USB' : `${device.networkHost}:${device.networkPort}`} + +
+
+ +
+ + +
+
+ )} +
+ ))} + + {showAddDevice ? ( + setShowAddDevice(false)} + /> + ) : ( + + )} +
+ )} +
+ + {showPairModal && ( + { setShowPairModal(false); onRefresh() }} + /> + )} + + ) +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export function EquipmentPage() { + const { user } = 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 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]) + + const handleAdd = async () => { + if (!addingName.trim()) return + setAdding(true) + await api.workstations.create(slug, addingName.trim()) + setAddingName('') + setShowAdd(false) + setAdding(false) + load() + } + + const online = workstations.filter(w => w.isOnline).length + const total = workstations.length + const noPairs = workstations.filter(w => !w.agentId).length + + return ( +
+ {/* Header */} +
+
+

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

+

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

+
+ +
+ + {/* 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-field" + placeholder="Ресепшн 1 / Бар / Касса SPA" + autoFocus + /> + + +
+
+ )} + + {/* Content */} + {loading ? ( +
+ {[1,2].map(i => ( +
+ ))} +
+ ) : error ? ( +
+ + {error} +
+ ) : workstations.length === 0 ? ( +
+ +

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

+

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

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

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

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