Heritage & More ERP

نظام إدارة الموارد المتكامل

💡 الحساب الافتراضي:
admin@pos.local / pos1234
📱
ثبّت التطبيق للوصول السريع والعمل بدون إنترنت
/* ═══════════════════════════════════════════════════════════════ HELPERS — Utilities ═══════════════════════════════════════════════════════════════ */ const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); const uid = () => Math.random().toString(36).slice(2, 9) + Date.now().toString(36).slice(-3); const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); const money = n => new Intl.NumberFormat('en-EG', { maximumFractionDigits: 2 }).format(Number(n) || 0) + ' ' + CONFIG.currency; const money0 = n => new Intl.NumberFormat('en-EG', { maximumFractionDigits: 0 }).format(Number(n) || 0) + ' ' + CONFIG.currency; const moneyCur = (n, code) => { const c = (DB.currencies || []).find(x => x.code === code) || { symbol: code }; return new Intl.NumberFormat('en-EG', { maximumFractionDigits: 2 }).format(Number(n) || 0) + ' ' + (c.symbol || code); }; const fmtDate = d => { try { return new Intl.DateTimeFormat('ar-EG', { year:'numeric', month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }).format(new Date(d)); } catch(e) { return String(d).slice(0,16); } }; const fmtDateShort = d => { try { return new Intl.DateTimeFormat('ar-EG', { year:'numeric', month:'short', day:'numeric' }).format(new Date(d)); } catch(e) { return String(d).slice(0,10); } }; const fmtMonth = (year, month) => { const names = ['يناير','فبراير','مارس','أبريل','مايو','يونيو','يوليو','أغسطس','سبتمبر','أكتوبر','نوفمبر','ديسمبر']; return names[month - 1] + ' ' + year; }; const initials = n => String(n || '?').trim().split(/\s+/).map(s => s[0] || '').slice(0,2).join('').toUpperCase(); const hashPass = p => { try { return btoa(unescape(encodeURIComponent('hm$erp$' + p))); } catch(e) { return 'hm$erp$' + p; } }; const todayISO = () => new Date().toISOString().slice(0,10); const addDays = (days) => { const d = new Date(); d.setDate(d.getDate() + days); return d.toISOString().slice(0,10); }; const daysBetween = (s, e) => Math.round((new Date(e) - new Date(s)) / 86400000) + 1; function toast(msg, type) { const t = $('#toast'); if (!t) return; t.textContent = msg; t.className = 'toast on' + (type ? ' ' + type : ''); clearTimeout(t._t); t._t = setTimeout(() => t.classList.remove('on'), 3200); } function placeholder(label) { const svg = '' + esc(String(label || '').slice(0,24)) + ''; return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); } function imgOr(url, label) { return (url && String(url).trim()) ? url : placeholder(label); } function downloadFile(name, content, mime) { const blob = new Blob([content], { type: mime || 'application/octet-stream' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 3000); } function isLocalProduct(p) { return !!p._local; } function stat(label, val) { return '
' + label + '
' + val + '
'; } function sanitizePhone(phone) { let p = String(phone || '').replace(/\D/g, ''); if (p.startsWith('0')) p = '20' + p.slice(1); else if (p.startsWith('1') && p.length === 10) p = '20' + p; else if (!p.startsWith('20') && p.length > 0) p = '20' + p; return p; } /* ═══════════════════════════════════════════════════════════════ CATEGORY FILTERS ═══════════════════════════════════════════════════════════════ */ function getAllCategories() { return { mainCat: Array.from(new Set((DB.products || []).map(p => p.mainCat).filter(Boolean))).sort(), subCat: Array.from(new Set((DB.products || []).map(p => p.subCat).filter(Boolean))).sort(), sizeCat: Array.from(new Set((DB.products || []).map(p => p.sizeCat).filter(Boolean))).sort(), designCat: Array.from(new Set((DB.products || []).map(p => p.designCat).filter(Boolean))).sort(), designName: Array.from(new Set((DB.products || []).map(p => p.designName).filter(Boolean))).sort() }; } function renderCategoryFilters(prefix, current, onChangePrefix) { const cats = getAllCategories(); return '
' + '' + '' + '' + '' + '' + '
'; } function matchCategoryFilters(p, filters) { if (filters.mainCat && p.mainCat !== filters.mainCat) return false; if (filters.subCat && p.subCat !== filters.subCat) return false; if (filters.sizeCat && p.sizeCat !== filters.sizeCat) return false; if (filters.designCat && p.designCat !== filters.designCat) return false; if (filters.designName && p.designName !== filters.designName) return false; return true; } /* ═══════════════════════════════════════════════════════════════ RAW MATERIALS HELPERS ═══════════════════════════════════════════════════════════════ */ function getMaterialById(id) { return (DB.rawMaterials || []).find(m => m.id === id); } function getMaterialByCode(code) { return (DB.rawMaterials || []).find(m => m.code === code); } function getMaterialStock(materialId) { return (DB.materialsBatches || []) .filter(b => b.materialId === materialId) .reduce((s, b) => s + (Number(b.qtyRemaining) || 0), 0); } function getMaterialStockByWarehouse(materialId, warehouseId) { if (!warehouseId) return getMaterialStock(materialId); return (DB.materialsBatches || []) .filter(b => b.materialId === materialId && b.warehouseId === warehouseId) .reduce((s, b) => s + (Number(b.qtyRemaining) || 0), 0); } function getMaterialAvgCost(materialId) { const batches = (DB.materialsBatches || []).filter(b => b.materialId === materialId && (b.qtyRemaining || 0) > 0); if (!batches.length) return 0; const totalQty = batches.reduce((s, b) => s + (Number(b.qtyRemaining) || 0), 0); const totalVal = batches.reduce((s, b) => s + (Number(b.qtyRemaining) || 0) * (Number(b.unitCost) || 0), 0); return totalQty > 0 ? totalVal / totalQty : 0; } function getMaterialBatches(materialId) { return (DB.materialsBatches || []).filter(b => b.materialId === materialId); } function nextMaterialCode() { const nums = (DB.rawMaterials || []).map(m => { const x = String(m.code || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'RM-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } function nextBatchNo() { const nums = (DB.materialsBatches || []).map(b => { const x = String(b.batchNo || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'B-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } function getMaterialCategoriesTree() { const all = DB.materialCategories || []; const build = (parentId) => all .filter(c => (c.parentId || null) === parentId) .sort((a, b) => (a.order || 0) - (b.order || 0)) .map(c => ({ ...c, children: build(c.id) })); return build(null); } function getUnitName(unitId) { const u = (DB.units || []).find(x => x.id === unitId); return u ? u.nameAr : (unitId || ''); } function getUnitByName(name) { return (DB.units || []).find(u => u.nameAr === name || u.nameEn === name); } function ensureUnit(nameAr) { if (!nameAr) return null; let u = getUnitByName(nameAr); if (!u) { u = { id: 'u-' + uid(), nameAr, nameEn: '', active: true }; if (!DB.units) DB.units = []; DB.units.push(u); } return u; } function logAudit(action, details) { if (!DB.auditLog) DB.auditLog = []; DB.auditLog.push({ id: uid(), at: new Date().toISOString(), action, details, userId: currentUser?.id || null, userName: currentUser?.name || '—' }); if (DB.auditLog.length > 500) DB.auditLog = DB.auditLog.slice(-500); } /* ═══════════════════════════════════════════════════════════════ COSTING ENGINE ═══════════════════════════════════════════════════════════════ */ function calcProductMaterialCost(sku) { const bom = (DB.bomItems || []).filter(b => b.productSku === sku); return bom.reduce((sum, item) => { const avgCost = getMaterialAvgCost(item.materialId); const qty = Number(item.qtyPerUnit) || 0; const waste = 1 + (Number(item.wastagePct) || 0) / 100; return sum + (avgCost * qty * waste); }, 0); } function findLaborRate(stageId, productSku, sizeCat, designCat, designName, qty) { const rates = (DB.laborRates || []).filter(r => r.active !== false && r.stageId === stageId && qty >= (Number(r.minQty) || 1) && (r.maxQty == null || Number(r.maxQty) === 0 || qty <= Number(r.maxQty)) ); const scored = rates.map(r => { let score = 0; if (r.productSku && r.productSku === productSku) score += 100; else if (r.productSku && r.productSku !== productSku) score -= 1000; if (r.sizeCat && r.sizeCat === sizeCat) score += 20; else if (r.sizeCat && r.sizeCat !== sizeCat) score -= 200; if (r.designCat && r.designCat === designCat) score += 10; else if (r.designCat && r.designCat !== designCat) score -= 100; if (r.designName && r.designName === designName) score += 5; else if (r.designName && r.designName !== designName) score -= 50; if (r.maxQty != null && Number(r.maxQty) > 0) score += 2; return { rate: r, score }; }).filter(x => x.score > -50).sort((a, b) => b.score - a.score); return scored.length ? Number(scored[0].rate.ratePerPiece) : 0; } function calcProductLaborCost(sku, sizeCat, designCat, designName, qty) { const p = DB.products.find(x => x.sku === sku); if (!p) return 0; const s = sizeCat || p.sizeCat; const dCat = designCat || p.designCat; const dName = designName || p.designName; const q = Math.max(1, Number(qty) || 1); const stages = (DB.manufacturingStages || []).filter(st => st.active !== false); return stages.reduce((sum, stage) => sum + findLaborRate(stage.id, sku, s, dCat, dName, q), 0); } function calcProductTotalCost(sku, qty, sizeCat, designCat, designName) { const mat = calcProductMaterialCost(sku); const lab = calcProductLaborCost(sku, sizeCat, designCat, designName, qty || 1); return { material: mat, labor: lab, total: mat + lab }; } function calcProductMargin(sku) { const p = DB.products.find(x => x.sku === sku); if (!p) return { cost: 0, price: 0, profit: 0, marginPct: 0, material: 0, labor: 0 }; const c = calcProductTotalCost(sku, 1); const price = Number(p.price) || 0; const profit = price - c.total; return { material: c.material, labor: c.labor, cost: c.total, price, profit, marginPct: price > 0 ? (profit / price) * 100 : 0 }; } function getBomForSku(sku) { return (DB.bomItems || []).filter(b => b.productSku === sku); } function getLaborForSku(sku) { return (DB.laborRates || []).filter(r => r.productSku === sku); } function getPlannedVsActual(sku) { const actuals = (DB.actualCosts || []).filter(a => a.productSku === sku); if (!actuals.length) { const planned = calcProductTotalCost(sku, 1).total; return { hasActual: false, planned, actual: 0, variance: 0, variancePct: 0, totalProduced: 0, recordsCount: 0 }; } const totalProduced = actuals.reduce((s, a) => s + a.qtyProduced, 0); const totalActual = actuals.reduce((s, a) => s + a.totalCost, 0); const avgActualPerUnit = totalProduced > 0 ? totalActual / totalProduced : 0; const planned = calcProductTotalCost(sku, 1).total; return { hasActual: true, planned, actual: avgActualPerUnit, variance: avgActualPerUnit - planned, variancePct: planned > 0 ? ((avgActualPerUnit - planned) / planned) * 100 : 0, totalProduced, recordsCount: actuals.length }; } /* ═══════════════════════════════════════════════════════════════ SUPPLIERS + PURCHASES HELPERS ═══════════════════════════════════════════════════════════════ */ function getSupplierById(id) { return (DB.suppliers || []).find(s => s.id === id); } function getPurchaseById(id) { return (DB.purchases || []).find(p => p.id === id); } function nextSupplierCode() { const nums = (DB.suppliers || []).map(s => { const x = String(s.code || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'SUP-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } function nextPurchaseNo() { const nums = (DB.purchases || []).map(p => { const x = String(p.invoiceNo || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'PUR-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } function getSupplierStats(supplierId) { const purchases = (DB.purchases || []).filter(p => p.supplierId === supplierId && p.status !== 'cancelled'); const totalPurchases = purchases.reduce((s, p) => s + (p.total || 0), 0); const totalPaid = purchases.reduce((s, p) => s + (p.paidAmount || 0), 0); return { purchasesCount: purchases.length, totalPurchases, totalPaid, balance: totalPurchases - totalPaid }; } function getPurchaseStatusLabel(s) { return { draft:'📝 مسودة', received:'✅ مستلمة', partial:'⏳ مدفوعة جزئياً', paid:'💰 مدفوعة', cancelled:'❌ ملغاة' }[s] || s; } function getPurchaseStatusChip(s) { return { draft:'gray', received:'blue', partial:'orange', paid:'green', cancelled:'red' }[s] || 'gray'; } /* ═══════════════════════════════════════════════════════════════ WORKERS HELPERS ═══════════════════════════════════════════════════════════════ */ function getWorkerById(id) { return (DB.workers || []).find(w => w.id === id); } function nextWorkerCode() { const nums = (DB.workers || []).map(w => { const x = String(w.code || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'EMP-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } function getWorkerChildren(workerId) { return (DB.workers || []).filter(w => w.supervisorId === workerId); } function getWorkerSubtree(workerId) { const result = [workerId]; for (const c of getWorkerChildren(workerId)) result.push(...getWorkerSubtree(c.id)); return result; } function getWorkerStats(workerId) { const adv = (DB.workerAdvances || []).filter(a => a.workerId === workerId && a.status === 'pending'); const ded = (DB.workerDeductions || []).filter(d => d.workerId === workerId && d.status === 'pending'); return { advancesCount: adv.length, totalAdvances: adv.reduce((s, a) => s + (a.amount || 0), 0), deductionsCount: ded.length, totalDeductions: ded.filter(d => (d.amount||0) > 0).reduce((s, d) => s + (d.amount || 0), 0), totalBonuses: Math.abs(ded.filter(d => (d.amount||0) < 0).reduce((s, d) => s + (d.amount || 0), 0)), netDue: adv.reduce((s, a) => s + (a.amount || 0), 0) - ded.reduce((s, d) => s + (d.amount || 0), 0) }; } function getWorkerPayTypeLabel(t) { return { monthly:'📅 شهري', piece:'🧵 بالقطعة', both:'📅+🧵 مختلط' }[t] || t; } function getWorkerRoleLabel(r) { return { worker:'👷 عامل', supervisor:'👔 مشرف', both:'👔+👷 مشرف/عامل' }[r] || r; } function getWorkerTree() { const all = (DB.workers || []).filter(w => w.active !== false); const build = (parentId) => all .filter(w => w.supervisorId === parentId) .sort((a, b) => (a.name || '').localeCompare(b.name || '')) .map(w => ({ ...w, children: build(w.id) })); return all .filter(w => !w.supervisorId) .sort((a, b) => (a.name || '').localeCompare(b.name || '')) .map(w => ({ ...w, children: build(w.id) })); } function getAllSupervisors() { return (DB.workers || []).filter(w => w.active !== false && (w.role === 'supervisor' || w.role === 'both')); } /* ═══════════════════════════════════════════════════════════════ MANUFACTURING HELPERS ═══════════════════════════════════════════════════════════════ */ function getMfgOrderById(id) { return (DB.manufacturingOrders || []).find(o => o.id === id); } function getStageById(id) { return (DB.manufacturingStages || []).find(s => s.id === id); } function nextMfgOrderNo() { const nums = (DB.manufacturingOrders || []).map(o => { const x = String(o.orderNo || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'MFG-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } function getMfgAssignments(orderId) { return (DB.stageAssignments || []).filter(a => a.mfgOrderId === orderId); } function getMfgOrderProgress(orderId) { const assignments = getMfgAssignments(orderId); if (!assignments.length) return { total: 0, confirmed: 0, pct: 0 }; const confirmed = assignments.filter(a => a.status === 'confirmed' || a.status === 'paid').length; return { total: assignments.length, confirmed, pct: Math.round((confirmed / assignments.length) * 100) }; } function getMfgOrderStatusLabel(s) { return { draft:'📝 مسودة', in_progress:'🔨 تحت التنفيذ', completed:'✅ مكتمل', cancelled:'❌ ملغى' }[s] || s; } function getMfgOrderStatusChip(s) { return { draft:'gray', in_progress:'orange', completed:'green', cancelled:'red' }[s] || 'gray'; } function getStageAssignmentStatusLabel(s) { return { assigned:'📌 مُسند', submitted:'📤 مُسلَّم', confirmed:'✅ مؤكَّد', paid:'💰 مدفوع', cancelled:'❌ ملغى' }[s] || s; } function getStageAssignmentStatusChip(s) { return { assigned:'blue', submitted:'orange', confirmed:'green', paid:'gold', cancelled:'red' }[s] || 'gray'; } function calcMfgMaterialRequirements(orderId) { const order = getMfgOrderById(orderId); if (!order) return []; const bom = (DB.bomItems || []).filter(b => b.productSku === order.productSku); return bom.map(item => { const requiredQty = (Number(item.qtyPerUnit) || 0) * (Number(order.qty) || 0) * (1 + (Number(item.wastagePct) || 0) / 100); const availableQty = getMaterialStock(item.materialId); return { materialId: item.materialId, material: getMaterialById(item.materialId), requiredQty, availableQty, shortage: Math.max(0, requiredQty - availableQty), unitName: getUnitName(getMaterialById(item.materialId)?.unitId) }; }); } function calcMfgLaborCost(orderId) { const confirmed = getMfgAssignments(orderId).filter(a => a.status === 'confirmed' || a.status === 'paid'); return confirmed.reduce((s, a) => s + (Number(a.totalPay) || 0), 0); } function calcMfgMaterialCost(orderId) { const consumptions = (DB.batchConsumptions || []).filter(c => c.mfgOrderId === orderId); return consumptions.reduce((s, c) => s + (Number(c.total) || 0), 0); } /* ═══════════════════════════════════════════════════════════════ PAYROLL HELPERS ═══════════════════════════════════════════════════════════════ */ function getPayrollById(id) { return (DB.payrolls || []).find(p => p.id === id); } function nextPayrollNo(periodType, periodStart) { const date = new Date(periodStart); const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, '0'); if (periodType === 'monthly') { const prefix = 'PAY-' + yyyy + '-' + mm; const existing = (DB.payrolls || []).filter(p => p.periodNo?.startsWith(prefix)); if (!existing.length) return prefix; return prefix + '-' + (existing.length + 1); } const dd = String(date.getDate()).padStart(2, '0'); return 'PAY-W-' + yyyy + '-' + mm + '-' + dd; } function getPayrollStatusLabel(s) { return { draft:'📝 مسودة', approved:'✅ معتمد', paid:'💰 مدفوع', cancelled:'❌ ملغى' }[s] || s; } function getPayrollStatusChip(s) { return { draft:'gray', approved:'blue', paid:'green', cancelled:'red' }[s] || 'gray'; } function calcBaseSalaryForPeriod(worker, periodStart, periodEnd) { if (worker.payType !== 'monthly' && worker.payType !== 'both') return 0; const monthly = Number(worker.monthlySalary) || 0; if (monthly <= 0) return 0; const days = daysBetween(periodStart, periodEnd); return Math.round((monthly / 30) * days * 100) / 100; } function calcPieceEarningsForPeriod(workerId, periodStart, periodEnd) { const startTs = new Date(periodStart).getTime(); const endTs = new Date(periodEnd + 'T23:59:59').getTime(); const assignments = (DB.stageAssignments || []).filter(a => { if (a.workerId !== workerId) return false; if (a.status !== 'confirmed' && a.status !== 'paid') return false; if (!a.confirmedAt) return false; const ts = new Date(a.confirmedAt).getTime(); return ts >= startTs && ts <= endTs; }); const usedInOtherPayrolls = new Set(); for (const payroll of (DB.payrolls || [])) { if (payroll.id === window._currentPayrollId) continue; if (payroll.status === 'cancelled') continue; for (const item of (payroll.items || [])) { if (item.workerId !== workerId) continue; for (const aid of (item.assignmentIds || [])) usedInOtherPayrolls.add(aid); } } const available = assignments.filter(a => !usedInOtherPayrolls.has(a.id)); return { total: available.reduce((s, a) => s + (Number(a.totalPay) || 0), 0), assignmentIds: available.map(a => a.id), count: available.length }; } function calcAdvancesForPeriod(workerId) { const advances = (DB.workerAdvances || []).filter(a => a.workerId === workerId && a.status === 'pending'); return { total: advances.reduce((s, a) => s + (Number(a.amount) || 0), 0), ids: advances.map(a => a.id), count: advances.length }; } function calcDeductionsForPeriod(workerId) { const deductions = (DB.workerDeductions || []).filter(d => d.workerId === workerId && d.status === 'pending'); const positive = deductions.filter(d => (d.amount || 0) > 0); const negative = deductions.filter(d => (d.amount || 0) < 0); return { deductionsTotal: positive.reduce((s, d) => s + (Number(d.amount) || 0), 0), bonusesTotal: Math.abs(negative.reduce((s, d) => s + (Number(d.amount) || 0), 0)), ids: deductions.map(d => d.id), count: deductions.length }; } function previewWorkerPayroll(workerId, periodStart, periodEnd) { const worker = getWorkerById(workerId); if (!worker) return null; const base = calcBaseSalaryForPeriod(worker, periodStart, periodEnd); const piece = calcPieceEarningsForPeriod(workerId, periodStart, periodEnd); const adv = calcAdvancesForPeriod(workerId); const ded = calcDeductionsForPeriod(workerId); const gross = base + piece.total; const net = gross - adv.total - ded.deductionsTotal + ded.bonusesTotal; return { workerId, workerName: worker.name, workerCode: worker.code, payType: worker.payType, baseSalary: base, pieceEarnings: piece.total, pieceCount: piece.count, assignmentIds: piece.assignmentIds, advancesDeducted: adv.total, advanceIds: adv.ids, deductionsTotal: ded.deductionsTotal, bonusesTotal: ded.bonusesTotal, deductionIds: ded.ids, gross, net }; } /* ═══════════════════════════════════════════════════════════════ ACCOUNTING HELPERS ═══════════════════════════════════════════════════════════════ */ function getAccountById(id) { return (DB.chartOfAccounts || []).find(a => a.id === id); } function getAccountByCode(code) { return (DB.chartOfAccounts || []).find(a => a.code === code); } function getJournalEntryById(id) { return (DB.journalEntries || []).find(e => e.id === id); } function getCOATree() { const all = (DB.chartOfAccounts || []).filter(a => a.active !== false); const build = (parentId) => all .filter(a => (a.parentId || null) === parentId) .sort((a, b) => (a.code || '').localeCompare(b.code || '')) .map(a => ({ ...a, children: build(a.id) })); return build(null); } function getAccountTypeLabel(t) { return { asset:'💰 أصول', liability:'📋 خصوم', equity:'🏛 حقوق ملكية', revenue:'📈 إيرادات', expense:'📉 مصروفات' }[t] || t; } function getAccountTypeChip(t) { return { asset:'green', liability:'red', equity:'purple', revenue:'blue', expense:'orange' }[t] || 'gray'; } function nextJournalEntryNo() { const nums = (DB.journalEntries || []).map(e => { const x = String(e.entryNo || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'JE-' + String(Math.max(0, ...nums) + 1).padStart(5, '0'); } function getAccountBalance(accountId, upToDate) { const entries = (DB.journalEntries || []).filter(e => { if (e.status !== 'posted') return false; if (upToDate && new Date(e.date) > new Date(upToDate)) return false; return true; }); let debit = 0, credit = 0; for (const entry of entries) { for (const line of (entry.lines || [])) { if (line.accountId === accountId) { debit += Number(line.debit) || 0; credit += Number(line.credit) || 0; } } } const account = getAccountById(accountId); const opening = Number(account?.openingBalance) || 0; return { debit, credit, net: debit - credit, opening, balance: opening + (debit - credit) }; } function calcTrialBalance(asOfDate) { const leaves = (DB.chartOfAccounts || []).filter(a => a.isLeaf && a.active !== false); const rows = []; let totalDebit = 0, totalCredit = 0; for (const acc of leaves) { const bal = getAccountBalance(acc.id, asOfDate); if (Math.abs(bal.balance) < 0.01) continue; const debitBal = Math.max(0, bal.balance); const creditBal = Math.max(0, -bal.balance); rows.push({ account: acc, opening: bal.opening, debit: bal.debit, credit: bal.credit, balance: bal.balance }); totalDebit += debitBal; totalCredit += creditBal; } return { rows, totalDebit, totalCredit }; } function createAutoJournalEntry({ date, description, refType, refId, lines, notes }) { if (!DB.settings?.autoAccounting) return null; const entryNo = nextJournalEntryNo(); const now = new Date().toISOString(); const entry = { id: uid(), entryNo, date: date || todayISO(), description, refType, refId, lines: lines.map(l => ({ accountId: l.accountId, debit: Number(l.debit) || 0, credit: Number(l.credit) || 0, notes: l.notes || '' })), status: 'posted', createdBy: currentUser?.id || null, createdByName: currentUser?.name || '—', createdAt: now, postedAt: now, notes: notes || '' }; const td = entry.lines.reduce((s, l) => s + l.debit, 0); const tc = entry.lines.reduce((s, l) => s + l.credit, 0); if (Math.abs(td - tc) > 0.01) { console.error('Unbalanced auto entry', entry); return null; } if (!DB.journalEntries) DB.journalEntries = []; DB.journalEntries.push(entry); return entry; } function autoPostSale(saleGroup, source) { if (!DB.settings?.autoAccounting) return; const cashAccount = getAccountByCode('1110'); const receivableAccount = getAccountByCode('1130'); const salesAccount = getAccountByCode( source === 'exhibition' ? '4100' : source === 'direct' ? '4200' : '4300' ); if (!cashAccount || !salesAccount) return; const isCash = saleGroup.paymentMethod === 'cash'; createAutoJournalEntry({ date: saleGroup.date || todayISO(), description: 'مبيعات ' + (source === 'exhibition' ? 'معرض' : source === 'direct' ? 'مباشر' : 'طلبية'), refType: 'sale', refId: saleGroup.groupId, lines: [ { accountId: isCash ? cashAccount.id : receivableAccount.id, debit: saleGroup.total, credit: 0 }, { accountId: salesAccount.id, debit: 0, credit: saleGroup.total } ] }); } function autoPostPurchase(purchase) { if (!DB.settings?.autoAccounting) return; const inv = getAccountByCode('1140'); const payable = getAccountByCode('2110'); const cash = getAccountByCode('1110'); if (!inv || !payable) return; const paid = Number(purchase.paidAmount) || 0; const remaining = (purchase.total || 0) - paid; const lines = [{ accountId: inv.id, debit: purchase.total, credit: 0 }]; if (paid > 0) lines.push({ accountId: cash.id, debit: 0, credit: paid }); if (remaining > 0) lines.push({ accountId: payable.id, debit: 0, credit: remaining }); createAutoJournalEntry({ date: purchase.date || todayISO(), description: 'فاتورة شراء ' + purchase.invoiceNo, refType: 'purchase', refId: purchase.id, lines }); } function autoPostPayroll(payroll) { if (!DB.settings?.autoAccounting) return; const exp = getAccountByCode('5210') || getAccountByCode('5120'); const payable = getAccountByCode('2120'); const cash = getAccountByCode('1110'); if (!exp || !payable) return; const isPaid = payroll.status === 'paid'; createAutoJournalEntry({ date: payroll.periodEnd || todayISO(), description: 'رواتب ' + payroll.periodNo, refType: 'payroll', refId: payroll.id, lines: isPaid ? [ { accountId: exp.id, debit: payroll.totalNet, credit: 0 }, { accountId: cash.id, debit: 0, credit: payroll.totalNet } ] : [ { accountId: exp.id, debit: payroll.totalNet, credit: 0 }, { accountId: payable.id, debit: 0, credit: payroll.totalNet } ] }); } /* ═══════════════════════════════════════════════════════════════ v9: WAREHOUSES HELPERS ═══════════════════════════════════════════════════════════════ */ function getWarehouseById(id) { return (DB.warehouses || []).find(w => w.id === id); } function getDefaultWarehouse() { return (DB.warehouses || []).find(w => w.isDefault) || (DB.warehouses || [])[0]; } function getStockAtWarehouse(sku, warehouseId) { const entry = (DB.warehouseStocks || []).find(s => s.sku === sku && s.warehouseId === warehouseId); return entry ? Number(entry.qty) || 0 : 0; } function getTotalStock(sku) { return (DB.warehouseStocks || []) .filter(s => s.sku === sku) .reduce((sum, s) => sum + (Number(s.qty) || 0), 0); } function setStockAtWarehouse(sku, warehouseId, qty) { let entry = (DB.warehouseStocks || []).find(s => s.sku === sku && s.warehouseId === warehouseId); if (entry) { entry.qty = Math.max(0, Number(qty) || 0); entry.lastUpdated = new Date().toISOString(); } else { if (!DB.warehouseStocks) DB.warehouseStocks = []; DB.warehouseStocks.push({ id: uid(), sku, warehouseId, qty: Math.max(0, Number(qty) || 0), lastUpdated: new Date().toISOString() }); } } function adjustStock(sku, warehouseId, delta) { const current = getStockAtWarehouse(sku, warehouseId); setStockAtWarehouse(sku, warehouseId, current + delta); } function nextTransferNo() { const nums = (DB.stockTransfers || []).map(t => { const x = String(t.transferNo || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'TRF-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } /* ═══════════════════════════════════════════════════════════════ v9: CURRENCIES HELPERS ═══════════════════════════════════════════════════════════════ */ function getCurrencyByCode(code) { return (DB.currencies || []).find(c => c.code === code); } function getBaseCurrency() { return (DB.currencies || []).find(c => c.isBase) || { code: 'EGP', symbol: 'ج.م', exchangeRate: 1 }; } function convertToBase(amount, fromCurrencyCode) { if (!fromCurrencyCode) fromCurrencyCode = getBaseCurrency().code; const c = getCurrencyByCode(fromCurrencyCode); if (!c || c.isBase) return Number(amount) || 0; return (Number(amount) || 0) * (Number(c.exchangeRate) || 1); } function convertFromBase(amount, toCurrencyCode) { if (!toCurrencyCode) toCurrencyCode = getBaseCurrency().code; const c = getCurrencyByCode(toCurrencyCode); if (!c || c.isBase) return Number(amount) || 0; return (Number(amount) || 0) / (Number(c.exchangeRate) || 1); } function moneyBase(n) { return money(convertToBase(n, CONFIG.baseCurrency)); } /* ═══════════════════════════════════════════════════════════════ v9: TAX HELPERS ═══════════════════════════════════════════════════════════════ */ function isProductTaxExempt(sku) { const cfg = DB.taxSettings || {}; return (cfg.exemptProductSkus || []).includes(sku); } function calcVATAmount(baseAmount, sku) { const cfg = DB.taxSettings || { vatRate: 14 }; if (!cfg.vatEnabled) return 0; if (sku && isProductTaxExempt(sku)) return 0; return (Number(baseAmount) || 0) * ((Number(cfg.vatRate) || 0) / 100); } function calcVATForSale(sale) { const base = Number(sale.total) || 0; return calcVATAmount(base, sale.sku); } function calcVATForPurchase(purchase) { const cfg = DB.taxSettings || { vatRate: 14 }; if (!cfg.vatEnabled) return 0; return (Number(purchase.total) || 0) * ((Number(cfg.vatRate) || 0) / 100); } function getVATSummary(year, month) { const start = new Date(year, month - 1, 1).getTime(); const end = new Date(year, month, 0, 23, 59, 59).getTime(); const inRange = (dateStr) => { if (!dateStr) return false; const ts = new Date(dateStr).getTime(); return ts >= start && ts <= end; }; // Output VAT من المبيعات let outputVAT = 0; let outputBase = 0; (DB.exhibitions || []).forEach(ex => { (ex.sales || []).forEach(s => { if (inRange(s.date)) { const vat = calcVATForSale(s); outputVAT += vat; outputBase += Number(s.total) || 0; } }); }); (DB.directSales || []).forEach(s => { if (inRange(s.date)) { const vat = calcVATForSale(s); outputVAT += vat; outputBase += Number(s.total) || 0; } }); // Input VAT من المشتريات let inputVAT = 0; let inputBase = 0; (DB.purchases || []).forEach(p => { if (p.status === 'cancelled') return; if (inRange(p.date)) { const vat = calcVATForPurchase(p); inputVAT += vat; inputBase += Number(p.total) || 0; } }); const netVAT = outputVAT - inputVAT; return { outputVAT, outputBase, inputVAT, inputBase, netVAT }; } /* ═══════════════════════════════════════════════════════════════ v9: FIXED ASSETS HELPERS ═══════════════════════════════════════════════════════════════ */ function getAssetById(id) { return (DB.fixedAssets || []).find(a => a.id === id); } function nextAssetCode() { const nums = (DB.fixedAssets || []).map(a => { const x = String(a.code || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'FA-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } function calcMonthlyDepreciation(asset) { const cost = Number(asset.purchaseCost) || 0; const salvage = Number(asset.salvageValue) || 0; const lifeYears = Number(asset.usefulLifeYears) || 5; const lifeMonths = lifeYears * 12; if (lifeMonths <= 0) return 0; return (cost - salvage) / lifeMonths; } function calcAccumulatedDepreciation(assetId) { return (DB.depreciationEntries || []) .filter(d => d.assetId === assetId) .reduce((s, d) => s + (Number(d.amount) || 0), 0); } function calcBookValue(asset) { return (Number(asset.purchaseCost) || 0) - calcAccumulatedDepreciation(asset.id); } /* ═══════════════════════════════════════════════════════════════ v9: RENT HELPERS ═══════════════════════════════════════════════════════════════ */ function getRentContractById(id) { return (DB.rentContracts || []).find(c => c.id === id); } function nextRentContractNo() { const nums = (DB.rentContracts || []).map(c => { const x = String(c.contractNo || '').match(/(\d+)$/); return x ? Number(x[1]) : 0; }); return 'RC-' + String(Math.max(0, ...nums) + 1).padStart(4, '0'); } function getRentPaymentsForContract(contractId) { return (DB.rentPayments || []).filter(p => p.contractId === contractId); } function getRentStatusLabel(s) { return { active:'✅ نشط', expired:'⏰ منتهي', terminated:'❌ ملغى' }[s] || s; } function getRentStatusChip(s) { return { active:'green', expired:'orange', terminated:'red' }[s] || 'gray'; } /* ═══════════════════════════════════════════════════════════════ STORAGE ═══════════════════════════════════════════════════════════════ */ function migrateFromPOS() { const POS_KEY = 'hm_pos_db_v3'; const ERP_KEY = CONFIG.storageKey; if (localStorage.getItem(ERP_KEY)) return false; const posData = localStorage.getItem(POS_KEY); if (!posData) return false; try { const parsed = JSON.parse(posData); parsed.version = 9; parsed._erp = { migratedFromPOS: true, migratedAt: new Date().toISOString(), posVersion: 3, erpVersion: 9 }; localStorage.setItem(ERP_KEY, JSON.stringify(parsed)); console.log('✅ Migrated POS → ERP'); return true; } catch(e) { console.error('Migration failed', e); return false; } } function loadDB() { migrateFromPOS(); try { const raw = localStorage.getItem(CONFIG.storageKey); if (raw) { const parsed = JSON.parse(raw); if (parsed.productSignatures) { SigDB.getAll().then(existing => { const merged = { ...existing, ...parsed.productSignatures }; SigDB.saveAll(merged); }); delete parsed.productSignatures; localStorage.setItem(CONFIG.storageKey, JSON.stringify(parsed)); } return deepMerge(JSON.parse(JSON.stringify(DEFAULT_DB)), parsed); } } catch(e) { console.error('Load failed', e); } return JSON.parse(JSON.stringify(DEFAULT_DB)); } function saveDB() { try { DB._meta = DB._meta || { updatedAt: 0, lastSyncAt: 0 }; DB._meta.updatedAt = Date.now(); const dbToSave = { ...DB }; delete dbToSave.productSignatures; localStorage.setItem(CONFIG.storageKey, JSON.stringify(dbToSave)); queueSyncPush(); // Offline queue queueOfflineAction('db_update', { ts: Date.now() }); } catch(e) { console.error('Save failed', e); toast('خطأ في الحفظ', 'err'); } } function deepMerge(base, over) { if (!over || typeof over !== 'object') return over !== undefined ? over : base; const out = Array.isArray(base) ? base.slice() : Object.assign({}, base); for (const k in over) { if (over[k] && typeof over[k] === 'object' && !Array.isArray(over[k]) && base && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) { out[k] = deepMerge(base[k], over[k]); } else { out[k] = over[k]; } } return out; } function ensureCategoriesFromProducts() { if (!Array.isArray(DB.categories)) DB.categories = []; if (!Array.isArray(DB.orders)) DB.orders = []; if (!DB.categories.length) { const cats = new Set(); (DB.products || []).forEach(p => { if (p.mainCat) cats.add(p.mainCat); }); DB.categories = Array.from(cats); } if (!DB.filterOptions || typeof DB.filterOptions !== 'object') DB.filterOptions = {}; } function sanitizeProductsPayload(products) { return (products || []).map(p => ({ ...p, stock: Math.max(0, Math.floor(Number(p.stock) || 0)) })); } /* ═══════════════════════════════════════════════════════════════ OFFLINE QUEUE (v9) ═══════════════════════════════════════════════════════════════ */ const OFFLINE_QUEUE_KEY = 'hm_erp_offline_queue'; function queueOfflineAction(action, data) { if (navigator.onLine && SYNC.ready) return; try { const queue = JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]'); queue.push({ id: uid(), action, data, ts: Date.now() }); if (queue.length > 1000) queue.splice(0, queue.length - 1000); localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(queue)); } catch(e) { /* ignore */ } } async function flushOfflineQueue() { try { const queue = JSON.parse(localStorage.getItem(OFFLINE_QUEUE_KEY) || '[]'); if (!queue.length) return; console.log('📤 Flushing offline queue:', queue.length, 'items'); localStorage.removeItem(OFFLINE_QUEUE_KEY); await syncPushNow(); toast('📤 تمت مزامنة ' + queue.length + ' عملية', 'ok'); } catch(e) { console.error('Flush failed', e); } } /* ═══════════════════════════════════════════════════════════════ SYNC — Cloud ═══════════════════════════════════════════════════════════════ */ function updateSyncBadge() { const badge = $('#syncBadge'); const txt = $('#syncText'); if (!badge) return; const labels = { connecting: 'جاري الاتصال...', synced: 'متزامن', syncing: 'جاري الحفظ...', offline: 'بدون اتصال', error: 'خطأ', live: '🔴 مباشر' }; badge.className = 'sync-badge ' + SYNC.status; if (txt) txt.textContent = labels[SYNC.status] || '...'; } function mergeSignatures(localSigs, serverSigs) { const merged = { ...localSigs }; for (const [sku, sigs] of Object.entries(serverSigs || {})) { if (!merged[sku]) merged[sku] = []; const existing = new Set(merged[sku].map(s => s.aHash + '|' + s.dHash)); for (const sig of sigs) { const key = sig.aHash + '|' + sig.dHash; if (!existing.has(key)) { merged[sku].push(sig); existing.add(key); } } const maxRefs = (DB.recognitionSettings || {}).maxReferencesPerProduct || 5; if (merged[sku].length > maxRefs) merged[sku] = merged[sku].slice(-maxRefs); } return merged; } async function syncInit() { if (!CONFIG.enableCloudSync) { SYNC.status = 'offline'; updateSyncBadge(); return { action: 'disabled' }; } updateSyncBadge(); try { const res = await fetch(CONFIG.posApiUrl + CONFIG.apiEndpoint, { cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status); const server = await res.json(); const serverTs = Number(server.updatedAt) || 0; const localTs = Number(DB._meta?.updatedAt) || 0; SYNC.ready = true; if (server.json && server.json.length > 10) { if (serverTs >= localTs) { try { const serverDB = JSON.parse(server.json); if (serverDB.productSignatures) { MEMORY_SIGS = mergeSignatures(MEMORY_SIGS, serverDB.productSignatures); await SigDB.saveAll(MEMORY_SIGS); delete serverDB.productSignatures; } DB = deepMerge(JSON.parse(JSON.stringify(DEFAULT_DB)), serverDB); DB._meta.updatedAt = serverTs; localStorage.setItem(CONFIG.storageKey, JSON.stringify(DB)); SYNC.status = 'synced'; updateSyncBadge(); return { action: 'pulled' }; } catch(e) { SYNC.status = 'error'; updateSyncBadge(); return { action: 'error' }; } } await syncPushNow(); return { action: 'pushed-local' }; } if (localTs > 0) { await syncPushNow(); return { action: 'migrated' }; } SYNC.status = 'synced'; updateSyncBadge(); return { action: 'noop' }; } catch(e) { SYNC.status = navigator.onLine ? 'error' : 'offline'; updateSyncBadge(); return { action: 'failed' }; } } function queueSyncPush() { if (!SYNC.ready) return; clearTimeout(SYNC.pushTimer); SYNC.pushTimer = setTimeout(syncPushNow, CONFIG.pushDebounceMs); } async function syncPushNow() { if (!SYNC.ready || SYNC.pushing) return; SYNC.pushing = true; SYNC.status = 'syncing'; updateSyncBadge(); try { DB._meta = DB._meta || {}; DB._meta.updatedAt = Date.now(); const payload = { ...DB, products: sanitizeProductsPayload(DB.products), productSignatures: MEMORY_SIGS }; const res = await fetch(CONFIG.posApiUrl + CONFIG.apiEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ json: JSON.stringify(payload), updatedAt: DB._meta.updatedAt }) }); if (!res.ok) throw new Error('HTTP ' + res.status); const result = await res.json(); if (result.error) throw new Error(result.error); SYNC.lastPushAt = Date.now(); DB._meta.lastSyncAt = SYNC.lastPushAt; const dbToSave = { ...DB }; delete dbToSave.productSignatures; localStorage.setItem(CONFIG.storageKey, JSON.stringify(dbToSave)); SYNC.status = 'synced'; updateSyncBadge(); } catch(e) { SYNC.status = navigator.onLine ? 'error' : 'offline'; updateSyncBadge(); setTimeout(() => { if (SYNC.ready && SYNC.status !== 'synced') syncPushNow(); }, 5000); } finally { SYNC.pushing = false; } } async function syncPull(silent) { if (!SYNC.ready || SYNC.pulling || SYNC.pushing) return false; if (silent) { if (document.querySelector('#modalContainer .modal')) return false; if (Date.now() - SYNC.lastUserActivity < 4000) return false; } SYNC.pulling = true; try { const res = await fetch(CONFIG.posApiUrl + CONFIG.apiEndpoint, { cache: 'no-store' }); if (!res.ok) throw new Error('HTTP ' + res.status); const server = await res.json(); const serverTs = Number(server.updatedAt) || 0; const localTs = Number(DB._meta?.updatedAt) || 0; if (!server.json || serverTs <= localTs + 500) { SYNC.status = 'synced'; updateSyncBadge(); return false; } const serverDB = JSON.parse(server.json); if (serverDB.productSignatures) { MEMORY_SIGS = mergeSignatures(MEMORY_SIGS, serverDB.productSignatures); await SigDB.saveAll(MEMORY_SIGS); delete serverDB.productSignatures; } const mergedDB = mergeDB(DB, serverDB); mergedDB._meta = mergedDB._meta || {}; mergedDB._meta.updatedAt = serverTs; mergedDB._meta.lastSyncAt = Date.now(); const stripMeta = db => { const c = JSON.parse(JSON.stringify(db)); delete c._meta; return JSON.stringify(c); }; const hasChanges = stripMeta(mergedDB) !== stripMeta(DB); DB = mergedDB; const dbToSave = { ...DB }; delete dbToSave.productSignatures; localStorage.setItem(CONFIG.storageKey, JSON.stringify(dbToSave)); SYNC.lastPullAt = Date.now(); SYNC.status = 'synced'; updateSyncBadge(); if (hasChanges) { if (stripMeta(mergedDB) !== stripMeta(serverDB)) queueSyncPush(); if (typeof currentTab !== 'undefined' && currentTab) showTab(currentTab); toast('🔄 تم تحديث البيانات', 'ok'); } return hasChanges; } catch(e) { SYNC.status = navigator.onLine ? 'error' : 'offline'; updateSyncBadge(); return false; } finally { SYNC.pulling = false; } } function mergeDB(local, server) { const merged = JSON.parse(JSON.stringify(server)); // Ensure arrays exist const arrays = ['exhibitions','products','admins','categories','directSales','orders','units','materialCategories','rawMaterials','materialsBatches','auditLog','manufacturingStages','bomItems','laborRates','actualCosts','suppliers','purchases','supplierPayments','workers','workerAdvances','workerDeductions','manufacturingOrders','stageAssignments','batchConsumptions','payrolls','chartOfAccounts','journalEntries','accountingTabs','accountingEntries','taxReturns','fixedAssets','depreciationEntries','warehouses','warehouseStocks','stockTransfers','rentContracts','rentPayments','currencies','exchangeRateHistory']; for (const k of arrays) if (!Array.isArray(merged[k])) merged[k] = []; if (!merged.filterOptions) merged.filterOptions = {}; // Exhibitions merge const localExMap = new Map((local.exhibitions || []).map(e => [e.id, e])); for (const serverEx of merged.exhibitions) { const localEx = localExMap.get(serverEx.id); if (!localEx) continue; const saleMap = new Map(); for (const s of (serverEx.sales || [])) saleMap.set(s.id, s); for (const s of (localEx.sales || [])) if (!saleMap.has(s.id)) saleMap.set(s.id, s); serverEx.sales = Array.from(saleMap.values()).sort((a, b) => new Date(a.date) - new Date(b.date)); if (serverEx.stock) { for (const sku in serverEx.stock) serverEx.stock[sku].sold = 0; for (const sale of serverEx.sales) { if (!serverEx.stock[sale.sku]) serverEx.stock[sale.sku] = { allocated: 0, sold: 0, returned: 0 }; serverEx.stock[sale.sku].sold = (serverEx.stock[sale.sku].sold || 0) + (sale.qty || 0); } } const sellers = new Set([...(serverEx.sellers || []), ...(localEx.sellers || [])]); serverEx.sellers = Array.from(sellers); const ord = { planning: 0, active: 1, closed: 2 }; if ((ord[localEx.status] ?? 0) > (ord[serverEx.status] ?? 0)) { serverEx.status = localEx.status; if (localEx.closedAt) serverEx.closedAt = localEx.closedAt; if (localEx.reconciliation) serverEx.reconciliation = localEx.reconciliation; } } const serverIds = new Set(merged.exhibitions.map(e => e.id)); for (const le of (local.exhibitions || [])) if (!serverIds.has(le.id)) merged.exhibitions.push(le); // Products merge const localPMap = new Map((local.products || []).map(p => [p.sku, p])); const serverTsForStock = Number(server._meta?.updatedAt) || 0; const localTsForStock = Number(local._meta?.updatedAt) || 0; const serverProductSkus = new Set(); merged.products = merged.products.map(sp => { serverProductSkus.add(sp.sku); const lp = localPMap.get(sp.sku); if (!lp) return sp; const keepStock = localTsForStock > serverTsForStock ? (Number(lp.stock) || 0) : (Number(sp.stock) || 0); return { ...sp, stock: keepStock }; }); for (const [sku, lp] of localPMap) if (!serverProductSkus.has(sku)) merged.products.push(lp); // Admins const serverAIds = new Set(merged.admins.map(a => a.id)); for (const la of (local.admins || [])) if (!serverAIds.has(la.id)) merged.admins.push(la); // Direct sales const serverDSMap = new Map(merged.directSales.map(s => [s.id, s])); for (const ls of (local.directSales || [])) if (!serverDSMap.has(ls.id)) merged.directSales.push(ls); merged.directSales.sort((a, b) => new Date(a.date) - new Date(b.date)); // Orders const localOrdersMap = new Map((local.orders || []).map(o => [o.id, o])); const serverOrderIds = new Set(merged.orders.map(o => o.id)); for (const [orderId, localOrder] of localOrdersMap) { if (!serverOrderIds.has(orderId)) { merged.orders.push(localOrder); } else { const serverOrder = merged.orders.find(o => o.id === orderId); const lt = new Date(localOrder.updatedAt || localOrder.createdAt || 0).getTime(); const st = new Date(serverOrder.updatedAt || serverOrder.createdAt || 0).getTime(); if (lt > st) { const idx = merged.orders.findIndex(o => o.id === orderId); merged.orders[idx] = localOrder; } } } merged.orders.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); // Categories const catSet = new Set([...(merged.categories || []), ...(local.categories || [])]); merged.categories = Array.from(catSet); for (const [k, vals] of Object.entries(local.filterOptions || {})) { if (!merged.filterOptions[k]) merged.filterOptions[k] = []; const s = new Set([...(merged.filterOptions[k] || []), ...(vals || [])]); merged.filterOptions[k] = Array.from(s); } // Generic merge by id for tables with updatedAt const mergeById = (key, updatedKey = 'updatedAt') => { const serverMap = new Map((merged[key] || []).map(x => [x.id, x])); for (const lx of (local[key] || [])) { if (!serverMap.has(lx.id)) merged[key].push(lx); else { const sx = serverMap.get(lx.id); const lt = new Date(lx[updatedKey] || lx.createdAt || 0).getTime(); const st = new Date(sx[updatedKey] || sx.createdAt || 0).getTime(); if (lt > st) Object.assign(sx, lx); } } }; mergeById('units'); mergeById('materialCategories'); mergeById('rawMaterials'); mergeById('manufacturingStages'); mergeById('bomItems'); mergeById('laborRates'); mergeById('suppliers'); mergeById('purchases'); mergeById('workers'); mergeById('manufacturingOrders'); mergeById('payrolls'); mergeById('chartOfAccounts'); mergeById('accountingTabs'); mergeById('fixedAssets'); mergeById('warehouses'); mergeById('rentContracts'); mergeById('currencies'); // Batches: min qtyRemaining const serverBatchMap = new Map(merged.materialsBatches.map(b => [b.id, b])); for (const lb of (local.materialsBatches || [])) { if (!serverBatchMap.has(lb.id)) merged.materialsBatches.push(lb); else { const sb = serverBatchMap.get(lb.id); sb.qtyRemaining = Math.min(sb.qtyRemaining || 0, lb.qtyRemaining || 0); } } // Audit log const serverAuditIds = new Set(merged.auditLog.map(a => a.id)); for (const la of (local.auditLog || [])) if (!serverAuditIds.has(la.id)) merged.auditLog.push(la); merged.auditLog.sort((a, b) => new Date(a.at) - new Date(b.at)); if (merged.auditLog.length > 500) merged.auditLog = merged.auditLog.slice(-500); // Simple array union by id for others const unionBy = (key) => { const ids = new Set(merged[key].map(x => x.id)); for (const lx of (local[key] || [])) if (!ids.has(lx.id)) merged[key].push(lx); }; unionBy('actualCosts'); unionBy('supplierPayments'); unionBy('workerAdvances'); unionBy('workerDeductions'); unionBy('stageAssignments'); unionBy('batchConsumptions'); unionBy('journalEntries'); unionBy('accountingEntries'); unionBy('taxReturns'); unionBy('depreciationEntries'); unionBy('stockTransfers'); unionBy('rentPayments'); unionBy('exchangeRateHistory'); // Warehouse stocks: use server (source of truth) // (merge skipped — server wins) return merged; } /* ═══════════════════════════════════════════════════════════════ SSE — Server-Sent Events (بدل polling) ═══════════════════════════════════════════════════════════════ */ function setupSSE() { if (!CONFIG.enableSSE || !SYNC.ready) return; if (SYNC.sse) { try { SYNC.sse.close(); } catch(e) {} } try { const url = CONFIG.posApiUrl + CONFIG.sseEndpoint; SYNC.sse = new EventSource(url); SYNC.sse.addEventListener('connected', (e) => { SYNC.sseConnected = true; SYNC.sseRetries = 0; SYNC.status = 'live'; updateSyncBadge(); console.log('✅ SSE connected'); }); SYNC.sse.addEventListener('update', async (e) => { try { const data = JSON.parse(e.data); const serverTs = data.updatedAt; const localTs = Number(DB._meta?.updatedAt) || 0; if (serverTs > localTs + 500) { console.log('📡 SSE: remote update detected, pulling...'); await syncPull(true); } } catch(err) { console.error('SSE parse error', err); } }); SYNC.sse.addEventListener('reconnect', (e) => { SYNC.sse.close(); setTimeout(setupSSE, CONFIG.sseReconnectDelayMs); }); SYNC.sse.onerror = () => { SYNC.sseConnected = false; SYNC.sseRetries++; // Fallback to polling if (SYNC.sseRetries > 3) { console.warn('SSE failed multiple times, falling back to polling'); try { SYNC.sse.close(); } catch(e) {} SYNC.sse = null; startPollingFallback(); } }; } catch(e) { console.error('SSE setup failed', e); startPollingFallback(); } } function startPollingFallback() { if (SYNC.pollTimer) return; SYNC.pollTimer = setInterval(() => { if (navigator.onLine && SYNC.ready) syncPull(true); }, CONFIG.pollIntervalMs); console.log('📊 Polling fallback started (20s)'); } function setupSync() { ['input', 'click', 'keydown', 'touchstart'].forEach(ev => { document.addEventListener(ev, () => { SYNC.lastUserActivity = Date.now(); }, { passive: true }); }); startPollingFallback(); window.addEventListener('focus', () => { if (SYNC.ready) syncPull(true); }); document.addEventListener('visibilitychange', () => { if (!document.hidden && SYNC.ready) syncPull(true); }); window.addEventListener('online', () => { if (!SYNC.ready) syncInit(); else { syncPushNow(); flushOfflineQueue(); if (CONFIG.enableSSE) setupSSE(); } }); window.addEventListener('offline', () => { SYNC.status = 'offline'; updateSyncBadge(); }); // Start SSE after a short delay if (CONFIG.enableSSE) { setTimeout(setupSSE, 2000); } } /* ═══════════════════════════════════════════════════════════════ AUTH ═══════════════════════════════════════════════════════════════ */ function getSession() { try { return JSON.parse(sessionStorage.getItem(CONFIG.sessionKey) || 'null'); } catch(e) { return null; } } function setSession(s) { try { sessionStorage.setItem(CONFIG.sessionKey, JSON.stringify(s)); } catch(e) {} } function clearSession() { try { sessionStorage.removeItem(CONFIG.sessionKey); } catch(e) {} } function ensureDefaultAdmin() { if (!Array.isArray(DB.admins)) DB.admins = []; if (DB.admins.length === 0) { DB.admins.push({ id: 'admin-default', name: 'Admin', email: 'admin@pos.local', passHash: hashPass('pos1234'), role: 'owner', active: true, createdAt: new Date().toISOString(), _locked: true }); saveDB(); } ensureDefaultCOA(); ensureDefaultAccountingTabs(); ensureDefaultWarehouses(); } function ensureDefaultWarehouses() { if (!Array.isArray(DB.warehouses) || !DB.warehouses.length) { DB.warehouses = [ { id:'wh-main', code:'WH-MAIN', nameAr:'المستودع الرئيسي', location:'', managerWorkerId:null, active:true, isDefault:true }, { id:'wh-exh1', code:'WH-EXH1', nameAr:'مخزن معرض 1', location:'', managerWorkerId:null, active:true, isDefault:false }, { id:'wh-exh2', code:'WH-EXH2', nameAr:'مخزن معرض 2', location:'', managerWorkerId:null, active:true, isDefault:false }, ]; saveDB(); } } function ensureDefaultCOA() { if ((DB.chartOfAccounts || []).length > 0) return; const now = new Date().toISOString(); const accounts = [ { code:'1000', nameAr:'الأصول', nameEn:'Assets', type:'asset', parentCode:null, level:1, isLeaf:false, normalSide:'debit' }, { code:'1100', nameAr:'الأصول المتداولة', nameEn:'Current Assets', type:'asset', parentCode:'1000', level:2, isLeaf:false, normalSide:'debit' }, { code:'1110', nameAr:'النقدية', nameEn:'Cash', type:'asset', parentCode:'1100', level:3, isLeaf:true, normalSide:'debit' }, { code:'1120', nameAr:'البنك', nameEn:'Bank', type:'asset', parentCode:'1100', level:3, isLeaf:true, normalSide:'debit' }, { code:'1130', nameAr:'العملاء', nameEn:'Accounts Receivable', type:'asset', parentCode:'1100', level:3, isLeaf:true, normalSide:'debit' }, { code:'1140', nameAr:'مخزون خامات', nameEn:'Raw Materials', type:'asset', parentCode:'1100', level:3, isLeaf:true, normalSide:'debit' }, { code:'1150', nameAr:'مخزون منتجات', nameEn:'Finished Goods', type:'asset', parentCode:'1100', level:3, isLeaf:true, normalSide:'debit' }, { code:'1200', nameAr:'أصول ثابتة', nameEn:'Fixed Assets', type:'asset', parentCode:'1000', level:2, isLeaf:false, normalSide:'debit' }, { code:'1210', nameAr:'معدات', nameEn:'Equipment', type:'asset', parentCode:'1200', level:3, isLeaf:true, normalSide:'debit' }, { code:'1220', nameAr:'مجمع الإهلاك', nameEn:'Accumulated Depreciation', type:'asset', parentCode:'1200', level:3, isLeaf:true, normalSide:'credit' }, { code:'2000', nameAr:'الخصوم', nameEn:'Liabilities', type:'liability', parentCode:null, level:1, isLeaf:false, normalSide:'credit' }, { code:'2100', nameAr:'خصوم متداولة', nameEn:'Current Liabilities', type:'liability', parentCode:'2000', level:2, isLeaf:false, normalSide:'credit' }, { code:'2110', nameAr:'الموردون', nameEn:'Accounts Payable', type:'liability', parentCode:'2100', level:3, isLeaf:true, normalSide:'credit' }, { code:'2120', nameAr:'رواتب مستحقة', nameEn:'Salaries Payable', type:'liability', parentCode:'2100', level:3, isLeaf:true, normalSide:'credit' }, { code:'2130', nameAr:'ضرائب مستحقة', nameEn:'Taxes Payable', type:'liability', parentCode:'2100', level:3, isLeaf:true, normalSide:'credit' }, { code:'3000', nameAr:'حقوق الملكية', nameEn:'Equity', type:'equity', parentCode:null, level:1, isLeaf:false, normalSide:'credit' }, { code:'3100', nameAr:'رأس المال', nameEn:'Capital', type:'equity', parentCode:'3000', level:2, isLeaf:true, normalSide:'credit' }, { code:'3200', nameAr:'أرباح محتجزة', nameEn:'Retained Earnings', type:'equity', parentCode:'3000', level:2, isLeaf:true, normalSide:'credit' }, { code:'4000', nameAr:'الإيرادات', nameEn:'Revenue', type:'revenue', parentCode:null, level:1, isLeaf:false, normalSide:'credit' }, { code:'4100', nameAr:'مبيعات معارض', nameEn:'Exhibition Sales', type:'revenue', parentCode:'4000', level:2, isLeaf:true, normalSide:'credit' }, { code:'4200', nameAr:'بيع مباشر', nameEn:'Direct Sales', type:'revenue', parentCode:'4000', level:2, isLeaf:true, normalSide:'credit' }, { code:'4300', nameAr:'مبيعات طلبيات', nameEn:'Pre-Order Sales', type:'revenue', parentCode:'4000', level:2, isLeaf:true, normalSide:'credit' }, { code:'5000', nameAr:'المصروفات', nameEn:'Expenses', type:'expense', parentCode:null, level:1, isLeaf:false, normalSide:'debit' }, { code:'5100', nameAr:'تكلفة المبيعات', nameEn:'COGS', type:'expense', parentCode:'5000', level:2, isLeaf:false, normalSide:'debit' }, { code:'5110', nameAr:'خامات مستهلكة', nameEn:'Materials Consumed', type:'expense', parentCode:'5100', level:3, isLeaf:true, normalSide:'debit' }, { code:'5120', nameAr:'عمالة مباشرة', nameEn:'Direct Labor', type:'expense', parentCode:'5100', level:3, isLeaf:true, normalSide:'debit' }, { code:'5200', nameAr:'مصروفات تشغيلية', nameEn:'Operating Expenses', type:'expense', parentCode:'5000', level:2, isLeaf:false, normalSide:'debit' }, { code:'5210', nameAr:'رواتب إدارية', nameEn:'Admin Salaries', type:'expense', parentCode:'5200', level:3, isLeaf:true, normalSide:'debit' }, { code:'5220', nameAr:'إيجارات', nameEn:'Rent', type:'expense', parentCode:'5200', level:3, isLeaf:true, normalSide:'debit' }, { code:'5230', nameAr:'كهرباء ومياه', nameEn:'Utilities', type:'expense', parentCode:'5200', level:3, isLeaf:true, normalSide:'debit' }, { code:'5240', nameAr:'اتصالات', nameEn:'Communications', type:'expense', parentCode:'5200', level:3, isLeaf:true, normalSide:'debit' }, { code:'5250', nameAr:'تسويق', nameEn:'Marketing', type:'expense', parentCode:'5200', level:3, isLeaf:true, normalSide:'debit' }, { code:'5260', nameAr:'مصروفات متنوعة', nameEn:'Miscellaneous', type:'expense', parentCode:'5200', level:3, isLeaf:true, normalSide:'debit' }, { code:'5300', nameAr:'إهلاك', nameEn:'Depreciation', type:'expense', parentCode:'5000', level:2, isLeaf:true, normalSide:'debit' }, ]; const codeToId = {}; accounts.forEach(a => { codeToId[a.code] = 'ACC-' + a.code; }); DB.chartOfAccounts = accounts.map(a => ({ id: codeToId[a.code], ...a, parentId: a.parentCode ? codeToId[a.parentCode] : null, openingBalance: 0, active: true, notes: '', createdAt: now })); delete a.parentCode; DB.chartOfAccounts.forEach(a => delete a.parentCode); saveDB(); } function ensureDefaultAccountingTabs() { if ((DB.accountingTabs || []).length > 0) return; const now = new Date().toISOString(); DB.accountingTabs = [ { id: 'tab-expenses', key: 'expenses', nameAr: '💸 مصروفات', icon: '💸', fields: [ { key: 'date', label: 'التاريخ', type: 'date', required: true }, { key: 'description', label: 'الوصف', type: 'text', required: true }, { key: 'amount', label: 'المبلغ', type: 'number', required: true }, { key: 'category', label: 'الفئة', type: 'text' }, { key: 'accountId', label: 'الحساب', type: 'account' }, { key: 'paymentMethod', label: 'طريقة الدفع', type: 'paymentMethod' }, { key: 'notes', label: 'ملاحظات', type: 'textarea' } ], order: 1, active: true, system: false, createdAt: now }, { id: 'tab-revenues', key: 'revenues', nameAr: '💰 إيرادات أخرى', icon: '💰', fields: [ { key: 'date', label: 'التاريخ', type: 'date', required: true }, { key: 'description', label: 'الوصف', type: 'text', required: true }, { key: 'amount', label: 'المبلغ', type: 'number', required: true }, { key: 'accountId', label: 'الحساب', type: 'account' }, { key: 'notes', label: 'ملاحظات', type: 'textarea' } ], order: 2, active: true, system: false, createdAt: now }, { id: 'tab-banks', key: 'banks', nameAr: '🏦 بنوك', icon: '🏦', fields: [ { key: 'date', label: 'التاريخ', type: 'date', required: true }, { key: 'bankName', label: 'البنك', type: 'text', required: true }, { key: 'description', label: 'البيان', type: 'text', required: true }, { key: 'deposit', label: 'إيداع', type: 'number' }, { key: 'withdraw', label: 'سحب', type: 'number' }, { key: 'ref', label: 'المرجع', type: 'text' }, { key: 'notes', label: 'ملاحظات', type: 'textarea' } ], order: 3, active: true, system: false, createdAt: now } ]; saveDB(); } async function doLogin() { const email = ($('#loginEmail').value || '').trim().toLowerCase(); const pass = $('#loginPass').value || ''; const err = $('#loginErr'); err.textContent = ''; if (!email || !pass) { err.textContent = 'أدخل البريد وكلمة المرور'; return; } if (SYNC.ready) { err.textContent = 'جاري التحقق...'; await syncPull(false).catch(() => {}); err.textContent = ''; } const acc = DB.admins.find(a => (a.email || '').toLowerCase() === email && a.active !== false); if (!acc) { err.textContent = 'الحساب غير موجود أو معطّل'; return; } if (acc.passHash !== hashPass(pass)) { err.textContent = 'كلمة المرور غير صحيحة'; return; } currentUser = { id: acc.id, name: acc.name, email: acc.email, role: acc.role, permissions: Array.isArray(acc.permissions) && acc.permissions.length ? acc.permissions : null }; setSession(currentUser); enterApp(); } function doLogout() { if (!confirm('تسجيل الخروج؟')) return; clearSession(); currentUser = null; activeExId = null; posCart = []; directSaleCart = []; if (SYNC.sse) { try { SYNC.sse.close(); } catch(e) {} SYNC.sse = null; } $('#appView').classList.add('hidden'); $('#loginView').classList.remove('hidden'); $('#loginEmail').value = ''; $('#loginPass').value = ''; } function enterApp() { $('#loginView').classList.add('hidden'); $('#appView').classList.remove('hidden'); $('#storeName').textContent = CONFIG.storeName; $('#userName').textContent = currentUser.name; $('#userAv').textContent = initials(currentUser.name); const canAdmin = hasPermission('manage_admins') || hasPermission('manage_settings'); $('#adminBtn').style.display = canAdmin ? 'inline-flex' : 'none'; showTab('home'); } /* ═══════════════════════════════════════════════════════════════ TABS ═══════════════════════════════════════════════════════════════ */ function showTab(tab) { currentTab = tab; const pendingOrdersCount = (DB.orders || []).filter(o => o.status === 'pending').length; const pendingConfirmCount = (DB.stageAssignments || []).filter(a => a.status === 'submitted').length; const tabs = [{ id:'home', label:'🏠 الرئيسية' }]; if (hasPermission('manage_orders')) tabs.push({ id:'orders', label:'📋 الطلبيات' + (pendingOrdersCount > 0 ? ' ' + pendingOrdersCount + '' : '') }); if (hasPermission('manage_catalog')) tabs.push({ id:'catalog', label:'📚 الكتالوج' }); if (hasPermission('manage_raw_materials')) tabs.push({ id:'rawmaterials', label:'🧵 الخامات' }); if (hasPermission('view_product_costs') || hasPermission('manage_bom') || hasPermission('manage_labor_rates')) tabs.push({ id:'productcosts', label:'💰 التكاليف' }); if (hasPermission('manage_suppliers') || hasPermission('view_purchases')) tabs.push({ id:'suppliers', label:'🏭 الموردين' }); if (hasPermission('manage_purchases') || hasPermission('view_purchases')) tabs.push({ id:'purchases', label:'🛒 المشتريات' }); if (hasPermission('manage_workers') || hasPermission('view_workers')) tabs.push({ id:'workers', label:'👷 الموظفين' }); if (hasPermission('manage_manufacturing') || hasPermission('view_manufacturing') || hasPermission('submit_work') || hasPermission('confirm_stage_work')) tabs.push({ id:'manufacturing', label:'🏭 التصنيع' + (pendingConfirmCount > 0 ? ' ' + pendingConfirmCount + '' : '') }); if (hasPermission('manage_payroll') || hasPermission('view_payroll')) tabs.push({ id:'payroll', label:'💵 الرواتب' }); if (hasPermission('manage_accounting') || hasPermission('view_accounting')) tabs.push({ id:'accounting', label:'🧮 الحسابات' }); if (hasPermission('manage_tax') || hasPermission('view_tax')) tabs.push({ id:'tax', label:'📊 الضرائب' }); if (hasPermission('manage_assets') || hasPermission('view_assets')) tabs.push({ id:'assets', label:'🏢 الأصول' }); if (hasPermission('manage_multi_warehouse') || hasPermission('transfer_stock')) tabs.push({ id:'warehouses', label:'🏬 المخازن' }); if (hasPermission('manage_rent') || hasPermission('view_rent')) tabs.push({ id:'rent', label:'📄 الإيجارات' }); if (hasPermission('manage_currencies')) tabs.push({ id:'currencies', label:'💱 العملات' }); if (hasPermission('manage_stock')) tabs.push({ id:'mainstock', label:'📦 المخزون' }); if (hasPermission('sell') || hasPermission('sell_direct')) tabs.push({ id:'pos', label:'💳 البيع' }); if (hasPermission('manage_exhibitions') || hasPermission('manage_warehouses')) tabs.push({ id:'exhibitions', label:'🎪 المعارض' }); if (hasPermission('view_reports') || hasPermission('view_profit_report')) tabs.push({ id:'reports', label:'📊 التقارير' }); if (hasPermission('export_data') || hasPermission('import_data') || hasPermission('manage_products')) tabs.push({ id:'data', label:'📤 البيانات' }); if (hasPermission('manage_admins') || hasPermission('manage_settings')) tabs.push({ id:'admin', label:'⚙️ الإدارة' }); tabs.push({ id:'help', label:'📖 دليل' }); $('#tabBar').innerHTML = tabs.map(t => '' ).join(''); const content = $('#tabContent'); const renders = { home: renderHome, orders: renderOrdersTab, catalog: renderCatalogTab, rawmaterials: renderRawMaterials, productcosts: renderProductCosts, suppliers: renderSuppliers, purchases: renderPurchases, workers: renderWorkers, manufacturing: renderManufacturing, payroll: renderPayroll, accounting: renderAccounting, tax: renderTax, assets: renderAssets, warehouses: renderWarehouses, rent: renderRent, currencies: renderCurrencies, mainstock: renderMainStockTab, pos: renderPOS, exhibitions: renderExhibitions, reports: renderReports, data: renderData, admin: renderAdmin, help: renderHelp, }; if (renders[tab]) renders[tab](content); } /* ═══════════════════════════════════════════════════════════════ HOME ═══════════════════════════════════════════════════════════════ */ function renderHome(el) { const active = DB.exhibitions.filter(e => e.status === 'active'); const all = DB.exhibitions; const totalSales = all.reduce((s, ex) => s + (ex.sales || []).reduce((x, sa) => x + (sa.total || 0), 0), 0); const directSales = (DB.directSales || []).reduce((s, x) => s + (x.total || 0), 0); const localCount = DB.products.filter(p => isLocalProduct(p)).length; const pendingOrders = (DB.orders || []).filter(o => o.status === 'pending').length; const inProgressOrders = (DB.orders || []).filter(o => o.status === 'in_progress').length; const readyOrders = (DB.orders || []).filter(o => o.status === 'ready').length; const rmCount = (DB.rawMaterials || []).length; const lowRm = (DB.rawMaterials || []).filter(m => getMaterialStock(m.id) < (Number(m.minStock) || 0)).length; const assetsCount = (DB.fixedAssets || []).length; const activeRent = (DB.rentContracts || []).filter(c => c.status === 'active').length; el.innerHTML = '
' + stat('المعارض النشطة', active.length) + stat('المنتجات', DB.products.length) + stat('🧵 الخامات', rmCount + (lowRm ? ' (' + lowRm + ' منخفض)' : '')) + stat('مبيعات المعارض', money0(totalSales)) + stat('البيع المباشر', money0(directSales)) + stat('📋 طلبيات جديدة', '' + pendingOrders + '') + stat('🔨 تحت التنفيذ', '' + inProgressOrders + '') + stat('✅ جاهزة', '' + readyOrders + '') + stat('🏢 الأصول', assetsCount) + stat('📄 عقود إيجار نشطة', activeRent) + '
' + ((pendingOrders + inProgressOrders + readyOrders) > 0 && hasPermission('manage_orders') ? '
' + '
' + '

📋 طلبيات تحتاج متابعة

' + '

' + pendingOrders + ' قيد الانتظار · ' + inProgressOrders + ' تحت التنفيذ · ' + readyOrders + ' جاهزة' + '

' + '' + '
' + '
' : '') + (active.length ? '

🎪 معارض نشطة

' + active.map(ex => renderExCard(ex, true)).join('') + '
' : '
🎯 لا توجد معارض نشطة.
') + '

⚡ اختصارات

' + (hasPermission('manage_orders') ? '' : '') + (hasPermission('manage_catalog') ? '' : '') + (hasPermission('manage_raw_materials') ? '' : '') + (hasPermission('view_product_costs') ? '' : '') + (hasPermission('manage_purchases') ? '' : '') + (hasPermission('manage_manufacturing') ? '' : '') + (hasPermission('manage_payroll') ? '' : '') + (hasPermission('manage_accounting') ? '' : '') + (hasPermission('sell_direct') ? '' : '') + (hasPermission('sell') ? '' : '') + '
'; } /* ═══════════════════════════════════════════════════════════════ ORDERS TAB ═══════════════════════════════════════════════════════════════ */ const ORDER_STATUS = { pending: { label: '⏳ قيد الانتظار', color: 'var(--warning)', chip: 'orange' }, in_progress: { label: '🔨 تحت التنفيذ', color: 'var(--goldD)', chip: 'gold' }, ready: { label: '✅ جاهز للشحن', color: 'var(--success)', chip: 'green' }, delivered: { label: '📦 تم التسليم', color: 'var(--muted)', chip: 'gray' }, cancelled: { label: '❌ ملغي', color: 'var(--danger)', chip: 'red' } }; function renderOrdersTab(el) { if (!hasPermission('manage_orders') && !hasPermission('sell')) { el.innerHTML = '
لا توجد صلاحية
'; return; } const orders = (DB.orders || []).slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); const q = (ordersState.searchQ || '').toLowerCase().trim(); const filtered = orders.filter(o => { if (ordersState.statusFilter !== 'all' && o.status !== ordersState.statusFilter) return false; if (q) { const searchStr = [o.id, o.customerName, o.customerPhone, o.customerWhatsapp, ...(o.items || []).map(i => i.title)].join(' ').toLowerCase(); if (!searchStr.includes(q)) return false; } return true; }); const counts = { all: orders.length, pending: orders.filter(o => o.status === 'pending').length, in_progress: orders.filter(o => o.status === 'in_progress').length, ready: orders.filter(o => o.status === 'ready').length, delivered: orders.filter(o => o.status === 'delivered').length, cancelled: orders.filter(o => o.status === 'cancelled').length }; const totalRevenue = orders.filter(o => o.status !== 'cancelled').reduce((s, o) => s + (o.total || 0), 0); const totalPaid = orders.filter(o => o.status !== 'cancelled').reduce((s, o) => s + (o.paidAmount || 0), 0); const totalDue = totalRevenue - totalPaid; el.innerHTML = '
' + '
' + '

📋 إدارة الطلبيات

' + '

تتبع الطلبات · التواصل مع العملاء · تحويلها لبيع

' + '
' + '
' + '
' + '
' + stat('إجمالي الطلبيات', orders.length) + stat('⏳ قيد الانتظار', '' + counts.pending + '') + stat('🔨 تحت التنفيذ', '' + counts.in_progress + '') + stat('✅ جاهزة', '' + counts.ready + '') + stat('📦 مسلّمة', counts.delivered) + stat('💰 قيمة الطلبيات', money0(totalRevenue)) + stat('💵 مدفوع', '' + money0(totalPaid) + '') + stat('⏰ مستحق', '' + money0(totalDue) + '') + '
' + '
' + '
' + '
' + '' + '' + '' + '' + '' + '' + '
' + (filtered.length ? '
' + filtered.map(o => renderOrderCard(o)).join('') + '
' : '
' + (orders.length === 0 ? '📋 لا توجد طلبيات بعد
تُنشأ الطلبيات من شاشة البيع' : '🔍 لا توجد طلبيات مطابقة') + '
' ) + '
'; } let _ordersSearchTimer = null; function debouncedOrdersSearch(val) { clearTimeout(_ordersSearchTimer); _ordersSearchTimer = setTimeout(() => { ordersState.searchQ = val; renderOrdersTab($('#tabContent')); }, 300); } function setOrdersFilter(filter) { ordersState.statusFilter = filter; renderOrdersTab($('#tabContent')); } function renderOrderCard(o) { const status = ORDER_STATUS[o.status] || ORDER_STATUS.pending; const payStatus = o.paidAmount >= o.total ? 'paid-full' : 'paid-partial'; const payLabel = o.paidAmount >= o.total ? '✅ مدفوع بالكامل' : (o.paidAmount > 0 ? '⏳ مدفوع جزئياً' : '❌ غير مدفوع'); const itemsList = (o.items || []).slice(0, 3).map(i => '
' + esc(i.title) + ' × ' + i.qty + '' + money(i.total) + '
').join('') + ((o.items || []).length > 3 ? '
+ ' + ((o.items || []).length - 3) + ' منتجات أخرى
' : ''); const waPhone = sanitizePhone(o.customerWhatsapp || o.customerPhone); const whatsappBtn = waPhone ? '💬 واتساب' : ''; return '
' + '
' + '

' + esc(o.customerName || 'عميل') + '

' + esc(o.id) + '
' + '' + status.label + '' + '
' + '
' + (o.customerPhone ? '📱 ' + esc(o.customerPhone) + ' ' : '') + (o.customerWhatsapp && o.customerWhatsapp !== o.customerPhone ? '· 💬 ' + esc(o.customerWhatsapp) : '') + '
' + (o.customerAddress ? '
📍 ' + esc(o.customerAddress) + '
' : '') + '
' + itemsList + '
' + '
📅 تاريخ الطلب: ' + fmtDateShort(o.createdAt) + '' + (o.deliveryDate ? ' · 🚚 التسليم: ' + fmtDateShort(o.deliveryDate) + '' : '') + '
' + '
' + payLabel + '' + money(o.paidAmount || 0) + ' / ' + money(o.total) + '
' + '
💰 الإجمالي' + money(o.total) + '
' + (o.notes ? '
📝 ' + esc(o.notes) + '
' : '') + '
' + whatsappBtn + (o.status === 'pending' ? '' : '') + (o.status === 'in_progress' ? '' : '') + (o.status === 'ready' ? '' : '') + '' + (o.status !== 'delivered' && o.status !== 'cancelled' ? '' : '') + '
' + '
'; } function changeOrderStatus(orderId, newStatus) { const o = (DB.orders || []).find(x => x.id === orderId); if (!o) return; const labels = { in_progress: 'بدء التنفيذ', ready: 'جاهز للشحن', delivered: 'تم التسليم' }; if (!confirm('تحويل الطلب إلى "' + labels[newStatus] + '"؟')) return; if (newStatus === 'delivered') { if (!confirm('⚠️ عند التسليم، سيتم خصم المنتجات من المخزون وتسجيلها كبيع.\n\nمتابعة؟')) return; for (const item of (o.items || [])) { const p = DB.products.find(x => x.sku === item.sku); if (p && !p.perOrder) { p.stock = Math.max(0, (Number(p.stock) || 0) - item.qty); // Multi-warehouse: deduct from default warehouse const defWh = getDefaultWarehouse(); if (defWh) adjustStock(item.sku, defWh.id, -item.qty); } } if (!DB.directSales) DB.directSales = []; const groupId = 'PRE-' + o.id; for (const item of (o.items || [])) { DB.directSales.push({ id: uid(), groupId, date: new Date().toISOString(), sku: item.sku, title: item.title, qty: item.qty, unitPrice: item.unitPrice, discount: 0, total: item.total, paymentMethod: o.paymentMethod || 'cash', paymentRef: o.paymentRef || '', sellerId: o.sellerId || currentUser.id, sellerName: o.sellerName || currentUser.name, customerName: o.customerName, customerPhone: o.customerPhone, source: 'preorder', orderId: o.id }); } } o.status = newStatus; o.updatedAt = new Date().toISOString(); if (newStatus === 'delivered') o.deliveredAt = new Date().toISOString(); saveDB(); toast('✅ تم تحديث حالة الطلب', 'ok'); if (currentTab === 'orders') renderOrdersTab($('#tabContent')); if (currentTab === 'home') renderHome($('#tabContent')); } function cancelOrder(orderId) { const o = (DB.orders || []).find(x => x.id === orderId); if (!o) return; if (!confirm('إلغاء الطلب ' + o.id + '؟')) return; o.status = 'cancelled'; o.updatedAt = new Date().toISOString(); o.cancelledAt = new Date().toISOString(); saveDB(); toast('❌ تم إلغاء الطلب', 'ok'); if (currentTab === 'orders') renderOrdersTab($('#tabContent')); } function openOrderDetails(orderId) { const o = (DB.orders || []).find(x => x.id === orderId); if (!o) return; const status = ORDER_STATUS[o.status] || ORDER_STATUS.pending; const itemsTable = (o.items || []).map(i => '' + esc(i.title) + '
' + esc(i.sku) + '' + i.qty + '' + money(i.unitPrice) + '' + money(i.total) + '').join(''); const waPhone = sanitizePhone(o.customerWhatsapp || o.customerPhone); showModal( '' + '' ); } function openPreOrderForm(source, items, cartSource) { if (!items || !items.length) { toast('السلة فارغة', 'err'); return; } const total = items.reduce((s, i) => s + i.unitPrice * i.qty, 0); showModal( '' + '' ); } function updatePreOrderPayment(type) { const field = $('#poPaidAmountField'); if (!field) return; field.style.display = (type === 'partial') ? 'block' : 'none'; } function savePreOrder(source, items) { const name = ($('#poName').value || '').trim(); const phone = ($('#poPhone').value || '').trim(); if (!name) return toast('الاسم مطلوب', 'err'); if (!phone) return toast('التليفون مطلوب', 'err'); const total = items.reduce((s, i) => s + i.unitPrice * i.qty, 0); const paymentType = $('#poPaymentType').value; let paidAmount = 0; if (paymentType === 'full') paidAmount = total; else if (paymentType === 'partial') paidAmount = Math.min(total, Math.max(0, Number($('#poPaidAmount').value) || 0)); const orderId = 'ORD-' + Date.now().toString(36).toUpperCase().slice(-6); const order = { id: orderId, status: 'pending', source, items: items.map(i => ({ sku: i.sku, title: i.title, qty: i.qty, unitPrice: i.unitPrice, total: i.unitPrice * i.qty })), total, paidAmount, remainingAmount: total - paidAmount, paymentType, paymentMethod: $('#poPaymentMethod').value, customerName: name, customerPhone: phone, customerWhatsapp: ($('#poWhatsapp').value || '').trim() || phone, customerEmail: ($('#poEmail').value || '').trim(), customerAddress: ($('#poAddress').value || '').trim(), deliveryDate: $('#poDeliveryDate').value, notes: ($('#poNotes').value || '').trim(), sellerId: currentUser.id, sellerName: currentUser.name, exhibitionId: source === 'exhibition' ? activeExId : null, exhibitionName: source === 'exhibition' ? (DB.exhibitions.find(e => e.id === activeExId)?.nameAr || '') : null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; if (!DB.orders) DB.orders = []; DB.orders.push(order); if (source === 'direct') { directSaleCart = []; renderDirectCart(); renderDirectProducts(); } else { posCart = []; posRenderCart(); posRenderProducts(); } saveDB(); closeModal(); toast('✅ تم حفظ الطلبية ' + orderId, 'ok'); showPreOrderReceipt(order); } function showPreOrderReceipt(order) { showModal( '' + '' ); } function printPreOrderReceipt(orderId) { const o = (DB.orders || []).find(x => x.id === orderId); if (!o) return; const html = 'حجز طلبية' + '' + '

Heritage & More

حجز طلبية · ' + o.id + '
' + '
التاريخ' + new Date(o.createdAt).toLocaleDateString('ar-EG') + '
' + '
العميل' + o.customerName + '
' + '
التليفون' + o.customerPhone + '
' + (o.deliveryDate ? '
التسليم المتوقع' + new Date(o.deliveryDate).toLocaleDateString('ar-EG') + '
' : '') + '
' + o.items.map(i => '
' + i.title + ' × ' + i.qty + '' + money(i.total) + '
').join('') + '
' + '
الإجمالي' + money(o.total) + '
' + (o.paidAmount > 0 ? '
مدفوع' + money(o.paidAmount) + '
' : '') + (o.remainingAmount > 0 ? '
متبقي' + money(o.remainingAmount) + '
' : '') + '
الإجمالي' + money(o.total) + '
' + '
📞 سنتواصل معكم قبل التسليم
شكراً لثقتكم
' + '