🏢
Heritage & More ERP
نظام إدارة الموارد المتكامل
💡 الحساب الافتراضي:
admin@pos.local / pos1234
🏢
Heritage & More
ERP · نظام إدارة الموارد
جاري الاتصال...
?
—
📱
ثبّت التطبيق
للوصول السريع والعمل بدون إنترنت
/* ═══════════════════════════════════════════════════════════════
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 = '';
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 ''; }
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(
'' +
'📋 تفاصيل الطلب
' +
'
' + esc(o.id) + ' · ' + status.label + '
' +
'
👤 بيانات العميل
' +
'
' +
'
الاسم:
' + esc(o.customerName || '—') + '
' +
'
التليفون:
' + esc(o.customerPhone || '—') + '
' +
'
واتساب:
' + esc(o.customerWhatsapp || o.customerPhone || '—') + '
' +
'
البريد:
' + esc(o.customerEmail || '—') + '
' +
'
العنوان:
' + esc(o.customerAddress || '—') + '
' +
'
' +
'
' +
'
🛍 المنتجات
| المنتج | كمية | سعر | إجمالي |
' + itemsTable + '
' +
'
' +
'
💰 إجمالي الطلب' + money(o.total) + '
' +
'
💵 مدفوع' + money(o.paidAmount || 0) + '
' +
'
⏰ مستحق' + money(o.total - (o.paidAmount || 0)) + '
' +
'
' +
'
📅 التواريخ
' +
'
📅 تاريخ الطلب: ' + fmtDate(o.createdAt) + '
' +
(o.deliveryDate ? '
🚚 التسليم المتوقع: ' + fmtDateShort(o.deliveryDate) + '
' : '') +
(o.deliveredAt ? '
✅ تاريخ التسليم: ' + fmtDate(o.deliveredAt) + '
' : '') +
(o.cancelledAt ? '
❌ تاريخ الإلغاء: ' + fmtDate(o.cancelledAt) + '
' : '') +
'
👤 البائع: ' + esc(o.sellerName || '—') + '
' +
(o.exhibitionName ? '
🎪 المعرض: ' + esc(o.exhibitionName) + '
' : '') +
'
🔖 المصدر: ' + (o.source === 'exhibition' ? '🎪 معرض' : '💰 بيع مباشر') + '
' +
'
' +
(o.notes ? '
📝 ملاحظات
' + esc(o.notes) + '
' : '') +
'
' +
'
' +
(waPhone ? '
💬 واتساب' : '') +
'
' +
'
'
);
}
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(
'' +
'' +
'
📋 حجز طلبية
' +
'
' +
(source === 'exhibition' ? '🎪 من المعرض' : '💰 من المخزون الرئيسي') + ' · ' + items.length + ' منتج · ' + money(total) +
'
' +
'
🛍 المنتجات
' +
items.map(i => '
' + esc(i.title) + ' × ' + i.qty + '' + money(i.unitPrice * i.qty) + '
').join('') +
'
الإجمالي' + money(total) + '
' +
'
' +
'
👤 بيانات العميل
' +
'
' +
'
💳 الدفع والتسليم
' +
'
' +
'' +
'
' +
'
' +
'
' +
'' +
'
' +
'
' +
'
' +
'
' +
'' +
'' +
'
' +
'
'
);
}
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(
'' +
'' +
'
' +
'
' +
'
📋
تأكيد حجز طلبية
' + esc(order.id) + '
' +
'
' +
'
' +
'
التاريخ' + fmtDate(order.createdAt) + '
' +
'
العميل' + esc(order.customerName) + '
' +
'
التليفون' + esc(order.customerPhone) + '
' +
(order.deliveryDate ? '
التسليم المتوقع' + fmtDateShort(order.deliveryDate) + '
' : '') +
'
' + order.items.map(i => '
' + esc(i.title) + ' × ' + i.qty + '' + money(i.total) + '
').join('') + '
' +
'
الإجمالي' + money(order.total) + '
' +
(order.paidAmount > 0 ? '
مدفوع' + money(order.paidAmount) + '
' : '') +
(order.remainingAmount > 0 ? '
متبقي' + money(order.remainingAmount) + '
' : '') +
'
📞 سنتواصل معكم قبل التسليم
' +
'
' +
'
' +
'
' +
'' +
'' +
'
' +
'
'
);
}
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) + '
' +
'📞 سنتواصل معكم قبل التسليم
شكراً لثقتكم
' +
'