// =============================================
// Recycle Object — План производства
// =============================================
const ProductionPlan = {
PRODUCTION_STATUSES: ['production_casting', 'production_printing', 'production_hardware', 'production_packaging', 'in_production', 'delivery'],
allRows: [],
filteredRows: [],
priority: [],
_state: { order_ids: [] },
_projectHardwareState: { checks: {} },
async load() {
const [orders, state, projectHwState] = await Promise.all([
loadOrders(),
loadProductionPlanState(),
loadProjectHardwareState(),
]);
this._state = state || { order_ids: [] };
this._projectHardwareState = projectHwState || { checks: {} };
if (!this._projectHardwareState.checks || typeof this._projectHardwareState.checks !== 'object') {
this._projectHardwareState.checks = {};
}
this.priority = Array.isArray(this._state.order_ids) ? this._state.order_ids.map(x => Number(x)) : [];
const productionOrders = (orders || []).filter(o => this.PRODUCTION_STATUSES.includes(o.status));
const details = await Promise.all(productionOrders.map(o => loadOrder(o.id).catch(() => null)));
const byId = new Map(details.filter(Boolean).map(d => [Number(d.order.id), d]));
this.allRows = productionOrders.map(order => {
const detail = byId.get(Number(order.id));
return this._buildRow(order, detail?.items || []);
});
this._syncPriority();
this.renderFilters();
this.applyFilters();
},
renderFilters() {
const managerEl = document.getElementById('pp-filter-manager');
if (!managerEl) return;
const current = managerEl.value;
const managers = [...new Set(this.allRows.map(r => r.manager).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'ru'));
managerEl.innerHTML = 'Все ' + managers.map(m => `${this.esc(m)} `).join('');
managerEl.value = managers.includes(current) ? current : '';
},
applyFilters() {
const q = (document.getElementById('pp-search')?.value || '').toLowerCase().trim();
const manager = document.getElementById('pp-filter-manager')?.value || '';
const stage = document.getElementById('pp-filter-stage')?.value || '';
let rows = this.allRows.slice();
if (manager) rows = rows.filter(r => r.manager === manager);
if (stage) rows = rows.filter(r => r.status === stage);
if (q) {
rows = rows.filter(r => [
r.orderName,
r.manager,
r.notes,
r.colorsPlain,
r.hwPlain,
r.pkgPlain,
r.productsPlain,
].join(' ').toLowerCase().includes(q));
}
this.filteredRows = this._sortRows(rows);
this.renderStats();
this.renderTable();
this.renderMobile();
},
renderStats() {
const cntEl = document.getElementById('pp-stat-count');
const qtyEl = document.getElementById('pp-stat-qty');
const lateEl = document.getElementById('pp-stat-late');
if (!cntEl || !qtyEl || !lateEl) return;
const now = new Date();
let qty = 0;
let late = 0;
this.filteredRows.forEach(r => {
qty += r.qtyTotal;
if (r.deadlineTs && r.deadlineTs < now.getTime()) late += 1;
});
cntEl.textContent = String(this.filteredRows.length);
qtyEl.textContent = String(qty);
lateEl.textContent = String(late);
},
renderTable() {
const el = document.getElementById('pp-table-wrap');
if (!el) return;
if (!this.filteredRows.length) {
el.innerHTML = '
Нет заказов в производстве
';
return;
}
let html = `
Приоритет
Заказ
Менеджер
Старт / дедлайн
Количество
Цвет
Фурнитура / упаковка
Заметки
`;
this.filteredRows.forEach((r) => {
const late = r.deadlineTs && r.deadlineTs < Date.now();
html += `
↑
↓
${this.esc(r.orderName)}
${this.esc(r.statusLabel)} · ${this.esc(r.productsPlain || 'Без изделий')}
${this._renderProgressBar(r)}
${this.esc(r.manager || '—')}
Старт: ${this.esc(r.startLabel)}
Дедлайн: ${this.esc(r.deadlineLabel)}
${r.qtyTotal}
${this._renderColorCell(r)}
${this._renderSupplyCell(r)}
${this.esc(r.notes || '—')}
Открыть
`;
});
html += '
';
el.innerHTML = html;
},
renderMobile() {
const el = document.getElementById('pp-mobile-list');
if (!el) return;
if (!this.filteredRows.length) {
el.innerHTML = '';
return;
}
el.innerHTML = this.filteredRows.map(r => {
const late = r.deadlineTs && r.deadlineTs < Date.now();
return `
${this.esc(r.orderName)}
${this.esc(r.statusLabel)} · ${r.qtyTotal} шт
↑
↓
Менеджер: ${this.esc(r.manager || '—')}
Старт: ${this.esc(r.startLabel)}
Дедлайн: ${this.esc(r.deadlineLabel)}
${this._renderProgressBar(r)}
${this._renderColorCell(r)}
${this._renderSupplyCell(r)}
Заметки: ${this.esc(r.notes || '—')}
Открыть заказ
`;
}).join('');
},
async moveUp(orderId) {
this._syncPriority();
const i = this.priority.indexOf(Number(orderId));
if (i <= 0) return;
[this.priority[i - 1], this.priority[i]] = [this.priority[i], this.priority[i - 1]];
await this._savePriority();
this.applyFilters();
},
async moveDown(orderId) {
this._syncPriority();
const i = this.priority.indexOf(Number(orderId));
if (i < 0 || i >= this.priority.length - 1) return;
[this.priority[i], this.priority[i + 1]] = [this.priority[i + 1], this.priority[i]];
await this._savePriority();
this.applyFilters();
},
_sortRows(rows) {
const pos = new Map(this.priority.map((id, i) => [Number(id), i]));
return rows.slice().sort((a, b) => {
const pa = pos.has(a.id) ? pos.get(a.id) : 999999;
const pb = pos.has(b.id) ? pos.get(b.id) : 999999;
if (pa !== pb) return pa - pb;
if (a.deadlineTs && b.deadlineTs) return a.deadlineTs - b.deadlineTs;
if (a.deadlineTs) return -1;
if (b.deadlineTs) return 1;
return (b.createdTs || 0) - (a.createdTs || 0);
});
},
_syncPriority() {
const ids = this.allRows.map(r => Number(r.id));
const existing = new Set(ids);
this.priority = this.priority.filter(id => existing.has(Number(id)));
const missingRows = this.allRows
.filter(r => !this.priority.includes(Number(r.id)))
.sort((a, b) => {
if (a.deadlineTs && b.deadlineTs) return a.deadlineTs - b.deadlineTs;
if (a.deadlineTs) return -1;
if (b.deadlineTs) return 1;
return (a.createdTs || 0) - (b.createdTs || 0);
});
this.priority.push(...missingRows.map(r => Number(r.id)));
},
async _savePriority() {
this._state = {
order_ids: this.priority.slice(),
updated_at: new Date().toISOString(),
updated_by: App.getCurrentEmployeeName(),
};
await saveProductionPlanState(this._state);
},
// Extract color group from product name brackets, e.g.
// "Бланк треугольник [Желтый]" → "Желтый"
_extractColorGroup(productName) {
const match = (productName || '').match(/\[([^\]]+)\]/);
return match ? match[1] : '';
},
_buildRow(order, items) {
const productItems = (items || []).filter(i => !i.item_type || i.item_type === 'product');
const hwItems = (items || []).filter(i => i.item_type === 'hardware');
const pkgItems = (items || []).filter(i => i.item_type === 'packaging');
const qtyTotal = productItems.reduce((s, i) => s + (parseFloat(i.quantity) || 0), 0);
const productsPlain = productItems.map(i => i.product_name).filter(Boolean).join(', ');
// Build product-index → colorGroup map for hardware/packaging linking
const productColorGroupByIndex = {};
productItems.forEach((item, idx) => {
productColorGroupByIndex[idx] = this._extractColorGroup(item.product_name);
});
const colorLines = [];
const attachments = [];
const printingLines = [];
const hasCustomMold = productItems.some(i => !i.is_blank_mold);
const hasPrinting = productItems.some(i => {
let p = i.printings;
if (typeof p === 'string') { try { p = JSON.parse(p); } catch(e) { p = []; } }
return Array.isArray(p) && p.some(pr => (parseFloat(pr.qty) || 0) > 0 || (parseFloat(pr.price) || 0) > 0 || pr.name);
});
productItems.forEach(item => {
const colors = this._extractColorNames(item);
const qty = parseFloat(item.quantity) || 0;
const setName = item.marketplace_set_name || '';
const colorGroup = this._extractColorGroup(item.product_name);
if (colors.length) {
colorLines.push({ text: `${item.product_name || 'Изделие'}: ${colors.join(' + ')}`, qty, name: item.product_name || 'Изделие', colors, setName, colorGroup });
} else {
colorLines.push({ text: item.product_name || 'Изделие', qty, name: item.product_name || 'Изделие', colors: [], setName, colorGroup });
}
attachments.push(...this._extractAttachments(item));
const printings = this._extractPrintings(item);
if (printings.length) printingLines.push(`${item.product_name || 'Изделие'}: ${printings.join(', ')}`);
});
// Products grouped by marketplace set name
const productsBySet = new Map();
colorLines.forEach(cl => {
const sn = cl.setName || '';
if (!productsBySet.has(sn)) productsBySet.set(sn, []);
productsBySet.get(sn).push(cl);
});
// Products grouped by color group (bracket content in product name)
const productsByColorGroup = new Map();
colorLines.forEach(cl => {
const cg = cl.colorGroup || '';
if (!productsByColorGroup.has(cg)) productsByColorGroup.set(cg, []);
productsByColorGroup.get(cg).push(cl);
});
// HW/PKG grouped by marketplace set name
const hwBySet = new Map();
hwItems.forEach(item => {
const setName = item.marketplace_set_name || '';
if (!hwBySet.has(setName)) hwBySet.set(setName, []);
hwBySet.get(setName).push(item);
});
const pkgBySet = new Map();
pkgItems.forEach(item => {
const setName = item.marketplace_set_name || '';
if (!pkgBySet.has(setName)) pkgBySet.set(setName, []);
pkgBySet.get(setName).push(item);
});
// HW/PKG grouped by parent product's color group
const hwByColorGroup = new Map();
hwItems.forEach(item => {
const parentIdx = item.hardware_parent_item_index;
const cg = (parentIdx !== null && parentIdx !== undefined)
? (productColorGroupByIndex[parentIdx] || '')
: '';
if (!hwByColorGroup.has(cg)) hwByColorGroup.set(cg, []);
hwByColorGroup.get(cg).push(item);
});
const pkgByColorGroup = new Map();
pkgItems.forEach(item => {
const parentIdx = item.packaging_parent_item_index;
const cg = (parentIdx !== null && parentIdx !== undefined)
? (productColorGroupByIndex[parentIdx] || '')
: '';
if (!pkgByColorGroup.has(cg)) pkgByColorGroup.set(cg, []);
pkgByColorGroup.get(cg).push(item);
});
const hwLines = hwItems.map(i => `${i.product_name || 'Фурнитура'} × ${(parseFloat(i.quantity) || 0)}`);
const pkgLines = pkgItems.map(i => `${i.product_name || 'Упаковка'} × ${(parseFloat(i.quantity) || 0)}`);
const hwPlain = hwLines.join(', ');
const pkgPlain = pkgLines.join(', ');
const hwDemands = this._collectWarehouseDemandFromOrderItems(hwItems);
const hwReady = hwDemands.filter(d => this._isHardwareLineReady(order.id, d.warehouse_item_id)).length;
const hwTotal = hwDemands.length;
const hardwareReadyLabelHtml = hwTotal === 0
? 'не требуется '
: (hwReady === hwTotal
? 'да, готово '
: `нет (${hwReady}/${hwTotal}) `);
const startIso = order.created_at || order.deadline_start || order.deadline || null;
const endIso = order.deadline_end || order.deadline || null;
const startLabel = startIso ? App.formatDate(startIso) : '—';
let deadlineLabel = '—';
if (endIso && startIso && endIso !== startIso) deadlineLabel = `${App.formatDate(startIso)} → ${App.formatDate(endIso)}`;
else if (endIso || startIso) deadlineLabel = App.formatDate(endIso || startIso);
return {
id: Number(order.id),
orderName: order.order_name || 'Заказ',
manager: order.manager_name || '',
notes: [order.notes || '', printingLines.length ? ('Печать: ' + printingLines.join(' · ')) : ''].filter(Boolean).join(' · '),
status: order.status,
statusLabel: App.statusLabel(order.status),
qtyTotal,
productsPlain,
hwLines,
pkgLines,
hwPlain,
pkgPlain,
hardwareReadyLabelHtml,
colorLines,
colorsPlain: colorLines.map(cl => cl.text).join(' · '),
productsBySet: Object.fromEntries(productsBySet),
productsByColorGroup: Object.fromEntries(productsByColorGroup),
hwItemsRaw: hwItems,
pkgItemsRaw: pkgItems,
hwBySet: Object.fromEntries(hwBySet),
pkgBySet: Object.fromEntries(pkgBySet),
hwByColorGroup: Object.fromEntries(hwByColorGroup),
pkgByColorGroup: Object.fromEntries(pkgByColorGroup),
attachments,
hasCustomMold,
hasPrinting,
startLabel,
deadlineLabel,
deadlineTs: endIso ? new Date(endIso).getTime() : (startIso ? new Date(startIso).getTime() : null),
createdTs: order.created_at ? new Date(order.created_at).getTime() : 0,
};
},
_collectWarehouseDemandFromOrderItems(hwItems) {
const grouped = new Map();
(hwItems || []).forEach(item => {
const src = (item.source || item.hardware_source || '').toLowerCase();
if (src !== 'warehouse') return;
const itemId = Number(item.warehouse_item_id || item.hardware_warehouse_item_id || 0);
const qty = parseFloat(item.quantity || item.qty || 0) || 0;
if (!itemId || qty <= 0) return;
grouped.set(itemId, (grouped.get(itemId) || 0) + qty);
});
return Array.from(grouped.entries()).map(([warehouse_item_id, qty]) => ({ warehouse_item_id, qty }));
},
_isHardwareLineReady(orderId, warehouseItemId) {
const checks = (this._projectHardwareState && this._projectHardwareState.checks) || {};
return !!checks[`${Number(orderId) || 0}:${Number(warehouseItemId) || 0}`];
},
_getProgressStages(row) {
const stages = [
{ key: 'casting', label: 'Выливание' },
{ key: 'mold', label: 'Форма' },
{ key: 'trim', label: 'Обрезание/линейка' },
{ key: 'printing', label: 'Печать' },
{ key: 'assembly', label: 'Сборка' },
{ key: 'packaging', label: 'Упаковка' },
{ key: 'delivery', label: 'Доставка' },
];
const currentByStatus = {
production_casting: 'casting',
in_production: 'trim',
production_printing: 'printing',
production_hardware: 'assembly',
production_packaging: 'packaging',
delivery: 'delivery',
completed: 'delivery',
};
const currentKey = currentByStatus[row.status] || 'casting';
const currentIdx = stages.findIndex(s => s.key === currentKey);
return stages.map((s, idx) => {
if (s.key === 'mold' && !row.hasCustomMold) {
return { ...s, state: 'skipped' };
}
if (s.key === 'printing' && !row.hasPrinting) {
return { ...s, state: 'skipped' };
}
if (idx < currentIdx) return { ...s, state: 'done' };
if (idx === currentIdx) return { ...s, state: 'active' };
return { ...s, state: 'todo' };
});
},
_renderProgressBar(row) {
const stages = this._getProgressStages(row);
const stageToStatus = {
casting: 'production_casting',
mold: 'in_production',
trim: 'in_production',
printing: 'production_printing',
assembly: 'production_hardware',
packaging: 'production_packaging',
delivery: 'delivery',
};
const legend = stages.map(s => {
const dotColor =
s.state === 'done' ? '#16a34a' :
s.state === 'active' ? '#2563eb' :
s.state === 'skipped' ? '#9ca3af' :
'#d1d5db';
const textColor =
s.state === 'done' ? '#166534' :
s.state === 'active' ? '#1d4ed8' :
s.state === 'skipped' ? '#6b7280' :
'#6b7280';
const weight = s.state === 'active' ? 700 : 500;
const mark = s.state === 'done' ? '✓' : (s.state === 'skipped' ? '—' : '•');
const targetStatus = stageToStatus[s.key];
const clickable = s.state !== 'skipped' && targetStatus ? 'cursor:pointer;' : '';
const onClick = s.state !== 'skipped' && targetStatus
? `onclick="ProductionPlan.setOrderStageStatus(${row.id}, '${targetStatus}')"`
: '';
return `
${mark}
${this.esc(s.label)}
`;
}).join('› ');
const segments = stages.map((s, idx) => {
const bg =
s.state === 'done' ? '#22c55e' :
s.state === 'active' ? '#3b82f6' :
s.state === 'skipped' ? '#9ca3af' :
'#e5e7eb';
const borderRadius =
idx === 0 && idx === stages.length - 1 ? '6px' :
idx === 0 ? '6px 0 0 6px' :
idx === stages.length - 1 ? '0 6px 6px 0' :
'0';
return `
`;
}).join('');
const nextStatusByCurrent = {
production_casting: 'in_production',
in_production: 'production_printing',
production_printing: 'production_hardware',
production_hardware: 'production_packaging',
production_packaging: 'delivery',
delivery: 'completed',
};
const nextStatus = nextStatusByCurrent[row.status] || '';
const nextLabel = nextStatus ? (App.statusLabel(nextStatus) || nextStatus) : '';
const nextBtn = nextStatus
? `Следующий этап → ${this.esc(nextLabel)} `
: '';
return `
${legend}
${segments}
${nextBtn}
`;
},
async setOrderStageStatus(orderId, newStatus) {
const row = this.allRows.find(r => Number(r.id) === Number(orderId));
if (!row || !newStatus || row.status === newStatus) return;
await this._changeOrderStatus(row, newStatus);
},
async goNextStage(orderId) {
const row = this.allRows.find(r => Number(r.id) === Number(orderId));
if (!row) return;
const nextStatusByCurrent = {
production_casting: 'in_production',
in_production: 'production_printing',
production_printing: 'production_hardware',
production_hardware: 'production_packaging',
production_packaging: 'delivery',
delivery: 'completed',
};
const next = nextStatusByCurrent[row.status];
if (!next) return;
await this._changeOrderStatus(row, next);
},
async _changeOrderStatus(row, newStatus) {
const oldStatus = row.status;
const orderId = Number(row.id);
const managerName = App.getCurrentEmployeeName() || 'Неизвестный';
if (typeof Orders !== 'undefined' && Orders && typeof Orders._ensureStatusTransitionAllowed === 'function') {
const guard = await Orders._ensureStatusTransitionAllowed(orderId, newStatus);
if (!guard.ok) return false;
}
await updateOrderStatus(orderId, newStatus);
if (typeof Orders !== 'undefined' && Orders && typeof Orders._syncWarehouseByStatus === 'function') {
try {
await Orders._syncWarehouseByStatus(orderId, oldStatus, newStatus, row.orderName, managerName);
} catch (e) {
console.error('ProductionPlan syncWarehouseByStatus failed:', e);
}
}
if (typeof Orders !== 'undefined' && Orders && typeof Orders.addChangeRecord === 'function') {
try {
await Orders.addChangeRecord(orderId, {
field: 'status',
old_value: App.statusLabel(oldStatus),
new_value: App.statusLabel(newStatus),
manager: managerName,
});
} catch (e) {
console.error('ProductionPlan addChangeRecord failed:', e);
}
}
row.status = newStatus;
App.toast(`Статус: ${App.statusLabel(newStatus)}`);
await this.load();
return true;
},
_extractColorNames(item) {
let colors = item.colors;
if (typeof colors === 'string') {
try { colors = JSON.parse(colors); } catch (e) { colors = []; }
}
if (!Array.isArray(colors)) colors = [];
const names = colors.map(c => {
if (typeof c === 'string') return c;
const num = c?.number || '';
const name = c?.name || '';
return num ? `${num} ${name}`.trim() : name;
}).filter(Boolean);
if (!names.length && item.color_name) names.push(item.color_name);
return names;
},
_extractAttachments(item) {
return normalizeColorAttachments(item)
.filter(att => !!att.data_url)
.map(att => ({
name: att.name || 'Файл',
type: att.type || '',
data_url: att.data_url,
}));
},
_extractPrintings(item) {
let printings = item.printings;
if (typeof printings === 'string') {
try { printings = JSON.parse(printings); } catch (e) { printings = []; }
}
if (!Array.isArray(printings)) return [];
return printings
.filter(p => (parseFloat(p.qty) || 0) > 0 || (parseFloat(p.price) || 0) > 0 || p.name)
.map(p => p.name || 'нанесение');
},
_renderColorCell(row) {
const hasColors = row.colorLines.length > 0;
if (!hasColors) {
return ``;
}
const renderProductLine = (cl) => {
const qtyLabel = cl.qty ? ` × ${cl.qty} шт ` : '';
const baseName = (cl.name || '').replace(/\s*\[.*?\]/g, '').trim();
const colorStr = cl.colors.length ? ' — ' + this.esc(cl.colors.join(' + ')) : '';
return `${this.esc(baseName)}${qtyLabel}${colorStr}
`;
};
// Check for color groups (bracket content like [Желтый], [Черно-белый])
const colorGroups = row.productsByColorGroup || {};
const cgKeys = Object.keys(colorGroups);
const hasColorGroups = cgKeys.length > 1 || (cgKeys.length === 1 && cgKeys[0] !== '');
// Check for set grouping
const productsBySet = row.productsBySet || {};
const setKeys = Object.keys(productsBySet);
const hasSetGroups = setKeys.length > 0 && !(setKeys.length === 1 && setKeys[0] === '');
let linesHtml = '';
if (hasColorGroups) {
// Primary grouping: by color group
if (hasSetGroups) {
// Within each set, sub-group by color group
for (const [setName, setItems] of Object.entries(productsBySet)) {
const byColor = new Map();
setItems.forEach(cl => {
const cg = cl.colorGroup || '';
if (!byColor.has(cg)) byColor.set(cg, []);
byColor.get(cg).push(cl);
});
let setHtml = '';
if (setName) {
setHtml += `📦 ${this.esc(setName)}:
`;
}
for (const [cg, items] of byColor) {
if (cg) {
setHtml += `🎨 ${this.esc(cg)}
`;
}
setHtml += `${items.map(cl => renderProductLine(cl)).join('')}
`;
}
linesHtml += `${setHtml}
`;
}
} else {
// No set names — just group by color group
for (const [cg, items] of Object.entries(colorGroups)) {
if (cg) {
linesHtml += `🎨 ${this.esc(cg)}
`;
}
linesHtml += `${items.map(cl => renderProductLine(cl)).join('')}
`;
}
}
} else if (hasSetGroups) {
// Only set grouping, no color groups
for (const [setName, items] of Object.entries(productsBySet)) {
const itemsHtml = items.map(cl => renderProductLine(cl)).join('');
if (setName) {
linesHtml += `📦 ${this.esc(setName)}:
${itemsHtml}
`;
} else {
linesHtml += itemsHtml;
}
}
} else {
// Flat list
linesHtml = `${row.colorLines.map(cl => renderProductLine(cl)).join('')}
`;
}
const imgs = row.attachments
.filter(att => (att.type || '').startsWith('image/'))
.map(att => ` `)
.join('');
const nonImg = row.attachments
.filter(att => !(att.type || '').startsWith('image/'))
.map(att => `📎 ${this.esc(att.name)} `)
.join('');
return `
Детали
${linesHtml}
${imgs ? `
${imgs}
` : ''}
${nonImg ? `
${nonImg}
` : ''}
`;
},
_renderSupplyCell(row) {
const hwByColorGroup = row.hwByColorGroup || {};
const pkgByColorGroup = row.pkgByColorGroup || {};
const hwCgKeys = Object.keys(hwByColorGroup);
const pkgCgKeys = Object.keys(pkgByColorGroup);
const hasHwColorGroups = hwCgKeys.length > 1 || (hwCgKeys.length === 1 && hwCgKeys[0] !== '');
const hasPkgColorGroups = pkgCgKeys.length > 1 || (pkgCgKeys.length === 1 && pkgCgKeys[0] !== '');
const hwBySet = row.hwBySet || {};
const pkgBySet = row.pkgBySet || {};
const hasSetGroups = (setObj) => {
const keys = Object.keys(setObj);
return keys.length > 0 && !(keys.length === 1 && keys[0] === '');
};
const renderItemLine = (i, label, skuField) => {
const name = i.product_name || label;
const qty = parseFloat(i.quantity) || 0;
const sku = i[skuField] || '';
const skuHtml = sku ? ` (${this.esc(sku)}) ` : '';
return `${this.esc(name)}${skuHtml} × ${qty}
`;
};
const renderByColor = (byColorGroup, fallbackBySet, fallbackItems, label, skuField, hasColorGrouping) => {
// Primary: group by color if available
if (hasColorGrouping) {
let html = '';
for (const [cg, items] of Object.entries(byColorGroup)) {
if (cg) {
html += `🎨 ${this.esc(cg)}
`;
}
html += items.map(i => renderItemLine(i, label, skuField)).join('');
}
return html || '—
';
}
// Fallback: group by set
if (hasSetGroups(fallbackBySet)) {
let html = '';
for (const [setName, items] of Object.entries(fallbackBySet)) {
const itemsHtml = items.map(i => renderItemLine(i, label, skuField)).join('');
if (setName && setName !== '') {
html += `📦 ${this.esc(setName)}:
${itemsHtml}`;
} else {
html += itemsHtml;
}
}
return html || '—
';
}
// Flat
return (fallbackItems || []).length
? `${fallbackItems.map(i => renderItemLine(i, label, skuField)).join('')}
`
: '—
';
};
const hw = renderByColor(hwByColorGroup, hwBySet, row.hwItemsRaw, 'Фурнитура', 'hardware_warehouse_sku', hasHwColorGroups);
const pkg = renderByColor(pkgByColorGroup, pkgBySet, row.pkgItemsRaw, 'Упаковка', 'packaging_warehouse_sku', hasPkgColorGroups);
return `
Готовность: ${row.hardwareReadyLabelHtml}
`;
},
esc(str) {
return String(str || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
},
// ==========================================
// Photo Lightbox
// ==========================================
showPhotoLightbox(linkEl) {
const img = linkEl.querySelector('img');
if (!img) return;
const src = img.src;
// Remove existing lightbox if any
const existing = document.getElementById('pp-photo-lightbox');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'pp-photo-lightbox';
overlay.className = 'pp-lightbox-overlay';
overlay.innerHTML = `
×
`;
// Close on overlay click or close button
overlay.addEventListener('click', (e) => {
if (e.target === overlay || e.target.classList.contains('pp-lightbox-close')) {
overlay.remove();
}
});
// Close on Escape key
const onKey = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', onKey);
}
};
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
},
};