// =============================================
// Recycle Object — Order Detail Page
// =============================================
const PAYMENT_STATUSES = [
{ key: 'not_sent', label: 'Не передано в оплату', color: 'gray' },
{ key: 'sent_to_payment', label: 'Передано в оплату', color: 'blue' },
{ key: 'paid_50', label: 'Оплачено 50%', color: 'orange' },
{ key: 'paid_100', label: 'Оплачено 100%', color: 'green' },
{ key: 'postpay_100', label: 'Постоплата 100%', color: 'green' },
{ key: 'special', label: 'Особые условия', color: 'red' },
];
const HARDWARE_STATUSES = [
{ key: 'discussion', label: 'Обсуждение', color: 'gray' },
{ key: 'from_stock', label: 'Из наличия', color: 'blue' },
{ key: 'ordered_waiting', label: 'Заказана, ждём', color: 'orange' },
{ key: 'arrived', label: 'Приехала', color: 'green' },
{ key: 'not_needed', label: 'Не нужна', color: 'gray' },
{ key: 'to_make', label: 'Сделать', color: 'yellow' },
];
// Plastic is always PP, print type is set at printing level — removed from UI
const OrderDetail = {
currentOrder: null,
currentItems: [],
currentFinancial: null,
currentTab: 'info',
// ==========================================
// LOAD
// ==========================================
async load(orderId) {
if (!orderId) { App.navigate('orders'); return; }
const data = await loadOrder(orderId);
if (!data) {
App.toast('Заказ не найден');
App.navigate('orders');
return;
}
this.currentOrder = data.order;
this.currentItems = data.items || [];
this.currentFinancial = this.buildLiveFinancialMeta();
this.currentTab = 'info';
this.renderHeader();
this.renderStats();
this.renderInfoTab();
this.renderProductionTab();
this.renderFilesTab();
this.renderItemsTab();
await this.renderHardwareTab();
await this.renderTasksTab();
this.renderChinaTab();
// Show first tab
this.switchTab('info');
if (data.repaired_duplicates) {
App.toast('Дубли позиций в заказе были автоматически исправлены');
}
},
// ==========================================
// TABS
// ==========================================
switchTab(tab) {
this.currentTab = tab;
document.querySelectorAll('.od-tab-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
document.querySelectorAll('.od-tab-content').forEach(c => {
c.style.display = c.id === 'od-tab-' + tab ? '' : 'none';
});
if (tab === 'hardware') {
void this.renderHardwareTab();
}
},
// ==========================================
// HEADER
// ==========================================
renderHeader() {
const o = this.currentOrder;
document.getElementById('od-title').textContent = o.order_name || 'Без названия';
const st = STATUS_OPTIONS.find(s => s.value === o.status);
const badge = document.getElementById('od-status-badge');
badge.textContent = st ? st.label : o.status;
badge.className = 'badge badge-' + this._statusColor(o.status);
},
// ==========================================
// STATS
// ==========================================
buildLiveFinancialMeta() {
if (typeof getOrderLiveCalculatorSnapshot !== 'function') {
return {
revenue: Number(this.currentOrder?.total_revenue_plan || 0),
marginPercent: Number(this.currentOrder?.margin_percent_plan || 0),
hours: Number(this.currentOrder?.total_hours_plan || 0),
};
}
try {
const snapshot = getOrderLiveCalculatorSnapshot(this.currentOrder || {}, this.currentItems || []);
return {
revenue: Number(snapshot?.revenue || 0),
marginPercent: Number(snapshot?.marginPercent || 0),
hours: Number(snapshot?.hours || 0),
};
} catch (e) {
console.warn('OrderDetail.buildLiveFinancialMeta fallback:', e);
return {
revenue: Number(this.currentOrder?.total_revenue_plan || 0),
marginPercent: Number(this.currentOrder?.margin_percent_plan || 0),
hours: Number(this.currentOrder?.total_hours_plan || 0),
};
}
},
renderStats() {
const o = this.currentOrder;
const ps = PAYMENT_STATUSES.find(s => s.key === o.payment_status) || PAYMENT_STATUSES[0];
this.currentFinancial = this.buildLiveFinancialMeta();
const revenue = Number(this.currentFinancial?.revenue || o.total_revenue_plan || 0);
const marginPercent = Number(this.currentFinancial?.marginPercent || o.margin_percent_plan || 0);
const hours = Number(this.currentFinancial?.hours || o.total_hours_plan || 0);
document.getElementById('od-stats').innerHTML = `
Выручка
${formatRub(revenue)}
Маржа
${formatPercent(marginPercent)}
Часы
${hours.toFixed(1)} ч
`;
},
// ==========================================
// INFO TAB
// ==========================================
renderInfoTab() {
const o = this.currentOrder;
const container = document.getElementById('od-tab-info');
container.innerHTML = `
${this._fieldRow('order_name', 'Название', o.order_name, 'text')}
${this._fieldRow('client_name', 'Клиент', o.client_name, 'text')}
${this._fieldRow('manager_name', 'Менеджер', o.manager_name, 'text')}
${this._fieldRowDateRange('deadline_start', 'deadline_end', 'Начало → Дедлайн', o.deadline_start || o.deadline, o.deadline_end)}
${this._fieldRow('notes', 'Заметки', o.notes, 'textarea')}
${this._fieldRow('delivery_address', 'Адрес доставки', o.delivery_address, 'text')}
${this._fieldRow('telegram', 'Telegram', o.telegram, 'text')}
${this._fieldRow('crm_link', 'CRM', o.crm_link, 'url')}
${this._fieldRow('fintablo_link', 'Финтабло', o.fintablo_link, 'url')}
`;
},
// ==========================================
// PRODUCTION TAB
// ==========================================
renderProductionTab() {
const o = this.currentOrder;
const container = document.getElementById('od-tab-production');
container.innerHTML = `
${this._fieldRowSelect('payment_status', 'Статус оплаты', o.payment_status || 'not_sent', PAYMENT_STATUSES)}
`;
},
// ==========================================
// FILES TAB
// ==========================================
renderFilesTab() {
const o = this.currentOrder;
const container = document.getElementById('od-tab-files');
container.innerHTML = `
${this._fieldRow('print_file_url', 'Файл печати', o.print_file_url, 'url')}
${this._fieldRow('cutting_file_url', 'Файл резки/формы', o.cutting_file_url, 'url')}
${this._fieldRow('delivery_documents_url', 'Документы доставки', o.delivery_documents_url, 'url')}
${this._fieldRow('reference_urls', 'URL референсов', o.reference_urls, 'textarea')}
${this._renderReferenceLinks(o.reference_urls)}
`;
},
_renderReferenceLinks(urls) {
if (!urls) return 'Нет референсов
';
const links = urls.split(',').map(u => u.trim()).filter(Boolean);
if (links.length === 0) return '';
return ``;
},
// ==========================================
// ITEMS TAB
// ==========================================
renderItemsTab() {
const container = document.getElementById('od-tab-items');
const products = this.currentItems.filter(i => i.item_type === 'product' || !i.item_type);
const allHardware = this.currentItems.filter(i => i.item_type === 'hardware');
const allPackaging = this.currentItems.filter(i => i.item_type === 'packaging');
const pendantItems = this.currentItems.filter(i => i.item_type === 'pendant');
// Build pendant HTML (used in both grouped and non-grouped paths)
let pendantHtml = '';
pendantItems.forEach(pndItem => {
let pnd;
try { pnd = typeof pndItem.item_data === 'string' ? JSON.parse(pndItem.item_data) : pndItem.item_data; } catch(e) { return; }
if (!pnd) return;
pendantHtml += this._renderPendantDetail(pnd, pndItem);
});
// Separate order-level vs per-item hw/pkg
const orderHardware = allHardware.filter(i => i.hardware_parent_item_index === null || i.hardware_parent_item_index === undefined);
const orderPackaging = allPackaging.filter(i => i.packaging_parent_item_index === null || i.packaging_parent_item_index === undefined);
// Check if items have marketplace_set_name for grouping
const hasSetNames = [...products, ...orderHardware, ...orderPackaging].some(i => i.marketplace_set_name);
let html = '';
if (hasSetNames) {
// Group all items by marketplace_set_name
const setGroups = new Map();
const noSetProducts = [];
products.forEach((item, idx) => {
const sn = item.marketplace_set_name || '';
if (sn) {
if (!setGroups.has(sn)) setGroups.set(sn, { products: [], hw: [], pkg: [] });
setGroups.get(sn).products.push({ item, idx });
} else {
noSetProducts.push({ item, idx });
}
});
orderHardware.forEach(item => {
const sn = item.marketplace_set_name || '';
if (sn) {
if (!setGroups.has(sn)) setGroups.set(sn, { products: [], hw: [], pkg: [] });
setGroups.get(sn).hw.push(item);
}
});
orderPackaging.forEach(item => {
const sn = item.marketplace_set_name || '';
if (sn) {
if (!setGroups.has(sn)) setGroups.set(sn, { products: [], hw: [], pkg: [] });
setGroups.get(sn).pkg.push(item);
}
});
// Render grouped by set
for (const [setName, group] of setGroups) {
const setQty = group.products.reduce((s, p) => s + (parseFloat(p.item.quantity) || 0), 0);
html += ``;
html += `
📦 ${this._esc(setName)} (${setQty} шт) `;
if (group.products.length > 0) {
html += '
Изделия:
';
group.products.forEach(({ item, idx }) => {
html += this._renderItemCard(item, 'product');
const itemHw = allHardware.filter(h => h.hardware_parent_item_index === idx);
const itemPkg = allPackaging.filter(p => p.packaging_parent_item_index === idx);
if (itemHw.length > 0 || itemPkg.length > 0) {
html += '
';
itemHw.forEach(h => { html += this._renderItemCard(h, 'hardware'); });
itemPkg.forEach(p => { html += this._renderItemCard(p, 'packaging'); });
html += '
';
}
});
}
if (group.hw.length > 0) {
html += '
🔩 Фурнитура:
';
group.hw.forEach(h => { html += this._renderItemCard(h, 'hardware'); });
}
if (group.pkg.length > 0) {
html += '
📦 Упаковка:
';
group.pkg.forEach(p => { html += this._renderItemCard(p, 'packaging'); });
}
html += '
';
}
// Products without set name
if (noSetProducts.length > 0) {
html += 'Прочие изделия ';
noSetProducts.forEach(({ item, idx }) => {
html += this._renderItemCard(item, 'product');
const itemHw = allHardware.filter(h => h.hardware_parent_item_index === idx);
const itemPkg = allPackaging.filter(p => p.packaging_parent_item_index === idx);
if (itemHw.length > 0 || itemPkg.length > 0) {
html += '';
itemHw.forEach(h => { html += this._renderItemCard(h, 'hardware'); });
itemPkg.forEach(p => { html += this._renderItemCard(p, 'packaging'); });
html += '
';
}
});
}
// HW/PKG without set name
const noSetHw = orderHardware.filter(i => !i.marketplace_set_name);
const noSetPkg = orderPackaging.filter(i => !i.marketplace_set_name);
if (noSetHw.length > 0) {
html += '🔩 Общая фурнитура ';
html += noSetHw.map(item => this._renderItemCard(item, 'hardware')).join('');
}
if (noSetPkg.length > 0) {
html += '📦 Общая упаковка ';
html += noSetPkg.map(item => this._renderItemCard(item, 'packaging')).join('');
}
// Pendant items
html += pendantHtml;
} else {
// Fallback: old display (no set grouping)
if (products.length > 0) {
html += 'Изделия ';
products.forEach((item, idx) => {
html += this._renderItemCard(item, 'product');
const itemHw = allHardware.filter(h => h.hardware_parent_item_index === idx);
const itemPkg = allPackaging.filter(p => p.packaging_parent_item_index === idx);
if (itemHw.length > 0 || itemPkg.length > 0) {
html += '';
itemHw.forEach(h => { html += this._renderItemCard(h, 'hardware'); });
itemPkg.forEach(p => { html += this._renderItemCard(p, 'packaging'); });
html += '
';
}
});
}
if (orderHardware.length > 0) {
html += '🔩 Общая фурнитура ';
html += orderHardware.map(item => this._renderItemCard(item, 'hardware')).join('');
}
if (orderPackaging.length > 0) {
html += '📦 Общая упаковка ';
html += orderPackaging.map(item => this._renderItemCard(item, 'packaging')).join('');
}
// Pendant items
html += pendantHtml;
}
if (!html) {
html = '📦
Нет позиций. Откройте в калькуляторе для добавления.
';
}
container.innerHTML = html;
},
async renderHardwareTab() {
const container = document.getElementById('od-tab-hardware');
if (!container || !this.currentOrder) return;
const orderId = Number(this.currentOrder.id || 0);
if (!orderId) {
container.innerHTML = '';
return;
}
container.innerHTML = `Загружаем фурнитуру заказа…
`;
try {
await Warehouse._ensureProjectHardwareStateLoaded();
const [warehouseItems, reservations, history] = await Promise.all([
loadWarehouseItems(),
loadWarehouseReservations(),
loadWarehouseHistory(),
]);
Warehouse.allItems = Array.isArray(warehouseItems) ? warehouseItems : [];
Warehouse.allReservations = Array.isArray(reservations) ? reservations : [];
const byItemId = new Map((warehouseItems || []).map(item => [Number(item.id), item]));
const historyDeltaMap = Warehouse._buildProjectHardwareHistoryDeltaMap(history || []);
const demandRows = Warehouse._collectWarehouseDemandFromOrderItems(this.currentItems || []);
const activeReservations = reservations || [];
if (!demandRows.length) {
container.innerHTML = `
🔧
В заказе пока нет складской фурнитуры или упаковки.
Открыть в калькуляторе
Открыть фурнитуру для проектов
`;
return;
}
const rows = demandRows
.map(row => {
const itemId = Number(row.warehouse_item_id || 0);
const whItem = byItemId.get(itemId) || {};
const plannedQty = round2(Math.max(0, parseFloat(row.qty) || 0));
const targetQty = Warehouse._buildProjectHardwareTargetQty(orderId, itemId, plannedQty, historyDeltaMap);
const actualQty = Warehouse._getProjectHardwareDisplayActualQty(orderId, itemId, plannedQty, historyDeltaMap, history || []);
const ready = Warehouse._computeProjectHardwareReadyState(orderId, itemId, plannedQty, history || [], historyDeltaMap);
const currentReserveQty = Warehouse._getProjectHardwareReservedQtyForOrderItem(activeReservations, orderId, itemId);
const totalReservedQty = round2(Warehouse._getActiveReservationsForItem(itemId)
.reduce((sum, reservation) => sum + (parseFloat(reservation.qty) || 0), 0));
const stockQty = round2(parseFloat(whItem.qty || 0) || 0);
const availableQty = Math.max(0, round2(stockQty - totalReservedQty));
const reserveHint = ready
? 'Уже собрано'
: (Math.abs(currentReserveQty - targetQty) <= 0.000001
? 'Резерв совпадает'
: (currentReserveQty < targetQty ? 'Резерв неполный' : 'Резерв скорректирован'));
return {
itemId,
itemName: whItem.name || (Array.isArray(row.names) ? row.names.find(Boolean) : '') || 'Позиция со склада',
itemSku: whItem.sku || '',
itemKind: Warehouse._projectSupplyKindLabel(row.material_type || whItem.category || 'hardware'),
plannedQty,
targetQty,
actualQty,
ready,
unit: String(whItem.unit || 'шт').trim() || 'шт',
stockTruth: this._buildProjectHardwareStockTruth({
orderId,
itemId,
item: whItem,
currentReserveQty,
totalReservedQty,
stockQty,
availableQty,
reservations: activeReservations,
history: history || [],
}),
currentReserveQty,
availableQty,
reserveHint,
};
})
.sort((a, b) => String(a.itemName).localeCompare(String(b.itemName), 'ru'));
const totalPlanned = round2(rows.reduce((sum, row) => sum + row.plannedQty, 0));
const totalTarget = round2(rows.reduce((sum, row) => sum + row.targetQty, 0));
const totalReserved = round2(rows.reduce((sum, row) => sum + row.currentReserveQty, 0));
const totalReady = rows.filter(row => row.ready).length;
container.innerHTML = `
Фурнитура и упаковка заказа
Меняйте здесь фактическое количество и сборку по заказу. Резерв на складе пересчитается автоматически.
Открыть в калькуляторе
Открыть на складе
Факт / цель
${totalTarget} шт
В резерве
${totalReserved} шт
Собрано
${totalReady} / ${rows.length}
Комплектующая
План
Резерв
Факт
Доступно сейчас
Собрано
${rows.map(row => `
${this._esc(row.itemName)}
${this._esc(row.itemKind)}
${row.itemSku ? `${this._esc(row.itemSku)}
` : ''}
${this._renderProjectHardwareStockTruth(row.stockTruth, row)}
${row.plannedQty}
${row.currentReserveQty}
${this._esc(row.reserveHint)}
если пусто — по плану
${row.availableQty}
собрано
`).join('')}
`;
} catch (error) {
console.error('renderHardwareTab error', error);
container.innerHTML = `
Не удалось загрузить фурнитуру заказа.
`;
}
},
async setProjectHardwareActualQty(itemId, rawValue) {
if (!this.currentOrder) return;
await Warehouse.setProjectHardwareActualQty(this.currentOrder.id, itemId, rawValue);
const data = await loadOrder(this.currentOrder.id);
if (data && data.order) {
this.currentOrder = data.order;
this.currentItems = data.items || [];
}
await this.renderHardwareTab();
},
async toggleProjectHardwareReady(itemId, checked) {
if (!this.currentOrder) return;
await Warehouse.toggleProjectHardwareReady(this.currentOrder.id, itemId, checked);
const data = await loadOrder(this.currentOrder.id);
if (data && data.order) {
this.currentOrder = data.order;
this.currentItems = data.items || [];
}
await this.renderHardwareTab();
},
_formatProjectHardwareQty(value, unitOrItem) {
if (typeof Warehouse !== 'undefined' && Warehouse && typeof Warehouse._formatWarehouseQtyDisplay === 'function') {
return Warehouse._formatWarehouseQtyDisplay(value, unitOrItem);
}
const parsed = round2(Math.max(0, parseFloat(value || 0) || 0));
return parsed.toLocaleString('ru-RU', { maximumFractionDigits: 2 });
},
_buildProjectHardwareHistoryEntryLabel(entry, item) {
const qty = round2(parseFloat(entry && entry.qty_change || 0) || 0);
const sign = qty > 0 ? '+' : (qty < 0 ? '−' : '');
const title = String(entry && (entry.notes || entry.order_name) || '').trim()
|| (String(entry && entry.type || '').trim() || 'движение');
const dateLabel = (typeof Warehouse !== 'undefined' && Warehouse && typeof Warehouse._formatDateCompact === 'function')
? Warehouse._formatDateCompact(entry && entry.created_at)
: App.formatDate(entry && entry.created_at);
return `${dateLabel} · ${title} · ${sign}${this._formatProjectHardwareQty(Math.abs(qty), item)} ${String(item && item.unit || 'шт').trim() || 'шт'}`;
},
_buildProjectHardwareStockTruth({ orderId, itemId, item, currentReserveQty, totalReservedQty, stockQty, availableQty, reservations, history }) {
const itemUnit = String(item && item.unit || 'шт').trim() || 'шт';
const activeReservations = (Array.isArray(reservations) ? reservations : []).filter(reservation =>
Number(reservation && reservation.item_id || 0) === Number(itemId || 0)
&& String(reservation && reservation.status || '') === 'active'
);
const otherReservations = activeReservations.filter(reservation => Number(reservation && reservation.order_id || 0) !== Number(orderId || 0));
const otherReservedQty = round2(otherReservations.reduce((sum, reservation) => sum + (parseFloat(reservation.qty) || 0), 0));
const manualReservedQty = round2(otherReservations
.filter(reservation => Warehouse._isManualReservationRecord(reservation))
.reduce((sum, reservation) => sum + (parseFloat(reservation.qty) || 0), 0));
const itemHistory = (Array.isArray(history) ? history : [])
.filter(entry => Number(entry && entry.item_id || 0) === Number(itemId || 0))
.sort((a, b) => {
const byDate = new Date(String(b && b.created_at || '')).getTime() - new Date(String(a && a.created_at || '')).getTime();
if (byDate !== 0) return byDate;
return Number(b && b.id || 0) - Number(a && a.id || 0);
});
const correctionEntries = itemHistory.filter(entry => Warehouse._isStockCorrectionHistoryEntry(entry));
const correctionNet = round2(correctionEntries.reduce((sum, entry) => sum + (parseFloat(entry.qty_change) || 0), 0));
const correctionSign = correctionNet > 0 ? '+' : (correctionNet < 0 ? '−' : '');
const recentEntries = itemHistory.slice(0, 3).map(entry => this._buildProjectHardwareHistoryEntryLabel(entry, item));
const otherReservationLabels = otherReservations.slice(0, 3).map(reservation => {
const meta = Warehouse._getReservationDisplayMeta(reservation);
return `${meta.primaryLabel} ${this._formatProjectHardwareQty(meta.qty, item)} ${itemUnit}`;
});
return {
itemUnit,
stockQty: round2(stockQty),
totalReservedQty: round2(totalReservedQty),
currentReserveQty: round2(currentReserveQty),
otherReservedQty,
manualReservedQty,
availableQty: round2(availableQty),
correctionEntriesCount: correctionEntries.length,
correctionNet,
correctionSign,
otherReservationLabels,
hasMoreOtherReservations: otherReservations.length > otherReservationLabels.length,
recentEntries,
};
},
_renderProjectHardwareStockTruth(truth, row) {
if (!truth) return '';
const unit = String(truth.itemUnit || row.unit || 'шт').trim() || 'шт';
const summaryBits = [
`На складе ${this._formatProjectHardwareQty(truth.stockQty, row)} ${unit}`,
`резерв всего ${this._formatProjectHardwareQty(truth.totalReservedQty, row)} ${unit}`,
`свободно ${this._formatProjectHardwareQty(truth.availableQty, row)} ${unit}`,
];
if (truth.correctionEntriesCount > 0) {
summaryBits.push(`корр. ${truth.correctionSign}${this._formatProjectHardwareQty(Math.abs(truth.correctionNet), row)} ${unit}`);
}
const detailLines = [];
if (truth.currentReserveQty > 0) {
detailLines.push(`Этот заказ держит ${this._formatProjectHardwareQty(truth.currentReserveQty, row)} ${unit}.`);
}
if (truth.otherReservationLabels.length > 0) {
detailLines.push(`Другие резервы: ${truth.otherReservationLabels.map(label => this._esc(label)).join('; ')}${truth.hasMoreOtherReservations ? ' …' : ''}.`);
} else if (truth.manualReservedQty > 0) {
detailLines.push(`Есть ручной резерв ${this._formatProjectHardwareQty(truth.manualReservedQty, row)} ${unit}.`);
} else {
detailLines.push('Других активных резервов сейчас нет.');
}
if (truth.recentEntries.length > 0) {
detailLines.push(`Последние движения: ${truth.recentEntries.map(line => this._esc(line)).join('; ')}.`);
}
return `
${summaryBits.map(bit => this._esc(bit)).join(' · ')}
Почему такой остаток
${detailLines.map(line => `
${line}
`).join('')}
`;
},
_renderPendantDetail(pnd, dbItem) {
const qty = pnd.quantity || 0;
const elements = pnd.elements || [];
const elemPrice = pnd.element_price_per_unit || 0;
const getAttachmentAllocatedQty = (entry) => {
if (typeof getPendantAttachmentAllocatedQty === 'function') {
return getPendantAttachmentAllocatedQty(pnd, entry);
}
const allocatedQty = parseFloat(entry?.allocated_qty);
if (Number.isFinite(allocatedQty) && allocatedQty >= 0) return allocatedQty;
return qty > 0 ? qty : 0;
};
const normalizeAttachments = (type) => {
const collectionKey = type === 'cord' ? 'cords' : 'carabiners';
const legacyKey = type === 'cord' ? 'cord' : 'carabiner';
const fallbackLengthCm = type === 'cord' ? (parseFloat(pnd.cord_length_cm) || 0) : 0;
let entries = Array.isArray(pnd[collectionKey]) ? pnd[collectionKey] : [];
if ((!entries || entries.length === 0) && pnd[legacyKey]) {
entries = [pnd[legacyKey]];
}
return (entries || [])
.map((entry, index) => {
const normalized = { ...(entry || {}) };
const qtyPerPendant = parseFloat(normalized.qty_per_pendant);
const lengthCm = parseFloat(normalized.length_cm);
const allocatedQty = parseFloat(normalized.allocated_qty);
const hasExplicitAllocatedQty = normalized.allocated_qty !== undefined && normalized.allocated_qty !== null && normalized.allocated_qty !== '';
normalized.qty_per_pendant = qtyPerPendant > 0 ? qtyPerPendant : 1;
normalized.length_cm = Number.isFinite(lengthCm) ? lengthCm : (type === 'cord' && index === 0 ? fallbackLengthCm : 0);
normalized.unit = normalized.unit || 'шт';
normalized.allocated_qty = Number.isFinite(allocatedQty)
? Math.max(0, Math.round(allocatedQty))
: ((
normalized.name
|| normalized.warehouse_item_id
|| normalized.warehouse_sku
|| (parseFloat(normalized.price_per_unit) || 0) > 0
|| (parseFloat(normalized.delivery_price) || 0) > 0
|| (parseFloat(normalized.sell_price) || 0) > 0
|| normalized.source === 'custom'
) && !hasExplicitAllocatedQty && qty > 0 ? qty : 0);
return normalized;
})
.filter(entry => entry && (
entry.name
|| entry.warehouse_item_id
|| entry.warehouse_sku
|| (parseFloat(entry.price_per_unit) || 0) > 0
|| (parseFloat(entry.delivery_price) || 0) > 0
|| (parseFloat(entry.sell_price) || 0) > 0
|| entry.source === 'custom'
));
};
const attachmentCostPerPendant = (type, entry) => {
if (type === 'cord' && (entry.unit === 'м' || entry.unit === 'см')) {
const metricFactor = typeof getPendantMetricRateFactor === 'function'
? getPendantMetricRateFactor(entry)
: ((entry.unit === 'см') ? (parseFloat(entry.length_cm) || 0) : ((parseFloat(entry.length_cm) || 0) / 100));
return round2(((entry.price_per_unit || 0) * metricFactor) + (entry.delivery_price || 0));
}
return round2(((entry.price_per_unit || 0) + (entry.delivery_price || 0)) * (entry.qty_per_pendant || 1));
};
const cords = normalizeAttachments('cord');
const carabiners = normalizeAttachments('carabiner');
// Group elements by color
const groups = {};
elements.forEach(el => {
const key = el.color || 'без цвета';
if (!groups[key]) groups[key] = [];
groups[key].push(el);
});
let rows = '';
cords.forEach(cord => {
const isMetric = cord.unit === 'м' || cord.unit === 'см';
const allocatedQty = getAttachmentAllocatedQty(cord);
const totalQtyLabel = isMetric
? `${round2((cord.length_cm || 0) * allocatedQty / 100)} м${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}`
: `${round2(allocatedQty * (cord.qty_per_pendant || 1))} шт${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}`;
const titleSuffix = isMetric && cord.length_cm > 0 ? ` (${cord.length_cm} см/подвес)` : ((cord.qty_per_pendant || 1) > 1 ? ` × ${cord.qty_per_pendant}` : '');
const pricePerPendant = attachmentCostPerPendant('cord', cord);
rows += `🧵 ${this._esc(cord.name || 'Шнур')}${titleSuffix} ${totalQtyLabel} ${formatRub(pricePerPendant)} ${formatRub(allocatedQty * pricePerPendant)} `;
});
carabiners.forEach(carabiner => {
const allocatedQty = getAttachmentAllocatedQty(carabiner);
const titleSuffix = (carabiner.qty_per_pendant || 1) > 1 ? ` × ${carabiner.qty_per_pendant}` : '';
const pricePerPendant = attachmentCostPerPendant('carabiner', carabiner);
rows += `🔗 ${this._esc(carabiner.name || 'Фурнитура')}${titleSuffix} ${round2(allocatedQty * (carabiner.qty_per_pendant || 1))} шт${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''} ${formatRub(pricePerPendant)} ${formatRub(allocatedQty * pricePerPendant)} `;
});
// Element groups by color
Object.entries(groups).forEach(([color, els]) => {
const chars = els.map(e => e.char).join(', ');
const groupQty = els.length * qty;
rows += `🔤 ${this._esc(chars)} (${this._esc(color)}) ${groupQty} ${formatRub(elemPrice)} ${formatRub(groupQty * elemPrice)} `;
});
// Print items
elements.filter(el => el.has_print).forEach(el => {
rows += `🖨 Печать на ${this._esc(el.char)} ${qty} ${formatRub(el.print_price)} ${formatRub(qty * (el.print_price || 0))} `;
});
// Packaging
if (pnd.packaging?.name) {
rows += `📦 ${this._esc(pnd.packaging.name)} ${qty} ${formatRub(pnd.packaging.price_per_unit)} ${formatRub(qty * (pnd.packaging.price_per_unit || 0))} `;
}
const sellPrice = dbItem.sell_price_item || 0;
const totalRevenue = sellPrice * qty;
return `
Позиция Кол-во Цена/шт Итого
${rows}
`;
},
_renderItemCard(item, type) {
const name = item.product_name || '—';
const qty = item.quantity || 0;
let costPerUnit = 0;
let sellPrice = 0;
if (type === 'product') {
costPerUnit = item.cost_total || 0;
sellPrice = item.sell_price_item || 0;
} else if (type === 'hardware') {
costPerUnit = item.cost_total || 0;
sellPrice = item.sell_price_hardware || 0;
} else if (type === 'packaging') {
costPerUnit = item.cost_total || 0;
sellPrice = item.sell_price_packaging || 0;
}
const revenue = sellPrice * qty;
const cost = costPerUnit * qty;
const margin = revenue > 0 ? ((revenue - cost) / revenue * 100) : 0;
const productMeta = type === 'product' ? this._renderProductMeta(item) : '';
return `
`;
},
_renderProductMeta(item) {
const colors = this._normalizeProductColors(item);
const attachments = this._normalizeColorAttachments(item);
const sections = [];
if (colors.length > 0) {
sections.push(`
Цвета:
${colors.map(color => `${this._esc(color.name || color.number || `#${color.id || '—'}`)} `).join('')}
`);
}
if (attachments.length > 0) {
const linkHtml = attachments.map(attachment => {
const label = this._esc(attachment.name || 'Файл цветового решения');
return attachment.data_url
? `${label} `
: label;
}).join(' ');
sections.push(`
${attachments.length > 1 ? 'Файлы:' : 'Файл:'}
${linkHtml}
`);
}
if (sections.length === 0) return '';
return `
${sections.join('')}
`;
},
_normalizeProductColors(item) {
let colors = item?.colors;
if (typeof colors === 'string') {
try {
colors = JSON.parse(colors);
} catch (e) {
colors = [];
}
}
if (!Array.isArray(colors)) colors = [];
colors = colors.filter(color => color && typeof color === 'object');
if (colors.length > 0) return colors;
if (item?.color_name) {
return [{
id: item.color_id || null,
name: item.color_name,
}];
}
if (item?.color_id) {
return [{
id: item.color_id,
name: `#${item.color_id}`,
}];
}
return [];
},
_normalizeColorAttachments(item) {
return normalizeColorAttachments(item);
},
// ==========================================
// TASKS TAB (v33: linked tasks)
// ==========================================
async renderTasksTab() {
const container = document.getElementById('od-tab-tasks');
const orderId = this.currentOrder.id;
const bundle = await loadWorkBundle();
const projects = (bundle.projects || []).filter(project => String(project.linked_order_id || '') === String(orderId));
const projectIds = new Set(projects.map(project => String(project.id)));
const tasks = (bundle.tasks || []).filter(task =>
String(task.order_id || '') === String(orderId)
|| projectIds.has(String(task.project_id || ''))
);
const projectCards = projects.length === 0
? 'Связанных проектов пока нет
'
: projects.map(project => {
const projectTasks = tasks.filter(task => String(task.project_id || '') === String(project.id));
return `
${this._esc(project.title)}
${this._esc(project.type || 'Другое')} · ${this._esc(WorkManagementCore.getProjectStatusLabel(project.status))}
Задач: ${projectTasks.length} · Владелец: ${this._esc(project.owner_name || '—')}
`;
}).join('');
const taskRows = tasks.map(task => {
const project = task.project_id ? projects.find(item => String(item.id) === String(task.project_id)) : null;
return `
${this._esc(task.title)}
${this._esc(project ? `Проект: ${project.title}` : 'Прямая задача заказа')}
${this._esc(WorkManagementCore.getTaskStatusLabel(task.status))}
${this._esc(task.assignee_name || '—')}
${this._esc(task.due_date ? App.formatDate(task.due_date) + (task.due_time ? ' ' + task.due_time : '') : '—')}
`;
}).join('');
const inProgress = tasks.filter(task => task.status === 'in_progress').length;
const done = tasks.filter(task => task.status === 'done').length;
const total = tasks.length;
container.innerHTML = `
Всего задач: ${total} | В работе: ${inProgress} | Готово: ${done} | Проектов: ${projects.length}
+ Проект
+ Задача
${tasks.length === 0
? `
☑
В этом заказе пока нет задач
Создать первую задачу
`
: `
Задача
Статус
Ответственный
Дедлайн
${taskRows}
`
}`;
},
// ==========================================
// CHINA TAB
// ==========================================
async renderChinaTab() {
const container = document.getElementById('od-tab-china');
const orderId = this.currentOrder.id;
const orderName = this._esc(this.currentOrder.order_name || '');
try {
const purchases = await loadChinaPurchases({ order_id: orderId });
const addBtn = `
+ Создать закупку для этого заказа
`;
if (!purchases || purchases.length === 0) {
container.innerHTML = `
${addBtn}
🇨🇳
Нет привязанных закупок из Китая
`;
return;
}
container.innerHTML = `
${addBtn}
Закупка
Поставщик
Статус
Сумма CNY
Трек
${purchases.map(p => {
const st = (typeof ChinaPurchases !== 'undefined' && ChinaPurchases.STATUSES)
? ChinaPurchases.STATUSES.find(s => s.key === p.status)
: null;
return `
${this._esc(p.purchase_name || '')}
${this._esc(p.supplier_name || '')}
${st ? '' + st.label + ' ' : (p.status || '')}
${(p.total_cny || 0).toFixed(2)}
${this._esc(p.tracking_number || '')}
`;
}).join('')}
`;
} catch (e) {
container.innerHTML = '';
}
},
// ==========================================
// ACTIONS
// ==========================================
async changeStatus() {
const o = this.currentOrder;
const opts = STATUS_OPTIONS.filter(s => s.value !== 'deleted' && s.value !== o.status);
const labels = opts.map((s, i) => `${i + 1}. ${s.label}`).join('\n');
const choice = prompt(`Текущий статус: ${App.statusLabel(o.status)}\n\nВыберите новый:\n${labels}\n\nВведите номер:`);
if (!choice) return;
const idx = parseInt(choice) - 1;
if (isNaN(idx) || idx < 0 || idx >= opts.length) {
App.toast('Неверный выбор');
return;
}
const newStatus = opts[idx].value;
if (typeof Orders !== 'undefined' && Orders && typeof Orders._ensureStatusTransitionAllowed === 'function') {
const guard = await Orders._ensureStatusTransitionAllowed(this.currentOrder.id, newStatus);
if (!guard.ok) return;
}
const managerName = prompt('Имя менеджера (для истории):') || 'Неизвестный';
await updateOrderStatus(this.currentOrder.id, newStatus);
if (typeof Orders !== 'undefined' && Orders._syncWarehouseByStatus) {
await Orders._syncWarehouseByStatus(
this.currentOrder.id,
o.status,
newStatus,
this.currentOrder.order_name || o.order_name,
managerName
);
if (Orders._syncReadyGoodsByStatus) {
await Orders._syncReadyGoodsByStatus(this.currentOrder.id, this.currentOrder, o.status, newStatus);
}
}
await Orders.addChangeRecord(this.currentOrder.id, {
field: 'status',
old_value: App.statusLabel(o.status),
new_value: App.statusLabel(newStatus),
manager: managerName,
});
this.currentOrder.status = newStatus;
this.renderHeader();
App.toast(`Статус: ${App.statusLabel(newStatus)}`);
},
openInCalculator() {
if (this.currentOrder) {
Calculator.loadOrder(this.currentOrder.id);
}
},
cloneOrder() {
if (this.currentOrder) {
Orders.cloneOrder(this.currentOrder.id);
}
},
// ==========================================
// INLINE EDIT HELPERS
// ==========================================
async saveField(field, value) {
await updateOrderFields(this.currentOrder.id, { [field]: value });
this.currentOrder[field] = value;
// Re-render current tab
const tabRenderers = {
info: () => this.renderInfoTab(),
production: () => this.renderProductionTab(),
files: () => this.renderFilesTab(),
hardware: () => this.renderHardwareTab(),
};
if (tabRenderers[this.currentTab]) tabRenderers[this.currentTab]();
if (['payment_status'].includes(field)) this.renderStats();
App.toast('Сохранено');
},
startEdit(field, currentValue, inputType) {
const cell = document.getElementById('od-val-' + field);
if (!cell) return;
cell.classList.add('od-editing');
if (inputType === 'textarea') {
cell.innerHTML = ``;
} else {
cell.innerHTML = ` `;
}
cell.querySelector('.od-inline-input').focus();
},
finishEdit(field, value) {
const trimmed = value.trim();
if (trimmed !== (this.currentOrder[field] || '').toString().trim()) {
this.saveField(field, trimmed);
} else {
// Revert — re-render tab
const tabRenderers = {
info: () => this.renderInfoTab(),
production: () => this.renderProductionTab(),
files: () => this.renderFilesTab(),
hardware: () => this.renderHardwareTab(),
};
if (tabRenderers[this.currentTab]) tabRenderers[this.currentTab]();
}
},
startEditDate(field, currentValue) {
const cell = document.getElementById('od-val-' + field);
if (!cell) return;
cell.innerHTML = ` `;
cell.querySelector('input').focus();
},
onSelectChange(field, value) {
this.saveField(field, value);
},
onCheckboxChange(field, checked) {
this.saveField(field, checked);
},
// ==========================================
// FIELD RENDERERS
// ==========================================
_fieldRow(field, label, value, inputType) {
let displayValue = '';
if (inputType === 'url' && value) {
const shortUrl = value.length > 50 ? value.substring(0, 50) + '...' : value;
displayValue = `${this._esc(shortUrl)} `;
} else {
displayValue = this._esc(value || '') || '— ';
}
return `
`;
},
_fieldRowDateRange(fieldStart, fieldEnd, label, valueStart, valueEnd) {
const fmtStart = valueStart ? App.formatDate(valueStart) : '—';
const fmtEnd = valueEnd ? App.formatDate(valueEnd) : '';
const display = fmtEnd ? `${fmtStart} → ${fmtEnd}` : fmtStart;
return `
${label}
${fmtStart}
→
${fmtEnd || '— '}
`;
},
_fieldRowSelect(field, label, value, options) {
const opts = options.map(o =>
`${o.label} `
).join('');
return `
`;
},
_fieldRowCheckbox(field, label, checked) {
return `
`;
},
// ==========================================
// UTILS
// ==========================================
_statusColor(status) {
const map = { draft: 'gray', calculated: 'blue', in_production: 'orange', production_printing: 'orange', completed: 'green', cancelled: 'red', deleted: 'red' };
return map[status] || 'gray';
},
_esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
},
_escAttr(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
},
};