// Main app — wires everything: desktop + mobile + chat + launch flow const { useState: useStateA, useEffect: useEffectA, useRef: useRefA } = React; const TWEAK_DEFAULS = /*EDITMODE-BEGIN*/{ "theme": "light", "accent": "blue", "role": "manager", "surface": "desktop" }/*EDITMODE-END*/; // Палитра «индустриально-пластиковая»: электрический синий-base + hazard-orange CTA. const ACCENT_MAP = { blue: { '--accent':'#2563eb', '--accent-hover':'#1d4ed8', '--accent-soft':'#bfdbfe' }, amber: { '--accent':'#ea580c', '--accent-hover':'#c2410c', '--accent-soft':'#fed7aa' }, lime: { '--accent':'#10b981', '--accent-hover':'#059669', '--accent-soft':'#a7f3d0' }, violet: { '--accent':'#7c3aed', '--accent-hover':'#6d28d9', '--accent-soft':'#ede9fe' }, }; const ACCENT_MAP_DARK = { blue: { '--accent':'#60a5fa', '--accent-hover':'#93c5fd', '--accent-soft':'#1e3a8a' }, amber: { '--accent':'#fb923c', '--accent-hover':'#fdba74', '--accent-soft':'#9a3412' }, lime: { '--accent':'#34d399', '--accent-hover':'#6ee7b7', '--accent-soft':'#065f46' }, violet: { '--accent':'#a78bfa', '--accent-hover':'#c4b5fd', '--accent-soft':'#4c1d95' }, }; function useTweaks() { const [tweaks, setTweaks] = useStateA(() => { try { const saved = localStorage.getItem('autosvet_tweaks'); if (saved) return { ...TWEAK_DEFAULS, ...JSON.parse(saved) }; } catch {} return TWEAK_DEFAULS; }); useEffectA(() => { localStorage.setItem('autosvet_tweaks', JSON.stringify(tweaks)); document.documentElement.setAttribute('data-theme', tweaks.theme); const accentVars = tweaks.theme === 'dark' ? ACCENT_MAP_DARK[tweaks.accent] : ACCENT_MAP[tweaks.accent]; for (const [k, v] of Object.entries(accentVars || {})) { document.documentElement.style.setProperty(k, v); } }, [tweaks]); const update = (patch) => { setTweaks(t => ({ ...t, ...patch })); try { window.parent.postMessage({ type: '__edit_mode_set_keys', edits: patch }, '*'); } catch {} }; return [tweaks, update]; } // Profile-меню в шапке. Отображает текущего залогиненного юзера + кнопку // «Выйти». Имя/роль приходят из window.AUTH.user (после /auth/login или // /auth/me на старте). function ProfileMenu({ currentUser, onLogout }) { const [open, setOpen] = useStateA(false); const [showLog, setShowLog] = useStateA(false); const ref = useRefA(null); const roles = Array.isArray(window.ROLES_HIERARCHY) ? window.ROLES_HIERARCHY : []; // Уведомление о новой версии (PWA). HTML-регистрация SW шлёт событие // 'app:update-available', когда новый Service Worker установился и ЖДЁТ. // Тогда: зелёная точка-бейдж на иконке аккаунта + кнопка «Обновить» в // выпадашке версии. Клик → window.__applyUpdate() (skipWaiting + reload, // данные на сервере не теряются). (2026-06-18, по запросу Александра) const [updateReady, setUpdateReady] = useStateA(!!window.__swUpdateReady); const [newVer, setNewVer] = useStateA(window.__swNewVersion || null); useEffectA(() => { const h = (e) => { setUpdateReady(true); if (e && e.detail && e.detail.version) setNewVer(e.detail.version); }; window.addEventListener('app:update-available', h); if (window.__swUpdateReady) setUpdateReady(true); return () => window.removeEventListener('app:update-available', h); }, []); const applyUpdate = () => { if (typeof window.__applyUpdate === 'function') window.__applyUpdate(); }; useEffectA(() => { if (!open) return; const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, [open]); if (!currentUser) return null; const myRole = roles.find(r => r.id === currentUser.role); return (
{updateReady && !open && ( )} {open && (
{currentUser.initials}
{currentUser.name}
{myRole?.name || currentUser.role}
{currentUser.email && (
{currentUser.email}
)}
{window.PWAInstallMenuItem && ( setOpen(false)}/> )}
setShowLog(true)} onMouseLeave={() => setShowLog(false)}> {updateReady && } v{window.APP_VERSION || '—'} {showLog && (updateReady || (window.APP_CHANGELOG && window.APP_CHANGELOG[window.APP_VERSION])) && (
{updateReady && (
🆕 Доступно обновление{newVer ? ' · v' + newVer : ''}
Загрузит свежую версию. Данные на сервере сохранятся.
)} {window.APP_CHANGELOG && window.APP_CHANGELOG[window.APP_VERSION] && (
Что нового · v{window.APP_VERSION}
{window.APP_CHANGELOG[window.APP_VERSION].map((c, i) => (
{c}
))}
)}
)}
)}
); } // Полноэкранная login-модалка. Показывается когда AUTH.user === null. // Никаких других экранов в это время не рендерим — login обязателен. function LoginGate({ onLogin }) { // Обычная форма входа: логин (email) + пароль + «Войти». // Пароль вводится руками и НЕ хранится в коде. Единственный вход в систему // (browser basic-auth Caddy снят — см. deploy/Caddyfile). const [email, setEmail] = useStateA(() => { try { return localStorage.getItem('autolight.lastEmail') || ''; } catch (_) { return ''; } }); const [pwd, setPwd] = useStateA(''); const [busy, setBusy] = useStateA(false); const [err, setErr] = useStateA(''); const submit = async (e) => { if (e && e.preventDefault) e.preventDefault(); if (busy) return; const em = (email || '').trim(); if (!em || !pwd) { setErr('Введите логин и пароль'); return; } setBusy(true); setErr(''); try { const user = await window.AUTH.login(em, pwd); try { localStorage.setItem('autolight.lastEmail', em); } catch (_) {} if (onLogin) onLogin(user); } catch (e2) { const m = String(e2 && e2.message || e2); setErr(m === 'invalid_credentials' ? 'Неверный логин или пароль' : 'Не удалось войти: ' + m); setBusy(false); } }; const inputStyle = { width:'100%', boxSizing:'border-box', padding:'10px 12px', borderRadius:10, border:'1px solid var(--border)', background:'var(--bg-sunk)', color:'var(--fg)', font:'inherit', fontSize:14, }; const labelStyle = { display:'flex', flexDirection:'column', gap:4, fontSize:12, color:'var(--fg-muted)' }; return (
Автосвет · WMS
Вход в систему
{err && (
{err}
)}
{window.PWAInstallButton && (
Установить как приложение — откроется в отдельном окне на весь экран
)}
); } function TopNav({ view, setView, onOpenTweaks, currentUser, onLogout }) { const viewMap = { manager: 'Главная', warehouse: 'Склад', cech: 'Цех', marketplace: 'Marketplace', suppliers: 'Поставщики', procurement: 'Снабжение', storeman: 'Кладовщик', assembler: 'Сборщик', scanner: 'Сканер', calculator: 'Калькулятор', settings: 'Настройки', }; return (
Автосвет · WMS
{Object.entries(viewMap).map(([k, label]) => ( ))}
{window.ProcessHistoryButton && } {window.PWAInstallButton && } {window.SyncIndicator && }
); } function TweaksPanel({ tweaks, update, open, onClose }) { if (!open) return null; return (
Tweaks
Тема
Акцент
{['blue','amber','lime','violet'].map(c => (
Изменения сохраняются в localStorage
); } // Один рабочий iPhone-фрейм с tab-bar'ом, который реально переключает экраны. // Каждый экземпляр держит собственный tab и chatOpen. function MobilePhone({ caption, initialTab = 'home', user }) { const [tab, setTab] = useStateA(initialTab); const [chatOpen, setChatOpen] = useStateA(false); const scrollRef = useRefA(null); const openChat = () => setChatOpen(true); const currentUser = user || EMPLOYEES.find(e => e.id === 'u7') || EMPLOYEES[0]; // Reset scroll to top on tab switch so the screen doesn't "jump" mid-page. useEffectA(() => { if (scrollRef.current) scrollRef.current.scrollTop = 0; }, [tab]); const screen = ( <> {tab === 'home' && } {tab === 'scan' && } {tab === 'calc' && } {tab === 'jobs' && setTab('assembler')}/>} {tab === 'assembler' && } ); return (
{caption}
{chatOpen ? ( setChatOpen(false)}/> ) : ( } fab={} > {screen} )}
); } // Mobile shell — 3 iPhone-фрейма, каждый с рабочим tab-bar'ом и своим дефолтным экраном. // Hash-параметр #solo=storeman|assembler|procurement переключает на одиночный телефон // для phone-single.html (открывается в Chrome app-mode 460×940 как «реальный экран»). const MOBILE_SOLO_PRESETS = { storeman: { caption: 'Кладовщик · Громов В.', initialTab: 'home', userId: 'u7' }, assembler: { caption: 'Сборщик · Кузьмин В.', initialTab: 'assembler', userId: 'u9' }, procurement: { caption: 'Снабженец · Орлов Р.', initialTab: 'calc', userId: 'u5' }, }; function MobileShell() { const hashParams = new URLSearchParams(location.hash.replace(/^#/, '')); const solo = hashParams.get('solo'); if (solo && MOBILE_SOLO_PRESETS[solo]) { const cfg = MOBILE_SOLO_PRESETS[solo]; return (
e.id === cfg.userId)}/>
); } return (
e.id === 'u7')}/> e.id === 'u9')}/> e.id === 'u5')}/>
); } function App() { injectStyles(); const [tweaks, updateTweaks] = useTweaks(); const [view, setView] = useStateA(() => localStorage.getItem('autosvet_view') || 'manager'); const [tweaksOpen, setTweaksOpen] = useStateA(false); const [warehouseStep, setWarehouseStep] = useStateA('groups'); // groups | items const [warehouseGroup, setWarehouseGroup] = useStateA(null); // C1 (2026-08-04): намерение, с которым открыли подгруппу. Сейчас одно — // 'add-position': кнопка «Новая позиция» в шапке склада спрашивает подгруппу // и сразу открывает форму создания внутри неё. Раньше эта кнопка уводила в // Настройки, из-за чего заказчик «ходил два раза». const [warehouseGroupIntent, setWarehouseGroupIntent] = useStateA(null); const [warehouseScrollTo, setWarehouseScrollTo] = useStateA(null); // id склада (1|2|3) для скролла const [openPart, setOpenPart] = useStateA(null); const [launchDraft, setLaunchDraft] = useStateA(null); const [chatOpen, setChatOpen] = useStateA(false); const [chatUnread, setChatUnread] = useStateA(0); // Phase 3 — текущий «я» приходит из AUTH (бэкенд /auth/me + /auth/login). // authUser хранится в window.AUTH.user; локальный state на компонент // — для перерендеринга при auth-changed event. // DEV-BYPASS: для просмотра без backend (когда docker compose не поднят) // — добавь ?demo=1 в URL или localStorage.setItem('autolight.demoBypass','1'). // Использовать ТОЛЬКО для локальной разработки на localhost. const [authUser, setAuthUser] = useStateA(() => { const cur = (window.AUTH && window.AUTH.user) || null; if (cur) return cur; try { const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; const params = new URLSearchParams(window.location.search); const demo = params.has('demo') || localStorage.getItem('autolight.demoBypass') === '1'; if (isLocal && demo) { const mock = { id: 'u-aleksandr', name: 'Александр (demo)', email: 'demo@local', role: 'OWNER' }; if (window.AUTH) window.AUTH.user = mock; return mock; } } catch (_) {} return null; }); useEffectA(() => { const onChange = () => setAuthUser((window.AUTH && window.AUTH.user) || null); window.addEventListener('auth-changed', onChange); return () => window.removeEventListener('auth-changed', onChange); }, []); // На монтировании: восстанавливаем сессию. Если есть accessToken — /auth/me. // Если токена НЕТ (после reload не загрузился / истёк), но юзер ВХОДИЛ // (AUTH.user из localStorage) — пробуем refresh по COOKIE, потом пере-резолв // роли. Без этого после reload роль залипала на viewer (кнопки серые), т.к. // все пути восстановления гейтились на accessToken. (фикс 2026-06-16) useEffectA(() => { if (!window.AUTH) return; if (window.AUTH.accessToken) { window.AUTH.me().catch(() => {}); } else if (window.AUTH.user && typeof window.AUTH._refresh === 'function') { window.AUTH._refresh().then(ok => { if (ok && window.API && window.API.currentUser) window.API.currentUser(true).catch(() => {}); }).catch(() => {}); } }, []); // Возврат во вкладку/окно после простоя (таймеры heartbeat могли встать при // сне ноутбука) — пере-резолвим роль, чтобы права не «слетали» до следующей // записи. Дёшево: currentUser кэшируется 60с, форсируем только по фокусу. useEffectA(() => { const refreshRole = () => { if (document.visibilityState === 'hidden') return; if (window.API && typeof window.API.currentUser === 'function') { window.API.currentUser(true).catch(() => {}); } }; window.addEventListener('focus', refreshRole); document.addEventListener('visibilitychange', refreshRole); return () => { window.removeEventListener('focus', refreshRole); document.removeEventListener('visibilitychange', refreshRole); }; }, []); // chatMe — это сотрудник из EMPLOYEES (там роль + initials), мапинг по // user.id (он же EMPLOYEES.id благодаря seed-chat.ts). На случай если в // EMPLOYEES нет матча — фолбэк synthetic entry из auth-данных. const chatMe = (() => { if (!authUser) return null; const fromList = Array.isArray(EMPLOYEES) ? EMPLOYEES.find(e => e.id === authUser.id) : null; if (fromList) return fromList; return { id: authUser.id, name: authUser.name || authUser.email, email: authUser.email || '', initials: (authUser.name || '??').slice(0,2).toUpperCase(), role: authUser.role === 'KEEPER' ? 'storeman' : authUser.role === 'DIRECTOR' ? 'superuser' : 'viewer', active: true, }; })(); // Live unread badge для FAB. Инкрементируется на любой входящий // chat-message пока окно чата закрыто; сброс — при открытии. useEffectA(() => { if (chatOpen) { setChatUnread(0); return; } const onMsg = (e) => { const m = e.detail; if (!m || !chatMe || m.fromUserId === chatMe.id) return; setChatUnread(n => n + 1); }; window.addEventListener('chat-message', onMsg); return () => window.removeEventListener('chat-message', onMsg); }, [chatOpen, chatMe]); // Deep-link параметры для страницы «Поставщики»: какой поставщик и сделка // должны быть открыты по клику (из блока «Требуется закуп» на дашборде). const [suppliersCtx, setSuppliersCtx] = useStateA(null); // Deep-link для «Настройки»: какая вкладка открывается первой // (например, «Новая позиция» со склада → вкладка «Группы номенклатуры»). const [settingsTab, setSettingsTab] = useStateA('company'); useEffectA(() => { localStorage.setItem('autosvet_view', view); }, [view]); // Header pin — глобальная галочка скрывает шапку (топнав). По умолчанию // выключена (шапка видна). При активации body получает класс // header-hidden — CSS-правила в styles.jsx коллапсируют топнав до 0 // высоты, контент под ним занимает освободившееся место. Persist в // localStorage между сессиями. const [headerHidden, setHeaderHidden] = useStateA(() => { try { return localStorage.getItem('autosvet_header_hidden') === '1'; } catch { return false; } }); useEffectA(() => { try { localStorage.setItem('autosvet_header_hidden', headerHidden ? '1' : '0'); } catch {} document.body.classList.toggle('header-hidden', headerHidden); return () => document.body.classList.remove('header-hidden'); }, [headerHidden]); // Warehouse edit-mode — режим редактирования инфраструктуры склада прямо // на странице «Склад» (без захода в Настройки → Группы номенклатуры). // Включается карандашиком в правом-верхнем углу; виден только для роли // owner/admin (см. CURRENT_ROLE / window.CURRENT_USER.role). В режиме // редактирования у элементов склада появляются drag-handle, кнопки // «+ / ✎ / 🗑»; за пределами режима UI выглядит как обычно. // TODO: когда backend для подподгрупп закроется, заменить // window.SUBSUBGROUP_OVERRIDES + localStorage('warehouse_order_*') на // window.API.subsubgroup.* + window.API.taxonomy.reorder(). const [warehouseEditMode, setWarehouseEditMode] = useStateA(false); useEffectA(() => { document.body.classList.toggle('warehouse-editing', warehouseEditMode); window.WAREHOUSE_EDIT_MODE = warehouseEditMode; try { window.dispatchEvent(new CustomEvent('warehouse-edit-mode-changed', { detail: { active: warehouseEditMode } })); } catch (_) {} return () => document.body.classList.remove('warehouse-editing'); }, [warehouseEditMode]); // Если ушли со страницы склада — гасим edit-режим, чтобы не остаться // в нём незаметно на других экранах. useEffectA(() => { if (view !== 'warehouse' && warehouseEditMode) setWarehouseEditMode(false); }, [view, warehouseEditMode]); // Demo-режим до бэкенда auth/owner: если CURRENT_USER ещё не приехал // (или роль низкая), оверрайдим до 'owner', чтобы карандашик был виден. // TODO: убрать после P3 — когда whoami/CanWrite будут давать реальную роль. useEffectA(() => { if (typeof window.CURRENT_ROLE === 'undefined') { window.CURRENT_ROLE = (window.CURRENT_USER && window.CURRENT_USER.role) || 'owner'; } }, []); // Решение «показывать ли кнопку»: всем кроме явного viewer. Любая // неопределённая роль (auth не пришёл, CURRENT_USER null) → показываем, // иначе пользователь думает что фича пропала. Реальные ограничения // write-операций — на backend через requireManager. const isOwner = (() => { const r = (window.CURRENT_USER && window.CURRENT_USER.role) || window.CURRENT_ROLE || 'owner'; return r !== 'viewer'; })(); useEffectA(() => { const listener = (e) => { if (e.data?.type === '__activate_edit_mode') setTweaksOpen(true); if (e.data?.type === '__deactivate_edit_mode') setTweaksOpen(false); }; window.addEventListener('message', listener); try { window.parent.postMessage({ type: '__edit_mode_available' }, '*'); } catch {} return () => window.removeEventListener('message', listener); }, []); // Phase 2: SSE-подписка на backend snapshot. Пока backend не существует — // EventSource ретраит и SyncIndicator стоит красный, app живёт на localStorage. useEffectA(() => { if (typeof window.connectStateStream !== 'function') return; const es = window.connectStateStream(); return () => { try { es && es.close && es.close(); } catch (_) {} if (window.API) window.API._es = null; }; }, []); // Phase 2 / A5: подтянуть текущего пользователя и роль из /api/whoami. // До этого момента CanWrite-гарды держат write-кнопки заблокированными // (default role=viewer). После ответа — dispatch 'current-user-changed' // ребутает CanWrite-обёртки. useEffectA(() => { if (window.API && typeof window.API.currentUser === 'function') { window.API.currentUser().catch(() => {}); } }, []); const handleGoWarehouse = (wId) => { setView('warehouse'); setWarehouseStep('groups'); setWarehouseScrollTo(typeof wId === 'number' ? wId : null); }; const handleGoMarketplace = () => setView('marketplace'); // Универсальный навигатор для KPI-карточек const handleNavigate = (target, opts) => { if (target === 'warehouse') return handleGoWarehouse(opts?.warehouseId); if (target === 'marketplace') return handleGoMarketplace(); if (target === 'suppliers') { setSuppliersCtx(opts || null); setView('suppliers'); return; } if (target === 'settings') { if (opts?.initialTab) { setSettingsTab(opts.initialTab); // И запоминаем её ТАМ ЖЕ, где Настройки хранят последнюю открытую // вкладку. Иначе переход по кнопке проигрывал сохранённой вкладке: // экран Настроек размонтируется при уходе, при возврате читает // хранилище заново, и запрошенная вкладка терялась у всех, кто хоть // раз заходил в Настройки. Это и есть жалоба «точку добавления не // находишь с первого раза». Явный переход — это тоже смена вкладки, // так что запоминать его правильно. try { localStorage.setItem('autolight_settings_tab_v1', opts.initialTab); } catch (_) {} } setView('settings'); return; } setView(target); }; // До логина — закрываем всё приложение модалкой. Никакой fetch к chat / state // не нужен без identity. Backend chat-routes требуют auth (Bearer/X-API-Key). if (!authUser) { return ( setAuthUser(window.AUTH && window.AUTH.user)}/> ); } return ( {view === 'warehouse' && isOwner && ( )} setTweaksOpen(true)} currentUser={chatMe} onLogout={() => window.AUTH && window.AUTH.logout()}/>
{view === 'manager' && ( )} {view === 'marketplace' && window.MarketplacePage && ( setView('manager')}/> )} {view === 'suppliers' && window.SuppliersPage && ( setView('manager')} openSupplierId={suppliersCtx?.openSupplierId} openDealId={suppliersCtx?.openDealId} /> )} {view === 'warehouse' && warehouseStep === 'groups' && ( {setWarehouseGroup(gid); setWarehouseGroupIntent(intent || null); setWarehouseStep('items');}} scrollToWarehouseId={warehouseScrollTo} onScrolled={()=>setWarehouseScrollTo(null)} onNavigate={handleNavigate} editMode={warehouseEditMode} /> )} {view === 'warehouse' && warehouseStep === 'items' && ( {setWarehouseGroupIntent(null); setWarehouseStep('groups');}} onOpenPart={setOpenPart} editMode={warehouseEditMode} intent={warehouseGroupIntent} onIntentDone={()=>setWarehouseGroupIntent(null)} /> )} {view === 'procurement' && } {view === 'storeman' && } {view === 'assembler' && } {view === 'cech' && window.CechScreen && ( setView('manager')}/> )} {view === 'scanner' && } {view === 'calculator' && } {view === 'settings' && }
{/* Drawer & modals */} setOpenPart(null)}/> {launchDraft && setLaunchDraft(null)} onLaunched={()=>{}}/>} {/* Chat — FAB на всех десктоп-вью (Главная, Склад, Marketplace, Поставщики, Снабжение, Кладовщик, Сборщик, Сканер, Калькулятор, Настройки). На 'mobile' не показываем — там MobileShell сам включает MobileChat внутри iPhone-фрейма. */} {view !== 'mobile' && !chatOpen && (
setChatOpen(true)} unread={chatUnread}/>
)} {chatOpen && (
setChatOpen(false)} currentUser={chatMe}/>
)} setTweaksOpen(false)}/>
); } ReactDOM.createRoot(document.getElementById('root')).render();