// Shared UI primitives const { useState, useEffect, useMemo, useRef, useCallback } = React; // Семантические action-tones для иконок. CSS-правила в styles.jsx // красят SVG в зависимости от data-tone (синий редакт, красный удал, зелёный плюс/чек), // автоматически перекрашиваясь в белый внутри btn-primary/btn-cta. const ICON_TONES = { edit: 'edit', trash: 'del', plus: 'add', minus: 'del', check: 'ok', close: 'mute', x: 'mute', alert: 'warn', download: 'edit', sync: 'edit', }; function Icon({ name, size = 16, stroke = 1.75, tone }) { const paths = { scan: <>, box: <>, warehouse: <>, factory: <>, truck: <>, search: <>, bell: <>, settings: <>, close: <>, x: <>, check: <>, plus: <>, minus: <>, arrow_right: <>, arrow_left: <>, calculator: <>, alert: <>, sync: <>, cloud: <>, clock: <>, user: <>, printer: <>, camera: <>, flash: <>, list: <>, grid: <>, dot: , trending_down: <>, trending_up: <>, download: <>, phone: <>, mobile: <>, crown: <>, shield: <>, server: <>, users: <>, paperclip: <>, send: <>, mic: <>, edit: <>, trash: <>, play: <>, key: <>, database: <>, plug: <>, palette: <>, copy: <>, move: <>, }; const resolvedTone = tone || ICON_TONES[name] || null; return ( {paths[name] || paths.dot} ); } // Logo — minimalist beam mark function Logo({ size = 22 }) { return ( ); } // ============================================================================ // ViewToggle — переиспользуемый сегмент-контрол «иконки ↔ список». // Две кнопки: grid («иконки») и list («список»). Активная подсвечена --accent. // useViewMode(storageKey, defaultMode) — хранит выбор в localStorage. // CSS инжектится локально (по образцу injectCechCss), а не в styles.jsx. // ============================================================================ const VIEW_TOGGLE_CSS = ` .view-toggle { display: inline-flex; align-items: center; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-sunk); } .view-toggle__btn { display: inline-flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--fg-muted); border-radius: 7px; padding: 5px 9px; cursor: pointer; transition: background .15s, color .15s, box-shadow .15s; } .view-toggle__btn:hover:not(.is-active) { color: var(--fg); } .view-toggle__btn.is-active { background: var(--accent); color: #fff; box-shadow: 0 1px 3px color-mix(in oklch, var(--accent) 40%, transparent); } .view-toggle--sm .view-toggle__btn { padding: 4px 7px; } .view-toggle--lg .view-toggle__btn { padding: 7px 12px; } `; function injectViewToggleCss() { const id = '__view_toggle_css'; if (typeof document === 'undefined' || document.getElementById(id)) return; const el = document.createElement('style'); el.id = id; el.textContent = VIEW_TOGGLE_CSS; document.head.appendChild(el); } // ViewToggle — { value: 'grid'|'list', onChange(mode), size: 'sm'|'md'|'lg' }. function ViewToggle({ value = 'grid', onChange, size = 'md' }) { React.useEffect(() => { injectViewToggleCss(); }, []); const iconSize = size === 'sm' ? 14 : size === 'lg' ? 18 : 16; const sizeCls = size === 'sm' ? ' view-toggle--sm' : size === 'lg' ? ' view-toggle--lg' : ''; const handle = (mode) => () => { if (onChange && mode !== value) onChange(mode); }; return (
); } // useViewMode — выбор вида с персистом в localStorage по ключу storageKey. // Возвращает [mode, setMode]; mode ∈ ('grid'|'list'). function useViewMode(storageKey, defaultMode = 'grid') { const read = () => { try { const v = window.localStorage.getItem(storageKey); return (v === 'grid' || v === 'list') ? v : defaultMode; } catch (_) { return defaultMode; } }; const [mode, setModeState] = React.useState(read); const setMode = React.useCallback((next) => { const v = (next === 'grid' || next === 'list') ? next : defaultMode; setModeState(v); try { window.localStorage.setItem(storageKey, v); } catch (_) {} }, [storageKey, defaultMode]); return [mode, setMode]; } // Toast system const ToastCtx = React.createContext({ push: () => {} }); function ToastProvider({ children }) { const [toasts, setToasts] = useState([]); const push = useCallback((msg, kind='info') => { const id = Math.random().toString(36).slice(2); setToasts(ts => [...ts, { id, msg, kind }]); setTimeout(() => setToasts(ts => ts.filter(t => t.id !== id)), 2800); }, []); // A4: мост к window.toast(...) — позволяет тригерить тосты из // не-React кода (data_materials.jsx wrappers, API client при оффлайне). // Используется CustomEvent('app-toast') чтобы не дублировать состояние. useEffect(() => { const h = (e) => { const d = (e && e.detail) || {}; push(d.msg || '', d.kind || 'info'); }; window.addEventListener('app-toast', h); return () => window.removeEventListener('app-toast', h); }, [push]); return ( {children}
{toasts.map(t => (
{t.msg}
))}
); } const useToast = () => React.useContext(ToastCtx); // Глобальный shortcut — для не-React кода (data_materials, API client). // Сам тост покажет ToastProvider, который слушает 'app-toast'. (function installGlobalToast() { if (typeof window === 'undefined') return; if (typeof window.toast === 'function') return; window.toast = function (msg, kind) { try { window.dispatchEvent(new CustomEvent('app-toast', { detail: { msg: String(msg || ''), kind: kind || 'info' }, })); } catch (_) {} }; })(); // Drawer function Drawer({ open, onClose, children, width = 520 }) { useEffect(() => { if (!open) return; const h = e => e.key === 'Escape' && onClose(); document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [open, onClose]); if (!open) return null; return ( <>
{children}
); } // Vertical procurement bar function VBar({ pct, label, sub, onClick }) { return ( ); } // Horizontal progress bar with label + value function HBar({ label, value, max, color = 'var(--accent)' }) { const pct = Math.min(100, (value / max) * 100); return (
{label}
{value}
); } // Section header function SectionHeader({ caps, title, right }) { return (
{caps &&
{caps}
} {title &&
{title}
}
{right}
); } // Material swatch const MATERIAL_COLORS = { POM: '#8b5cf6', ABS: '#0ea5e9', PP: '#10b981', PC: '#f59e0b', PPS: '#ef4444', PVC: '#ec4899', }; function MatChip({ m }) { if (!m) return Закупной; return ( {m} ); } // Origin flag (CN/RU) function OriginFlag({ origin }) { if (!origin) return null; const label = origin === 'CN' ? 'КНР' : 'РФ'; return {label}; } // Формат числа BOM-строки: целое — без дробей, дробное — до 3 знаков (для кг/мл). function _bcNum(v) { const x = Number(v); if (!Number.isFinite(x)) return v; return (Number.isInteger(x) ? x : Math.round(x * 1000) / 1000).toLocaleString('ru'); } // Суффикс единицы: показываем кг/г/мл/л/т; «шт» и пустое — без подписи (чисто). function _bcUnit(u) { return (u && u !== 'шт') ? ' ' + u : ''; } // Список позиций BOM с цветным статусом. Заменяет сетку-квадратики на читаемый список. // bom — массив результата checkBOM (есть name, qty, n, status, available, need, shortage, // а также _dispUnit/_dispPerUnit — единица и расход-на-изделие для показа) // dense=true — компактный стиль (для мобильных фреймов, высота ограничена) function BomChecklist({ bom, dense = false, maxHeight = 320 }) { const colorFor = (s) => s === 'green' ? 'var(--green)' : s === 'yellow' ? 'var(--amber)' : 'var(--red)'; const greens = bom.filter(l => l.status === 'green').length; const reds = bom.filter(l => l.status === 'red').length; return (
{bom.length} позиций ● {greens} в наличии {reds > 0 && ● {reds} нехватка}
{bom.map(l => (
{l.n}
{l.name}
× {_bcNum(l._dispPerUnit != null ? l._dispPerUnit : l.qty)}{_bcUnit(l._dispUnit)} {l.status === 'green' ? 'есть' : l.shortage != null ? `нехв. ${_bcNum(l.shortage)}${_bcUnit(l._dispUnit)}` : 'нехватка'}
))}
); } // ============================================================================ // === FX-1: warehouse icon layout — IconTile · IconGrid · WarehouseStripe ==== // === · ItemPopover. Визуально наследуем v2-паттерн (плитка + анимация), == // === в палитре v1 (#1d4ed8 без смены токенов). == // ============================================================================ // Мини-набор SVG-иконок групп/складов — самодостаточный, чтобы не зависеть // от порядка загрузки warehouse_pages.jsx. Возвращает , цвет — через // currentColor из стиля обёртки. const TILE_ICON_SVG = { flask: , drop: , screw: <>, lamp: <>, package: <>, chip: <>, oring: <>, box: <>, factory: <>, headlight: <>, lens: <>, gear: <>, }; function TileIcon({ name = 'box', size = 22 }) { const el = TILE_ICON_SVG[name] || TILE_ICON_SVG.box; return ( {el} ); } // Цвет/иконка по groupId — индустриально-пластиковая палитра. // Fallback для неизвестных groupId — accent blue. const TILE_STYLE_FOR_GROUP = { // Склад №1 — сырьё 'w1-plastics': { icon: 'flask', color: '#7c3aed' }, // PE/PP фиолет 'w1-metal': { icon: 'screw', color: '#475569' }, // сталь 'w1-lamps': { icon: 'lamp', color: '#eab308' }, // янтарь нити накала 'w1-g6': { icon: 'chip', color: '#10b981' }, // PCB-зелёный (Электрика) 'w1-chemistry': { icon: 'drop', color: '#0891b2' }, // растворитель-бирюза 'w1-g5': { icon: 'package', color: '#b45309' }, // крафт-картон // Склад №2 — полуфабрикаты 'w2-corpus': { icon: 'box', color: '#334155' }, // корпус — тёмный пластик 'w2-reflector': { icon: 'headlight', color: '#94a3b8' }, // зеркало 'w2-glass': { icon: 'lens', color: '#38bdf8' }, // прозрачный PC 'w2-mech': { icon: 'gear', color: '#64748b' }, // POM-моторика (шестерни/опоры) // Склад №3 — готовая продукция 'w3-headlights': { icon: 'headlight', color: '#f59e0b' }, // готовая фара — янтарь 'w3-spare-glass': { icon: 'lens', color: '#0ea5e9' }, // запчасть-стекло — небо // Legacy (если ещё встречаются в старых экранах) 'w2-pf': { icon: 'factory', color: '#2563eb' }, 'w3-stock': { icon: 'headlight', color: '#f59e0b' }, 'w3-live': { icon: 'headlight', color: '#ea580c' }, 'w3-spare': { icon: 'headlight', color: '#0ea5e9' }, }; function tileStyleForItem(item) { return TILE_STYLE_FOR_GROUP[item.groupId] || { icon: 'box', color: '#1d4ed8' }; } // ============================================================================ // «Вес примерный» — маркер позиций, у которых масса выдумана/взята по аналогу // и требует ручной сверки. Контракт с данными (SQL-процесс пометит позиции): // 1) source содержит подстроку 'approx' (например 'approx-weight') — явный // флаг, проставленный процессом нормализации; // 2) ЛИБО вес отсутствует у позиции, которая по смыслу обязана иметь вес — // ПФ Склада №2 / отражатели / рассеиватели / корпуса: material задан, // но weight пуст (0 / null / ''). // _isWeightApprox(item) — единый предикат, используется во всех местах склада. // ============================================================================ // Группы Склада №2 (ПФ), для которых отсутствие веса при заданном материале // считается «примерным/недостающим» весом. Корпуса/отражатели/стёкла/механизмы. const _WEIGHT_REQUIRED_GROUPS = new Set([ 'w2-corpus', 'w2-reflector', 'w2-glass', 'w2-mech', 'w2-pf', ]); function _weightIsEmpty(item) { // Пусто = null/undefined/'' или числовой 0 (нулевой вес физической детали // невозможен → трактуем как «не указан»). if (item == null) return true; const w = item.weight; if (w == null || w === '') return true; const n = Number(w); return !Number.isFinite(n) || n <= 0; } function _isWeightApprox(item) { if (!item) return false; // (1) Явный флаг в source. if (String(item.source || item._source || '').toLowerCase().includes('approx')) return true; // (2) ПФ Склада №2 (или иная позиция с материалом) без веса. const isPfGroup = _WEIGHT_REQUIRED_GROUPS.has(item.groupId) || item.warehouseId === 2 || item.warehouseId === '2'; const hasMaterial = !!item.material; if (isPfGroup && hasMaterial && _weightIsEmpty(item)) return true; return false; } // _approxWeightCountInItems(items) — сколько позиций из набора «примерны». // Используется для агрегатного бейджа на группе/подгруппе/подподгруппе. function _approxWeightCountInItems(items) { if (!Array.isArray(items)) return 0; let n = 0; for (const it of items) if (_isWeightApprox(it)) n++; return n; } // WtApproxBadge — маленький фиолетовый бейдж «вес примерный», absolute снизу-слева // внутри плитки/строки. mode='item' — одиночная позиция (иконка ⚖?), mode='count' // — агрегат на группе/подгруппе с числом примерных позиций (~N). // Контейнер-родитель ДОЛЖЕН быть position:relative (.icon-tile уже relative). function WtApproxBadge({ count = null, inline = false }) { const isCount = count != null; const title = isCount ? `Вес примерный (по аналогу) — требует корректировки: позиций ${count}` : 'Вес примерный (по аналогу) — требует корректировки'; return ( {isCount ? <>~{count} : <>⚖?} ); } // useReserveTick — подписка на событие 'reserve-changed', которое шлёт // data_materials.reserveForRequest / releaseForRequest. Компонент получает // bump при каждом изменении резерва (без setInterval-polling). function useReserveTick() { const [, bump] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const h = () => bump(); window.addEventListener('reserve-changed', h); return () => window.removeEventListener('reserve-changed', h); }, []); } // useTaxonomyTick — подписка на 'taxonomy-changed' (ребилд таксономии/спек/ // оборудования от Settings или SSE). Bump на каждое событие, без polling. function useTaxonomyTick() { const [, bump] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const h = () => bump(); window.addEventListener('taxonomy-changed', h); return () => window.removeEventListener('taxonomy-changed', h); }, []); } // useCechTick — подписка на 'cech:state-changed' (запуск партии в ЛФ, // SSE-snapshot из backend). Bump на каждое событие, без polling. Зеркало // useReserveTick, но для данных Цеха (window.CECH_STATE). function useCechTick() { const [, bump] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const h = () => bump(); window.addEventListener('cech:state-changed', h); return () => window.removeEventListener('cech:state-changed', h); }, []); } // ─── Цех: производные ФГ-наряды (read-only проекция CECH_STATE) ────────────── // fgOrdersInProduction() — активные ФГ-наряды «в производстве». // READ-ONLY проекция window.CECH_STATE.items (клиентское зеркало // manufacturingBatches) + window.REQUESTS. Готовая фара при запуске // разузловывается launch_flow в N ПФ-партий (по числу изготавливаемых строк // BOM), каждая со своим этапом цеха и общим _requestId. Здесь они сводятся // обратно в один «наряд» = одна запускаемая модель. // // ФГ-партия детектится по productKind ('fg' / 'fg-pf' — backend-snapshot) // либо по префиксу id 'fg-' (локальный item из launch_flow до round-trip, // где productKind ещё не проставлен). ПФ-партии ('pf-' / 'pf') исключены. // // «В производстве» = у наряда есть хоть одна деталь не на Складе №3 // (sklad3 — финальная зона маршрута ФГ). Полностью прибывшие на W3 наряды // уже видны как реальный сток INVENTORY и здесь не дублируются. // // Возвращает массив: { key, name, qty, partsCount, stageCounts, // activeStages:[{key,name,short,qty}], inProductionQty, doneQty }. function fgOrdersInProduction() { const cs = window.CECH_STATE || {}; const rawItems = Array.isArray(cs.items) ? cs.items : []; // Дедуп по id (защита от транзитной гонки local↔snapshot). const seen = new Set(); const items = []; for (const it of rawItems) { if (!it || (it.id != null && seen.has(it.id))) continue; if (it.id != null) seen.add(it.id); items.push(it); } const isFg = (it) => it.productKind === 'fg' || it.productKind === 'fg-pf' || (typeof it.id === 'string' && it.id.indexOf('fg-') === 0); const reqs = window.REQUESTS || []; // requestId на партии = либо id заявки, либо локальный key (externalId). const findReq = (rid) => rid ? reqs.find(r => r.id === rid || r.externalId === rid) : null; const groups = new Map(); // canonicalKey → наряд for (const it of items) { if (!isFg(it)) continue; const req = findReq(it._requestId); // Взаимосвязь «нет заявки → нет производства»: партию-сироту (без живой // заявки) НЕ показываем как наряд в работе — иначе на витрине «в // производстве» висит то, чего нет в буфере заявок (рассинхрон). // + Архивный наряд (досрочно завершён и убран в «Архив») тоже НЕ висит // «в производстве» — иначе архивированный корпус-наряд оставался тут // (жалоба Александра 2026-06-24, скрин 4). if (!req || req.archived) continue; const key = req.id; let order = groups.get(key); if (!order) { order = { key, name: req.productName || '—', qty: req.qty || null, partsCount: 0, stageCounts: {}, inProductionQty: 0, doneQty: 0, }; groups.set(key, order); } order.partsCount += 1; const stages = it.stages || {}; for (const sk of Object.keys(stages)) { const q = Number(stages[sk]) || 0; if (q <= 0) continue; order.stageCounts[sk] = (order.stageCounts[sk] || 0) + q; if (sk === 'sklad3') order.doneQty += q; else order.inProductionQty += q; } } const stageKeys = Object.keys(window.CECH_STAGES || {}); const stagesMeta = window.CECH_STAGES || {}; const out = []; for (const o of groups.values()) { if (o.inProductionQty <= 0) continue; // полностью на W3 — не «в производстве» o.activeStages = Object.keys(o.stageCounts) .filter(k => o.stageCounts[k] > 0) .sort((a, b) => stageKeys.indexOf(a) - stageKeys.indexOf(b)) .map(k => ({ key: k, name: (stagesMeta[k] && stagesMeta[k].name) || k, short: (stagesMeta[k] && stagesMeta[k].short) || k, qty: o.stageCounts[k], })); out.push(o); } return out; } // reservationsByProductForGroup(groupId) — разбивка резерва подгруппы склада // по ЦЕЛЕВОМУ изделию (наряду/заявке). READ-ONLY: для каждой позиции // подгруппы с reserved>0 берёт window.reservationsForInventory(invId) // (заявки, держащие эту позицию) и группирует по productName. // Возвращает [{ productName, productQty, status, byUnit:{unit:amount}, // posCount, _total }] — сортировка по суммарному резерву (крупные изделия // первыми). byUnit даёт разбивку «X кг сырья · Y шт деталей» на изделие. function reservationsByProductForGroup(groupId) { if (!groupId || typeof window.reservedItemsInGroup !== 'function' || typeof window.reservationsForInventory !== 'function') return []; const reserved = window.reservedItemsInGroup(groupId); const byProduct = new Map(); for (const it of reserved) { const recs = window.reservationsForInventory(it.id) || []; for (const r of recs) { const pname = r.productName || '—'; let p = byProduct.get(pname); if (!p) { p = { productName: pname, productQty: r.productQty || null, status: r.status, requestId: r.requestId || null, byUnit: {}, posCount: 0, _total: 0 }; byProduct.set(pname, p); } const unit = r.unit || it.unit || 'шт'; const amt = Number(r.amount) || 0; p.byUnit[unit] = (p.byUnit[unit] || 0) + amt; p.posCount += 1; p._total += amt; } } return Array.from(byProduct.values()).sort((a, b) => b._total - a._total); } // formatQty — единый форматтер количеств: кг округляется до 0.1, остальное // до целого; всегда ru-локаль (разделитель тысяч). Дедуп 4 inline-копий. function formatQty(v, unit) { return unit === 'кг' ? (Math.round(v * 10) / 10).toLocaleString('ru') : Math.round(v).toLocaleString('ru'); } // useCurrentRole — текущая роль из window.CURRENT_USER (data_server_sync.jsx). // Подписан на 'current-user-changed' event'ы, чтобы CanWrite-обёртки сами // перерисовывались когда whoami приедет/изменится. const ROLE_ORDER = { anonymous: 0, viewer: 1, manager: 2, admin: 3 }; function useCurrentRole() { const [, bump] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const h = () => bump(); window.addEventListener('current-user-changed', h); return () => window.removeEventListener('current-user-changed', h); }, []); const cu = window.CURRENT_USER || {}; return cu.role || 'viewer'; } // CanWrite — UI-гейт по ролям для write-кнопок. Если текущая роль ниже // требуемой — рендерит child как disabled (greyed-out + tooltip + click-block). // mode='hide' — полностью скрывает (для пунктов меню, где disabled не имеет // смысла). Поддерживает single React-элемент в children (button//
). // // // // // // // Сбросить базу // function CanWrite({ role = 'manager', mode = 'disabled', children }) { const current = useCurrentRole(); const allowed = (ROLE_ORDER[current] || 0) >= (ROLE_ORDER[role] || 0); if (allowed) return children; if (mode === 'hide') return null; if (React.Children.count(children) !== 1 || !React.isValidElement(children)) { // Несколько детей или не-элемент — fallback: оборачиваем в div // с пониженной прозрачностью и tooltip. return ( {children} ); } const child = React.Children.only(children); return React.cloneElement(child, { disabled: true, onClick: undefined, title: 'недостаточно прав: требуется роль ' + role, 'aria-disabled': true, style: Object.assign( {}, child.props.style || {}, { opacity: 0.45, cursor: 'not-allowed', pointerEvents: 'auto' }, ), }); } // useApiSyncStatus — reactive снимок состояния window.API. Подписывается на // 'api-status-changed' + tick (1с) чтобы поля age/pending корректно реактивно // устаревали без новых ивентов. Возвращает: // online — успешный sync < 10с назад // lastSyncAt — ms timestamp последнего успеха (0 = ни разу не было) // pendingCount — кол-во ops в _offlineQueue // syncing — true пока есть незавершённый fetch // ageMs — сколько прошло с последнего успешного sync function useApiSyncStatus() { const [, bump] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const h = () => bump(); window.addEventListener('api-status-changed', h); window.addEventListener('reserve-changed', h); const t = setInterval(h, 1000); return () => { window.removeEventListener('api-status-changed', h); window.removeEventListener('reserve-changed', h); clearInterval(t); }; }, []); const api = window.API || {}; const lastSyncAt = api._lastSuccessAt || 0; const ageMs = lastSyncAt ? (Date.now() - lastSyncAt) : Infinity; return { online: lastSyncAt > 0 && ageMs < 10000, lastSyncAt, ageMs, pendingCount: (api._offlineQueue && api._offlineQueue.length) || 0, syncing: (api._pending || 0) > 0, }; } // SyncIndicator — статус-дот синхронизации с backend (Phase 2 shared state). // Зелёный: успешный sync < 5с. Жёлтый: запрос в полёте или 5-30с с момента // последнего sync. Красный: оффлайн > 30с или backend никогда не отвечал. // compact=true — мобильная версия (без tooltip и счётчика очереди). function SyncIndicator({ compact = false }) { const [, bump] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const h = () => bump(); window.addEventListener('api-status-changed', h); window.addEventListener('reserve-changed', h); // tick раз в секунду — иначе цвет "застрянет" между событиями (синхронизация // была 4с назад → должен переключиться на amber/red без новых ивентов) const t = setInterval(h, 1000); return () => { window.removeEventListener('api-status-changed', h); window.removeEventListener('reserve-changed', h); clearInterval(t); }; }, []); const api = window.API || {}; const lastAt = api._lastSuccessAt || 0; const pending = api._pending || 0; const queueLen = (api._offlineQueue && api._offlineQueue.length) || 0; const ageMs = lastAt ? (Date.now() - lastAt) : Infinity; let color, label; if (pending > 0) { color = '#f59e0b'; label = 'синхронизация…'; } else if (lastAt && ageMs < 5000) { color = '#10b981'; label = 'синхронизировано'; } else if (lastAt && ageMs < 30000) { color = '#f59e0b'; label = 'устаревшая синхронизация'; } else { color = '#ef4444'; label = lastAt ? 'оффлайн' : 'оффлайн (нет связи с backend)'; } let tooltipText = label; if (lastAt) { const dt = new Date(lastAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit', }); tooltipText += ' · последний sync: ' + dt; } if (queueLen > 0) tooltipText += ' · ожидают отправки: ' + queueLen; const size = compact ? 8 : 10; return (
{!compact && queueLen > 0 && ( {queueLen} )}
); } // IconTile — одна плитка в полосе/сетке. // Props: icon, color, title, code, count, size, onClick. // Новые опциональные: // hoverContent — React-нода, всплывает при наведении (для подгрупп склада). // reserveBadge — { reserved, unit } → маленькая amber-метка поверх плитки. // reserveTone — 'amber' | 'red' — стиль плитки и счётчика (для блока «Сырьё в резерве»). function IconTile({ icon, color = '#1d4ed8', title, code, count, countLabel = 'шт', size = 'md', hot = false, stub = false, active = false, index = 0, onClick, hoverContent = null, reserveBadge = null, reserveTone = null, shipBadge = null, spareBadge = false, framed = false, weightApprox = false, approxCount = null, cornerButton = null }) { const [hovered, setHovered] = React.useState(false); const [hoverPos, setHoverPos] = React.useState(null); const hoverTimer = React.useRef(null); const wrapRef = React.useRef(null); const show = () => { clearTimeout(hoverTimer.current); hoverTimer.current = setTimeout(() => { // На момент показа вычисляем позицию тултипа относительно viewport, чтобы // обойти overflow:auto у родительской полосы склада. if (wrapRef.current) { const r = wrapRef.current.getBoundingClientRect(); setHoverPos({ top: r.bottom + 6, left: r.left }); } setHovered(true); }, 180); }; const hide = () => { clearTimeout(hoverTimer.current); setHovered(false); }; React.useEffect(() => () => clearTimeout(hoverTimer.current), []); // Пока hover активен — следим за scroll/resize и пересчитываем координаты // тултипа, чтобы он визуально оставался прикреплённым к плитке. // position:fixed даёт стабильный z-index поверх overflow:auto родителей, // а listener с capture ловит прокрутку внутренних скроллеров (.wh-stripe-row). React.useEffect(() => { if (!hovered || !hoverContent) return; const update = () => { if (!wrapRef.current) return; const r = wrapRef.current.getBoundingClientRect(); setHoverPos({ top: r.bottom + 6, left: r.left }); }; window.addEventListener('scroll', update, true); window.addEventListener('resize', update); return () => { window.removeEventListener('scroll', update, true); window.removeEventListener('resize', update); }; }, [hovered, hoverContent]); const sizeCls = size === 'xs' ? ' icon-tile--xs' : size === 'sm' ? ' icon-tile--sm' : size === 'lg' ? ' icon-tile--lg' : ''; const toneCls = reserveTone === 'red' ? ' icon-tile--reserve-red' : reserveTone === 'amber' ? ' icon-tile--reserve-amber' : ''; const cls = 'icon-tile' + sizeCls + toneCls + (active ? ' icon-tile--active' : '') + (hoverContent ? ' icon-tile--has-hover' : ''); // --i управляет задержкой каскадного появления (см. keyframes tile-expand в styles.jsx) // framed — зелёная рамка-выделение для ПФ-запчастей на Складе №3 (корпус/рассеиватель). const style = { '--i': Math.min(index, 24), '--tile-accent': color, ...(framed ? { outline: '2px solid var(--green)', outlineOffset: '2px' } : {}) }; const fmtBadge = (v, u) => window.formatQty(v, u); // Нужна ли обёртка .icon-tile-wrap? Только если есть hover-окошко или // amber-бейдж. Без обёртки — плитка рендерится как обычный button, grid // сохраняет прежние размеры колонок. const needsWrap = !!hoverContent || (reserveBadge && Number(reserveBadge.reserved) > 0) || !!cornerButton; const buttonEl = ( ); if (!needsWrap) return buttonEl; return (
{buttonEl} {cornerButton && {cornerButton}} {hoverContent && hovered && hoverPos && (
{hoverContent}
)}
); } // IconGrid — адаптивная сетка плиток. function IconGrid({ items, columns, size = 'md', onItemClick, renderExtra }) { const cols = columns || (size === 'sm' ? 8 : size === 'lg' ? 4 : 6); return (
{items.map((it, idx) => { const st = tileStyleForItem(it); // Синий бейдж «↑N» (отгружено) на готовой фаре Склада №3 — сумма отгруженного // по ОТКРЫТЫМ нарядам этой позиции (status != done/cancelled). Гаснет при // закрытии наряда. (Александр 2026-06-17) const _shipFg = (it.warehouseId === 3 && typeof window.getRequestProgress === 'function') ? (window.REQUESTS || []).reduce((sum, r) => { if (!r || r.status === 'done' || r.status === 'cancelled') return sum; if ((r.productSKU || r.productSku) !== it.sku) return sum; const p = window.getRequestProgress(r.id); return sum + ((p && p.shipped > 0) ? p.shipped : 0); }, 0) : 0; // «Вес примерный»: для плитки-агрегата (подгруппа/подподгруппа) — счётчик // примерных позиций внутри; для обычной позиции — её собственный флаг. const _isAggTile = (it._subsub || it._subgroup) && window.itemsForGroup; const _approxCnt = _isAggTile ? _approxWeightCountInItems(window.itemsForGroup(it.groupId, it._subsubKey)) : 0; return ( onItemClick && onItemClick(it)} shipBadge={_shipFg > 0 ? _shipFg : null} weightApprox={!_isAggTile && _isWeightApprox(it)} approxCount={_isAggTile ? _approxCnt : null} cornerButton={(!_isAggTile && it.id && window.MovePositionLauncher) ? : null} /> ); })} {renderExtra}
); } // WarehouseStripe — одна полоса склада: шапка + плитки подгрупп + опциональный // «Полный список». // Props: warehouse {id,name}, hotItems, totalCount, onItemClick, onFullList, // subtitle, size ('md'|'sm') — регулирует размер плиток. function WarehouseStripe({ warehouse, hotItems, totalCount, onItemClick, onFullList, subtitle, size = 'md', renderHover = null }) { const tileSize = size === 'sm' ? 'sm' : 'md'; // Подписка на изменения резерва — чтобы amber-бейджи на плитках подгрупп // перерисовывались сразу после reserveForRequest / releaseForRequest. useReserveTick(); return (
Склад №{warehouse.id}
{warehouse.name.replace(/^Склад №\d+ —\s*/, '') || warehouse.name}
{subtitle && (
{subtitle}
)}
{onFullList && ( )}
{hotItems.map((it, idx) => { const st = tileStyleForItem(it); const hover = renderHover ? renderHover(it) : null; const reservedAgg = (window.reservedItemsInGroup && it.id) ? window.reservedItemsInGroup(it.id) : []; const totalReserved = reservedAgg.reduce((s, r) => s + (Number(r.reserved) || 0), 0); const reserveUnit = reservedAgg[0]?.unit || null; // Каскад синего бейджа «↑N» (отгружено по открытому наряду) на плитку // группы Склада №3 — сумма отгруженного по всем готовым фарам группы. const shipGroup = (window.shippedSumInGroup && it.id) ? window.shippedSumInGroup(it.id) : 0; // Агрегат «вес примерный»: сколько позиций внутри группы примерны. // Для ПФ-запчасти (дубль W2 на W3) считаем по исходной подгруппе W2. const _agGroupId = it._spareItem && it._spareGroupId ? it._spareGroupId : it.id; const approxCount = (window.itemsForGroup && _agGroupId) ? window._approxWeightCountInItems(window.itemsForGroup(_agGroupId)) : 0; return ( onItemClick(it)} hoverContent={hover} reserveBadge={totalReserved > 0 && reserveUnit ? { reserved: totalReserved, unit: reserveUnit } : null} shipBadge={shipGroup > 0 ? shipGroup : null} spareBadge={!!it._spareBadge || !!it._spareItem} framed={!!it._spareFramed} approxCount={approxCount} /> ); })} {hotItems.length === 0 && (
Пока нет позиций.
)}
); } // ReserveBlock — блок «Резерв» на карточке любой позиции. Объединяет прежний // RawReserveWidget (amber при активном резерве) и прежний «Входит в BOM» // (связь с эталонной фарой). Рисуется ВСЕГДА, меняется тон: // reserved > 0 → активный (amber), критичный (red) при ≥ 80%; // reserved = 0 → нейтральный, «сейчас не зарезервировано». // Если у позиции есть bomN — справа чип-справка «× qty на фару ПТФ Гранта L». function ReserveBlock({ item }) { if (!item) return null; const reserved = Number(item.reserved) || 0; const stock = Number(item.stock) || 0; const free = Math.max(0, stock - reserved); const unit = item.unit || 'шт'; const fmt = v => window.formatQty(v, unit); const reservedPct = stock > 0 ? Math.min(100, (reserved / stock) * 100) : 0; const active = reserved > 0; const critical = reservedPct >= 80; const bomEntry = item.bomN != null ? (window.BOM_GRANTA_L || []).find(b => b.n === item.bomN) : null; const reservations = active && window.reservationsForInventory ? window.reservationsForInventory(item.id) : []; // Разбивка по целевому изделию: несколько заявок на одну фару сливаются в // одну строку (× N заявок), сумма резерва складывается. Отвечает «подо что // зарезервирована позиция» по изделию, а не по сырому id заявки. const reservByProduct = []; const _rbpIdx = {}; for (const r of reservations) { const k = r.productName || '—'; if (_rbpIdx[k] == null) { _rbpIdx[k] = reservByProduct.length; reservByProduct.push({ productName: k, productQty: r.productQty || null, amount: 0, count: 0, reqId: r.requestId }); } const g = reservByProduct[_rbpIdx[k]]; g.amount += Number(r.amount) || 0; g.count += 1; } const cls = 'reserve-block' + (active ? ' reserve-block--active' : ' reserve-block--empty') + (critical ? ' reserve-block--critical' : ''); return (
Резерв {bomEntry && ( × {bomEntry.qty} на фару ПТФ Гранта L )}
В резерве {active ? fmt(reserved) : '0'} {unit}
Свободно {fmt(free)} {unit}
); } // Совместимость со старыми ссылками — RawReserveWidget остаётся как alias. // Старые вставки в ItemPopover/ItemCardContent удалим отдельно, но на случай // других мест в коде держим синоним, чтобы не ломать. const RawReserveWidget = ReserveBlock; // ItemPopover — правая шторка с полной карточкой позиции. // Базируется на Drawer. Ширина 420px. // relatedBom — массив {product, qty} или вычисляется из bomN по BOM_GRANTA_L. function ItemPopover({ item, open, onClose }) { useReserveTick(); if (!item) return null; // Связь с BOM: если item.bomN совпадает с номером в BOM_GRANTA_L — // показываем, что позиция входит в эталонную фару Гранта L. const bomEntry = item.bomN != null ? (window.BOM_GRANTA_L || []).find(b => b.n === item.bomN) : null; const stockLow = item.minStock != null && item.stock < item.minStock; const stockCrit = item.minStock != null && item.stock < item.minStock * 0.25; const stockColor = stockCrit ? 'var(--red)' : stockLow ? 'var(--amber)' : 'var(--green)'; const stockPct = item.minStock ? Math.min(100, (item.stock / item.minStock) * 100) : 100; const wh = (window.WAREHOUSES || []).find(w => w.id === item.warehouseId); const groups = [...(window.GROUPS_W1 || []), ...(window.GROUPS_W2 || []), ...(window.GROUPS_W3 || [])]; const grp = groups.find(g => g.id === item.groupId); const tile = tileStyleForItem(item); return (
{wh ? wh.name : 'Склад'}
{item.name}
{item.sku || ('#' + item.id)}
{wh && {wh.name.split('—')[0].trim()}} {grp && {grp.name}} {item.material && } {item.origin && } {item.isHot && горячая} {Number(item.reserved) > 0 && ( в резерве {(item.unit === 'кг' ? (Math.round(Number(item.reserved) * 10) / 10) : Math.round(Number(item.reserved))).toLocaleString('ru')} {item.unit || 'шт'} )} {_isWeightApprox(item) && }
{item.isStub && (
Демо-позиция (сгенерировано для макета)
)}
Наличие
{Number(item.stock ?? 0).toLocaleString('ru')} {item.unit || 'шт'}
{item.minStock != null && ( <>
мин.: {item.minStock.toLocaleString('ru')} {stockLow && ниже минимума}
)}
{item.warehouseId === 2 && window.W2ReserveWidget && ( )} {item.warehouseId === 2 && window.WasteWidget && window.findProcessByOutput && window.findProcessByOutput(item.sku) && ( )}
{item.supplier && (
Поставщик
{item.supplier}
)} {(item.weight != null || _isWeightApprox(item)) && (
Вес {_isWeightApprox(item) && }
{_weightIsEmpty(item) ? не указан : <>{window.fmtKg(item.weight)} кг}
)} {item.packaging && (
Упаковка
{item.packaging}
)}
{Array.isArray(item.variants) && item.variants.length > 0 && (
Модификации
{item.variants.map((v, i) => ( {v} ))}
)} {item.comment && (
💬 Комментарий
{item.comment}
)}
); } // ============================================================================ // FloatingItemCard — перемещаемое окно карточки позиции (вместо drawer). // Можно открыть несколько одновременно (мультиоконность): drag за заголовок, // X в правом верхнем углу, клик по окну — поднимает его на передний план. // ============================================================================ function ItemCardContent({ item }) { useReserveTick(); const bomEntry = item.bomN != null ? (window.BOM_GRANTA_L || []).find(b => b.n === item.bomN) : null; const stockLow = item.minStock != null && item.stock < item.minStock; const stockCrit = item.minStock != null && item.stock < item.minStock * 0.25; const stockColor = stockCrit ? 'var(--red)' : stockLow ? 'var(--amber)' : 'var(--green)'; const stockPct = item.minStock ? Math.min(100, (item.stock / item.minStock) * 100) : 100; const wh = (window.WAREHOUSES || []).find(w => w.id === item.warehouseId); const groups = [...(window.GROUPS_W1 || []), ...(window.GROUPS_W2 || []), ...(window.GROUPS_W3 || [])]; const grp = groups.find(g => g.id === item.groupId); const tile = tileStyleForItem(item); return ( <>
{item.name}
{item.sku || ('#' + item.id)}
{wh && {wh.name.split('—')[0].trim()}} {grp && {grp.name}} {item.material && } {item.origin && } {item.isHot && горячая} {Number(item.reserved) > 0 && ( в резерве {(item.unit === 'кг' ? (Math.round(Number(item.reserved) * 10) / 10) : Math.round(Number(item.reserved))).toLocaleString('ru')} {item.unit || 'шт'} )}
{item.isStub && (
Демо-позиция (сгенерировано для макета)
)}
Наличие
{Number(item.stock ?? 0).toLocaleString('ru')} {item.unit || 'шт'}
{item.minStock != null && ( <>
мин.: {item.minStock.toLocaleString('ru')} {stockLow && ниже минимума}
)}
{item.warehouseId === 2 && window.W2ReserveWidget && ( )} {item.warehouseId === 2 && window.WasteWidget && window.findProcessByOutput && window.findProcessByOutput(item.sku) && ( )}
{item.supplier && (
Поставщик
{item.supplier}
)} {item.weight != null && (
Вес
{window.fmtKg(item.weight)} кг
)} {item.packaging && (
Упаковка
{item.packaging}
)}
{Array.isArray(item.variants) && item.variants.length > 0 && (
Модификации
{item.variants.map((v, i) => ( {v} ))}
)} {item.comment && (
💬 Комментарий
{item.comment}
)}
); } // Одно floating-окно. Позиция хранится внутри (не теряется при ререндере // родителя). zIndex и фокус — управляются снаружи. // Размер: по умолчанию 440×520, меняется двумя способами — (1) drag за // уголок в правом нижнем углу, (2) кнопка max/restore в заголовке. const _FLOAT_DEFAULT_SIZE = { w: 440, h: 520 }; function FloatingItemCard({ item, initialX, initialY, zIndex, onClose, onFocus }) { const [pos, setPos] = useState({ x: initialX, y: initialY }); const [size, setSize] = useState(_FLOAT_DEFAULT_SIZE); const [maximized, setMaximized] = useState(false); const dragRef = useRef(null); const resizeRef = useRef(null); const cardRef = useRef(null); const onHeadMouseDown = useCallback((e) => { // drag только за пустую часть заголовка (не за кнопки) if (e.target.closest('button')) return; if (maximized) return; // в полноэкранном режиме не двигаем e.preventDefault(); onFocus && onFocus(); dragRef.current = { startX: e.clientX, startY: e.clientY, offX: pos.x, offY: pos.y, }; const move = (ev) => { if (!dragRef.current) return; const dx = ev.clientX - dragRef.current.startX; const dy = ev.clientY - dragRef.current.startY; const w = cardRef.current?.offsetWidth || 440; const h = cardRef.current?.offsetHeight || 400; const maxX = window.innerWidth - 40; const maxY = window.innerHeight - 40; setPos({ x: Math.max(-(w - 80), Math.min(maxX, dragRef.current.offX + dx)), y: Math.max(0, Math.min(maxY, dragRef.current.offY + dy)), }); }; const up = () => { dragRef.current = null; document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }, [pos, onFocus, maximized]); const onResizeMouseDown = useCallback((e) => { if (maximized) return; e.preventDefault(); e.stopPropagation(); onFocus && onFocus(); resizeRef.current = { startX: e.clientX, startY: e.clientY, w: size.w, h: size.h, }; const move = (ev) => { if (!resizeRef.current) return; const dw = ev.clientX - resizeRef.current.startX; const dh = ev.clientY - resizeRef.current.startY; setSize({ w: Math.max(320, Math.min(window.innerWidth - 32, resizeRef.current.w + dw)), h: Math.max(280, Math.min(window.innerHeight - 32, resizeRef.current.h + dh)), }); }; const up = () => { resizeRef.current = null; document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }, [size, onFocus, maximized]); const toggleMaximize = useCallback(() => { setMaximized(m => !m); }, []); const wh = (window.WAREHOUSES || []).find(w => w.id === item.warehouseId); // Полноэкранный режим — занимает почти всё доступное пространство. const effPos = maximized ? { x: 16, y: 60 } : pos; const effSize = maximized ? { w: window.innerWidth - 32, h: window.innerHeight - 76 } : size; return (
onFocus && onFocus()} >
{wh ? wh.name.split('—')[0].trim() : 'Склад'}
{item.name}
{!maximized && (
)}
); } // WindowManager — хранит массив открытых окон, рендерит их поверх экрана. // Экспонирует openItem/close/focus. Позиция каскадная (каждое следующее // окно слегка смещено, чтобы не полностью перекрывать предыдущее). function useFloatingWindows() { const [windows, setWindows] = useState([]); const nextIdRef = useRef(1); const nextZRef = useRef(100); const openItem = useCallback((item) => { setWindows(prev => { // если уже открыто окно с тем же item.id — просто поднимем его const existingIdx = prev.findIndex(w => (w.item.id || w.item.sku) === (item.id || item.sku)); nextZRef.current += 1; if (existingIdx >= 0) { return prev.map((w, i) => i === existingIdx ? { ...w, z: nextZRef.current } : w); } // Starting position: centered over viewport, cascaded per extra window. // Floating card is 440px wide; centering it over vw lands it as an «окошко», // not a right-hand drawer. Each next window is offset down-right by 28px. const offset = prev.length * 28; const vw = window.innerWidth || 1280; const vh = window.innerHeight || 800; const cardW = 440; const cardH = 520; const baseX = Math.max(24, Math.round((vw - cardW) / 2)); const baseY = Math.max(80, Math.round((vh - cardH) / 2) - 20); const id = nextIdRef.current++; return [...prev, { id, item, x: baseX + offset, y: baseY + offset, z: nextZRef.current, }]; }); }, []); const closeWindow = useCallback((id) => setWindows(prev => prev.filter(w => w.id !== id)), []); const focusWindow = useCallback((id) => { setWindows(prev => { nextZRef.current += 1; return prev.map(w => w.id === id ? { ...w, z: nextZRef.current } : w); }); }, []); return { windows, openItem, closeWindow, focusWindow }; } function FloatingWindowsLayer({ windows, onClose, onFocus }) { if (!windows || windows.length === 0) return null; return (
{windows.map(w => ( onClose(w.id)} onFocus={() => onFocus(w.id)} /> ))}
); } // StatusChip — бейдж-табличка со статусом закупки. // Цвета/подписи берутся из window.PROCUREMENT_STATUSES (v1/data_suppliers.jsx). // Шаблон {eta} подставляется для in_transit. function StatusChip({ status, etaDays, size = 'sm' }) { const dict = window.PROCUREMENT_STATUSES || {}; const s = dict[status]; if (!s) return null; const label = s.label.replace('{eta}', etaDays == null ? '?' : etaDays); const pad = size === 'sm' ? '3px 8px' : '5px 12px'; const fs = size === 'sm' ? 11 : 12; return ( {label} ); } // Универсальная модалка подтверждения для удаления / опасных действий. // Вынесена из settings.jsx чтобы переиспользовать в любых экранах // (requests_section, settings, и пр.) без window.confirm(). // API: { title, message, confirmLabel?='Подтвердить', cancelLabel?='Отмена', // danger?=false, onConfirm, onClose } function ConfirmModal({ title, message, confirmLabel='Подтвердить', cancelLabel='Отмена', danger=false, onConfirm, onClose }) { useEffect(() => { const h = (e) => { if (e.key === 'Escape') onClose(); }; document.addEventListener('keydown', h); return () => document.removeEventListener('keydown', h); }, [onClose]); return (
e.stopPropagation()}>
{title}
{message}
); } // useConfirm — удобный хук-обёртка над ConfirmModal. // const confirm = useConfirm(); // confirm.ask({ title, message, danger, confirmLabel, onConfirm }); // В JSX рендерим {confirm.modal}. function useConfirm() { const [state, setState] = useState(null); const ask = useCallback((opts) => setState(opts || null), []); const close = useCallback(() => setState(null), []); const modal = state ? ( { try { state.onConfirm && state.onConfirm(); } finally { close(); } }} onClose={() => { state.onCancel && state.onCancel(); close(); }} /> ) : null; return { ask, close, modal }; } // ============================================================================= // SpecViewerModal — read-only viewer спецификации фары/ПФ. Используется на // дашборде (рядом с выбором FG в «Заказ на производство») и в шагe 1 LaunchFlow // (кнопка «Спецификация»). Редактирование — только в Settings → Спецификации; // этот компонент только ОТОБРАЖАЕТ дерево BOM + фото-слайдер + PDF-ссылку. // // Контракт props: // sku — обязательный, ключ window.SPECIFICATIONS / window.PRODUCTS. // onClose — close-callback. // onNavigate — опционально; если spec нет, кнопка «Создать в Настройках» // вызовет onNavigate('settings'). // // Источник данных: spec = window.SPECIFICATIONS[sku]. Поддерживаются ДВА // формата (агент E переводит на расширенный): // Старый: Array (текущее состояние, дерево BOM прямо в значении). // Новый : { sku, name, model, pathLabel, bom: Array, photos: [], pdfUrl?: string }. // _normalizeSpec() приводит оба к единому объекту с полем bom: Array. // ============================================================================= // Hard-coded PDF для Гранты (единственная фара с PDF-спекой на момент // production-старта). Когда агент E нальёт pdfUrl в spec — этот fallback // перестанет использоваться. const _SPEC_PDF_FALLBACK = { 'GRN-L-H4': 'Блок_фара_Гранта_с_лампой_L1.pdf', 'GRN-R-H4': 'Блок_фара_Гранта_с_лампой_L1.pdf', }; function _normalizeSpec(sku) { if (!sku) return null; const raw = (window.SPECIFICATIONS || {})[sku]; const product = (window.PRODUCTS || []).find(p => p.sku === sku) || null; const fallbackPdf = _SPEC_PDF_FALLBACK[sku] || null; // Старый формат: Array if (Array.isArray(raw)) { if (raw.length === 0) return null; return { sku, name: product ? product.name : sku, model: product ? (product.family || '') : '', pathLabel: null, bom: raw, photos: [], pdfUrl: fallbackPdf, }; } // Новый формат: Object if (raw && typeof raw === 'object') { if (!Array.isArray(raw.bom) || raw.bom.length === 0) return null; return { sku: raw.sku || sku, name: raw.name || (product ? product.name : sku), model: raw.model || (product ? (product.family || '') : ''), pathLabel: raw.pathLabel || null, bom: raw.bom, photos: Array.isArray(raw.photos) ? raw.photos : [], pdfUrl: raw.pdfUrl || fallbackPdf, }; } return null; } function SpecPhotoSlider({ photos }) { const [idx, setIdx] = useState(0); if (!photos || photos.length === 0) return null; const cur = photos[idx] || photos[0]; const url = cur && (cur.url || cur); return (
{ e.currentTarget.style.opacity = '0.2'; }}/> {photos.length > 1 && ( <>
{idx + 1} / {photos.length}
)}
); } // Read-only вариант BomTreeNode. Рендерит дерево без кнопок edit/add/remove. // Раскрытие — локальный state в SpecViewerModal. // Найти SKU полуфабриката (W2) для BOM-строки. Маппинг: row.n (BOM-номер) ↔ // NOMENCLATURE.bomN. Возвращает null если это не ПФ или мэппинга нет // (тогда «Запуск в производство» не показываем). function _pfSkuForBomRow(row) { if (!row || row.type !== 'internal') return null; const nom = window.NOMENCLATURE || []; const found = nom.find(n => n && n.bomN === row.n && (n.tier === 'W2' || n.warehouseId === 2)); return found ? found.sku : null; } function SpecBomNode({ row, depth, expanded, onToggle, path, onLaunchPf }) { const isInternal = row.type === 'internal'; const hasChildren = isInternal && Array.isArray(row.children) && row.children.length > 0; const isExpanded = expanded.has(path); const pad = depth * 18; const pfSku = isInternal ? _pfSkuForBomRow(row) : null; return ( <>
0 ? 'color-mix(in oklch, var(--accent) 3%, var(--bg-panel))' : 'transparent', }}>
{hasChildren && ( )}
{row.n}
{Array.isArray(row.photos) && row.photos.length > 0 && ( { e.currentTarget.style.display = 'none'; }}/> )} {row.name} {row.invId && ( ({row.invId.replace(/^INV-/, '')}) )}
{isInternal ? 'ПФ' : row.type === 'raw' ? 'сырьё' : 'закуп'}
{row.qty}
{row.unit || 'шт'}
{row.material ? {row.material} : }
{row.origin || '—'}
{pfSku && onLaunchPf && ( )}
{isExpanded && hasChildren && row.children.map((c, i) => ( ))} ); } function SpecViewerModal({ sku, onClose, onNavigate, onLaunchPf }) { const spec = useMemo(() => _normalizeSpec(sku), [sku]); const [expanded, setExpanded] = useState(() => { // По умолчанию раскрыть все ПФ-узлы первого уровня — пользователь сразу // видит из чего состоит каждый ПФ. Глубже — свернуто. const s = new Set(); if (spec && Array.isArray(spec.bom)) { spec.bom.forEach((r, i) => { if (r && r.type === 'internal' && Array.isArray(r.children) && r.children.length > 0) { s.add(String(i)); } }); } return s; }); const toggle = useCallback((path) => { setExpanded(prev => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }, []); // ESC закрывает useEffect(() => { const h = (e) => { if (e.key === 'Escape') onClose && onClose(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [onClose]); const product = (window.PRODUCTS || []).find(p => p.sku === sku) || null; const title = spec ? spec.name : (product ? product.name : sku); const subtitle = spec ? (spec.model || product?.family || '') + (spec.pathLabel ? ' · ' + spec.pathLabel : '') : (product ? (product.family || '') : ''); return (
e.stopPropagation()}>
Спецификация
{title}
{subtitle && (
{subtitle}
)}
{!spec && (
📋
Спецификация не создана
Для {title} ({sku}) ещё нет BOM-дерева. Создайте её в Настройках → Спецификации, добавив узлы-ПФ и детали.
)} {spec && ( <> {spec.photos && spec.photos.length > 0 && ( )}
BOM · {_countSpecLines(spec.bom)} позиций ПФ-узлов: {spec.bom.filter(r => r && r.type === 'internal').length}
{/* header — добавлен столбец под кнопку «Запуск в производство» для ПФ */}
Название
Тип
Кол-во
Ед.
Мат.
Страна
🏭
{spec.bom.map((r, i) => ( ))}
)}
{spec ? `SKU ${spec.sku}` : ''}
{spec && spec.pdfUrl && ( 📥 Скачать PDF )}
); } function _countSpecLines(tree) { if (!Array.isArray(tree)) return 0; let n = 0; const walk = (rows) => { for (const r of rows) { if (!r) continue; n++; if (Array.isArray(r.children)) walk(r.children); } }; walk(tree); return n; } // ============================================================================ // Корректировка позиции склада (кнопка «Корректировка» в карточке позиции). // // Решение по типу позиции: // • готовая фара (есть в PRODUCTS) ИЛИ составной ПФ (isSubassembly) ИЛИ уже // есть спецификация с BOM → SpecEditorModal — полное редактируемое // дерево спецификации (как в Настройках), правится прямо со склада. // • простая позиция (деталь/сырьё без спеки) → StockAdjustModal — // развесовка/инфо + правка остатка (проводкой движения склада). // ============================================================================ function _itemHasEditableSpec(item) { if (!item || !item.sku) return false; const sku = item.sku; const isProduct = (window.PRODUCTS || []).some(p => p && p.sku === sku); const isSubassembly = !!item.isSubassembly; const spec = (typeof _normalizeSpec === 'function') ? _normalizeSpec(sku) : null; return isProduct || isSubassembly || !!spec; } function CorrField({ label, value }) { return (
{label}
{value}
); } // Простая позиция: вся информация (развесовка) + корректировка остатка. // Правку остатка проводим дельтой через тот же канал, что Цех/перенос // (window._applyStockEditDelta → API.stockMovement), иначе следующий SSE- // снапшот затрёт прямую запись stock. Правка — под ролью manager. function StockAdjustModal({ item, onClose }) { const { push: toast } = useToast(); const role = useCurrentRole(); const canWrite = (ROLE_ORDER[role] || 0) >= (ROLE_ORDER['manager'] || 0); const unit = item.unit || 'шт'; const cur = Number(item.stock ?? 0); const [val, setVal] = useState(() => String(cur)); useEffect(() => { const h = (e) => { if (e.key === 'Escape') onClose && onClose(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [onClose]); const next = Number(val); const delta = Number.isFinite(next) ? next - cur : 0; const wh = (window.WAREHOUSES || []).find(w => w.id === item.warehouseId); const weightG = (item.weight != null && Number(item.weight) > 0) ? Number(item.weight) : null; const totalMassG = (weightG != null) ? weightG * cur : null; const fmtKg = window.fmtKg || (g => (Number(g) / 1000)); const bump = (d) => setVal(v => { const n = Number(v); return String((Number.isFinite(n) ? n : cur) + d); }); const apply = () => { if (!Number.isFinite(next)) { toast('Введите число'); return; } if (next < 0) { toast('Остаток не может быть отрицательным'); return; } if (!item.id) { toast('Нет id позиции — правка остатка недоступна'); return; } const moved = window._applyStockEditDelta && window._applyStockEditDelta(item.id, next, cur, unit); if (moved) toast(`Остаток «${item.name}»: ${next.toLocaleString('ru')} ${unit}`); else toast('Остаток без изменений'); onClose && onClose(); }; return (
e.stopPropagation()}>
Корректировка позиции
{item.name}
{item.sku || ('#' + item.id)}
{/* развесовка / информация о позиции */}
{fmtKg(weightG)} кг : 'не указан'}/> {fmtKg(totalMassG)} кг : '—'}/> {item.supplier && } {Number(item.reserved) > 0 && }
{canWrite ? (
Корректировка остатка
Текущий: {cur.toLocaleString('ru')} {unit}
setVal(e.target.value)} inputMode="decimal" style={{flex: 1, textAlign: 'center', fontSize: 17, fontWeight: 600}}/>
0 ? 'var(--green)' : delta < 0 ? 'var(--red)' : 'var(--fg-muted)'}}> {delta === 0 ? 'без изменений' : `${delta > 0 ? '+' : ''}${delta.toLocaleString('ru')} ${unit}`}
) : (
Текущий остаток: {cur.toLocaleString('ru')} {unit}. Правка остатка доступна роли «менеджер» и выше.
)}
{canWrite && ( )}
); } // Кнопка «Корректировка» + её модалка. Вставляется в карточку позиции // (ItemPopover, ItemCardContent) вместо мёртвой кнопки. Модалка рендерится // порталом в body — поверх drawer/floating-окна, без обрезания overflow'ом. function ItemCorrectionLauncher({ item }) { const [mode, setMode] = useState(null); // 'spec' | 'stock' | null const open = () => setMode(_itemHasEditableSpec(item) ? 'spec' : 'stock'); const close = () => setMode(null); const portal = (node) => (window.ReactDOM && window.ReactDOM.createPortal && node) ? window.ReactDOM.createPortal(node, document.body) : node; let modal = null; if (mode === 'spec') { modal = window.SpecEditorModal ? : (window.SpecViewerModal ? : null); } else if (mode === 'stock') { modal = ; } return ( <> {modal && portal(modal)} ); } // ============================================================================ // Перенос позиции по иерархии склада (кнопка ⇄ в правом верхнем углу плитки). // // Физический перенос меняет НА BACKEND warehouseId + groupId позиции через // window.API.updateItem (PATCH /api/taxonomy/item/:id — персист + SSE-бродкаст), // + оптимистичное обновление window.INVENTORY_FULL. Ссылки на позицию в спеках/ // BOM/Цехе идут по артикулу (sku/invId) — они стабильны и при переносе НЕ // ломаются; производственный маршрут (ТПА→…→Склад№N) — это план цеха, не место // хранения, его не трогаем. Подподгруппа (3-й уровень) выводится из имени, не // отдельное поле → перенос идёт на уровень склад + подгруппа. // ============================================================================ function _moveDestTree() { const whs = window.WAREHOUSES || []; const byWh = { 1: window.GROUPS_W1 || [], 2: window.GROUPS_W2 || [], 3: window.GROUPS_W3 || [] }; return whs.map(w => ({ warehouseId: w.id, name: w.name, groups: (byWh[w.id] || []).map(g => ({ groupId: g.id, name: g.name, subsubs: (typeof window.subsubsForGroup === 'function' ? (window.subsubsForGroup(g.id) || []) : []), })), })); } function _appToast(msg, kind) { try { window.dispatchEvent(new CustomEvent('app-toast', { detail: { msg, kind: kind || 'info' } })); } catch (_) {} } // Физический перенос позиции. target = { warehouseId, groupId }. function _performMovePosition(item, target) { if (!item || !item.id || !target) return false; const sameWh = Number(item.warehouseId) === Number(target.warehouseId); const sameGrp = item.groupId === target.groupId; if (sameWh && sameGrp) return false; // no-op // Оптимистично двигаем в локальной модели (по id ИЛИ sku — что найдётся). const pools = [window.INVENTORY_FULL, window.NOMENCLATURE]; for (const pool of pools) { if (!Array.isArray(pool)) continue; const inv = pool.find(i => i && (i.id === item.id || (item.sku && i.sku === item.sku))); if (inv) { inv.warehouseId = target.warehouseId; inv.groupId = target.groupId; } } // Персист на backend (+ SSE-бродкаст вернёт авторитетный снапшот). if (window.API && typeof window.API.updateItem === 'function') { window.API.updateItem(item.id, { warehouseId: target.warehouseId, groupId: target.groupId }); } try { window.dispatchEvent(new Event('taxonomy-changed')); } catch (_) {} try { window.dispatchEvent(new Event('reserve-changed')); } catch (_) {} return true; } // Модалка-пикер: дерево Склад → Подгруппа (подподгруппы показаны контекстом). function WarehouseHierarchyPicker({ item, onCancel, onConfirm }) { const tree = useMemo(() => _moveDestTree(), []); const curWh = Number(item.warehouseId); const curGid = item.groupId; const [expanded, setExpanded] = useState(() => new Set([curWh])); const [sel, setSel] = useState(null); // { warehouseId, groupId, name, whName } useEffect(() => { const h = (e) => { if (e.key === 'Escape') onCancel && onCancel(); }; window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h); }, [onCancel]); const toggleWh = (wid) => setExpanded(s => { const n = new Set(s); n.has(wid) ? n.delete(wid) : n.add(wid); return n; }); const isCurrent = (wid, gid) => Number(wid) === curWh && gid === curGid; const canConfirm = sel && !isCurrent(sel.warehouseId, sel.groupId); return (
e.stopPropagation()}>
Переместить позицию
{item.name}
{item.sku || ('#' + item.id)} · сейчас здесь ↓
{tree.map(w => { const open = expanded.has(w.warehouseId); return (
{open && (
{w.groups.length === 0 &&
нет подгрупп
} {w.groups.map(g => { const cur = isCurrent(w.warehouseId, g.groupId); const picked = sel && sel.warehouseId === w.warehouseId && sel.groupId === g.groupId; return (
{g.subsubs.length > 0 && (
{g.subsubs.slice(0, 8).map(s => · {s.key})}
)}
); })}
)}
); })}
{sel ? <>→ {sel.whName} · {sel.name} : 'Выберите подгруппу-назначение'}
); } // Угловая кнопка ⇄ на плитке позиции + пикер (портал в body). Под ролью manager. function MovePositionLauncher({ item }) { const [open, setOpen] = useState(false); const role = useCurrentRole(); const canWrite = (ROLE_ORDER[role] || 0) >= (ROLE_ORDER['manager'] || 0); if (!canWrite || !item || !item.id) return null; const portal = (node) => (window.ReactDOM && window.ReactDOM.createPortal) ? window.ReactDOM.createPortal(node, document.body) : node; const confirm = (target) => { const moved = _performMovePosition(item, target); if (moved) _appToast(`«${item.name}» → ${target.whName} · ${target.name}`, 'success'); setOpen(false); }; return ( <> {open && portal( setOpen(false)} onConfirm={confirm} />)} ); } Object.assign(window, { Icon, Logo, ToastProvider, useToast, Drawer, ConfirmModal, useConfirm, VBar, HBar, SectionHeader, MatChip, OriginFlag, MATERIAL_COLORS, ToastCtx, BomChecklist, // FX-1 TileIcon, IconTile, IconGrid, WarehouseStripe, ItemPopover, tileStyleForItem, TILE_STYLE_FOR_GROUP, // Корректировка позиции склада (кнопка в карточке позиции) ItemCorrectionLauncher, StockAdjustModal, // Перенос позиции по иерархии склада (угловая кнопка ⇄ на плитке) MovePositionLauncher, WarehouseHierarchyPicker, // FX-1 multi-window ItemCardContent, FloatingItemCard, FloatingWindowsLayer, useFloatingWindows, // FX-RESERVE-V2 ReserveBlock, useReserveTick, useTaxonomyTick, formatQty, // S4 — Цех/резервы для витрины (read-only проекции) useCechTick, fgOrdersInProduction, reservationsByProductForGroup, // FX-SUPPLIERS StatusChip, // Phase 2 — sync indicator + role gating SyncIndicator, useApiSyncStatus, useCurrentRole, CanWrite, // Phase 3 — spec viewer (shared между dashboard и launch_flow) SpecViewerModal, // Переиспользуемый переключатель «иконки ↔ список» ViewToggle, useViewMode, // «Вес примерный» — маркер + бейдж (требует корректировки массы) WtApproxBadge, _isWeightApprox, _weightIsEmpty, _approxWeightCountInItems, });