// 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 (
{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 (
Тема
Акцент
{['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();