// =============================================
// Recycle Object — Orders Home Page
// List-first view inspired by Notion Active
// =============================================
const STATUS_OPTIONS = [
{ value: 'draft', label: 'Черновик' },
{ value: 'sample', label: 'Заказ образца' },
{ value: 'production_casting', label: 'Производство: Выливание' },
{ value: 'production_printing', label: 'Производство: Печать' },
{ value: 'production_hardware', label: 'Производство: Сборка' },
{ value: 'production_packaging', label: 'Производство: Упаковка' },
{ value: 'delivery', label: 'Доставка' },
{ value: 'completed', label: 'Готово' },
{ value: 'cancelled', label: 'Отменён' },
{ value: 'deleted', label: 'Удалён' },
];
const DRAFT_STATUSES = ['draft', 'calculated'];
const SAMPLE_STATUSES = ['sample'];
const PRODUCTION_STATUSES = ['production_casting', 'production_printing', 'production_hardware', 'production_packaging', 'in_production', 'delivery'];
const ACTIVE_STATUSES = [...SAMPLE_STATUSES, ...PRODUCTION_STATUSES];
const SKY_STATUSES = [...DRAFT_STATUSES, ...ACTIVE_STATUSES, 'completed', 'cancelled'];
const ORDERS_MODE_HINTS = {
active: 'Список по умолчанию для менеджеров: только Образцы и Production.',
sample: 'Только заказы, которые находятся в статусе образца.',
production: 'Только заказы, которые уже пошли в производство.',
board: 'Доска по всем статусам: черновики, образцы, производство, готово и отменённые.',
sky: 'Общий плоский список без лишних колонок: название, старт, дедлайн и текущий этап.',
basket: 'Удалённые заказы. Здесь можно восстановить заказ или удалить его навсегда.',
};
const ORDERS_SECTIONS = {
sample: {
key: 'sample',
label: 'Образцы',
color: '#3b82f6',
defaultOpen: true,
statuses: SAMPLE_STATUSES,
},
production: {
key: 'production',
label: 'Production',
color: '#f59e0b',
defaultOpen: true,
statuses: PRODUCTION_STATUSES,
},
basket: {
key: 'basket',
label: 'Корзина',
color: '#9ca3af',
defaultOpen: true,
statuses: ['deleted'],
},
};
const PRODUCTION_SUBSTAGES = {
production_casting: 'Выливание формы',
production_printing: 'Печать / нанесение',
production_hardware: 'Сборка фурнитуры',
production_packaging: 'Упаковка',
in_production: 'В производстве',
};
const BOARD_COLUMNS = [
{ key: 'draft', label: 'Черновики', color: '#6b7280', icon: '○', statuses: DRAFT_STATUSES },
{ key: 'sample', label: 'Образцы', color: '#3b82f6', icon: '◎', statuses: SAMPLE_STATUSES },
{ key: 'production', label: 'Production', color: '#f59e0b', icon: '◐', statuses: PRODUCTION_STATUSES },
{ key: 'completed', label: 'Готово', color: '#22c55e', icon: '●', statuses: ['completed'] },
{ key: 'cancelled', label: 'Отменён', color: '#ef4444', icon: '✕', statuses: ['cancelled'] },
];
const CHINA_STATUS_META = {
ordered: { label: 'Заказано', className: 'badge-orange' },
in_china_warehouse: { label: 'В Китае', className: 'badge-yellow' },
in_transit: { label: 'В пути', className: 'badge-blue' },
delivered: { label: 'Доставлено', className: 'badge-green' },
received: { label: 'Принято на склад', className: 'badge-green' },
};
const Orders = {
allOrders: [],
metaByOrderId: {},
mode: 'active',
collapsedSections: {},
isLoading: false,
isMetaLoading: false,
_loadSeq: 0,
hydrateFromCache() {
if (typeof getLocal !== 'function' || typeof LOCAL_KEYS === 'undefined') return false;
const cachedOrders = getLocal(LOCAL_KEYS.orders) || [];
if (!Array.isArray(cachedOrders) || cachedOrders.length === 0) return false;
this.allOrders = this.mode === 'basket'
? cachedOrders.filter(order => order.status === 'deleted')
: cachedOrders.filter(order => order.status !== 'deleted');
return this.allOrders.length > 0;
},
setMode(mode) {
if (!mode) return;
const reloadRequired = this.allOrders.length === 0 || this.mode === 'basket' || mode === 'basket';
this.mode = mode;
this.updateModeControls();
if (reloadRequired) {
this.loadList();
return;
}
this.render();
},
updateModeControls() {
document.querySelectorAll('.orders-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === this.mode);
});
const hint = document.getElementById('orders-mode-hint');
if (hint) hint.textContent = ORDERS_MODE_HINTS[this.mode] || ORDERS_MODE_HINTS.active;
},
async loadList() {
const loadSeq = ++this._loadSeq;
try {
this.updateModeControls();
const hydrated = this.hydrateFromCache();
this.isLoading = !hydrated && this.allOrders.length === 0;
this.isMetaLoading = hydrated && this.mode !== 'basket';
this.render();
const filters = this.mode === 'basket' ? { status: 'deleted' } : {};
this.allOrders = await loadOrders(filters);
if (this._loadSeq !== loadSeq) return;
this.isLoading = false;
if (this.mode === 'basket' || !this.allOrders.length) {
this.metaByOrderId = {};
this.isMetaLoading = false;
this.render();
return;
}
this.isMetaLoading = true;
this.render();
await this.buildMeta(this.allOrders);
if (this._loadSeq !== loadSeq) return;
this.isMetaLoading = false;
this.render();
} catch (e) {
console.error('Orders load error:', e);
this.isLoading = false;
this.isMetaLoading = false;
const container = document.getElementById('orders-table-view');
if (container) {
container.innerHTML = this.renderEmptyState('Не удалось загрузить заказы');
}
}
},
async buildMeta(orders) {
if (this.mode === 'basket' || !orders.length) {
this.metaByOrderId = {};
return;
}
const orderIds = orders.map(order => order.id);
const { projects, tasks, chinaPurchases, orderItems } = await this.loadMetaBundle(orderIds);
const projectToOrder = new Map();
(projects || []).forEach(project => {
if (project && project.id != null && project.linked_order_id != null) {
projectToOrder.set(String(project.id), String(project.linked_order_id));
}
});
const tasksByOrder = new Map();
(tasks || []).forEach(task => {
if (!task) return;
const orderId = task.order_id != null
? String(task.order_id)
: projectToOrder.get(String(task.project_id || ''));
if (!orderId) return;
if (!tasksByOrder.has(orderId)) tasksByOrder.set(orderId, []);
tasksByOrder.get(orderId).push(task);
});
const purchasesByOrder = new Map();
(chinaPurchases || []).forEach(purchase => {
if (!purchase || purchase.order_id == null) return;
const key = String(purchase.order_id);
if (!purchasesByOrder.has(key)) purchasesByOrder.set(key, []);
purchasesByOrder.get(key).push(purchase);
});
const itemsByOrder = new Map();
(orderItems || []).forEach(item => {
if (!item || item.order_id == null) return;
const key = String(item.order_id);
if (!itemsByOrder.has(key)) itemsByOrder.set(key, []);
itemsByOrder.get(key).push(item);
});
const nextMeta = {};
orders.forEach(order => {
nextMeta[order.id] = this.buildOrderMeta(
order,
itemsByOrder.get(String(order.id)) || [],
purchasesByOrder.get(String(order.id)) || [],
tasksByOrder.get(String(order.id)) || []
);
});
this.metaByOrderId = nextMeta;
},
async loadMetaBundle(orderIds) {
const ids = [...new Set((orderIds || [])
.map(id => Number(id))
.filter(id => Number.isFinite(id) && id > 0))];
if (ids.length === 0) {
return { projects: [], tasks: [], chinaPurchases: [], orderItems: [] };
}
if (typeof isSupabaseReady === 'function' && isSupabaseReady() && typeof supabaseClient !== 'undefined' && supabaseClient) {
try {
return await this.loadMetaBundleRemote(ids);
} catch (e) {
console.warn('Orders meta remote load fallback:', e);
}
}
return this.loadMetaBundleLocal(ids);
},
async loadMetaBundleRemote(orderIds) {
const idSet = new Set(orderIds.map(id => String(id)));
const [projectsResp, directTasksResp, chinaResp, orderItems] = await Promise.all([
supabaseClient
.from('projects')
.select('id,linked_order_id')
.in('linked_order_id', orderIds),
supabaseClient
.from('tasks')
.select('id,status,order_id,project_id')
.in('order_id', orderIds),
supabaseClient
.from('china_purchases')
.select('id,status,purchase_data,created_at,updated_at')
.order('created_at', { ascending: false }),
typeof loadOrderItemsByOrderIds === 'function'
? loadOrderItemsByOrderIds(orderIds).catch(() => [])
: Promise.resolve([]),
]);
if (projectsResp.error) throw projectsResp.error;
if (directTasksResp.error) throw directTasksResp.error;
if (chinaResp.error) throw chinaResp.error;
const projects = (projectsResp.data || []).filter(project => idSet.has(String(project.linked_order_id)));
const projectIds = projects.map(project => Number(project.id)).filter(id => Number.isFinite(id) && id > 0);
let projectTasks = [];
if (projectIds.length > 0) {
const projectTasksResp = await supabaseClient
.from('tasks')
.select('id,status,order_id,project_id')
.in('project_id', projectIds);
if (projectTasksResp.error) throw projectTasksResp.error;
projectTasks = projectTasksResp.data || [];
}
const tasksById = new Map();
[...(directTasksResp.data || []), ...projectTasks].forEach(task => {
if (!task || task.id == null) return;
tasksById.set(String(task.id), task);
});
const chinaPurchases = (chinaResp.data || [])
.map(row => {
let parsed = {};
if (row.purchase_data) {
try {
parsed = typeof row.purchase_data === 'string'
? JSON.parse(row.purchase_data)
: row.purchase_data;
} catch (e) {
parsed = {};
}
}
return {
...parsed,
id: row.id,
status: row.status || parsed.status,
created_at: row.created_at || parsed.created_at,
updated_at: row.updated_at || parsed.updated_at,
};
})
.filter(purchase => idSet.has(String(purchase.order_id)));
return {
projects,
tasks: Array.from(tasksById.values()),
chinaPurchases,
orderItems,
};
},
async loadMetaBundleLocal(orderIds) {
const idSet = new Set(orderIds.map(id => String(id)));
const [projects, tasks, chinaPurchases, orderItems] = await Promise.all([
typeof loadWorkProjects === 'function' ? loadWorkProjects().catch(() => []) : Promise.resolve([]),
typeof loadWorkTasks === 'function' ? loadWorkTasks().catch(() => []) : Promise.resolve([]),
typeof loadChinaPurchases === 'function' ? loadChinaPurchases({}).catch(() => []) : Promise.resolve([]),
typeof loadOrderItemsByOrderIds === 'function' ? loadOrderItemsByOrderIds(orderIds).catch(() => []) : Promise.resolve([]),
]);
const filteredProjects = (projects || []).filter(project => idSet.has(String(project.linked_order_id)));
const projectIds = new Set(filteredProjects.map(project => String(project.id)));
const filteredTasks = (tasks || []).filter(task =>
idSet.has(String(task.order_id))
|| projectIds.has(String(task.project_id))
);
const filteredPurchases = (chinaPurchases || []).filter(purchase => idSet.has(String(purchase.order_id)));
return {
projects: filteredProjects,
tasks: filteredTasks,
chinaPurchases: filteredPurchases,
orderItems,
};
},
buildOrderMeta(order, items, purchases, tasks) {
return {
todo: this.buildTodoMeta(tasks),
hardware: this.buildHardwareMeta(items),
china: this.buildChinaMeta(purchases, items),
production: this.buildProductionMeta(order),
financial: this.buildFinancialMeta(order, items),
};
},
buildFinancialMeta(order, items) {
if (typeof getOrderLiveCalculatorSnapshot !== 'function') {
return {
revenue: Number(order?.total_revenue_plan || 0),
marginPercent: Number(order?.margin_percent_plan || 0),
hours: Number(order?.total_hours_plan || 0),
};
}
try {
const snapshot = getOrderLiveCalculatorSnapshot(order, items || []);
return {
revenue: Number(snapshot?.revenue || 0),
marginPercent: Number(snapshot?.marginPercent || 0),
hours: Number(snapshot?.hours || 0),
};
} catch (e) {
console.warn('Orders.buildFinancialMeta fallback:', e);
return {
revenue: Number(order?.total_revenue_plan || 0),
marginPercent: Number(order?.margin_percent_plan || 0),
hours: Number(order?.total_hours_plan || 0),
};
}
},
buildTodoMeta(tasks) {
const total = (tasks || []).length;
const isFinished = task => (
typeof WorkManagementCore !== 'undefined'
&& typeof WorkManagementCore.isTaskFinished === 'function'
&& WorkManagementCore.isTaskFinished(task)
);
const openTasks = (tasks || []).filter(task => !isFinished(task));
const reviewCount = openTasks.filter(task => task.status === 'review').length;
if (total === 0) {
return { label: 'Empty 0', className: 'badge-gray', title: 'К заказу не привязаны задачи' };
}
if (openTasks.length === 0) {
return { label: `Done ${total}/${total}`, className: 'badge-green', title: `Все ${total} задач закрыты` };
}
if (reviewCount > 0) {
return {
label: `Review ${reviewCount}/${total}`,
className: 'badge-blue',
title: `На согласовании: ${reviewCount}. Открыто всего: ${openTasks.length} из ${total}`,
};
}
return {
label: `To-do ${openTasks.length}/${total}`,
className: 'badge-yellow',
title: `Открытых задач: ${openTasks.length} из ${total}`,
};
},
buildHardwareMeta(items) {
const hardwareItems = (items || []).filter(item => item.item_type === 'hardware');
const pendantAttachments = [];
const productNfcAttachments = [];
if (typeof getPendantAttachmentEntries === 'function') {
(items || []).filter(item => item.item_type === 'pendant').forEach(pendant => {
['cord', 'carabiner'].forEach(type => {
getPendantAttachmentEntries(pendant, type).forEach(entry => {
pendantAttachments.push({
hardware_source: entry.source || 'warehouse',
source: entry.source || 'warehouse',
custom_country: entry.custom_country || 'china',
});
});
});
});
}
if (typeof getProductWarehouseDemandRows === 'function') {
(items || []).forEach(item => {
getProductWarehouseDemandRows(item).forEach(() => {
productNfcAttachments.push({
hardware_source: 'warehouse',
source: 'warehouse',
custom_country: 'warehouse',
});
});
});
}
const demandItems = [...hardwareItems, ...pendantAttachments, ...productNfcAttachments];
if (demandItems.length === 0) {
return {
label: 'Фурнитура не нужна',
className: 'badge-red',
title: 'В заказе нет строк фурнитуры',
};
}
const sourceKinds = new Set(demandItems.map(item => {
const source = String(item.hardware_source || item.source || 'custom').toLowerCase();
const country = String(item.custom_country || 'china').toLowerCase();
if (source === 'warehouse') return 'warehouse';
if (source === 'china' || (source === 'custom' && country === 'china')) return 'china';
return 'custom';
}));
if (sourceKinds.size === 1 && sourceKinds.has('warehouse')) {
return {
label: 'Фурнитура из наличия',
className: 'badge-yellow',
title: 'Вся фурнитура берётся со склада',
};
}
if (sourceKinds.size === 1 && sourceKinds.has('china')) {
return {
label: 'Фурнитура из Китая',
className: 'badge-blue',
title: 'Вся фурнитура идёт через Китай',
};
}
if (sourceKinds.size === 1 && sourceKinds.has('custom')) {
return {
label: 'Фурнитура под заказ',
className: 'badge-orange',
title: 'Фурнитура кастомная или закупается отдельно',
};
}
if (sourceKinds.has('warehouse')) {
return {
label: 'Частично из наличия',
className: 'badge-blue',
title: 'Часть фурнитуры со склада, часть под заказ',
};
}
return {
label: 'Смешанная фурнитура',
className: 'badge-gray',
title: 'В заказе несколько типов источников фурнитуры',
};
},
buildChinaMeta(purchases, items) {
const sortedPurchases = (purchases || []).slice().sort((a, b) => String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')));
if (sortedPurchases.length > 0) {
const latest = sortedPurchases[0];
const meta = CHINA_STATUS_META[latest.status] || { label: 'Есть закупка', className: 'badge-blue' };
return {
label: `${meta.label}${sortedPurchases.length > 1 ? ` · ${sortedPurchases.length}` : ''}`,
className: meta.className,
title: `Связанных закупок: ${sortedPurchases.length}`,
};
}
const hasChinaSource = (items || []).some(item => {
const source = String(item.hardware_source || item.packaging_source || item.source || '').toLowerCase();
const country = String(item.custom_country || 'china').toLowerCase();
return source === 'china' || (source === 'custom' && country === 'china');
});
if (hasChinaSource) {
return {
label: 'Нужна закупка',
className: 'badge-orange',
title: 'В заказе есть позиции с China source, но закупка ещё не создана',
};
}
return {
label: '—',
className: 'badge-gray',
title: 'Закупка в Китае не требуется',
};
},
buildProductionMeta(order) {
return {
label: App.statusLabel(order.status),
className: this.statusClassName(order.status),
};
},
statusClassName(status) {
if (status === 'completed') return 'badge-green';
if (status === 'cancelled' || status === 'deleted') return 'badge-red';
if (status === 'sample') return 'badge-blue';
if (PRODUCTION_STATUSES.includes(status)) return 'badge-yellow';
return 'badge-gray';
},
getModeOrders() {
if (this.mode === 'basket') {
return this.allOrders.filter(order => order.status === 'deleted');
}
if (this.mode === 'sample') {
return this.allOrders.filter(order => SAMPLE_STATUSES.includes(order.status));
}
if (this.mode === 'production') {
return this.allOrders.filter(order => PRODUCTION_STATUSES.includes(order.status));
}
if (this.mode === 'board') {
return this.allOrders.filter(order => order.status !== 'deleted');
}
if (this.mode === 'sky') {
return this.allOrders.filter(order => SKY_STATUSES.includes(order.status));
}
return this.allOrders.filter(order => ACTIVE_STATUSES.includes(order.status));
},
getVisibleOrders() {
const query = String(document.getElementById('orders-search')?.value || '').toLowerCase().trim();
const list = this.getModeOrders().slice();
const filtered = !query
? list
: list.filter(order =>
(order.order_name || '').toLowerCase().includes(query)
|| (order.client_name || '').toLowerCase().includes(query)
|| (order.manager_name || '').toLowerCase().includes(query)
);
const sortFn = this.mode === 'basket'
? (a, b) => String(b.deleted_at || b.updated_at || b.created_at || '').localeCompare(String(a.deleted_at || a.updated_at || a.created_at || ''))
: (a, b) => this.compareOrders(a, b);
return filtered.sort(sortFn);
},
compareOrders(a, b) {
const aKey = this.orderSortKey(a);
const bKey = this.orderSortKey(b);
if (aKey !== bKey) return String(aKey).localeCompare(String(bKey));
return String(b.created_at || '').localeCompare(String(a.created_at || ''));
},
orderSortKey(order) {
return order.deadline_end || order.deadline || order.deadline_start || order.created_at || '9999-12-31';
},
filterLocal() {
this.render();
},
render() {
this.updateModeControls();
const container = document.getElementById('orders-table-view');
const board = document.getElementById('orders-board-view');
if (!container) return;
if (this.isLoading && !this.allOrders.length) {
container.style.display = '';
if (board) board.style.display = 'none';
container.innerHTML = this.renderEmptyState('Загружаем заказы…');
return;
}
const visibleOrders = this.getVisibleOrders();
if (this.mode === 'board') {
container.style.display = 'none';
if (board) board.style.display = '';
this.renderBoard(visibleOrders);
return;
}
if (board) board.style.display = 'none';
container.style.display = '';
if (this.mode === 'sky') {
container.innerHTML = this.renderSkyView(visibleOrders);
return;
}
if (this.mode === 'basket') {
container.innerHTML = this.renderBasketView(visibleOrders);
return;
}
container.innerHTML = this.renderManagerView(visibleOrders);
},
renderManagerView(orders) {
const ordersLoadMeta = typeof getLastDataLoadMeta === 'function' ? getLastDataLoadMeta('orders') : null;
if (orders.length === 0 && ordersLoadMeta?.unavailable) {
return this.renderEmptyState('Не удалось загрузить заказы из базы. Попробуй обновить страницу чуть позже.');
}
if (orders.length === 0) {
return this.renderEmptyState('Нет заказов в этом списке');
}
const sections = this.getSectionsForMode();
const html = sections
.map(section => this.renderSection(section, orders.filter(order => section.statuses.includes(order.status))))
.filter(Boolean)
.join('');
return html || this.renderEmptyState('Нет заказов в этом списке');
},
renderSkyView(orders) {
const ordersLoadMeta = typeof getLastDataLoadMeta === 'function' ? getLastDataLoadMeta('orders') : null;
if (orders.length === 0 && ordersLoadMeta?.unavailable) {
return this.renderEmptyState('Не удалось загрузить заказы из базы. Попробуй обновить страницу чуть позже.');
}
if (orders.length === 0) {
return this.renderEmptyState('Нет заказов для списка Sky');
}
return `
| Заказ |
Старт |
Дедлайн |
Этап |
|
${orders.map(order => this.renderSkyRow(order)).join('')}
`;
},
renderBasketView(orders) {
if (orders.length === 0) {
return this.renderEmptyState('Корзина пуста');
}
return `
| Заказ |
Когда удалён |
Последний статус |
|
${orders.map(order => this.renderBasketRow(order)).join('')}
`;
},
renderSection(section, sectionOrders) {
if (sectionOrders.length === 0) return '';
if (this.collapsedSections[section.key] === undefined) {
this.collapsedSections[section.key] = !section.defaultOpen;
}
const collapsed = this.collapsedSections[section.key];
const totalRevenue = sectionOrders
.filter(order => (order.client_name || '').toUpperCase() !== 'B2C')
.reduce((sum, order) => sum + Number(this.metaByOrderId[order.id]?.financial?.revenue || order.total_revenue_plan || 0), 0);
const b2cCount = sectionOrders.filter(order => (order.client_name || '').toUpperCase() === 'B2C').length;
return `
| Заказ |
Дедлайн |
To-do RO |
Кто ведет |
Статус оплаты |
Статус фурнитуры |
Статус производства |
Китай |
|
${sectionOrders.map(order => this.renderManagerRow(order)).join('')}
`;
},
getSectionsForMode() {
if (this.mode === 'sample') return [ORDERS_SECTIONS.sample];
if (this.mode === 'production') return [ORDERS_SECTIONS.production];
return [ORDERS_SECTIONS.sample, ORDERS_SECTIONS.production];
},
renderBoard(orders) {
const container = document.getElementById('orders-board-view');
if (!container) return;
const ordersLoadMeta = typeof getLastDataLoadMeta === 'function' ? getLastDataLoadMeta('orders') : null;
if (orders.length === 0 && ordersLoadMeta?.unavailable) {
container.innerHTML = this.renderEmptyState('Не удалось загрузить заказы из базы. Попробуй обновить страницу чуть позже.');
return;
}
container.innerHTML = BOARD_COLUMNS.map(column => {
const columnOrders = orders.filter(order => column.statuses.includes(order.status));
const totalRevenue = columnOrders
.filter(order => (order.client_name || '').toUpperCase() !== 'B2C')
.reduce((sum, order) => sum + Number(this.metaByOrderId[order.id]?.financial?.revenue || order.total_revenue_plan || 0), 0);
const b2cCount = columnOrders.filter(order => (order.client_name || '').toUpperCase() === 'B2C').length;
return `
${columnOrders.length === 0
? '
Нет заказов
'
: columnOrders.map(order => this.renderBoardCard(order)).join('')}
`;
}).join('');
},
renderBoardCard(order) {
const payment = PAYMENT_STATUSES.find(item => item.key === (order.payment_status || 'not_sent')) || PAYMENT_STATUSES[0];
const financial = this.metaByOrderId[order.id]?.financial || null;
const margin = Number(financial?.marginPercent || order.margin_percent_plan || 0);
const revenue = Number(financial?.revenue || order.total_revenue_plan || 0);
let deadlineHtml = '';
if (order.deadline_end || order.deadline_start || order.deadline) {
const value = order.deadline_end || order.deadline_start || order.deadline;
const date = new Date(value);
if (!Number.isNaN(date.getTime())) {
const overdue = date < new Date() && order.status !== 'completed';
deadlineHtml = `
${overdue ? '!' : ''}${date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })}
`;
}
}
const subStage = PRODUCTION_SUBSTAGES[order.status];
const subStageBadge = subStage
? `◉ ${subStage}
`
: '';
const isB2C = (order.client_name || '').toUpperCase() === 'B2C';
const b2cBadge = isB2C
? 'B2C'
: '';
return `
${this.escHtml(order.order_name || 'Без названия')}${b2cBadge}
${subStageBadge}
${this.escHtml(order.client_name || '')} ${order.manager_name ? '/ ' + this.escHtml(order.manager_name) : ''}
${formatRub(revenue)}
${deadlineHtml}
`;
},
onBoardDragStart(event, orderId) {
event.dataTransfer.setData('text/plain', orderId);
event.currentTarget.classList.add('dragging');
},
onBoardDragOver(event) {
event.preventDefault();
event.currentTarget.classList.add('drag-over');
},
onBoardDragLeave(event) {
event.currentTarget.classList.remove('drag-over');
},
async onBoardDrop(event, newStatus) {
event.preventDefault();
event.currentTarget.classList.remove('drag-over');
const orderId = parseInt(event.dataTransfer.getData('text/plain'), 10);
const order = this.allOrders.find(item => item.id === orderId);
if (!order) return;
if (newStatus === 'production' && PRODUCTION_STATUSES.includes(order.status)) {
return;
}
const actualStatus = newStatus === 'production' ? 'production_casting' : newStatus;
if (order.status === actualStatus) return;
const guard = await this._ensureStatusTransitionAllowed(orderId, actualStatus);
if (!guard.ok) {
this.render();
return;
}
const oldStatus = order.status;
const managerName = App.getCurrentEmployeeName();
await updateOrderStatus(orderId, actualStatus);
await this._syncWarehouseByStatus(orderId, oldStatus, actualStatus, order.order_name, managerName || 'Неизвестный');
await this._syncReadyGoodsByStatus(orderId, order, oldStatus, actualStatus);
order.status = actualStatus;
await this.addChangeRecord(orderId, {
field: 'status',
old_value: App.statusLabel(oldStatus),
new_value: App.statusLabel(actualStatus),
manager: managerName || 'Неизвестный',
});
App.toast(`Статус: ${App.statusLabel(actualStatus)}`);
this.render();
},
toggleSection(key) {
this.collapsedSections[key] = !this.collapsedSections[key];
const body = document.getElementById(`orders-section-body-${key}`);
const icon = document.getElementById(`orders-section-icon-${key}`);
if (body) body.style.display = this.collapsedSections[key] ? 'none' : '';
if (icon) icon.textContent = this.collapsedSections[key] ? '▸' : '▾';
},
renderManagerRow(order) {
const meta = this.metaByOrderId[order.id] || this.buildOrderMeta(order, [], [], []);
return `
| ${this.renderOrderCell(order)} |
${this.renderDeadlineCell(order)} |
${this.renderBadge(meta.todo.label, meta.todo.className, meta.todo.title)} |
${this.renderManagerCell(order)} |
${this.renderPaymentBadge(order.payment_status || 'not_sent')} |
${this.renderBadge(meta.hardware.label, meta.hardware.className, meta.hardware.title)} |
${this.renderStatusControl(order)} |
${this.renderBadge(meta.china.label, meta.china.className, meta.china.title)} |
${this.renderActionButtons(order)} |
`;
},
renderSkyRow(order) {
return `
| ${this.renderOrderCell(order)} |
${this.formatOrderStart(order)} |
${this.renderDeadlineCell(order)} |
${this.renderBadge(App.statusLabel(order.status), this.statusClassName(order.status))} |
${this.renderActionButtons(order, { compact: true })} |
`;
},
renderBasketRow(order) {
return `
| ${this.renderOrderCell(order)} |
${App.formatDate(order.deleted_at || order.updated_at || order.created_at)} |
${this.renderBadge(App.statusLabel(order.status), 'badge-red')} |
${this.renderActionButtons(order, { compact: true })} |
`;
},
renderOrderCell(order) {
const client = this.escHtml(order.client_name || '—');
const b2c = (order.client_name || '').toUpperCase() === 'B2C'
? this.renderBadge('B2C', 'badge-blue')
: '';
return ``;
},
renderManagerCell(order) {
const manager = this.escHtml(order.manager_name || '—');
const start = this.formatOrderStart(order);
return `
${manager}
Старт: ${start}
`;
},
renderDeadlineCell(order) {
const deadline = this.formatOrderDeadline(order);
const overdue = this.isOverdue(order);
if (deadline === '—') {
return '—';
}
return `${deadline}`;
},
renderBadge(label, className, title = '') {
return `${this.escHtml(label || '—')}`;
},
renderPaymentBadge(paymentStatus) {
const payment = PAYMENT_STATUSES.find(item => item.key === paymentStatus) || PAYMENT_STATUSES[0];
return this.renderBadge(payment.label, `badge-${payment.color}`);
},
renderStatusControl(order) {
if (order.status === 'deleted') {
return 'Удалён';
}
return ``;
},
renderActionButtons(order, options = {}) {
const compact = options.compact === true;
if (order.status === 'deleted') {
return `
`;
}
return `
${compact ? '' : ``}
`;
},
renderEmptyState(text) {
return `
☰
${this.escHtml(text || 'Нет заказов')}
`;
},
_formatCompletedStatusBlockMessage(summary) {
if (!summary || summary.error) {
return 'Нельзя перевести заказ в «Готово»: не удалось проверить, все ли позиции со склада отмечены как «собрано».';
}
return `Нельзя перевести заказ в «Готово»: не все позиции со склада отмечены как «собрано». Собрано ${summary.readyRows || 0} из ${summary.totalRows || 0}.`;
},
async _ensureStatusTransitionAllowed(orderId, newStatus, options = {}) {
if (newStatus !== 'completed') {
return { ok: true };
}
if (typeof Warehouse === 'undefined'
|| !Warehouse
|| typeof Warehouse.getOrderProjectHardwareCompletion !== 'function') {
return { ok: true };
}
let summary = null;
try {
summary = await Warehouse.getOrderProjectHardwareCompletion(orderId, options.orderDetail || null);
} catch (error) {
console.error('Orders._ensureStatusTransitionAllowed failed:', error);
summary = { error: 'exception', totalRows: 0, readyRows: 0, pendingRows: 0 };
}
if (summary && summary.canComplete) {
return { ok: true, summary };
}
const message = this._formatCompletedStatusBlockMessage(summary);
if (options.toast !== false && typeof App !== 'undefined' && App && typeof App.toast === 'function') {
App.toast(message);
}
return {
ok: false,
reason: summary && summary.error ? 'project_hardware_check_failed' : 'project_hardware_incomplete',
message,
summary,
};
},
formatOrderDeadline(order) {
const start = order.deadline_start || '';
const end = order.deadline_end || order.deadline || '';
if (start && end && String(start) !== String(end)) {
return `${App.formatDate(start)} → ${App.formatDate(end)}`;
}
if (end) return App.formatDate(end);
if (start) return App.formatDate(start);
return '—';
},
formatOrderStart(order) {
return App.formatDate(order.deadline_start || order.created_at || '');
},
isOverdue(order) {
const target = String(order.deadline_end || order.deadline || '').slice(0, 10);
if (!target) return false;
if (['completed', 'cancelled', 'deleted'].includes(order.status)) return false;
return target < App.todayLocalYMD();
},
shortRub(amount) {
if (amount >= 1000000) return (amount / 1000000).toFixed(1) + 'M';
if (amount >= 1000) return (amount / 1000).toFixed(0) + 'K';
return amount.toFixed(0);
},
// ==========================================
// STATUS CHANGE
// ==========================================
async onStatusChange(orderId, newStatus, oldStatus) {
if (newStatus === oldStatus) return;
const guard = await this._ensureStatusTransitionAllowed(orderId, newStatus);
if (!guard.ok) {
this.render();
return;
}
const managerName = App.getCurrentEmployeeName();
await updateOrderStatus(orderId, newStatus);
const order = this.allOrders.find(item => item.id === orderId);
await this._syncWarehouseByStatus(orderId, oldStatus, newStatus, order && order.order_name, managerName || 'Неизвестный');
await this._syncReadyGoodsByStatus(orderId, order, oldStatus, newStatus);
await this.addChangeRecord(orderId, {
field: 'status',
old_value: App.statusLabel(oldStatus),
new_value: App.statusLabel(newStatus),
manager: managerName || 'Неизвестный',
});
App.toast(`Статус: ${App.statusLabel(newStatus)}`);
this.loadList();
},
_isConsumedStatus(status) {
return ['production_casting', 'production_hardware', 'production_packaging', 'in_production', 'delivery', 'completed'].includes(status);
},
_collectWarehouseDemand(items, options) {
const includeHardware = !options || options.hardware !== false;
const includePackaging = !options || options.packaging !== false;
const demand = new Map();
const explicitHardwareIds = new Set();
const add = (itemId, qty) => {
if (!itemId || qty <= 0) return;
demand.set(itemId, (demand.get(itemId) || 0) + qty);
};
(items || []).forEach(item => {
const qty = parseFloat(
item.quantity
?? item.hardware_qty
?? item.packaging_qty
?? item.qty
) || 0;
if (qty <= 0) return;
if (includeHardware && item.item_type === 'hardware' && item.hardware_source === 'warehouse' && item.hardware_warehouse_item_id) {
explicitHardwareIds.add(Number(item.hardware_warehouse_item_id || 0));
}
});
(items || []).forEach(item => {
const qty = parseFloat(
item.quantity
?? item.hardware_qty
?? item.packaging_qty
?? item.qty
) || 0;
if (qty <= 0) return;
if (includeHardware && item.item_type === 'hardware' && item.hardware_source === 'warehouse' && item.hardware_warehouse_item_id) {
add(item.hardware_warehouse_item_id, qty);
}
if (includePackaging && item.item_type === 'packaging' && item.packaging_source === 'warehouse' && item.packaging_warehouse_item_id) {
add(item.packaging_warehouse_item_id, qty);
}
if (includeHardware && item.item_type === 'pendant' && typeof getPendantWarehouseDemandRows === 'function') {
getPendantWarehouseDemandRows(item).forEach(row => {
add(row.warehouse_item_id, parseFloat(row.qty) || 0);
});
}
if (includeHardware && item.item_type === 'product' && typeof getProductWarehouseDemandRows === 'function') {
getProductWarehouseDemandRows(item).forEach(row => {
if (explicitHardwareIds.has(Number(row.warehouse_item_id || 0))) return;
add(row.warehouse_item_id, parseFloat(row.qty) || 0);
});
}
});
return demand;
},
async _collectWarehouseReturnDemand(orderId, demand) {
const history = typeof loadWarehouseHistory === 'function'
? await loadWarehouseHistory()
: [];
const seenItems = new Set();
const netDeltaByItem = new Map();
(history || []).forEach(entry => {
if (Number(entry.order_id || 0) !== Number(orderId || 0)) return;
const itemId = Number(entry.item_id || 0);
if (!itemId) return;
const note = String(entry.notes || '');
const type = String(entry.type || '').toLowerCase();
if (!/при смене статуса/i.test(note)) return;
if (type !== 'deduction' && type !== 'addition') return;
const delta = parseFloat(entry.qty_change || 0) || 0;
if (!delta) return;
seenItems.add(itemId);
netDeltaByItem.set(itemId, (netDeltaByItem.get(itemId) || 0) + delta);
});
const returnDemand = new Map();
demand.forEach((qty, itemId) => {
const normalizedItemId = Number(itemId || 0);
const normalizedQty = parseFloat(qty) || 0;
if (!normalizedItemId || normalizedQty <= 0) return;
if (!seenItems.has(normalizedItemId)) {
returnDemand.set(normalizedItemId, normalizedQty);
return;
}
const netDelta = parseFloat(netDeltaByItem.get(normalizedItemId) || 0) || 0;
const consumedQty = Math.max(0, -netDelta);
const returnQty = Math.min(normalizedQty, consumedQty);
if (returnQty > 0) {
returnDemand.set(normalizedItemId, returnQty);
}
});
return returnDemand;
},
async _syncWarehouseByStatus(orderId, oldStatus, newStatus, orderName, managerName) {
if (oldStatus === newStatus) return;
const data = await loadOrder(orderId);
if (!data) return;
if (typeof Warehouse !== 'undefined' && Warehouse.syncProjectHardwareOrderState) {
await Warehouse.syncProjectHardwareOrderState({
orderId,
orderName,
managerName,
status: newStatus,
currentItems: data.items || [],
previousItems: data.items || [],
});
return;
}
const demand = this._collectWarehouseDemand(data.items, { hardware: false, packaging: true });
if (demand.size === 0) return;
const wasConsumed = this._isConsumedStatus(oldStatus);
const nowConsumed = this._isConsumedStatus(newStatus);
const nowSample = newStatus === 'sample';
const reservations = await loadWarehouseReservations();
const nowIso = new Date().toISOString();
reservations.forEach(reservation => {
if (reservation.status === 'active' && reservation.source === 'order_calc' && reservation.order_id === orderId) {
reservation.status = 'released';
reservation.released_at = nowIso;
}
});
await saveWarehouseReservations(reservations);
if (wasConsumed && !nowConsumed) {
const returnDemand = await this._collectWarehouseReturnDemand(orderId, demand);
for (const [itemId, qty] of returnDemand.entries()) {
await Warehouse.adjustStock(
itemId,
qty,
'addition',
orderName || 'Заказ',
`Возврат на склад при смене статуса: ${App.statusLabel(oldStatus)} → ${App.statusLabel(newStatus)}`,
managerName || '',
{ order_id: orderId }
);
}
}
if (nowSample) {
const freshReservations = await loadWarehouseReservations();
const items = await loadWarehouseItems();
const activeReservedByItem = new Map();
let hadShortage = false;
freshReservations.forEach(reservation => {
if (reservation.status !== 'active') return;
activeReservedByItem.set(reservation.item_id, (activeReservedByItem.get(reservation.item_id) || 0) + (parseFloat(reservation.qty) || 0));
});
const toInsert = [];
demand.forEach((qty, itemId) => {
const whItem = items.find(item => item.id === itemId);
if (!whItem) return;
const stock = parseFloat(whItem.qty) || 0;
const reserved = activeReservedByItem.get(itemId) || 0;
const available = Math.max(0, stock - reserved);
const reserveQty = Math.min(qty, available);
if (reserveQty > 0) {
toInsert.push({
id: Date.now() + Math.floor(Math.random() * 1000),
item_id: itemId,
order_id: orderId,
order_name: orderName || 'Заказ',
qty: reserveQty,
status: 'active',
source: 'order_calc',
created_at: new Date().toISOString(),
created_by: managerName || '',
});
}
if (reserveQty < qty) {
hadShortage = true;
}
});
if (toInsert.length > 0) {
await saveWarehouseReservations([...freshReservations, ...toInsert]);
}
if (hadShortage) {
App.toast('Часть упаковки не встала в полный резерв: недостаточно остатка');
}
return;
}
if (!wasConsumed && nowConsumed) {
let hadShortage = false;
for (const [itemId, qty] of demand.entries()) {
const result = await Warehouse.adjustStock(
itemId,
-qty,
'deduction',
orderName || 'Заказ',
`Списание при смене статуса: ${App.statusLabel(oldStatus)} → ${App.statusLabel(newStatus)}`,
managerName || '',
{ order_id: orderId }
);
if (result && result.clamped && (result.requestedQtyChange || 0) < 0) {
hadShortage = true;
}
}
if (hadShortage) {
App.toast('Часть упаковки списана не полностью: недостаточно остатка');
}
}
},
async _moveToReadyGoods(orderId, order) {
try {
if (typeof Warehouse !== 'undefined' && Warehouse.moveOrderToReadyGoods) {
const count = await Warehouse.moveOrderToReadyGoods(orderId, order && order.order_name);
if (count > 0) {
App.toast(`${count} товар(ов) → Готовая продукция`);
}
}
} catch (e) {
console.warn('moveToReadyGoods warning:', e);
}
},
_shouldSyncOrderToReadyGoods(order) {
return (order?.client_name || '').toUpperCase() === 'B2C';
},
async _syncReadyGoodsByStatus(orderId, order, oldStatus, newStatus) {
if (oldStatus !== 'completed' && newStatus === 'completed' && this._shouldSyncOrderToReadyGoods(order)) {
await this._moveToReadyGoods(orderId, order);
return;
}
if (oldStatus === 'completed' && newStatus !== 'completed') {
try {
if (typeof Warehouse !== 'undefined' && Warehouse.removeOrderFromReadyGoods) {
const count = await Warehouse.removeOrderFromReadyGoods(orderId, order && order.order_name, newStatus);
if (count > 0) {
App.toast(`${count} товар(ов) убрано из Готовой продукции`);
}
}
} catch (e) {
console.warn('rollbackReadyGoods warning:', e);
}
}
},
editOrder(orderId) {
App.navigate('order-detail', true, orderId);
},
_cloneDeep(value) {
if (value == null) return value;
try {
return JSON.parse(JSON.stringify(value));
} catch (e) {
return Array.isArray(value) ? value.slice() : { ...value };
}
},
_parseClonePayload(value) {
if (!value) return null;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (e) {
return null;
}
}
return value;
},
_asCloneArray(value) {
const parsed = this._parseClonePayload(value);
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
},
_pickFirstCloneArray(candidates) {
for (const candidate of candidates || []) {
const arr = this._asCloneArray(candidate);
if (arr.length) return arr;
}
return [];
},
_looksLikeFlattenedCloneItems(items) {
return Array.isArray(items) && items.some(item =>
item && typeof item === 'object' && (
item.item_type != null
|| item.order_id != null
|| item.product_name != null
|| item.cost_total != null
)
);
},
_extractSnapshotItems(snapshot) {
const parsed = this._parseClonePayload(snapshot);
if (Array.isArray(parsed)) return parsed;
if (!parsed || typeof parsed !== 'object') return [];
return this._pickFirstCloneArray([
parsed.items,
parsed.order_items,
parsed.product_items,
parsed.products,
]);
},
_prepareClonedOrderItem(item) {
const cloned = this._cloneDeep(item) || {};
delete cloned.id;
delete cloned.order_id;
delete cloned.created_at;
delete cloned.updated_at;
return cloned;
},
_minutesToCloneSpeedPerHour(value) {
const minutes = Number(value);
if (!(minutes > 0)) return 0;
return Math.round(minutes * 60 * 100) / 100;
},
_restoreCloneItemsFromOrderPayload(order) {
if (!order || typeof order !== 'object') return [];
const calculatorData = this._parseClonePayload(order.calculator_data) || {};
const itemsSnapshot = this._parseClonePayload(order.items_snapshot);
const hardwareSnapshot = this._parseClonePayload(order.hardware_snapshot);
const packagingSnapshot = this._parseClonePayload(order.packaging_snapshot);
const directOrderItems = this._asCloneArray(order.items);
if (this._looksLikeFlattenedCloneItems(directOrderItems)
&& !directOrderItems.some(item => item && typeof item === 'object' && (item.item_type === 'hardware' || item.item_type === 'packaging' || item.item_type === 'pendant' || item.item_type === 'extra_cost'))) {
// Treat plain `order.items` without explicit item types as calculator product rows.
} else if (this._looksLikeFlattenedCloneItems(directOrderItems)) {
return directOrderItems.map(item => this._prepareClonedOrderItem(item));
}
const snapshotItems = this._extractSnapshotItems(itemsSnapshot);
const snapshotHasItemTypes = this._looksLikeFlattenedCloneItems(snapshotItems)
&& snapshotItems.some(item => item && typeof item === 'object' && item.item_type);
const snapshotHasNonProductTypes = snapshotHasItemTypes
&& snapshotItems.some(item => item && typeof item === 'object' && item.item_type && item.item_type !== 'product');
if (snapshotHasNonProductTypes) {
return snapshotItems.map(item => this._prepareClonedOrderItem(item));
}
const snapshotProductItems = snapshotHasItemTypes
? snapshotItems.filter(item => !item?.item_type || item.item_type === 'product')
: snapshotItems;
const snapshotHardwareItems = snapshotHasItemTypes
? snapshotItems.filter(item => item?.item_type === 'hardware')
: [];
const snapshotPackagingItems = snapshotHasItemTypes
? snapshotItems.filter(item => item?.item_type === 'packaging')
: [];
const snapshotExtraCosts = snapshotHasItemTypes
? snapshotItems.filter(item => item?.item_type === 'extra_cost')
: [];
const snapshotPendants = snapshotHasItemTypes
? snapshotItems.filter(item => item?.item_type === 'pendant')
: [];
const productItems = this._pickFirstCloneArray([
directOrderItems,
order.productItems,
order.product_items,
order.calculator_items,
calculatorData.items,
calculatorData.productItems,
calculatorData.product_items,
calculatorData.calculator_items,
snapshotProductItems,
]);
const hardwareItems = this._pickFirstCloneArray([
order.hardwareItems,
order.hardware_items,
calculatorData.hardwareItems,
calculatorData.hardware_items,
hardwareSnapshot,
hardwareSnapshot && hardwareSnapshot.items,
snapshotHardwareItems,
]);
const packagingItems = this._pickFirstCloneArray([
order.packagingItems,
order.packaging_items,
calculatorData.packagingItems,
calculatorData.packaging_items,
packagingSnapshot,
packagingSnapshot && packagingSnapshot.items,
snapshotPackagingItems,
]);
const extraCosts = this._pickFirstCloneArray([
order.extraCosts,
order.extra_costs,
calculatorData.extraCosts,
calculatorData.extra_costs,
snapshotExtraCosts,
]);
const pendants = this._pickFirstCloneArray([
order.pendants,
order.pendant_items,
calculatorData.pendants,
calculatorData.pendant_items,
snapshotPendants,
]);
const restored = [];
productItems.forEach((item, index) => {
const cloned = this._prepareClonedOrderItem(item);
cloned.item_number = Number.isFinite(Number(cloned.item_number)) ? Number(cloned.item_number) : index + 1;
cloned.item_type = cloned.item_type || 'product';
if (Array.isArray(cloned.printings)) cloned.printings = JSON.stringify(cloned.printings);
if (Array.isArray(cloned.colors)) cloned.colors = JSON.stringify(cloned.colors);
if (cloned.color_solution_attachment && typeof cloned.color_solution_attachment === 'object') {
cloned.color_solution_attachment = JSON.stringify(cloned.color_solution_attachment);
}
restored.push(cloned);
});
hardwareItems.forEach((hw, index) => {
const cloned = this._prepareClonedOrderItem(hw);
const qty = cloned.quantity ?? cloned.qty ?? 0;
const speedPerHour = cloned.hardware_assembly_speed
?? cloned.assembly_speed
?? this._minutesToCloneSpeedPerHour(cloned.assembly_minutes);
restored.push({
...cloned,
item_number: Number.isFinite(Number(cloned.item_number)) ? Number(cloned.item_number) : 100 + index,
item_type: 'hardware',
product_name: cloned.product_name || cloned.name || '',
quantity: qty,
hardware_assembly_speed: speedPerHour,
hardware_price_per_unit: cloned.hardware_price_per_unit ?? cloned.price ?? 0,
hardware_delivery_per_unit: cloned.hardware_delivery_per_unit ?? cloned.delivery_price ?? 0,
hardware_delivery_total: cloned.hardware_delivery_total ?? cloned.delivery_total ?? 0,
sell_price_hardware: cloned.sell_price_hardware ?? cloned.sell_price ?? 0,
target_price_hardware: cloned.target_price_hardware ?? cloned.target_price ?? 0,
hardware_source: cloned.hardware_source || cloned.source || 'custom',
custom_country: cloned.custom_country || 'china',
hardware_warehouse_item_id: cloned.hardware_warehouse_item_id ?? cloned.warehouse_item_id ?? null,
hardware_warehouse_sku: cloned.hardware_warehouse_sku ?? cloned.warehouse_sku ?? '',
china_item_id: cloned.china_item_id ?? null,
china_delivery_method: cloned.china_delivery_method || 'avia',
price_cny: cloned.price_cny ?? 0,
weight_grams: cloned.weight_grams ?? 0,
hardware_parent_item_index: cloned.hardware_parent_item_index ?? cloned.parent_item_index ?? null,
hardware_from_template: cloned.hardware_from_template ?? cloned._from_template ?? false,
});
});
packagingItems.forEach((pkg, index) => {
const cloned = this._prepareClonedOrderItem(pkg);
const qty = cloned.quantity ?? cloned.qty ?? 0;
const speedPerHour = cloned.packaging_assembly_speed
?? cloned.assembly_speed
?? this._minutesToCloneSpeedPerHour(cloned.assembly_minutes);
restored.push({
...cloned,
item_number: Number.isFinite(Number(cloned.item_number)) ? Number(cloned.item_number) : 200 + index,
item_type: 'packaging',
product_name: cloned.product_name || cloned.name || '',
quantity: qty,
packaging_assembly_speed: speedPerHour,
packaging_price_per_unit: cloned.packaging_price_per_unit ?? cloned.price ?? 0,
packaging_delivery_per_unit: cloned.packaging_delivery_per_unit ?? cloned.delivery_price ?? 0,
packaging_delivery_total: cloned.packaging_delivery_total ?? cloned.delivery_total ?? 0,
sell_price_packaging: cloned.sell_price_packaging ?? cloned.sell_price ?? 0,
target_price_packaging: cloned.target_price_packaging ?? cloned.target_price ?? 0,
packaging_source: cloned.packaging_source || cloned.source || 'custom',
custom_country: cloned.custom_country || 'china',
packaging_warehouse_item_id: cloned.packaging_warehouse_item_id ?? cloned.warehouse_item_id ?? null,
packaging_warehouse_sku: cloned.packaging_warehouse_sku ?? cloned.warehouse_sku ?? '',
china_item_id: cloned.china_item_id ?? null,
china_delivery_method: cloned.china_delivery_method || 'avia',
price_cny: cloned.price_cny ?? 0,
weight_grams: cloned.weight_grams ?? 0,
packaging_parent_item_index: cloned.packaging_parent_item_index ?? cloned.parent_item_index ?? null,
});
});
extraCosts.forEach((extraCost, index) => {
const cloned = this._prepareClonedOrderItem(extraCost);
const amount = cloned.amount ?? cloned.cost_total ?? cloned.sell_price_item ?? 0;
restored.push({
...cloned,
item_number: Number.isFinite(Number(cloned.item_number)) ? Number(cloned.item_number) : 300 + index,
item_type: 'extra_cost',
product_name: cloned.product_name || cloned.name || 'Доп. доход',
quantity: cloned.quantity || 1,
cost_total: amount,
sell_price_item: cloned.sell_price_item ?? amount,
});
});
pendants.forEach((pendant, index) => {
const cloned = this._prepareClonedOrderItem(pendant);
restored.push({
...cloned,
item_number: Number.isFinite(Number(cloned.item_number)) ? Number(cloned.item_number) : 400 + index,
item_type: 'pendant',
product_name: cloned.product_name || ('Подвес "' + (cloned.name || '') + '"'),
quantity: cloned.quantity || 0,
cost_total: cloned.cost_total ?? cloned.result?.costPerUnit ?? 0,
sell_price_item: cloned.sell_price_item ?? cloned.result?.sellPerUnit ?? cloned._totalSellPerUnit ?? 0,
});
});
return restored;
},
async cloneOrder(orderId) {
App.toast('Копирование заказа...');
try {
const data = await loadOrder(orderId);
if (!data) {
App.toast('Ошибка загрузки', 'error');
return;
}
const clonedOrder = { ...data.order };
delete clonedOrder.id;
clonedOrder.order_name = (clonedOrder.order_name || 'Заказ') + ' (копия)';
clonedOrder.status = 'draft';
delete clonedOrder.created_at;
delete clonedOrder.updated_at;
const sourceItems = Array.isArray(data.items) && data.items.length
? data.items
: this._restoreCloneItemsFromOrderPayload(data.order);
const clonedItems = (sourceItems || []).map(item => this._prepareClonedOrderItem(item));
const newId = await saveOrder(clonedOrder, clonedItems);
if (newId) {
App.toast('Заказ скопирован');
Calculator.loadOrder(newId);
}
} catch (e) {
console.error('Clone order error:', e);
App.toast('Ошибка копирования', 'error');
}
},
async confirmDelete(orderId) {
const order = this.allOrders.find(item => item.id === orderId);
const name = order && order.order_name ? order.order_name : 'Без названия';
if (confirm(`Перенести заказ "${name}" в корзину?`)) {
const managerName = App.getCurrentEmployeeName();
await deleteOrder(orderId);
await this.addChangeRecord(orderId, {
field: 'status',
old_value: 'Активный',
new_value: 'Удалён (в корзину)',
manager: managerName,
});
App.toast('Заказ перемещён в корзину');
this.loadList();
}
},
async restoreOrder(orderId) {
await restoreOrder(orderId);
await this.addChangeRecord(orderId, {
field: 'status',
old_value: 'Удалён',
new_value: 'Черновик (восстановлен)',
manager: App.getCurrentEmployeeName(),
});
App.toast('Заказ восстановлен');
this.loadList();
},
async confirmPermanentDelete(orderId) {
const order = this.allOrders.find(item => item.id === orderId);
const name = order && order.order_name ? order.order_name : 'Без названия';
if (confirm(`ВНИМАНИЕ: Удалить заказ "${name}" НАВСЕГДА? Это действие нельзя отменить!`)) {
await permanentDeleteOrder(orderId);
App.toast('Заказ удалён навсегда');
this.loadList();
}
},
// ==========================================
// CHANGE HISTORY
// ==========================================
async addChangeRecord(orderId, change) {
const history = await this.loadHistory(orderId);
history.push({
date: new Date().toISOString(),
manager: change.manager || '',
field: change.field || '',
old_value: change.old_value || '',
new_value: change.new_value || '',
description: change.description || '',
});
await this.saveHistory(orderId, history);
},
async loadHistory(orderId) {
const key = 'ro_calc_order_history_' + orderId;
try {
return JSON.parse(localStorage.getItem(key)) || [];
} catch (e) {
return [];
}
},
async saveHistory(orderId, history) {
const key = 'ro_calc_order_history_' + orderId;
localStorage.setItem(key, JSON.stringify(history));
},
escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
},
};