).
//
//
//
//
//
//
//
//
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}
итого на складе
{fmt(stock)} {unit}
{active && reservByProduct.length > 0 && (
{reservByProduct.map((g, i) => (
1 ? g.count + ' заявок на это изделие' : g.reqId}>
{g.count > 1 ? '×' + g.count : g.reqId}
{g.productName}{g.productQty ? ' · ' + g.productQty.toLocaleString('ru') + ' шт' : ''}
{fmt(g.amount)}
))}
)}
{!active && (
Сейчас не зарезервировано. Создайте заявку и переведите в MRP — сырьё уйдёт в резерв.
)}
);
}
// Совместимость со старыми ссылками — 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 (
{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()}>
{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}` : ''}
);
}
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 (
);
}
// Простая позиция: вся информация (развесовка) + корректировка остатка.
// Правку остатка проводим дельтой через тот же канал, что Цех/перенос
// (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,
});