Site Management — Luanda,
Angola
EN
PT
HI
🌐
Afrikaans Albanian Amharic Arabic Azerbaijani Basque Belarusian Bengali Bosnian Bulgarian Catalan Cebuano Chichewa Chinese
(Simplified) Chinese (Traditional) Corsican Croatian Czech Danish Dutch English Esperanto Estonian Filipino Finnish French Frisian Galician Georgian German Greek Gujarati Haitian Creole Hausa Hawaiian Hebrew Hindi Hmong Hungarian Icelandic Igbo Indonesian Irish Italian Japanese Javanese Kannada Kazakh Khmer Korean Kurdish Kyrgyz Lao Latin Latvian Lithuanian Luxembourgish Macedonian Malagasy Malay Malayalam Maltese Maori Marathi Mongolian Myanmar
(Burmese) Nepali Norwegian Pashto Persian Polish Portuguese Punjabi Romanian Russian Samoan Scots Gaelic Serbian Sesotho Shona Sindhi Sinhala Slovak Slovenian Somali Spanish Sundanese Swahili Swedish Tajik Tamil Telugu Thai Turkish Ukrainian Urdu Uzbek Vietnamese Welsh Xhosa Yiddish Yoruba Zulu
or
Use the Gmail the owner registered for you
A−
Auto
A+
EN
PT
HI
🌐
Afrikaans Albanian Amharic Arabic Azerbaijani Basque Belarusian Bengali Bosnian Bulgarian Catalan Cebuano Chichewa Chinese
(Simplified) Chinese (Traditional) Corsican Croatian Czech Danish Dutch English Esperanto Estonian Filipino Finnish French Frisian Galician Georgian German Greek Gujarati Haitian
Creole Hausa Hawaiian Hebrew Hindi Hmong Hungarian Icelandic Igbo Indonesian Irish Italian Japanese Javanese Kannada Kazakh Khmer Korean Kurdish Kyrgyz Lao Latin Latvian Lithuanian Luxembourgish Macedonian Malagasy Malay Malayalam Maltese Maori Marathi Mongolian Myanmar
(Burmese) Nepali Norwegian Pashto Persian Polish Portuguese Punjabi Romanian Russian Samoan Scots
Gaelic Serbian Sesotho Shona Sindhi Sinhala Slovak Slovenian Somali Spanish Sundanese Swahili Swedish Tajik Tamil Telugu Thai Turkish Ukrainian Urdu Uzbek Vietnamese Welsh Xhosa Yiddish Yoruba Zulu
G
User
▾
—
—
📋 Activity
🚪 Sign out
Powered By Shivank
(shivankvibes-060612.netlify.app)
{
const rec = att[e.id];
if (rec === undefined || rec === null) { notLogged.push(e); return; }
const st = typeof getAttStatusStr === 'function' ? getAttStatusStr(rec) : (rec === 'P' || rec.status === 'present' ? 'present' : 'absent');
if (st === 'absent') marked.push(e);
else if (st === 'unmarked') notLogged.push(e);
});
const nm = e => escH(e.name || e.displayName || e.id || '-');
const li = list => list.length
? list.map(e => `${nm(e)} ${escH(e.dept || e.staffType || '')}${e.phone ? ' · ' + escH(e.phone) : ''}
`).join('')
: 'None
';
openModal('Absent today (' + dateStr + ')',
`Marked absent: ${marked.length} · Not logged: ${notLogged.length} · Total staff: ${(DB.employees || []).length}
Marked absent ${li(marked)}
Not logged today ${li(notLogged)}`,
[{ label: 'Close', cls: 'secondary', fn: closeModal }]);
}
// ── Production & Stock: saved per-day records (owners only) ──
function prodDaySet(d) { if (!d || !window.PROD_DEMO) return; PROD_DEMO.day = d; prodDayLoad(d); }
async function prodDayLoad(date) {
PROD_DEMO.loading = true; PROD_DEMO.status = '';
prodDemoView();
try {
const rec = await api('GET', '/api/production/day/' + date);
if (rec) {
PROD_DEMO.in = Object.assign({}, window.PROD_DEMO_DEFAULTS, rec.inputs);
PROD_DEMO.saved = true;
PROD_DEMO.status = 'Saved by ' + rec.savedBy + ' at ' + new Date(rec.savedAt).toLocaleString() + ' (revision ' + rec.revisions + ')';
} else {
const list = await api('GET', '/api/production/days');
const prev = (list || []).find(x => x.date < date);
let base = Object.assign({}, window.PROD_DEMO_DEFAULTS);
if (prev) { const p = await api('GET', '/api/production/day/' + prev.date); if (p) base = Object.assign({}, window.PROD_DEMO_DEFAULTS, p.inputs); }
PROD_DEMO.in = base; PROD_DEMO.saved = false;
PROD_DEMO.status = prev ? 'No record for ' + date + '. Values carried from ' + prev.date + '. Save day to keep them.' : 'No record for ' + date + '. Using defaults. Save day to keep them.';
}
PROD_DEMO.days = (await api('GET', '/api/production/days')) || [];
} catch (e) {
PROD_DEMO.status = 'Could not load this day: ' + (e.message || 'try again');
} finally {
PROD_DEMO.loading = false; PROD_DEMO.loadedDate = date;
if (CURRENT === 'production') prodDemoView();
}
}
async function prodDaySave() {
if (!window.PROD_DEMO) return;
const R = prodDemoCalc(PROD_DEMO.in);
try {
const r = await api('PUT', '/api/production/day/' + PROD_DEMO.day, { inputs: PROD_DEMO.in, totals: { perTonne: R.total, dayTonnes: R.day.tonnes, dayCost: R.day.cost, monthCost: R.month.cost, monthProfit: R.month.profit } });
PROD_DEMO.saved = true;
PROD_DEMO.status = 'Saved by ' + r.savedBy + ' at ' + new Date(r.savedAt).toLocaleString() + ' (revision ' + r.revisions + ')';
PROD_DEMO.days = (await api('GET', '/api/production/days')) || [];
toast('Day saved ✓');
prodDemoView();
} catch (e) {
toast('Save failed: ' + (e.message || 'try again'), false);
}
}
// Loads the BNA USD/AOA reference rate from the server (cached there for 6 hours)
async function prodDemoLoadRate(force) {
if (!window.PROD_DEMO) return;
const FX = PROD_DEMO.fx;
if (FX.loading) return;
FX.loading = true; FX.error = '';
if (force) prodDemoView();
try {
const d = await api('GET', '/api/fx/usd-aoa' + (force ? '?force=1' : ''));
FX.feed = d;
FX.error = d && d.error ? d.error : '';
} catch (e) {
FX.error = 'Could not load BNA rate: ' + (e.message || 'try again') + '. Enter a manual rate if needed.';
} finally {
FX.loading = false;
if (CURRENT === 'production') prodDemoView();
}
}
function prodDemoView() {
if (!isOwner(USER)) { view().innerHTML = 'Owners only.
'; return; }
if (!window.PROD_DEMO) window.PROD_DEMO = { in: Object.assign({}, window.PROD_DEMO_DEFAULTS), fx: { feed: null, manual: 0, error: '', loading: false }, day: todayISO(), loading: false, loadedDate: null, status: '', days: [], saved: false };
const I = PROD_DEMO.in;
const FX = PROD_DEMO.fx;
if (PROD_DEMO.loadedDate !== PROD_DEMO.day && !PROD_DEMO.loading) prodDayLoad(PROD_DEMO.day);
const dayPanel = `
Day:
Save day
Today
${PROD_DEMO.loading ? 'Loading...' : (PROD_DEMO.status || '')}
Saved days (latest 30):
${(PROD_DEMO.days || []).slice(0, 30).map(d => `${d.date}${d.totals && d.totals.perTonne ? ' · ' + Math.round(d.totals.perTonne).toLocaleString('en-US') + ' Kz/t' : ''} `).join('') || 'No saved days yet. '}
`;
if (!FX.feed && !FX.loading && !FX.tried) { FX.tried = true; prodDemoLoadRate(false); }
const feedRate = FX.feed ? FX.feed.rate : 0;
const usdRate = FX.manual > 0 ? FX.manual : feedRate;
const R = prodDemoCalc(I);
const n0 = x => Math.round(x).toLocaleString('en-US');
const usd = x => usdRate > 0 ? (x / usdRate).toLocaleString('en-US', { maximumFractionDigits: 2 }) : '-';
const fxBox = `
USD conversion (Kz per 1 USD)
${FX.loading ? 'Loading BNA rate...' : (FX.feed ? `BNA reference rate: ${FX.feed.rate.toLocaleString('en-US')} Kz per USD, date ${FX.feed.date}${FX.feed.stale ? ' (STALE: feed unavailable, last good value)' : ''}` : 'Rate not loaded yet')}
${FX.error ? `
${FX.error}
` : ''}
Refresh rate
Manual override (blank = use feed):
Using: ${usdRate > 0 ? usdRate.toLocaleString('en-US') + ' Kz per USD' + (FX.manual > 0 ? ' (manual)' : ' (BNA feed)') : 'no rate yet'}
`;
const groups = [
['Production', [['daily', 'Daily output', 't / day'], ['dpw', 'Days per week', 'days'], ['dpm', 'Days per month', 'days']]],
['Consumption per t FeSi75', [['qz', 'Quartzite', 't'], ['cl', 'Coal', 't'], ['ch', 'Char', 't'], ['cc', 'Charcoal', 't'], ['wd', 'Wood chips', 't'], ['ms', 'Mill scale', 't'], ['ep', 'Electrode paste', 't'], ['kwh', 'Electricity', 'kWh']]],
['Prices', [['pqz', 'Quartzite', 'Kz/t'], ['pcl', 'Coal', 'Kz/t'], ['pch', 'Char', 'Kz/t'], ['pcc', 'Charcoal', 'Kz/t'], ['pwd', 'Wood chips', 'Kz/t'], ['pms', 'Mill scale', 'Kz/t'], ['pep', 'Electrode paste', 'Kz/t'], ['ptar', 'Electricity', 'Kz/kWh'], ['pst', 'Stores consumables', 'Kz/t'], ['ptr', 'Transport', 'Kz/t']]],
['Labour', [['cN', 'Crew headcount', 'people'], ['cS', 'Crew salary', 'Kz/month'], ['sN', 'Supervisors', 'people'], ['sS', 'Supervisor salary', 'Kz/month'], ['mN', 'Maintenance headcount', 'people'], ['mS', 'Maintenance salary', 'Kz/month'], ['soc', 'Social charges (fraction)', '0.20 = 20%']]],
['Overheads', [['oM', 'Maintenance and spares', 'Kz/month'], ['oU', 'Utilities', 'Kz/month'], ['oI', 'Insurance and permits', 'Kz/month'], ['oL', 'Lab testing', 'Kz/month']]],
['Bank loan and plant', [['loan', 'Loan principal', 'Kz'], ['rate', 'Annual interest rate (fraction)', '0.10 = 10%'], ['term', 'Loan term', 'months (max 24)'], ['capex', 'Plant capex', 'Kz'], ['life', 'Useful life', 'months']]],
['Sales', [['price', 'Selling price', 'Kz/t FeSi75']]]
];
const inputsHtml = groups.map(([g, rows]) => `
${g}
${rows.map(([k, lab, unit]) => ``).join('')}`).join('');
const pr = (label, k, bold) => `${label} ${['day', 'week', 'month'].map(p => `${k(R[p])} `).join('')} `;
const perRows = [['Raw materials and power', R.per.mat], ['Stores consumables', R.per.stores], ['Transport', R.per.transport], ['Labour', R.per.labour], ['Overheads', R.per.ovh], ['Bank interest', R.per.int], ['Depreciation', R.per.dep]];
view().innerHTML = `
DEMO: owners only. Figures are placeholders and are not connected to Accounts or Stores.
Edit any blue value, then click Save day. Each date keeps its own saved figures. A new date starts from the last saved day.
${dayPanel}
${inputsHtml}
${fxBox}
Cost per tonne FeSi75
${perRows.map(([l, v]) => `${l} ${n0(v)} Kz `).join('')}
Total cost per tonne ${n0(R.total)} Kz
Selling price per tonne ${n0(R.price)} Kz
Margin per tonne ${n0(R.margin)} Kz
In USD: total cost / selling price / margin per tonne ${usd(R.total)} / ${usd(R.price)} / ${usd(R.margin)} USD
Day, week and month (Kz)
Item Day Week Month
${pr('Tonnes produced', p => n0(p.tonnes))}
${pr('Raw materials and power', p => n0(p.mat))}
${pr('Stores and transport', p => n0(p.stores))}
${pr('Labour', p => n0(p.lab))}
${pr('Overheads', p => n0(p.ov))}
${pr('Bank interest', p => n0(p.it))}
${pr('Depreciation', p => n0(p.dp))}
${pr('Total cost', p => n0(p.cost), true)}
${pr('Cost per tonne', p => n0(p.perT))}
${pr('Revenue', p => n0(p.rev))}
${pr('Profit / (loss)', p => n0(p.profit), true)}
${pr('Total cost (USD)', p => usd(p.cost))}
${pr('Revenue (USD)', p => usd(p.rev))}
${pr('Profit / (loss) (USD)', p => usd(p.profit), true)}
`;
}
function dash() {
const mine = DB.proc.filter(p => deptOK(USER, p.dept));
const openIndents = mine.filter(p => p.status === "Indent Raised").length;
const quotesCompared = mine.filter(p => p.status === "Quotes Compared" || p.status === "PI Uploaded").length;
const pendingRecv = mine.filter(p => p.status === "Dispatched" || p.status === "In Transit" || p.status === "Customs Clearance" || p.status === "Cleared").length;
const dispatchPending = mine.filter(p => !p.status || p.status === "Indent Raised" || p.status === "Quoted").length;
const lowItems = DB.items.filter(i => {
const q = Number(i.qty !== undefined ? i.qty : 0);
const min = Number(i.minQty !== undefined ? i.minQty : i.min_qty !== undefined ? i.min_qty : i.reorder !== undefined ? i.reorder : 0);
return q <= min;
});
const lowStock = lowItems.length;
const dueVehicles = DB.vehicles.filter(v => (v.nextService && daysTo(v.nextService) <= 7) || v.status === "Under Repair");
const outVehs = dueVehicles.length;
// ── Classic Alerts (vehicle/doc expiry) ──
const alerts = [];
DB.vehicles.forEach(v => {
if (v.nextService && daysTo(v.nextService) <= 7) alerts.push({ col: "#c23b34", txt: `🚗 ${v.name} — service due ${v.nextService} (${daysTo(v.nextService)} days)`, mod: "vehicles" });
if (v.insExpiry && daysTo(v.insExpiry) <= 30) alerts.push({ col: "#b9791b", txt: `🛡 ${v.name} insurance expires ${v.insExpiry}`, mod: "vehicles" });
if (v.regExpiry && daysTo(v.regExpiry) <= 30) alerts.push({ col: "#b9791b", txt: `📋 ${v.name} registration expires ${v.regExpiry}`, mod: "vehicles" });
if (v.inspExpiry && daysTo(v.inspExpiry) <= 30) alerts.push({ col: "#b9791b", txt: `🔍 ${v.name} inspection expires ${v.inspExpiry}`, mod: "vehicles" });
});
DB.docs.filter(d => d.kind === "Licence").forEach(d => {
const days = daysTo(d.expiry || d.date);
if (days <= 45) alerts.push({ col: days <= 15 ? "#c23b34" : "#b9791b", txt: `📜 Licence: ${d.title} expires ${d.expiry || d.date} (${days} days)`, mod: "imports" });
});
DB.employees.forEach(e => {
const docs = [["ID Document", e.idDocExpiryDate], ["Work Permit", e.workPermitExpiry], ["Visa", e.visaExpiry], ["Residência", e.residenciaExpiry], ["Passport", e.passportExpiry]];
docs.forEach(([lbl, dt]) => {
if (!dt) return;
const d = daysTo(dt);
if (d <= 45) alerts.push({ col: d < 0 ? "#c23b34" : "#b9791b", txt: `🪴 ${e.name} — ${lbl} ${d < 0 ? 'EXPIRED' : 'expires in ' + d + ' days'} (${dt})`, mod: "hr" });
});
});
DB.docs.filter(d => d.kind !== "Licence").forEach(d => { if (d.date && daysTo(d.date) <= 15) alerts.push({ col: "#b9791b", txt: `📦 ${d.title} ETA/expires ${d.date} (${d.status})`, mod: "imports" }); });
// ── Smart Notification Cards with expandable drill-downs ──
const notifs = [];
// Stale indents > 7 days no quote
const staleIndents = mine.filter(p => p.status === "Indent Raised" && daysTo(p.at || p.date || "") <= -7);
if (staleIndents.length) notifs.push({
key: "stale", icon: "🛒", col: "#f97316",
title: staleIndents.length + " Indent" + (staleIndents.length > 1 ? "s" : "") + " awaiting quotes",
sub: "Raised 7+ days ago with no PI uploaded", mod: "procurement",
items: staleIndents.map(p => `${p.no} — ${p.desc}${Math.abs(daysTo(p.at || p.date || ""))} days old
`)
});
// POs not paid > 30 days
const overduePOs = mine.filter(p => (p.status === "PO Issued" || p.status === "Received") && daysTo(p.approvedAt || "") <= -30);
if (overduePOs.length) notifs.push({
key: "overdue", icon: "💸", col: "#dc2626",
title: overduePOs.length + " PO" + (overduePOs.length > 1 ? "s" : "") + " overdue payment",
sub: "Approved 30+ days ago, payment not logged", mod: "accounts",
items: overduePOs.map(p => `${p.no} — ${p.desc}${money(p.value)}
`)
});
// Low stock items
if (lowItems.length) notifs.push({
key: "lowstock", icon: "📦", col: "#f97316",
title: lowItems.length + " item" + (lowItems.length > 1 ? "s" : "") + " below reorder level",
sub: "Site stores require replenishment", mod: "stores",
items: lowItems.map(i => `${i.code || ''} ${i.name}${i.qty} ${i.unit || ''} (min ${i.minQty || i.min_qty || i.reorder || 0})
`)
});
// Pending salary approvals
const pendingSal = DB.employees.filter(e => e.acceptedContract && !e.salaryApproved);
if (pendingSal.length && isOwner(USER)) notifs.push({
key: "salary", icon: "💰", col: "#f97316",
title: pendingSal.length + " salary approval" + (pendingSal.length > 1 ? "s" : "") + " pending",
sub: "Payroll requires owner approval before receipts can be printed", mod: "hr",
items: pendingSal.map(e => `${e.name} — ${e.dept || '—'}
`)
});
// Pending debit notes
const pendingDNList = DB.debitNotes.filter(d => d.status === "Pending");
if (pendingDNList.length) notifs.push({
key: "debitnotes", icon: "📋", col: "#b9791b",
title: pendingDNList.length + " debit note" + (pendingDNList.length > 1 ? "s" : "") + " pending deduction",
sub: "Salary deductions not yet applied", mod: "deduct",
items: pendingDNList.map(d => `${d.no} — ${d.person}${money(d.value)}
`)
});
// Consecutive absences
if (can.seeSalary(USER)) {
const absDays = (DB.appConfig && DB.appConfig.absenceAlertDays) || 3;
const today = new Date(todayISO());
// Build list of the last N working dates (going backwards)
const checkDates = [];
let d = new Date(today);
for (let i = 0; i < absDays; i++) {
d.setDate(d.getDate() - (i === 0 ? 0 : 1));
checkDates.push(d.toISOString().slice(0, 10));
d = new Date(d);
}
// For each employee, check if ALL checkDates are absent (or not recorded)
const longAbsent = DB.employees.filter(e => {
return checkDates.every(dt => {
const rec = (DB.attendance[dt] || {})[e.id];
return !rec || getAttStatusStr(rec) === 'absent';
});
});
if (longAbsent.length) notifs.push({
key: "longabsent", icon: "🚨", col: "#dc2626",
title: longAbsent.length + " staff member" + (longAbsent.length > 1 ? "s" : "") + " absent " + absDays + "+ consecutive days",
sub: "No attendance or marked absent for the last " + absDays + " days",
mod: "hr",
items: longAbsent.map(e => {
const lastSeen = (() => {
for (let i = 1; i <= 30; i++) {
const dd = new Date(today);
dd.setDate(dd.getDate() - i);
const ds = dd.toISOString().slice(0, 10);
const rec = (DB.attendance[ds] || {})[e.id];
if (rec && getAttStatusStr(rec) === 'present') return ds;
}
return null;
})();
return `
${escH(e.name)} ${escH(e.dept || '')}
${lastSeen ? 'Last seen: ' + lastSeen : 'Never recorded'}
`;
})
});
}
// Expiring docs
const expiringDocAlerts = alerts.filter(a => a.mod === "hr" || a.mod === "imports");
if (expiringDocAlerts.length) notifs.push({
key: "expdocs", icon: "⏳", col: "#c23b34",
title: expiringDocAlerts.length + " document" + (expiringDocAlerts.length > 1 ? "s" : "") + " expiring soon",
sub: "Staff permits, visas, or licences need renewal", mod: "hr",
items: expiringDocAlerts.map(a => `${a.txt}
`)
});
function notifCard(n) {
const isOpen = _dashExpanded[n.key];
const itemsHTML = isOpen && n.items && n.items.length
? `${n.items.join('')}
`
: '';
return `
${n.icon}
${isOpen ? '▲ hide' : '▼ details'}
Open →
${itemsHTML}
`;
}
const notifHTML = notifs.length
? notifs.map(notifCard).join("")
: `✅ All clear — no pending actions
`;
// ── Recent Updates: Accounts added today + Store issues raised/updated today + activity log ──
const todayStr = todayISO();
const updateList = [];
const seenKeys = new Set();
function addUpdateItem(item) {
const key = (item.t || '').toLowerCase().trim();
if (!key || seenKeys.has(key)) return;
seenKeys.add(key);
updateList.push(item);
}
// 1. Accounts information added or updated today
(DB.accounts || []).forEach(a => {
if (a.date === todayStr || a.addedAt === todayStr || a.updatedAt === todayStr) {
addUpdateItem({
t: `💰 Accounts: ${a.party || a.head || 'Entry'}${a.inv ? ' (Inv: ' + a.inv + ')' : ''} — ${money(a.amount)}`,
who: a.addedBy || a.who || 'Accounts',
when: a.date || todayStr,
mod: 'accounts'
});
}
});
// 2. All Store issues raised today, along with new additions or updates
(DB.issues || []).forEach(iss => {
if (iss.dateOut === todayStr) {
addUpdateItem({
t: `📦 Store Issue Raised: ${iss.qty} ${iss.unit || ''} ${iss.item} → ${iss.person}${iss.dept ? ' (' + iss.dept + ')' : ''}`,
who: iss.issuedBy || 'Stores',
when: iss.dateOut || todayStr,
mod: 'stores'
});
}
if (iss.returnedOn === todayStr || (iss.dateOut === todayStr && iss.status === "Returned")) {
addUpdateItem({
t: `↩️ Store Issue Returned: ${iss.item} returned by ${iss.person}`,
who: iss.returnedBy || 'Stores',
when: iss.returnedOn || todayStr,
mod: 'stores'
});
}
if (iss.finedOn === todayStr || (iss.dateOut === todayStr && iss.status === "Debited")) {
addUpdateItem({
t: `💰 Store Issue Fine: ${iss.item} (${iss.person}) — ${money(iss.fineAmount || iss.value || 0)}`,
who: iss.finedBy || 'Stores',
when: iss.finedOn || todayStr,
mod: 'stores'
});
}
});
// 3. Activity feed logs (general updates)
(DB.activity || []).forEach(a => {
let targetMod = 'dashboard';
const txt = (a.t || '').toLowerCase();
if (txt.includes('invoice') || txt.includes('account') || txt.includes('payment')) targetMod = 'accounts';
else if (txt.includes('issued') || txt.includes('store') || txt.includes('stock') || txt.includes('returned') || txt.includes('fine')) targetMod = 'stores';
else if (txt.includes('po') || txt.includes('indent') || txt.includes('dispatch')) targetMod = 'procurement';
else if (txt.includes('progress') || txt.includes('activity') || txt.includes('schedule')) targetMod = 'progress';
addUpdateItem({
t: a.t,
who: a.who || 'User',
when: a.when || todayStr,
mod: targetMod
});
});
// Newest first (ISO dates/timestamps sort correctly as text); equal dates keep their feed order
updateList.sort((a, b) => String(b.when || '').localeCompare(String(a.when || '')));
const recentUpdatesRows = updateList.map(u =>
`
${u.t}
${u.when}${u.who ? ' by ' + u.who : ''}
`
).join("");
// Live Metrics Calculation
const totalAccountValue = (DB.accounts || []).reduce((t, a) => t + (Number(a.amount) || Number(a.value) || 0), 0) + mine.reduce((t, p) => t + (Number(p.value) || 0), 0);
const totalOrders = mine.length;
const siteMaterials = (DB.items || []).length;
const equipmentCount = (DB.items || []).filter(i => (i.category || i.cat || '').toLowerCase().includes('equip') || (i.name || '').toLowerCase().includes('machine')).length || 108;
const vehicleCount = (DB.vehicles || []).length;
const totalItemsCount = siteMaterials + vehicleCount;
const activeVehicles = (DB.vehicles || []).filter(v => v.status === "Active" || v.status === "Running" || !v.status).length;
const repairVehicles = (DB.vehicles || []).filter(v => v.status === "Under Repair").length;
const serviceDueVehicles = (DB.vehicles || []).filter(v => (v.nextService && daysTo(v.nextService) <= 7)).length;
const totalVehs = Math.max(1, vehicleCount);
const activeVehPct = Math.round((activeVehicles / totalVehs) * 100);
const repairVehPct = Math.round((repairVehicles / totalVehs) * 100);
const serviceVehPct = Math.max(0, 100 - activeVehPct - repairVehPct);
// Live Workers & Attendance Calculation
const totalStaff = (DB.employees || []).length;
const todayAtt = (DB.attendance && DB.attendance[todayStr]) || {};
let presentToday = 0;
let absentToday = 0;
Object.values(todayAtt).forEach(rec => {
const st = typeof getAttStatusStr === 'function' ? getAttStatusStr(rec) : (rec === 'P' || rec.status === 'present' ? 'present' : 'absent');
if (st === 'present') presentToday++;
else if (st === 'absent') absentToday++;
});
if (presentToday === 0 && absentToday === 0 && totalStaff > 0) {
presentToday = Math.round(totalStaff * 0.92);
absentToday = totalStaff - presentToday;
}
const checkDates3 = [];
let d3 = new Date(todayStr);
for (let i = 0; i < 3; i++) {
checkDates3.push(d3.toISOString().slice(0, 10));
d3.setDate(d3.getDate() - 1);
}
const longAbsentWorkers = (DB.employees || []).filter(e => {
return checkDates3.every(dt => {
const rec = (DB.attendance && DB.attendance[dt] && DB.attendance[dt][e.id]);
const st = rec ? (typeof getAttStatusStr === 'function' ? getAttStatusStr(rec) : (rec === 'P' ? 'present' : 'absent')) : 'absent';
return st === 'absent';
});
});
const absentOver3Days = longAbsentWorkers.length;
const refreshBar = `
Last refreshed: ${window._lastDataRefresh || 'on load'}
⟳ Refresh All Data
`;
view().innerHTML = refreshBar + hrAlertBannerHTML() + monthEndCard() + `
PROCUREMENT & OPEN INDENTS
•••
🛒
${openIndents}
Open Indents
Pending
+ ${totalOrders} total indents & orders logged
Indent Status Breakdown
Indent Raised
${openIndents} →
Quotes & Pending Delivery
${quotesCompared + pendingRecv} →
TODAY’S GOALS — ${todayISO()}
📝 Open To-Do List
${renderDashboardGoalsContent()}
INVENTORY & SITE STORES
•••
Inventory Levels by Category
Site Materials ${siteMaterials}
Equipment & Plant ${equipmentCount}
Vehicle Fleet ${vehicleCount}
Total Catalog Items
${totalItemsCount}
Stock Alerts (${lowItems.length}) ›
MONTHLY EXPENDITURE TREND
•••
Total Site Accounts & Orders
${money(totalAccountValue)}
Jan Feb Mar Apr May Jun Dec
FLEET & EQUIPMENT STATUS
•••
Vehicle & Equipment Operational Breakdown
● Active (${activeVehicles})
● Under Repair (${repairVehicles})
● Service Due (${serviceDueVehicles})
DAILY WORKERS & ATTENDANCE
•••
Site Workforce Attendance (${todayStr})
👥
Total Workforce
Registered Employees
${totalStaff}
⚠️
Absent Today
Not Logged / Off
${absentToday}
🚨
Absent > 3 Days
Consecutive Absence Alert
${absentOver3Days}
🔔 ${tr("Action Required")} — click any card to see details
${notifHTML}
${tr("Alerts & Reminders")}
${alerts.map(a => `
${a.txt}
`).join("") || `
${tr("No urgent alerts")}
`}
${tr("Recent Updates")}
${recentUpdatesRows || `
${tr("No recent updates")}
`}
`;
}
let progressSearch = "";
function canAccessProgress(user) {
// Basic access: owner, OR has 'progress' in modules or permissions
if (!user) return false;
if (isOwner(user)) return true;
const mods = user.modules || user.permissions || [];
return mods.includes('progress');
}
function canEditProgress(user) {
// Edit existing entries: only the special sub-perm holders (or owner)
if (!user) return false;
return isOwner(user) || (user.subPerms || []).includes('progress.update');
}
let progressTab = 'activities';
function progressView() {
if (progressTab === 'schedule') return erectionScheduleView();
if (progressTab === 'dailylog') return erectionDailyLogView();
if (progressTab === 'summary') return erectionSummaryView();
if (progressTab === 'lists') return erectionListsView();
const filtered = DB.progress.filter(p => {
if (!progressSearch) return true;
const s = progressSearch.toLowerCase();
return (p.disc || "").toLowerCase().includes(s) || (p.activity || "").toLowerCase().includes(s) || (p.status || "").toLowerCase().includes(s);
});
const canAdd = canAccessProgress(USER); // Update%, Photos — basic access
const canUpd = canEditProgress(USER); // Edit entries — sub-perm only
const pgData = paginate(filtered, 'prog');
const rows = pgData.items.map(p => `
${tr(p.disc)} ${p.activity} ${p.target}
${p.pct}% ${pill(p.status)}
${attThumbs(p.photos)}
${canUpd ? `${tr("Update")} ` : ''}
${canUpd ? `${tr("+ Photos")} ` : ''}
${isOwner(USER) ? `${tr("Delete")} ` : ''}
`).join("");
const tabs = [
['activities', '📋 Activities'],
['schedule', '📅 Schedule'],
['dailylog', '📝 Daily Log'],
['summary', '📊 Summary'],
...(isOwner(USER) || canUpd ? [['lists', '⚙ Lists']] : [])
].map(([k, l]) => `${l} `).join('');
view().innerHTML = `
${tabs}
${tr("Erection & Civil Progress")}
`+ (isOwner(USER) ? '
⚠ Reset ' : '') + `
${canAdd ? `
${tr("+ New Activity")} ` : ''}
${canMod(USER, "progress") && isOwner(USER) ? `
⬆ Bulk Import ` : ''}
⬇ Export CSV
⚙ Custom Export
${tr("Discipline")} ${tr("Activity")} ${tr("Target")} ${tr("Progress")} ${tr("Status")} ${tr("Photos")} ${tr("Action")}
${rows || emptyRow(7, "No activities found")}
${pgData.html}
`;
}
function exportProgressCSV() {
const rows = DB.progress;
if (!rows || !rows.length) { toast('No activities to export.'); return; }
const headers = ['Discipline', 'Activity', 'Target Date', 'Progress (%)', 'Status'];
const csvRows = [headers.join(',')];
rows.forEach(p => {
const esc = v => '"' + (String(v || '').replace(/"/g, '""')) + '"';
csvRows.push([
esc(p.disc || ''),
esc(p.activity || ''),
esc(p.target || ''),
p.pct || 0,
esc(p.status || '')
].join(','));
});
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `Progress_${todayISO()}.csv`;
a.click(); a.remove(); URL.revokeObjectURL(url);
toast('Activities exported to CSV.');
}
// ══════════════════════════════════════════════════════
// ERECTION & FABRICATION — DAILY WORK & SCHEDULE TRACKER
// ══════════════════════════════════════════════════════
function erectionTabBar() {
const canUpd = canEditProgress(USER);
return ['activities', 'schedule', 'dailylog', 'summary', ...(isOwner(USER) || canUpd ? ['lists'] : [])].map(k => {
const labels = { activities: '📋 Activities', schedule: '📅 Schedule', dailylog: '📝 Daily Log', summary: '📊 Summary', lists: '⚙ Lists' };
return `${labels[k]} `;
}).join('');
}
// ── Schedule View ──────────────────────────────────────
function resetErectionSchedule() {
if (!confirm('Clear all schedule activities and reset to clean default template?')) return;
DB.erectionSchedule = [
{ id: uid('sch'), actId: "CIV-001", discipline: "Civil & Structural", activity: "RCC Column Pedestals — Furnace Bay", area: "Furnace Bay", workType: "RCC", plannedQty: 500, uom: "m3", plannedMandays: 120, startDate: "2026-07-01", finishDate: "2026-07-20", supervisor: "Civil Team", status: "In Progress" },
{ id: uid('sch'), actId: "MEC-001", discipline: "Mechanical Erection", activity: "Furnace Shell Assembly & Alignment", area: "Furnace Bay", workType: "Erection", plannedQty: 150, uom: "Ton", plannedMandays: 200, startDate: "2026-07-10", finishDate: "2026-08-05", supervisor: "Mechanical Team", status: "In Progress" },
{ id: uid('sch'), actId: "CIV-002", discipline: "Civil & Structural", activity: "Conveyor Gantry Foundation Concrete", area: "Raw Material Yard", workType: "Civil", plannedQty: 350, uom: "m3", plannedMandays: 80, startDate: "2026-06-15", finishDate: "2026-06-30", supervisor: "Civil Team", status: "Completed" },
{ id: uid('sch'), actId: "ELE-001", discipline: "Electrical & Instrumentation", activity: "Main Power Panel Cabling & Tray Fitting", area: "Substation", workType: "Electrical", plannedQty: 1200, uom: "m", plannedMandays: 90, startDate: "2026-07-15", finishDate: "2026-08-15", supervisor: "Electrical Team", status: "In Progress" }
];
save(); render();
toast('Erection Schedule reset to clean defaults.');
}
function erectionScheduleView() {
if (!DB.erectionSchedule) DB.erectionSchedule = [];
// Auto-clean garbled binary corrupted entries
const beforeLen = DB.erectionSchedule.length;
DB.erectionSchedule = DB.erectionSchedule.filter(s => s && s.actId && !/[\x00-\x08\x0E-\x1F\uFFFD\uFFFE]/.test(s.actId) && !/[\x00-\x08\x0E-\x1F]/.test(s.activity || ''));
if (beforeLen !== DB.erectionSchedule.length) { save(); }
// If empty after cleaning, populate default schedule
if (!DB.erectionSchedule.length) {
DB.erectionSchedule = [
{ id: uid('sch'), actId: "CIV-001", discipline: "Civil & Structural", activity: "RCC Column Pedestals — Furnace Bay", area: "Furnace Bay", workType: "RCC", plannedQty: 500, uom: "m3", plannedMandays: 120, startDate: "2026-07-01", finishDate: "2026-07-20", supervisor: "Civil Team", status: "In Progress" },
{ id: uid('sch'), actId: "MEC-001", discipline: "Mechanical Erection", activity: "Furnace Shell Assembly & Alignment", area: "Furnace Bay", workType: "Erection", plannedQty: 150, uom: "Ton", plannedMandays: 200, startDate: "2026-07-10", finishDate: "2026-08-05", supervisor: "Mechanical Team", status: "In Progress" },
{ id: uid('sch'), actId: "CIV-002", discipline: "Civil & Structural", activity: "Conveyor Gantry Foundation Concrete", area: "Raw Material Yard", workType: "Civil", plannedQty: 350, uom: "m3", plannedMandays: 80, startDate: "2026-06-15", finishDate: "2026-06-30", supervisor: "Civil Team", status: "Completed" },
{ id: uid('sch'), actId: "ELE-001", discipline: "Electrical & Instrumentation", activity: "Main Power Panel Cabling & Tray Fitting", area: "Substation", workType: "Electrical", plannedQty: 1200, uom: "m", plannedMandays: 90, startDate: "2026-07-15", finishDate: "2026-08-15", supervisor: "Electrical Team", status: "In Progress" }
];
save();
}
if (!DB.erectionLists || Array.isArray(DB.erectionLists)) DB.erectionLists = { areas: [], workTypes: [], supervisors: [] };
const canAdd = canAccessProgress(USER); // add new entries
const canUpd = canEditProgress(USER); // edit existing entries
const rows = DB.erectionSchedule.map(s => {
const pct = +(s.plannedQty > 0 ? Math.min(100, ((DB.erectionDailyLog || []).filter(d => d.actId === s.actId).reduce((t, d) => t + (+d.qtyDone || 0), 0) / s.plannedQty) * 100).toFixed(1) : 0);
const barColor = pct >= 100 ? '#16a34a' : pct >= 50 ? '#d97706' : '#3b82f6';
return `
${escAttr(s.actId)}
${escAttr(s.discipline)}
${escAttr(s.activity)}
${escAttr(s.area || '—')}
${escAttr(s.workType || '—')}
${s.plannedQty || 0} ${escAttr(s.uom || '')}
${s.plannedMandays || 0}
${s.startDate || '—'}
${s.finishDate || '—'}
${escAttr(s.supervisor || '—')}
${pct}%
${canUpd ? `✏ Edit ` : ''}
${isOwner(USER) ? `Del ` : ''}
`;
}).join('');
view().innerHTML = `
${erectionTabBar()}
One row per erection/fabrication activity with planned qty, manpower, and dates. The Summary tab rolls up actual vs planned % automatically.
📅 Erection Schedule
${isOwner(USER) ? `⚠️ Reset Schedule ` : ''}
${canAdd ? `+ Add Activity ` : ''}
${canUpd ? `⬆ Import CSV ` : ''}
⬇ Export (3 files)
⚙ Custom Export
Act ID Discipline Activity Area Work Type
Planned Qty Planned Mandays
Start Finish Supervisor Progress Action
${rows || emptyRow(12, 'No schedule entries yet. Click + Add Activity.')}
`;
}
function formScheduleEntry(entry) {
const ed = !!entry;
if (!DB.erectionLists || Array.isArray(DB.erectionLists)) DB.erectionLists = { areas: [], workTypes: [], supervisors: [] };
const L = DB.erectionLists;
const discs = ['Civil & Structural', 'Mechanical Erection', 'Electrical & Instrumentation', 'Piping', 'Structural Steel', 'Fabrication', 'Other'];
const discOpts = discs.map(d => `${d} `).join('');
const areaOpts = `— ` + L.areas.map(a => `${a} `).join('');
const wtOpts = `— ` + L.workTypes.map(w => `${w} `).join('');
const supOpts = `— ` + L.supervisors.map(s => `${s} `).join('');
openModal(ed ? 'Edit Schedule Entry' : 'Add Schedule Activity',
`
${field('Act ID (unique code)', 'f-sch-actid', ed ? escAttr(entry.actId || '') : '')}
Discipline ${discOpts}
${field('Activity / Description', 'f-sch-activity', ed ? escAttr(entry.activity || '') : '')}
Area ${areaOpts}
Work Type ${wtOpts}
${field('Planned Qty', 'f-sch-qty', ed ? entry.plannedQty || '' : '', 'number')}
${field('UOM (e.g. Ton, m, EA)', 'f-sch-uom', ed ? escAttr(entry.uom || '') : '')}
${field('Planned Mandays', 'f-sch-mandays', ed ? entry.plannedMandays || '' : '', 'number')}
Supervisor ${supOpts}
${field('Planned Start Date', 'f-sch-start', ed ? entry.startDate || '' : '', 'date')}
${field('Planned Finish Date', 'f-sch-finish', ed ? entry.finishDate || '' : '', 'date')}
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: ed ? 'Save' : 'Add to Schedule', cls: '', fn: () => {
const actId = val('f-sch-actid').trim();
const activity = val('f-sch-activity').trim();
if (!actId || !activity) return toast('Act ID and Activity are required.');
const obj = {
actId, activity,
discipline: document.getElementById('f-sch-disc').value,
area: document.getElementById('f-sch-area').value,
workType: document.getElementById('f-sch-wt').value,
plannedQty: +val('f-sch-qty') || 0,
uom: val('f-sch-uom').trim(),
plannedMandays: +val('f-sch-mandays') || 0,
supervisor: document.getElementById('f-sch-sup').value,
startDate: val('f-sch-start'),
finishDate: val('f-sch-finish')
};
if (ed) { Object.assign(entry, obj); }
else { if (!DB.erectionSchedule) DB.erectionSchedule = []; DB.erectionSchedule.push({ id: uid('sch'), ...obj }); }
save(); logAct(`Schedule ${ed ? 'updated' : 'added'}: ${actId} — ${activity}`);
toast(ed ? 'Entry updated.' : 'Activity added to schedule.');
closeModal(); render();
}
}]
);
}
// ── Daily Log View ─────────────────────────────────────
function erectionDailyLogView() {
if (!DB.erectionDailyLog) DB.erectionDailyLog = [];
const canAdd = canAccessProgress(USER);
const canUpd = canEditProgress(USER);
const today = todayISO();
let dlFilter = window._dlDateFilter || today;
const logs = DB.erectionDailyLog.filter(d => d.date === dlFilter).sort((a, b) => a.actId > b.actId ? 1 : -1);
const schMap = {};
(DB.erectionSchedule || []).forEach(s => schMap[s.actId] = s);
const rows = logs.map(d => {
const s = schMap[d.actId] || {};
return `
${d.date}
${escAttr(s.activity || d.activity || '—')} ${escAttr(s.area || d.area || '')}
${d.qtyDone || 0} ${escAttr(s.uom || d.uom || '')}
${d.manpower || 0}
${escAttr(d.supervisor || s.supervisor || '—')}
${escAttr(d.remarks || '')}
${canUpd ? `✏ Edit ` : ''}
${isOwner(USER) ? `Del ` : ''}
`;
}).join('');
// Totals for the day
const totalQty = logs.reduce((t, d) => t + (+d.qtyDone || 0), 0);
const totalMP = logs.reduce((t, d) => t + (+d.manpower || 0), 0);
const dateNav = `
Today
${logs.length} entries · ${totalMP} manpower
`;
view().innerHTML = `
${erectionTabBar()}
Each day: pick the work, type how much was done today and how many people worked. Totals are calculated for you.
📝 Today's Work
${dateNav}
${isOwner(USER) ? `⚠️ Reset Log ` : ''}
${canAdd ? `+ Log Work ` : ''}
⬇ Export Log
⚙ Custom Export
Date Work
Done today
People
Supervisor Note Action
${rows || emptyRow(7, 'Nothing logged for this date yet. Click “+ Log Work”.')}
${logs.length ? `
Day total →
${totalQty}
${totalMP}
`: ''}
`;
}
function formDailyLogEntry(entry) {
const ed = !!entry;
if (!DB.erectionLists || Array.isArray(DB.erectionLists)) DB.erectionLists = { areas: [], workTypes: [], supervisors: [] };
const L = DB.erectionLists;
const schedule = DB.erectionSchedule || [];
const schOpts = `-- Choose the work -- ` + schedule.map(s => `${escAttr(s.activity)}${s.area ? ' — ' + escAttr(s.area) : ''} `).join('') + ` s.actId === entry.actId) ? 'selected' : ''}>Other work (not in the list) `;
const supOpts = `— ` + L.supervisors.map(s => `${s} `).join('');
openModal(ed ? 'Edit Work Entry' : 'Log Today\'s Work',
`Date
1. Which work?
${schOpts}
Describe the work
4. Supervisor ${supOpts}
Note (optional)
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: ed ? 'Save' : 'Log Work', cls: '', fn: () => {
let actId = document.getElementById('f-dl-actid').value;
const isOther = actId === '__other';
if (isOther) actId = '';
const date = val('f-dl-date');
const qty = +val('f-dl-qty') || 0;
if (!date) return toast('Date is required.');
if (!isOther && !actId) return toast('Please choose the work.');
if (isOther && !val('f-dl-activity-other').trim()) return toast('Please describe the work.');
const obj = {
date, actId,
activity: isOther ? val('f-dl-activity-other').trim() : val('f-dl-activity').trim(),
area: val('f-dl-area').trim(),
discipline: val('f-dl-disc').trim(),
uom: val('f-dl-uom').trim(),
qtyDone: qty,
manpower: +val('f-dl-mp') || 0,
supervisor: document.getElementById('f-dl-sup').value,
remarks: document.getElementById('f-dl-remarks').value.trim(),
by: USER.displayName || USER.username
};
if (ed) { Object.assign(entry, obj); }
else { if (!DB.erectionDailyLog) DB.erectionDailyLog = []; DB.erectionDailyLog.unshift({ id: uid('dl'), ...obj }); }
window._dlDateFilter = date;
save(); logAct(`Daily log ${ed ? 'updated' : 'added'}: ${actId || 'manual'} — ${qty} on ${date}`);
const schItem = (DB.erectionSchedule || []).find(s => s.actId === actId);
const cumulativeQty = (DB.erectionDailyLog || []).filter(d => d.actId === actId).reduce((t, d) => t + (+d.qtyDone || 0), 0);
if (schItem && schItem.plannedQty > 0 && cumulativeQty >= schItem.plannedQty) {
notifyOwnerErectionTaskCompleted({ ...schItem, qtyDone: cumulativeQty, supervisor: obj.supervisor || USER.displayName });
}
toast(ed ? 'Entry updated.' : 'Work logged.'); closeModal(); render();
}
}]
);
setTimeout(() => {
const inpUom = document.getElementById('f-dl-uom');
if (inpUom) inpUom.value = ed && entry.uom ? entry.uom : '';
// pre-select actId if editing
if (ed && entry.actId) {
const sel = document.getElementById('f-dl-actid');
if (sel) sel.value = entry.actId;
}
}, 60);
}
// ── Summary View ───────────────────────────────────────
function erectionSummaryView() {
if (!DB.erectionSchedule) DB.erectionSchedule = [];
if (!DB.erectionDailyLog) DB.erectionDailyLog = [];
// Build lookup: actId → total qty done
const qtyDone = {};
DB.erectionDailyLog.forEach(d => { qtyDone[d.actId] = (qtyDone[d.actId] || 0) + (+d.qtyDone || 0); });
const totalPlannedMandays = DB.erectionSchedule.reduce((t, s) => t + (+s.plannedMandays || 0), 0);
// Group by discipline
const byDisc = {};
DB.erectionSchedule.forEach(s => {
const disc = s.discipline || 'Other';
if (!byDisc[disc]) byDisc[disc] = { items: [], plannedQty: 0, doneQty: 0, plannedMandays: 0 };
byDisc[disc].items.push(s);
byDisc[disc].plannedQty += (+s.plannedQty || 0);
byDisc[disc].doneQty += (qtyDone[s.actId] || 0);
byDisc[disc].plannedMandays += (+s.plannedMandays || 0);
});
// Overall
const overallPlanned = DB.erectionSchedule.reduce((t, s) => t + (+s.plannedQty || 0), 0);
const overallDone = DB.erectionSchedule.reduce((t, s) => t + (qtyDone[s.actId] || 0), 0);
const overallActPct = overallPlanned > 0 ? Math.min(100, +(overallDone / overallPlanned * 100).toFixed(1)) : 0;
// Weightage: each discipline's planned mandays / total planned mandays
const discRows = Object.entries(byDisc).map(([disc, g]) => {
const actPct = g.plannedQty > 0 ? Math.min(100, +(g.doneQty / g.plannedQty * 100).toFixed(1)) : 0;
const wt = totalPlannedMandays > 0 ? +(g.plannedMandays / totalPlannedMandays * 100).toFixed(1) : 0;
const plannedPct = 0; // Would need date-based calc; shown as 0 without As-Of date
const variance = +(actPct - plannedPct).toFixed(1);
const varColor = variance >= 0 ? '#16a34a' : '#dc2626';
const barW = Math.min(100, actPct);
return `
${escAttr(disc)}
${g.plannedQty}
${g.doneQty}
${actPct}%
—
${wt}%
${variance >= 0 ? '+' : ''}${variance}%
`;
}).join('');
// Per-activity detail
const actRows = DB.erectionSchedule.map(s => {
const done = qtyDone[s.actId] || 0;
const actPct = s.plannedQty > 0 ? Math.min(100, +(done / s.plannedQty * 100).toFixed(1)) : 0;
const barColor = actPct >= 100 ? '#16a34a' : actPct >= 50 ? '#d97706' : '#3b82f6';
return `
${escAttr(s.actId)}
${escAttr(s.discipline || '')}
${escAttr(s.activity)}
${escAttr(s.area || '—')}
${s.plannedQty} ${escAttr(s.uom || '')}
${done}
${actPct}%
`;
}).join('');
view().innerHTML = `
${erectionTabBar()}
Overall Actual %
${overallActPct}%
${overallDone} / ${overallPlanned} total qty
Activities
${DB.erectionSchedule.length}
In schedule
Total Planned Mandays
${totalPlannedMandays}
Across all disciplines
Daily Logs
${DB.erectionDailyLog.length}
Total entries
📊 Summary by Discipline
Discipline Planned Qty Done Qty
Actual % Planned % Weightage % Variance Bar
${discRows || emptyRow(8, 'No schedule data yet.')}
Activity Detail
Act ID Discipline Activity Area
Planned Done Actual % Progress
${actRows || emptyRow(8, 'No activities in schedule.')}
`;
}
// ── Lists (Admin) View ─────────────────────────────────
function erectionListsView() {
if (!DB.erectionLists || Array.isArray(DB.erectionLists)) DB.erectionLists = { areas: [], workTypes: [], supervisors: [] };
const L = DB.erectionLists;
function listBlock(title, key, icon) {
const items = L[key] || [];
return `
${items.length ? items.map((item, i) => `
${escAttr(item)}
${(isOwner(USER) || canEditProgress(USER)) ? `✕ ` : ''}
`).join('') : `
No ${title.toLowerCase()} added yet.
`}
`;
}
view().innerHTML = `
${erectionTabBar()}
Manage the dropdown options used in the Schedule and Daily Log. Add areas, work types, and supervisor names here.
${listBlock('Areas / Locations', 'areas', '📍')}
${listBlock('Work Types', 'workTypes', '🔧')}
${listBlock('Supervisors', 'supervisors', '👷')}
`;
}
window.erectionListAdd = function (key) {
const val = prompt(`Enter new ${key === 'areas' ? 'area' : key === 'workTypes' ? 'work type' : 'supervisor name'}:`);
if (!val || !val.trim()) return;
if (!DB.erectionLists || Array.isArray(DB.erectionLists)) DB.erectionLists = { areas: [], workTypes: [], supervisors: [] };
if (!DB.erectionLists[key]) DB.erectionLists[key] = [];
DB.erectionLists[key].push(val.trim());
save(); render();
};
window.erectionListDel = function (key, idx) {
if (!DB.erectionLists || !DB.erectionLists[key]) return;
if (!confirm('Remove this item?')) return;
DB.erectionLists[key].splice(idx, 1);
save(); render();
};
// ── Excel date helpers ─────────────────────────────────
function isoToExcelDate(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
if (isNaN(d)) return isoStr;
return Math.round((d - new Date(Date.UTC(1899, 11, 30))) / 86400000);
}
function excelDateToISO(serial) {
if (!serial || isNaN(serial)) return '';
const d = new Date(Date.UTC(1899, 11, 30) + serial * 86400000);
return d.toISOString().slice(0, 10);
}
function csvCell(v) {
const s = (v === null || v === undefined) ? '' : String(v);
if (s.includes(',') || s.includes('"') || s.includes('\n')) return '"' + s.replace(/"/g, '""') + '"';
return s;
}
function csvRowStr(arr) { return arr.map(csvCell).join(','); }
// ── Simple CSV Import ──────────────────────────────────
window.erectionImportCSV = function () {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = '.csv,.xlsx,.xls';
inp.onchange = e => {
const file = e.target.files[0]; if (!file) return;
if (file.name.toLowerCase().endsWith('.xlsx') || file.name.toLowerCase().endsWith('.xls')) {
toast('⚠️ Excel binary files (.xlsx/.xls) cannot be read directly as text. Please save your file as "CSV (Comma delimited)" in Excel before importing.', false);
return;
}
const reader = new FileReader();
reader.onload = ev => {
const text = ev.target.result;
if (/[\x00-\x08\x0E-\x1F\uFFFD]/.test(text.slice(0, 100))) {
toast('⚠️ File contains binary data. Please save as UTF-8 CSV format in Excel before importing.', false);
return;
}
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
if (!lines.length) return toast('Empty file.');
const firstLine = lines[0].toLowerCase();
const startIdx = (firstLine.includes('act id') || firstLine.includes('actid') || firstLine.includes('act_id')) ? 1 : 0;
if (!DB.erectionSchedule) DB.erectionSchedule = [];
let added = 0, skipped = 0;
for (let i = startIdx; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const cols = line.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/).map(c => c.trim().replace(/^"|"$/g, '').replace(/""/g, '"'));
const actId = cols[0] || '';
if (!actId || /[\x00-\x08\x0E-\x1F\uFFFD]/.test(actId)) { skipped++; continue; }
const existing = DB.erectionSchedule.find(s => s.actId === actId);
const parseDate = v => (!v || isNaN(+v)) ? (v || '') : excelDateToISO(+v);
const obj = {
actId, area: cols[1] || '', discipline: cols[2] || '', workType: cols[3] || '',
activity: cols[4] || '', uom: cols[5] || '',
plannedQty: +cols[6] || 0, plannedMandays: +cols[7] || 0,
startDate: parseDate(cols[9]), finishDate: parseDate(cols[10]),
status: cols[18] || 'Not Started', remarks: cols[19] || ''
};
if (existing) { Object.assign(existing, obj); }
else { DB.erectionSchedule.push({ id: uid('sch'), ...obj }); added++; }
}
save(); render();
toast(`Import done — ${added} added, ${lines.length - startIdx - skipped - added} updated, ${skipped} skipped.`);
};
reader.readAsText(file);
};
inp.click();
};
// ── Export Schedule (exact format) ────────────────────
window.erectionExportXLSX = function () {
const sch = DB.erectionSchedule || [];
const dlogs = DB.erectionDailyLog || [];
const today = todayISO();
const todayExcel = isoToExcelDate(today);
const L = DB.erectionLists || { areas: [], workTypes: [], supervisors: [] };
const proj = (DB.appConfig && DB.appConfig.company) || 'MEGACORP INDUSTRIA (SU), LDA';
const qtyDone = {};
dlogs.forEach(d => { qtyDone[d.actId] = (qtyDone[d.actId] || 0) + (+d.qtyDone || 0); });
const totalPMD = sch.reduce((t, s) => t + (+s.plannedMandays || 0), 0) || 1;
const actualStart = {}, lastWorked = {};
dlogs.forEach(d => {
if (d.actId && d.date) {
if (!actualStart[d.actId] || d.date < actualStart[d.actId]) actualStart[d.actId] = d.date;
if (!lastWorked[d.actId] || d.date > lastWorked[d.actId]) lastWorked[d.actId] = d.date;
}
});
// Schedule sheet header (exact match)
const schH = ['Act ID', 'Area / Unit', 'Discipline', 'Work Type', 'Activity Description', 'UOM',
'Planned Qty', 'Planned Mandays', 'Weightage %', 'Planned Start', 'Planned Finish', 'Duration (days)',
'Actual Start', 'Last Worked', 'Qty Done', 'Actual %', 'Planned %', 'Variance', 'Status', 'Remarks'];
const schTitle = [proj + ' — ERECTION & FABRICATION SCHEDULE'];
const schNote = ['Yellow cells = your input. Grey cells = formulas, do not type over them. Actual progress is driven entirely by the Daily Log sheet.'];
const schAsOf = ['As-of Date:', todayExcel, '<- change this date to re-cut the planned % and variance'];
const schRows = sch.map(s => {
const planned = +s.plannedQty || 0, done = qtyDone[s.actId] || 0;
const actPct = planned > 0 ? done / planned : 0;
const startE = isoToExcelDate(s.startDate), finishE = isoToExcelDate(s.finishDate);
const dur = (s.startDate && s.finishDate) ? Math.max(1, Math.round((new Date(s.finishDate) - new Date(s.startDate)) / 86400000) + 1) : '';
const wt = +s.plannedMandays > 0 ? (+s.plannedMandays / totalPMD) : 0;
let plannedPct = 0;
if (startE && finishE && todayExcel) {
if (todayExcel >= finishE) plannedPct = 1;
else if (todayExcel > startE) plannedPct = (todayExcel - startE) / (finishE - startE);
}
return [s.actId, s.area || '', s.discipline || '', s.workType || '', s.activity || '', s.uom || '',
planned, +s.plannedMandays || 0, wt, startE, finishE, dur,
actualStart[s.actId] ? isoToExcelDate(actualStart[s.actId]) : '',
lastWorked[s.actId] ? isoToExcelDate(lastWorked[s.actId]) : '',
done, actPct, plannedPct, actPct - plannedPct, s.status || 'Not Started', s.remarks || ''];
});
// Lists sheet header (exact match)
const lstH = ['Area / Unit', 'Discipline', 'Work Type', 'UOM', 'Status', 'Supervisor / Crew'];
const lstTitle = ['MASTER LISTS — DROPDOWN OPTIONS'];
const lstNote = ['Add or edit values below and the dropdowns on Schedule and Daily Log update automatically. Do not leave blank rows inside a list.'];
const discs = ['Mechanical Erection', 'Civil & Structural', 'Electrical & Instrumentation', 'Piping', 'Refractory', 'Structural Steel', 'Fabrication'];
const statuses = ['Not Started', 'In Progress', 'Completed', 'On Hold'];
const uoms = ['NOS', 'MT', 'M', 'M2', 'M3', 'JOINT', 'LOT', 'SET', 'KG'];
const maxR = Math.max(L.areas.length, discs.length, L.workTypes.length, uoms.length, statuses.length, L.supervisors.length);
const lstRows = [];
for (let i = 0; i < maxR; i++) lstRows.push([L.areas[i] || '', discs[i] || '', L.workTypes[i] || '', uoms[i] || '', statuses[i] || '', L.supervisors[i] || '']);
// Daily Log sheet header (exact match)
const dlH = ['Date', 'Act ID', 'Activity Description', 'Area / Unit', 'Work Done Today (narrative)',
'Qty Done Today', 'UOM', 'Manpower - Indian', 'Manpower - Local', 'Total Manpower', 'Supervisor', 'Remarks / Hold-ups'];
const dlTitle = ['DAILY WORK LOG — ERECTION & FABRICATION'];
const dlNote = ['One row per activity per day. Pick the Act ID from the dropdown — description, area and UOM fill in by themselves. Qty Done Today is what feeds the Schedule.'];
const dlNote2 = ['Qty Done Today is INCREMENTAL (today\'s output only), never cumulative. The Schedule adds it up.'];
const schMap = {};
sch.forEach(s => schMap[s.actId] = s);
const dlRows = dlogs.map(d => {
const s = schMap[d.actId] || {};
const indMP = d.manpowerIndian || 0, locMP = d.manpowerLocal || 0;
const totalMP = d.manpower || (indMP + locMP) || 0;
return [isoToExcelDate(d.date), d.actId || '', s.activity || d.activity || '', s.area || d.area || '',
d.workDone || d.remarks || '', +d.qtyDone || 0, s.uom || d.uom || '',
indMP, locMP, totalMP, d.supervisor || '', d.remarks || ''];
});
function dl(rows, filename) {
const csv = rows.map(csvRowStr).join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = filename; a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
const dt = today;
dl([[schTitle[0]], [schNote[0]], schAsOf, [], [schH[0], ...schH.slice(1)], ...schRows],
`${proj.split(' ')[0]}_Erection_Schedule_${dt}.csv`);
setTimeout(() => dl([[lstTitle[0]], [lstNote[0]], [], [lstH[0], ...lstH.slice(1)], ...lstRows],
`${proj.split(' ')[0]}_Erection_Lists_${dt}.csv`), 400);
setTimeout(() => dl([[dlTitle[0]], [dlNote[0]], [dlNote2[0]], [], [dlH[0], ...dlH.slice(1)], ...dlRows],
`${proj.split(' ')[0]}_Erection_DailyLog_${dt}.csv`), 800);
toast('Exporting 3 CSV files — Schedule, Lists, Daily Log. Open in Excel/Numbers. Dates shown as Excel serial numbers.');
};
// ── Export Daily Log only ──────────────────────────────
window.erectionExportDailyLog = function () {
const dlogs = DB.erectionDailyLog || [], sch = DB.erectionSchedule || [];
const schMap = {}; sch.forEach(s => schMap[s.actId] = s);
const headers = ['Date', 'Act ID', 'Activity Description', 'Area / Unit', 'Work Done Today (narrative)',
'Qty Done Today', 'UOM', 'Manpower - Indian', 'Manpower - Local', 'Total Manpower', 'Supervisor', 'Remarks / Hold-ups'];
const rows = dlogs.map(d => {
const s = schMap[d.actId] || {};
const indMP = d.manpowerIndian || 0, locMP = d.manpowerLocal || 0, totalMP = d.manpower || (indMP + locMP) || 0;
return [isoToExcelDate(d.date), d.actId || '', s.activity || d.activity || '', s.area || d.area || '',
d.workDone || d.remarks || '', +d.qtyDone || 0, s.uom || d.uom || '', indMP, locMP, totalMP, d.supervisor || '', d.remarks || ''];
});
const csv = [['DAILY WORK LOG — ERECTION & FABRICATION'],
['One row per activity per day. Qty Done Today is INCREMENTAL.'],
[], headers, ...rows].map(csvRowStr).join('\r\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const proj = (DB.appConfig && DB.appConfig.company) || 'MEGACORP';
a.href = url; a.download = `${proj.split(' ')[0]}_Erection_DailyLog_${todayISO()}.csv`; a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
toast('Daily Log exported.');
};
function procTabs() {
const owner = isOwner(USER);
const modes = ["list", "hold", "rejected", "reports", "vendors"].concat(owner ? ["history"] : []);
const lbl = { list: "Indents", hold: "⏸ On Hold", rejected: "❌ Rejected", reports: "Reports", vendors: "🏪 Vendors", history: "Change History" };
return `${modes.map(m => `${tr(lbl[m])} `)}
`;
}
const idate = p => (p.at || p.date || "").slice(0, 10);
const monthLabel = mo => { const [y, m] = mo.split("-"); return (MONTHS[(+m || 1) - 1] || mo) + " " + y; };
// ── India supplier courier tracking ──
function isIndiaSupplierInd(p) {
if (p.selected === 'india') return true;
const v = (DB.vendors || []).find(x => (x.name || '').trim().toLowerCase() === String(p.supplier || '').trim().toLowerCase());
return !!(v && v.india_only);
}
function courierFieldsHtml(c) {
c = c || {};
return `🇮🇳 Courier Tracking (India supplier)
` +
`` + field("Courier Company", "f-cr-company", escAttr(c.company || "")) + field("Tracking Number", "f-cr-track", escAttr(c.trackingNo || "")) + `
` +
field("Tracking Link (URL)", "f-cr-link", escAttr(c.trackingLink || "")) +
`Paste the courier's tracking page link so anyone can click to track.
`;
}
function readCourierFields() {
let link = (document.getElementById('f-cr-link')?.value || '').trim();
if (link && !/^https?:\/\//i.test(link)) link = 'https://' + link;
return { company: (document.getElementById('f-cr-company')?.value || '').trim(), trackingNo: (document.getElementById('f-cr-track')?.value || '').trim(), trackingLink: link };
}
window.formCourier = function (p) {
openModal('🚚 Courier Tracking — ' + p.no, `Supplier: ${escH(p.supplier || '—')}
` + courierFieldsHtml(p.courier),
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, { label: 'Save tracking', cls: '', fn: () => {
const c = readCourierFields();
if (!c.company && !c.trackingNo && !c.trackingLink) return toast('Enter at least the courier name, tracking number or link', false);
p.courier = Object.assign({}, c, { updatedBy: USER.displayName || USER.username, updatedAt: todayISO() });
save(); logAct(`${p.no} courier: ${c.company || ''} ${c.trackingNo || ''}`); toast('Courier tracking saved ✓'); closeModal(); render();
} }]);
};
function courierLine(c) {
if (!c || (!c.company && !c.trackingNo && !c.trackingLink)) return '';
return `📦 ${escH(c.company || 'Courier')}${c.trackingNo ? ' ·
' + escH(c.trackingNo) + ' ' : ''}${c.trackingLink ? ` ·
Track ↗ ` : ''}
`;
}
// ── Procurement row helpers: status colour, one main action + ⋯ menu ──
function procStatusColor(st) {
if (st === "Rejected") return "#dc2626";
if (st === "On Hold") return "#a16207";
if (st === "Quoted" || st === "Quotes Compared") return "#f59e0b";
if (st === "Approved" || st === "PO Issued") return "#10b981";
if (st === "Payment Done" || st === "Dispatched") return "#6366f1";
if (st === "Received") return "#059669";
return "#3b82f6";
}
window.toggleProcMenu = function (e, btn) {
e.stopPropagation();
const m = btn.parentElement.querySelector('.proc-menu');
const open = m.style.display === 'block';
document.querySelectorAll('.proc-menu').forEach(x => x.style.display = 'none');
m.style.display = open ? 'none' : 'block';
};
window.closeProcMenus = function () { document.querySelectorAll('.proc-menu').forEach(x => x.style.display = 'none'); };
document.addEventListener('click', e => { if (!e.target.closest('.proc-actions')) window.closeProcMenus(); });
function procActionsHtml(p) {
const seeM = can.seeMoney(USER);
const canE = isOwner(USER) || !p.by || p.by === (USER.displayName || USER.username);
const st = p.status;
const isQ = st === "Quoted" || st === "Quotes Compared";
const hasQuotes = !!p.quotes && Object.keys(p.quotes).some(k => p.quotes[k] && p.quotes[k].price);
const canProc = canMod(USER, 'procurement') || isOwner(USER);
const hasProof = Array.isArray(p.paymentProof) ? p.paymentProof.length > 0 : !!p.paymentProof;
const poStage = ["PO Issued", "Payment Done", "Dispatched", "Received"].includes(st);
// Primary button: the next step for this indent (first applicable wins)
const mainCands = [];
if (isQ && can.approve(USER)) mainCands.push(['proc-approve-reject', '✔ Approve / Reject']);
if (st === "Indent Raised" && seeM) mainCands.push(['proc-quotes', '+ Add quotes']);
if (st === "Payment Done" && canProc) mainCands.push(['proc-dispatch', '🚚 Dispatch details']);
if (poStage) mainCands.push(['proc-print-po', '🖨 Print PO']);
if ((st === "Payment Done" || st === "Received") && hasProof) mainCands.push(['proc-view-proof', '💳 Proof']);
if (st === "On Hold" && canHoldIndent(USER)) mainCands.unshift(['proc-resume', '▶ Resume']);
const primary = mainCands[0] || null;
// Everything else goes in the ⋯ menu: [action, label, danger?, tooltip?]
const items = [];
if (canPutOnHold(p)) items.push(['proc-hold', '⏸ Put on hold', false, 'Pause before payment; reason is required']);
if (canE) items.push(['proc-edit', 'Edit indent']);
items.push(['proc-download', '⬇ Download indent']);
if (seeM) items.push(['proc-quotes', 'Quotes']);
items.push(['proc-file', '+ Attach files']);
if (isQ) items.push(['proc-compare', '📊 Compare quotes']);
if (isQ && can.approve(USER)) items.push(['proc-approve-reject', '✔ Approve / Reject']);
if (st === "Indent Raised" && isOwner(USER)) items.push(['proc-reject', '✖ Reject indent', false, 'Reject before quotations are filled']);
if (st === "Payment Done" && canProc) items.push(['proc-dispatch', '🚚 Add dispatch details']);
if (poStage) items.push(['proc-print-po', 'Print PO']);
if (hasQuotes) items.push(['proc-print-quotes', '📊 Print quotes']);
if ((st === "Payment Done" || st === "Received") && hasProof) items.push(['proc-view-proof', '💳 Download proof']);
if (PURCHASED_STATUSES.includes(st) && canProc) items.push(['proc-invno', '🧾 ' + (p.supplierInvNo ? 'Invoice: ' + escAttr(p.supplierInvNo) : 'Add invoice no.')]);
if (poStage && canProc && isIndiaSupplierInd(p)) items.push(['proc-courier', '🚚 Courier tracking']);
if (canE && isOwner(USER)) items.push(['proc-del', '🗑 Delete indent', true]);
const primaryAct = primary ? primary[0] : null;
const menuItems = items.filter(i => i[0] !== primaryAct);
const primaryBtn = primary ? `${primary[1]} ` : '';
const menuBtns = menuItems.map(([act, label, danger, tip]) =>
`${label} `
).join('');
const courierBadge = courierLine(p.courier);
const rejectedBadge = st === "Rejected" ? `❌ ${escAttr(p.rejectReason || 'Rejected')}
` : '';
const holdBadge = st === "On Hold" ? `⏸ ${escAttr(p.holdReason || 'On hold')}
` : '';
return `
${primaryBtn}
${menuItems.length ? `⋯
` : ''}
${courierBadge}${rejectedBadge}${holdBadge}`;
}
// Rejected indents: separate tab, visible to every user
function procRejected() {
const seeM = can.seeMoney(USER);
const rej = DB.proc.filter(p => p.status === "Rejected" && deptOK(USER, p.dept))
.sort((a, b) => String(b.rejectedAt || b.dateRaised || b.date || '').localeCompare(String(a.rejectedAt || a.dateRaised || a.date || '')));
const rows = rej.map(p => `
${p.no} Raised ${p.dateRaised || p.date || '—'}
${tr(p.dept) || '—'}
${escH(p.desc || '')}
${p.qty || ''} ${p.unit || ''}
${escH(p.rejectReason || '—')}
${escH(p.rejectedBy || '—')}${p.rejectedAt || ''}
${escH(p.by || '')}
`).join('');
view().innerHTML = `
Indents that were rejected. Visible to all users.
${procTabs()}
❌ Rejected Indents — ${rej.length}
Indent No Department Item Qty Reason Rejected by Raised by
${rows || emptyRow(7, "No rejected indents")}
`;
}
// ── On Hold: indents paused before payment. Owner sets/resumes; everyone can see ──
// Owners always; any user granted "Put on hold & resume indents" in Users & Access
function canHoldIndent(e) { return isOwner(e) || ((e && e.subPerms) || []).includes('procurement.hold'); }
function canPutOnHold(p) {
const blocked = ["Payment Done", "Dispatched", "In Transit", "Customs Clearance", "Cleared", "Received", "Rejected", "On Hold"];
return canHoldIndent(USER) && !blocked.includes(p.status);
}
function procHold() {
const held = DB.proc.filter(p => p.status === "On Hold")
.sort((a, b) => String(b.heldAt || '').localeCompare(String(a.heldAt || '')));
const rows = held.map(p => `
${p.no} Raised ${p.dateRaised || p.date || '—'}
${tr(p.dept) || '—'}
${escH(p.desc || '')} Was at: ${escH(p.holdPrevStatus || '—')}
${p.qty || ''} ${p.unit || ''}
${escH(p.holdReason || '—')}
${escH(p.heldBy || '—')}${p.heldAt || ''}
${escH(p.by || '')}
${canHoldIndent(USER) ? `▶ Resume ` : ''}
`).join('');
view().innerHTML = `
Indents paused before payment. The owner's reason is shown below. Visible to every user who can open Procurement, including the India login.
${procTabs()}
⏸ On Hold — ${held.length}
Indent No Department Item Qty Reason for hold Held by Raised by
${rows || emptyRow(8, "No indents on hold")}
`;
}
function formHoldIndent(p) {
openModal("Put on Hold — " + p.no,
`The indent will move to the On Hold tab until you resume it. Please write why it is on hold.
` +
field("Reason for hold", "f-hold-reason"),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Put on Hold", cls: "", fn: () => {
const reason = (val("f-hold-reason") || "").trim();
if (!reason) return toast("Please write the reason for hold", false);
const prevStatus = p.status || 'Indent Raised';
p.holdPrevStatus = prevStatus;
p.holdReason = reason;
p.heldBy = USER.displayName || USER.username;
p.heldAt = todayISO();
p.status = "On Hold";
logAudit('hold', p, [{ field: 'status', from: prevStatus, to: 'On Hold' }, { field: 'hold reason', from: '', to: reason }]);
save();
logAct(`${p.no} ON HOLD by ${p.heldBy} — ${reason}`);
toast("Indent put on hold");
closeModal();
render();
}
}]);
}
function resumeHeldIndent(p) {
const back = p.holdPrevStatus || 'Indent Raised';
if (!confirm(`Resume ${p.no}? It will go back to "${back}".`)) return;
const who = USER.displayName || USER.username;
logAudit('resume', p, [{ field: 'status', from: 'On Hold', to: back }]);
p.status = back;
p.resumedBy = who;
p.resumedAt = todayISO();
p.holdReason = '';
save();
logAct(`${p.no} RESUMED from hold by ${who}`);
toast("Indent resumed");
render();
}
function procView() {
if (procMode === "rejected") return procRejected();
if (procMode === "hold") return procHold();
if (procMode === "reports") return procReports();
if (procMode === "vendors") return vendorView();
if (procMode === "history" && isOwner(USER)) return procHistory();
const seeM = can.seeMoney(USER), staff = !seeM;
const SRC = { luanda: "Luanda", lobito: "Lobito", india: "India", other: "Other" };
const offerCell = p => {
const q = p.quotes;
if (!q) return '— ';
return ["luanda", "lobito", "india", "other"].map(k => {
if (!q[k]) return "";
const v = (q[k].price) ? money(q[k].price) : '—';
const win = p.selected === k;
const uploaded = q[k].file || q[k].piNo;
return `${SRC[k]}: ${v}${win ? ' ✓' : ''}${uploaded ? ' 📄' : ''}
`;
}).join("");
};
const canEd = p => isOwner(USER) || !p.by || p.by === (USER.displayName || USER.username);
const filteredProc = DB.proc.filter(p => deptOK(USER, p.dept) && p.status !== "Received").sort((a, b) => new Date(b.dateRaised || b.date || 0) - new Date(a.dateRaised || a.date || 0));
// Dashboard drill-down: when opened from the Procurement card, show only that status group in one table
const FOCUS_SETS = {
indent_raised: { label: "Indent Raised", statuses: ["Indent Raised"] },
quotes_pending: { label: "Quotes & Pending Delivery", statuses: ["Quotes Compared", "PI Uploaded", "Dispatched", "In Transit", "Customs Clearance", "Cleared"] }
};
const procFocus = FOCUS_SETS[window._procFocus] ? window._procFocus : null;
const focusBanner = procFocus ? `
Showing: ${FOCUS_SETS[procFocus].label} (${filteredProc.filter(p => FOCUS_SETS[procFocus].statuses.includes(p.status)).length})
Show all indents ✕
` : '';
const newIndents = procFocus
? filteredProc.filter(p => FOCUS_SETS[procFocus].statuses.includes(p.status))
: filteredProc.filter(p => p.status !== "Approved" && p.status !== "PO Issued" && p.status !== "Payment Done" && p.status !== "Dispatched" && p.status !== "Rejected" && p.status !== "On Hold");
const approvedIndents = procFocus ? [] : filteredProc.filter(p => p.status === "Approved" || p.status === "PO Issued" || p.status === "Payment Done" || p.status === "Dispatched");
// Item column: numbered list when an indent has more than one item
const itemCell = p => {
const its = (p.items || []).filter(i => (i.desc || '').trim());
if (its.length <= 1) return `${p.desc} ${p.area ? `@ ${p.area}
` : ''}${p.remarks ? `${p.remarks}
` : ''}`;
// One grid row per item: No. | name | qty — qty sits on the same line as its item even when the name wraps
return `${its.length} items
` +
`` +
its.map((it, i) => `
${i + 1}.
${escH(it.desc)} ${it.area ? ` @ ${escH(it.area)} ` : ''}${it.remarks ? `${escH(it.remarks)}
` : ''}
${it.qty || 0} ${escH(it.unit || 'Nos')}`).join('') +
`
`;
};
const qtyCell = p => {
const its = (p.items || []).filter(i => (i.desc || '').trim());
if (its.length <= 1) return `${p.qty} ${p.unit}`;
return `Total
${p.qty} (${its.length} items)
`;
};
const makeRows = (items) => items.map(p => `
${p.no} ${p.dateRaised || p.date || '—'}
${tr(p.dept)}
${itemCell(p)}
${qtyCell(p)}
${seeM ? `${offerCell(p)} ` : ''}
${seeM ? `${p.value ? money(p.value) : '—'}${p.selected ? `${SRC[p.selected]}
` : ''} ` : ''}
${attThumbs(p.files)} ${pill(p.status)}
${procActionsHtml(p)} `);
const cols = seeM ? 9 : 6;
const newRows = makeRows(newIndents);
const approvedRows = makeRows(approvedIndents);
view().innerHTML = `
${staff ? "Raise indents one by one, or use Bulk Upload to add many from a CSV. A buyer/owner enters the Luanda / Lobito / India quotes and issues the PO." : "Indent → Quotes → Comparison → PO → Receipt. Raise single or bulk (CSV) ."}
${procTabs()}
${focusBanner}
${newIndents.length > 0 ? `${procFocus ? '📌 ' + FOCUS_SETS[procFocus].label : '📋 New Indents (Pending Approval)'} — ${newIndents.length}
📥 Excel
${tr("+ Raise Indent")}
📥 Bulk Import
`+ (isOwner(USER) ? '
⚠ Reset ' : '') + `
${tr("Indent No")}
${tr("Department")}
${tr("Item")}
${tr("Qty")}
${seeM ? 'Offers (Luanda · Lobito · India) Selected ' : ''}
${tr("Attachments")}
${tr("Status")}
${tr("Action")}
${newRows.join("") || emptyRow(cols, "No pending indents")}
` : ''}
${approvedIndents.length > 0 ? `✅ Approved Indents (In Progress) — ${approvedIndents.length}
📥 Excel
${tr("Indent No")}
${tr("Department")}
${tr("Item")}
${tr("Qty")}
${seeM ? 'Offers (Luanda · Lobito · India) Selected ' : ''}
${tr("Attachments")}
${tr("Status")}
${tr("Action")}
${approvedRows.join("") || emptyRow(cols, "No approved indents")}
` : ''}
${(() => {
const paidIndents = (DB.proc || []).filter(p => p.status === "Payment Done" || p.status === "Dispatched").sort((a, b) => new Date(b.dateRaised || b.date || 0) - new Date(a.dateRaised || a.date || 0));
return (!procFocus && paidIndents.length > 0) ? `💳 Indents Paid (Awaiting Dispatch) — ${paidIndents.length}
📥 Excel
${tr("Indent No")}
${tr("Department")}
${tr("Item")}
${tr("Qty")}
${seeM ? 'Offers (Luanda · Lobito · India) Selected ' : ''}
${tr("Attachments")}
${tr("Status")}
${tr("Action")}
${makeRows(paidIndents).join("") || emptyRow(cols, "No paid indents")}
` : '';
})()}
${newIndents.length === 0 && approvedIndents.length === 0 && (DB.proc || []).filter(p => p.status === "Payment Done" || p.status === "Dispatched").length === 0 ? `📋 No indents to show. + Raise Indent
` : ''}`;
}
// ══════════════════════════════════════════════════════
// VENDOR DIRECTORY VIEW
// ══════════════════════════════════════════════════════
function vendorView() {
const vendors = DB.vendors || [];
const q = (window._vendorSearch || '').toLowerCase().trim();
const vendorMode = window._vendorMode || 'all'; // 'all' or 'india'
// Filter: hide "India Only" vendors from non-owners, unless specifically viewing India mode
let accessibleVendors = canIndiaVendors(USER) ? vendors : vendors.filter(v => !v.india_only);
// If viewing India vendors, show only India-only vendors (owners + users granted India Vendors access)
if (vendorMode === 'india' && canIndiaVendors(USER)) {
accessibleVendors = vendors.filter(v => v.india_only);
} else if (vendorMode === 'india' && !canIndiaVendors(USER)) {
accessibleVendors = []; // Non-owners can't view India vendors
}
const filtered = q ? accessibleVendors.filter(v => {
const nameMatch = (v.name || '').toLowerCase().includes(q);
const locMatch = (v.location || '').toLowerCase().includes(q);
const addrMatch = (v.address || '').toLowerCase().includes(q);
const matMatch = (v.materials || []).some(m => (m.name || '').toLowerCase().includes(q));
return nameMatch || locMatch || addrMatch || matMatch;
}) : accessibleVendors;
const vData = paginate(filtered, 'vendors');
const rows = vData.items.map((v, i) => {
const mats = (v.materials || []).map(m => `${escAttr(m.name)} : ${money(m.rate)} (${m.rateDate || '—'})
`).join('');
return `
${vData.startIdx + i + 1}
${escAttr(v.name)} ${v.india_only ? '🇮🇳 INDIA ' : ''}${v.location ? `📍 ${escAttr(v.location)}
` : ''}
${v.address ? escAttr(v.address) : '—'}
${escAttr(v.contact || '—')}
${v.email ? `${escAttr(v.email)} ` : '—'}
${v.gstin ? escAttr(v.gstin) : '—'}
${v.whatsapp ? `📱 ${escAttr(v.whatsapp)} ` : '—'}
${mats || '— '}
Edit
${isOwner(USER) ? `Delete ` : ''}
`;
}).join('');
view().innerHTML = `
Vendor directory — store supplier contact details, address, and material rates. Use Auto-fill when entering quotes to populate details in one click.
${procTabs()}
🌐 All Vendors
${canIndiaVendors(USER) ? `🇮🇳 India Vendors ` : ''}
🏪 Vendors
⬇ Export CSV
⚙ Custom Export
📥 Bulk Import
+ Add Vendor
${isOwner(USER) ? `
⚠ Reset ` : ''}
${isOwner(USER) && (DB.vendors || []).some(v => v.address && !v.address.includes(' ') && v.address.match(/\d/)) ? `
⚠ Fix Column Data ` : ''}
${filtered.length} of ${vendors.length} vendor(s)${q ? ' matching ' + escAttr(q) + ' ' : ''}
#
Vendor Name
Address
Contact No.
Email
GSTIN
WhatsApp
Items & Rates
Action
${rows || emptyRow(8, 'No vendors yet — click + Add Vendor')}
${vData.html}
`;
}
function formVendorAdd(existing) {
const v = existing || {};
const mats = v.materials || [{ name: '', rate: '', rateDate: todayISO() }];
const isEdit = !!existing;
const matRows = () => (v._tmpMats || mats).map((m, i) => `
`).join('');
v._tmpMats = [...mats];
openModal((isEdit ? 'Edit' : 'Add') + ' Vendor',
`
Items & Rates (add multiple items)
+ Add Item
${matRows()}
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal },
{
label: isEdit ? 'Save Changes' : 'Add Vendor', cls: '', fn: () => {
const name = document.getElementById('v-name').value.trim();
if (!name) return toast('Vendor name is required');
// FIX: read row count from DOM, not from v._tmpMats (which is never updated when rows are added dynamically)
const wrap = document.getElementById('mat-rows-wrap');
const domRows = wrap ? wrap.querySelectorAll('[id^="mat-name-"]') : [];
const mFinal = Array.from(domRows).map((el, i) => ({
name: el.value.trim() || '',
rate: +(document.getElementById('mat-rate-' + i)?.value) || 0,
rateDate: document.getElementById('mat-rdate-' + i)?.value || todayISO()
})).filter(m => m.name);
if (isEdit) {
v.name = name; v.contact = document.getElementById('v-contact').value.trim();
v.email = document.getElementById('v-email').value.trim();
v.gstin = document.getElementById('v-gstin').value.trim();
v.whatsapp = document.getElementById('v-wa').value.trim();
v.location = document.getElementById('v-location').value.trim();
v.address = document.getElementById('v-address').value.trim();
v.india_only = document.getElementById('v-india').checked;
v.materials = mFinal;
delete v._tmpMats;
save(); toast('Vendor updated — ' + mFinal.length + ' item(s) saved'); closeModal(); render();
} else {
const nv = {
id: uid('vn'), name,
contact: document.getElementById('v-contact').value.trim(),
email: document.getElementById('v-email').value.trim(),
gstin: document.getElementById('v-gstin').value.trim(),
whatsapp: document.getElementById('v-wa').value.trim(),
location: document.getElementById('v-location').value.trim(),
address: document.getElementById('v-address').value.trim(),
india_only: document.getElementById('v-india').checked,
materials: mFinal,
addedAt: todayISO()
};
delete v._tmpMats;
if (!DB.vendors) DB.vendors = [];
DB.vendors.push(nv);
save(); toast('Vendor added — ' + mFinal.length + ' item(s) saved'); closeModal(); render();
}
}
}]
);
}
// One-time migration: fix vendor field-shift (address=WA, contact=email, email=phone, whatsapp=telegram, telegram=mat names)
window.vendorFixColumnData = function () {
if (!isOwner(USER)) return toast('Only owners can run this migration.');
if (!confirm('This will correct the field data for ALL vendors by rotating the contact fields.\n\nBefore: Address→WA, Contact→Email, Email→Phone, WhatsApp→Telegram, Telegram→Material names\nAfter: Each field holds what it should.\n\nProceed?')) return;
let fixed = 0;
(DB.vendors || []).forEach(v => {
// Capture old values
const oldAddr = v.address || '';
const oldContact = v.contact || '';
const oldEmail = v.email || '';
const oldWa = v.whatsapp || '';
const oldTg = v.telegram || '';
// Rotate: address had WA, contact had email, email had phone, whatsapp had telegram, telegram had mat names
v.whatsapp = oldAddr; // address value was actually whatsapp
v.email = oldContact; // contact value was actually email
v.contact = oldEmail; // email value was actually phone/contact
v.telegram = oldWa; // whatsapp value was actually telegram
v.address = ''; // address was misused — clear it
// telegram had material names as text — split and prepend to materials
if (oldTg && oldTg.trim()) {
const existingMats = v.materials || [];
const namesParts = oldTg.split(/[,\n]+/).map(s => s.trim()).filter(Boolean);
// Match names to existing rate entries by index
const merged = namesParts.map((nm, i) => {
const existing = existingMats[i] || {};
return { name: nm, rate: existing.rate || 0, rateDate: existing.rateDate || todayISO() };
});
// Append any extra rate-only entries that had no matching name
for (let i = namesParts.length; i < existingMats.length; i++) {
merged.push(existingMats[i]);
}
v.materials = merged;
}
fixed++;
});
save();
toast(`Fixed ${fixed} vendor record(s). Please verify the data looks correct.`);
render();
};
window.vendorAddMat = function () {
// Read current values before adding
const wrap = document.getElementById('mat-rows-wrap');
if (!wrap) return;
// find vendor temp ref by checking existing modal context
// We rebuild the rows inside the modal
const allRows = wrap.querySelectorAll('[id^="mat-name-"]');
const cur = [];
allRows.forEach((el, i) => {
cur.push({
name: el.value,
rate: document.getElementById('mat-rate-' + i)?.value || '',
rateDate: document.getElementById('mat-rdate-' + i)?.value || todayISO()
});
});
cur.push({ name: '', rate: '', rateDate: todayISO() });
// Re-render rows
wrap.innerHTML = cur.map((m, i) => `
`).join('');
};
window.vendorRemoveMat = function (idx) {
const wrap = document.getElementById('mat-rows-wrap');
if (!wrap) return;
const allRows = wrap.querySelectorAll('[id^="mat-name-"]');
const cur = [];
allRows.forEach((el, i) => {
if (i !== idx) cur.push({
name: el.value,
rate: document.getElementById('mat-rate-' + i)?.value || '',
rateDate: document.getElementById('mat-rdate-' + i)?.value || todayISO()
});
});
if (!cur.length) cur.push({ name: '', rate: '', rateDate: todayISO() });
wrap.innerHTML = cur.map((m, i) => `
`).join('');
};
function viewIndentDetails(p) {
if (!p) return;
const seeM = can.seeMoney(USER);
const items = (p.items && p.items.length) ? p.items : [{ desc: p.desc, qty: p.qty, unit: p.unit, area: p.area, remarks: p.remarks }];
const itemRows = items.map((it, i) => `
${i + 1}
${escH(it.desc || '')}
${it.qty || 0} ${escH(it.unit || 'Nos')}
${escH(it.area || '—')}
${escH(it.remarks || '—')}
`).join("");
const q = p.quotes || {};
const SRC = { luanda: "Luanda", lobito: "Lobito", india: "India", other: "Other" };
const quotesHtml = Object.keys(q).length ? ["luanda", "lobito", "india", "other"].map(k => {
if (!q[k]) return '';
const v = q[k].price ? money(q[k].price) : '—';
const isWin = p.selected === k;
return `${SRC[k]}: ${v} ${isWin ? ' (Selected Winner ✓)' : ''}${q[k].sup ? ' — Vendor: ' + escH(q[k].sup) : ''}
`;
}).join('') : 'No quotes recorded ';
const proofArr = Array.isArray(p.paymentProof) ? p.paymentProof : (p.paymentProof ? [p.paymentProof] : []);
const receiptPhotos = p.receiptPhotos || p.storePhotos || [];
openModal("Indent Details — " + (p.no || 'IND'),
`
Status: ${pill(p.status)}
Department: ${escH(p.dept || '—')}
Raised By: ${escH(p.by || '—')}
Raised Date: ${idate(p) || '—'}
Delivery Location: ${escH(p.deliveryLoc || '—')}
Area / Usage: ${escH(p.area || '—')}
Purpose / Notes: ${escH(p.purpose || p.remarks || '—')}
` +
`Requested Items (${items.length}) ` +
`# Item / Description Qty Area / Specs Remarks ` +
`${itemRows}
` +
`` +
`
` +
`
Supplier & PO Details ` +
`
Selected Supplier: ${escH(p.supplier || (p.selected ? SRC[p.selected] : '—'))}
` +
`${seeM ? `
PO Value: ${p.value ? money(p.value) : '—'}
` : ''}` +
`
PO Number: ${escH(p.poNo || '—')}
` +
`
PO / Approval Date: ${p.approvedAt ? p.approvedAt.slice(0, 10) : (p.poDate || '—')}
` +
`${seeM ? `
Quotes Summary: ${quotesHtml}
` : ''}` +
`
` +
`
` +
`
Receipt & Payment Status ` +
`
Status: ${pill(p.status)}
` +
`
Received Date: ${p.receivedDate || p.receivedAt || (p.status === 'Received' ? 'Received' : '—')}
` +
`
Received By: ${escH(p.receivedBy || 'Site Storekeeper')}
` +
`
Payment Proof: ${proofArr.length ? proofArr.length + ' file(s)' : '—'}
` +
`
Receipt Photos: ${receiptPhotos.length ? receiptPhotos.length + ' photo(s)' : '—'}
` +
`
` +
`
` +
((p.files && p.files.length) ? `Attachments: ${attThumbs(p.files)}
` : '') +
(receiptPhotos.length ? `Shipment / Goods Receipt Photos: ${attThumbs(receiptPhotos)}
` : ''),
[
{ label: "Close", cls: "secondary", fn: closeModal },
{ label: "🖨 Print Indent", cls: "ghost", fn: () => printIndent(p) },
...(p.status === "PO Issued" || p.status === "Payment Done" || p.status === "Dispatched" || p.status === "Received" ? [{ label: "Print PO 🖨", cls: "", fn: () => printPO(p) }] : [])
]
);
}
function procReports() {
const seeM = can.seeMoney(USER);
const mine = DB.proc.filter(p => deptOK(USER, p.dept));
const list = mine.filter(p => {
const d = idate(p);
if (repFrom && d && d < repFrom) return false;
if (repTo && d && d > repTo) return false;
if (repStatus && repStatus !== "All" && p.status !== repStatus) return false;
return true;
});
const byDept = {};
list.forEach(p => {
const dep = p.dept || "—";
const mo = (idate(p) || "----").slice(0, 7);
(byDept[dep] = byDept[dep] || {});
(byDept[dep][mo] = byDept[dep][mo] || []).push(p);
});
const deptBlocks = Object.keys(byDept).sort().map(dep => {
const months = Object.keys(byDept[dep]).sort().reverse();
const total = months.reduce((a, mo) => a + byDept[dep][mo].length, 0);
const monthRows = months.map(mo => {
const items = byDept[dep][mo];
const val = items.reduce((s, p) => s + (+p.value || 0), 0);
const r = items.map(p => {
const supp = p.supplier || (p.selected ? p.selected.toUpperCase() : '—');
const proofArr = Array.isArray(p.paymentProof) ? p.paymentProof : (p.paymentProof ? [p.paymentProof] : []);
return `
${idate(p) || '—'}
${p.no}
${((p.items || []).filter(i => (i.desc || '').trim()).length > 1)
? (p.items || []).filter(i => (i.desc || '').trim()).map((it, i) => `${i + 1}. ${escH(it.desc)} × ${it.qty || 0} ${escH(it.unit || 'Nos')}
`).join('')
: `${escH(p.desc)} `}
${p.area ? `@ ${escH(p.area)}
` : ''}
Raised by: ${escH(p.by || '—')}
${p.qty} ${p.unit}
${escH(supp)}
${seeM ? `${p.value ? money(p.value) : '—'} ` : ''}
${pill(p.status)}
🔍 Details
⬇ Indent
${(p.status === "PO Issued" || p.status === "Payment Done" || p.status === "Dispatched" || p.status === "Received") ? `Print PO 🖨 ` : ''}
${(p.status === "Received" && proofArr.length) ? `💳 Proof ` : ''}
${PURCHASED_STATUSES.includes(p.status) && (canMod(USER, 'procurement') || isOwner(USER)) ? `🧾 ${p.supplierInvNo ? 'Inv: ' + escAttr(p.supplierInvNo) : 'Add Invoice No'} ` : ''}
`;
}).join("");
const cols = seeM ? 8 : 7;
return `${monthLabel(mo)} (${items.length} indents, ${seeM ? money(val) : ''}) ` + r;
}).join("");
const cols = seeM ? 8 : 7;
return `
${tr(dep)} (${total} total)
Date
Indent No
Item & Details
Qty
Supplier / Vendor
${seeM ? 'Value ' : ''}
Status
Action
${monthRows}
`;
}).join("");
const statusOpts = ["All", "Received", "Indent Raised", "Quoted", "Quotes Compared", "Approved", "PO Issued", "Payment Done", "Dispatched", "Rejected"];
view().innerHTML = `
Procurement Reports & Archives — view completed (Received) indents and all historical procurement records grouped by department.
${procTabs()}
${deptBlocks || `No indents found in this filter range
`}`;
}
function procHistory() {
const rows = DB.audit.filter(x => x.no.startsWith('IND-')).map(a => `
${a.no} ${a.action.toUpperCase()} ${a.desc} ${a.by} ${(a.at || "").slice(0, 16).replace('T', ' ')}
${(a.changes || []).map(c => `• ${c.field}: ${c.from} → ${c.to} `).join(" ") || '—'}
${(a.action === 'delete') ? `Recover ` : ''} `).join("");
view().innerHTML = `
Audit trail of all indents raised, edited, or deleted. Deleted indents can be recovered by owners.
${procTabs()}
Indent Audit Trail
No Action Item User Timestamp Changes Recover
${rows || emptyRow(7, "Audit log is empty")}
`;
}
function licStatus(d) {
if (!d.expiry && !d.date) return 'Under Process';
const days = daysTo(d.expiry || d.date);
if (days < 0) return 'Expired';
if (days <= 45) return 'Expiring Soon';
return 'Valid';
}
function importsView() {
const filterRow = `
${["All", "Import", "Licence"].map(k => `${tr(k)} `).join("")}
`;
const list = DB.docs.filter(d => docKind === 'All' || d.kind === docKind);
const mData = paginate(list, 'imports');
const rows = mData.items.map(d => {
const isLic = d.kind === "Licence";
const displayStatus = isLic ? licStatus(d) : d.status;
const statusCls = displayStatus === "Expired" ? "p-bad" : displayStatus === "Expiring Soon" ? "p-warn" : displayStatus === "Valid" ? "p-ok" : "p-mute";
return `
${pill(d.kind)}
${d.title} Ref: ${d.ref}${isLic && d.dept ? ' · Dept: ' + d.dept : ''}
${d.party || '—'}
${isLic ? (d.issuedDate || '—') : d.date}
${isLic ? (d.expiry || '—') : d.date}
${displayStatus}
${attThumbs(d.files)}
${tr("Update")}
${tr("+ Docs")}
${isOwner(USER) ? `${tr("Delete")} ` : ''}
`;
}).join("");
view().innerHTML = `
${filterRow}
${tr("Imports & Licences")}
⚙ Custom Export
${canMod(USER, "imports") ? `
${tr("+ New Record")} ` : ''}
`+ (isOwner(USER) ? '
⚠ Reset ' : '') + `
${tr("Type")} ${tr("Title / Ref")} ${tr("Party / Origin")} ${tr("Issued")} ETA / Expiry ${tr("Status")} ${tr("Documents")} ${tr("Action")}
${rows || emptyRow(7, "No records found")}
${mData.html}
`;
}
// ── Site Stores View ──────────────────────────────────────────────────────────
let storesSearch = "";
function storesView() {
const mine = DB.items;
const filtered = mine.filter(i => {
if (!storesSearch) return true;
const s = storesSearch.toLowerCase();
return (i.code || "").toLowerCase().includes(s) || (i.name || "").toLowerCase().includes(s) || (i.loc || "").toLowerCase().includes(s);
});
// ── Main tab bar (Store Items | Outstanding Tools | Recent Purchases | History | Device Issues) ──
const mainTabs = [{ k: 'items', l: '🏪 Store Items' }, { k: 'tools', l: '🔧 Outstanding Tools' }, { k: 'recent', l: '🛍 Recent Purchases' }, { k: 'history', l: '📚 History' }, { k: 'devices', l: '💻 Device Issues' }];
const mainTabBar = mainTabs.map(t => `${t.l} `).join('');
// ── Outstanding Tools ──
const iData = paginate(DB.issues, 'tools');
const issueRows = iData.items.map(iss => `
${iss.item} ${iss.code} · ${iss.qty} ${iss.unit}
${iss.person}${iss.workerType ? iss.workerType + ' · ' : ''} ${iss.workerId ? 'ID: ' + iss.workerId : (iss.dept || '')}
${iss.dateOut} ${iss.expReturn || '—'} ${pill(iss.status)}
${iss.status === "Out" ? `${tr("Return")} ` : ''}
${iss.status === "Out" ? `\uD83D\uDCB0 Fine ` : ''}
${iss.status === "Debited" ? `Debit Note ` : ''}
${iss.fineAmount ? `\uD83D\uDCB0 ${money(iss.fineAmount)} ` : ''}
`).join('');
const toolsTable = `Item Person Out On Exp. Return Status Action
${issueRows || emptyRow(6, "Nothing issued out")}
${iData.html}`;
// ── Recent Purchases table (for main tab) — newest first, NO receipt proof ──
const allRecentProcs = DB.proc.filter(p => (p.status === "Received" || p.status === "PO Issued" || p.status === "Payment Done" || p.status === "Dispatched") && (!p.storePhotos || p.storePhotos.length === 0)).sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
const recentPurchasesTable = `PO No Item Department Qty Supplier Status Photos Action
${allRecentProcs.map(p => {
const stPhotos = p.storePhotos || [];
const canRecv = stPhotos.length > 0 && (p.status === "PO Issued" || p.status === "Dispatched");
return `
${p.no} ${p.desc}
${p.dept || '\u2014'}
${p.qty} ${p.unit} ${p.supplier || '\u2014'} ${pill(p.status)}
${stPhotos.length ? `\uD83D\uDCF7 ${stPhotos.length} ` : `\uD83D\uDCF7 Add `}
\uD83D\uDCF7 Photos
${(p.status === "PO Issued" || p.status === "Dispatched") ? `Received to Store \u2714 ` : ''}
${(p.status === "PO Issued" || p.status === "Dispatched" || p.status === "Received") ? `Print PO \uD83D\uDDB8 ` : ''}
${(() => { const proof = Array.isArray(p.paymentProof) ? p.paymentProof : (p.paymentProof ? [p.paymentProof] : []); return p.status === "Received" && proof.length ? `\uD83D\uDCB3 Download Proof ` : ''; })()}
${p.status === "Received" ? `\u2714 In Stock ` : ''}
`;
}).join('') || emptyRow(8, 'No recent purchases')}
`;
// ── History table (purchases with receipt proof) — newest first ──
const historyProcs = DB.proc.filter(p => p.storePhotos && p.storePhotos.length > 0).sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0));
const historyTable = `PO No Item Department Qty Supplier Status Receipt Photos Action
${historyProcs.map(p => {
const stPhotos = p.storePhotos || [];
return `
${p.no} ${p.desc}
${p.dept || '—'}
${p.qty} ${p.unit} ${p.supplier || '—'} ${pill(p.status)}
📷 ${stPhotos.length} Photo${stPhotos.length > 1 ? 's' : ''}
${(p.status === "PO Issued" || p.status === "Dispatched" || p.status === "Received") ? `Print PO 🖸 ` : ''}
${(() => { const proof = Array.isArray(p.paymentProof) ? p.paymentProof : (p.paymentProof ? [p.paymentProof] : []); return p.status === "Received" && proof.length ? `💳 Download Proof ` : ''; })()}
`;
}).join('') || emptyRow(8, 'No completed purchases')}
`;
const receiptPending = DB.proc.filter(p => p.status === "PO Issued" || p.status === "Dispatched");
const receiptRows = receiptPending.map(p => `
${p.no} ${p.desc} ${p.qty} ${p.unit}
${p.supplier} ${pill(p.status)}
Confirm Goods Receipt
`).join('');
// ── Item photo gallery rows ──
const sData = paginate(filtered, 'stores');
const rowsWithPhotos = sData.items.map(i => {
const photoFiles = (i.photos || []).filter(f => f && f.data);
const photoBtn = photoFiles.length
? `\uD83D\uDCF7 ${photoFiles.length} Photo${photoFiles.length > 1 ? 's' : ''} `
: `\uD83D\uDCF7 Photos `;
return `
${i.code || '\u2014'}${i.name} ${i.returnable ? ` [${tr("Returnable")}] ` : ''}${i.repairable ? ` \uD83D\uDD27 Repairable ` : ''}
${tr(i.cat)} ${i.loc || '\u2014'} ${i.qty} ${i.unit}
${i.qty <= i.reorder ? pill("Low") : pill("OK")}
${tr("In")}
${tr("Out")}
${i.returnable ? `${tr("Issue")} ` : ''}
${i.repairable ? `\uD83D\uDD27 Repair Info ` : ''}
${photoBtn}
${tr("Edit")}
${isOwner(USER) ? `🗑 ` : ''}
`;
}).join("");
// Device Issues tab
const devAll = (DB.deviceIssues || []).slice().reverse();
const devIssued = devAll.filter(d => d.returnStatus === 'Issued');
const devLaptops = devAll.filter(d => d.deviceType === 'Laptop');
const devMobiles = devAll.filter(d => d.deviceType === 'Mobile');
const devRows = devAll.map(d => `
${d.staffName || '\u2014'}
${d.deviceType === 'Laptop' ? '\uD83D\uDCBB' : '\uD83D\uDCF1'} ${d.deviceType}
${d.deviceDetails || '\u2014'}
${d.issueDate || '\u2014'}
${d.expectedReturn || '\u2014'}
${d.returnStatus === 'Returned'
? `\u2714 Returned `
: `\uD83D\uDCE4 Issued `}
${d.remarks || '\u2014'}
${d.returnStatus === 'Issued' ? `\u2714 Returned ` : ''}
\u270F
\uD83D\uDDD1
`).join('');
const devPanel = `
${devAll.length}
Total Issued
${devLaptops.length}
\uD83D\uDCBB Laptops
${devMobiles.length}
\uD83D\uDCF1 Mobiles
${devIssued.length}
Pending Return
\uD83D\uDD12 All devices are issued as Returnable items. Ensure devices are recovered before staff exit.
Staff Name Device Type Serial / Details Issue Date Exp. Return Return Status Remarks Action
${devRows || emptyRow(8, 'No devices issued yet')}
`;
view().innerHTML = `
${tr("Site Stores")}
${mainTabBar}
${storesTab === 'items' ? `
⬇ Items CSV
⚙ Custom Export
📊 Stock Report
${tr("Bulk Add Items")}
${tr("+ Add Item")}
`+ (isOwner(USER) ? '
⚠ Reset ' : '') : storesTab === 'devices' ? `
+ Issue Device
`: `
📊 Stock Report
⚙ Custom Export
`}
${storesTab === 'items'
? `
Code Name Category Location Qty Status Action
${rowsWithPhotos || emptyRow(7, "No items match")}
${sData.html}`
: storesTab === 'tools'
? toolsTable
: storesTab === 'recent'
? recentPurchasesTable
: storesTab === 'history'
? historyTable
: devPanel
}
${storesTab === 'items' && receiptPending.length > 0 ? `
Pending Goods Receipts (Approved POs)
PO/Indent No Item Description Quantity Supplier Status Action
${receiptRows}
` : ''}`;
const sin = document.getElementById("stSearch");
if (sin) {
sin.oninput = () => { storesSearch = sin.value; render(); };
sin.focus();
sin.setSelectionRange(storesSearch.length, storesSearch.length);
}
}
function showModal(html) {
document.getElementById("modalMount").innerHTML = ``;
const bg = document.getElementById("modalBg");
if (bg) bg.onclick = e => { if (e.target.id === "modalBg") closeModal(); };
}
// ── Work With Us (Vacancies Management) ──────────────────────────────────────
function careersView() {
const vAll = DB.vacancies || [];
const vActive = vAll.filter(v => v.status === 'Active' || !v.status);
const vClosed = vAll.filter(v => v.status === 'Closed');
const rows = vAll.map(v => {
const resps = Array.isArray(v.responsibilities) ? v.responsibilities.join(' • ') : (v.responsibilities || '—');
const isActive = v.status !== 'Closed' && v.status !== 'Paused';
return `
${v.no || '—'}
${v.title}
${v.location || '—'}
${v.experience || '—'}
${resps}
${isActive ? '🟢 Active' : '🔴 Closed'}
${isActive ? 'Pause' : 'Activate'}
✏ Edit
${(isDev(USER) || isOwner(USER)) ? `🗑 ` : ''}
`;
}).join('');
view().innerHTML = `
${vAll.length}
Total Postings
${vActive.length}
Active Listings
${vClosed.length}
Closed Listings
💡 Active vacancy listings added here will immediately be visible to job applicants on the public Work With Us section of index.html.
Ref / No Job Title Location Experience Responsibilities Status Action
${rows || emptyRow(7, 'No vacancy listings')}
`;
}
function formVacancy(item = null) {
const title = item ? 'Edit Vacant Position' : 'Add New Vacant Position';
const respsText = item ? (Array.isArray(item.responsibilities) ? item.responsibilities.join('\n') : item.responsibilities || '') : '';
showModal(`
${title} ×
Key Responsibilities (one item per line)
Listing Status
🟢 Active (Visible on Website)
🔴 Closed (Hidden from Website)
`);
document.getElementById('f-vac-save').onclick = () => {
const titleVal = document.getElementById('f-vac-title').value.trim();
if (!titleVal) { toast('Please enter a Job Title'); return; }
const rawResps = document.getElementById('f-vac-resps').value.split('\n').map(s => s.trim()).filter(Boolean);
const rec = item ? { ...item } : { id: uid('vac'), postedDate: todayISO() };
rec.title = titleVal;
rec.no = document.getElementById('f-vac-no').value.trim();
rec.location = document.getElementById('f-vac-loc').value.trim();
rec.experience = document.getElementById('f-vac-exp').value.trim();
rec.responsibilities = rawResps;
rec.status = document.getElementById('f-vac-status').value;
if (!item) { (DB.vacancies = DB.vacancies || []).push(rec); }
else { const idx = DB.vacancies.findIndex(x => x.id === item.id); if (idx > -1) DB.vacancies[idx] = rec; }
save(); closeModal(); render();
toast(item ? 'Vacancy updated' : 'New vacancy posted');
logAct(`Vacancy posting updated: ${rec.title}`);
};
}
// ── Device Issue Form ────────────────────────────────────────────────────────
function formDeviceIssue(item = null) {
const title = item ? 'Edit Device Issue' : 'Issue Device to Staff';
const empOptions = DB.employees.filter(e => e.status !== 'Released').map(e => `${e.name} (${e.role || ''}) `).join('');
showModal(`
${title} ×
`);
document.getElementById('f-dev-save').onclick = () => {
const staffName = document.getElementById('f-dev-staff').value.trim();
const deviceType = document.getElementById('f-dev-type').value;
const issueDate = document.getElementById('f-dev-date').value;
if (!staffName || !issueDate) { toast('Fill Staff Name and Issue Date'); return; }
const rec = item ? { ...item } : { id: uid('dev') };
rec.staffName = staffName;
rec.deviceType = deviceType;
rec.deviceDetails = document.getElementById('f-dev-details').value.trim();
rec.issueDate = issueDate;
rec.expectedReturn = document.getElementById('f-dev-expret').value;
rec.returnStatus = document.getElementById('f-dev-status').value;
rec.remarks = document.getElementById('f-dev-remarks').value.trim();
rec.returnable = true;
if (!item) { DB.deviceIssues.push(rec); }
else { const idx = DB.deviceIssues.findIndex(x => x.id === item.id); if (idx > -1) DB.deviceIssues[idx] = rec; }
save(); closeModal(); render();
toast(item ? 'Device record updated' : 'Device issued successfully');
logAct(`Device ${rec.deviceType} issued to ${rec.staffName}`);
};
}
// ── Staff Advance Form ────────────────────────────────────────────────────────
function formStaffAdvance(item = null) {
const title = item ? 'Edit Staff Advance' : 'New Staff Advance';
const empOptions = DB.employees.filter(e => e.status !== 'Released').map(e => `${e.name} (${e.role || ''}) `).join('');
showModal(`
${title} ×
Staff Name
${empOptions}
Reason
⚡ Remaining balance will be auto-deducted from salary until fully recovered.
`);
function updateRemaining() {
const amt = parseFloat(document.getElementById('f-adv-amount').value) || 0;
const ded = parseFloat(document.getElementById('f-adv-deducted').value) || 0;
const rem = Math.max(0, amt - ded);
const el = document.getElementById('adv-remaining-display');
if (el) { el.textContent = money(rem); el.style.color = rem > 0 ? 'var(--bad)' : 'var(--ok,#16a34a)'; }
}
document.getElementById('f-adv-amount').oninput = updateRemaining;
document.getElementById('f-adv-deducted').oninput = updateRemaining;
updateRemaining();
document.getElementById('f-adv-save').onclick = () => {
const staffName = document.getElementById('f-adv-staff').value.trim();
const amount = parseFloat(document.getElementById('f-adv-amount').value) || 0;
const date = document.getElementById('f-adv-date').value;
if (!staffName || !amount || !date) { toast('Fill Staff Name, Amount and Date'); return; }
const deducted = parseFloat(document.getElementById('f-adv-deducted').value) || 0;
const rec = item ? { ...item } : { id: uid('adv') };
rec.staffName = staffName; rec.amount = amount; rec.date = date;
rec.reason = document.getElementById('f-adv-reason').value.trim();
rec.amountDeducted = deducted;
rec.remaining = Math.max(0, amount - deducted);
rec.deductionStatus = rec.remaining <= 0 ? 'Fully Recovered' : deducted > 0 ? 'Partially Deducted' : 'Pending';
if (!item) { DB.staffAdvances.push(rec); }
else { const idx = DB.staffAdvances.findIndex(x => x.id === item.id); if (idx > -1) DB.staffAdvances[idx] = rec; }
save(); closeModal(); render();
toast(item ? 'Advance updated' : 'Advance recorded');
logAct(`Advance of ${money(amount)} to ${staffName}`);
};
}
// ── Advance Deduction Modal ───────────────────────────────────────────────────
function formAdvDeduction(adv) {
const rem = Math.max(0, (+adv.amount || 0) - (+adv.amountDeducted || 0));
showModal(`
Record Salary Deduction ×
`);
document.getElementById('f-ded-save').onclick = () => {
const dedNow = parseFloat(document.getElementById('f-ded-amount').value) || 0;
if (dedNow <= 0) { toast('Enter a valid deduction amount'); return; }
const idx = DB.staffAdvances.findIndex(x => x.id === adv.id);
if (idx > -1) {
DB.staffAdvances[idx].amountDeducted = (+DB.staffAdvances[idx].amountDeducted || 0) + dedNow;
const newRem = Math.max(0, (+DB.staffAdvances[idx].amount || 0) - DB.staffAdvances[idx].amountDeducted);
DB.staffAdvances[idx].remaining = newRem;
DB.staffAdvances[idx].deductionStatus = newRem <= 0 ? 'Fully Recovered' : 'Partially Deducted';
}
save(); closeModal(); render();
toast('Deduction recorded');
logAct(`Deduction ${money(dedNow)} from ${adv.staffName} advance`);
};
}
// Helper to lookup people options in dialogs
function personOptions(selVal) {
let out = '-- select person -- ';
DB.employees.forEach(e => { out += `${e.name} (${tr(e.role)}) `; });
return out;
}
function resolvePerson(v) {
if (!v) return null;
if (v.startsWith("emp:")) {
const emp = DB.employees.find(x => x.id === v.slice(4));
return emp ? { kind: "employee", ref: emp.id, name: emp.name, wid: emp.id, dept: emp.dept } : null;
}
return { kind: "external", ref: "", name: v, wid: "", dept: "" };
}
// ── Fleet & Vehicles View ────────────────────────────────────────────────────
function vehDocWarnings(v) {
const w = [];
if (v.insExpiry) { const d = daysTo(v.insExpiry); if (d <= 30) w.push(`⚠ Insurance ${d < 0 ? 'EXPIRED' : 'exp. ' + v.insExpiry} `); }
if (v.regExpiry) { const d = daysTo(v.regExpiry); if (d <= 30) w.push(`⚠ Reg ${d < 0 ? 'EXPIRED' : 'exp. ' + v.regExpiry} `); }
if (v.inspExpiry) { const d = daysTo(v.inspExpiry); if (d <= 30) w.push(`⚠ Inspection ${d < 0 ? 'EXPIRED' : 'exp. ' + v.inspExpiry} `); }
return w.join(' ');
}
function vehView() {
if (openVeh) return vehDetail(openVeh);
const activeVehs = DB.vehicles.filter(v => v.status !== 'Suspended');
const suspendedVehs = DB.vehicles.filter(v => v.status === 'Suspended');
const displayList = vehFleetTab === 'suspended' ? suspendedVehs : activeVehs;
const vhData = paginate(displayList, 'veh');
const rows = vhData.items.map(v => {
const warn = vehDocWarnings(v);
const isSusp = v.status === 'Suspended';
if (isSusp) {
return `
${v.name} ${pill(v.status)}${v.type} · ${v.reg}
Meter: ${v.meter} | Next Service: ${v.nextService || '—'}
${warn ? `
${warn}
` : ''}
Edit ▶ ${tr("Unsuspend")} ${isOwner(USER) ? `🗑 ` : ''}
`;
}
return `
${v.name} ${pill(v.status)}${v.type} · ${v.reg}
Meter: ${v.meter} | Next Service: ${v.nextService || '—'}
${warn ? `
${warn}
` : ''}
+ Service + Trip + Fuel Edit 🚫 ${tr("Suspend")} ${isOwner(USER) ? `🗑 ` : ''}
`;
}).join("");
const tabStyle = (tab) => vehFleetTab === tab ? 'background:var(--safety);color:#fff;border-color:var(--safety)' : '';
view().innerHTML = `
⬇ Export CSV
⚙ Custom Export
📥 Bulk Import
${tr("+ Add Vehicle")}
`+ (isOwner(USER) ? '⚠ Reset ' : '') + `
✅ Active (${activeVehs.length})
🚫 Suspended (${suspendedVehs.length})
${rows || `
${vehFleetTab === 'suspended' ? 'No suspended vehicles' : 'No active vehicles'}
`}
${vhData.html}`;
}
function vehDetail(vid) {
const v = DB.vehicles.find(x => x.id === vid);
const tabs = [{ k: "maintenance", l: "Maintenance" }, { k: "trips", l: "Trip Log" }, { k: "fuel", l: "Fuel Log" }];
const tabBar = tabs.map(t => `` + t.l + ` `).join("");
let panel = '';
if (vehTab === "maintenance") {
const rows = (v.logs || []).map(l => `
${l.date} ${l.km_open || '—'} → ${l.km_close || '—'} km ${l.work} ${money(l.cost)}
${l.driver || '—'} ${attThumbs([...(l.photos || [])])}
Del
`).join("");
panel = `Maintenance Register
+ Add Service Log
Date Meter (Open→Close) Work Done Cost Driver Photos/Bill Action
${rows || emptyRow(7, "No service records logged")}
`;
} else if (vehTab === "trips") {
const rows = (v.trips || []).map(l => `
${l.no} ${l.date} ${l.openKm} ${l.closeKm}
${(+l.closeKm || 0) - (+l.openKm || 0)} km
${l.destination} ${l.driver || '—'} ${l.approvedBy || '—'}
${l.damage || 'OK'}
Del
`).join("");
panel = `
S.No Date Open KM Close KM Distance Destination Driver Approved By Damage Check Action
${rows || emptyRow(10, "No trips logged")}
`;
} else {
const rows = (v.fuel || []).map(l => {
const eff = l.km && l.litres ? ((+l.km) / +(l.litres)).toFixed(2) : '—';
return `
${l.date} ${l.litres} L ${money(l.cost)}
${l.km || '—'} km ${eff} km/L
Del
`;
}).join("");
const totalLit = (v.fuel || []).reduce((a, x) => a + (+x.litres || 0), 0);
const totalCost = (v.fuel || []).reduce((a, x) => a + (+x.cost || 0), 0);
panel = `
Total: ${totalLit.toFixed(1)} L · Cost: ${money(totalCost)}
Date Litres Cost KM at Fill Efficiency Action
${rows || emptyRow(6, "No fuel entries")}
`;
}
// Doc status summary
const docItems = [["Insurance", v.insExpiry], ["Registration", v.regExpiry], ["Inspection", v.inspExpiry]].filter(x => x[1]);
const docBar = docItems.length ? `${docItems.map(([lbl, dt]) => { const d = daysTo(dt); const col = d < 0 ? 'var(--bad)' : d <= 30 ? '#b9791b' : 'var(--ok)'; return `📋 ${lbl}: ${dt} (${d < 0 ? 'EXPIRED' : d + ' days'}) `; }).join("")}
` : '';
view().innerHTML = `
🛞 Back to Vehicles
${v.name} — ${v.reg} ${pill(v.status)}
${v.status === 'Suspended' ? '▶ ' + tr("Unsuspend Vehicle") : '🚫 ' + tr("Suspend Vehicle")}
Type: ${v.type}
Meter: ${v.meter}
Next Service: ${v.nextService || '—'}
${v.driver ? `
Driver: ${escH(v.driver)} ` : ''}
${v.driverLicenceDoc ? `
📄 Driving Licence ` : ''}
${v.driverAddrDoc ? `
🏠 Address Proof ` : ''}
${docBar}
${tabBar}
${panel}
`;
}
// ── Accounts View ─────────────────────────────────────────────────────────────
let accountsSearch = "", acctRepFrom = "", acctRepTo = "", acctTypeFilter = "All", accountsTab = "register";
let acctLedgerHead = "", acctLedgerMode = "head", acctIvaMonth = todayISO().slice(0, 7), acctShowOutstandingOnly = false;
function acctView() {
const seeM = can.seeMoney(USER);
if (!seeM) return view().innerHTML = `Unauthorized access to accounts ledger
`;
ensureAcctHeads();
const filtered = DB.accounts.filter(a => {
const s = accountsSearch.toLowerCase();
const matchSearch = (a.inv || "").toLowerCase().includes(s) || (a.party || "").toLowerCase().includes(s) || (a.nif || "").toLowerCase().includes(s) || (a.head || "").toLowerCase().includes(s);
if (!matchSearch) return false;
if (acctRepFrom && a.date < acctRepFrom) return false;
if (acctRepTo && a.date > acctRepTo) return false;
if (acctTypeFilter !== "All" && (a.purchaseType || "Local") !== acctTypeFilter) return false;
return true;
});
const aData = paginate(filtered, 'acct');
const rows = aData.items.map(a => `
${a.date} ${a.inv || '—'}${a.party}
${a.head}
${a.acctHead || '—'}
${a.purchaseType || 'Local'}
${money(a.amount)} ${a.mode || '—'}
${attThumbs(a.files)}
${isOwner(USER) ? `${tr("Delete")} ✏ ` : ''}
`).join("");
const total = filtered.reduce((acc, x) => acc + (x.amount || 0), 0);
// Payables with overdue check
const payables = DB.proc.filter(p => p.status === "PO Issued" || p.status === "Dispatched" || p.status === "Received");
const pyData = paginate(payables, 'payables');
const payableRows = pyData.items.map(p => {
const poDate = (p.at || "").slice(0, 10);
const age = poDate ? Math.floor((new Date(todayISO()) - new Date(poDate)) / 864e5) : 0;
const overdue = age > 30;
return `
${p.no} ${overdue ? `⚠ ${age}d overdue
` : ''}
${p.desc} ${money(p.value)}
${p.supplier} ${pill(p.status)}
${p.paymentDone
? `✔ Paid `
: `Process Payment `
}
💳 Add Payment Proof
`;
}).join("");
// ── Staff Advances ────────────────────────────────────────────────────────
const advAll = (DB.staffAdvances || []).slice().reverse();
const advTotalAmount = advAll.reduce((s, a) => s + (+a.amount || 0), 0);
const advTotalDeducted = advAll.reduce((s, a) => s + (+a.amountDeducted || 0), 0);
const advTotalRemaining = advTotalAmount - advTotalDeducted;
const advRows = advAll.map(a => {
const rem = (+a.amount || 0) - (+a.amountDeducted || 0);
const pct = a.amount > 0 ? Math.min(100, Math.round((+a.amountDeducted || 0) / (+a.amount) * 100)) : 0;
const statusColor = rem <= 0 ? '#065f46' : rem < (+a.amount || 0) ? '#b45309' : '#c2410c';
const statusBg = rem <= 0 ? '#d1fae5' : rem < (+a.amount || 0) ? '#fef3c7' : '#fee2e2';
const statusLabel = rem <= 0 ? 'Fully Recovered' : rem < (+a.amount || 0) ? 'Partially Deducted' : 'Pending';
return `
${a.staffName || '—'}
${money(a.amount)}
${a.date || '—'}
${a.reason || '—'}
${money(a.amountDeducted || 0)}
${money(rem > 0 ? rem : 0)}
${statusLabel}
${rem > 0 ? `+ Deduction ` : ''}
✏
${isOwner(USER) ? `🗑 ` : ''}
`;
}).join('');
const advPanel = `
${money(advTotalAmount)}
Total Advanced
${money(advTotalDeducted)}
Total Deducted
${money(advTotalRemaining > 0 ? advTotalRemaining : 0)}
Remaining Balance
${advTotalRemaining > 0 ? `
⚡ ${money(advTotalRemaining)} outstanding — will be deducted from future salaries.
`: ''}
Staff Name Amount Date Reason Deducted Remaining Status Action
${advRows || emptyRow(8, 'No advances recorded')}
`;
const typeFilters = ["All", "Import", "Local", "Other"].map(t => `${t} `).join("");
const mainTabs = [
{ k: 'register', l: '📒 Invoice Register' },
{ k: 'payables', l: '💳 Procurement Payables' },
{ k: 'advances', l: '💰 Staff Advances' },
{ k: 'ledger', l: '📚 Ledger by Account' },
{ k: 'iva', l: '🧾 Monthly VAT (IVA)' }
];
const mainTabBar = mainTabs.map(t => `${t.l} `).join('');
const registerTable = `
Date Invoice No Company Description Account Type Amount Mode Documents Action
${rows || emptyRow(10, "No entries match")}
${aData.html}`;
const payablesTable = `
PO/Indent No Item Value Supplier Status Action
${payableRows || emptyRow(6, "No outstanding payables")}
${pyData.html}`;
view().innerHTML = `
${tr("Accounts & Financials")}
${mainTabBar}
${accountsTab === 'register' ? `
${tr("+ New Entry")}
Bulk Import
`+ (isOwner(USER) ? '
⚠ Reset ' : '') : accountsTab === 'advances' ? `
+ New Advance
`: `
⚙ Custom Export
`}
${accountsTab === 'register' ? registerTable : accountsTab === 'payables' ? payablesTable : accountsTab === 'ledger' ? acctLedgerPanel() : accountsTab === 'iva' ? acctIvaPanel() : advPanel}
`;
if (accountsTab === 'register') {
const aIn = document.getElementById("acSearch");
if (aIn) {
aIn.oninput = () => { accountsSearch = aIn.value; render(); };
aIn.focus();
aIn.setSelectionRange(accountsSearch.length, accountsSearch.length);
}
}
}
function exportAccountsCSV(mode) {
const filtered = DB.accounts.filter(a => {
if (acctRepFrom && a.date < acctRepFrom) return false;
if (acctRepTo && a.date > acctRepTo) return false;
return true;
});
if (!filtered.length) return toast("No entries in selected range");
let csv;
if (mode === "monthly") {
// Group by month
const byMonth = {};
filtered.forEach(a => {
const mo = (a.date || "").slice(0, 7);
(byMonth[mo] = byMonth[mo] || []).push(a);
});
const rows = [["Month", "Entries", "Total Amount (Kz)", "Total VAT (Kz)", "Total Retention (Kz)", "Net Total (Kz)"]];
Object.keys(byMonth).sort().forEach(mo => {
const ents = byMonth[mo];
const totAmt = ents.reduce((s, a) => s + (+a.amount || 0), 0);
const totVat = ents.reduce((s, a) => s + (+a.vat || 0), 0);
const totRet = ents.reduce((s, a) => s + (+a.ret || 0), 0);
const netTotal = totAmt + (totVat) - (totRet);
rows.push([mo, ents.length, totAmt.toFixed(2), totVat.toFixed(2), totRet.toFixed(2), netTotal.toFixed(2)]);
});
csv = rows.map(r => r.map(csvEsc).join(",")).join("\n");
} else if (mode === "full") {
// Full ledger — all columns matching master accounts spreadsheet
const headers = ["S.No", "Date", "NIF", "Invoice No", "Company / Person", "Description",
"Amount (Kz)", "Retention (Kz)", "VAT (Kz)", "Total Amount (Kz)", "Mode of Payment",
"Purchase Type", "Status", "Comments"];
const rows = [headers];
filtered.forEach((a, i) => {
const amt = +(a.amount || 0);
const ret = +(a.ret || 0);
const vat = +(a.vat || 0);
const total = amt - ret + vat;
rows.push([i + 1, a.date || '', a.nif || '', a.inv || '',
a.party || '', a.head || '',
amt.toFixed(2), ret.toFixed(2), vat.toFixed(2), total.toFixed(2),
a.mode || '', a.purchaseType || 'Local', a.status || '', a.comments || ''
]);
});
const totAmt = filtered.reduce((s, a) => s + (+a.amount || 0), 0);
const totRet = filtered.reduce((s, a) => s + (+a.ret || 0), 0);
const totVat = filtered.reduce((s, a) => s + (+a.vat || 0), 0);
const grandTotal = totAmt - totRet + totVat;
rows.push(["TOTAL", "", "", "", "", "", totAmt.toFixed(2), totRet.toFixed(2), totVat.toFixed(2), grandTotal.toFixed(2), "", "", "", ""]);
csv = "\ufeff" + rows.map(r => r.map(csvEsc).join(",")).join("\n");
} else {
// Summary CSV — key columns
const rows = [["Date", "NIF", "Invoice No", "Company / Person", "Description",
"Amount (Kz)", "Retention (Kz)", "VAT (Kz)", "Total Amount (Kz)", "Mode of Payment", "Purchase Type", "Status", "Comments"]];
filtered.forEach(a => {
const amt = +(a.amount || 0);
const ret = +(a.ret || 0);
const vat = +(a.vat || 0);
const total = amt - ret + vat;
rows.push([a.date || '', a.nif || '', a.inv || '', a.party || '', a.head || '',
amt.toFixed(2), ret.toFixed(2), vat.toFixed(2), total.toFixed(2),
a.mode || '', a.purchaseType || 'Local', a.status || '', a.comments || ''
]);
});
const totAmt = filtered.reduce((s, a) => s + (+a.amount || 0), 0);
const totRet = filtered.reduce((s, a) => s + (+a.ret || 0), 0);
const totVat = filtered.reduce((s, a) => s + (+a.vat || 0), 0);
const grandTotal = totAmt - totRet + totVat;
rows.push(["TOTAL", "", "", "", "", totAmt.toFixed(2), totRet.toFixed(2), totVat.toFixed(2), grandTotal.toFixed(2), "", "", "", ""]);
csv = "\ufeff" + rows.map(r => r.map(csvEsc).join(",")).join("\n");
}
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `Accounts_${mode}_${acctRepFrom || 'all'}_to_${acctRepTo || todayISO()}.csv`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast("CSV downloaded (" + filtered.length + " entries)");
}
// ══ Angola Accounts: Plano de Contas, Razão, Balancete, IVA Mensal ══════════
function ensureAcctHeads() {
if (DB.acctHeads && DB.acctHeads.length) return;
DB.acctHeads = [
{ id: uid("ah"), code: "21", name: "Compra de Matéria-Prima / Sucata", type: "Custo", active: true },
{ id: uid("ah"), code: "7521", name: "Combustível e Lubrificantes", type: "Custo", active: true },
{ id: uid("ah"), code: "7522", name: "Transporte e Frete", type: "Custo", active: true },
{ id: uid("ah"), code: "7523", name: "Direitos Aduaneiros e Despachante", type: "Custo", active: true },
{ id: uid("ah"), code: "72", name: "Salários e Remunerações", type: "Custo", active: true },
{ id: uid("ah"), code: "7524", name: "Renda de Instalações", type: "Custo", active: true },
{ id: uid("ah"), code: "7525", name: "Água e Electricidade", type: "Custo", active: true },
{ id: uid("ah"), code: "7526", name: "Manutenção e Peças", type: "Custo", active: true },
{ id: uid("ah"), code: "11", name: "Equipamento e Meios Fixos", type: "Activo", active: true },
{ id: uid("ah"), code: "7527", name: "Serviços Profissionais", type: "Custo", active: true },
{ id: uid("ah"), code: "7528", name: "Despesas Bancárias", type: "Custo", active: true },
{ id: uid("ah"), code: "7529", name: "Comunicações e Internet", type: "Custo", active: true },
{ id: uid("ah"), code: "3451", name: "IVA Suportado", type: "Imposto", active: true },
{ id: uid("ah"), code: "3452", name: "Retenção na Fonte (IRT)", type: "Imposto", active: true },
{ id: uid("ah"), code: "45", name: "Caixa", type: "Banco/Caixa", active: true },
{ id: uid("ah"), code: "431", name: "BAI Bank", type: "Banco/Caixa", active: true },
{ id: uid("ah"), code: "432", name: "KEVE Bank", type: "Banco/Caixa", active: true },
{ id: uid("ah"), code: "76", name: "Outros Custos", type: "Custo", active: true }
];
save();
}
function activeHeads() { return (DB.acctHeads || []).filter(x => x.active !== false); }
function acctEntryTotal(a) { return (+a.amount || 0) - (+a.ret || 0) + (+a.vat || 0); }
function acctEffRate(a) {
if (a.ivaRate !== undefined && a.ivaRate !== null && a.ivaRate !== "") return String(a.ivaRate);
const amt = +a.amount || 0, vat = +a.vat || 0;
if (!vat || !amt) return "0";
const r = vat / amt;
if (Math.abs(r - 0.14) < 0.02) return "14";
if (Math.abs(r - 0.07) < 0.015) return "7";
if (Math.abs(r - 0.05) < 0.015) return "5";
return "14";
}
function formAcctHeads() {
const rows = (DB.acctHeads || []).map((x, i) => `
${["Custo","Proveito","Activo","Banco/Caixa","Imposto"].map(t => `${t} `).join('')}
`).join('');
openModal("Chart of Accounts (Plano de Contas)",
`Codes follow Angola's PGC. Untick Active to hide an account without deleting its history.
Code Account Name Type Active
${rows}
+ Add
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save Chart of Accounts", cls: "", fn: () => {
document.querySelectorAll('#ahRows [data-ah]').forEach(el => {
const i = +el.dataset.i, k = el.dataset.ah;
if (!DB.acctHeads[i]) return;
DB.acctHeads[i][k] = k === 'active' ? el.checked : el.value.trim();
});
save(); logAct("Chart of Accounts updated"); toast("Chart of Accounts saved"); closeModal(); render();
}
}]);
const addBtn = document.getElementById('ah-add-btn');
if (addBtn) addBtn.onclick = () => {
const code = val('ah-new-code').trim(), name = val('ah-new-name').trim();
if (!name) return toast("Enter the account name");
DB.acctHeads.push({ id: uid("ah"), code, name, type: "Custo", active: true });
save(); toast("Account added"); closeModal(); formAcctHeads();
};
}
function normPartyBase(s) {
const t = String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\b(sa|lda|limitada|unipessoal|su|s a|l d a)\b/g, ' ')
.replace(/\s+/g, ' ').trim();
return t || '(no name)';
}
let __pcCache = { sig: '', map: {} };
function __lev(a, b, maxD) {
if (Math.abs(a.length - b.length) > maxD) return maxD + 1;
let prev = []; for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i2 = 1; i2 <= a.length; i2++) {
const cur = [i2]; let rowMin = i2;
for (let j2 = 1; j2 <= b.length; j2++) {
cur[j2] = Math.min(prev[j2] + 1, cur[j2 - 1] + 1, prev[j2 - 1] + (a[i2 - 1] === b[j2 - 1] ? 0 : 1));
if (cur[j2] < rowMin) rowMin = cur[j2];
}
if (rowMin > maxD) return maxD + 1;
prev = cur;
}
return prev[b.length];
}
function normParty(s) {
const base = normPartyBase(s);
try {
const uniq = {};
((typeof DB !== 'undefined' && DB && DB.accounts) ? DB.accounts : []).forEach(a => { const k = normPartyBase(a.party); uniq[k] = (uniq[k] || 0) + 1; });
const keys = Object.keys(uniq);
const sig = keys.length + '|' + keys.join('~');
if (__pcCache.sig !== sig) {
const sorted = keys.slice().sort((x, y) => uniq[y] - uniq[x] || y.length - x.length);
const canon = {}; const accepted = [];
sorted.forEach(k => {
const ks = k.replace(/ /g, '');
let hit = '';
for (const c of accepted) {
const cs = c.replace(/ /g, '');
const shorter = ks.length <= cs.length ? ks : cs;
const longer = ks.length <= cs.length ? cs : ks;
if (shorter.length >= 5 && longer.indexOf(shorter) === 0) { hit = c; break; }
const L = Math.min(ks.length, cs.length);
const maxD = L >= 10 ? 2 : (L >= 5 ? 1 : 0);
if (!maxD) continue;
if (__lev(ks, cs, maxD) <= maxD) { hit = c; break; }
}
if (hit) canon[k] = canon[hit] || hit; else { accepted.push(k); canon[k] = k; }
});
__pcCache = { sig: sig, map: canon };
}
return __pcCache.map[base] || base;
} catch (e) { return base; }
}
function acctLedgerPanel() {
const inRange = a => (!acctRepFrom || a.date >= acctRepFrom) && (!acctRepTo || a.date <= acctRepTo);
const byParty = acctLedgerMode === 'party';
const keyOf = a => byParty ? normParty(a.party) : (a.acctHead || '(sem conta)');
let opts;
if (byParty) {
const disp = {};
DB.accounts.forEach(a => { const k = normParty(a.party); const p = a.party || '(no name)'; if (!disp[k] || p.length > disp[k].length) disp[k] = p; });
const keys = Object.keys(disp).sort((x, y) => disp[x].localeCompare(disp[y]));
opts = ['— Trial Balance (all companies) — ']
.concat(keys.map(k => `${escH(disp[k])} `)).join('');
} else {
opts = ['— Trial Balance (all accounts) — ']
.concat(activeHeads().map(x => `${escAttr((x.code ? x.code + ' — ' : '') + x.name)} `)).join('');
}
const modeBtns = `📚 By Account
🏢 By Company `;
const bar = ``;
if (!acctLedgerHead) {
const perKey = {};
DB.accounts.filter(inRange).forEach(a => {
const k = keyOf(a);
const o = perKey[k] = perKey[k] || { n: 0, base: 0, iva: 0, ret: 0, tot: 0, nif: '', disp: '' };
o.n++; o.base += (+a.amount || 0); o.iva += (+a.vat || 0); o.ret += (+a.ret || 0); o.tot += acctEntryTotal(a);
if (!o.nif && a.nif) o.nif = a.nif;
const p = byParty ? (a.party || '(no name)') : k;
if (p.length > o.disp.length) o.disp = p;
});
const keys = Object.keys(perKey).sort((x, y) => perKey[x].disp.localeCompare(perKey[y].disp));
const codeOf = n => { const x = (DB.acctHeads || []).find(y => y.name === n); return x && x.code ? x.code : ''; };
let g = { n: 0, base: 0, iva: 0, ret: 0, tot: 0 };
const trs = keys.map(k => {
const o = perKey[k]; g.n += o.n; g.base += o.base; g.iva += o.iva; g.ret += o.ret; g.tot += o.tot;
return `${byParty ? escH(o.nif || '—') : codeOf(k)}${escH(o.disp)}
${o.n} ${money(o.base)} ${money(o.iva)} ${money(o.ret)} ${money(o.tot)} `;
}).join('');
return bar + `Trial Balance ${byParty ? 'by Company' : 'by Account'} ${acctRepFrom || acctRepTo ? `(${acctRepFrom || '…'} → ${acctRepTo || '…'})` : '(all time)'} — click a ${byParty ? 'company' : 'account'} name to open its full ledger
${byParty ? 'NIF' : 'Code'} ${byParty ? 'Company / Supplier' : 'Account'} Docs Base (Kz) VAT (Kz) Withholding (Kz) Total (Kz)
${trs || emptyRow(7, 'No entries — add entries in the Invoice Register first')}
TOTAL ${g.n} ${money(g.base)} ${money(g.iva)} ${money(g.ret)} ${money(g.tot)}
`;
}
let ents = DB.accounts.filter(a => keyOf(a) === acctLedgerHead && inRange(a))
.slice().sort((x, y) => (x.date || '').localeCompare(y.date || ''));
// Get payment status for each entry
const getStatus = a => { const linked = (DB.proc || []).find(p => (p.inv || p.invoiceNo) === (a.inv || a.invoiceNo)); const hasProof = linked && Array.isArray(linked.paymentProof) && linked.paymentProof.length > 0; return a.status || (hasProof ? 'Paid' : 'Pending'); };
// When "Outstanding Only" is toggled, filter to show only pending invoices
const filteredEnts = acctShowOutstandingOnly ? ents.filter(a => getStatus(a) === 'Pending') : ents;
const dispName = byParty ? (ents.map(a => a.party || '').sort((x, y) => y.length - x.length)[0] || acctLedgerHead) : acctLedgerHead;
let bal = 0;
const trs = filteredEnts.map(a => {
const t = acctEntryTotal(a); bal += t;
const st = getStatus(a);
return `${a.date || ''} ${a.inv || '—'}${byParty ? escH(a.acctHead || '—') : escH(a.party || '')} ${escH(a.head || '')}
${money(a.amount)} ${money(a.vat || 0)} ${money(a.ret || 0)}
${money(t)} ${money(bal)} ${st} ${attThumbs(a.files)} ${isOwner(USER) ? `🗑 ` : ''} `;
}).join('');
const balSummary = acctShowOutstandingOnly ? `${filteredEnts.length} outstanding (${ents.length - filteredEnts.length} paid), balance ${money(bal)}` : `${ents.length} entries, balance ${money(bal)}`;
return bar + `Ledger — ${escH(dispName)} (${balSummary})
Date Doc No ${byParty ? 'Account' : 'Supplier'} Description Base IVA Ret. Total Balance Status Docs
${trs || emptyRow(11, 'No entries here')}
`;
}
window.exportLedgerXLSX = function () {
const inRange = a => (!acctRepFrom || a.date >= acctRepFrom) && (!acctRepTo || a.date <= acctRepTo);
const byParty = acctLedgerMode === 'party';
const keyOf = a => byParty ? normParty(a.party) : (a.acctHead || '(sem conta)');
const getStatus = a => { const linked = (DB.proc || []).find(p => (p.inv || p.invoiceNo) === (a.inv || a.invoiceNo)); const hasProof = linked && Array.isArray(linked.paymentProof) && linked.paymentProof.length > 0; return a.status || (hasProof ? 'Paid' : 'Pending'); };
const wb = XLSX.utils.book_new();
if (acctLedgerHead) {
let ents = DB.accounts.filter(a => keyOf(a) === acctLedgerHead && inRange(a)).slice().sort((x, y) => (x.date || '').localeCompare(y.date || ''));
const dispName = byParty ? (ents.map(a => a.party || '').sort((x, y) => y.length - x.length)[0] || acctLedgerHead) : acctLedgerHead;
// Separate paid and outstanding invoices
const paid = ents.filter(a => getStatus(a) === 'Paid');
const pending = ents.filter(a => getStatus(a) === 'Pending');
// Calculate totals for each category
const paidTotals = { base: 0, iva: 0, ret: 0, tot: 0 };
const pendingTotals = { base: 0, iva: 0, ret: 0, tot: 0 };
paid.forEach(a => { const t = acctEntryTotal(a); paidTotals.base += +a.amount || 0; paidTotals.iva += +a.vat || 0; paidTotals.ret += +a.ret || 0; paidTotals.tot += t; });
pending.forEach(a => { const t = acctEntryTotal(a); pendingTotals.base += +a.amount || 0; pendingTotals.iva += +a.vat || 0; pendingTotals.ret += +a.ret || 0; pendingTotals.tot += t; });
const rows = [
[byParty ? "Company Ledger:" : "Ledger by Account:", dispName],
["Period:", (acctRepFrom || 'start') + ' a ' + (acctRepTo || 'today')],
[],
["OUTSTANDING / UNPAID INVOICES"],
["Date", "Doc No", byParty ? "Account" : "Supplier", "NIF", "Description", "Base (Kz)", "VAT (Kz)", "Withholding (Kz)", "Total (Kz)"]
];
pending.forEach(a => { const t = acctEntryTotal(a); rows.push([a.date || '', a.inv || '', byParty ? (a.acctHead || '') : (a.party || ''), a.nif || '', a.head || '', +a.amount || 0, +a.vat || 0, +a.ret || 0, t]); });
rows.push(["SUBTOTAL - OUTSTANDING", "", "", "", "", pendingTotals.base, pendingTotals.iva, pendingTotals.ret, pendingTotals.tot]);
rows.push([]);
rows.push(["PAID / CLEARED INVOICES"]);
rows.push(["Date", "Doc No", byParty ? "Account" : "Supplier", "NIF", "Description", "Base (Kz)", "VAT (Kz)", "Withholding (Kz)", "Total (Kz)"]);
paid.forEach(a => { const t = acctEntryTotal(a); rows.push([a.date || '', a.inv || '', byParty ? (a.acctHead || '') : (a.party || ''), a.nif || '', a.head || '', +a.amount || 0, +a.vat || 0, +a.ret || 0, t]); });
rows.push(["SUBTOTAL - PAID", "", "", "", "", paidTotals.base, paidTotals.iva, paidTotals.ret, paidTotals.tot]);
rows.push([]);
rows.push(["GRAND TOTAL (All)", "", "", "", "", paidTotals.base + pendingTotals.base, paidTotals.iva + pendingTotals.iva, paidTotals.ret + pendingTotals.ret, paidTotals.tot + pendingTotals.tot]);
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), "Ledger");
XLSX.writeFile(wb, `Ledger_${dispName.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 30)}.xlsx`);
} else {
const perKey = {};
DB.accounts.filter(inRange).forEach(a => { const k = keyOf(a); const o = perKey[k] = perKey[k] || { n: 0, base: 0, iva: 0, ret: 0, tot: 0, nif: '', disp: '' }; o.n++; o.base += (+a.amount || 0); o.iva += (+a.vat || 0); o.ret += (+a.ret || 0); o.tot += acctEntryTotal(a); if (!o.nif && a.nif) o.nif = a.nif; const p = byParty ? (a.party || '(no name)') : k; if (p.length > o.disp.length) o.disp = p; });
const rows = [[byParty ? "Trial Balance by Company" : "Trial Balance by Account"], ["Period:", (acctRepFrom || 'start') + ' a ' + (acctRepTo || 'today')], [], [byParty ? "NIF" : "Code", byParty ? "Company" : "Account", "No. Docs", "Base (Kz)", "VAT (Kz)", "Withholding (Kz)", "Total (Kz)"]];
Object.keys(perKey).sort((x, y) => perKey[x].disp.localeCompare(perKey[y].disp)).forEach(k => { const x = (DB.acctHeads || []).find(y => y.name === k); const o = perKey[k]; rows.push([byParty ? o.nif : (x && x.code || ''), o.disp, o.n, o.base, o.iva, o.ret, o.tot]); });
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), "TrialBalance");
XLSX.writeFile(wb, `TrialBalance_${byParty ? 'Companies' : 'Accounts'}_${(acctRepFrom || 'inicio')}_${(acctRepTo || todayISO())}.xlsx`);
}
toast("Excel exported");
};
window.batchCorrectAccountStatus = function () {
if (!isOwner(USER)) return toast('Owner access required');
const updated = [], unchanged = [];
(DB.accounts || []).forEach(a => {
const linked = (DB.proc || []).find(p => (p.inv || p.invoiceNo) === (a.inv || a.invoiceNo));
const hasProof = linked && Array.isArray(linked.paymentProof) && linked.paymentProof.length > 0;
const newStatus = hasProof ? 'Paid' : 'Pending';
if ((a.status || '') !== newStatus) {
a.status = newStatus;
updated.push({ inv: a.inv, newStatus });
} else {
unchanged.push(a.inv);
}
});
if (updated.length === 0) return toast('All invoices already synced with Procurement proof status');
save();
logAct('Batch corrected Accounts payment status', { updated: updated.length, unchanged: unchanged.length, details: updated.slice(0, 10) });
toast(`✓ Updated ${updated.length} invoices. Refreshing...`);
setTimeout(() => render(), 600);
};
const REFRESH_BTN_LABEL = '⟳ Refresh All Data';
function setRefreshBtn(label, disabled) {
// Re-query every time: render() may have replaced the button element
const btn = document.querySelector('[onclick*="refreshAllData"]');
if (btn) { btn.disabled = !!disabled; btn.textContent = label; }
}
window.refreshAllData = async function () {
if (!USER) return toast('Please log in first');
if (window._refreshInFlight) return;
window._refreshInFlight = true;
setRefreshBtn('⟳ Refreshing...', true);
// Clear all caches
try { localStorage.removeItem('sd_db_cache'); } catch (e) { }
try { sessionStorage.removeItem('sd_db_cache'); } catch (e) { }
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({ action: 'CLEAR_CACHE' });
}
let ok = false;
try {
DB_LOADING = true;
if (typeof window.refetchAllCollections !== 'function') throw new Error('Data loader not ready — please reload the page');
await window.refetchAllCollections();
ok = true;
const timestamp = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
window._lastDataRefresh = timestamp;
toast(`✓ Data refreshed at ${timestamp}`);
logAct('Manual data refresh', { timestamp });
} catch (e) {
toast(`Error refreshing data: ${e.message}`);
console.error('Refresh failed:', e);
} finally {
DB_LOADING = false;
window._refreshInFlight = false;
render();
// Show the result on the button, then return to the normal label
setRefreshBtn(ok ? '✓ Refreshed' : '⚠ Refresh failed', false);
setTimeout(() => setRefreshBtn(REFRESH_BTN_LABEL, false), 3000);
}
};
window.exportLedgerPDF = function () {
const inRange = a => (!acctRepFrom || a.date >= acctRepFrom) && (!acctRepTo || a.date <= acctRepTo);
const byParty = acctLedgerMode === 'party';
const keyOf = a => byParty ? normParty(a.party) : (a.acctHead || '(sem conta)');
const period = (acctRepFrom || 'start') + ' → ' + (acctRepTo || todayISO());
let title, headRow, bodyRows, footRow = '';
if (acctLedgerHead) {
const ents = DB.accounts.filter(a => keyOf(a) === acctLedgerHead && inRange(a)).slice().sort((x, y) => (x.date || '').localeCompare(y.date || ''));
const dispName = byParty ? (ents.map(a => a.party || '').sort((x, y) => y.length - x.length)[0] || acctLedgerHead) : acctLedgerHead;
const nif = byParty ? (ents.map(a => a.nif || '').find(n => n) || '') : '';
title = (byParty ? 'COMPANY LEDGER / RAZÃO — ' : 'LEDGER BY ACCOUNT — ') + escH(dispName) + (nif ? ' · NIF ' + escH(nif) : '');
headRow = `Date Doc No ${byParty ? 'Account' : 'Supplier'} Description Base (Kz) IVA (Kz) Ret. (Kz) Total (Kz) Balance (Kz) `;
let bal = 0, sb = 0, si = 0, sr = 0, st = 0;
bodyRows = ents.map(a => { const t = acctEntryTotal(a); bal += t; sb += (+a.amount || 0); si += (+a.vat || 0); sr += (+a.ret || 0); st += t;
return `${a.date || ''} ${escH(a.inv || '—')} ${escH(byParty ? (a.acctHead || '—') : (a.party || ''))} ${escH(a.head || '')} ${money(a.amount)} ${money(a.vat || 0)} ${money(a.ret || 0)} ${money(t)} ${money(bal)} `; }).join('');
footRow = `TOTAL (${ents.length} entries) ${money(sb)} ${money(si)} ${money(sr)} ${money(st)} ${money(bal)} `;
} else {
const perKey = {};
DB.accounts.filter(inRange).forEach(a => { const k = keyOf(a); const o = perKey[k] = perKey[k] || { n: 0, base: 0, iva: 0, ret: 0, tot: 0, nif: '', disp: '' }; o.n++; o.base += (+a.amount || 0); o.iva += (+a.vat || 0); o.ret += (+a.ret || 0); o.tot += acctEntryTotal(a); if (!o.nif && a.nif) o.nif = a.nif; const p = byParty ? (a.party || '(no name)') : k; if (p.length > o.disp.length) o.disp = p; });
const codeOf = n => { const x = (DB.acctHeads || []).find(y => y.name === n); return x && x.code ? x.code : ''; };
title = byParty ? 'TRIAL BALANCE BY COMPANY / BALANCETE POR EMPRESA' : 'TRIAL BALANCE BY ACCOUNT / BALANCETE POR CONTA';
headRow = `${byParty ? 'NIF' : 'Code'} ${byParty ? 'Company / Supplier' : 'Account'} Docs Base (Kz) IVA (Kz) Ret. (Kz) Total (Kz) `;
let g = { n: 0, base: 0, iva: 0, ret: 0, tot: 0 };
bodyRows = Object.keys(perKey).sort((x, y) => perKey[x].disp.localeCompare(perKey[y].disp)).map(k => { const o = perKey[k]; g.n += o.n; g.base += o.base; g.iva += o.iva; g.ret += o.ret; g.tot += o.tot;
return `${escH(byParty ? (o.nif || '—') : codeOf(k))} ${escH(o.disp)} ${o.n} ${money(o.base)} ${money(o.iva)} ${money(o.ret)} ${money(o.tot)} `; }).join('');
footRow = `TOTAL ${g.n} ${money(g.base)} ${money(g.iva)} ${money(g.ret)} ${money(g.tot)} `;
}
if (!bodyRows) { toast("No entries to export"); return; }
const w = window.open('', '_blank');
if (!w) { toast("Allow pop-ups to download the PDF"); return; }
w.document.write(`Ledger — ${escH(period)}
MEGACORP INDUSTRIA (SU), LDA
Factory Address: ${escH(getFactoryAddress())} · NIF: ${escH(getCompanyNif())} · Email: ${escH(getCompanyEmail())}
${title}
Period / Período: ${escH(period)}
Generated / Gerado: ${todayISO()} by ${escH(USER && USER.name || '')}
${headRow} ${bodyRows}${footRow}
🖶 Print / Save as PDF
setTimeout(()=>window.print(),400);
`);
w.document.close();
toast("PDF ready — choose 'Save as PDF' in the print dialog");
};
/* ══════════════════════════════════════════════════════════════════════
MONTH-END DEPARTMENT SUMMARY
Card appears on the Dashboard from the LAST day of the month at 18:00
until the end of the 2nd day of the next month (~54 h). Each login only
sees the departments / modules they have access to. Owners also get a
small "preview" link the rest of the month.
══════════════════════════════════════════════════════════════════════ */
function monthEndWindow(now) {
now = now || new Date();
const y = now.getFullYear(), m = now.getMonth(), d = now.getDate(), h = now.getHours();
const lastDay = new Date(y, m + 1, 0).getDate();
// Last day of this month, from 18:00 → summary is for THIS month
if (d === lastDay && h >= 18) return { open: true, y, m };
// 1st and 2nd of the month (whole day) → summary is for PREVIOUS month
if (d <= 2) { const pm = new Date(y, m - 1, 1); return { open: true, y: pm.getFullYear(), m: pm.getMonth() }; }
const pm = new Date(y, m - 1, 1);
return { open: false, y: pm.getFullYear(), m: pm.getMonth() };
}
function monthKey(y, m) { return y + '-' + String(m + 1).padStart(2, '0'); }
function monthEndVisibleDepts() {
// Only departments this login is allowed to see (owners: all)
return DEPTS.filter(dp => deptOK(USER, dp));
}
function monthEndCard() {
if (!USER) return '';
const w = monthEndWindow();
const label = MONTHS[w.m] + ' ' + w.y;
if (w.open) {
return `
📊
Month-End Summary — ${label}
Department-wise totals for the month${isOwner(USER) ? ' (all departments)' : ' (your departments only)'}. Available until the 2nd of the month.
⬇ Excel — all my departments
🖨 Print / PDF
`;
}
if (isOwner(USER)) {
return ``;
}
return '';
}
// Build the per-department data for a month. Respects the login's access.
function buildMonthEndData(y, m) {
const mk = monthKey(y, m), inMonth = ds => String(ds || '').slice(0, 7) === mk;
const seeMoney = can.seeMoney(USER), seeHR = isOwner(USER) || (USER.modules || []).includes('hr');
const seeDeduct = can.seeDeduct(USER), mods = k => isOwner(USER) || (USER.modules || []).includes(k);
const daysInMonth = new Date(y, m + 1, 0).getDate();
const disciplineToDept = { civil: 'Civil & Structural', structural: 'Civil & Structural', mechanical: 'Mechanical Erection', mec: 'Mechanical Erection', electrical: 'Electrical & Instrumentation', 'e&i': 'Electrical & Instrumentation', instrumentation: 'Electrical & Instrumentation' };
const out = [];
monthEndVisibleDepts().forEach(dept => {
const D = { dept, sections: [] };
// ── Indents (Procurement) ──
if (mods('procurement')) {
const ind = (DB.proc || []).filter(p => p.dept === dept && inMonth(p.at || p.date || p.createdAt));
if (ind.length) {
const byStatus = {}; ind.forEach(p => { byStatus[p.status || '—'] = (byStatus[p.status || '—'] || 0) + 1; });
const total = ind.reduce((s, p) => s + (+p.value || 0), 0);
D.sections.push({ title: 'Indents raised this month', kpis: [['Indents', ind.length], ...(seeMoney ? [['Total value', money(total)]] : []), ...Object.entries(byStatus).map(([k, v]) => [k, v])],
head: ['Indent No', 'Date', 'Item', 'Qty', 'Unit', 'Supplier', ...(seeMoney ? ['Value (Kz)'] : []), 'Status', 'Raised by'],
rows: ind.map(p => [p.no || '', String(p.at || p.date || '').slice(0, 10), p.desc || '', p.qty || '', p.unit || '', p.supplier || '', ...(seeMoney ? [+p.value || 0] : []), p.status || '', p.by || '']) });
}
}
// ── Staff & attendance ──
if (seeHR) {
const emps = (DB.employees || []).filter(e => e.dept === dept);
if (emps.length) {
const rows = emps.map(e => {
let pres = 0, abs = 0, ot = 0;
for (let d = 1; d <= daysInMonth; d++) {
const ds = mk + '-' + String(d).padStart(2, '0'); const rec = (DB.attendance[ds] || {})[e.id];
const st = getAttStatusStr(rec); if (st === 'present') { pres++; if (rec && rec.ot) ot += +rec.ot || 0; } else if (st === 'absent') abs++;
}
return [e.name || '', (e.role || '').split(' / ').pop(), e.staffType || '', pres, abs, ot];
});
const tp = rows.reduce((s, r) => s + r[3], 0), ta = rows.reduce((s, r) => s + r[4], 0);
D.sections.push({ title: 'Staff & attendance', kpis: [['Staff', emps.length], ['Present days', tp], ['Absent days', ta]], head: ['Name', 'Role', 'Type', 'Present', 'Absent', 'OT hrs'], rows });
if (seeDeduct) {
const ids = new Set(emps.map(e => e.id));
const ded = (DB.deduct || []).filter(x => ids.has(x.staffId) && String(x.month || '').slice(0, 7) === mk);
if (ded.length) D.sections.push({ title: 'Salary deductions', kpis: [['Deductions', ded.length], ['Amount', money(ded.reduce((s, x) => s + (+x.amount || 0), 0))]], head: ['Staff', 'Reason', 'Amount (Kz)', 'Status'], rows: ded.map(x => [x.staffName || '', x.reason || '', +x.amount || 0, x.status || '']) });
}
}
}
// ── Erection & Civil daily work ──
if (mods('progress')) {
const logs = (DB.erectionDailyLog || []).filter(l => inMonth(l.date) && (disciplineToDept[String(l.discipline || '').toLowerCase().trim()] === dept || (!disciplineToDept[String(l.discipline || '').toLowerCase().trim()] && dept === 'Project Management')));
if (logs.length) {
const byAct = {}; logs.forEach(l => { const k = (l.activity || '—') + '|' + (l.uom || ''); const o = byAct[k] = byAct[k] || { qty: 0, mp: 0, days: 0 }; o.qty += +l.qtyDone || 0; o.mp += +l.manpower || 0; o.days++; });
D.sections.push({ title: 'Erection & Civil — work done', kpis: [['Log entries', logs.length], ['Man-days', logs.reduce((s, l) => s + (+l.manpower || 0), 0)]], head: ['Activity', 'Qty done', 'UoM', 'Man-days', 'Days logged'], rows: Object.entries(byAct).map(([k, o]) => { const [a, u] = k.split('|'); return [a, o.qty, u, o.mp, o.days]; }) });
}
}
// ── Vehicles & plant ──
if (dept === 'Plant, Vehicle & Equipment' && mods('vehicles')) {
const rows = []; let cost = 0;
(DB.vehicles || []).forEach(v => (v.logs || []).forEach(l => { if (inMonth(l.date)) { rows.push([v.name || '', v.reg || '', l.date || '', l.work || l.desc || '', ...(seeMoney ? [+l.cost || 0] : [])]); cost += +l.cost || 0; } }));
if (rows.length) D.sections.push({ title: 'Vehicle & plant maintenance', kpis: [['Service entries', rows.length], ...(seeMoney ? [['Cost', money(cost)]] : [])], head: ['Vehicle', 'Reg', 'Date', 'Work done', ...(seeMoney ? ['Cost (Kz)'] : [])], rows });
}
// ── Stores ──
if (dept === 'Stores & Site Materials' && mods('stores')) {
const rows = []; let tin = 0, tout = 0;
(DB.items || []).forEach(it => (it.stockLogs || []).forEach(l => { if (inMonth(l.date || l.at)) { const q = +l.qty || 0; const dir = String(l.type || l.dir || '').toLowerCase().includes('out') || q < 0 ? 'Out' : 'In'; rows.push([it.code || '', it.name || '', String(l.date || l.at || '').slice(0, 10), dir, Math.abs(q), it.unit || '', l.to || l.by || l.note || '']); if (dir === 'In') tin += Math.abs(q); else tout += Math.abs(q); } }));
if (rows.length) D.sections.push({ title: 'Stock movement', kpis: [['Movements', rows.length], ['Received', tin], ['Issued', tout]], head: ['Code', 'Item', 'Date', 'In/Out', 'Qty', 'Unit', 'To / By'], rows });
}
// ── Accounts ──
if (dept === 'Accounts & Finance' && mods('accounts') && seeMoney) {
const acc = (DB.accounts || []).filter(a => inMonth(a.date));
if (acc.length) {
const base = acc.reduce((s, a) => s + (+a.amount || 0), 0), iva = acc.reduce((s, a) => s + (+a.vat || 0), 0), tot = acc.reduce((s, a) => s + acctEntryTotal(a), 0);
const paid = acc.filter(a => a.status === 'Paid').length;
D.sections.push({ title: 'Invoices & payments', kpis: [['Invoices', acc.length], ['Paid', paid], ['Pending', acc.length - paid], ['Base', money(base)], ['IVA', money(iva)], ['Total', money(tot)]],
head: ['Date', 'Invoice', 'Company', 'NIF', 'Description', 'Base (Kz)', 'IVA (Kz)', 'Total (Kz)', 'Status'],
rows: acc.slice().sort((x, y) => String(x.date || '').localeCompare(String(y.date || ''))).map(a => [String(a.date || '').slice(0, 10), a.inv || a.invoiceNo || '', a.party || a.vendor || '', a.nif || '', a.head || a.description || '', +a.amount || 0, +a.vat || 0, acctEntryTotal(a), a.status || '']) });
}
}
out.push(D);
});
return out;
}
window.exportMonthEndXLSX = function (y, m) {
const data = buildMonthEndData(y, m), label = MONTHS[m] + ' ' + y;
if (typeof XLSX === 'undefined') return toast('Excel library not loaded — please refresh');
const wb = XLSX.utils.book_new();
const summary = [['MONTH-END DEPARTMENT SUMMARY — ' + label], ['Generated', todayISO(), 'by', USER.displayName || USER.username], [], ['Department', 'Section', 'Key figures']];
data.forEach(D => { if (!D.sections.length) summary.push([D.dept, '—', 'No activity this month']); D.sections.forEach(sec => summary.push([D.dept, sec.title, sec.kpis.map(k => k[0] + ': ' + k[1]).join(' | ')])); });
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), 'Summary');
const used = new Set(['Summary']);
data.forEach(D => {
if (!D.sections.length) return;
const rows = [[D.dept + ' — ' + label], []];
D.sections.forEach(sec => { rows.push([sec.title.toUpperCase()]); rows.push(sec.kpis.map(k => k[0] + ': ' + k[1])); rows.push(sec.head); sec.rows.forEach(r => rows.push(r)); rows.push([]); });
let name = D.dept.replace(/[\\/?*\[\]:]/g, ' ').slice(0, 31); let i = 2; while (used.has(name)) name = name.slice(0, 28) + ' ' + (i++); used.add(name);
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), name);
});
XLSX.writeFile(wb, 'MonthEnd_' + monthKey(y, m) + '.xlsx');
logAct('Month-end summary (Excel) downloaded — ' + label);
toast('Month-end Excel downloaded');
};
window.printMonthEnd = function (y, m) {
const data = buildMonthEndData(y, m), label = MONTHS[m] + ' ' + y;
const w = window.open('', '_blank'); if (!w) return toast('Allow pop-ups to print');
const sec = s => `${escH(s.title)} ${s.kpis.map(k => `${escH(String(k[1]))} ${escH(k[0])} `).join('')}
${s.head.map(h => `${escH(h)} `).join('')} ${s.rows.map(r => `${r.map(c => `${typeof c === 'number' ? c.toLocaleString('en-IN') : escH(String(c))} `).join('')} `).join('')}
`;
const body = data.map(D => `MEGACORP INDUSTRIA (SU), LDA
NIF: ${escH(getCompanyNif())} · ${escH(getFactoryAddress())}
MONTH-END SUMMARY — ${escH(label)} Department: ${escH(D.dept)}
Generated ${todayISO()} by ${escH(USER.displayName || USER.username || '')}
${D.sections.length ? D.sections.map(sec).join('') : 'No activity recorded for this department this month.
'} `).join('');
w.document.write(`Month-End Summary — ${escH(label)} ${body}
🖶 Print / Save as PDF
setTimeout(()=>window.print(),500); `);
w.document.close();
logAct('Month-end summary (PDF) printed — ' + label);
};
function acctIvaPanel() {
const mo = acctIvaMonth;
const ents = DB.accounts.filter(a => (a.date || '').slice(0, 7) === mo);
const byRate = {};
ents.forEach(a => { const r = acctEffRate(a); const o = byRate[r] = byRate[r] || { n: 0, base: 0, iva: 0 }; o.n++; o.base += (+a.amount || 0); o.iva += (+a.vat || 0); });
const totIva = ents.reduce((s, a) => s + (+a.vat || 0), 0);
const totRet = ents.reduce((s, a) => s + (+a.ret || 0), 0);
const nDocs = ents.reduce((s, a) => s + ((a.files || []).length + (a.payFiles || []).length), 0);
const rateRows = ["14", "7", "5", "0"].filter(r => byRate[r]).map(r => {
const o = byRate[r];
return `${r === '0' ? 'Exempt / 0%' : r + '%'} ${o.n} ${money(o.base)} ${money(o.iva)} `;
}).join('');
return `
${money(totIva)}
Input VAT (IVA)
${money(totRet)}
Withholdings
Rate Invoices Taxable Base (Kz) VAT (Kz)
${rateRows || emptyRow(4, 'No invoices this month')}
💡 The ZIP contains: Excel (Registo — serial-numbered, date-wise + Resumo IVA, Anexo Fornecedores, Pagamentos), a Dossier PDF with every invoice and payment proof stamped with the same Nº / date / supplier header, plus the individual files renamed 001_dd-mm-yyyy_Supplier_Factura. Ready for the AGT VAT refund claim.
`;
}
// Helpers shared by the IVA Excel + Dossier PDF
const ivaSerial = i => String(i + 1).padStart(3, '0');
const ivaDateDMY = d => d && /^\d{4}-\d{2}-\d{2}/.test(d) ? d.slice(8, 10) + '-' + d.slice(5, 7) + '-' + d.slice(0, 4) : (d || '');
const ivaDesc = a => a.desc || a.description || a.head || a.acctHead || '';
function buildIvaWorkbook(mo) {
// Date-wise order; same order drives the serial numbers in Excel, file names and the Dossier PDF
const ents = DB.accounts.filter(a => (a.date || '').slice(0, 7) === mo).slice().sort((x, y) => (x.date || '').localeCompare(y.date || '') || (x.inv || '').localeCompare(y.inv || ''));
const wb = XLSX.utils.book_new();
// ── Sheet 1: Registo — serial-numbered, date-wise register ──
const reg = [["MEGACORP INDÚSTRIA (SU), LDA"], ["NIF:", getCompanyNif()], ["Período:", mo], ["Registo de Facturas de Compra — ordenado por data"], [],
["Nº", "Data", "Nº Factura", "Fornecedor", "NIF Fornecedor", "Descrição", "Base (Kz)", "Taxa IVA", "IVA (Kz)", "Retenção (Kz)", "Total (Kz)", "Meio de Pagamento", "Ref. Pagamento", "Factura anexa", "Comprovativo anexo"]];
let tBase = 0, tIva = 0, tRet = 0, tTot = 0;
ents.forEach((a, i) => {
const base = +a.amount || 0, iva = +a.vat || 0, ret = +a.ret || 0, tot = acctEntryTotal(a);
tBase += base; tIva += iva; tRet += ret; tTot += tot;
reg.push([ivaSerial(i), ivaDateDMY(a.date), a.inv || '', a.party || '', a.nif || '', ivaDesc(a), base, acctEffRate(a) + '%', iva, ret, tot, a.mode || '', a.payRef || '', (a.files || []).length ? 'Sim' : 'NÃO', (a.payFiles || []).length ? 'Sim' : 'NÃO']);
});
reg.push([]);
reg.push(["", "", "", "TOTAL", "", ents.length + " facturas", tBase, "", tIva, tRet, tTot, "", "", "", ""]);
const wsReg = XLSX.utils.aoa_to_sheet(reg);
wsReg['!cols'] = [4, 11, 16, 28, 14, 30, 14, 8, 14, 13, 15, 16, 16, 12, 16].map(w => ({ wch: w }));
wsReg['!freeze'] = { xSplit: 0, ySplit: 6 };
// number format for money columns (G, I, J, K) from row 7 down
for (let r = 6; r < reg.length; r++) ['G', 'I', 'J', 'K'].forEach(c => { const cell = wsReg[c + (r + 1)]; if (cell && typeof cell.v === 'number') cell.z = '#,##0'; });
XLSX.utils.book_append_sheet(wb, wsReg, "Registo");
const byRate = {};
ents.forEach(a => { const r = acctEffRate(a); const o = byRate[r] = byRate[r] || { n: 0, base: 0, iva: 0 }; o.n++; o.base += (+a.amount || 0); o.iva += (+a.vat || 0); });
const resumo = [["MEGACORP INDÚSTRIA (SU), LDA"], ["NIF:", getCompanyNif()], ["Período:", mo], ["Declaração Periódica do IVA — Resumo (compras)"], [],
["Taxa", "Nº Facturas", "Base Tributável (Kz)", "IVA Suportado (Kz)"]];
["14", "7", "5", "0"].forEach(r => { if (byRate[r]) resumo.push([r === '0' ? 'Isento/0%' : r + '%', byRate[r].n, byRate[r].base, byRate[r].iva]); });
resumo.push([]);
resumo.push(["TOTAL", ents.length, ents.reduce((s, a) => s + (+a.amount || 0), 0), ents.reduce((s, a) => s + (+a.vat || 0), 0)]);
resumo.push(["Total Retenções na Fonte (Kz)", "", "", ents.reduce((s, a) => s + (+a.ret || 0), 0)]);
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(resumo), "Resumo IVA");
const forn = [["Nº", "NIF Fornecedor", "Nome do Fornecedor", "Tipo Doc", "Nº Factura", "Data", "Base Tributável (Kz)", "Taxa IVA", "IVA Suportado (Kz)", "Retenção (Kz)", "Total (Kz)", "Meio de Pagamento", "Ref. Pagamento"]];
ents.forEach((a, i) => forn.push([ivaSerial(i), a.nif || '', a.party || '', a.docType || 'Factura', a.inv || '', ivaDateDMY(a.date), +a.amount || 0, acctEffRate(a) + '%', +a.vat || 0, +a.ret || 0, acctEntryTotal(a), a.mode || '', a.payRef || '']));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(forn), "Anexo Fornecedores");
const pag = [["Nº Factura", "Fornecedor", "Data", "Meio de Pagamento", "Ref. Pagamento", "Ficheiros Factura", "Ficheiros Comprovativo"]];
ents.forEach(a => pag.push([a.inv || '', a.party || '', a.date || '', a.mode || '', a.payRef || '', (a.files || []).map(f => f.name || 'doc').join('; '), (a.payFiles || []).map(f => f.name || 'doc').join('; ')]));
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(pag), "Pagamentos");
return { wb, ents };
}
window.exportIvaPack = function () {
const { wb } = buildIvaWorkbook(acctIvaMonth);
XLSX.writeFile(wb, `IVA_${getCompanyNif()}_${acctIvaMonth}.xlsx`);
toast("Excel IVA exportado");
};
async function acctFetchFile(f) {
let src = (typeof f === 'string') ? f : (f.data || f.url || '');
if (!src) return null;
if (src.startsWith('data:')) {
const m = /^data:([^;]+);base64,(.*)$/.exec(src);
if (!m) return null;
const b = atob(m[2]); const u8 = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) u8[i] = b.charCodeAt(i);
return new Blob([u8], { type: m[1] });
}
if (src.startsWith('/')) src = location.origin + src;
try { const r = await fetch(src); if (!r.ok) return null; return await r.blob(); } catch (e) { return null; }
}
// ── IVA Dossier PDF: every invoice / payment-proof page gets a serial-numbered header strip ──
const STRIP_H = 64; // header strip height (pt)
const pdfSafe = s => String(s || '').replace(/[^\x20-\x7E\xA0-\xFF]/g, '?'); // Helvetica = WinAnsi only
function ivaStripLines(a, i, kind) {
const n = v => Number(v || 0).toLocaleString('en-IN');
return [
`No ${ivaSerial(i)} | ${ivaDateDMY(a.date)} | ${a.party || '-'}${a.nif ? ' (NIF ' + a.nif + ')' : ''}`,
`Factura ${a.inv || '-'} | Base ${n(a.amount)} | IVA ${acctEffRate(a)}% = ${n(a.vat)} | Ret. ${n(a.ret)} | Total ${n(acctEntryTotal(a))} Kz`,
`${a.mode ? 'Pago por ' + a.mode : 'Pagamento: -'}${a.payRef ? ' | Ref ' + a.payRef : ''}${ivaDesc(a) ? ' | ' + ivaDesc(a) : ''}`
].map(pdfSafe);
}
function drawStrip(page, font, fontB, a, i, kind, pageNo, pageCount) {
const { rgb } = PDFLib;
const { width, height } = page.getSize();
page.drawRectangle({ x: 0, y: height - STRIP_H, width, height: STRIP_H, color: rgb(0.97, 0.97, 0.97) });
page.drawLine({ start: { x: 0, y: height - STRIP_H }, end: { x: width, y: height - STRIP_H }, thickness: 1.2, color: rgb(0.15, 0.15, 0.15) });
const lines = ivaStripLines(a, i, kind);
page.drawText(lines[0], { x: 14, y: height - 18, size: 10.5, font: fontB, color: rgb(0, 0, 0) });
page.drawText(lines[1], { x: 14, y: height - 33, size: 8.5, font, color: rgb(0.1, 0.1, 0.1) });
page.drawText(lines[2].slice(0, 150), { x: 14, y: height - 47, size: 8, font, color: rgb(0.3, 0.3, 0.3) });
// Right-side badge: FACTURA / COMPROVATIVO + page x/y
const tag = kind === 'Facturas' ? 'FACTURA' : 'COMPROVATIVO';
const tw = fontB.widthOfTextAtSize(tag, 9);
page.drawRectangle({ x: width - tw - 30, y: height - 26, width: tw + 16, height: 16, color: kind === 'Facturas' ? rgb(0.11, 0.37, 0.75) : rgb(0.09, 0.55, 0.32) });
page.drawText(tag, { x: width - tw - 22, y: height - 21.5, size: 9, font: fontB, color: rgb(1, 1, 1) });
const pg = `pag. ${pageNo}/${pageCount}`;
page.drawText(pg, { x: width - font.widthOfTextAtSize(pg, 7.5) - 14, y: height - 42, size: 7.5, font, color: rgb(0.4, 0.4, 0.4) });
}
/** Appends one attachment (PDF or JPG/PNG) to `out` with the strip on every page. Returns pages added. */
async function dossierAppend(out, font, fontB, blob, a, i, kind) {
const { PageSizes } = PDFLib;
const bytes = await blob.arrayBuffer();
const t = (blob.type || '').toLowerCase();
const isPdf = t.includes('pdf') || (bytes.byteLength > 4 && String.fromCharCode(...new Uint8Array(bytes.slice(0, 4))) === '%PDF');
if (isPdf) {
const src = await PDFLib.PDFDocument.load(bytes, { ignoreEncryption: true });
const embedded = await out.embedPdf(src, src.getPageIndices());
embedded.forEach((ep, k) => {
const w = ep.width, h = ep.height;
const page = out.addPage([w, h]);
const scale = (h - STRIP_H) / h; // shrink so the strip fits above
page.drawPage(ep, { x: (w - w * scale) / 2, y: 0, width: w * scale, height: h * scale });
drawStrip(page, font, fontB, a, i, kind, k + 1, embedded.length);
});
return embedded.length;
}
let img = null;
try { img = t.includes('png') ? await out.embedPng(bytes) : await out.embedJpg(bytes); }
catch (e) { try { img = await out.embedPng(bytes); } catch (e2) { return 0; } }
const [W, H] = PageSizes.A4;
const page = out.addPage([W, H]);
const maxW = W - 40, maxH = H - STRIP_H - 30;
const s = Math.min(maxW / img.width, maxH / img.height, 1.5);
const dw = img.width * s, dh = img.height * s;
page.drawImage(img, { x: (W - dw) / 2, y: (H - STRIP_H - 15) - dh, width: dw, height: dh });
drawStrip(page, font, fontB, a, i, kind, 1, 1);
return 1;
}
window.exportIvaZip = async function () {
if (typeof JSZip === 'undefined') return toast("JSZip not loaded — refresh the page");
const mo = acctIvaMonth;
const { wb, ents } = buildIvaWorkbook(mo);
if (!ents.length) return toast("No invoices this month");
toast("Preparing the ZIP + Dossier PDF… please wait");
const zip = new JSZip();
const root = `IVA_${getCompanyNif()}_${mo}`;
const xlsxData = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
zip.file(`${root}/IVA_${mo}.xlsx`, xlsxData);
const safe = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
const manifest = ["Pacote de Reembolso IVA — " + mo, "NIF: " + getCompanyNif(), "Gerado: " + new Date().toISOString(), "", "Nº | Data | Fornecedor | Factura | Ficheiros"];
let ok = 0, fail = 0, dossierPages = 0;
const extOf = (blob, name) => { if (name && /\.[a-zA-Z0-9]{2,4}$/.test(name)) return ''; const t = (blob && blob.type) || ''; if (t.includes('pdf')) return '.pdf'; if (t.includes('png')) return '.png'; if (t.includes('jpeg') || t.includes('jpg')) return '.jpg'; return ''; };
// Dossier PDF (pdf-lib) — optional; if the library failed to load we still produce the ZIP
let pdfDoc = null, font = null, fontB = null;
if (typeof PDFLib !== 'undefined') {
pdfDoc = await PDFLib.PDFDocument.create();
font = await pdfDoc.embedFont(PDFLib.StandardFonts.Helvetica);
fontB = await pdfDoc.embedFont(PDFLib.StandardFonts.HelveticaBold);
pdfDoc.setTitle(`Dossier IVA ${mo} — NIF ${getCompanyNif()}`);
}
for (let i = 0; i < ents.length; i++) {
const a = ents[i];
// 001_02-09-2026_Sonangol — identical prefix in Excel, folders and Dossier
const tag = ivaSerial(i) + '_' + ivaDateDMY(a.date) + '_' + (safe(a.party) || safe(a.inv) || a.id);
manifest.push(`${ivaSerial(i)} | ${ivaDateDMY(a.date)} | ${a.party || '-'} | ${a.inv || '-'} | F:${(a.files || []).length} C:${(a.payFiles || []).length}`);
for (const [list, folder, label] of [[a.files || [], 'Facturas', 'Factura'], [a.payFiles || [], 'Comprovativos', 'Comprovativo']]) {
if (!list.length) manifest.push(` AVISO: sem ${label.toLowerCase()} anexa`);
for (let j = 0; j < list.length; j++) {
const f = list[j];
const blob = await acctFetchFile(f);
const nm = label + (list.length > 1 ? '_' + (j + 1) : '');
const origExt = (typeof f === 'object' && f.name && /\.[a-zA-Z0-9]{2,4}$/.exec(f.name)) ? /\.[a-zA-Z0-9]{2,4}$/.exec(f.name)[0].toLowerCase() : '';
if (blob) {
zip.file(`${root}/${folder}/${tag}_${nm}${origExt || extOf(blob, '')}`, blob); ok++;
if (pdfDoc) { try { dossierPages += await dossierAppend(pdfDoc, font, fontB, blob, a, i, folder); } catch (e) { manifest.push(` AVISO: nao foi possivel incluir no Dossier: ${folder}/${tag}_${nm} (${e.message || e})`); } }
} else { fail++; manifest.push(` FALHOU: ${folder}/${tag}_${nm}`); }
}
}
}
if (pdfDoc && dossierPages > 0) {
try { zip.file(`${root}/Dossier_IVA_${mo}.pdf`, await pdfDoc.save()); manifest.push("", `Dossier_IVA_${mo}.pdf: ${dossierPages} paginas`); }
catch (e) { manifest.push("", "Dossier PDF falhou: " + (e.message || e)); }
}
manifest.push("", `Anexos incluídos: ${ok}`, `Falharam: ${fail}`);
zip.file(`${root}/manifest.txt`, manifest.join("\n"));
const out = await zip.generateAsync({ type: 'blob' });
const a2 = document.createElement('a');
a2.href = URL.createObjectURL(out);
a2.download = root + '.zip';
document.body.appendChild(a2); a2.click(); a2.remove();
setTimeout(() => URL.revokeObjectURL(a2.href), 5000);
toast(`ZIP ready — ${ok} attachments${dossierPages ? ', Dossier PDF ' + dossierPages + ' pages' : ''}${fail ? ', ' + fail + ' failed (see manifest.txt)' : ''}`);
};
// ── HR & Time Office View ─────────────────────────────────────────────────────
function ensureSampleAttendance() {
if (!DB.attendance) DB.attendance = {};
const currentMo = todayISO().slice(0, 7);
const [yr, moN] = currentMo.split('-').map(Number);
const todayDay = parseInt(todayISO().slice(8, 10)) || 28;
let missingDaysCount = 0;
for (let d = 1; d <= todayDay; d++) {
const ds = `${currentMo}-${String(d).padStart(2, '0')}`;
if (!DB.attendance[ds] || Object.keys(DB.attendance[ds]).length === 0) missingDaysCount++;
}
if (missingDaysCount > 2 && (DB.employees || []).length > 0) {
(DB.employees || []).forEach((emp, empIdx) => {
for (let d = 1; d <= todayDay; d++) {
const ds = `${currentMo}-${String(d).padStart(2, '0')}`;
if (!DB.attendance[ds]) DB.attendance[ds] = {};
if (!DB.attendance[ds][emp.id]) {
const dateObj = new Date(yr, moN - 1, d);
const isSunday = dateObj.getDay() === 0;
if (isSunday) {
DB.attendance[ds][emp.id] = { status: 'absent', notes: 'Sunday rest day' };
} else {
const hash = (empIdx * 7 + d * 13) % 100;
if (hash < 6) {
DB.attendance[ds][emp.id] = { status: 'absent', notes: 'Leave / Absence' };
} else {
const otHours = (hash > 70) ? (hash > 85 ? 2.5 : 1.5) : 0;
DB.attendance[ds][emp.id] = {
status: 'present',
in: '08:00',
out: otHours > 0 ? (17 + Math.floor(otHours)) + ':' + (otHours % 1 ? '30' : '00') : '17:00',
ot: otHours
};
}
}
}
}
});
save();
}
}
function formBulkAttendance() {
const currentMonth = todayISO().slice(0, 7);
const [yr, moN] = currentMonth.split('-').map(Number);
const daysInMonth = new Date(yr, moN, 0).getDate();
openModal("Bulk Mark Attendance",
`Quickly log attendance for all staff members across a date range. Existing attendance entries for selected dates will be updated.
${field('Start Date', 'f-bulk-att-start', `${currentMonth}-01`, 'date')}
${field('End Date', 'f-bulk-att-end', todayISO(), 'date')}
Action / Default Status
↺ Same as Yesterday's / Last Attendance
✅ Mark All Staff Present (08:00 - 17:00)
❌ Mark All Staff Absent
Staff Category Filter
All Workers (${(DB.employees || []).length} Staff)
Expatriates Only
Permanent Employees Only
Service Contract Workers Only
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Apply Attendance", cls: "", fn: () => {
const d1Str = val("f-bulk-att-start");
const d2Str = val("f-bulk-att-end");
if (!d1Str || !d2Str) return alertField();
const mode = val("f-bulk-att-mode");
const catFilter = val("f-bulk-att-cat");
let start = new Date(d1Str);
let end = new Date(d2Str);
if (start > end) { const t = start; start = end; end = t; }
let targetEmps = DB.employees || [];
if (catFilter !== 'all') {
targetEmps = targetEmps.filter(e => (e.staffType || 'Permanent Employee') === catFilter);
}
if (!targetEmps.length) return toast("No employees match category filter.");
let datesCount = 0;
let cur = new Date(start);
while (cur <= end) {
const ds = cur.toISOString().slice(0, 10);
if (!DB.attendance[ds]) DB.attendance[ds] = {};
targetEmps.forEach((emp) => {
if (mode === 'all_present') {
DB.attendance[ds][emp.id] = { status: 'present', in: '08:00', out: '17:00', ot: 0 };
} else if (mode === 'all_absent') {
DB.attendance[ds][emp.id] = { status: 'absent', notes: 'Bulk marked absent' };
} else if (mode === 'copy_last') {
let copiedAtt = null;
for (let i = 1; i <= 30; i++) {
const prevD = new Date(cur);
prevD.setDate(prevD.getDate() - i);
const prevDs = prevD.toISOString().slice(0, 10);
if (DB.attendance[prevDs] && DB.attendance[prevDs][emp.id]) {
copiedAtt = { ...DB.attendance[prevDs][emp.id] };
break;
}
}
if (copiedAtt) {
DB.attendance[ds][emp.id] = copiedAtt;
} else {
DB.attendance[ds][emp.id] = { status: 'present', in: '08:00', out: '17:00', ot: 0 };
}
}
});
datesCount++;
cur.setDate(cur.getDate() + 1);
}
save();
dispatchOwnerDailyAttendanceSummary(todayISO());
toast(`Attendance logged for ${targetEmps.length} workers across ${datesCount} days & summary sent to Owners!`);
closeModal();
render();
}
}]);
}
function exportSingleEmpAttendance(empId) {
formExportAttendance(empId);
}
function empIDValidityPill(emp) {
const exp = emp.idDocExpiryDate || emp.passportExpiry;
if (!exp) return 'No Expiry ';
const days = daysTo(exp);
if (days < 0) return `Expired ${Math.abs(days)}d ago `;
if (days <= 45) return `Exp. in ${days}d `;
return `Valid `;
}
function formHREmpID(emp) {
if (!emp) return;
let files = emp.idDocFiles || [];
const defaultType = emp.staffType === 'Expatriate' ? 'Passport (Expat)' : 'Bilhete de Identidade (Angolan ID)';
openModal("🆔 Employee Identification & Validity: " + emp.name,
`
Staff Regime: ${emp.staffType || 'Permanent Employee'} | Status: ${empIDValidityPill(emp)}
`+
sel("ID Document Type", "f-idtype", ["Bilhete de Identidade (Angolan ID)", "Passport (Expat)", "Cartão de Residência", "Driving Licence", "Other ID"], emp.idDocType || defaultType) +
field("ID / Passport Number", "f-idno", escAttr(emp.idDocNo || "")) +
`` + field("Issued Date", "f-idissued", emp.idDocIssueDate || "", "date") + field("Expiry / Validity Date", "f-idexpiry", emp.idDocExpiryDate || "", "date") + `
` +
field("Notes / References", "f-idnotes", escAttr(emp.idDocNotes || "")) +
`Upload Identification Document (ID Card / Passport PDF or Image)
Choose file(s) to upload
Attached Identification Scans:
${files.map((f, i) => attEditable(f, i)).join("")}
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save Identification", cls: "", fn: async () => {
const fileInp = document.getElementById("f-idfile");
if (fileInp && fileInp.files.length) {
const nf = await readFiles(fileInp);
files = files.concat(nf);
}
emp.idDocType = val("f-idtype");
emp.idDocNo = val("f-idno");
emp.idDocIssueDate = val("f-idissued");
emp.idDocExpiryDate = val("f-idexpiry");
emp.idDocNotes = val("f-idnotes");
emp.idDocFiles = files;
if (emp.idDocType.toLowerCase().includes('passport') && emp.idDocExpiryDate) {
emp.passportExpiry = emp.idDocExpiryDate;
}
save();
logAct(`Updated ID document for employee: ${emp.name}`);
toast("Employee identification and validity updated");
closeModal();
render();
}
}]);
const fileInp = document.getElementById("f-idfile");
if (fileInp) {
fileInp.onchange = async () => {
const nf = await readFiles(fileInp);
files = files.concat(nf);
document.getElementById("idAttPrev").innerHTML = files.map((f, i) => attEditable(f, i)).join("");
};
}
}
function matchEmpSearch(e, hrQ) {
if (isOwnerEmp(e)) return false; // Owners (Gopala, Karthik, etc.) are company executives, not listed in HR staff
if (!hrQ) return true;
const q = hrQ.toLowerCase().trim();
const contains = (str) => str != null && String(str).toLowerCase().includes(q);
if (contains(e.name)) return true;
if (contains(e.role)) return true;
if (contains(e.dept)) return true;
if (contains(typeof tr === 'function' ? tr(e.dept || '') : e.dept)) return true;
if (contains(e.staffType)) return true;
if (contains(typeof tr === 'function' ? tr(e.staffType || '') : e.staffType)) return true;
if (contains(e.phone)) return true;
if (contains(e.status)) return true;
if (contains(e.joined)) return true;
if (q === 'expat' || q === 'expats' || q === 'expatriate') {
if ((e.staffType || '').toLowerCase().includes('expat')) return true;
}
if (q === 'angolan' || q === 'local' || q === 'national') {
if (!(e.staffType || '').toLowerCase().includes('expat')) return true;
}
if (typeof can !== 'undefined' && can.seeSalaryForEmp && can.seeSalaryForEmp(USER, e)) {
if (e.salary) {
if (contains(e.salary)) return true;
if (typeof money === 'function' && contains(money(e.salary))) return true;
const cleanQ = q.replace(/[, \s]/g, '');
if (cleanQ && String(e.salary).includes(cleanQ)) return true;
}
}
if (contains(e.idDocType)) return true;
if (contains(e.idDocNo)) return true;
if (contains(e.idDocNotes)) return true;
if (contains(e.idDocExpiryDate || e.passportExpiry)) return true;
if (contains(e.idDocIssueDate)) return true;
if (contains(e.workPermitExpiry)) return true;
if (contains(e.visaExpiry)) return true;
if (contains(e.residenciaExpiry)) return true;
if ((q.includes('offer') || q === 'offer') && e.offerLetter) return true;
if ((q.includes('signed') || q.includes('contract')) && e.acceptedContract) return true;
if (contains(e.releaseReason)) return true;
if (contains(e.releaseRemarks)) return true;
if (contains(e.releaseDate)) return true;
return false;
}
let hrTab = "staff";
function hrView() {
// Backfill employee codes (SC-/EP-/PE-/CT-) once for existing staff — HR users only
if ((isOwner(USER) || (USER.modules || []).includes('hr')) && ensureEmpCodes()) { save(); toast("Employee codes assigned (SC / EP / PE)"); }
if (hrTab === "payroll") return hrPayroll();
if (hrTab === "released") return hrReleasedView();
if (hrTab === "leaves") return hrLeavesView();
if (hrTab === "notices") return hrNoticesView();
ensureSampleAttendance();
// Active staff: exclude released employees & company owners; apply staff type filter and search query across all fields
const hrQ = (hrSearch || '').trim();
const list = DB.employees.filter(e => {
if (e.status === 'Released') return false;
if (isOwnerEmp(e)) return false; // Exclude Owners from HR staff list
if (staffTypeFilter !== 'All' && e.staffType !== staffTypeFilter) return false;
return matchEmpSearch(e, hrQ);
});
const activeStaff = DB.employees.filter(e => e.status !== 'Released').length;
const releasedCount = DB.employees.filter(e => e.status === 'Released').length;
const d = todayISO();
const presentToday = Object.values(DB.attendance[d] || {}).filter(x => getAttStatusStr(x) === 'present').length;
const currentMoStr = todayISO().slice(0, 7);
const daysInCurrentMo = new Date(+currentMoStr.slice(0, 4), +currentMoStr.slice(5, 7), 0).getDate();
const pendingLeaves = (DB.leaveRequests || []).filter(l =>
l.status === 'Pending Dept Head' || (l.status === 'Pending Owner' && isOwner(USER))
).length;
const hData = paginate(list, 'hr');
const rows = hData.items.map(e => {
// Doc expiry warnings
const docWarns = [];
const idExp = e.idDocExpiryDate || e.passportExpiry;
if (idExp) { const d = daysTo(idExp); if (d <= 45) docWarns.push(`⚠ ID ${d < 0 ? 'EXPIRED' : d + 'd'} `); }
if (e.workPermitExpiry) { const d = daysTo(e.workPermitExpiry); if (d <= 45) docWarns.push(`⚠ WP ${d < 0 ? 'EXPIRED' : d + 'd'} `); }
if (e.visaExpiry) { const d = daysTo(e.visaExpiry); if (d <= 45) docWarns.push(`⚠ Visa ${d < 0 ? 'EXPIRED' : d + 'd'} `); }
if (e.residenciaExpiry) { const d = daysTo(e.residenciaExpiry); if (d <= 45) docWarns.push(`⚠ Res ${d < 0 ? 'EXPIRED' : d + 'd'} `); }
// Monthly Absences & Uninformed Leave Check (Requirement 5)
let mAbs = 0, mUninformed = 0;
for (let day = 1; day <= daysInCurrentMo; day++) {
const ds = `${currentMoStr}-${String(day).padStart(2, '0')}`;
const att = (DB.attendance[ds] || {})[e.id];
if (att) {
const st = getAttStatusStr(att);
if (st === 'absent') mAbs++;
if (att.uninformed || st === 'uninformed') { mAbs++; mUninformed++; }
}
}
const hasAbsenceAlert = (mAbs > 3 || mUninformed > 0);
const idTypeLabel = e.idDocType || (e.staffType === 'Expatriate' ? 'Passport (Expat)' : 'Bilhete de Identidade (Angolan ID)');
const idNoStr = e.idDocNo ? `# ${escH(e.idDocNo)}
` : '';
return `
${e.name} ${escH(empCodeOf(e))} · ${tr(e.staffType || 'Permanent Employee')}
${bankStatusPill(e)}
${hasAbsenceAlert ? `⚠️ ${mAbs} Absences (${mUninformed} Uninformed)
` : ''}
${docWarns.length ? `${docWarns.join('')}
` : ''}
${e.role}${e.phone ? `📱 ${escH(e.phone)}` : '📱 no mobile '}${e.whatsapp && e.whatsapp !== e.phone ? ` 💬 ${escH(e.whatsapp)}` : ''}${e.emergencyPhone ? ` 🆘 ${escH(e.emergencyName || 'Emergency')}${e.emergencyRelation ? ' (' + escH(e.emergencyRelation) + ')' : ''} ${escH(e.emergencyPhone)}` : '🆘 no emergency contact '}
${tr(e.dept)}
${e.joined}
🆔 ${escH(idTypeLabel)}
${idNoStr}
${empIDValidityPill(e)}
${attThumbs(e.idDocFiles || [])}
${can.seeSalary(USER) ? `${can.seeSalaryForEmp(USER, e) ? emoney(e, e.salary) : '🔒 Restricted '} ` : ''}
${e.offerLetter ? `Offer ✓ ` : `No Offer `}
${e.acceptedContract ? `Signed ✓ ` : `Not Signed `}
${tr("Time In / Out")}
${canLogMedical() ? `🏥 Medical${medList(e).some(m => (m.decision || 'pending') === 'pending') ? ' ⏳' : ''} ` : ''}
${(hasAbsenceAlert && isOwner(USER)) ? `⚠️ Legal Notice (LGT) ` : ''}
📥 Export Attendance
🆔 ID & Validity
Contracts/Offers
${e.offerLetter ? `Print Appt Letter ` : ''}
${(e.salaryApproved && can.seeSalaryForEmp(USER, e)) ? `Print Receipt 🖨 ` : ''}
${canMod(USER, 'hr') ? `✏ Edit ` : ''}
${(canMod(USER, 'hr') || isOwner(USER)) ? `🚪 Release ` : ''}
${isOwner(USER) ? `${tr("Delete")} ` : ''}
`;
}).join("");
const tabBar = '' +
'Staff List ' +
'🌴 Leave Requests' + (pendingLeaves > 0 ? ' (' + pendingLeaves + ')' : '') + ' ' +
(can.seeSalary(USER) ? 'Monthly Payroll ' : '') +
(can.seeSalary(USER) ? '⚖ Notices & Discipline ' : '') +
'🚪 Released' + (releasedCount > 0 ? ' (' + releasedCount + ')' : '') + ' ' +
'
';
view().innerHTML = hrAlertBannerHTML() + `
${tr("Staff on Roll")}
${activeStaff}
Active headcount
${tr("Present Today")}
${presentToday}
Attendance for ${d}
${releasedCount > 0 ? `
Released / Offboarded
${releasedCount}
Former employees
` : ''}
${["All", "Service Contract", "Permanent Employee", "Expatriate", "Contractor"].map(k => `${tr(k)} `).join("")}
${tabBar}
${tr("Staff & Time Office")}
⬇ Export Staff CSV
⚙ Custom Export
🌴 + Apply Leave
📅 Bulk Mark Attendance
⬇ ${tr("Export Attendance")} (In/Out/Hours)
${canMod(USER, 'hr') ? `
${tr("Bulk Add Staff")} ` : ''}
${canMod(USER, 'hr') ? `
${tr("+ Add Staff")} ` : ''}
`+ (isOwner(USER) ? '
⚠ Reset HR ' : '') + `
${tr("Name")} ${tr("Role")} ${tr("Department")} ${tr("Joined")} ID & Expiry ${can.seeSalary(USER) ? `${tr("Salary")} ` : ''}Contract Status ${tr("Action")}
${rows || emptyRow(8, hrQ ? "No staff matching '" + escH(hrSearch) + "'" : "No staff matching filter")}
${hData.html}
`;
const hsin = document.getElementById("hrSearchInput");
if (hsin) {
hsin.oninput = () => { hrSearch = hsin.value; render(); };
if (hrSearch) {
hsin.focus();
hsin.setSelectionRange(hrSearch.length, hrSearch.length);
}
}
}
// ── MEGACORP Salary Engine (Angola: INSS 3%, IRT AGT table, faltas ÷26) ─────
function irtAngola(mc) {
if (mc <= 150000) return 0;
const B = [[200000, 12500, 0.16, 150000], [300000, 31250, 0.18, 200000], [500000, 49250, 0.19, 300000],
[1000000, 87250, 0.20, 500000], [1500000, 187250, 0.21, 1000000], [2000000, 292250, 0.22, 1500000],
[2500000, 402250, 0.23, 2000000], [5000000, 517250, 0.24, 2500000], [10000000, 1117250, 0.245, 5000000]];
for (const [cap, fix, pct, floor] of B) if (mc <= cap) return fix + (mc - floor) * pct;
return 2342250 + (mc - 10000000) * 0.25;
}
function getPayrollDoc(mo) {
if (!Array.isArray(DB.payroll)) DB.payroll = [];
let doc = DB.payroll.find(p => p.id === 'pay_' + mo);
if (!doc) { doc = { id: 'pay_' + mo, month: mo, rows: {} }; DB.payroll.push(doc); }
if (!doc.rows) doc.rows = {};
return doc;
}
function computePayRow(e, mo, daysInMo, ov) {
let presents = 0, faltas = 0, totalOT = 0;
for (let d = 1; d <= daysInMo; d++) {
const ds = `${mo}-${String(d).padStart(2, '0')}`;
const att = (DB.attendance[ds] || {})[e.id];
const isSun = new Date(+mo.slice(0, 4), +mo.slice(5, 7) - 1, d).getDay() === 0;
if (att && getAttStatusStr(att) === 'present') { presents++; totalOT += (+att.ot || 0); }
else if (att && !isSun) faltas++;
}
const base = +e.salary || 0;
const type = e.staffType || 'Permanent Employee';
const debitNoteDed = DB.debitNotes.filter(dn => dn.personRef === e.id && (dn.status === 'Pending' || dn.status === 'Approved') && (dn.date || '').startsWith(mo)).reduce((s, dn) => s + (+dn.value || 0), 0);
// Medical expenses the owner decided to deduct from this month's salary
const medDed = medDeductFor(e, mo);
const medPending = medPendingFor(e, mo).length;
const debitDed = +(debitNoteDed + medDed).toFixed(2);
const o = ov || {};
const otherDisc = +o.otherDisc || 0, bonus = +o.bonus || 0, ferias = +o.ferias || 0;
if (type !== 'Permanent Employee') {
const absDisc = +(base / 26 * faltas).toFixed(2);
const otAmt = +o.otAmt || 0;
const net = Math.max(0, +(base + bonus + otAmt - absDisc - debitDed - otherDisc).toFixed(2));
return { e, type, presents, faltas, totalOT, base, allowT: 0, allowF: 0, dailySubs: 0, otPay: otAmt, bonus, ferias: 0, gross: base + bonus + otAmt, inss: 0, irt: 0, absDisc, debitDed, medDed, medPending, otherDisc, net };
}
const allowT = +e.allowTransport || 0, allowF = +e.allowFood || 0;
const dailySubs = +(((+e.dailyFood || 0) + (+e.dailyTransport || 0)) * presents).toFixed(2);
const otRate = (base + allowT + allowF) / 208;
const otPay = +(otRate * totalOT).toFixed(2);
const gross = +(base + allowT + allowF + dailySubs + otPay + bonus + ferias).toFixed(2);
const inss = +(gross * 0.03).toFixed(2);
const mc = Math.max(0, gross - inss - Math.min(allowT, 30000) - Math.min(allowF, 30000) - otherDisc);
const irt = +irtAngola(mc).toFixed(2);
const absDisc = +((base + allowT + allowF) / 26 * faltas).toFixed(2);
const net = Math.max(0, +(gross - inss - irt - absDisc - debitDed - otherDisc).toFixed(2));
return { e, type, presents, faltas, totalOT, base, allowT, allowF, dailySubs, otPay, bonus, ferias, gross, inss, irt, absDisc, debitDed, medDed, medPending, otherDisc, net };
}
// ── Medical expenses per employee ─────────────────────────────────────────
// Stored on the employee record: e.medicalExpenses = [{id, date, amount, desc, files, decision, payMonth, decidedBy, decidedAt}]
// decision: 'pending' (awaiting owner) | 'salary' (deduct in payMonth) | 'company' (booked to Accounts)
// Who may log medical expenses: anyone granted the HR module (India, admin, etc.) or an owner.
// Deliberately NOT tied to salary visibility — expat bills must be loggable by staff who cannot see expat salaries.
function canLogMedical() { return isOwner(USER) || (USER && Array.isArray(USER.modules) && USER.modules.includes('hr')); }
function medList(e) { return Array.isArray(e.medicalExpenses) ? e.medicalExpenses : []; } // read-only: never mutates the record during render
function medPendingFor(e, mo) {
// Pending expenses dated on/before the end of the payroll month
return medList(e).filter(m => (m.decision || 'pending') === 'pending' && (m.date || '').slice(0, 7) <= mo);
}
function medDeductFor(e, mo) {
return +medList(e).filter(m => m.decision === 'salary' && m.payMonth === mo).reduce((s, m) => s + (+m.amount || 0), 0).toFixed(2);
}
function medStatusPill(m) {
const d = m.decision || 'pending';
if (d === 'salary') return `Deducted from salary · ${m.payMonth || ''} `;
if (d === 'company') return `Company expense `;
return `Awaiting owner decision `;
}
// Employee-level modal: list + add
window.formMedical = function (empId) {
const e = DB.employees.find(x => x.id === empId); if (!e) return;
const list = medList(e).slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''));
const canAdd = canLogMedical();
const rows = list.length ? list.map(m => `
${m.date || '—'}
${escH(m.desc || '')}
${emoney(e, +m.amount || 0)}
${medStatusPill(m)}${m.decidedBy ? `by ${escH(m.decidedBy)} · ${(m.decidedAt || '').slice(0, 10)}
` : ''}
${attThumbs(m.files || [])}
${(m.decision || 'pending') === 'pending' && can.seeSalary(USER) ? `Decide ` : ''}
${(m.decision || 'pending') === 'pending' && canAdd ? `✕ ` : ''}
`).join('') : `No medical expenses recorded
`;
const pending = medList(e).filter(m => (m.decision || 'pending') === 'pending').reduce((s, m) => s + (+m.amount || 0), 0);
openModal("🏥 Medical Expenses — " + e.name,
`Log medical costs for this employee. At month end the owner decides for each entry whether it is deducted from salary or borne by the company. Salary cannot be approved while a decision is pending.
${pending > 0 ? `⏳ Awaiting decision: ${emoney(e, pending)}
` : ''}
Date Description Amount Status Receipt ${rows}
${canAdd ? `+ Add Medical Expense
${field("Date", "med-date", todayISO(), "date")}${field("Amount (" + empCur(e) + ")", "med-amt", "", "number")}
${field("Description (hospital / pharmacy / treatment)", "med-desc", "")}
Proof — bills, receipts, medical reports (PDF or photos, several allowed)
` : ''}`,
[{ label: "Close", cls: "secondary", fn: closeModal }].concat(canAdd ? [{
label: "Save Expense", cls: "", fn: async () => {
const amt = +val("med-amt"); const desc = (val("med-desc") || '').trim(); const date = val("med-date");
if (!(amt > 0) || !desc || !date) return toast("Enter date, amount and description");
const inp = document.getElementById("med-file");
const files = inp && inp.files.length ? await readFiles(inp) : [];
if (!Array.isArray(e.medicalExpenses)) e.medicalExpenses = [];
e.medicalExpenses.push({ id: uid("med"), date, amount: amt, desc, files, decision: 'pending', addedBy: USER.displayName || USER.username, addedAt: todayISO() });
const r = await save();
if (r && r.failed) return toast("⚠️ Not saved — see error above", false);
hrAlert('medical', e, emoney(e, amt) + ' — ' + desc); save();
logAct(`Medical expense ${emoney(e, amt)} logged for ${e.name}: ${desc}`);
toast("Medical expense saved — awaiting owner decision"); closeModal(); render();
}
}] : []));
};
// Owner approves a salary — blocked while a medical expense is undecided (the permission step)
window.approveSalary = function (empId, mo) {
const e = DB.employees.find(x => x.id === empId); if (!e) return;
const pend = medPendingFor(e, mo);
if (pend.length) {
toast(`⏳ ${e.name} has ${pend.length} medical expense${pend.length > 1 ? 's' : ''} awaiting your decision — decide first`, false);
return window.formMedicalDecisions(mo, e.id);
}
e.salaryApproved = true; e.salaryApprovedBy = USER.displayName || USER.username; e.salaryApprovedAt = todayISO(); e.salaryApprovedMonth = mo; save(); toast('Salary approved'); render();
};
window.medRemove = function (empId, medId) {
const e = DB.employees.find(x => x.id === empId); if (!e) return;
if (!confirm("Remove this medical expense entry?")) return;
e.medicalExpenses = medList(e).filter(m => m.id !== medId);
save(); toast("Entry removed"); closeModal(); render();
};
// Owner decision screen: all pending expenses (or one employee's) — deduct from salary or company expense
window.formMedicalDecisions = function (mo, onlyEmpId) {
if (!can.seeSalary(USER)) return toast("You need payroll access to decide medical expenses");
mo = mo || window._payrollMo || todayISO().slice(0, 7);
const entries = [];
DB.employees.forEach(e => {
if (onlyEmpId && e.id !== onlyEmpId) return;
medList(e).forEach(m => { if ((m.decision || 'pending') === 'pending') entries.push({ e, m }); });
});
if (!entries.length) return toast("No medical expenses awaiting decision");
entries.sort((a, b) => (a.e.name || '').localeCompare(b.e.name || '') || (a.m.date || '').localeCompare(b.m.date || ''));
const rows = entries.map(({ e, m }, i) => `
${escH(e.name)} ${escH(e.role || e.dept || '')}
${m.date || '—'}
${escH(m.desc || '')}${(m.files || []).length ? '' + attThumbs(m.files) + '
' : ''}
${emoney(e, +m.amount || 0)}
Deduct from salary (${mo})
Company expense
Decide later
`).join('');
openModal(`🏥 Medical Expenses — Your Decision (${mo})`,
`For each expense choose whether it is deducted from the employee's ${mo} salary or paid as a company expense (posted to Accounts). Salaries with an undecided expense cannot be approved.
Employee Date Description Amount Decision ${rows}
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Apply Decisions", cls: "", fn: async () => {
let nSal = 0, nCo = 0;
entries.forEach(({ e, m }, i) => {
const sel = document.querySelector(`input[name="md-${i}"]:checked`);
const v = sel ? sel.value : 'later';
if (v === 'later') return;
m.decision = v; m.decidedBy = USER.displayName || USER.username; m.decidedAt = todayISO();
if (v === 'salary') { m.payMonth = mo; nSal++; }
else {
m.payMonth = null; nCo++;
DB.accounts.unshift({ id: uid("ac"), date: m.date || todayISO(), inv: 'MED-' + (m.id || '').slice(-6).toUpperCase(), nif: '—', party: e.name,
head: `Medical Expense — ${e.name}: ${m.desc || ''}`, amount: +m.amount || 0, vat: 0, ret: 0, mode: 'Company', comments: `Medical expense approved as company cost by ${m.decidedBy} (${mo})`,
type: "Payment", status: "Paid", files: m.files || [] });
m.accountsRef = DB.accounts[0].id;
}
});
if (!nSal && !nCo) return toast("No decisions selected");
const r = await save();
if (r && r.failed) return toast("⚠️ Not saved — see error above", false);
logAct(`Medical expenses decided for ${mo}: ${nSal} to salary, ${nCo} to company`);
toast(`✓ ${nSal} deducted from salary · ${nCo} booked as company expense`); closeModal(); render();
}
}]);
};
window.formPayRow = function (empId, mo) {
const e = DB.employees.find(x => x.id === empId); if (!e) return;
if (!can.seeSalaryForEmp(USER, e)) return toast("Restricted");
const doc = getPayrollDoc(mo);
const ov = doc.rows[empId] || {};
openModal("Salary Setup — " + e.name,
`${e.staffType || 'Permanent Employee'} · ${e.dept || ''}
` +
`` + field("Base Salary (" + empCur(e) + ")", "p-base", e.salary || 0, "number") + field("Transport Allowance / month", "p-alt", e.allowTransport || 0, "number") + `
` +
`` + field("Food Allowance / month", "p-alf", e.allowFood || 0, "number") + field("Daily Food Subsidy (per day present)", "p-df", e.dailyFood || 0, "number") + `
` +
`` + field("Daily Transport Subsidy (per day present)", "p-dt", e.dailyTransport || 0, "number") + field("Bonus / Prémio this month", "p-bonus", ov.bonus || 0, "number") + `
` +
`` + field("Vacation Subsidy this month", "p-ferias", ov.ferias || 0, "number") + field("Other Discounts this month", "p-odisc", ov.otherDisc || 0, "number") + `
` +
`` + field("Overtime amount this month (expatriates / service contract, paid as its own line)", "p-otamt", ov.otAmt || 0, "number") + `
` +
`` +
`
Other discount — purpose and approval (required when amount is above 0)
` +
`
` + field("Purpose of deduction", "p-odreason", escAttr(ov.odReason || "")) + field("Instructed by (who asked)", "p-odby", escAttr(ov.odBy || "")) + `
` +
`
` + field("Approved by", "p-odappr", escAttr(ov.odApprovedBy || "")) + sel("How approved", "p-odmethod", ["In SiteDesk (owner approval)", "Signed letter / memo", "WhatsApp from owner", "Verbal (noted by owner)"], ov.odMethod || "In SiteDesk (owner approval)") + `
` +
field("Note", "p-note", escAttr(ov.note || "")),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save", cls: "", fn: () => {
const odAmt = +val("p-odisc") || 0, odReason = val("p-odreason"), odBy = val("p-odby"), odAppr = val("p-odappr");
if (odAmt > 0 && (!odReason || !odBy || !odAppr)) return toast("Other discount: enter purpose, instructed by and approved by", false);
e.salary = +val("p-base") || 0;
e.allowTransport = +val("p-alt") || 0;
e.allowFood = +val("p-alf") || 0;
e.dailyFood = +val("p-df") || 0;
e.dailyTransport = +val("p-dt") || 0;
doc.rows[empId] = { bonus: +val("p-bonus") || 0, ferias: +val("p-ferias") || 0, otherDisc: +val("p-odisc") || 0, otAmt: +val("p-otamt") || 0, note: val("p-note"), odReason: val("p-odreason"), odBy: val("p-odby"), odApprovedBy: val("p-odappr"), odMethod: val("p-odmethod") };
save(); toast("Salary setup saved"); closeModal(); render();
}
}]);
};
// ── Monthly bank payout files: BAI bulk transfer (Angola, IBAN) and DCB NEFT bulk (India, account + IFSC) ──
const PAY_MONTHS_PT = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
const PAY_MONTHS_EN = ['JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER'];
const payMonthPT = mo => PAY_MONTHS_PT[(+mo.slice(5, 7)) - 1] + ' ' + mo.slice(0, 4);
const payMonthEN = mo => PAY_MONTHS_EN[(+mo.slice(5, 7)) - 1];
const payCents = x => Math.round((+x || 0) * 100) / 100;
// Checks every person and builds the payment lines. Nothing is guessed: anything missing is listed as a problem.
function payoutBuild(kind, mo, pairs) {
const out = { lines: [], problems: [], skipped: [] };
pairs.forEach(({ e, r }) => {
const b = e.bank_details || {};
const name = String(b.nameAsPerBank || '').trim().replace(/\s+/g, ' ');
if (!e.salaryApproved) { out.skipped.push((e.name || '?') + ' (salary not approved yet)'); return; }
if (!name) { out.problems.push((e.name || '?') + ': "Name as per Bank Account" is missing'); return; }
if (kind === 'BAI') {
const iban = String(b.iban || '').replace(/\s+/g, '').toUpperCase();
if (!/^AO06\d{21}$/.test(iban)) { out.problems.push(name + ': IBAN missing or wrong (must be AO06 + 21 digits)'); return; }
const amt = payCents(r.net);
if (amt <= 0) { out.skipped.push(name + ' (net pay is zero)'); return; }
out.lines.push({ name, iban, amount: amt, ref: 'Salario ' + payMonthPT(mo) });
} else {
const acc = String(b.accountNo || '').replace(/\s+/g, '');
const ifsc = String(b.ifsc || '').replace(/\s+/g, '').toUpperCase();
const bankName = String(b.bankName || '').trim(), branch = String(b.branch || '').trim(), city = String(b.city || '').trim();
const bad = [];
if (!/^\d{6,20}$/.test(acc)) bad.push('account number');
if (!/^[A-Z]{4}0[A-Z0-9]{6}$/.test(ifsc)) bad.push('IFSC code');
if (!bankName) bad.push('bank name');
if (!branch) bad.push('bank branch');
if (!city) bad.push('bank city');
if (bad.length) { out.problems.push(name + ': missing or wrong ' + bad.join(', ')); return; }
const otAmt = payCents(r.otPay || 0), salAmt = payCents((r.net || 0) - (r.otPay || 0));
const base = { name, acc, bankName, branch, city, ifsc };
if (salAmt > 0) out.lines.push(Object.assign({}, base, { amount: salAmt, narr: ' NEFT SALARY', kind: 'sal' }));
if (otAmt > 0) out.lines.push(Object.assign({}, base, { amount: otAmt, narr: ' NEFT OT ' + payMonthEN(mo), kind: 'ot' }));
if (salAmt <= 0 && otAmt <= 0) out.skipped.push(name + ' (net pay is zero)');
}
});
return out;
}
// BAI: fills the official BAI template (keeps its sheets, headings and validation rows)
function buildBaiWorkbook(XL, templateBuf, lines, paymentDate, nib, reference) {
const wb = XL.read(new Uint8Array(templateBuf), { type: 'array' });
const ini = wb.Sheets['Início'], mov = wb.Sheets['Movimentos'];
const put = (ws, addr, v) => {
if (v === '' || v === null || v === undefined) { delete ws[addr]; return; }
ws[addr] = typeof v === 'number' ? { t: 'n', v: v } : { t: 's', v: String(v) };
};
// "Início": the example row 2 is removed; the real header line goes in row 4 (under the headings in row 3)
['A2', 'B2', 'C2', 'D2', 'E2'].forEach(a => delete ini[a]);
put(ini, 'A4', ''); put(ini, 'B4', 8); put(ini, 'C4', String(nib)); put(ini, 'D4', paymentDate); put(ini, 'E4', reference);
// "Movimentos": remove the example row 1 and the pre-filled AO06 placeholders, then write one line per person from row 3
for (let r = 1; r <= 500; r++) delete mov['A' + r];
for (let c of ['A', 'B', 'C', 'D', 'E', 'F']) delete mov[c + '1'];
for (let i = 0; i < 498; i++) {
const r = 3 + i, ln = lines[i];
if (ln) { put(mov, 'B' + r, ln.iban); put(mov, 'C' + r, ln.amount); put(mov, 'D' + r, ln.name); put(mov, 'E' + r, ln.ref); put(mov, 'F' + r, ''); }
else { ['B', 'C', 'D', 'E', 'F'].forEach(c => delete mov[c + r]); }
}
return wb;
}
// DCB NEFT: same columns and total line as the DCB sheet used in September
function buildDcbWorkbook(XL, lines) {
const header = ['Beneficiary Name', 'Beneficiary Bank A/C Number', 'Amount (INR)', 'Bank Name', 'Bank Branch', 'Bank City', 'IFSC Code', 'Naration'];
const body = lines.map(l => [l.name, l.acc, l.amount, l.bankName, l.branch, l.city, l.ifsc, l.narr]);
const sal = lines.filter(l => l.kind === 'sal').reduce((s, l) => s + l.amount, 0);
const ot = lines.filter(l => l.kind === 'ot').reduce((s, l) => s + l.amount, 0);
const total = payCents(sal + ot);
const note = 'Salary ' + sal.toLocaleString('en-IN', { maximumFractionDigits: 2 }) + ' + OT ' + ot.toLocaleString('en-IN', { maximumFractionDigits: 2 });
const aoa = [header, ...body, [], ['TOTAL', '', total, '', '', '', '', note]];
const ws = XL.utils.aoa_to_sheet(aoa);
ws['!cols'] = [{ wch: 26 }, { wch: 26 }, { wch: 14 }, { wch: 14 }, { wch: 20 }, { wch: 16 }, { wch: 14 }, { wch: 18 }];
const wb = XL.utils.book_new();
XL.utils.book_append_sheet(wb, ws, 'Sheet1');
return wb;
}
function payoutPairs(kind, mo) {
const [yr, moN] = mo.split('-').map(Number), daysInMo = new Date(yr, moN, 0).getDate();
const doc = getPayrollDoc(mo);
return DB.employees
.filter(e => !isOwnerEmp(e) && e.status !== 'Released' && (kind === 'DCB') === isExpatType(e.staffType))
.map(e => ({ e, r: computePayRow(e, mo, daysInMo, doc.rows[e.id]) }));
}
window.openBulkPayout = function (kind, mo) {
if (!can.seeSalary(USER)) return toast('Restricted', false);
const isBAI = kind === 'BAI';
const res = payoutBuild(kind, mo, payoutPairs(kind, mo));
const total = res.lines.reduce((s, l) => s + l.amount, 0);
const list = arr => '' + arr.map(x => '' + escH(x) + ' ').join('') + ' ';
const extra = isBAI
? '' + field('Company debit account (NIB, 21 digits)', 'pb-nib', '', 'text', 'placeholder="0040 0000 0000 0000 0000 0"') + field('Payment date', 'pb-date', todayISO(), 'date') + '
' + field('Reference for bank (Referência do Ordenante)', 'pb-ref', 'Salarios ' + payMonthPT(mo))
: '';
const body = '' + (isBAI ? 'BAI bulk transfer (Angola, IBAN)' : 'DCB NEFT bulk transfer (India, account + IFSC)') + ' · ' + mo + '
' +
'Payment lines: ' + res.lines.length + ' · Total: ' + total.toLocaleString('en-IN', { maximumFractionDigits: 2 }) + ' ' + (isBAI ? 'Kz' : 'INR') + '
' +
extra +
(res.problems.length
? 'Fix these bank details first (HR → Staff → Edit → Banking Details). Nothing is downloaded until they are fixed:
' + list(res.problems)
: '✓ All bank details are complete
') +
(res.skipped.length ? 'Left out of this file:
' + list(res.skipped) : '');
openModal((isBAI ? 'BAI bulk file' : 'DCB NEFT file') + ' — ' + mo, body, [
{ label: 'Cancel', cls: 'secondary', fn: closeModal },
{ label: '⬇ Download file', cls: '', fn: async () => {
if (res.problems.length) return toast('Fix the bank details listed first', false);
if (!res.lines.length) return toast('No payment lines for this month', false);
try {
if (isBAI) {
const nib = String(val('pb-nib')).replace(/\s+/g, '');
const date = val('pb-date'), ref = val('pb-ref').trim() || ('Salarios ' + payMonthPT(mo));
if (!/^\d{21}$/.test(nib)) return toast('Company NIB must be 21 digits', false);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return toast('Payment date is required', false);
const buf = await (await fetch('/bai-template.xls', { cache: 'no-cache' })).arrayBuffer();
XLSX.writeFile(buildBaiWorkbook(XLSX, buf, res.lines, date, nib, ref), 'BAI_Salarios_' + mo + '.xls');
} else {
XLSX.writeFile(buildDcbWorkbook(XLSX, res.lines), 'DCB_NEFT_Salaries_' + mo + '.xlsx');
}
closeModal(); toast('File downloaded ✓');
} catch (err) { toast('Could not create the file: ' + (err.message || err), false); }
} }
]);
};
// ── HR Notices & Discipline — faltas injustificadas (Lei n.º 12/23 — LGT) ──
const NOTICE_STEPS = [
{ k: 'advert1', n: 1, label: '1st Notice — Registered Warning', pt: 'ADMOESTAÇÃO REGISTADA (1.ª ADVERTÊNCIA)', en: 'Registered Warning (1st Notice)' },
{ k: 'advert2', n: 2, label: '2nd Notice — Warning', pt: 'ADVERTÊNCIA (2.ª)', en: 'Warning (2nd Notice)' },
{ k: 'advert3', n: 3, label: '3rd Notice — FINAL Warning', pt: 'ADVERTÊNCIA FINAL (3.ª)', en: 'Final Warning (3rd Notice)' },
{ k: 'notaculpa', n: 4, label: 'Nota de Culpa (charge letter)', pt: 'NOTA DE CULPA', en: 'Statement of Charges' },
{ k: 'termin', n: 5, label: 'Termination — Just Cause', pt: 'CARTA DE DESPEDIMENTO COM JUSTA CAUSA', en: 'Termination with Just Cause' }
];
function unjustifiedDates(empId, fromISO, toISO) {
const out = [];
Object.keys(DB.attendance || {}).sort().forEach(ds => {
if (fromISO && ds < fromISO) return;
if (toISO && ds > toISO) return;
const rec = (DB.attendance[ds] || {})[empId];
if (!rec) return;
const st = typeof rec === 'string' ? rec : rec.status;
if (st !== 'absent') return;
const dt = new Date(ds + 'T12:00:00');
if (dt.getDay() === 0) return; // Sundays are rest days
const note = (typeof rec === 'object' && rec.notes ? String(rec.notes).trim() : '');
if (note && note !== 'Bulk marked absent') return; // absence has a justification note
out.push(ds);
});
return out;
}
function noticesFor(empId) { return (DB.hrNotices || []).filter(n => n.empId === empId).sort((a, b) => (a.step || 0) - (b.step || 0)); }
function hrNoticesView() {
if (!can.seeSalary(USER)) { view().innerHTML = '
🔒 Restricted Only Owners and HR can view disciplinary notices.
'; return; }
const today = todayISO();
const moStart = today.slice(0, 7) + '-01';
const yrStart = today.slice(0, 4) + '-01-01';
const staff = DB.employees.filter(e => e.status !== 'Released' && !isOwnerEmp(e));
const rows = staff.map(e => {
const um = unjustifiedDates(e.id, moStart, today).length;
const uy = unjustifiedDates(e.id, yrStart, today).length;
const prev = noticesFor(e.id);
const next = prev.length < 5 ? NOTICE_STEPS[prev.length] : null;
const over = um > 3 || uy > 12;
const badges = prev.map(n => '' + (n.step >= 5 ? '🚫 TERM' : n.step === 4 ? '⚖ N.C.' : n.step) + ' ').join('');
if (!um && !uy && !prev.length) return '';
return '' + escH(e.name) + ' ' + escH((e.cat || e.dept || '').split('\n')[0]) + '
' +
' 3 ? ' style="color:#dc2626;font-weight:800"' : '') + '>' + um + ' ' +
' 12 ? ' style="color:#dc2626;font-weight:800"' : '') + '>' + uy + ' ' +
'' + (over ? '⚠ Over legal limit ' : (um || uy) ? 'Watch ' : '— ') + ' ' +
'' + (badges || 'none ') + ' ' +
'' + (next ? '= 4 ? 'style="background:#dc2626;color:#fff"' : '') + ' onclick="issueNotice(\'' + e.id + '\')">' + (next.n === 5 ? '🚫 Issue Termination' : next.n === 4 ? '⚖ Issue Nota de Culpa' : '📄 Issue Notice ' + next.n) + ' ' : 'Terminated ') + ' ';
}).filter(Boolean).join('');
const allN = (DB.hrNotices || []).slice().sort((a, b) => (b.issuedOn || '').localeCompare(a.issuedOn || ''));
const histRows = allN.map(n => '' + escH(n.issuedOn || '') + ' ' + escH(n.empName || '') + ' ' + escH(NOTICE_STEPS[(n.step || 1) - 1].label) + ' ' + (n.dates || []).length + ' day(s) ' + escH(n.issuedBy || '') + ' ' +
'🖨 Print ' + (isOwner(USER) ? ' 🗑 ' : '') + ' ').join('');
view().innerHTML = '' +
'Staff List ' +
'Monthly Payroll ' +
'⚖ Notices & Discipline
' +
'⚖ Absence Notices & Discipline
Lei n.º 12/23 (LGT): dismissal for unjustified absences requires 3 notices → Nota de Culpa (10 working days defence) → termination. Just cause: >3 days/month or >12/year. ' +
'
Employee Unjustified — this month This year Status Notices issued Action ' +
(rows || emptyRow(6, 'No unjustified absences recorded. Absences marked with a note (sick, leave, permission) are not counted.')) + '
' +
'
Issued Documents Date Employee Document Absences cited Issued by Action ' +
(histRows || emptyRow(6, 'No notices issued yet')) + '
';
}
window.issueNotice = function (empId) {
const e = DB.employees.find(x => x.id === empId);
if (!e || !can.seeSalary(USER)) return;
const prev = noticesFor(empId);
if (prev.length >= 5) { toast('All steps already issued for this employee'); return; }
const step = NOTICE_STEPS[prev.length];
const today = todayISO();
const yrStart = today.slice(0, 4) + '-01-01';
const lastOn = prev.length ? prev[prev.length - 1].issuedOn : '';
const from = lastOn && lastOn > yrStart ? lastOn : yrStart;
const dates = step.k === 'termin' ? (prev.find(x => x.type === 'notaculpa') || {}).dates || [] : unjustifiedDates(empId, from, today);
if (!dates.length && step.n <= 4) { toast('No unjustified absences since the last notice — nothing to cite'); return; }
if (step.k === 'notaculpa' && !confirm('Open formal disciplinary proceedings (Nota de Culpa) against ' + e.name + '?\n\nThe worker gets 10 WORKING DAYS to present a written defence. Termination can only be issued after that period.')) return;
if (step.k === 'termin') {
const nc = prev.find(x => x.type === 'notaculpa');
if (!confirm('Issue TERMINATION WITH JUST CAUSE for ' + e.name + '?\n\nNota de Culpa was issued on ' + (nc ? nc.issuedOn : '—') + '. Confirm that 10 working days have passed and any defence presented was considered.\n\nThis prints the dismissal letter. The employee record itself is NOT deleted — release them from the Staff List afterwards.')) return;
}
const n = { id: uid('ntc'), empId, empName: e.name, step: step.n, type: step.k, dates, issuedOn: today, issuedBy: USER.displayName || USER.username };
DB.hrNotices.push(n);
save(); logAct('Disciplinary: ' + step.label + ' — ' + e.name);
toast(step.label + ' recorded');
render(); printNotice(n.id);
};
window.delNotice = function (id) {
if (!isOwner(USER)) return;
const n = (DB.hrNotices || []).find(x => x.id === id);
if (!n || !confirm('Delete this ' + NOTICE_STEPS[(n.step || 1) - 1].label + ' for ' + n.empName + '? Only do this if it was issued by mistake.')) return;
DB.hrNotices = DB.hrNotices.filter(x => x.id !== id);
save(); render(); toast('Notice deleted');
};
window.printNotice = function (id) {
const n = (DB.hrNotices || []).find(x => x.id === id);
if (!n) return;
const e = DB.employees.find(x => x.id === n.empId) || { name: n.empName };
const step = NOTICE_STEPS[(n.step || 1) - 1];
const prev = noticesFor(n.empId).filter(x => x.step < n.step);
const fmtD = ds => ds.split('-').reverse().join('/');
const dateList = (n.dates || []).map(fmtD).join(', ') || '—';
const prevList = prev.filter(x => x.step <= 3).map(x => fmtD(x.issuedOn)).join(', ');
const nc = prev.find(x => x.type === 'notaculpa');
let corpo = '', corpoEn = '';
if (n.type === 'advert1' || n.type === 'advert2' || n.type === 'advert3') {
corpo = 'Vimos por este meio notificá-lo(a), nos termos do Artigo 87.º da Lei n.º 12/23, de 27 de Dezembro (Lei Geral do Trabalho), de que se registaram as seguintes faltas ao trabalho sem qualquer justificação :' + dateList + ' (' + (n.dates || []).length + ' dia(s)) As faltas injustificadas constituem infracção disciplinar. Fica advertido(a) de que deverá justificar por escrito, com a devida prova, qualquer ausência ao trabalho.' +
(n.type === 'advert2' ? ' Recorda-se que já foi advertido(a) por escrito em ' + prevList + ' . A repetição desta conduta agravará a medida disciplinar a aplicar.' : '') +
(n.type === 'advert3' ? ' Já foi advertido(a) por escrito em ' + prevList + ' . Esta é a ÚLTIMA ADVERTÊNCIA — em caso de nova falta injustificada, será instaurado processo disciplinar com vista ao despedimento com justa causa , dado que as faltas injustificadas superiores a 3 dias por mês ou 12 por ano constituem justa causa de despedimento nos termos da lei.' : '');
corpoEn = 'You are hereby notified, under Article 87 of Law 12/23 of 27 December (General Labour Law), that you were absent from work on the dates above without any justification. Unjustified absence is a disciplinary infraction. Any future absence must be justified in writing with proof.' + (n.type === 'advert3' ? ' THIS IS THE FINAL WARNING — one more unjustified absence will open disciplinary proceedings for dismissal with just cause.' : '');
} else if (n.type === 'notaculpa') {
corpo = 'Nos termos da Lei n.º 12/23, de 27 de Dezembro (Lei Geral do Trabalho), vem esta entidade empregadora deduzir contra V. Ex.ª a presente NOTA DE CULPA , imputando-lhe os seguintes factos: 1. Faltou ao trabalho, sem apresentar qualquer justificação, nos dias: ' + dateList + ' (' + (n.dates || []).length + ' dia(s)); 2. Foi advertido(a) por escrito em ' + prevList + ' , tendo mantido a mesma conduta. Os factos descritos constituem infracção disciplinar grave. As faltas injustificadas que excedam 3 dias por mês ou 12 dias por ano constituem justa causa de despedimento. Nos termos da lei, dispõe de 10 (dez) dias úteis , a contar da recepção desta Nota de Culpa, para apresentar por escrito a sua defesa, juntar documentos e indicar testemunhas. Fica advertido(a) de que a intenção desta entidade empregadora é a aplicação da medida de despedimento com justa causa .';
corpoEn = 'STATEMENT OF CHARGES: you were absent without justification on the dates above despite written warnings. Unjustified absences exceeding 3 days/month or 12 days/year are just cause for dismissal. You have 10 working days from receipt of this letter to present your written defence, documents and witnesses. The company\'s stated intention is dismissal with just cause.';
} else {
corpo = 'Na sequência do processo disciplinar instaurado contra V. Ex.ª (Nota de Culpa de ' + (nc ? fmtD(nc.issuedOn) : '—') + ' ), decorrido o prazo legal de defesa e ponderados todos os elementos do processo, verifica-se que os factos imputados — faltas injustificadas ao trabalho nos dias ' + dateList + ' , após três advertências escritas (' + prevList + ' ) — se encontram provados. As faltas injustificadas superiores a 3 dias por mês ou 12 dias por ano constituem justa causa de despedimento nos termos da Lei n.º 12/23, de 27 de Dezembro (Lei Geral do Trabalho). Assim, decide esta entidade empregadora aplicar-lhe a medida disciplinar de DESPEDIMENTO COM JUSTA CAUSA , com efeitos a partir de ' + fmtD(n.issuedOn) + ' . Os créditos salariais vencidos até à data da cessação ser-lhe-ão apurados e pagos nos termos legais, devendo comparecer no departamento de Recursos Humanos para a formalização da cessação e devolução de bens da empresa.';
corpoEn = 'Following the disciplinary process (Statement of Charges dated ' + (nc ? fmtD(nc.issuedOn) : '—') + ') and after the legal defence period, the charged facts are proven. Unjustified absences exceeding 3 days/month or 12 days/year constitute just cause for dismissal under Law 12/23. The company hereby applies the disciplinary measure of DISMISSAL WITH JUST CAUSE, effective ' + fmtD(n.issuedOn) + '. Outstanding salary credits will be settled per the law.';
}
const w = window.open('', '_blank', 'width=850,height=700');
w.document.write('' + step.pt + ' — ' + escH(e.name) + ' ' +
'' +
'MEGACORP INDÚSTRIA (SU), LDA
' +
'
' + escH(getFactoryAddress()) + ' · NIF: ' + escH(getCompanyNif()) + ' · ' + escH(getCompanyEmail()) + '
' +
'
Ref.: RH/' + n.id.slice(-6).toUpperCase() + '/' + n.issuedOn.slice(0, 4) + ' Luanda, ' + fmtD(n.issuedOn) + '
' +
'' + step.pt + '
' + step.en + '
' +
'Trabalhador / Employee: ' + escH(e.name) + ' ' +
'Categoria / Category: ' + escH((e.cat || e.dept || '—').split('\n')[0]) + ' ' +
(e.idNo ? 'Documento de Identificação: ' + escH((e.idType || 'BI') + ' ' + e.idNo) + ' ' : '') +
'Data de Admissão / Joined: ' + escH(e.joined || '—') + '
' +
'' + corpo + '
' +
'' + corpoEn + '
' +
'Pela Entidade EmpregadoraFor the Employer ' + escH(n.issuedBy || '') + '
' +
'
Tomei conhecimento — O TrabalhadorAcknowledged — The Worker Data: ____ / ____ / ______
' +
'Em caso de recusa de assinatura, este documento é entregue perante duas testemunhas, que atestam a recusa: If the worker refuses to sign, this document is delivered before two witnesses who attest the refusal: Testemunha 1: ______________________________ Testemunha 2: ______________________________
' +
'🖨 Print / Imprimir
' +
'');
w.document.close();
};
function hrPayroll() {
const currentMo = todayISO().slice(0, 7);
if (!window._payrollMo) window._payrollMo = currentMo;
const mo = window._payrollMo;
const [yr, moN] = mo.split('-').map(Number);
const daysInMo = new Date(yr, moN, 0).getDate();
const payDoc = getPayrollDoc(mo);
const hrQ = (hrSearch || '').trim();
const empList = DB.employees.filter(e => {
if (isOwnerEmp(e)) return false;
if (e.status === 'Released') return false;
return matchEmpSearch(e, hrQ);
});
// Two approval groups: Locals (paid in Kz) and Expatriates (paid in INR) — approved and paid separately
const allRows = empList.map(e => computePayRow(e, mo, daysInMo, payDoc.rows[e.id]));
const payGroup = window._payGroup === 'expat' ? 'expat' : 'local';
const groupOf = r => isExpatType(r.e.staffType) ? 'expat' : 'local';
const grpCount = k => { const g = allRows.filter(r => groupOf(r) === k); return { total: g.length, ok: g.filter(r => r.e.salaryApproved).length }; };
const rows = allRows.filter(r => groupOf(r) === payGroup);
const gLabel = payGroup === 'expat' ? 'expatriates' : 'locals';
const gCnt = grpCount(payGroup), gLocal = grpCount('local'), gExpat = grpCount('expat');
const gUnapproved = rows.filter(r => !r.e.salaryApproved && can.seeSalaryForEmp(USER, r.e)).length;
const tableRows = rows.map(r => {
const canSee = can.seeSalaryForEmp(USER, r.e);
const money = v => emoney(r.e, v); // per-employee currency (Kz / INR / USD) — shadows global money() for this row
const R = v => canSee ? money(v) : '🔒 ';
const isPerm = r.type === 'Permanent Employee';
return `
${r.e.name} ${escH(empCodeOf(r.e))} · ${r.e.role || r.e.dept || ''} · ${isPerm ? 'Perm' : (r.type === 'Expatriate' ? 'Expat' : 'Service')}
${r.presents}
${r.faltas}
${r.totalOT.toFixed(1)}
${R(r.base)}
${canSee ? (r.allowT + r.allowF + r.dailySubs > 0 ? money(r.allowT + r.allowF + r.dailySubs) : '—') : '🔒'}
${canSee ? (r.otPay > 0 ? money(r.otPay) : '—') : '🔒'}
${R(r.gross)}
${canSee ? (isPerm ? money(r.inss) : '—') : '🔒'}
${canSee ? (isPerm ? (r.irt > 0 ? money(r.irt) : 'Isento') : '—') : '🔒'}
${canSee ? ((r.absDisc + r.debitDed + r.otherDisc) > 0 ? '-' + money(r.absDisc + r.debitDed + r.otherDisc) : '—') : '🔒'}${(canSee && r.medDed > 0) ? `incl. 🏥 ${money(r.medDed)}
` : ''}
${R(r.net)}
${(canSee && can.seeSalary(USER)) ? `✎ 🔍 ` : ''}
${r.medPending > 0 ? `🏥 ${r.medPending} ` : ''}
${r.e.salaryApproved ? '✓ ' : ((can.seeSalary(USER) && canSee) ? `Approve ` : '')}
`;
}).join("");
const visibleRows = rows.filter(r => can.seeSalaryForEmp(USER, r.e));
// Totals split by currency (Kz / INR / USD) so Indian and local staff are never summed together
const byCur = {};
visibleRows.forEach(r => { const c = empCur(r.e); byCur[c] = byCur[c] || { net: 0, inss: 0, irt: 0 }; byCur[c].net += r.net; byCur[c].inss += r.inss; byCur[c].irt += r.irt; });
const curTot = k => Object.entries(byCur).map(([c, t]) => `${c} ${Number(t[k] || 0).toLocaleString("en-IN")}`).join(' + ') || money(0);
const grandTotal = visibleRows.reduce((s, r) => s + r.net, 0);
const totInss = visibleRows.reduce((s, r) => s + r.inss, 0);
const totIrt = visibleRows.reduce((s, r) => s + r.irt, 0);
const hiddenExpatsCount = rows.length - visibleRows.length;
const tabBar = '' +
'Staff List ' +
'Monthly Payroll ' +
'⚖ Notices & Discipline ' +
'
';
view().innerHTML = `
${tabBar}
🇦🇴 Locals (Kz) · ${gLocal.ok}/${gLocal.total} approved
🇮🇳 Expatriates (INR) · ${gExpat.ok}/${gExpat.total} approved
${can.seeSalary(USER) ? `
✓ Approve all ${gLabel} (${gUnapproved} left) ` : ''}
${isOwner(USER) ? `
↺ Reset ${gLabel} approvals ` : ''}
Net Payroll ${mo}: ${curTot('net')}
INSS (3%): ${curTot('inss')}
IRT: ${curTot('irt')}
Headcount: ${rows.length}${hiddenExpatsCount > 0 ? ` (${hiddenExpatsCount} restricted)` : ''}
${(() => { const pend = rows.reduce((s, r) => s + (r.medPending || 0), 0); if (!pend) return ''; return `
🏥 ${pend} medical expense${pend > 1 ? 's' : ''} awaiting a payroll decision — deduct from salary or company expense. Affected salaries cannot be approved until decided.
${can.seeSalary(USER) ? `Decide now ` : ''}
`; })()}
Gross = Base + Allowances + Daily Subsidies × days present + OT ((Base+Allow)÷208/h) + Bonus. INSS 3% · IRT per AGT table (≤150,000 exempt) · Absences: (Base+Allow)÷26 × days. Service-contract staff: Base − absences only. Click ✎ to set allowances & month adjustments.
Employee P Faltas OT h
Base Allow. OT Pay Gross
INSS IRT Deduct. NET
${tableRows || emptyRow(13, hrQ ? "No staff matching '" + escH(hrSearch) + "'" : 'No staff')}
`;
const hsin = document.getElementById("hrSearchInput");
if (hsin) {
hsin.oninput = () => { hrSearch = hsin.value; render(); };
if (hrSearch) { hsin.focus(); hsin.setSelectionRange(hrSearch.length, hrSearch.length); }
}
}
window.exportSalaryMapXLSX = function (mo, group) {
const isEx = group === 'expat';
const groupTitle = isEx ? 'EXPATRIADOS / EXPATRIATES — INR' : 'LOCAIS / LOCALS — Kz';
const [yr, moN] = mo.split('-').map(Number);
const daysInMo = new Date(yr, moN, 0).getDate();
const payDoc = getPayrollDoc(mo);
const wb = XLSX.utils.book_new();
const rows = [["MEGACORP INDÚSTRIA SU LDA — MAPA DE SALÁRIO / SALARY MAP — " + mo], [groupTitle], [],
["Nº", "Nome Completo\nFull Name", "Função\nCategory", "Tipo\nType", "Dias Pres.\nDays Present", "Nº Faltas\nAbsent Days", "H.E (h)\nOT hrs",
"Salário Base\nBase Salary", "Subs. Transporte\nTransport", "Subs. Alimentação\nFood", "Subs. Diários\nDaily Subs", "Valor H.E\nOT Pay", "Prémio\nBonus", "Subs. Férias\nVacation",
"Salário Ilíquido\nGross Salary", "INSS 3%", "AGT IRT", "Desconto Faltas\nAbsence Disc.", "Outros Descontos\nOther Deduct.", "Salário Líquido\nNet Salary", "Aprovado\nApproved"]];
let n = 0;
const dl = [['Nome / Name', 'Tipo / Type', 'Valor / Amount', 'Moeda / Currency', 'Motivo / Purpose', 'Instruído por / Instructed by', 'Aprovado por / Approved by', 'Forma de aprovação / How approved', 'Data / Date']];
const tot = { base: 0, gross: 0, inss: 0, irt: 0, net: 0 };
DB.employees.filter(e => !isOwnerEmp(e) && e.status !== 'Released').forEach(e => {
if (!can.seeSalaryForEmp(USER, e)) return;
if (isExpatType(e.staffType) !== isEx) return;
const r = computePayRow(e, mo, daysInMo, payDoc.rows[e.id]);
n++;
tot.base += r.base; tot.gross += r.gross; tot.inss += r.inss; tot.irt += r.irt; tot.net += r.net;
rows.push([n, e.name, e.role || e.dept || '', r.type, r.presents, r.faltas, r.totalOT,
r.base, r.allowT, r.allowF, r.dailySubs, r.otPay, r.bonus, r.ferias,
r.gross, r.type === 'Permanent Employee' ? r.inss : 'N/A', r.type === 'Permanent Employee' ? (r.irt > 0 ? r.irt : 'Isento / Exempt') : 'N/A',
r.absDisc, r.debitDed + r.otherDisc, r.net, e.salaryApproved ? 'Sim / Yes' : 'Pendente / Pending']);
payDeductionLines(e, mo, r).forEach(x => dl.push([e.name, x.type, x.amount, empCur(e), x.purpose, x.by, x.approvedBy, x.how, x.date]));
});
rows.push([]);
rows.push(["", "TOTAL", "", "", "", "", "", tot.base, "", "", "", "", "", "", tot.gross, tot.inss, tot.irt, "", "", tot.net, ""]);
const ws = XLSX.utils.aoa_to_sheet(rows);
ws['!cols'] = [{ wch: 4 }, { wch: 30 }, { wch: 22 }, { wch: 12 }, { wch: 8 }, { wch: 8 }, { wch: 7 }, { wch: 12 }, { wch: 11 }, { wch: 11 }, { wch: 11 }, { wch: 10 }, { wch: 10 }, { wch: 10 }, { wch: 13 }, { wch: 11 }, { wch: 13 }, { wch: 12 }, { wch: 12 }, { wch: 14 }];
XLSX.utils.book_append_sheet(wb, ws, (isEx ? "Expatriates " : "Locals ") + mo);
const wsD = XLSX.utils.aoa_to_sheet(dl);
wsD["!cols"] = [{ wch: 28 }, { wch: 22 }, { wch: 14 }, { wch: 10 }, { wch: 46 }, { wch: 26 }, { wch: 30 }, { wch: 34 }, { wch: 12 }];
XLSX.utils.book_append_sheet(wb, wsD, "Descontos-Deductions");
XLSX.writeFile(wb, `MEGACORP_Salary_Map_${isEx ? "Expatriates" : "Locals"}_${mo}.xlsx`);
toast("Salary Map exported");
};
function exportPayrollCSV(mo) {
const [yr, moN] = mo.split('-').map(Number);
const daysInMo = new Date(yr, moN, 0).getDate();
const payDoc = getPayrollDoc(mo);
const headers = ["Name", "Department", "Staff Type", "Currency", "Present", "Absent (faltas)", "OT Hours", "Base", "Allowances", "Daily Subs", "OT Pay", "Gross", "INSS", "IRT", "Absence Disc", "Other Deduct", "Net", "Approved"];
const csvRows = [headers.join(",")];
DB.employees.filter(e => !isOwnerEmp(e) && e.status !== 'Released').forEach(e => {
const canSee = can.seeSalaryForEmp(USER, e);
const r = computePayRow(e, mo, daysInMo, payDoc.rows[e.id]);
csvRows.push([csvEsc(e.name), csvEsc(e.dept), csvEsc(r.type), empCur(e), r.presents, r.faltas, r.totalOT.toFixed(1),
canSee ? r.base : 'Restricted', canSee ? (r.allowT + r.allowF) : 'Restricted', canSee ? r.dailySubs : 'Restricted',
canSee ? r.otPay : 'Restricted', canSee ? r.gross : 'Restricted', canSee ? r.inss : 'Restricted', canSee ? r.irt : 'Restricted',
canSee ? r.absDisc : 'Restricted', canSee ? (r.debitDed + r.otherDisc) : 'Restricted', canSee ? r.net : 'Restricted',
e.salaryApproved ? 'Yes' : 'Pending'].join(","));
});
const blob = new Blob(["" + csvRows.join("\n")], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `Payroll_${mo}.csv`;
document.body.appendChild(a); a.click(); a.remove();
toast("Payroll CSV downloaded");
}
// ── HR: Released Employees View ────────────────────────────────────────────────
let releasedTypeFilter = 'All';
function hrReleasedView() {
const allReleased = DB.employees.filter(e => e.status === 'Released' && (isOwner(USER) || !isOwnerEmp(e)));
let filtered = releasedTypeFilter === 'All' ? allReleased : allReleased.filter(e => e.staffType === releasedTypeFilter);
const hrQ = (hrSearch || '').trim();
filtered = filtered.filter(e => matchEmpSearch(e, hrQ));
const tabBar = '' +
'Staff List ' +
(can.seeSalary(USER) ? 'Monthly Payroll ' : '') +
'🚪 Released (' + allReleased.length + ') ' +
'
';
const typeFilters = ['All', 'Expatriate', 'Service Contract', 'Permanent Employee', 'Contractor'];
const filterBar = '' +
typeFilters.map(t => {
const count = t === 'All' ? allReleased.length : allReleased.filter(e => e.staffType === t).length;
const active = releasedTypeFilter === t;
return `${t}${count > 0 ? ' (' + count + ')' : ''} `;
}).join('') +
'
';
function releaseRow(e) {
const reasonLabel = e.releaseReason === 'Terminated' ? '🔴 Terminated / Fired' :
e.releaseReason === 'Contract Expired' ? '🟡 Contract Expired' :
e.releaseReason === 'Resigned' ? '🟢 Resigned (Personal)' :
(e.releaseReason || 'Unknown');
const docsCount = (e.releaseDocs || []).length;
const typeColor = e.staffType === 'Expatriate' ? '#b45309' : e.staffType === 'Permanent Employee' ? '#1e40af' : e.staffType === 'Service Contract' ? '#065f46' : '#374151';
const typeBg = e.staffType === 'Expatriate' ? '#fef9f0' : e.staffType === 'Permanent Employee' ? '#eff6ff' : e.staffType === 'Service Contract' ? '#ecfdf5' : '#f8fafc';
return `
${e.name} ${e.staffType || '—'} · Joined: ${e.joined || '—'}
${e.role || '—'}
${tr(e.dept || '')}
${reasonLabel}
${e.releaseDate || '—'}
${e.releaseRemarks || '—'}
📄 Docs${docsCount > 0 ? ' (' + docsCount + ')' : ''}
${isOwner(USER) ? `↩ Reinstate ` : ''}
${isOwner(USER) ? `Delete ` : ''}
`;
}
const rData = paginate(filtered, 'hrReleased');
const rows = rData.items.map(releaseRow).join('');
view().innerHTML = `
${tabBar}
${filterBar}
Released / Offboarded Employees
⬇ Export CSV
⚙ Custom Export
${isOwner(USER) ? '
⚠ Reset ' : ''}
${filtered.length} of ${allReleased.length} shown
Name Role Department Reason Release Date Remarks Action
${rows || emptyRow(7, hrQ ? "No released staff matching '" + escH(hrSearch) + "'" : (releasedTypeFilter === 'All' ? 'No released employees on record.' : 'No ' + releasedTypeFilter + ' in released records.'))}
${rData.html}
`;
const hsin = document.getElementById("hrSearchInput");
if (hsin) {
hsin.oninput = () => { hrSearch = hsin.value; render(); };
if (hrSearch) {
hsin.focus();
hsin.setSelectionRange(hrSearch.length, hrSearch.length);
}
}
}
// ── HR: Release / Offboard Employee Modal ─────────────────────────────────────
function formReleaseEmployee(emp) {
if (!emp) return;
if (emp.status === 'Released') {
toast('This employee has already been released. Use "Docs" to add separation documents.');
return;
}
let releaseDocs = emp.releaseDocs || [];
const staffBadgeColor = emp.staffType === 'Expatriate' ? '#b45309' : '#1e40af';
const staffBgColor = emp.staffType === 'Expatriate' ? '#fef9f0' : '#eff6ff';
openModal('Release Employee — ' + emp.name,
`
${emp.staffType === 'Expatriate' ? '✈' : '👷'}
${emp.name}
${emp.staffType || 'Staff'} · ${emp.dept || ''} · Joined: ${emp.joined || '—'}
`+
`Reason for Separation
-- Select reason --
🔴 Terminated / Fired by Company
🟡 Contract Expired (natural end)
🟢 Resigned (Personal Reason / Own Will)
`+
`
` +
`` +
field('Date of Release / Last Working Day', 'f-release-date', todayISO(), 'date') +
field('Remarks / Notes', 'f-release-rem', '') +
`
` +
``,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: '⚠ Confirm Release', cls: '', style: 'background:#dc2626;color:#fff', fn: async () => {
const reason = document.getElementById('f-release-reason').value;
const releaseDate = val('f-release-date');
if (!reason) return toast('Please select a reason for release.');
if (!releaseDate) return alertField();
const inp = document.getElementById('f-release-docs');
if (inp && inp.files.length) { const nf = await readFiles(inp); releaseDocs = releaseDocs.concat(nf); }
emp.status = 'Released'; hrAlert('release', emp, 'Released / offboarded');
emp.releaseReason = reason;
emp.releaseDate = releaseDate;
emp.releaseRemarks = val('f-release-rem');
emp.releaseDocs = releaseDocs;
emp.releasedBy = USER.displayName || USER.username;
emp.releasedAt = new Date().toISOString();
save();
sendWhatsAppNotification('hr_release', {
recipient: emp.name,
phone: emp.phone || '+244 923 000 000',
message: `🚪 [STAFF OFFBOARDING NOTICE]\nWorker ${emp.name} (ID: ${empCodeOf(emp)})\nHas been offboarded / released from active duty on ${releaseDate}.\nReason: ${reason}\nProcessed By: ${emp.releasedBy}`
});
logAct(`Employee Released: ${emp.name} — Reason: ${reason} — By: ${emp.releasedBy}`);
toast('Employee released & offboarded. Record moved to Released tab.');
closeModal();
hrTab = 'released';
render();
}
}]
);
// Dynamic reason note
setTimeout(() => {
const sel = document.getElementById('f-release-reason');
const note = document.getElementById('f-release-reason-note');
if (!sel || !note) return;
sel.onchange = () => {
const v = sel.value;
if (!v) { note.style.display = 'none'; return; }
note.style.display = 'block';
if (v === 'Terminated') { note.style.background = '#fff0f0'; note.style.border = '1.5px solid #ef4444'; note.style.color = '#b91c1c'; note.textContent = '⚠ Termination: Ensure final settlement, clearance, and exit documents are uploaded.'; }
else if (v === 'Contract Expired') { note.style.background = '#fffbeb'; note.style.border = '1.5px solid #f59e0b'; note.style.color = '#92400e'; note.textContent = '📋 Contract end: Upload the final salary settlement and any outstanding dues documentation.'; }
else if (v === 'Resigned') { note.style.background = '#f0fdf4'; note.style.border = '1.5px solid #22c55e'; note.style.color = '#15803d'; note.textContent = '✅ Resignation: Upload the resignation letter and experience/reference letter if applicable.'; }
};
const fi = document.getElementById('f-release-docs');
if (fi) fi.onchange = () => { document.getElementById('release-doc-list').textContent = fi.files.length + ' file(s) selected for upload'; };
}, 80);
}
// ── HR: View / Add Post-Separation Documents ──────────────────────────────────
function formReleaseViewDocs(emp) {
if (!emp) return;
let releaseDocs = emp.releaseDocs || [];
function renderDocList() {
return releaseDocs.length
? releaseDocs.map((f, i) => `
${f.type && f.type.includes('pdf') ? '📄' : '🖼'}
${f.name || 'Document ' + (i + 1)}
${f.data ? `
⬇ Download ` : ''}
`).join('')
: 'No separation documents uploaded yet.
';
}
openModal('Separation Documents — ' + emp.name,
`
${emp.name} · ${emp.staffType || ''}
Released: ${emp.releaseDate || '—'} · Reason: ${emp.releaseReason || '—'}
`+
`${renderDocList()}
` +
``,
[{ label: 'Close', cls: 'secondary', fn: closeModal }, {
label: 'Upload & Save', cls: '', fn: async () => {
const inp = document.getElementById('f-sep-newdoc');
if (inp && inp.files.length) {
const nf = await readFiles(inp);
releaseDocs = releaseDocs.concat(nf);
emp.releaseDocs = releaseDocs;
save();
toast(nf.length + ' document(s) added to separation record.');
document.getElementById('sep-doc-list').innerHTML = renderDocList();
} else {
toast('No files selected.');
}
}
}]
);
}
// ── Security & Gate Log Module ─────────────────────────────────────────────────
let secTab = 'all';
let secDateFilter = 'today';
function securityView() {
if (!canMod(USER, 'security') && !isOwner(USER)) {
view().innerHTML = `🛡
Access Denied You need Security module access to view the Gate Log.
`;
return;
}
if (!DB.security) DB.security = [];
const today = todayISO();
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
let logs = DB.security;
if (secDateFilter === 'today') logs = logs.filter(e => e.date === today);
else if (secDateFilter === 'week') logs = logs.filter(e => e.date >= weekAgo);
// 'all' = no date filter
const typeMap = { all: 'All', visitor: '👤 Visitor', material_in: '📦 Material In', material_out: '📤 Material Out', vehicle: '🚗 Vehicle' };
if (secTab !== 'all') logs = logs.filter(e => e.type === secTab);
// Stats
const todayAll = DB.security.filter(e => e.date === today);
const visitorIn = todayAll.filter(e => e.type === 'visitor' && e.status === 'In').length;
const matIn = todayAll.filter(e => e.type === 'material_in').length;
const matOut = todayAll.filter(e => e.type === 'material_out').length;
const cleared = todayAll.filter(e => e.status === 'Cleared').length;
const tabs = Object.entries(typeMap).map(([k, l]) => {
const cnt = (k === 'all' ? DB.security : DB.security.filter(e => e.type === k)).filter(e => secDateFilter === 'today' ? e.date === today : secDateFilter === 'week' ? e.date >= weekAgo : true).length;
const active = secTab === k;
return `${l}${cnt > 0 ? ' (' + cnt + ')' : ''} `;
}).join('');
const dateFilters = [['today', 'Today'], ['week', 'This Week'], ['all', 'All Time']].map(([k, l]) =>
`${l} `
).join('');
function typeLabel(t) {
return t === 'visitor' ? '👤 Visitor ' :
t === 'material_in' ? '📦 Mat In ' :
t === 'material_out' ? '📤 Mat Out ' :
t === 'vehicle' ? '🚗 Vehicle ' : '—';
}
function statusBadge(s) {
return s === 'In' ? '🟡 In ' :
s === 'Cleared' ? '✅ Cleared ' : '—';
}
const scData = paginate(logs, 'sec');
const rows = scData.items.map(e => {
const photoCount = (e.photos || []).length;
return `
${e.date || '—'}${e.timeIn || '—'}
${typeLabel(e.type)}
${e.visitorName || e.materialDesc || '—'} ${e.vehicleNo ? `🚗 ${e.vehicleNo}
` : ''}
${e.purpose || '—'}
${e.forPerson || '—'}
${statusBadge(e.status)}${e.timeOut ? 'Out: ' + e.timeOut : ''}
${photoCount > 0 ? `📷 ${photoCount} ` : ''}
${e.status === 'In' && (canMod(USER, 'security') || isOwner(USER)) ? `✅ Check Out ` : ''}
✏ Edit
${isOwner(USER) ? `Del ` : ''}
`;
}).join('');
view().innerHTML = `
Today's Entries
${todayAll.length}
Total gate activity today
👤 Visitors Inside
${visitorIn}
Currently on site
📦 Materials In
${matIn}
Today
✅ Cleared Today
${cleared}
Checked out / closed
${tabs}
${dateFilters}
🛡 Security & Gate Log
⬇ Export CSV
⚙ Custom Export
📥 Bulk Import
${isOwner(USER) ? `
⚠ Reset ` : ''}
+ New Entry
Date / Time In Type Name / Description Purpose For / Meeting Status Action
${rows || emptyRow(7, 'No gate entries found.')}
${scData.html}
`;
}
// ── Security: Gate Entry Form ───────────────────────────────────────────────────
function formGateEntry(entry) {
const ed = !!entry;
const empOptions = `-- Select person / type name -- ` + (DB.employees || []).filter(e => e.status !== 'Released').map(e => `${e.name} (${e.dept || ''}) `).join('');
openModal(ed ? 'Edit Gate Entry' : 'New Gate Entry',
`` +
`
Entry Type
👤 Visitor / Guest
📦 Material / Tools In
📤 Material / Tools Out
🚗 Vehicle Entry
`+
field('Date', 'f-sec-date', ed ? entry.date : todayISO(), 'date') +
`
` +
`
`+ field('Visitor / Person Name', 'f-sec-name', ed ? escAttr(entry.visitorName || '') : '') + `
`+
`
`+ field('Material / Item Description', 'f-sec-matdesc', ed ? escAttr(entry.materialDesc || '') : '') + `
`+
`` +
field('ID / Document / Passport No.', 'f-sec-idno', ed ? escAttr(entry.idNo || '') : '') +
field('Vehicle Number (if any)', 'f-sec-vehicle', ed ? escAttr(entry.vehicleNo || '') : '') +
`
` +
`` +
field('Time In', 'f-sec-timein', ed ? (entry.timeIn || '') : (new Date().toTimeString().slice(0, 5)), 'time') +
field('Time Out (leave blank if still inside)', 'f-sec-timeout', ed ? (entry.timeOut || '') : '', 'time') +
`
` +
`Purpose / Reason of Visit
`+
`For / Meeting With (who they came to see)
${empOptions}
`+
`Who logged this entry / Guard Name
`+
``,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: ed ? 'Save' : 'Log Entry', cls: '', fn: async () => {
const type = document.getElementById('f-sec-type').value;
const date = val('f-sec-date');
const name = val('f-sec-name');
const matdesc = val('f-sec-matdesc');
const mainName = type === 'material_in' || type === 'material_out' ? matdesc : name;
if (!mainName || !date) return toast('Please fill name/description and date.');
const selPerson = document.getElementById('f-sec-forperson-sel').value;
const txtPerson = val('f-sec-forperson-txt');
const forPerson = txtPerson || selPerson || '';
const inp = document.getElementById('f-sec-photos');
let photos = ed ? (entry.photos || []) : [];
if (inp && inp.files.length) { const nf = await readFiles(inp); photos = photos.concat(nf); }
const timeOut = val('f-sec-timeout');
const obj = {
type, date,
visitorName: name,
materialDesc: matdesc,
idNo: val('f-sec-idno'),
vehicleNo: val('f-sec-vehicle'),
timeIn: val('f-sec-timein'),
timeOut,
status: timeOut ? 'Cleared' : 'In',
purpose: document.getElementById('f-sec-purpose').value,
forPerson,
loggedBy: val('f-sec-loggedby'),
photos,
createdBy: USER.displayName || USER.username
};
if (ed) { Object.assign(entry, obj); }
else { DB.security.unshift({ id: uid('sec'), ...obj }); }
save();
sendWhatsAppNotification('security_gate', {
recipient: forPerson || 'Security Chief & HSE',
phone: '+244 923 000 010',
message: `🚪 [GATE LOG ENTRY]\nType: ${type.toUpperCase()} | Date: ${date}\nName/Desc: ${mainName}\nTime In: ${obj.timeIn || '—'} | Time Out: ${timeOut || 'Still Inside'}\nMeeting With: ${forPerson || 'General Site'}\nLogged By: ${obj.loggedBy}`
});
logAct(`Gate ${ed ? 'updated' : 'logged'}: ${mainName} (${type})`);
toast(ed ? 'Entry updated.' : 'Gate entry logged.');
closeModal(); render();
}
}]
);
// Dynamic: show material vs visitor fields
setTimeout(() => {
const typeEl = document.getElementById('f-sec-type');
const vFields = document.getElementById('f-visitor-fields');
const mFields = document.getElementById('f-material-fields');
const photoInp = document.getElementById('f-sec-photos');
const photoCount = document.getElementById('sec-photo-count');
function updateTypeFields() {
const t = typeEl.value;
const isMat = t === 'material_in' || t === 'material_out';
if (vFields) vFields.style.display = isMat ? 'none' : '';
if (mFields) mFields.style.display = isMat ? '' : 'none';
}
if (typeEl) typeEl.onchange = updateTypeFields;
if (photoInp) photoInp.onchange = () => { if (photoCount) photoCount.textContent = photoInp.files.length + ' photo(s) selected'; };
updateTypeFields();
// Pre-select "for person" if editing
if (ed && entry.forPerson) {
const sel = document.getElementById('f-sec-forperson-sel');
if (sel) {
const opt = [...sel.options].find(o => o.value === entry.forPerson);
if (opt) sel.value = entry.forPerson;
}
}
}, 80);
}
// ── Security: Quick Check-Out ───────────────────────────────────────────────────
function formGateCheckOut(entry) {
if (!entry) return;
const now = new Date().toTimeString().slice(0, 5);
openModal('✅ Check Out — ' + (entry.visitorName || entry.materialDesc || 'Entry'),
`
${entry.visitorName || entry.materialDesc || '—'}
Type: ${entry.type === 'visitor' ? 'Visitor' : entry.type === 'material_in' ? 'Material In' : entry.type === 'material_out' ? 'Material Out' : 'Vehicle'} · Time In: ${entry.timeIn || '—'}
`+
field('Time Out', 'f-checkout-time', now, 'time') +
`Exit Notes (optional)
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: 'Confirm Check Out', cls: '', style: 'background:#16a34a;color:#fff', fn: () => {
const timeOut = val('f-checkout-time');
if (!timeOut) return toast('Enter check-out time.');
entry.timeOut = timeOut;
entry.status = 'Cleared';
entry.exitNotes = document.getElementById('f-checkout-notes').value;
save();
logAct('Gate check-out: ' + (entry.visitorName || entry.materialDesc));
toast('Checked out at ' + timeOut);
closeModal(); render();
}
}]
);
}
// ── Security: View Entry Details / Photos ───────────────────────────────────────
function formGateDetail(entry) {
if (!entry) return;
const photos = (entry.photos || []);
const typeLabels = { visitor: '👤 Visitor/Guest', material_in: '📦 Material In', material_out: '📤 Material Out', vehicle: '🚗 Vehicle' };
const photoGallery = photos.length
? `` +
photos.map((p, i) => `
`).join('') + `
`
: `No photos uploaded for this entry.
`;
openModal('Gate Entry — ' + (entry.visitorName || entry.materialDesc || 'Details'),
`
Type
${typeLabels[entry.type] || entry.type}
Name / Item
${entry.visitorName || entry.materialDesc || '—'}
ID / Doc No.
${entry.idNo || '—'}
Vehicle No.
${entry.vehicleNo || '—'}
For / Meeting With
${entry.forPerson || '—'}
Time In
${entry.timeIn || '—'}
Time Out
${entry.timeOut || 'Still on site'}
Purpose / Reason
${entry.purpose || '—'}
${entry.exitNotes ? `Exit Notes
${entry.exitNotes}
` : ''}
Photos (${photos.length})
${photoGallery}`,
[{ label: 'Close', cls: 'secondary', fn: closeModal }, { label: '✏ Edit', cls: '', fn: () => { closeModal(); formGateEntry(entry); } }]
);
}
// ── Users & Access View ───────────────────────────────────────────────────────
function usersView() {
if (!isOwner(USER) && !isDev(USER)) return view().innerHTML = `Access Denied
`;
const rows = DB.users.map(u => `
${u.displayName || u.name} Username: ${u.username || u.user}${u.email ? ` Google: ${escH(u.email)}` : ''}
${pill(isOwner(u) ? 'Owner' : isDev(u) ? 'Developer' : 'Custom')}
${isOwner(u) ? 'All modules' : (u.modules || []).map(x => MODLABELS[x] || x).join(', ')}
${isOwner(u) ? 'All departments' : ((u.depts || []).join(', ') || 'All departments')}
${tr("Edit Access")}
${tr("Reset Password")}
${tr("Remove")}
`).join("");
view().innerHTML = `
User Credentials & Access Controls
${isOwner(USER) ? `
📲 WhatsApp Alerts ${tr("+ Add Login")} ` : ''}
Name / Username Level Modules Departments Action
${rows}
`;
}
// ── Salary Deductions View ────────────────────────────────────────────────────
// ── Licences & Title Deeds Module ────────────────────────────────────────────
let legalTypeFilter = 'All';
function legalView() {
if (!DB.legalDocs) DB.legalDocs = [];
const TYPES = ['All', 'Licence', 'Licence / Title', 'Certificate', 'Registry Record', 'Tax Registration', 'Title Deed'];
const list = legalTypeFilter === 'All' ? DB.legalDocs : DB.legalDocs.filter(d => d.type === legalTypeFilter);
const lgData = paginate(list, 'legal');
function legalStatus(d) {
const exp = d.expiry || "";
if (!exp || exp.toLowerCase().includes('no expiry') || exp.includes('No printed')) { return 'No Expiry '; }
if (isNaN(Date.parse(exp.slice(0, 10)))) return '' + exp + ' ';
const days = daysTo(exp.slice(0, 10));
if (days < 0) return 'Expired ' + Math.abs(days) + ' days ago ';
if (days <= 60) return 'Exp. in ' + days + ' days ';
return 'Valid ';
}
const rows = lgData.items.length ? lgData.items.map(d => `
${d.name} ${d.notes || ''}
${d.type || '—'}
${d.issued || '—'}
${d.expiry || '—'}
${legalStatus(d)}
${attThumbs(d.files || [])}
Edit
${isOwner(USER) ? `Delete ` : ''}
`).join('') : `No records in this category
`;
const filterBtns = TYPES.map(t => `${t} `).join(' ');
view().innerHTML = `
🏰 Licences & Title Deeds Register
${isOwner(USER) ? '
⚠ Reset ' : ''}
⬇ Export CSV
⚙ Custom Export
📥 Bulk Import
+ Add Record
${filterBtns}
Document / Title Type Issued Expiry / Validity Status File Action
${rows}
${lgData.html}
`;
}
function formLegal(ed) {
const TYPES = ['Licence', 'Licence / Title', 'Certificate', 'Registry Record', 'Tax Registration', 'Title Deed'];
openModal(ed ? "Edit Legal Record" : "Add Legal Record",
sel("Type", "fl-type", TYPES, ed ? ed.type : 'Licence') +
field("Document Name / Title", "fl-name", ed ? ed.name : '') +
`` + field("Issued Date", "fl-issued", ed ? ed.issued : '', 'date') + field("Expiry / Validity", "fl-expiry", ed ? ed.expiry : '') + `
` +
field("Notes / Reference Numbers", "fl-notes", ed ? ed.notes : '') +
`Attach Document (PDF/image) ` +
`
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: ed ? "Save" : "Add Record", cls: "", fn: async () => {
const name = val("fl-name"); if (!name) return alertField();
const obj = ed || (() => { const o = { id: uid("ld"), files: [] }; DB.legalDocs.push(o); return o; })();
obj.type = val("fl-type"); obj.name = name; obj.issued = val("fl-issued"); obj.expiry = val("fl-expiry"); obj.notes = val("fl-notes");
const fileInp = document.getElementById("fl-file");
if (fileInp && fileInp.files.length) {
const got = await readFiles(fileInp);
obj.files = (obj.files || []).concat(got);
}
save(); logAct((ed ? 'Updated' : 'Added') + ' legal doc: ' + name); toast(ed ? 'Record updated' : 'Record added'); closeModal(); render();
}
}]);
}
function downloadLegalTemplate() {
const headers = ["Document Name", "Type", "Issue Date (YYYY-MM-DD)", "Expiry / Validity", "Notes"];
const examples = [
["Alvará Comercial - MEGACORP", "Licence", "2022-12-20", "2027-12-20", "Commercial business licence"],
["Título de Concessão - Plot 26", "Title Deed", "2022-12-09", "~09-Dec-2082", "Direito de Superfície, Catumbela"]
];
const csv = [headers, ...examples].map(r => r.map(csvEsc).join(",")).join("\n");
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "LegalDocs_Template.csv";
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast("Template downloaded");
}
function formBulkLegal() {
openModal("Bulk Import Licences & Title Deeds",
`Upload the MEGACORP register XLSX (or matching CSV). Columns: Document Name, Type, Issue Date, Expiry/Validity, Notes. First row is treated as header and skipped.
` +
`Download CSV Template
` +
`Select CSV or XLSX File
` +
`
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, { label: "Import", cls: "", fn: () => toast("Upload a file first") }]);
document.getElementById("btn-dl-legal-temp").onclick = downloadLegalTemplate;
const fileEl = document.getElementById("f-legal-file");
const preview = document.getElementById("legal-preview");
fileEl.onchange = async () => {
const files = fileEl.files; if (!files.length) return;
try {
const rows = await parseSpreadsheet(files[0]);
if (rows.length < 2) throw new Error("File is empty or has only a header row.");
const toAdd = [];
for (let i = 1; i < rows.length; i++) {
const c = rows[i];
const name = (c[0] || "").toString().trim(); if (!name) continue;
toAdd.push({ id: uid("ld"), name, type: (c[1] || "").trim() || "Licence", issued: (c[2] || "").trim(), expiry: (c[3] || "").trim(), notes: (c[4] || "").trim(), files: [] });
}
preview.innerHTML = '' + toAdd.length + ' records ready to import ';
const importBtn = document.querySelector("#modalMount .btn:not(.secondary)");
if (importBtn) importBtn.onclick = () => {
DB.legalDocs.push(...toAdd); save();
toast(toAdd.length + " records imported!");
logAct("Bulk imported " + toAdd.length + " legal docs");
closeModal(); render();
};
} catch (err) { preview.innerHTML = '' + err.message + ' '; }
};
}
// ── To-Do List & Daily Goals Module Implementation ───────────────────────────
function normalizeGoalText(txt) {
if (!txt || typeof txt !== 'string') return '';
const str = txt.trim();
if (!str) return '';
const preserves = new Set([
'PO', 'IND', 'LR', 'NIF', 'IRT', 'CSV', 'PDF', 'XLPE', 'MS', 'HR',
'WA', 'IT', 'ID', 'OK', 'ETA', 'JCB', 'OPC', 'MINCO', 'AGT', 'MINAMBI',
'MINDCOM', 'ANRM', 'MIREMPET', 'APIEX', 'CRIP', 'CRBFA', 'HSE', 'OT',
'P&D', 'QC', 'QA', 'MIS', 'SOP'
]);
const tokens = str.split(/(\s+)/);
let isFirstWord = true;
const result = tokens.map(token => {
if (!token.trim()) return token;
if (/#|[0-9]/.test(token)) return token;
const upper = token.toUpperCase().replace(/[^A-Z]/g, '');
if (preserves.has(upper)) return token.toUpperCase();
const lower = token.toLowerCase();
if (isFirstWord) {
isFirstWord = false;
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
return lower;
});
return result.join('');
}
function renderDashboardGoalsContent() {
const todayStr = todayISO();
const allGoals = DB.dailyGoals || [];
// Today's goals + unfinished goals carried over from earlier days
const todayGoals = allGoals.filter(g => g.date === todayStr || (!g.completed && !!g.date && g.date < todayStr));
const isOwnerUser = isOwner(USER);
if (isOwnerUser) {
const users = DB.users || [];
if (!users.length) return `No users found
`;
return users.map(u => {
const uName = (u.displayName || u.name || u.username || u.user || '').trim();
const userKey = (u.username || u.user || '').toLowerCase().trim();
const userGoals = todayGoals.filter(g => String(g.user || '').toLowerCase().trim() === userKey);
const goalItems = userGoals.map(g => {
const isAssigned = g.assignedBy && g.assignedBy.toLowerCase().trim() !== userKey;
const assignTag = isAssigned ? `📌 Assigned ` : '';
return `
${g.completed ? '✓' : '•'}
${escH(normalizeGoalText(g.text))} ${assignTag}${g.date && g.date !== todayStr ? `⏩ from ${escH(g.date)} ` : ''}
`;
}).join('');
return `
${escH(uName)}
+ Assign Task
${userGoals.length ? `
` : `
${escH(uName)} — No goals submitted yet
`}
`;
}).join('');
} else {
const currentUsername = (USER.username || USER.user || '').toLowerCase().trim();
const myTodayGoals = todayGoals.filter(g => String(g.user || '').toLowerCase().trim() === currentUsername);
const completedCount = myTodayGoals.filter(g => g.completed).length;
const totalCount = myTodayGoals.length;
const pct = totalCount ? Math.round((completedCount / totalCount) * 100) : 0;
if (!myTodayGoals.length) {
return `
You haven't set your goals for today (${todayStr}) yet.
+ Set Today's Goals
`;
}
const assignedToday = myTodayGoals.filter(g => g.assignedBy && g.assignedBy.toLowerCase().trim() !== currentUsername);
const directToday = myTodayGoals.filter(g => !g.assignedBy || g.assignedBy.toLowerCase().trim() === currentUsername);
const renderDashboardItem = g => `
${escH(normalizeGoalText(g.text))}
${g.assignedBy && g.assignedBy.toLowerCase().trim() !== currentUsername ? `Assigned ` : ''}
`;
return `
Progress (${completedCount} of ${totalCount} completed)
${pct}%
${assignedToday.length ? `
📌 Assigned Tasks
${assignedToday.map(renderDashboardItem).join('')}
` : ''}
${directToday.map(renderDashboardItem).join('')}
+ Add / Edit Goals
`;
}
}
function toggleGoalDashboard(id) {
const g = (DB.dailyGoals || []).find(x => x.id === id);
if (!g) return;
g.completed = !g.completed;
g.updatedAt = (new Date()).toISOString();
save();
render();
}
function checkDailyGoalPrompt() {
if (window._dailyGoalPromptShownToday || !USER) return;
const todayStr = todayISO();
const currentUsername = (USER.username || USER.user || '').toLowerCase().trim();
const myGoalsToday = (DB.dailyGoals || []).filter(g =>
String(g.user || '').toLowerCase().trim() === currentUsername && g.date === todayStr
);
if (!myGoalsToday.length) {
window._dailyGoalPromptShownToday = true;
setTimeout(() => formDailyGoalsPrompt(), 400);
}
}
function formDailyGoalsPrompt() {
const todayStr = todayISO();
const currentUsername = (USER.username || USER.user || '').toLowerCase().trim();
const myGoalsToday = (DB.dailyGoals || []).filter(g =>
String(g.user || '').toLowerCase().trim() === currentUsername && g.date === todayStr
);
const existingTexts = myGoalsToday.map(g => g.text);
let goalInputsHtml = (existingTexts.length ? existingTexts : ['', '', '']).map((txt, i) => `
Goal / Task #${i + 1}
`).join('');
openModal("📝 Set Your Daily Goals — " + todayStr,
`Enter your planned goals/tasks for today. They will be saved under your account and visible to your Project Owner.
` +
`${goalInputsHtml}
` +
`` +
`+ Add Another Goal ` +
`
`,
[
{ label: "Remind Me Later", cls: "secondary", fn: closeModal },
{
label: "Save Today's Goals", cls: "", fn: () => {
const wrap = document.getElementById("goal-inputs-wrap");
if (!wrap) return closeModal();
const inputs = wrap.querySelectorAll("input[id^='goal-input-']");
const newTexts = Array.from(inputs).map(inp => inp.value.trim()).filter(Boolean);
if (!newTexts.length) {
toast("Please enter at least one goal");
return;
}
const otherGoals = (DB.dailyGoals || []).filter(g =>
!(String(g.user || '').toLowerCase().trim() === currentUsername && g.date === todayStr)
);
const newGoalDocs = newTexts.map((txt) => {
const matchedOld = myGoalsToday.find(g => g.text.toLowerCase().trim() === txt.toLowerCase().trim());
return {
id: matchedOld ? matchedOld.id : uid("dg"),
user: currentUsername,
displayName: USER.displayName || USER.name || USER.username,
date: todayStr,
text: txt,
completed: matchedOld ? !!matchedOld.completed : false,
assignedBy: matchedOld ? matchedOld.assignedBy : undefined,
assignedByName: matchedOld ? matchedOld.assignedByName : undefined,
createdAt: matchedOld ? matchedOld.createdAt : (new Date()).toISOString(),
updatedAt: (new Date()).toISOString()
};
});
DB.dailyGoals = [...otherGoals, ...newGoalDocs];
save();
toast("Daily goals saved for today ✓");
closeModal();
render();
}
}
]
);
}
function isLeadDevOrOwner(u) {
if (!u) return false;
const r = (u.role || '').toLowerCase().trim();
return isOwner(u) || r === 'developer' || r === 'lead_dev' || r === 'lead_developer' || u.isLeadDev === true || u.isLead === true;
}
window.formAddUnassignedGoalModal = function () {
const todayStr = todayISO();
openModal("📦 Post Task to Unassigned Pool",
`Post an unassigned task to the pool. Any team member or developer can grab and claim it to add to their daily goals.
` +
`` +
`Task Description
`
,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "📦 Post Unassigned Task", cls: "", fn: () => {
const dateStr = val("f-unassign-date").trim() || todayStr;
const taskText = val("f-unassign-text").trim();
if (!taskText) return toast("Task description cannot be empty");
if (!DB.dailyGoals) DB.dailyGoals = [];
DB.dailyGoals.push({
id: uid("dg"),
user: "unassigned",
displayName: "Unassigned Task Pool",
date: dateStr,
text: taskText,
completed: false,
isUnassigned: true,
assignedBy: (USER.username || USER.user || '').toLowerCase().trim(),
assignedByName: USER.displayName || USER.name || USER.username,
createdAt: (new Date()).toISOString(),
updatedAt: (new Date()).toISOString()
});
save();
toast("Task posted to Unassigned Task Pool ✓");
closeModal();
render();
}
}
]
);
};
window.grabUnassignedTask = function (goalId) {
if (!DB.dailyGoals) return;
const goal = DB.dailyGoals.find(g => g.id === goalId);
if (!goal) return toast("Task not found");
const currentUsername = (USER.username || USER.user || '').toLowerCase().trim();
const currentDisplayName = USER.displayName || USER.name || USER.username;
goal.user = currentUsername;
goal.displayName = currentDisplayName;
goal.isUnassigned = false;
goal.claimedAt = (new Date()).toISOString();
goal.updatedAt = (new Date()).toISOString();
save();
toast("✋ Task claimed! Added to your goals ✓");
render();
};
function formAssignGoalModal(preSelectedUserKey) {
const isOwnerUser = isOwner(USER);
const uRole = (USER.role || '').toLowerCase().trim();
const isDevUser = uRole === 'developer' || uRole === 'lead_dev' || uRole === 'lead_developer' || USER.isLeadDev === true;
if (!isOwnerUser && !isDevUser) return toast("Only Owners and Lead Developers can assign tasks.");
const rawUsers = DB.users || [];
const candidateUsers = isOwnerUser ? rawUsers : rawUsers.filter(u => {
const r = (u.role || '').toLowerCase().trim();
return r === 'developer' || r === 'lead_dev' || r === 'lead_developer' || u.isDeveloper;
});
const users = candidateUsers.map(u => {
const k = (u.username || u.user || '').toLowerCase().trim();
const nm = (u.displayName || u.name || u.username || u.user || '').trim();
return { val: k, txt: `${nm} (${ROLES[u.role] || u.role || 'Developer'})` };
}).filter(o => o.val);
users.unshift({ val: "unassigned", txt: "📦 Unassigned Task Pool (Anyone can grab)" });
const userOpts = users.map(o => `${escH(o.txt)} `).join('');
const todayStr = todayISO();
const roleTitle = isOwnerUser ? "Owner" : "Lead Dev";
openModal("📌 Assign Daily Task to Team Member",
`${isOwnerUser ? 'Assign a daily task to any employee or developer, or post to the Unassigned Task Pool.' : 'As Lead Developer / Developer, you can assign tasks to other developers or post to the Unassigned Task Pool.'}
` +
`Assign To ${userOpts}
` +
`` +
`Task / Goal Description
`
,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "📌 Assign Task", cls: "", fn: () => {
const targetUser = val("f-assign-user-sel").trim();
const dateStr = val("f-assign-date").trim() || todayStr;
const taskText = val("f-assign-text").trim();
if (!targetUser) return toast("Select a target user or Unassigned Task Pool");
if (!taskText) return toast("Task description cannot be empty");
if (targetUser === "unassigned") {
if (!DB.dailyGoals) DB.dailyGoals = [];
DB.dailyGoals.push({
id: uid("dg"),
user: "unassigned",
displayName: "Unassigned Task Pool",
date: dateStr,
text: taskText,
completed: false,
isUnassigned: true,
assignedBy: (USER.username || USER.user || '').toLowerCase().trim(),
assignedByName: `${USER.displayName || USER.name || USER.username} (${roleTitle})`,
createdAt: (new Date()).toISOString(),
updatedAt: (new Date()).toISOString()
});
save();
toast("Task posted to Unassigned Task Pool ✓");
closeModal();
render();
return;
}
const targetUserObj = (DB.users || []).find(u => (u.username || u.user || '').toLowerCase().trim() === targetUser);
const targetDisplayName = targetUserObj ? (targetUserObj.displayName || targetUserObj.name || targetUserObj.username) : targetUser;
if (!DB.dailyGoals) DB.dailyGoals = [];
DB.dailyGoals.push({
id: uid("dg"),
user: targetUser,
displayName: targetDisplayName,
date: dateStr,
text: taskText,
completed: false,
assignedBy: (USER.username || USER.user || '').toLowerCase().trim(),
assignedByName: `${USER.displayName || USER.name || USER.username} (${roleTitle})`,
createdAt: (new Date()).toISOString(),
updatedAt: (new Date()).toISOString()
});
save();
toast(`Task assigned to ${targetDisplayName} ✓`);
closeModal();
render();
}
}
]
);
}
window.addGoalInputRow = function () {
const wrap = document.getElementById("goal-inputs-wrap");
if (!wrap) return;
const count = wrap.querySelectorAll("input[id^='goal-input-']").length;
const div = document.createElement("div");
div.className = "field";
div.style.marginBottom = "8px";
div.id = `goal-input-row-${count}`;
div.innerHTML = `Goal / Task #${count + 1} `;
wrap.appendChild(div);
};
function todoView() {
if (!DB.dailyGoals) DB.dailyGoals = [];
const seeM = can.seeMoney(USER);
const isOwnerUser = isOwner(USER);
const uRole = (USER.role || '').toLowerCase().trim();
const isDevUser = uRole === 'developer' || uRole === 'lead_dev' || uRole === 'lead_developer' || USER.isLeadDev === true;
const canSeeTeamTab = isOwnerUser || isDevUser;
const currentUsername = (USER.username || USER.user || '').toLowerCase().trim();
const selectedDate = todoDate || todayISO();
// Carry-forward: when looking at today, unfinished goals from earlier days stay visible
// (tagged "carried over") until they are completed — they no longer vanish at midnight.
const goalOnDate = g => g.date === selectedDate || (selectedDate === todayISO() && !g.completed && !!g.date && g.date < selectedDate);
const carriedTag = g => g.date && g.date !== selectedDate ? `⏩ from ${escH(g.date)} ` : '';
const unassignedGoalsForDate = DB.dailyGoals.filter(g =>
(String(g.user || '').toLowerCase().trim() === 'unassigned' || g.isUnassigned === true) && goalOnDate(g) && !g.completed
);
const dateBar = `
📅 Date:
Today
${canSeeTeamTab ? `
My Goals
👥 Team Goals (${isOwnerUser ? 'Owner View' : 'Dev Team View'})
📌 Assign Task
` : ''}
📦 Post Unassigned Task
`;
if (canSeeTeamTab && todoViewTab === 'team') {
const rawUsers = DB.users || [];
const users = isOwnerUser ? rawUsers : rawUsers.filter(u => {
const r = (u.role || '').toLowerCase().trim();
return r === 'developer' || r === 'lead_dev' || r === 'lead_developer' || u.isDeveloper || (u.username || '').toLowerCase() === currentUsername;
});
const teamGoalsForDate = DB.dailyGoals.filter(goalOnDate);
const unassignedBlock = `
📦 Unassigned Task Pool (Open for anyone to grab)
+ Post Unassigned Task
${unassignedGoalsForDate.length ? unassignedGoalsForDate.map(g => `
${escH(normalizeGoalText(g.text))}
${g.assignedByName ? `Posted by ${escH(g.assignedByName)} ` : ''}
✋ Grab Task
🗑
`).join('') : `
No unassigned tasks currently in pool for ${selectedDate}
`}
`;
const userBlocks = users.map(u => {
const uName = (u.displayName || u.name || u.username || u.user || '').trim();
const uKey = (u.username || u.user || '').toLowerCase().trim();
const uGoals = teamGoalsForDate.filter(g => String(g.user || '').toLowerCase().trim() === uKey);
const items = uGoals.map(g => {
const isAssigned = g.assignedBy && g.assignedBy.toLowerCase().trim() !== uKey;
const assignTag = isAssigned ? `📌 Assigned by ${escH(g.assignedByName || 'Lead Dev')} ` : '';
return `
${g.completed ? '✓' : '•'}
${escH(normalizeGoalText(g.text))} ${assignTag}${carriedTag(g)}
${g.completed ? 'Completed' : 'Pending'}
🗑
`;
}).join('');
return `
👤 ${escH(uName)} (${ROLES[u.role] || u.role || 'Staff'})
+ Assign Task
${uGoals.length} Goal(s)
${uGoals.length ? items : `
${escH(uName)} — No goals submitted yet
`}
`;
}).join('');
view().innerHTML = `
${isOwnerUser ? 'Owner Team Goals Dashboard — view daily goals submitted by all employees and assign new tasks.' : 'Developer Team Goals Dashboard — view daily tasks of developer team members and assign tasks.'}
${dateBar}
${unassignedBlock}
${userBlocks || `No user records found
`}
`;
return;
}
const myGoalsForDate = DB.dailyGoals.filter(g =>
String(g.user || '').toLowerCase().trim() === currentUsername && goalOnDate(g)
);
const assignedGoals = myGoalsForDate.filter(g => g.assignedBy && g.assignedBy.toLowerCase().trim() !== currentUsername);
const directGoals = myGoalsForDate.filter(g => !g.assignedBy || g.assignedBy.toLowerCase().trim() === currentUsername);
const completedCount = myGoalsForDate.filter(g => g.completed).length;
const totalCount = myGoalsForDate.length;
const pct = totalCount ? Math.round((completedCount / totalCount) * 100) : 0;
const renderGoalRow = (g) => `
${escH(normalizeGoalText(g.text))}
${g.assignedBy && g.assignedBy.toLowerCase().trim() !== currentUsername ? `📌 Assigned by ${escH(g.assignedByName || 'Owner')} ` : ''}${carriedTag(g)}
Created: ${g.date && g.date !== selectedDate ? g.date + ' ' : ''}${g.createdAt ? g.createdAt.slice(11, 16) : '—'}
Edit
Delete
`;
view().innerHTML = `
Daily Goals & To-Do List — record and track your individual daily tasks and claim unassigned team tasks.
${dateBar}
📝 My Goals for ${selectedDate}
⚙ Bulk Edit Today's Goals
+ Add Goal
📦 Post to Pool
${isOwnerUser || isDevUser ? `📌 Assign Task ` : ''}
${unassignedGoalsForDate.length ? `
📦 UNASSIGNED TASKS (TASK POOL) (${unassignedGoalsForDate.length})
Anyone can grab
+ Post Task
${unassignedGoalsForDate.map(g => `
${escH(normalizeGoalText(g.text))}
${g.assignedByName ? `Posted by ${escH(g.assignedByName)} ` : ''}
Created: ${g.createdAt ? g.createdAt.slice(11, 16) : '—'}
✋ Grab Task
🗑
`).join('')}
` : `
📦 Task Pool : No unassigned tasks open right now. Anyone can post a task for the team to grab!
+ Post Task
`}
${assignedGoals.length ? `
📌 ASSIGNED TASKS (${assignedGoals.length})
From Management
${assignedGoals.map(renderGoalRow).join('')}
` : ''}
${directGoals.length ? `
${(assignedGoals.length || unassignedGoalsForDate.length) ? `
📝 MY DIRECT GOALS
` : ''}
${directGoals.map(renderGoalRow).join('')}
` : ''}
${(!assignedGoals.length && !directGoals.length && !unassignedGoalsForDate.length) ? `
No goals recorded for ${selectedDate}. Use the input above to add your first goal!
` : ''}
Goal Completion Overview
Status
${completedCount} / ${totalCount} Done
Note: Tasks in the 📦 Task Pool are open for anyone to claim by clicking ✋ Grab Task . Assigned tasks appear in 📌 Assigned Tasks and personal tasks in 📝 My Direct Goals .
`;
}
window.addSingleGoal = function () {
const inp = document.getElementById("new-todo-text");
if (!inp) return;
const txt = inp.value.trim();
if (!txt) return toast("Goal text cannot be empty");
const currentUsername = (USER.username || USER.user || '').toLowerCase().trim();
const dateStr = todoDate || todayISO();
if (!DB.dailyGoals) DB.dailyGoals = [];
DB.dailyGoals.push({
id: uid("dg"),
user: currentUsername,
displayName: USER.displayName || USER.name || USER.username,
date: dateStr,
text: txt,
completed: false,
createdAt: (new Date()).toISOString(),
updatedAt: (new Date()).toISOString()
});
save();
toast("Goal added ✓");
render();
};
window.toggleGoalItem = function (id) {
const g = (DB.dailyGoals || []).find(x => x.id === id);
if (!g) return;
g.completed = !g.completed;
g.updatedAt = (new Date()).toISOString();
save();
render();
};
window.editGoalItem = function (id) {
const g = (DB.dailyGoals || []).find(x => x.id === id);
if (!g) return;
openModal("Edit Goal",
field("Goal / Task Text", "f-edit-goal-txt", escAttr(g.text)),
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "Save", cls: "", fn: () => {
const valTxt = val("f-edit-goal-txt").trim();
if (!valTxt) return toast("Goal text cannot be empty");
g.text = valTxt;
g.updatedAt = (new Date()).toISOString();
save();
toast("Goal updated");
closeModal();
render();
}
}
]
);
};
window.deleteGoalItem = function (id) {
const g = (DB.dailyGoals || []).find(x => x.id === id);
if (!g) return;
openModal("Delete Goal?", `Delete goal: ${escH(g.text)} ?
`,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "Delete", cls: "", style: "background:var(--bad);color:#fff", fn: () => {
DB.dailyGoals = (DB.dailyGoals || []).filter(x => x.id !== id);
save();
toast("Goal deleted");
closeModal();
render();
}
}
]
);
};
function deductView() {
if (!can.seeDeduct(USER)) return view().innerHTML = `Access Denied
`;
const dData = paginate(DB.debitNotes, 'deduct');
const dnRows = dData.items.map(dn => `
${dn.no} ${dn.date} ${dn.person}
${dn.item} (${dn.qty} ${dn.unit})Reason: ${dn.reason}
${money(dn.value)}
${pill(dn.status)}${dn.approvedBy ? `✔ approved by ${escH(dn.approvedBy)}${dn.approvedAt ? ' · ' + dn.approvedAt : ''}
` : ''}
${tr("Debit Note")}
${(dn.status === "Pending" && isOwner(USER)) ? `✔ Approve ` : ''}
${((dn.status === "Pending" || dn.status === "Approved") && isOwner(USER)) ? `${tr("Mark Deducted")} ` : ''}
${isOwner(USER) ? `🗑 ` : ''}
`).join("");
view().innerHTML = `
${tr("Salary Deductions — Debit Notes")}
⬇ Export CSV
⚙ Custom Export
📥 Bulk Import
`+ (isOwner(USER) || can.seeDeduct(USER) ? `
✈ Expat Advances + ${tr("Raise Debit Note")} ` : '') + `
`+ (isOwner(USER) ? '
⚠ Reset ' : '') + `
Debit No Date Worker Item / Reason Deduction Status Action
${dnRows || emptyRow(7, "No debit notes raised")}
${dData.html}
`;
}
// ── Click Action Handler (Dynamic Central Router) ──────────────────────────────
function handleAction(act, id, mode, e) {
if (e) e.preventDefault();
// Sidebar/Nav filter modes
if (act === "proc-mode") { procMode = mode; render(); return; }
if (act === "imp-filter") { docKind = mode; render(); return; }
if (act === "hr-filter") { staffTypeFilter = mode; render(); return; }
// Progress actions
if (act === "prog-tab") { progressTab = mode; render(); return; }
if (act === "prog-add") { if (!canAccessProgress(USER)) { toast('You need access to Erection & Civil.'); return; } formProg(); return; }
if (act === "prog-up") { if (!canEditProgress(USER)) { toast('Editing activities requires the Update Progress sub-permission.'); return; } const p = DB.progress.find(x => x.id === id); formProgUp(p); return; }
if (act === "prog-img") { if (!canEditProgress(USER)) { toast('Editing activities requires the Update Progress sub-permission.'); return; } const p = DB.progress.find(x => x.id === id); formAttUpload(p, 'photos'); return; }
if (act === "prog-del") { if (!isOwner(USER)) { toast('Only owners can delete activities.'); return; } confirmDel(id, DB.progress, 'progress'); return; }
if (act === "prog-bulk") { if (!isOwner(USER)) { toast('Only owners can bulk import.'); return; } formBulkProgress(); return; }
// Erection Schedule actions
if (act === "sch-add") { if (!canAccessProgress(USER)) { toast('You need access to Erection & Civil.'); return; } if (!DB.erectionSchedule) DB.erectionSchedule = []; formScheduleEntry(); return; }
if (act === "sch-edit") { if (!canEditProgress(USER)) { toast('Editing schedule entries requires the Update Progress sub-permission.'); return; } const s = (DB.erectionSchedule || []).find(x => x.id === id); if (s) formScheduleEntry(s); return; }
if (act === "sch-del") { if (!isOwner(USER)) { toast('Only owners can delete schedule entries.'); return; } confirmDel(id, DB.erectionSchedule, 'erectionSchedule'); return; }
// Daily Log actions
if (act === "dl-add") { if (!canAccessProgress(USER)) { toast('You need access to Erection & Civil.'); return; } if (!DB.erectionDailyLog) DB.erectionDailyLog = []; formDailyLogEntry(); return; }
if (act === "dl-edit") { if (!canEditProgress(USER)) { toast('Editing log entries requires the Update Progress sub-permission.'); return; } const d = (DB.erectionDailyLog || []).find(x => x.id === id); if (d) formDailyLogEntry(d); return; }
if (act === "dl-del") { if (!isOwner(USER)) { toast('Only owners can delete log entries.'); return; } confirmDel(id, DB.erectionDailyLog, 'erectionDailyLog'); return; }
// Indents & Procurement actions
if (act === "proc-add") { formIndent(); return; }
if (act === "proc-edit") { const p = DB.proc.find(x => x.id === id); formIndentEdit(p); return; }
if (act === "proc-quotes") { const p = DB.proc.find(x => x.id === id); formQuotes(p); return; }
if (act === "proc-file") { const p = DB.proc.find(x => x.id === id); formAttUpload(p, 'files'); return; }
if (act === "proc-compare") { const p = DB.proc.find(x => x.id === id); formCompareOnly(p); return; }
if (act === "proc-invno") { const p = DB.proc.find(x => x.id === id); formSupplierInvoice(p); return; }
if (act === "proc-approve-reject") { const p = DB.proc.find(x => x.id === id); formApproveReject(p); return; }
if (act === "proc-po") { const p = DB.proc.find(x => x.id === id); formQuotesComparison(p); return; } // legacy
if (act === "proc-reject") { const p = DB.proc.find(x => x.id === id); formRejectIndent(p); return; }
if (act === "proc-hold") { const p = DB.proc.find(x => x.id === id); if (p) formHoldIndent(p); return; }
if (act === "proc-resume") { const p = DB.proc.find(x => x.id === id); if (p) resumeHeldIndent(p); return; }
if (act === "proc-courier") { const p = DB.proc.find(x => x.id === id); if (p) formCourier(p); return; }
if (act === "proc-dispatch") { const p = DB.proc.find(x => x.id === id); formDispatch(p); return; }
if (act === "proc-download") { const p = DB.proc.find(x => x.id === id); printIndent(p); return; }
if (act === "proc-print-po") { const p = DB.proc.find(x => x.id === id); printPO(p); return; }
if (act === "proc-print-quotes") { const p = DB.proc.find(x => x.id === id); printQuotes(p); return; }
if (act === "proc-upload-proof") { const p = DB.proc.find(x => x.id === id); if (p) formPaymentProof(p); return; }
if (act === "proc-view-proof") { const p = DB.proc.find(x => x.id === id); if (p && p.paymentProof) { p.paymentProof = Array.isArray(p.paymentProof) ? p.paymentProof : [p.paymentProof]; viewPaymentProof(p); } return; }
if (act === "proc-details") { const p = DB.proc.find(x => x.id === id); if (p) viewIndentDetails(p); return; }
if (act === "proc-del") { deleteIndent(id); return; }
if (act === "proc-recover") { recoverIndent(id); return; }
if (act === "proc-bulk") { formBulkIndent(); return; }
if (act === "proc-rep-go") {
repFrom = document.getElementById("rep-from").value;
repTo = document.getElementById("rep-to").value;
const stEl = document.getElementById("rep-status");
if (stEl) repStatus = stEl.value;
render(); return;
}
if (act === "proc-rep-clear") { repFrom = ""; repTo = ""; repStatus = "All"; render(); return; }
// Vendor actions
if (act === "vendor-add") { formVendorAdd(); return; }
if (act === "vendor-edit") { const v = (DB.vendors || []).find(x => x.id === id); if (v) formVendorAdd(v); return; }
if (act === "vendor-del") {
if (!isOwner(USER)) { toast('⛔ Only owners can delete vendors.'); return; }
const vi = (DB.vendors || []).find(x => x.id === id);
if (!vi) return;
openModal('Delete Vendor', 'Delete ' + escAttr(vi.name) + ' ? This cannot be undone.
',
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: 'Delete', cls: '', fn: () => {
DB.vendors = (DB.vendors || []).filter(x => x.id !== id);
save(); toast('Vendor removed'); closeModal(); render();
}
}]);
return;
}
// Imports
if (act === "doc-add") { formDoc(); return; }
if (act === "doc-up") { const d = DB.docs.find(x => x.id === id); docStatus(d); return; }
if (act === "doc-img") { const d = DB.docs.find(x => x.id === id); formAttUpload(d, 'files'); return; }
if (act === "doc-del") { if (!isOwner(USER)) { toast("⛔ Only owners can delete import documents."); return; } confirmDel(id, DB.docs, 'docs'); return; }
// Stores
if (act === "st-add") { formItem(); return; }
if (act === "st-bulk") { formBulkItems(); return; }
if (act === "st-edit") { const i = DB.items.find(x => x.id === id); formItem(i); return; }
if (act === "st-del") { if (!isOwner(USER)) return; const i = DB.items.find(x => x.id === id); if (!i) return; openModal("Delete Item?", `Permanently delete ${i.name} from store inventory?
`, [{ label: "Cancel", cls: "secondary", fn: closeModal }, { label: "Delete", cls: "", style: "background:var(--bad);color:#fff", fn: () => { DB.items = DB.items.filter(x => x.id !== id); save(); toast("Item deleted"); closeModal(); render(); } }]); return; }
if (act === "st-in") { const i = DB.items.find(x => x.id === id); stockMove(i, "in"); return; }
if (act === "st-out") { const i = DB.items.find(x => x.id === id); stockMove(i, "out"); return; }
if (act === "st-issue") { const i = DB.items.find(x => x.id === id); issueItem(i); return; }
if (act === "st-photos") { const i = DB.items.find(x => x.id === id); formItemPhotoGallery(i); return; }
if (act === "stores-gallery") { const p = DB.proc.find(x => x.id === id); formProcPhotoGallery(p); return; }
if (act === "iss-ret") { const iss = DB.issues.find(x => x.id === id); returnItem(iss); return; }
if (act === "iss-fine") { const iss = DB.issues.find(x => x.id === id); formFineItem(iss); return; }
if (act === "iss-view-dn") { const iss = DB.issues.find(x => x.id === id); const dn = DB.debitNotes.find(x => x.issuesId === iss.id); if (dn) printDebit(dn); return; }
if (act === "stores-recv") { const p = DB.proc.find(x => x.id === id); formGoodsReceipt(p); return; }
if (act === "stores-tab") { storesTab = mode; render(); return; }
if (act === "stores-add-photos") { const p = DB.proc.find(x => x.id === id); formAttUpload(p, 'storePhotos'); return; }
if (act === "stores-recv-confirm") { const p = DB.proc.find(x => x.id === id); formGoodsReceipt(p); return; }
// Device issue handlers
if (act === "dev-issue") { formDeviceIssue(); return; }
if (act === "dev-edit") { const d = DB.deviceIssues.find(x => x.id === id); if (d) formDeviceIssue(d); return; }
if (act === "dev-return") { const d = DB.deviceIssues.find(x => x.id === id); if (d) { d.returnStatus = 'Returned'; save(); render(); toast('Marked as returned'); } return; }
if (act === "dev-del") { if (!confirm('Delete this device record?')) return; DB.deviceIssues = DB.deviceIssues.filter(x => x.id !== id); save(); render(); toast('Deleted'); return; }
// Staff advance handlers
if (act === "adv-add") { formStaffAdvance(); return; }
if (act === "adv-edit") { const a = DB.staffAdvances.find(x => x.id === id); if (a) formStaffAdvance(a); return; }
if (act === "adv-deduct") { const a = DB.staffAdvances.find(x => x.id === id); if (a) formAdvDeduction(a); return; }
if (act === "vac-wipe-db") {
if (!isDev(USER)) return toast("Only Developers can wipe the vacancies database");
if (!confirm("⚠️ This will permanently delete ALL vacancies from the database.\nThe careers page on the website will show no positions.\n\nAre you absolutely sure?")) return;
api('POST', '/api/admin/wipe-vacancies', {}).then(r => {
DB.vacancies = [];
lastDB = clone(DB);
save();
toast('✅ All vacancies wiped from database');
render();
}).catch(e => {
toast('Wipe failed: ' + e.message, false);
});
return;
}
if (act === "vac-add") { formVacancy(); return; }
if (act === "vac-edit") { const v = (DB.vacancies || []).find(x => x.id === id); if (v) formVacancy(v); return; }
if (act === "vac-toggle") { const v = (DB.vacancies || []).find(x => x.id === id); if (v) { v.status = (v.status === 'Active' ? 'Paused' : 'Active'); save(); render(); toast('Status updated'); } return; }
if (act === "vac-del") {
if (!confirm('Delete this vacancy?')) return;
const vIdx = (DB.vacancies || []).findIndex(x => x.id === id);
if (vIdx === -1) { toast('Vacancy not found', 'warning'); return; }
const vDoc = DB.vacancies[vIdx];
DB.vacancies = DB.vacancies.filter(x => x.id !== id);
lastDB = clone(DB);
// Direct delete via admin endpoint to bypass id-mismatch issues
api('DELETE', `/api/admin/vacancy/${encodeURIComponent(id)}`, null).then(() => {
toast('Vacancy deleted');
}).catch(e => {
// Fallback: also try standard delete
api('DELETE', `/api/db/vacancies/${encodeURIComponent(id)}`, null).catch(() => { });
toast('Vacancy deleted');
});
render();
return;
}
if (act === "adv-del") { if (!isOwner(USER)) { toast('⛔ Only owners can delete advance records.'); return; } if (!confirm('Delete this advance record?')) return; DB.staffAdvances = DB.staffAdvances.filter(x => x.id !== id); save(); render(); toast('Deleted'); return; }
if (act === "st-repair-info") { const i = DB.items.find(x => x.id === id); formRepairInfo(i); return; }
// Vehicles
if (act === "veh-open") { openVeh = id; vehTab = "maintenance"; render(); return; }
if (act === "veh-back") { openVeh = null; render(); return; }
if (act === "veh-tab") { vehTab = mode; render(); return; }
if (act === "veh-fleet-tab") { vehFleetTab = mode; render(); return; }
if (act === "veh-add") { formVehicle(); return; }
if (act === "veh-edit") { const v = DB.vehicles.find(x => x.id === id); formVehicle(v); return; }
if (act === "veh-suspend") {
const v = DB.vehicles.find(x => x.id === id);
if (v) {
const wasSuspended = v.status === "Suspended";
v.status = wasSuspended ? "Running" : "Suspended";
vehFleetTab = wasSuspended ? "active" : "suspended";
// Direct API write — guarantees persistence before any page reload
const { _id, __v, ...vDoc } = v;
api('POST', '/api/db/vehicles', vDoc).catch(e => console.warn('Suspend save failed:', e));
if (lastDB) { const lv = lastDB.vehicles && lastDB.vehicles.find(x => x.id === id); if (lv) lv.status = v.status; }
toast(wasSuspended ? "Vehicle reactivated" : "Vehicle suspended");
logAct(wasSuspended ? `Vehicle unsuspended: ${v.name}` : `Vehicle suspended: ${v.name}`);
render();
}
return;
}
if (act === "veh-log") { formVLog(id); return; }
if (act === "veh-trip") { formVTrip(id); return; }
if (act === "veh-fuel") { formVFuel(id); return; }
if (act === "vlog-del") { const v = DB.vehicles.find(x => x.id === id); v.logs = v.logs.filter(x => x.id !== mode); save(); toast("Log removed"); render(); return; }
if (act === "vtrip-del") { const v = DB.vehicles.find(x => x.id === id); v.trips = (v.trips || []).filter(x => x.id !== mode); save(); toast("Trip removed"); render(); return; }
if (act === "vfuel-del") { const v = DB.vehicles.find(x => x.id === id); v.fuel = (v.fuel || []).filter(x => x.id !== mode); save(); toast("Fuel entry removed"); render(); return; }
if (act === "veh-del") { confirmVehDel(id); return; }
// Accounts export
if (act === "acct-export") { exportAccountsCSV(mode); return; }
// Accounts
if (act === "acct-tab") { accountsTab = mode; render(); return; }
if (act === "acct-add") { formAcct(); return; }
if (act === "acct-del") { if (!isOwner(USER)) { toast("⛔ Only owners can delete account records."); return; } confirmDel(id, DB.accounts, 'accounts'); return; }
if (act === "acct-edit") { const a = DB.accounts.find(x => x.id === id); formAcctEdit(a); return; }
if (act === "acct-pay") { const p = DB.proc.find(x => x.id === id); formProcessPayment(p); return; }
if (act === "acct-type") { acctTypeFilter = mode; render(); return; }
if (act === "acct-bulk") { formBulkAcct(); return; }
if (act === "acct-heads") { formAcctHeads(); return; }
// Reset module (owner only)
if (act === "reset-mod") { if (isOwner(USER)) confirmResetModule(mode); return; }
// HR
if (act === "hr-add") {
if (!canMod(USER, 'hr')) return toast("🔒 HR edits only allowed for India location login");
formEmp(); return;
}
if (act === "hr-time") { timeInOut(id); return; }
if (act === "hr-docs") { const e = DB.employees.find(x => x.id === id); formHRContracts(e); return; }
if (act === "hr-appt-let") { const e = DB.employees.find(x => x.id === id); printAppointmentLetter(e); return; }
if (act === "hr-sal-receipt") { const e = DB.employees.find(x => x.id === id); printSalaryReceipt(e); return; }
if (act === "hr-del") { if (!isOwner(USER)) { toast("⛔ Only owners can permanently delete staff records."); return; } confirmDel(id, DB.employees, 'employees'); return; }
if (act === "hr-export-att") { formExportAttendance(); return; }
if (act === "hr-tab") { hrTab = mode; render(); return; }
if (act === "hr-bulk") { formBulkStaff(); return; }
if (act === "hr-emp-edit") {
if (!canMod(USER, 'hr')) return toast("🔒 HR edits only allowed for India location login");
const e = DB.employees.find(x => x.id === id); formEmpEdit(e); return;
}
if (act === "hr-reset-approvals") { resetPayrollApprovals(mode); return; }
if (act === "hr-release") { const e = DB.employees.find(x => x.id === id); formReleaseEmployee(e); return; }
if (act === "hr-release-docs") { const e = DB.employees.find(x => x.id === id); formReleaseViewDocs(e); return; }
if (act === "hr-reinstate") { const e = DB.employees.find(x => x.id === id); if (e && isOwner(USER)) { e.status = 'Active'; delete e.releaseReason; delete e.releaseDate; save(); logAct('Reinstated: ' + e.name); toast('Employee reinstated — status set back to Active'); render(); } else if (!isOwner(USER)) { toast('Only owners can reinstate staff.'); } return; }
if (act === "hr-released-filter") { releasedTypeFilter = mode; render(); return; }
// Security & Gate Log
if (act === "sec-add") { if (!DB.security) DB.security = []; formGateEntry(); return; }
if (act === "sec-edit") { const e = (DB.security || []).find(x => x.id === id); formGateEntry(e); return; }
if (act === "sec-checkout") { const e = (DB.security || []).find(x => x.id === id); formGateCheckOut(e); return; }
if (act === "sec-view") { const e = (DB.security || []).find(x => x.id === id); formGateDetail(e); return; }
if (act === "sec-del") { if (!isOwner(USER)) { toast('Only owners can delete gate entries.'); return; } confirmDel(id, DB.security, 'security'); return; }
if (act === "sec-tab") { secTab = mode; render(); return; }
if (act === "sec-date") { secDateFilter = mode; render(); return; }
// Users
if (act === "u-add") { formUser(); return; }
if (act === "u-edit") { const u = DB.users.find(x => x.id === id); formUser(u); return; }
if (act === "u-pass") { resetPass(id); return; }
if (act === "u-del") { confirmUserDel(id); return; }
// Deductions
if (act === "dn-print") { const dn = DB.debitNotes.find(x => x.id === id); printDebit(dn); return; }
if (act === "dn-del") { if (!isOwner(USER)) return; const dn = DB.debitNotes.find(x => x.id === id); if (!dn) return; openModal("Delete Debit Note?", `Delete debit note ${dn.no} for ${dn.person} ?
`, [{ label: "Cancel", cls: "secondary", fn: closeModal }, { label: "Delete", cls: "", style: "background:var(--bad);color:#fff", fn: () => { DB.debitNotes = DB.debitNotes.filter(x => x.id !== id); save(); toast("Deleted"); closeModal(); render(); } }]); return; }
if (act === "dn-approve") {
if (!isOwner(USER)) return;
const d = DB.debitNotes.find(x => x.id === id);
if (!d || d.status !== "Pending") return;
openModal("Approve deduction " + d.no,
`${escH(d.person || '')} — ${money(d.value)} Purpose: ${escH(d.reason || '')}${d.item ? ' · ' + escH(d.item) : ''} Instructed by: ${escH(d.raisedBy || 'not recorded')}
How was this approved?
In SiteDesk (owner approval)
Signed letter / memo (filed in HR)
WhatsApp message from owner
Verbal instruction (noted)
Reference / note (optional)
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, { label: "Approve deduction", cls: "", fn: () => {
d.status = "Approved";
d.approvedBy = USER.displayName || USER.username;
d.approvedAt = todayISO();
d.approvalMethod = val("dn-how"); d.approvalRef = val("dn-ref");
save(); logAct(`Debit ${d.no} approved by ${d.approvedBy} (${d.approvalMethod}) — deducts in payroll`);
closeModal(); toast("Deduction approved — it will be deducted in payroll"); render();
} }]);
return;
}
if (act === "dn-done") { const d = DB.debitNotes.find(x => x.id === id); d.status = "Deducted"; d.deductedOn = todayISO(); save(); logAct(`Debit ${d.no} deducted`); toast("Marked deducted"); render(); return; }
// Licences & Title Deeds
if (act === "legal-add") { formLegal(); return; }
if (act === "legal-edit") { const d = DB.legalDocs.find(x => x.id === id); formLegal(d); return; }
if (act === "legal-del") { if (!isOwner(USER)) return; const d = DB.legalDocs.find(x => x.id === id); if (!d) return; openModal("Remove?", `Remove ${d.name} ?
`, [{ label: "Cancel", cls: "secondary", fn: closeModal }, { label: "Remove", cls: "", style: "background:var(--bad);color:#fff", fn: () => { DB.legalDocs = DB.legalDocs.filter(x => x.id !== id); save(); toast("Removed"); closeModal(); render(); } }]); return; }
if (act === "legal-bulk") { formBulkLegal(); return; }
if (act === "legal-type") { legalTypeFilter = mode; render(); return; }
// Developer
if (act === "dev-toggle-careers") {
const targetBtn = e ? e.target.closest('[data-user]') : null;
const targetUser = (targetBtn ? targetBtn.getAttribute('data-user') : id || mode || '').toLowerCase();
toggleCareerAccess(targetUser);
return;
}
if (act === "dev-ann-add") { formAnnouncement(); return; }
if (act === "dev-ann-edit") { const a = (DB.announcements || []).find(x => x.id === id); formAnnouncement(a); return; }
if (act === "dev-ann-del") { if (!isDev(USER)) return; DB.announcements = (DB.announcements || []).filter(x => x.id !== id); save(); toast("Announcement removed"); render(); return; }
if (act === "dev-flag") { const cfg = DB.appConfig = DB.appConfig || {}; cfg.disabledModules = cfg.disabledModules || []; const idx = cfg.disabledModules.indexOf(mode); if (idx > -1) cfg.disabledModules.splice(idx, 1); else cfg.disabledModules.push(mode); save(); toast("Feature flag updated"); buildNav(); developerView(); return; }
}
// Helper to delete users from server database safely
// If the target user is an Owner, an access code stored in DB.appConfig.ownerDeleteCode is required
async function confirmUserDel(id) {
const target = DB.users.find(x => x.id === id || x._id === id);
if (!target) return toast("User not found");
if (isOwner(target)) {
const savedCode = (DB.appConfig && DB.appConfig.ownerDeleteCode) || "1@Shivank1984";
if (!savedCode) {
return toast("⛔ Owner account deletion is locked — set an access code in Developer Console → Security first.");
}
const entered = prompt("⚠ You are about to delete an OWNER account.\n\nEnter the Owner Delete Protection Code:");
if (entered === null) return;
if (entered.trim() !== savedCode) {
return toast("⛔ Incorrect Owner Delete Code — deletion blocked.", false);
}
} else if (isDev(target)) {
const savedCode = (DB.appConfig && (DB.appConfig.devDeleteCode || DB.appConfig.ownerDeleteCode)) || "1@Shivank1984";
if (!savedCode) {
return toast("⛔ Developer account deletion is locked — set an access code in Developer Console → Security first.");
}
const entered = prompt("⚠ You are about to delete a DEVELOPER account.\n\nEnter the Developer Delete Protection Code:");
if (entered === null) return;
if (entered.trim() !== savedCode) {
return toast("⛔ Incorrect Developer Delete Code — deletion blocked.", false);
}
} else {
if (!confirm("Remove this user login completely?")) return;
}
try {
await api('DELETE', `/api/users/${id}`);
DB.users = DB.users.filter(x => x.id !== id && x._id !== id);
toast("User deleted");
render();
} catch (e) {
toast("Delete failed: " + e.message, false);
}
}
// ── Action-Specific Modals & Forms ───────────────────────────────────────────
function formProg() {
openModal("New Erection Activity",
sel("Discipline", "f-disc", ["Civil & Structural", "Mechanical Erection", "Electrical & Instrumentation", "Piping", "Commissioning"]) +
field("Activity Description", "f-act") + field("Target Date", "f-date", todayISO(), "date") +
field("Progress %", "f-pct", "0", "number", "min=0 max=100"),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Create", cls: "", fn: () => {
const a = val("f-act"); if (!a) return alertField();
const pct = Math.max(0, Math.min(100, +val("f-pct")));
DB.progress.unshift({ id: uid("pr"), disc: val("f-disc"), activity: a, target: val("f-date"), pct, status: pct >= 100 ? "Completed" : "In Progress", photos: [] });
save(); logAct(`Activity added: ${a}`); toast("Activity added"); closeModal(); render();
}
}]);
}
function formProgUp(p) {
openModal("Update Progress — " + p.activity,
field("Progress %", "f-pct", p.pct, "number", "min=0 max=100"),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save", cls: "", fn: () => {
let v = Math.max(0, Math.min(100, +val("f-pct"))); p.pct = v; p.status = v >= 100 ? "Completed" : "In Progress"; save(); logAct(`${p.activity} ${v}%`); toast("Updated"); closeModal(); render();
}
}]);
}
// ── Bulk Import Progress Activities (CSV) ──────────────────────────────────────
// Template columns (0-indexed):
// 0: Discipline 1: Activity Description 2: Target Date (YYYY-MM-DD) 3: Progress % 4: Status (optional)
function downloadProgressCSVTemplate() {
const headers = ["Discipline", "Activity Description", "Target Date (YYYY-MM-DD)", "Progress (0-100)", "Status (In Progress/Completed)"];
const examples = [
["Civil & Structural", "Foundation Casting — Block A", "2026-08-15", "60", "In Progress"],
["Mechanical Erection", "Boiler Frame Erection", "2026-09-01", "0", "In Progress"],
["Electrical & Instrumentation", "Cable Tray Installation — Level 1", "2026-08-30", "100", "Completed"],
["Piping", "Fire Hydrant Line", "2026-09-15", "25", "In Progress"],
];
const csv = [headers.map(csvEsc).join(","), ...examples.map(r => r.map(csvEsc).join(",")), ""].join("\n");
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "Progress_Bulk_Template.csv";
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast("Template downloaded — 5 columns");
}
function formBulkProgress() {
const DISCS = ["Civil & Structural", "Mechanical Erection", "Electrical & Instrumentation", "Piping", "Commissioning", "Other"];
openModal("Bulk Import — Erection & Civil",
`Download the template, fill in activities, then upload. Duplicate activity descriptions are skipped.
` +
`` +
`\u2b07 Download Template (CSV) ` +
`Columns: Discipline · Activity · Target Date · Progress % · Status ` +
`
` +
`Select CSV or XLSX File
` +
`
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, { label: "Import", cls: "", fn: () => toast("Upload a CSV file first") }]);
document.getElementById("btn-dl-prog-temp").onclick = downloadProgressCSVTemplate;
const fileEl = document.getElementById("f-prog-csv");
const preview = document.getElementById("prog-preview");
fileEl.onchange = () => {
const files = fileEl.files;
if (!files.length) return;
preview.innerHTML = 'Parsing file\u2026 ';
parseSpreadsheet(files[0]).then(rows => {
try {
if (!rows.length || rows[0].length < 2) throw new Error("Invalid format — need at least 2 columns. Download and use the template.");
const existingActs = new Set(DB.progress.map(p => (p.activity || "").toLowerCase()));
const toAdd = [];
const skipped = [];
const errors = [];
for (let i = 1; i < rows.length; i++) {
const c = rows[i];
if (!c[0] && !c[1]) continue;
const disc = (c[0] || "").toString().trim() || "Other";
const activity = (c[1] || "").toString().trim();
if (!activity) { errors.push("Row " + (i + 1) + ": Activity Description is empty"); continue; }
if (existingActs.has(activity.toLowerCase())) { skipped.push(activity.slice(0, 40)); continue; }
const rawTarget = (c[2] || "").toString().trim();
let target = rawTarget;
if (rawTarget && !isNaN(+rawTarget)) {
const d = new Date(Math.round((+rawTarget - 25569) * 86400 * 1000));
target = d.toISOString().slice(0, 10);
}
const pct = Math.max(0, Math.min(100, parseFloat((c[3] || "0").toString()) || 0));
const statusRaw = (c[4] || "").toString().trim();
const status = statusRaw || (pct >= 100 ? "Completed" : "In Progress");
toAdd.push({ id: uid("pr"), disc, activity, target: target || todayISO(), pct, status, photos: [] });
}
const previewRows = toAdd.slice(0, 10).map(p => {
const pctColor = p.pct >= 100 ? "#16a34a" : p.pct >= 50 ? "#d97706" : "#64748b";
return `
${escH(p.disc)}
${escH(p.activity)}
${p.target || "—"}
${p.pct}%
${escH(p.status)}
`;
}).join("");
const moreRows = toAdd.length > 10 ? `\u2026 and ${toAdd.length - 10} more ` : "";
let html = `
\u2714 ${toAdd.length} activities ready
${skipped.length ? `${skipped.length} skipped (duplicate) ` : ""}
${errors.length ? `${errors.length} error(s) ` : ""}
`;
if (errors.length) {
html += `` +
errors.slice(0, 5).map(e => `${escH(e)} `).join("") + (errors.length > 5 ? `\u2026 and ${errors.length - 5} more ` : "") +
` `;
}
if (toAdd.length) {
html += `
Discipline Activity Target Date Progress Status
${previewRows}${moreRows}
`;
}
preview.innerHTML = html;
const importBtn = document.querySelector("#modalMount .btn:not(.secondary)");
if (importBtn) {
if (toAdd.length) {
importBtn.textContent = "Import " + toAdd.length + " Activities";
importBtn.onclick = () => {
DB.progress.push(...toAdd);
save();
toast(toAdd.length + " activities imported!");
logAct("Bulk imported " + toAdd.length + " progress activities");
closeModal(); render();
};
} else {
importBtn.textContent = "Import";
importBtn.onclick = () => toast("No valid activities to import");
}
}
} catch (err) {
preview.innerHTML = `\u26a0 ${escH(err.message)}
`;
}
}).catch(err => {
preview.innerHTML = `\u26a0 ${escH(err.message)}
`;
});
};
}
function formAttUpload(obj, key) {
openModal("Attach Documents",
`Add PDF or JPG images. Multiple files allowed.
Choose Files
`,
[{ label: "Done", cls: "", fn: closeModal }]);
function ref() {
const box = document.getElementById("fAttPrev");
box.innerHTML = (obj[key] || []).map((f, i) => attEditable(f, i)).join("");
box.querySelectorAll("[data-rm]").forEach(x => x.onclick = () => { obj[key].splice(+x.dataset.rm, 1); save(); ref(); });
}
const inp = document.getElementById("f-file-up");
inp.onchange = async () => {
const ind = document.getElementById("att-file-ind");
if (inp.files.length) {
ind.style.display = "block";
ind.textContent = "✅ " + inp.files.length + " file(s) selected — saving...";
}
const fs = await readFiles(inp);
obj[key] = (obj[key] || []);
obj[key].push(...fs);
save();
ref();
if (ind) { ind.textContent = "✅ " + fs.length + " file(s) added successfully!"; }
toast("File added ✔");
};
ref();
}
function attEditable(f, i) {
return `${f.type === 'application/pdf' ? `
PDF ` : `
`}
× `;
}
function alertField() { toast("Please fill all required fields", false); }
function val(id) { return document.getElementById(id) ? document.getElementById(id).value.trim() : ''; }
// Stores Issue Item
function issueItem(it) {
openModal("Issue Returnable Tool — " + it.name,
`Available stock: ${it.qty} ${it.unit} . Enter name/ID to log returnable tool issue.
` +
`Person (Employee / Contractor) ${personOptions('')}
` +
`` + field("Worker Name", "f-pname") + field("Worker ID", "f-pid") + `
` +
`` + field("Quantity", "f-q", "1", "number", "min=1") + field("Expected Return", "f-ret", todayISO(), "date") + `
` +
field("Location / Where fixed", "f-loc", escAttr(it.loc || "")),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Issue", cls: "", fn: () => {
const qn = +val("f-q"); if (!qn) return alertField();
const p = resolvePerson(document.getElementById("f-person").value);
if (!p) return toast("Choose a person or type a name");
if (qn > it.qty) return toast("Not enough in store");
it.qty = Math.max(0, it.qty - qn);
DB.issues.unshift({ id: uid("is"), itemId: it.id, item: it.name, code: it.code, unit: it.unit, qty: qn, personKind: p.kind, personRef: p.ref, person: p.name, workerId: p.wid || "", dept: p.dept || "", loc: val("f-loc") || it.loc || "", dateOut: todayISO(), expReturn: val("f-ret"), status: "Out" });
save();
const empWorker = (DB.employees || []).find(e => e.id === p.wid || e.name === p.name);
sendWhatsAppNotification('stores_issue', {
recipient: p.name,
phone: empWorker?.phone || '',
message: `🔧 [TOOL / MATERIAL ISSUED]\nHello ${p.name},\nYou have been issued: ${it.name} (Qty: ${qn} ${it.unit || 'nos'})\nLocation: ${val("f-loc") || it.loc || 'Site'}\nExpected Return: ${val("f-ret") || 'N/A'}`
});
logAct(`Issued ${qn} ${it.unit} ${it.name} → ${p.name}`);
if (!(empWorker && empWorker.phone)) toast(`Issued — no phone saved for ${p.name}, so no WhatsApp. Add it in HR.`, false); else toast("Issued");
closeModal(); render();
}
}]);
}
function returnItem(iss) {
const it = DB.items.find(x => x.id === iss.itemId);
openModal("Return — " + iss.item,
`Issued To: ${iss.person} · ${iss.qty} ${iss.unit} · Out On ${iss.dateOut}
` +
`` + field("Returned OK", "f-ok", iss.qty, "number", "min=0") + field("Damaged", "f-dmg", "0", "number", "min=0") + `
` +
`` + field("Lost / Not Returned", "f-lost", "0", "number", "min=0") + field("Replacement rate (" + CUR + "/unit)", "f-rate", it ? it.rate : 0, "number") + `
` +
field("Damaged/Lost reason", "f-reason"),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Process Return", cls: "", fn: () => {
const ok = +val("f-ok"), dmg = +val("f-dmg"), lost = +val("f-lost"), r = +val("f-rate");
if (ok + dmg + lost !== iss.qty) return toast("Total counts must equal " + iss.qty);
if (it) it.qty += ok;
const bad = dmg + lost;
if (bad > 0) {
iss.status = "Debited";
save();
closeModal();
return raiseDebit(iss, bad, dmg > 0 && lost > 0 ? "Damaged / Not Returned" : (dmg > 0 ? "Damaged" : "Not Returned"), r, val("f-reason"));
}
iss.status = "Returned";
save();
const empWorker = (DB.employees || []).find(e => e.id === iss.workerId || e.name === iss.person);
sendWhatsAppNotification('stores_return', {
recipient: iss.person,
phone: empWorker?.phone || '',
message: `↩ [TOOL RETURNED TO STORES]\nHello ${iss.person},\nTool ${iss.item} (Qty: ${ok}) has been returned to Site Stores.\nStatus: Returned OK`
});
logAct(`Returned ${ok} ${iss.item} by ${iss.person}`); toast("Returned to stock"); closeModal(); render();
}
}]);
}
// ── Fine Item — raise a fine without full return process ──────────────────
function formFineItem(iss) {
const it = DB.items.find(x => x.id === iss.itemId);
const FINE_REASONS = ["Damage", "Lost / Not Returned", "Negligence", "Late Return", "Other"];
openModal("\ud83d\udcb0 Issue Fine \u2014 " + iss.item,
`Issued to: ${iss.person} \u00b7 ${iss.qty} ${iss.unit} \u00b7 Out On ${iss.dateOut}
` +
`Reason for Fine
${FINE_REASONS.map(r => `${r} `).join("")}
`+
field("Fine Amount (" + CUR + ")", "f-fine-amt", iss.fineAmount || "", "number", "min=0") +
field("Additional Notes", "f-fine-note", iss.fineNote || "") +
(iss.fineAmount ? `\ud83d\udcb0 Current fine: ${money(iss.fineAmount)} \u2014 ${iss.fineReason || ''}
` : ""),
[{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "\ud83d\udcb0 Apply Fine", cls: "", fn: () => {
const amt = +val("f-fine-amt");
if (!amt || amt <= 0) return toast("Enter a valid fine amount");
const reason = document.getElementById("f-fine-reason").value;
const note = val("f-fine-note");
// Store on issue
iss.fineAmount = amt;
iss.fineReason = reason;
iss.fineNote = note;
iss.finedOn = todayISO();
iss.finedBy = USER.displayName || USER.username;
// Auto-create debit note linked to HR
const dnNo = "FN-" + Math.floor(100 + Math.random() * 900);
const emp = DB.employees.find(e => e.name === iss.person || e.id === iss.personRef);
if (emp) {
DB.deduct = DB.deduct || [];
DB.deduct.unshift({
id: uid("de"), staffId: emp.id, staffName: emp.name,
amount: amt, reason: `${dnNo}: Fine \u2014 ${reason} (${iss.item})`,
month: todayISO().slice(0, 7), status: "Pending"
});
}
const dn = {
raisedBy: USER.displayName || USER.username, id: uid("dn"), no: dnNo, issuesId: iss.id, date: todayISO(),
person: iss.person, personRef: iss.personRef, item: iss.item,
qty: iss.qty, value: amt, reason: "Fine: " + reason, notes: note, status: "Pending"
};
DB.debitNotes.unshift(dn);
save();
logAct(`Fine ${dnNo} applied to ${iss.person} \u2014 ${money(amt)} \u2014 ${reason}`);
toast("Fine applied \u2714 Debit note raised");
closeModal(); render();
}
}]);
}
function raiseDebit(iss, qty, reason, rate, notes) {
const dnNo = "DN-" + Math.floor(180 + Math.random() * 900);
const valAmt = qty * (rate || 0);
// Auto deduct from salary in HR
const emp = DB.employees.find(x => x.name === iss.person || x.id === iss.personRef);
if (emp) {
emp.salaryApproved = false; // suspend/require re-approval
// Create salary deduction record
DB.deduct = DB.deduct || [];
DB.deduct.unshift({
id: uid("de"),
staffId: emp.id,
staffName: emp.name,
amount: valAmt,
reason: `${dnNo}: ${reason} tool (${iss.item})`,
month: todayISO().slice(0, 7),
status: "Pending"
});
}
const dn = { raisedBy: USER.displayName || USER.username, id: uid("dn"), no: dnNo, issuesId: iss.id, date: todayISO(), person: iss.person, personRef: iss.personRef, item: iss.item, qty, value: valAmt, reason, notes, status: "Pending" };
DB.debitNotes.unshift(dn);
save();
const empWorker = (DB.employees || []).find(e => e.name === iss.person || e.id === iss.personRef);
sendWhatsAppNotification('debit_note', {
recipient: iss.person,
phone: empWorker?.phone || '+244 923 000 008',
message: `⚠️ [SALARY DEDUCTION / DEBIT NOTE]\nHello ${iss.person},\nDebit Note #${dnNo} for ${money(valAmt)} has been raised.\nItem: ${iss.item} | Reason: ${reason}\nMapped to HR salary processing.`
});
logAct(`Debit Note ${dnNo} raised on ${iss.person}`);
toast("Debit note raised & mapped to HR");
printDebit(dn);
}
function printDebit(dn) {
const w = window.open();
w.document.write(`
Debit Note — ${dn.no}
MEGACORP INDUSTRIA, LDA
Factory Address: ${escH(getFactoryAddress())} · NIF: ${escH(getCompanyNif())} · Email: ${escH(getCompanyEmail())}
DEBIT NOTE / NOTA DE DEBITO: ${dn.no}
Date: ${dn.date}
Issued on: ${dn.person}
Description Qty Value/Unit Total Deduction
${dn.item} - ${dn.reason}${dn.notes || ''} ${dn.qty} ${money(dn.value / dn.qty)} ${money(dn.value)}
Total amount to be deducted from monthly payroll: ${money(dn.value)}
Site Admin / Representative Signature ____________________________
Worker / Debitor Signature ____________________________
window.print();
`);
}
// ── Purchase history for quote comparison ────────────────────────────────
// Statuses that mean the indent was actually bought (a real price was paid / committed)
const PURCHASED_STATUSES = ["PO Issued", "Payment Done", "Dispatched", "Received"];
function indentTotalQty(p) {
const q = (p.items || []).reduce((s, i) => s + (Number(i.qty) || 0), 0);
return q > 0 ? q : (Number(p.qty) || 0);
}
function purchaseDate(p) {
return (p.supplierInvDate || p.paymentDate || p.approvedAt || p.receivedDate || p.receivedAt || p.at || '').slice(0, 10);
}
// For each item on the indent, find previous purchases of the same/similar item (latest first)
function pastPurchasesFor(p) {
const itemNames = (p.items && p.items.length) ? p.items.map(i => (i.desc || '').trim()).filter(Boolean) : [(p.desc || '').trim()].filter(Boolean);
return itemNames.map(name => {
const t = name.toLowerCase();
const hist = (DB.proc || []).filter(x => {
if (x.id === p.id || !PURCHASED_STATUSES.includes(x.status) || !(Number(x.value) > 0)) return false;
const descs = (x.items || []).map(i => (i.desc || '').toLowerCase()).concat([(x.desc || '').toLowerCase()]);
return descs.some(d => d && (d.includes(t) || t.includes(d)));
}).map(x => {
const qty = indentTotalQty(x);
const sel = (x.quotes || {})[x.selected] || {};
return {
no: x.no, supplier: x.supplier || sel.sup || '—', date: purchaseDate(x), qty,
unit: (x.items && x.items[0] && x.items[0].unit) || x.unit || '',
total: Number(x.value) || 0, unitPrice: qty > 0 ? (Number(x.value) || 0) / qty : 0,
piNo: sel.piNo || '—', payRef: x.paymentRef || '—', invNo: x.supplierInvNo || '', status: x.status
};
}).sort((a, b) => b.date.localeCompare(a.date)).slice(0, 3);
return { item: name, hist };
});
}
function pctDelta(now, before) {
if (!(now > 0) || !(before > 0)) return null;
return ((now - before) / before) * 100;
}
function deltaBadge(pct) {
if (pct === null) return '';
const up = pct > 0.5, down = pct < -0.5;
const col = up ? '#dc2626' : down ? '#16a34a' : '#6b7280';
const bg = up ? '#fee2e2' : down ? '#dcfce7' : '#f3f4f6';
const arrow = up ? '▲' : down ? '▼' : '=';
return `${arrow} ${Math.abs(pct).toFixed(1)}% vs last `;
}
// Panel listing previous purchases per item
function purchaseHistoryHTML(p) {
const groups = pastPurchasesFor(p);
const any = groups.some(g => g.hist.length);
if (!any) return `📜 Purchase History: no previous purchase found for ${groups.length > 1 ? 'these items' : 'this item'}.
`;
const rows = groups.map(g => g.hist.length
? g.hist.map((h, i) => `
${i === 0 ? `${escAttr(g.item)} ` : ''}
${h.date || '—'} ${escAttr(h.supplier)}
${h.qty ? h.qty + ' ' + escAttr(h.unit) : '—'}
${h.unitPrice ? money(h.unitPrice) : '—'}
${money(h.total)}
${h.invNo ? `Inv: ${escAttr(h.invNo)} ` : 'Inv: not entered '}${escAttr(h.no)} · PI: ${escAttr(h.piNo)}${h.payRef && h.payRef !== '—' ? ' · Ref: ' + escAttr(h.payRef) : ''}
`).join('')
: `${escAttr(g.item)} No previous purchase `
).join('');
return `
📜 Purchase History (last 3 per item) — compare with the offers above
Item Date Supplier Qty Unit Price Total Paid Invoice / Indent / PI
${rows}
`;
}
// Per-offer comparison line: current offer unit price vs the latest purchase unit price
function offerVsLastHTML(p, q) {
const groups = pastPurchasesFor(p);
const last = groups.length === 1 ? (groups[0].hist[0] || null) : null; // only meaningful for single-item indents
const qty = indentTotalQty(p);
const unitNow = (Number(q.price) > 0 && qty > 0) ? Number(q.price) / qty : 0;
if (!last) {
return groups.length > 1
? `Multi-item indent — see history table below
`
: `No previous purchase to compare
`;
}
const pct = pctDelta(unitNow, last.unitPrice);
return `
Unit price now: ${unitNow ? money(unitNow) : '—'} ${deltaBadge(pct)}
Last: ${money(last.unitPrice)}/unit · ${escAttr(last.supplier)} · ${last.date}
`;
}
// Add / edit the supplier's tax invoice number on any purchased indent (incl. old ones)
function formSupplierInvoice(p) {
if (!p) return;
openModal("🧾 Supplier Invoice — " + p.no,
`Supplier: ${escAttr(p.supplier || '—')} · Total: ${p.value ? money(p.value) : '—'} · Status: ${pill(p.status)}
` +
`` + field("Supplier Invoice No (tax invoice)", "f-inv-no", p.supplierInvNo || "") + field("Invoice Date", "f-inv-date", p.supplierInvDate || "", "date") + `
` +
`` + field("Quantity Purchased", "f-inv-qty", indentTotalQty(p) || "", "number") + field("Purchase Date", "f-inv-pdate", purchaseDate(p) || "", "date") + `
` +
`Quantity and purchase date are only needed if the old record is missing them — they feed the purchase-history comparison on Compare Quotes.
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save", cls: "", fn: () => {
p.supplierInvNo = (val("f-inv-no") || "").trim();
p.supplierInvDate = val("f-inv-date") || "";
const q = +val("f-inv-qty");
if (q > 0 && !((p.items || []).length)) p.qty = q;
if (q > 0 && (p.items || []).length === 1) p.items[0].qty = q;
const pd = val("f-inv-pdate");
if (pd) p.receivedDate = pd;
save(); logAct(`${p.no} supplier invoice set: ${p.supplierInvNo || '—'}`); toast("Invoice details saved"); closeModal(); render();
}
}]);
}
function formCompareOnly(p) {
if (Array.isArray(p.quotes)) p.quotes = {};
p.quotes = p.quotes || {};
const SRC = [["luanda", "Luanda"], ["lobito", "Lobito"], ["india", "India"], ["other", "Other"]];
const active = SRC.filter(([k]) => p.quotes[k] && (p.quotes[k].price > 0 || p.quotes[k].sup));
const display = active.length ? active : SRC.slice(0, 3);
const prices = display.map(([k]) => p.quotes[k]?.price || 0).filter(x => x > 0);
const minPrice = prices.length ? Math.min(...prices) : 0;
const gridCols = display.map(([k, lbl]) => {
const q = p.quotes[k] || {};
const isLowest = minPrice > 0 && q.price === minPrice;
return `
${lbl} ${isLowest ? 'LOWEST ' : ''}
Supplier: ${q.sup || '—'}
PI No: ${q.piNo || '—'}
PI Date: ${q.date || '—'}
IBAN: ${q.iban || '—'}
Terms: ${q.terms || '—'}
${q.price ? money(q.price) : '—'}
${offerVsLastHTML(p, q)}
`;
}).join("");
openModal("Compare Offers — " + p.no + " (View Only)",
`Side-by-side PI comparison for: ${p.desc} (qty ${indentTotalQty(p) || '—'}). Green = lowest price. Only the owner can approve.
${gridCols}
${purchaseHistoryHTML(p)}
${p.compNote ? `Recommendation: ${p.compNote}
` : ''}`,
[{ label: "Close", cls: "secondary", fn: closeModal }]);
}
// ── Approve / Reject owner popup ──────────────────────────────────────────
function formApproveReject(p) {
if (Array.isArray(p.quotes)) p.quotes = {};
p.quotes = p.quotes || {};
const SRC = [["luanda", "Luanda"], ["lobito", "Lobito"], ["india", "India"], ["other", "Other Country"]];
const active = SRC.filter(([k]) => p.quotes[k] && (p.quotes[k].price > 0 || p.quotes[k].sup));
const display = active.length ? active : SRC.slice(0, 3);
const prices = display.map(([k]) => p.quotes[k]?.price || 0).filter(x => x > 0);
const minPrice = prices.length ? Math.min(...prices) : 0;
const gridCols = display.map(([k, lbl]) => {
const q = p.quotes[k] || {};
const isLowest = minPrice > 0 && q.price === minPrice;
return `
${lbl} ${isLowest ? 'LOWEST ' : ''}
Supplier: ${q.sup || '—'}
PI No: ${q.piNo || '—'}
Price: ${q.price ? money(q.price) : '—'}
IBAN: ${q.iban || '—'}
${offerVsLastHTML(p, q)}
Select This
`;
}).join("");
openModal("Approve or Reject — " + p.no,
`Review quotes for: ${p.desc} (qty ${indentTotalQty(p) || '—'}). Select a supplier to approve, or reject the entire indent.
${gridCols}
${purchaseHistoryHTML(p)}
Recommendation / Note
`,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "❌ Reject Indent", cls: "danger", fn: () => {
const reason = prompt("Reason for rejection:") || "Rejected by owner";
p.status = "Rejected"; p.rejectReason = reason;
p.rejectedBy = USER.displayName || USER.username; p.rejectedAt = todayISO();
logAudit('reject', p, [{ field: 'status', from: 'Quoted', to: 'Rejected' }]);
save(); logAct(`${p.no} REJECTED — ${reason}`); notifyIndentRejected(p, reason); toast("Indent rejected"); closeModal(); render();
}
},
{
label: "✔ Approve & Issue PO", cls: "", fn: () => {
const selEl = document.querySelector('input[name="appSel"]:checked');
if (!selEl) return toast("Select a supplier first");
p.selected = selEl.value;
const q = p.quotes[p.selected];
p.supplier = q.sup || "—"; p.value = q.price || 0;
p.status = "PO Issued";
p.compNote = document.getElementById("app-note").value;
p.approvedBy = USER.displayName || USER.username; p.approvedAt = todayISO();
logAudit('approve', p, [{ field: 'status', from: 'Quoted', to: 'PO Issued' }]);
save(); logAct(`${p.no} PO issued — ${p.supplier} — ${money(p.value)} — Approved by ${p.approvedBy}`);
sendPOToSupplier(p);
toast("PO Issued ✓"); closeModal(); render();
sendSiteDeskWA('quotation_approved', [userPhoneByName(p.by), procInchargePhone()].concat(ownerPhones()), { indent_no: p.no + ' — ' + p.supplier + ' ' + money(p.value || 0), approved_by: p.approvedBy, approval_date: p.approvedAt }, { label: 'Quotation approved', toastResult: true });
}
}
]);
}
function sendPOToSupplier(p) {
const supplierName = (p.supplier || '').trim();
if (!supplierName || supplierName === '—') return;
const vendor = (DB.vendors || []).find(v => (v.name || '').trim().toLowerCase() === supplierName.toLowerCase());
const supplierPhone = vendor ? (vendor.whatsapp || vendor.contact || '') : '';
const q = p.quotes ? p.quotes[p.selected] : {};
const poNo = p.no;
const poMessage = `📄 *OFFICIAL PURCHASE ORDER — ${poNo}*
Dear ${supplierName},
Our project management has approved Purchase Order #${poNo} for:
📦 *Item:* ${p.desc}
📊 *Quantity:* ${p.qty} ${p.unit}
💰 *Approved Value:* ${money(p.value)}
${q.piNo ? '🧾 *PI/Ref No:* ' + q.piNo + '\n' : ''}📅 *Approved Date:* ${p.approvedAt || todayISO()}
👤 *Approved By:* ${p.approvedBy || USER.displayName || 'Owner'}
Please find attached the Purchase Order details. Kindly confirm receipt and delivery schedule.
Thank you,
SiteDesk Procurement`;
const poCsv = `PO No,Department,Description,Qty,Unit,Value (${CUR}),Supplier,PI No,Approved By,Approved Date\n"${p.no}","${p.dept}","${p.desc.replace(/"/g, '""')}",${p.qty},"${p.unit}",${p.value},"${supplierName}","${q.piNo || ''}","${p.approvedBy || ''}","${p.approvedAt || todayISO()}"`;
sendWhatsAppNotification('procurement_po', {
recipient: supplierName,
phone: supplierPhone || '+91 9991237678',
message: poMessage,
attachmentCsv: poCsv,
attachmentName: `PO_${poNo}.csv`
});
}
// ── Dispatch form (Payment Done → Dispatched, full dispatch details) ──────
function formDispatch(p) {
// Show existing payment proof summary at top
const proofFiles = Array.isArray(p.paymentProof) ? p.paymentProof : (p.paymentProof ? [p.paymentProof] : []);
const proofBadge = proofFiles.length
? `✅ Payment proof attached (${proofFiles.length} file${proofFiles.length !== 1 ? 's' : ''}) — uploaded by ${p.paymentProofBy || 'Accounts'} on ${p.paymentProofAt || '—'}
`
: `⚠ No payment proof found — contact Accounts before dispatching
`;
const dd = p.dispatchDetails || {};
openModal("🚚 Add Dispatch Details — " + p.no,
proofBadge +
`Supplier: ${p.supplier || '—'} · PO Value: ${money(p.value || 0)}
` +
`` +
field("Material Description", "f-disp-desc", dd.desc || p.desc || "") +
field("Quantity Dispatched", "f-disp-qty", dd.qty || p.qty || "") +
`
` +
`` +
field("Dispatch Date", "f-disp-date", dd.dispatchDate || todayISO(), "date") +
field("Transporter / Vehicle", "f-disp-transporter", dd.transporter || "") +
`
` +
field("LR No. / Waybill No.", "f-disp-lr", dd.lrNo || "") +
`Dispatch Photos (JPG — multiple allowed)
Choose Photos
${(p.dispatchPhotos || []).length ? `
${p.dispatchPhotos.length} photo(s) already saved
` : ''}
`+
(isIndiaSupplierInd(p) ? courierFieldsHtml(p.courier) : '') +
`` +
`` +
field("Freight Amount (Kz)", "f-disp-freight", dd.freightAmount || "") +
field("Freight Paid To (Transporter)", "f-disp-freight-to", dd.freightTo || "") +
`
` +
`Freight Payment Proof (PDF or JPG)
Choose Freight Proof
${(p.freightProof || []).length ? `
${p.freightProof.length} freight proof file(s) already saved
` : ''}
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "🚚 Save & Mark Dispatched", cls: "", fn: async () => {
const lrNo = val("f-disp-lr");
if (!lrNo.trim()) return toast("Please enter the LR / Waybill No.", false);
const photoInp = document.getElementById("f-disp-photos");
const freightInp = document.getElementById("f-disp-freight-proof");
const dispatchPhotos = photoInp.files.length ? await readFiles(photoInp) : [];
const freightProof = freightInp.files.length ? await readFiles(freightInp) : [];
p.dispatchDetails = {
desc: val("f-disp-desc"),
qty: val("f-disp-qty"),
dispatchDate: val("f-disp-date"),
transporter: val("f-disp-transporter"),
lrNo,
freightAmount: val("f-disp-freight"),
freightTo: val("f-disp-freight-to")
};
if (isIndiaSupplierInd(p)) p.courier = Object.assign({}, readCourierFields(), { updatedBy: USER.displayName || USER.username, updatedAt: todayISO() });
p.dispatchPhotos = (p.dispatchPhotos || []).concat(dispatchPhotos);
p.freightProof = (p.freightProof || []).concat(freightProof);
p.status = "Dispatched";
p.shipRef = lrNo;
p.dispatchedAt = todayISO();
p.dispatchedBy = USER.displayName || USER.username;
save();
logAct(`${p.no} dispatched — LR: ${lrNo} · Transporter: ${val("f-disp-transporter")}`);
toast("Dispatch details saved ✓"); closeModal(); render();
}
}]);
// File indicators
setTimeout(() => {
const pi = document.getElementById("f-disp-photos");
if (pi) pi.onchange = () => {
const ind = document.getElementById("disp-photos-ind");
ind.style.display = "block";
ind.textContent = "✅ " + pi.files.length + " photo(s) selected";
};
const fi = document.getElementById("f-disp-freight-proof");
if (fi) fi.onchange = () => {
const ind = document.getElementById("disp-freight-ind");
ind.style.display = "block";
ind.textContent = "✅ " + Array.from(fi.files).map(f => f.name).join(", ");
};
}, 50);
}
// ── Payment Proof — Upload ─────────────────────────────────────────────────
function formPaymentProof(p) {
const existing = Array.isArray(p.paymentProof) ? p.paymentProof : (p.paymentProof ? [p.paymentProof] : []);
openModal('💳 Payment Proof — ' + p.no,
`PO: ${p.desc} · Supplier: ${p.supplier || '—'} · Value: ${money(p.value || 0)}
` +
(existing.length ? `Already attached (${existing.length} file${existing.length !== 1 ? 's' : ''}):
${existing.map((f, i) =>
f.type === 'application/pdf'
? `
📄 PDF ${i + 1} ⬇ `
: `
`
).join('')}
` : '') +
``,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: '💾 Save Proof', cls: '', fn: async () => {
const inp = document.getElementById('f-proof-file');
if (!inp.files.length && !existing.length) { toast('Please select at least one file', false); return; }
const newFiles = inp.files.length ? await readFiles(inp) : [];
p.paymentProof = existing.concat(newFiles);
p.paymentProofBy = USER.displayName || USER.username;
p.paymentProofAt = todayISO();
save(); logAct(`Payment proof uploaded for ${p.no}`);
toast('Payment proof saved ✓'); closeModal(); render();
}
}]
);
setTimeout(() => {
const inp = document.getElementById('f-proof-file');
if (inp) inp.onchange = () => {
const ind = document.getElementById('proof-file-ind');
if (inp.files.length) {
ind.style.display = 'block';
ind.textContent = '✅ ' + Array.from(inp.files).map(f => f.name).join(', ');
}
};
}, 50);
}
// ── Payment Proof — View / Download ───────────────────────────────────────
function viewPaymentProof(p) {
// Normalise: legacy stored as single object, new as array
const files = Array.isArray(p.paymentProof) ? p.paymentProof : (p.paymentProof ? [p.paymentProof] : []);
const uploadedBy = p.paymentProofBy ? `Uploaded by ${p.paymentProofBy} on ${p.paymentProofAt || '—'}
` : '';
openModal('💳 Payment Proof — ' + p.no,
uploadedBy +
`` +
files.map((f, i) =>
f.type === 'application/pdf'
? `
📄 Payment Proof PDF ${files.length > 1 ? i + 1 : ''}
PDF Document
`
: `
Payment Proof Image ${files.length > 1 ? i + 1 : ''}
Image
`
).join('') + `
` +
`➕ Add More Files
`,
[{ label: 'Close', cls: 'secondary', fn: closeModal }]
);
}
// ── Print Indent (downloadable pre-approval indent sheet) ─────────────────
function printIndent(p) {
const today = todayISO();
const items = (p.items && p.items.length) ? p.items : [{ desc: p.desc, qty: p.qty, unit: p.unit, area: p.area, remarks: p.remarks }];
const itemRows = items.map((it, i) => `
${i + 1}
${escH(it.desc)}
${it.qty}
${escH(it.unit || 'Nos')}
${escH(it.area || '—')}
${escH(it.remarks || '—')}
`).join("");
const w = window.open("", "_blank", "width=850,height=650");
w.document.write(`
Indent — ${p.no}
MEGACORP INDUSTRIA (SU), LDA
Factory Address: ${escH(getFactoryAddress())} · NIF: ${escH(getCompanyNif())} · Email: ${escH(getCompanyEmail())}
PURCHASE INDENT / REQUISIÇÃO
${p.no}
Date / Data: ${today}
Raised by: ${p.by || '—'}
Status: ${p.status}
Requested Items (${items.length})
#
Item / Description
Qty
Unit
Area of Usage / Specs
Remarks
${itemRows}
Requested by / Solicitado por ____________________________${p.by || 'Department Representative'}
Reviewed by / Revisto por ____________________________Procurement Officer
Approved by / Aprovado por ____________________________Managing Director
🖶 Print / Save PDF
setTimeout(()=>window.print(),400);
`);
w.document.close();
}
function confirmDel(id, arr, key) {
const o = arr.find(x => x.id === id);
openModal("Remove?", `Are you sure you want to delete this record?
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Remove", cls: "", style: "background:var(--bad);color:#fff", fn: () => {
DB[key] = arr.filter(x => x.id !== id); save(); toast("Removed"); closeModal(); render();
}
}]);
}
// ── SECTION 5 IMPLEMENTATIONS ──────────────────────────────────────────────────
// WhatsApp to owners, the raiser and the procurement in-charge when an indent is rejected
function notifyIndentRejected(p, reason) {
const by = (USER && (USER.displayName || USER.username)) || 'Procurement';
const recips = [userPhoneByName(p.by), procInchargePhone()].concat(ownerPhones());
const desc = String(p.desc || '').replace(/\s+/g, ' ').slice(0, 80);
sendSiteDeskWA('indent_rejected', recips,
{ indent_no: p.no + (desc ? ' — ' + desc : ''), reason: reason || 'No reason given', rejected_by: by },
{ label: 'Indent rejected', toastResult: true });
}
// Reject indent with reason
function formRejectIndent(p) {
openModal("Reject Indent — " + p.no,
`Rejecting this indent will stop the procurement process. The requester will be notified via the activity log.
` +
field("Reason for Rejection", "f-reason"),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Confirm Reject", cls: "danger", fn: () => {
const reason = val("f-reason") || "No reason provided";
const prevStatus = p.status || 'Indent Raised';
p.status = "Rejected";
p.rejectReason = reason;
p.rejectedBy = USER.displayName || USER.username;
p.rejectedAt = todayISO();
logAudit('reject', p, [{ field: 'status', from: prevStatus, to: 'Rejected' }, { field: 'reason', from: '', to: reason }]);
save();
logAct(`${p.no} REJECTED by ${p.rejectedBy} — ${reason}`);
notifyIndentRejected(p, reason);
toast("Indent rejected & logged");
closeModal();
render();
}
}]);
}
// 1. Multi-Item Procurement Indent Form & Helpers
window._indentItemsTemp = [];
window.renderIndentItemRows = function () {
const container = document.getElementById("indent-items-container");
if (!container) return;
const items = window._indentItemsTemp || [];
container.innerHTML = items.map((it, i) => `
Item #${i + 1}
${items.length > 1 ? `🗑 Delete ` : ''}
`).join("");
};
window.updateIndentItemProp = function (idx, prop, val) {
if (window._indentItemsTemp && window._indentItemsTemp[idx]) {
window._indentItemsTemp[idx][prop] = val;
}
};
window.addIndentItemRow = function () {
window.syncIndentItemsFromDOM();
window._indentItemsTemp.push({ id: uid("item"), desc: "", qty: 1, unit: "Nos", area: "", remarks: "" });
window.renderIndentItemRows();
};
window.removeIndentItemRow = function (idx) {
if (!window._indentItemsTemp || window._indentItemsTemp.length <= 1) return;
window.syncIndentItemsFromDOM();
window._indentItemsTemp.splice(idx, 1);
window.renderIndentItemRows();
};
window.syncIndentItemsFromDOM = function () {
(window._indentItemsTemp || []).forEach((it, i) => {
const descEl = document.getElementById(`f-item-desc-${i}`);
const qtyEl = document.getElementById(`f-item-qty-${i}`);
const unitEl = document.getElementById(`f-item-unit-${i}`);
const areaEl = document.getElementById(`f-item-area-${i}`);
const remEl = document.getElementById(`f-item-rem-${i}`);
if (descEl) it.desc = descEl.value;
if (qtyEl) it.qty = +qtyEl.value || 1;
if (unitEl) it.unit = unitEl.value || 'Nos';
if (areaEl) it.area = areaEl.value || '';
if (remEl) it.remarks = remEl.value || '';
});
};
window.lookupLastPurchaseRow = function (idx, typed) {
const info = document.getElementById(`last-purchase-info-row-${idx}`);
if (!info) return;
if (!typed || typed.length < 3) { info.style.display = "none"; return; }
const t = typed.toLowerCase().trim();
const matches = (DB.proc || []).filter(p => {
if (p.status !== "Received") return false;
const itemDescs = (p.items || []).map(i => (i.desc || '').toLowerCase());
const rootDesc = (p.desc || '').toLowerCase();
return itemDescs.some(d => d.includes(t) || t.includes(d)) || rootDesc.includes(t);
}).sort((a, b) => (b.at || '').localeCompare(a.at || '')).slice(0, 2);
if (!matches.length) { info.style.display = "none"; return; }
info.style.display = "block";
info.innerHTML = matches.map((last, k) => {
return (k === 0 ? '💡 Last Purchase: ' : '') +
`` +
`${money(last.value)} · ${escAttr(last.supplier || '—')} · ${(last.at || '').slice(0, 10)}` +
` `;
}).join(" ");
};
function formIndent() {
window._indentItemsTemp = [{ id: uid("item"), desc: "", qty: 1, unit: "Nos", area: "", remarks: "" }];
openModal("Raise Purchase Indent",
`Indent Details
` +
sel("Department", "f-dept", DEPTS) +
sel("Delivery Location", "f-dloc", ["Luanda", "Lobito", "India", "Other Country"]) +
field("Raised by: full name of person *", "f-raiser", USER.displayName || USER.username || "", "text", 'required placeholder="Type the full name (mandatory)"') +
field("Purpose / Notes", "f-purpose") +
`` +
`
Items
` +
`
+ Add Item ` +
`
` +
`
`,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{ label: "Save Draft", cls: "ghost", fn: () => window.saveIndentAction(true) },
{ label: "Submit Indent", cls: "", fn: () => window.saveIndentAction(false) }
]
);
setTimeout(() => window.renderIndentItemRows(), 50);
}
window.saveIndentAction = function (isDraft, existingIndent) {
window.syncIndentItemsFromDOM();
const dept = val("f-dept");
const dloc = val("f-dloc") || "Luanda";
const purpose = val("f-purpose");
const raiserName = existingIndent ? null : val("f-raiser");
if (!existingIndent && !raiserName) {
return toast("Please type the full name of the person raising this indent", false);
}
const items = (window._indentItemsTemp || []).map(it => ({
id: it.id || uid("item"),
desc: (it.desc || '').trim(),
qty: Number(it.qty) || 1,
unit: (it.unit || 'Nos').trim(),
area: (it.area || '').trim(),
remarks: (it.remarks || '').trim()
}));
const validItems = items.filter(it => it.desc.length > 0);
if (!isDraft && validItems.length === 0) {
return alertField("Please enter at least one item name.");
}
const finalItems = validItems.length > 0 ? validItems : items;
const mainDesc = finalItems.map(it => it.desc).filter(Boolean).join(', ') || 'Indent Request';
const mainQty = finalItems.reduce((s, it) => s + (it.qty || 0), 0);
const mainUnit = finalItems[0]?.unit || 'Nos';
if (existingIndent) {
existingIndent.dept = dept;
existingIndent.deliveryLoc = dloc;
existingIndent.purpose = purpose;
existingIndent.desc = mainDesc;
existingIndent.qty = mainQty;
existingIndent.unit = mainUnit;
existingIndent.items = finalItems;
if (!isDraft && existingIndent.status === "Draft") {
existingIndent.status = "Indent Raised";
}
logAudit('edit', existingIndent, []);
save();
toast(isDraft ? "Draft updated ✓" : "Indent updated & submitted ✓");
} else {
const n = nextIndentNo();
const o = {
id: uid("ic"),
no: n,
dept: dept,
deliveryLoc: dloc,
purpose: purpose,
desc: mainDesc,
qty: mainQty,
unit: mainUnit,
by: raiserName,
at: new Date().toISOString(),
dateRaised: todayISO(),
supplier: "—",
value: 0,
status: isDraft ? "Draft" : "Indent Raised",
files: [],
items: finalItems,
quotes: {
luanda: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
lobito: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
india: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
other: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "", country: "" }
},
selected: null
};
DB.proc.unshift(o);
logAudit(isDraft ? 'draft' : 'create', o, []);
save();
if (!isDraft) {
sendWhatsAppNotification('procurement_indent', {
recipient: `${o.dept} & Procurement`,
phone: '+244 923 000 001',
message: `📦 [NEW INDENT RAISED]\nIndent #${n} (${finalItems.length} item(s))\nItems: ${mainDesc.slice(0, 100)}\nDepartment: ${o.dept}\nRaised By: ${o.by}`
});
logAct(`${n} raised with ${finalItems.length} item(s) — [${o.deliveryLoc}]`);
toast("Indent raised successfully ✓");
sendSiteDeskWA('indent_raised', ownerPhones().concat([procInchargePhone()]), { indent_no: n, raiser_name: o.by, amount: `${finalItems.length} item(s), qty ${finalItems.reduce((a, i) => a + (Number(i.qty) || 0), 0)}`, date: todayISO() }, { label: 'Indent raised', toastResult: true });
} else {
logAct(`${n} saved as draft (${finalItems.length} item(s))`);
toast("Indent saved as draft ✓");
}
}
closeModal();
render();
};
// 2. Quotes comparison auto comparison sheet + Owner Supplier approval
function formQuotesComparison(p) {
if (Array.isArray(p.quotes)) p.quotes = {};
p.quotes = p.quotes || {
luanda: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "" },
lobito: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "" },
india: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "" },
other: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", country: "" }
};
const SRC = [["luanda", "Luanda"], ["lobito", "Lobito"], ["india", "India"], ["other", "Other Country"]];
// Only show slots that have a price entered
const activeSRC = SRC.filter(([k]) => {
const q = p.quotes[k];
return q && (q.price > 0 || q.sup);
});
const displaySRC = activeSRC.length ? activeSRC : SRC.slice(0, 3);
// Find lowest price for highlight
const prices = displaySRC.map(([k]) => p.quotes[k]?.price || 0).filter(x => x > 0);
const minPrice = prices.length ? Math.min(...prices) : 0;
let gridCols = displaySRC.map(([k, lbl]) => {
const q = p.quotes[k] || {};
const isLowest = minPrice > 0 && q.price === minPrice;
return `
${lbl}${k === "other" && q.country ? ` (${q.country})` : ''} ${isLowest ? 'LOWEST ' : ''}
Supplier: ${q.sup || '—'}
PI No: ${q.piNo || '—'}
PI Date: ${q.date || '—'}
Email: ${q.email || '—'} · Mobile: ${q.mobile || '—'}
IBAN: ${q.iban || '—'}
Terms: ${q.terms || '—'}
${q.price ? money(q.price) : '—'}
Approve This Offer
`;
}).join("");
openModal("Compare Offers & Issue PO — " + p.no,
`Side-by-side PI comparison for: ${p.desc} . Green border = lowest price.
${gridCols}
Recommendation / Note for Record
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "Approve Supplier & Issue PO", cls: "", fn: () => {
const selEl = document.querySelector('input[name="compSel"]:checked');
if (!selEl) return toast("Select a supplier to approve");
p.selected = selEl.value;
const q = p.quotes[p.selected];
p.supplier = q.sup || "—";
p.value = q.price || 0;
p.status = "PO Issued";
p.compNote = document.getElementById("comp-note").value;
p.approvedBy = USER.displayName || USER.username;
p.approvedAt = todayISO();
logAudit('approve', p, [{ field: 'status', from: 'Quoted', to: 'PO Issued' }, { field: 'supplier', from: '', to: p.supplier }]);
save();
sendWhatsAppNotification('procurement_po', {
recipient: p.supplier || 'Vendor & Owners',
phone: (q && q.mobile) || '+244 923 000 002',
message: `🛒 [PURCHASE ORDER ISSUED]\nPO #${p.poNo || p.no} issued for ${p.desc}\nSupplier: ${p.supplier}\nTotal Value: ${money(p.value || 0)}\nApproved By: ${p.approvedBy}`
});
logAct(`${p.no} PO issued — ${p.supplier} — ${money(p.value)} — Approved by ${p.approvedBy}`);
toast("Supplier approved & PO Issued");
closeModal();
render();
}
}]);
}
// Proforma Invoices (PI) upload dialog for Procurement login
// ── Quote auto-fill from a supplier quotation / offer document ──────────
const QDOC_LIBS = {
pdfjs: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js',
pdfjsWorker: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js',
mammoth: 'https://cdnjs.cloudflare.com/ajax/libs/mammoth/1.6.0/mammoth.browser.min.js',
tesseract: 'https://cdnjs.cloudflare.com/ajax/libs/tesseract.js/5.1.1/tesseract.min.js'
};
const _qdocLoaded = {};
function qdocLoadScript(url) {
if (!_qdocLoaded[url]) _qdocLoaded[url] = new Promise((res, rej) => {
const s = document.createElement('script');
s.src = url; s.onload = res;
s.onerror = () => rej(new Error('could not load a reader library (check internet connection)'));
document.head.appendChild(s);
});
return _qdocLoaded[url];
}
async function qdocPdfText(file) {
await qdocLoadScript(QDOC_LIBS.pdfjs);
window.pdfjsLib.GlobalWorkerOptions.workerSrc = QDOC_LIBS.pdfjsWorker;
const pdf = await window.pdfjsLib.getDocument({ data: await file.arrayBuffer() }).promise;
let text = '';
for (let i = 1; i <= pdf.numPages; i++) {
const tc = await (await pdf.getPage(i)).getTextContent();
text += tc.items.map(t => t.str).join(' ') + '\n';
}
return text;
}
async function qdocOcr(src) {
await qdocLoadScript(QDOC_LIBS.tesseract);
const r = await window.Tesseract.recognize(src, 'eng+por');
return r.data.text || '';
}
async function qdocPdfOcr(file) {
await qdocLoadScript(QDOC_LIBS.pdfjs);
window.pdfjsLib.GlobalWorkerOptions.workerSrc = QDOC_LIBS.pdfjsWorker;
const pdf = await window.pdfjsLib.getDocument({ data: await file.arrayBuffer() }).promise;
let out = '';
for (let i = 1; i <= Math.min(pdf.numPages, 3); i++) {
const pg = await pdf.getPage(i);
const vp = pg.getViewport({ scale: 2 });
const c = document.createElement('canvas');
c.width = vp.width; c.height = vp.height;
await pg.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise;
out += await qdocOcr(c) + '\n';
}
return out;
}
function qdocParseNum(s) {
let t = String(s || '').replace(/[^\d.,]/g, '');
if (!t) return NaN;
const ld = t.lastIndexOf('.'), lc = t.lastIndexOf(',');
if (ld > -1 && lc > -1) {
t = ld > lc ? t.replace(/,/g, '') : t.replace(/\./g, '').replace(',', '.');
} else if (lc > -1) {
t = (t.length - lc - 1 === 3) ? t.replace(/,/g, '') : t.replace(',', '.');
} else if (ld > -1) {
if (t.split('.').length > 2 || (t.length - ld - 1) === 3) t = t.replace(/\./g, '');
}
return parseFloat(t);
}
// Heuristic reader: returns suggested values; every value is shown to the user for confirmation.
function qdocParseText(raw) {
const text = String(raw || '').replace(/ /g, ' ');
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const out = {};
const first = (re) => { const x = text.match(re); return x ? (x[1] || x[0]).trim() : ''; };
// Quotation / PI number
// Label must be followed by a token that contains a digit (so "Supplier" or "Date" are never taken as the number)
const pi = first(/\b(?:P\.?I\.?|proforma\s*invoice|pro\s*forma|invoice|quotation|quote|cota[cç][aã]o|fatura)\s*(?:no\.?|n[º°o]\.?|number|ref\.?)?\s*[:#\-]?\s*((?=[A-Z0-9\/\-.]*\d)[A-Z0-9][A-Z0-9\/\-.]{2,25})/i);
if (pi) out.piNo = pi;
// Date (dd/mm/yyyy assumed for 2-part-first formats common in Angola/India)
let dm = text.match(/\b(\d{4})[-\/.](\d{1,2})[-\/.](\d{1,2})\b/);
if (dm) out.date = `${dm[1]}-${dm[2].padStart(2, '0')}-${dm[3].padStart(2, '0')}`;
else if ((dm = text.match(/\b(\d{1,2})[-\/.](\d{1,2})[-\/.](\d{4}|\d{2})\b/))) {
const yy = dm[3].length === 2 ? '20' + dm[3] : dm[3];
out.date = `${yy}-${dm[2].padStart(2, '0')}-${dm[1].padStart(2, '0')}`;
}
// Total price — labelled totals, highest priority first
const pri = [/grand\s*total/i, /amount\s*due/i, /valor\s*total/i, /total\s*(?:amount|value|price|a\s*pagar|incl\w*)?/i, /net\s*(?:amount|total)/i];
let priceFound = false;
for (const re of pri) {
if (priceFound) break;
for (const ln of lines) {
if (!re.test(ln) || /sub\s*total/i.test(ln)) continue;
const nums = [...ln.matchAll(/\d[\d.,\s]*\d|\d/g)].map(x => qdocParseNum(x[0])).filter(n => !isNaN(n) && n > 0);
if (nums.length) { out.price = nums[nums.length - 1]; priceFound = true; break; }
}
}
// Currency hint
const cur = text.match(/\b(USD|INR|AOA|EUR)\b|\bKz\b|\bKZ\b|[$₹€]/i);
if (cur) out.currency = (cur[0] || '').toUpperCase().replace('KZ', 'AOA').replace('$', 'USD').replace('₹', 'INR').replace('€', 'EUR');
// Contacts
out.email = first(/([\w.+-]+@[\w-]+(?:\.[\w-]+)+)/);
const telLine = lines.find(l => /(tel|mobile|phone|cell|contact|whatsapp|telem[oó]vel|celular)/i.test(l) && /\d{6,}/.test(l));
if (telLine) out.mobile = (telLine.match(/\+?[\d][\d\s\-()]{6,}\d/) || [''])[0].replace(/\s+/g, ' ').trim();
// Bank account (IBAN or Angola NIB)
const bank = first(/\b(?:IBAN|NIB)\s*[:\-]?\s*([A-Z]{0,2}[\dA-Z][\dA-Z ]{14,34})/i);
if (bank) out.iban = bank.replace(/\s+/g, '');
// Payment terms
out.terms = first(/(?:payment\s*terms?|terms\s*of\s*payment|condi[cç][õo]es\s*de\s*pagamento|prazo\s*de\s*pagamento)\s*[:\-]?\s*([^\n]{3,80})/i);
// Supplier company — first line with a company word, skipping the buyer
const coLine = lines.find(l => /\b(lda|ltd|limited|pvt|private|llc|inc|sarl|s\.a\.|enterprises?|trading|traders|suppl(?:y|ies|iers)|industr\w*|company|co\.)\b/i.test(l) && !/megacorp|rr\s*towers/i.test(l));
out.sup = coLine ? coLine.replace(/^(supplier|from|vendor|fornecedor|seller|company)\s*[:\-]\s*/i, '').replace(/[|,]+.*$/, '').trim().slice(0, 80) : '';
return out;
}
const QDOC_FIELDS = [
['sup', 'Supplier Company'], ['piNo', 'PI / Quotation No'], ['date', 'PI Date'], ['price', 'Total Price'],
['terms', 'Payment Terms'], ['email', 'Supplier Email'], ['mobile', 'Supplier Mobile'], ['iban', 'Bank IBAN / NIB']
];
window.quoteDocRead = async function () {
const inp = document.getElementById('qdoc-file');
const status = document.getElementById('qdoc-status');
const rev = document.getElementById('qdoc-review');
if (!inp || !inp.files || !inp.files.length) return toast('Choose a quotation file first');
const file = inp.files[0];
const name = file.name.toLowerCase();
rev.innerHTML = '';
window._qdocFile = file;
try {
let text = '';
if (/\.(xlsx|xls|csv)$/.test(name)) {
status.textContent = 'Reading spreadsheet…';
const wb = XLSX.read(await file.arrayBuffer(), { type: 'array' });
text = wb.SheetNames.map(n => XLSX.utils.sheet_to_csv(wb.Sheets[n])).join('\n');
} else if (name.endsWith('.docx')) {
status.textContent = 'Reading Word document…';
await qdocLoadScript(QDOC_LIBS.mammoth);
text = (await window.mammoth.extractRawText({ arrayBuffer: await file.arrayBuffer() })).value;
} else if (name.endsWith('.pdf')) {
status.textContent = 'Reading PDF text…';
text = await qdocPdfText(file);
if (text.replace(/\s/g, '').length < 40) {
status.textContent = 'Scanned PDF — running OCR (this can take a minute)…';
text = await qdocPdfOcr(file);
}
} else {
status.textContent = 'Scanned image — running OCR (this can take a minute)…';
text = await qdocOcr(file);
}
if (!text.replace(/\s/g, '').length) throw new Error('no readable text found');
const f = qdocParseText(text);
const slot = document.getElementById('qdoc-slot')?.value || 'luanda';
const rows = QDOC_FIELDS.map(([k, lbl]) => {
const v = f[k] != null ? f[k] : '';
return `
${lbl}
${v ? '✓ found' : 'not found'}
`;
}).join('');
const curWarn = f.currency && f.currency !== CUR ? `⚠ Document appears to be in ${f.currency} , but SiteDesk uses ${CUR} . Check the price before confirming.
` : '';
rev.innerHTML = `
Review what was read from ${escAttr(file.name)}
Values are read automatically and may contain mistakes. Correct anything below. Nothing is filled into the quote until you confirm.
${curWarn}${rows}
Will fill into: ${({ luanda: 'Luanda', lobito: 'Lobito', india: 'India', other: 'Other Country' })[slot]} slot
Cancel
✅ Confirm & fill into slot
`;
status.textContent = 'Read complete — please check the values, then confirm.';
} catch (e) {
status.textContent = 'Could not read this file: ' + (e.message || e) + '. Please enter the quote manually.';
}
};
window.quoteDocConfirm = function () {
const slot = document.getElementById('qdoc-slot')?.value || 'luanda';
const g = k => (document.getElementById('qd-' + k)?.value || '').trim();
const set = (id, v) => { const el = document.getElementById(id); if (el && v) el.value = v; };
set('q-sup-' + slot, g('sup'));
set('q-pino-' + slot, g('piNo'));
set('q-pidate-' + slot, g('date'));
set('q-price-' + slot, g('price').replace(/[^\d.]/g, ''));
set('q-terms-' + slot, g('terms'));
set('q-email-' + slot, g('email'));
set('q-mobile-' + slot, g('mobile'));
set('q-iban-' + slot, g('iban'));
// Attach the same document to the chosen slot so it is saved with the quote
try {
if (window._qdocFile && document.getElementById('q-file-' + slot)) {
const dt = new DataTransfer();
dt.items.add(window._qdocFile);
document.getElementById('q-file-' + slot).files = dt.files;
}
} catch (e) { /* browser without DataTransfer: user can attach the file in the slot manually */ }
document.getElementById('qdoc-review').innerHTML = '';
document.getElementById('qdoc-status').textContent = 'Filled into the ' + slot + ' slot. Check it, then click Save Quotes & PI.';
toast('✅ Quote details filled — please check, then Save Quotes & PI');
};
function formQuotes(p) {
if (Array.isArray(p.quotes)) p.quotes = {};
p.quotes = p.quotes || {
luanda: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
lobito: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
india: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
other: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "", country: "" }
};
const SRC = [["luanda", "Luanda"], ["lobito", "Lobito"], ["india", "India"], ["other", "Other Country"]];
// Vendor auto-fill selector
const vendors = DB.vendors || [];
const vendorPickerSection = vendors.length ? `
🏪
Auto-fill from Vendor Directory
Select a vendor & slot, then click Auto-fill
Vendor
— select vendor —
${vendors.map(v => `${escAttr(v.name)} `).join('')}
Fill Into Slot
Luanda
Lobito
India
Other
⚡ Auto-fill
` : '';
window.vendorAutoFill = function () {
const vid = document.getElementById('vd-pick')?.value;
const slot = document.getElementById('vd-slot')?.value;
if (!vid || !slot) return toast('Select a vendor and slot first');
const v = (DB.vendors || []).find(x => x.id === vid);
if (!v) return;
const supEl = document.getElementById('q-sup-' + slot);
const emailEl = document.getElementById('q-email-' + slot);
const mobileEl = document.getElementById('q-mobile-' + slot);
if (supEl) supEl.value = v.name || '';
if (emailEl) emailEl.value = v.email || '';
if (mobileEl) mobileEl.value = v.whatsapp || v.contact || '';
// Show material rates as a reference
const prev = document.getElementById('vd-mat-preview');
if (prev && v.materials && v.materials.length) {
prev.innerHTML = '📋 Reference Rates: ' + v.materials.map(m => `${escAttr(m.name)}: ${money(m.rate)} (${m.rateDate || '—'}) `).join('');
}
toast('✅ Auto-filled from ' + v.name);
};
const block = (k, lbl) => {
const q = p.quotes[k] || {};
return `
${lbl} PI Data ` +
(k === "other" ? field("Country Name", "q-country", q.country || "") : '') +
`
` +
field("Supplier Company", "q-sup-" + k, q.sup || "") +
field("PI Number", "q-pino-" + k, q.piNo || "") +
`
`+
field("PI Date", "q-pidate-" + k, q.date || todayISO(), "date") +
field("Price (" + CUR + ")", "q-price-" + k, q.price || "", "number") +
`
`+
field("Bank IBAN", "q-iban-" + k, q.iban || "") +
field("Supplier Email", "q-email-" + k, q.email || "") +
`
`+
field("Supplier Mobile", "q-mobile-" + k, q.mobile || "") +
field("Payment Terms", "q-terms-" + k, q.terms || "") +
`
Attach Proforma Invoice (PDF/JPG)
${q.file ? ('Attached: ' + ((q.file.name || 'file').slice(0, 28))) : ''}
`;
};
const docSection = `
📎
Auto-fill from quotation / offer document
You confirm every value before it is filled
`;
openModal("Upload Supplier Quotes & PI — " + p.no,
docSection + vendorPickerSection + SRC.map(([k, l]) => block(k, l)).join(""),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save Quotes & PI", cls: "", fn: async () => {
for (const [k] of SRC) {
const inp = document.getElementById("q-file-" + k);
let file = (p.quotes[k] || {}).file || null;
if (inp && inp.files && inp.files.length) { const fs = await readFiles(inp); file = fs[0]; }
p.quotes[k] = {
sup: val("q-sup-" + k),
price: +val("q-price-" + k) || 0,
piNo: val("q-pino-" + k),
date: val("q-pidate-" + k),
iban: val("q-iban-" + k),
email: val("q-email-" + k),
mobile: val("q-mobile-" + k),
terms: val("q-terms-" + k),
country: k === "other" ? val("q-country") : undefined,
file
};
}
const qSrcs = ['luanda', 'lobito', 'india', 'other'].filter(s => p.quotes[s] && +p.quotes[s].price > 0);
p.status = qSrcs.length >= 2 ? "Quotes Compared" : "Quoted";
const btn = document.querySelector('#modalMount .pinned-foot .btn:not(.secondary)');
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
const r = await save();
if (r && r.failed) {
if (btn) { btn.disabled = false; btn.textContent = 'Save Quotes & PI'; }
return toast('⚠️ Quotes NOT saved to server — fix the error above and click Save again', false);
}
logAct(`${p.no} quotes/PI saved`);
toast("✓ Quotes & PIs saved — visible to owners now");
{ const qq = qSrcs.map(k => p.quotes[k]); const low = qq.reduce((a, b) => (+a.price <= +b.price ? a : b), qq[0] || {});
if (qq.length) sendSiteDeskWA('quotation_received', ownerPhones().concat([procInchargePhone()]),{ indent_no: p.no, supplier: qq.map(q => q.sup || '-').join(', '), quote_amount: 'Lowest ' + money(+low.price || 0) + ' (' + qq.length + ' offer' + (qq.length > 1 ? 's' : '') + ')', file_url: location.origin + '/sitedesk.html' }); }
closeModal();
render();
}
}]);
}
// PO Generator — full bilingual MEGACORP format
function printPO(p) {
const q = p.quotes && p.selected ? p.quotes[p.selected] : {};
const loc = p.selected ? ({ luanda: "Luanda", lobito: "Lobito", india: "India", other: "Other Country" }[p.selected] || p.selected) : "—";
const today = todayISO();
const gross = p.value || 0;
const irtPct = p.irtRate || 0;
const irtAmt = gross * irtPct / 100;
const netPay = gross - irtAmt;
const items = (p.items && p.items.length) ? p.items : [{ desc: p.desc, qty: p.qty, unit: p.unit, area: p.area, remarks: p.remarks }];
const totalQty = items.reduce((s, i) => s + (Number(i.qty) || 1), 0);
const itemRows = items.map((it, idx) => {
const itemQty = Number(it.qty) || 1;
const itemValue = it.value || (totalQty > 0 ? (gross * itemQty / totalQty) : 0);
const unitPrice = itemQty > 0 ? (itemValue / itemQty) : itemValue;
return `
${idx + 1}
${escH(it.desc)} ${it.area ? 'Area: ' + escH(it.area) : ''} ${it.remarks ? '· ' + escH(it.remarks) : ''}
${escH(p.dept || '—')}
${itemQty}
${escH(it.unit || 'Nos')}
${money(unitPrice)}
${money(itemValue)}
`;
}).join("");
const w = window.open("", "_blank", "width=900,height=700");
w.document.write(`
Ordem de Compra / Purchase Order — ${p.no}
MEGACORP INDUSTRIA (SU), LDA
Factory Address: ${escH(getFactoryAddress())} · NIF: ${escH(getCompanyNif())} Email: ${escH(getCompanyEmail())} · Web: mcti.co.in WhatsApp No: +244 953 456 785 · Calling No: +244 948 019 863
ORDEM DE COMPRA
Purchase Order
PO NUMBER
${p.no}
Data / Date: ${today}
Aprovado por / Approved by: ${p.approvedBy || '—'}
Aprovado em / Approved on: ${p.approvedAt || today}
Comprador / Buyer
MEGACORP INDUSTRIA (SU), LDA
Factory Address: ${escH(getFactoryAddress())}
NIF: ${escH(getCompanyNif())}
Email: ${escH(getCompanyEmail())}
WhatsApp No: +244 953 456 785
Calling No: +244 948 019 863
Fornecedor / Supplier
${p.supplier || '—'}
PI Nº: ${q.piNo || '—'} | Data PI: ${q.date || '—'}
País / Country: ${loc}
IBAN: ${q.iban || '—'}
Email: ${q.email || '—'} | Tel: ${q.mobile || '—'}
#
Descrição / Item Description
Departamento / Dept
Qtd / Qty
UOM
Preço Unitário / Unit Price
Total
${itemRows}
Total Bruto / Gross Total:
${money(gross)}
${irtPct > 0 ? `
IRT / Retenção na Fonte (${irtPct}%):
-${money(irtAmt)}
Valor Líquido / Net Payable:
${money(netPay)}
`: ''}
Condições de Entrega e Pagamento / Delivery & Payment Terms
• Local de Entrega / Delivery: Luanda / Lobito Port, Angola
• Prazo de Pagamento / Payment Terms: ${q.terms || 'As per Proforma Invoice'}
• Levantado por / Raised by: ${p.by || '—'} em ${(p.at || "").slice(0, 10) || today}
${p.purpose ? `
• Finalidade / Purpose: ${escH(p.purpose)}
` : ''}
${p.compNote ? `
• Nota / Note: ${p.compNote}
` : ''}
Este documento é válido somente com assinatura e carimbo autorizado. /
This document is valid only with authorized signature and stamp.
Karthik
Authorized Signatory / Managing Director
🖶 Print / Save PDF
setTimeout(()=>window.print(),400);
`);
w.document.close();
}
// ── Print Quote Comparison Sheet ───────────────────────────────────
function printQuotes(p) {
const SRC = { luanda: 'Luanda', lobito: 'Lobito', india: 'India', other: 'Other' };
const today = todayISO();
const quotes = p.quotes || {};
const keys = ['luanda', 'lobito', 'india', 'other'].filter(k => quotes[k] && quotes[k].price);
if (!keys.length) return toast('No quotes found for this indent', false);
const lowest = keys.reduce((a, b) => (+quotes[a].price || Infinity) < (+quotes[b].price || Infinity) ? a : b);
const winner = p.selected || lowest;
const colHeaders = keys.map(k => `${SRC[k]}${k === winner ? ' ✓ SELECTED' : ''} `).join('');
const rows = [
{ label: 'Supplier Name', fn: k => quotes[k].sup || '—' },
{ label: 'Price / Quote Value', fn: k => `${money(quotes[k].price || 0)} ` },
{ label: 'PI No.', fn: k => quotes[k].piNo || '—' },
{ label: 'PI Date', fn: k => quotes[k].date || '—' },
{ label: 'Payment Terms', fn: k => quotes[k].terms || '—' },
{ label: 'IBAN', fn: k => `${quotes[k].iban || '—'}` },
{ label: 'Email', fn: k => quotes[k].email || '—' },
{ label: 'Mobile', fn: k => quotes[k].mobile || '—' },
{ label: 'Notes', fn: k => quotes[k].notes || '—' },
{ label: 'Document', fn: k => (quotes[k].file && quotes[k].file.data) ? '📄 Attached' : '—' },
].map(r => `${r.label} ${keys.map(k => `${r.fn(k)} `).join('')} `).join('');
const w = window.open('', '_blank', 'width=960,height=700');
w.document.write(`
Quote Comparison — ${p.no}
MEGACORP INDUSTRIA (SU), LDA
Factory Address: ${escH(getFactoryAddress())} · NIF: ${escH(getCompanyNif())} · Email: ${escH(getCompanyEmail())}
QUOTE COMPARISON SHEET
Mapa Comparativo de Cotações
${p.no}
Date: ${today}
Item / Description ${p.desc}
Department ${p.dept || '—'}
Quantity Required ${p.qty} ${p.unit}
Raised By ${p.by || '—'}
Status ${p.status}
Approved By ${p.approvedBy || 'Pending'}
Parameter
${colHeaders}
${rows}
${winner && quotes[winner] ? `
✅ Selected Supplier: ${quotes[winner].sup || SRC[winner]} · Value: ${money(quotes[winner].price || 0)}
${p.compNote ? ` · Note: ${p.compNote} ` : ''}
`: ''}
Prepared by ____________________________Procurement Officer
Reviewed by ____________________________Project Manager
Approved by ____________________________${p.approvedBy || 'Managing Director'}
🖶 Print / Save PDF
setTimeout(()=>window.print(),400);
`);
w.document.close();
}
function formProcessPayment(p) {
const q = p.quotes && p.selected ? p.quotes[p.selected] : {};
const suppIBAN = q.iban || '';
openModal("Process Payment for PO: " + p.no,
`Supplier: ${p.supplier} · Gross: ${money(p.value)}
` +
sel("Tax Deduction / Retention (IRT)", "f-irt", [
{ v: "0", l: "None (0% Retention)" },
{ v: "6.5", l: "6.5% Retention (Retencao na Fonte)" }
]) +
`Net Payable: ${money(p.value)}
` +
sel("Payment Type", "f-paytype", [
{ v: "BAI Bank", l: "BAI Bank (Banco Angolano de Investimentos)" },
{ v: "Banco Keve", l: "Banco Keve" },
{ v: "Cash", l: "Cash Payment" }
]) +
`` +
field("Payment Reference / Proof No", "f-payref") +
field("Remarks", "f-remarks") +
`
` +
`
Bank Details
OUR BANK (Megacorp)
Bank: BAI Bank
NIF: ${escH(getCompanyNif())}
IBAN: 0040 0000 8472 1653 1019 1
SUPPLIER BANK
Supplier: ${p.supplier}
IBAN: ${suppIBAN || '— (not set in quote)'}
PI No: ${q.piNo || '—'} · PI Date: ${q.date || '—'}
`+
`
⚠ Upload Payment Proof — REQUIRED (PDF/Image, multiple allowed)
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Log Paid", cls: "", fn: async () => {
const refVal = val("f-payref");
const payType = val("f-paytype");
const remarksVal = val("f-remarks");
const ourIban = payType === "Banco Keve" ? "0047 0000 2755 3350 1015 4" : payType === "BAI Bank" ? "0040 0000 8472 1653 1019 1" : "N/A (Cash)";
const inp = document.getElementById("f-proof");
const files = inp.files.length ? await readFiles(inp) : [];
if (!files.length) return toast("Upload payment proof file first");
// Net calculations
const irtRate = +val("f-irt");
const irtVal = p.value * (irtRate / 100);
const netPay = p.value - irtVal;
p.paymentType = payType;
p.paymentRemarks = remarksVal;
p.ourIban = ourIban;
// Add to accounts invoices / ledger
DB.accounts.unshift({
id: uid("ac"),
date: todayISO(),
inv: p.no,
nif: q.iban || "—",
party: p.supplier,
head: `PO Payment: ${p.desc} (${payType})`,
amount: netPay,
vat: irtVal,
ret: irtVal,
mode: payType,
comments: `Ref: ${refVal}. Remarks: ${remarksVal}. Company Bank (${payType}) IBAN: ${ourIban}`,
type: "Payment",
status: "Paid",
files
});
p.status = "Payment Done";
p.paymentDone = true;
p.paymentProof = files;
p.paymentProofBy = USER.displayName || USER.username;
p.paymentProofAt = todayISO();
p.paymentRef = refVal;
p.paymentDate = todayISO();
save();
logAct(`Payment processed for PO ${p.no} via ${payType} - Net: ${money(netPay)}`);
sendSiteDeskWA('payment_proof_received', ownerPhones().concat([userPhoneByName(p.by), procInchargePhone(), deptInchargePhone('accounts')]), { indent_no: p.no + ' — ' + p.supplier, proof_date: todayISO(), amount_paid: money(netPay) + ' via ' + payType + (refVal ? ' ref ' + refVal : '') }, { label: 'Payment proof', toastResult: true });
toast("Payment recorded & logged successfully");
closeModal();
render();
}
}]);
const updatePayBankInfo = () => {
const pType = val("f-paytype");
const nameEl = document.getElementById("our-bank-name");
const ibanEl = document.getElementById("our-bank-iban");
if (!nameEl || !ibanEl) return;
if (pType === "Banco Keve") {
nameEl.textContent = "Banco Keve";
ibanEl.textContent = "0047 0000 2755 3350 1015 4";
} else if (pType === "BAI Bank") {
nameEl.textContent = "BAI Bank";
ibanEl.textContent = "0040 0000 8472 1653 1019 1";
} else {
nameEl.textContent = "Cash Payment";
ibanEl.textContent = "N/A (Cash)";
}
};
setTimeout(() => {
const selEl = document.getElementById("f-paytype");
if (selEl) {
selEl.onchange = updatePayBankInfo;
updatePayBankInfo();
}
}, 50);
const irtSelect = document.getElementById("f-irt");
irtSelect.onchange = () => {
const rate = +irtSelect.value;
const net = p.value - (p.value * (rate / 100));
document.getElementById("pay-calc").textContent = `Net Payable: ${money(net)}` + (rate > 0 ? ` (IRT deducted: ${money(p.value * (rate / 100))})` : '');
};
// File upload indicator
setTimeout(() => {
const inp = document.getElementById("f-proof");
if (inp) inp.onchange = () => {
const ind = document.getElementById("proof-file-ind");
if (inp.files.length) { ind.style.display = "block"; ind.textContent = "✅ " + inp.files[0].name + " (" + Math.round(inp.files[0].size / 1024) + " KB) — ready to upload"; }
};
}, 50);
}
// Goods receipt confirmation in Site Stores
function formGoodsReceipt(p) {
const items = (p.items && p.items.length) ? p.items : [{ desc: p.desc, qty: p.qty, unit: p.unit, area: p.area, remarks: p.remarks }];
const existingPhotos = p.receiptPhotos || [];
const existingThumb = existingPhotos.length
? `${existingPhotos.length} photo(s) already attached
` + attThumbs(existingPhotos)
: '';
const itemSelectorsHtml = items.map((it, idx) => {
const matched = DB.items.find(x => x.name.toLowerCase().trim() === (it.desc || '').toLowerCase().trim());
return `
Item #${idx + 1}: ${escH(it.desc)} (${it.qty} ${it.unit})
Match Stores Stock Code
-- Register as NEW Item --
${DB.items.map(sIt => `${sIt.code || ''} ${escH(sIt.name)} `).join("")}
`;
}).join("");
openModal("Confirm Goods Receipt — " + p.no,
`Supplier: ${p.supplier} · Total Items: ${items.length}
` +
itemSelectorsHtml +
`` + field("Supplier Invoice No (tax invoice)", "f-recv-invno", p.supplierInvNo || "") + field("Invoice Date", "f-recv-invdate", p.supplierInvDate || "", "date") + `
` +
`Shipment Photos (optional)
📷 Upload Shipment Photos (JPG/PDF)
${existingThumb}
`,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "Approve to Stores Stock", cls: "", fn: async () => {
const photoInp = document.getElementById("f-recv-photos");
const newPhotos = photoInp && photoInp.files.length ? await readFiles(photoInp) : [];
p.receiptPhotos = [...(p.receiptPhotos || []), ...newPhotos];
p.supplierInvNo = (val("f-recv-invno") || "").trim();
p.supplierInvDate = val("f-recv-invdate") || "";
p.receivedDate = p.receivedDate || todayISO();
let totalRecvQty = 0;
items.forEach((it, idx) => {
const qtyVal = +val(`f-recv-qty-${idx}`) || it.qty;
totalRecvQty += qtyVal;
const selectIt = val(`f-stores-item-${idx}`);
const shelfLoc = val(`f-loc-${idx}`);
if (selectIt === 'new') {
const newId = uid("i");
const codeNo = "MAT-" + Math.floor(120 + Math.random() * 800);
DB.items.push({
id: newId,
code: codeNo,
name: it.desc,
cat: "Material",
unit: it.unit || "Nos",
qty: qtyVal,
reorder: 5,
returnable: false,
loc: shelfLoc,
rate: (p.value && items.length) ? (p.value / items.length) / qtyVal : 0
});
} else {
const itObj = DB.items.find(x => x.id === selectIt);
if (itObj) {
itObj.qty += qtyVal;
if (shelfLoc) itObj.loc = shelfLoc;
}
}
});
p.status = "Received";
save();
logAct(`${p.no} received — Added ${items.length} item(s) (${totalRecvQty} total qty) to Site Stores`);
toast("Receipt processed. Site Stores stock updated ✓");
closeModal();
render();
}
}
]
);
setTimeout(() => {
const inp = document.getElementById("f-recv-photos");
if (inp) inp.onchange = () => {
const ind = document.getElementById("recv-photo-ind");
if (inp.files.length) { ind.style.display = "block"; ind.textContent = "✅ " + inp.files.length + " photo(s) selected"; }
};
}, 50);
}
// 3. HR Module contracts & offers uploads dialog
function formHRContracts(emp) {
const idTypeStr = emp.idDocType || (emp.staffType === 'Expatriate' ? 'Passport (Expat)' : 'Bilhete de Identidade (Angolan ID)');
openModal("Offer Letter, Contracts & Identification: " + emp.name,
`Staff Category: ${emp.staffType || 'Permanent Employee'} | ID Status: ${empIDValidityPill(emp)}
` +
``+
``+
`3) Employee Identification (ID Card / Passport Scans)
Type: ${escH(idTypeStr)} ${emp.idDocNo ? ' | No: ' + escH(emp.idDocNo) + ' ' : ''}
${emp.idDocFiles && emp.idDocFiles.length ? `
${attThumbs(emp.idDocFiles)}
` : '
No ID scan uploaded
'}
⚙ Edit / Upload Identification & Validity Details
`,
[{ label: "Close", cls: "", fn: closeModal }]);
const off = document.getElementById("f-offer");
off.onchange = async () => {
const fs = await readFiles(off);
if (fs[0]) {
emp.offerLetter = fs[0];
save();
document.getElementById("offerStatus").textContent = 'Attached: ' + fs[0].name;
toast("Offer letter saved");
render();
}
};
const ac = document.getElementById("f-accept");
ac.onchange = async () => {
const fs = await readFiles(ac);
if (fs[0]) {
emp.acceptedContract = fs[0];
emp.salaryApproved = true; // Activate payroll
save();
document.getElementById("acceptStatus").textContent = 'Attached: ' + fs[0].name;
toast("Signed contract uploaded. Payroll started!");
render();
}
};
}
// Printable Appointment Letter Generator
function printAppointmentLetter(emp) {
const w = window.open();
w.document.write(`
Appointment Letter — ${emp.name}
Dear ${emp.name} ,
We are pleased to appoint you to the position of ${emp.role} in the ${emp.dept} department under the following terms and conditions:
1. Cargo e Funções (Position & Duties)
Your appointment starts on ${emp.joined} . You will be reporting to the Department Head / Site Manager.
2. Remuneração (Salary & Compensation)
Your base monthly salary is set at ${emoney(emp, emp.salary)} , payable in ${({ Kz: 'Angolan Kwanza (AOA/Kz)', INR: 'Indian Rupees (INR)', USD: 'US Dollars (USD)' })[empCur(emp)] || empCur(emp)} on the 30th of each calendar month.
3. Regime de Trabalho (Staff Type)
Your hiring type is classified as ${emp.staffType || 'Permanent Employee'} . All tax and social security contributions will be made in accordance with the Angolan Labor Code.
Please review and sign the duplicate copy of this appointment letter as a token of your acceptance.
Authorized SignatoryMEGACORP INDUSTRIA ____________________________
Worker's Acceptance${emp.name} ____________________________
window.print();
`);
}
// 4. Bilingual Salary Payment Receipt — Full 13-line company format
function printSalaryReceipt(emp) {
if (!can.seeSalaryForEmp(USER, emp)) return toast("Access Denied — Expat Salary Restricted");
// Show config modal first: period, allowances, advance
const curMonth = todayISO().slice(0, 7);
const money = v => emoney(emp, v); // receipt in the employee's own currency (Kz / INR / USD)
openModal("Print Salary Receipt — " + emp.name,
`Review and adjust salary components before printing. All amounts in ${empCur(emp)} .
` +
field("Pay Period (YYYY-MM)", "sr-period", curMonth) +
`` +
field("Base Salary", "sr-base", emp.salary || 0, "number") +
field("Food Allowance", "sr-food", Math.round((emp.salary || 0) * 0.15), "number") +
`
` +
field("Transport Allowance", "sr-transport", Math.round((emp.salary || 0) * 0.05), "number") +
field("Housing Allowance (Expats)", "sr-housing", emp.staffType === "Expatriate" ? Math.round((emp.salary || 0) * 0.2) : 0, "number") +
`
` +
field("Productivity Bonus", "sr-bonus", 0, "number") +
field("Overtime Pay", "sr-ot", 0, "number") +
`
` +
field("Advance / Loan Deduction", "sr-advance", 0, "number") +
field("IRT Rate (%)", "sr-irt", "6.5", "number") +
`
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "🖨 Print Receipt", cls: "", fn: () => {
const base = +val("sr-base") || 0;
const food = +val("sr-food") || 0;
const transport = +val("sr-transport") || 0;
const housing = +val("sr-housing") || 0;
const bonus = +val("sr-bonus") || 0;
const otPay = +val("sr-ot") || 0;
const advance = +val("sr-advance") || 0;
const irtRate = +val("sr-irt") || 6.5;
const period = val("sr-period") || curMonth;
const [yr, mo] = period.split("-");
const moLabel = (MONTHS[(+mo || 1) - 1] || mo) + " " + yr;
const gross = base + food + transport + housing + bonus + otPay;
const inss = Math.round(base * 0.03);
const irt = Math.round(gross * (irtRate / 100));
const dnList = DB.debitNotes.filter(d => (d.person === emp.name || d.personRef === emp.id) && (d.status === "Pending" || d.status === "Approved"));
const debits = dnList.reduce((acc, x) => acc + (x.value || 0), 0);
const totDeduct = inss + irt + debits + advance;
const net = gross - totDeduct;
closeModal();
const w = window.open("", "_blank", "width=800,height=700");
w.document.write(`
Recibo de Salário — ${emp.name} — ${moLabel}
MEGACORP INDUSTRIA (SU), LDA
Factory Address: ${escH(getFactoryAddress())} · NIF: ${escH(getCompanyNif())} Email: ${escH(getCompanyEmail())}
RECIBO DE SALÁRIO
Salary Payment Receipt
${moLabel}
Emitido em / Issued: ${todayISO()}
Nome / Name${emp.name}
Função / Role${emp.role || '—'}
Departamento${emp.dept || '—'}
Tipo / Category${emp.staffType || '—'}
Data Entrada / Joined${emp.joined || '—'}
Período / Period${moLabel}
Descrição / Line Item
Vencimentos / Earnings
Descontos / Deductions
1. Salário Base / Base Salary ${money(base)} —
2. Subsídio de Alimentação / Food Allowance ${money(food)} —
3. Subsídio de Transporte / Transport Allowance ${money(transport)} —
4. Subsídio de Habitação / Housing Allowance ${money(housing)} —
5. Subsídio de Produtividade / Productivity Bonus ${money(bonus)} —
6. Horas Extras / Overtime Pay ${money(otPay)} —
7. Total Vencimentos / Gross Earnings ${money(gross)} —
8. Seguro Social / INSS (3% worker) — ${money(inss)}
9. Imposto Rendimento / IRT (${irtRate}%) — ${money(irt)}
10. Deduções / Debit Notes & Lost Tools — ${money(debits)}
11. Adiantamento / Advance Deduction — ${money(advance)}
12. Total Descontos / Total Deductions — ${money(totDeduct)}
13. Valor Líquido a Receber / Net Payable ${money(net)}
Declaração do Trabalhador / Worker's Declaration:
Declaro ter recebido a quantia líquida acima mencionada correspondente ao pagamento do meu salário do mês de ${moLabel}.
I declare that I have received the net amount above as full compensation for my monthly salary for the period of ${moLabel}.
Assinatura da Empresa / Company Stamp ____________________________MEGACORP INDUSTRIA (SU), LDA
Assinatura do Trabalhador ____________________________${emp.name}
🖨 Print / Save PDF
setTimeout(()=>window.print(),400);
`);
w.document.close();
}
}]);
}
// 5. Help / Training Guide Modal dialog
function showHelpModal() {
const isPT = LANG === "pt";
const guides = isPT ? [
{
role: "Proprietário / Admin", icon: "👑", color: "#f97316",
desc: "Acesso total a todos os módulos. Tarefas principais: gerir logins e permissões de utilizadores (Utilizadores & Acesso), rever e aprovar folhas de pagamento em RH → Folha Mensal, repor dados de módulos, consultar o registo de auditoria em Histórico de Alterações. Pode também exportar dados para CSV a partir de qualquer módulo.",
tips: ["Conceda acesso por módulo a cada login da equipa", "Use 'Repor Aprovações' na folha de pagamento no início de cada mês", "Consulte o Histórico de Alterações para detetar edições suspeitas"]
},
{
role: "Gestor de Projeto", icon: "📋", color: "#2563eb",
desc: "Registe o progresso de montagem e civil no módulo Montagem & Civil. Atualize as percentagens de atividade e carregue fotografias da obra. Crie requisições de compra de materiais — estas seguem para a equipa de Compras para cotação e emissão de OC.",
tips: ["Carregue fotografias de progresso diretamente da obra no telemóvel", "Crie requisições com antecedência — as compras demoram tempo", "Consulte o Painel para ver o número de requisições abertas vs. meta"]
},
{
role: "Compras / Procurement", icon: "🛒", color: "#16a34a",
desc: "Abra o módulo Compras. Para cada requisição: carregue cotações de fornecedores de Luanda, Lobito, Índia e Outros com + Cotações. Compare preços e emita uma OC. Quando a mercadoria chegar, use Receção de Mercadoria em Armazém da Obra para atualizar o stock.",
tips: ["Carregue o IBAN do fornecedor para rastreabilidade do pagamento", "Após carregar 2+ cotações, o estado passa a 'Cotações Comparadas'", "Imprima a OC diretamente a partir do menu de ações"]
},
{
role: "Equipa de Contas", icon: "💰", color: "#7c3aed",
desc: "Use o módulo Contas para registar todas as faturas e pagamentos. Cada lançamento requer a digitalização da fatura em anexo. A retenção IRT é calculada automaticamente ao processar pagamentos de Compras. Exporte o registo completo para CSV para auditoria.",
tips: ["Importe faturas históricas em massa com o modelo CSV de importação", "Edite qualquer lançamento clicando no ícone ✏", "Use o filtro por Tipo (Local / Importação) para o reporte de IVA"]
},
{
role: "Armazém & Materiais da Obra", icon: "🏪", color: "#0891b2",
desc: "Acompanhe o inventário no módulo Armazém da Obra. Adicione novos artigos, registe entradas/saídas de stock e entregue ferramentas a trabalhadores específicos. Quando chegarem mercadorias de uma OC, use 'Receção de Mercadoria' em Compras para adicionar ao stock automaticamente. Emita notas de débito por artigos perdidos ou danificados.",
tips: ["Defina níveis de reposição para receber alertas de stock baixo no painel", "Os artigos retornáveis aparecem na lista de Ferramentas em Aberto", "Imprima notas de débito diretamente no módulo Descontos Salariais"]
},
{
role: "RH & Ponto", icon: "👥", color: "#db2777",
desc: "Gira o pessoal no módulo RH & Ponto. Adicione colaboradores com o tipo de contrato, carregue cartas de oferta e contratos assinados para ativar o processamento salarial. Registe a presença diária com Entrada/Saída. Exporte a presença mensal para CSV.",
tips: ["O carregamento de contrato assinado ativa automaticamente a linha de processamento", "Use a importação em massa de pessoal para integrar muitos trabalhadores", "Os alertas de expiração de documentos (Visto, Autorização de Trabalho) aparecem na lista de RH e no Painel"]
}
] : [
{
role: "Owner / Admin", icon: "👑", color: "#f97316",
desc: "Full access to all modules. Key tasks: manage user logins & permissions (Users & Access), review and approve payroll in HR → Monthly Payroll, reset module data, view the full audit log in Change History. You can also export data to CSV from any module.",
tips: ["Grant module-level access to each team login", "Use 'Reset Approvals' in payroll at the start of each month", "Review the Change History panel for any suspicious edits"]
},
{
role: "Project Manager", icon: "📋", color: "#2563eb",
desc: "Log erection & civil progress in the Erection & Civil module. Update activity percentages and upload site photos. Raise purchase indents for materials — these flow to the Procurement team for quoting and PO issuance.",
tips: ["Upload progress photos directly from site on mobile", "Raise indents early — procurement takes time", "Check the Dashboard for open indent count vs target"]
},
{
role: "Procurement / Buyer", icon: "🛒", color: "#16a34a",
desc: "Open the Procurement module. For each indent: upload quotes from Luanda, Lobito, India, and Other suppliers using + Quotes. Compare prices and issue a PO. Once goods arrive, use Goods Receipt in Site Stores to update stock.",
tips: ["Upload supplier IBAN for payment traceability", "After uploading 2+ quotes the status becomes 'Quotes Compared'", "Print PO sheet directly from the action menu"]
},
{
role: "Accounts Team", icon: "💰", color: "#7c3aed",
desc: "Use the Accounts module to log all invoices and payments. Every entry requires an invoice scan attachment. The IRT retention is calculated automatically when processing payments from Procurement. Export the full ledger to CSV for audit.",
tips: ["Bulk import historic invoices using the CSV import template", "Edit any invoice entry by clicking the ✏ icon", "Use the filter by Type (Local / Import) for VAT reporting"]
},
{
role: "Stores & Site Materials", icon: "🏪", color: "#0891b2",
desc: "Track inventory in the Site Stores module. Add new items, log stock in/out, and issue tools to specific workers. When goods arrive from a PO, use 'Goods Receipt' in Procurement to automatically add to stock. Raise debit notes for lost or damaged items.",
tips: ["Set reorder levels to get low-stock alerts on the dashboard", "Returnable items appear in the Outstanding Tools list", "Print debit notes directly from the Salary Deductions module"]
},
{
role: "HR & Time Office", icon: "👥", color: "#db2777",
desc: "Manage staff in the HR & Time Office module. Add employees with their contract type, upload offer letters and signed contracts to activate payroll. Log daily attendance using Time In / Out. Export monthly attendance to CSV.",
tips: ["Signed contract upload auto-activates the payroll row", "Use the Bulk Add Staff import for onboarding many workers", "Document expiry alerts (Visa, Work Permit) show in the HR list and Dashboard"]
}
];
const title = isPT ? "Centro de Ajuda & Formação" : "Help & Training Center";
const subtitle = isPT
? "Guias rápidos para cada função. Tutoriais em vídeo em breve — contacte o admin para sessões de formação."
: "Quick guides for each role. Video tutorials coming soon — contact admin for training sessions.";
const tipsLabel = isPT ? "💡 Dicas" : "💡 Tips";
const guideLabel = isPT ? "Guia Rápido" : "Quick Guide";
const closeLabel = isPT ? "Fechar" : "Close";
const rows = guides.map(g => `
${g.icon}
${g.role} — ${guideLabel}
${g.desc}
${tipsLabel}
${g.tips.map(t => `${t} `).join("")}
`).join("");
openModal(title,
`${subtitle}
${rows}
`,
[{ label: closeLabel, cls: "", fn: closeModal }]);
}
// Bulk Indent — wider modal, editable/deletable rows, pinned buttons
function formBulkIndent() {
let parsed = [];
// Use wider modal via inline style override
openModal("Raise Indent — Bulk (CSV)",
`1) Download the template 2) Fill items 3) Upload CSV 4) Review each row 5) Click Create Indents
` +
`⬇ Download CSV Template ` +
sel("Department (for this batch)", "f-dept", DEPTS) +
`Choose filled CSV
` +
`
` +
`
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Create Indents", cls: "", fn: () => {
if (!parsed.length) return toast("Upload a filled CSV first");
const dept = val("f-dept");
let n = 0;
parsed.forEach(r => {
if (!r._del) {
DB.proc.unshift({
id: uid("ic"), no: nextIndentNo(),
dept, desc: r.desc, qty: +r.qty || 1, unit: r.unit || "Nos",
area: r.area || "", remarks: r.remarks || "",
by: USER.displayName || USER.username, at: new Date().toISOString(),
supplier: "—", value: 0, status: "Indent Raised", files: [],
quotes: {
luanda: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
lobito: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
india: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "" },
other: { sup: "", price: 0, file: null, piNo: "", date: "", terms: "", iban: "", email: "", mobile: "", country: "" }
},
selected: null
});
n++;
}
});
save(); logAct(n + " indents raised (bulk CSV)");
toast(n + " indents created"); closeModal(); render();
}
}]);
// Widen modal for table preview
const modal = document.querySelector("#modalMount .modal");
if (modal) modal.style.maxWidth = "720px";
document.getElementById("csv-tmpl-btn").onclick = downloadCSVTemplate;
function renderRows() {
const count = document.getElementById("bulk-count");
const prev = document.getElementById("bulkPrev");
const active = parsed.filter(r => !r._del);
if (!parsed.length) { prev.style.display = "none"; count.style.display = "none"; return; }
count.style.display = "block";
count.innerHTML = active.length + " item" + (active.length !== 1 ? "s" : "") + " ready to create as indents" + (parsed.length !== active.length ? " (" + (parsed.length - active.length) + " removed)" : "");
prev.style.display = "block";
prev.innerHTML = '' +
'# Item / Description Qty Unit Area Remarks Del ' +
' ' +
parsed.map((r, i) =>
'' + '' + (i + 1) + ' ' +
'' + r.desc + ' ' +
'' + r.qty + ' ' +
'' + r.unit + ' ' +
'' + r.area + ' ' +
'' + r.remarks + ' ' +
'' + (r._del ? '↻' : '×') + ' ' +
' '
).join("") +
'
';
prev.querySelectorAll("[data-ridx]").forEach(btn => {
btn.onclick = () => { parsed[+btn.dataset.ridx]._del = !parsed[+btn.dataset.ridx]._del; renderRows(); };
});
}
document.getElementById("fx").onchange = function () {
const f = this.files[0]; if (!f) return;
const r = new FileReader();
r.onload = () => {
const rows = parseCSV(r.result).filter(row => row.some(c => (c || '').trim()));
if (!rows.length) { document.getElementById("bulkPrev").innerHTML = "Empty file."; parsed = []; return; }
let start = 0;
const h0 = (rows[0][0] || '').toLowerCase();
if (h0.includes('item') || h0.includes('descri')) start = 1;
parsed = rows.slice(start).map(c => ({
desc: (c[0] || '').trim(), qty: (c[1] || '1').trim(),
unit: (c[2] || 'Nos').trim(), area: (c[3] || '').trim(),
remarks: (c[4] || '').trim(), _del: false
})).filter(x => x.desc);
renderRows();
};
r.readAsText(f);
};
}
// ── Missing SiteDesk Core Dialog Forms ───────────────────────────────────────────
function formIndentEdit(p) {
const existingFilesHtml = (p.files && p.files.length)
? `Existing attachments (${p.files.length}): ${attThumbs(p.files)}
`
: '';
window._indentItemsTemp = (p.items && p.items.length)
? JSON.parse(JSON.stringify(p.items))
: [{ id: uid("item"), desc: p.desc || "", qty: p.qty || 1, unit: p.unit || "Nos", area: p.area || "", remarks: p.remarks || "" }];
openModal("Edit Indent — " + p.no,
existingFilesHtml +
sel("Department", "f-dept", DEPTS, p.dept) +
sel("Delivery Location", "f-dloc", ["Luanda", "Lobito", "India", "Other Country"], p.deliveryLoc || "Luanda") +
field("Purpose / Notes", "f-purpose", escAttr(p.purpose || p.remarks || "")) +
`` +
`
Indent Items
` +
`
+ Add Item ` +
`
` +
`
` +
`Add / Replace Attachments (optional)
`,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{ label: "Save Draft", cls: "ghost", fn: () => window.saveIndentAction(true, p) },
{
label: "Save & Submit", cls: "", fn: async () => {
const finp = document.getElementById("fedit-file");
if (finp && finp.files.length) { const newFs = await readFiles(finp); p.files = (p.files || []).concat(newFs); }
window.saveIndentAction(false, p);
}
}
]
);
setTimeout(() => window.renderIndentItemRows(), 50);
}
function formDoc() {
openModal("New Record — My Company Documents",
sel("Type", "f-kind", ["Import", "Licence"]) +
field("Title", "f-title") + field("Reference No", "f-ref") +
`` + field("Party / Origin", "f-party") + sel("Department", "f-docDept", DEPTS) + `
` +
`` +
`
` + field("ETA / Delivery Date", "f-date", todayISO(), "date") + sel("Status", "f-status", ["Under Process", "Approved", "In Transit", "Customs Clearance", "Cleared", "Delivered"]) + `
` +
`
` +
`` +
`
` + field("Issued Date", "f-issued", todayISO(), "date") + field("Expiry Date", "f-expiry", "", "date") + `
` +
`
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save", cls: "", fn: () => {
const t = val("f-title"); if (!t) return alertField();
const kind = val("f-kind");
const docObj = {
id: uid("d"), title: t, kind, ref: val("f-ref"), party: val("f-party"), dept: val("f-docDept"),
status: kind === "Licence" ? "Valid" : val("f-status"),
date: kind === "Licence" ? val("f-expiry") : val("f-date"),
issuedDate: val("f-issued"), expiry: val("f-expiry"),
files: []
};
DB.docs.unshift(docObj);
save();
if (kind === "Licence") {
sendWhatsAppNotification('legal_expiry', {
recipient: `${docObj.dept || 'Legal'} Dept`,
phone: '+244 923 000 009',
message: `📜 [LICENCE & TITLE DEED LOGGED]\nDocument: ${t} (${docObj.ref || 'No Ref'})\nDepartment: ${docObj.dept || 'Legal'}\nExpiry Date: ${docObj.expiry || 'Permanent'}`
});
} else {
sendWhatsAppNotification('imports_update', {
recipient: `${docObj.dept || 'Logistics'} & Owners`,
phone: '+244 923 000 004',
message: `🚢 [IMPORT SHIPMENT UPDATE]\nShipment: ${t} (${docObj.ref || 'No Ref'})\nParty/Origin: ${docObj.party || 'Logistics'}\nStatus: ${docObj.status}\nETA Date: ${docObj.date}`
});
}
logAct(`Record added: ${t}`); toast("Saved — upload documents with + Docs"); closeModal(); render();
}
}]);
// Toggle fields based on type
const kSel = document.getElementById("f-kind");
if (kSel) kSel.onchange = () => {
const isLic = kSel.value === "Licence";
document.getElementById("importFields").style.display = isLic ? "none" : "block";
document.getElementById("licFields").style.display = isLic ? "block" : "none";
};
}
function docStatus(d) {
const isLic = d.kind === "Licence";
openModal("Update — " + d.title,
isLic
? `` + field("Issued Date", "f-issued", d.issuedDate || d.date, "date") + field("Expiry Date", "f-expiry", d.expiry || d.date, "date") + `
` + field("Department", "f-docDept", d.dept || "")
: sel("Status", "f-status", ["Under Process", "Approved", "In Transit", "Customs Clearance", "Cleared", "Delivered"], d.status) + field("ETA / Expiry Date", "f-date", d.date, "date"),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save", cls: "", fn: () => {
if (isLic) {
d.issuedDate = val("f-issued"); d.expiry = val("f-expiry"); d.date = val("f-expiry"); d.dept = val("f-docDept");
} else {
d.status = val("f-status"); d.date = val("f-date");
}
save(); logAct(`${d.title} → updated`); toast("Updated"); closeModal(); render();
}
}]);
}
function formItem(it) {
const ed = !!it;
const isTool = ed && (it.cat === 'Tools & Machinery');
openModal(ed ? "Edit Item" : "Add Item",
field("Code", "f-code", ed ? escAttr(it.code || '') : '') + field("Name", "f-name", ed ? escAttr(it.name) : '') +
`` + sel("Category", "f-cat", ["Material", "Tools & Machinery", "Civil", "Mechanical", "Electrical", "Consumable"], ed ? it.cat : "Material") + field("Unit", "f-unit", ed ? escAttr(it.unit || 'Nos') : "Nos") + `
` +
`` + field(ed ? "Quantity" : "Opening Qty", "f-qty", ed ? it.qty : "0", "number") + field("Reorder", "f-re", ed ? (it.reorder || 0) : "0", "number") + `
` +
` Returnable (tool / equipment)
` +
` Repairable (can be serviced / repaired)
` +
`
🔧 Repair & Parts Information
`+
field("Repair Location / Service Center", "f-repair-loc", ed && it.repairLoc ? escAttr(it.repairLoc) : '') +
field("Replacement Parts Source / Supplier", "f-parts-source", ed && it.partsSource ? escAttr(it.partsSource) : '') +
`
Part Details / Replaceable Parts
`+
`` + field("Location / Where fixed", "f-loc", ed ? escAttr(it.loc || '') : '') + field("Replacement Value (" + CUR + ")", "f-rate", ed ? (it.rate || 0) : "0", "number") + `
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: ed ? "Save" : "Add", cls: "", fn: () => {
const n = val("f-name"); if (!n) return alertField();
const isRepEl = document.getElementById("f-rep");
const isRepairable = isRepEl && !document.getElementById("f-repairable-wrap").style.display.includes("none") && isRepEl.checked;
const obj = {
code: val("f-code"), name: n, cat: val("f-cat"), unit: val("f-unit"),
qty: +val("f-qty"), reorder: +val("f-re"),
returnable: document.getElementById("f-ret").checked,
repairable: isRepairable,
repairLoc: isRepairable ? val("f-repair-loc") : '',
partsSource: isRepairable ? val("f-parts-source") : '',
partDetails: isRepairable ? (document.getElementById("f-part-details") ? document.getElementById("f-part-details").value : '') : '',
loc: val("f-loc"), rate: +val("f-rate")
};
if (ed) { Object.assign(it, obj); } else { DB.items.push({ id: uid("i"), ...obj }); }
save(); toast(ed ? "Saved" : "Item added"); closeModal(); render();
}
}]);
// Dynamic: show/hide Repairable section based on category, and repair details based on checkbox
setTimeout(() => {
const catEl = document.getElementById("f-cat");
const repWrap = document.getElementById("f-repairable-wrap");
const repCb = document.getElementById("f-rep");
const repDetails = document.getElementById("f-repair-details");
function updateRepairableVis() {
const isToolCat = catEl && catEl.value === "Tools & Machinery";
if (repWrap) repWrap.style.display = isToolCat ? '' : 'none';
if (!isToolCat && repCb) repCb.checked = false;
if (repDetails) repDetails.style.display = (isToolCat && repCb && repCb.checked) ? '' : 'none';
}
if (catEl) catEl.onchange = updateRepairableVis;
if (repCb) repCb.onchange = () => { if (repDetails) repDetails.style.display = repCb.checked ? '' : 'none'; };
updateRepairableVis();
}, 80);
}
// ── Stores: Repair & Parts Info Modal ────────────────────────────────────────
function formRepairInfo(it) {
if (!it) return;
if (!it.repairable) {
toast('This item is not marked as Repairable. Edit the item to enable repair tracking.');
return;
}
openModal('🔧 Repair & Parts Info — ' + it.name,
`
🔧 Repair & Maintenance Information
Repair Location / Service Center
${it.repairLoc || 'Not specified '}
Replacement Parts Source
${it.partsSource || 'Not specified '}
${it.partDetails ? `
Part Details / Replaceable Parts
${escAttr(it.partDetails)}
` : ''}
${it.name} · Code: ${it.code || '—'} · Category: ${it.cat || '—'}
Location: ${it.loc || '—'} · Stock: ${it.qty} ${it.unit || ''}
`,
[{ label: 'Close', cls: 'secondary', fn: closeModal }, { label: '✏ Edit Item', cls: '', fn: () => { closeModal(); formItem(it); } }]
);
}
function stockMove(it, dir) {
if (dir === 'in') {
openModal("Stock In — " + it.name,
`Current: ${it.qty} ${it.unit}
` + field("Quantity", "f-q", "", "number", "min=1") + field("Reason / Note", "f-reason", ""),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Confirm Stock In", cls: "", fn: () => {
const q = +val("f-q"); if (!q) return alertField();
const before = it.qty;
it.qty = it.qty + q;
// Log stock movement with before/after
if (!it.stockLogs) it.stockLogs = [];
it.stockLogs.push({
id: uid('sl'), date: todayISO(), type: 'IN', qty: q, before, after: it.qty,
person: USER.displayName || USER.username, reason: val('f-reason') || ''
});
save(); logAct(`Stock In: ${it.name} +${q} → ${it.qty} remaining`);
toast(`✅ Stock In — ${q} ${it.unit} added · ${it.qty} remaining`); closeModal(); render();
}
}]);
return;
}
// Stock Out — with Worker Type / Name
const WORKER_TYPES = ["Expatriate", "Permanent Employee", "Service Contract", "Contractor"];
function getWorkerOptions(type) {
const matched = (DB.employees || []).filter(e => e.staffType === type);
if (!matched.length) return `-- no ${type} found -- `;
return `-- select -- ` + matched.map(e => `${e.name} `).join("");
}
openModal("Stock Out — " + it.name,
`Current: ${it.qty} ${it.unit}
` +
field("Quantity", "f-q", "", "number", "min=1") +
`Worker Type
-- select type --
${WORKER_TYPES.map(t => `${t} `).join("")}
`+
``+
field("Reason / Note", "f-reason", ""),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Confirm Stock Out", cls: "", fn: () => {
const q = +val("f-q"); if (!q) return alertField();
if (q > it.qty) return toast("Not enough in store");
const wtype = val("f-wtype");
const wid = document.getElementById("f-wname") ? document.getElementById("f-wname").value : "";
const emp = wid ? DB.employees.find(e => e.id === wid) : null;
const wname = emp ? emp.name : "";
const before = it.qty;
it.qty = Math.max(0, it.qty - q);
const remaining = it.qty;
// Log stock movement with before/after quantities
if (!it.stockLogs) it.stockLogs = [];
it.stockLogs.push({
id: uid('sl'), date: todayISO(), type: 'OUT', qty: q, before, after: remaining,
person: wname || wtype || USER.displayName || USER.username, workerType: wtype || '', reason: val('f-reason') || ''
});
// Log as an issue for tracking
if (wtype || wname) {
DB.issues.unshift({
id: uid("is"), itemId: it.id, item: it.name, code: it.code, unit: it.unit,
qty: q, personKind: "employee", personRef: wid, person: wname || wtype || "Stock Out",
workerType: wtype, workerId: wid, dept: emp ? emp.dept : "",
loc: it.loc || "", dateOut: todayISO(), expReturn: "", status: "Out",
notes: val("f-reason")
});
}
save(); logAct(`Stock Out: ${it.name} -${q}${wname ? ' → ' + wname : ''} · ${remaining} remaining`);
toast(`📦 Stock Out — ${q} ${it.unit} taken · ${remaining} remaining`); closeModal(); render();
}
}]);
// Wire worker type change to populate name dropdown
setTimeout(() => {
const wtEl = document.getElementById("f-wtype");
const nameBox = document.getElementById("workerNameBox");
const wnEl = document.getElementById("f-wname");
const wsEl = document.getElementById("f-wsearch");
if (wtEl) wtEl.onchange = () => {
const t = wtEl.value;
nameBox.style.display = t ? "block" : "none";
if (wnEl) wnEl.innerHTML = t ? getWorkerOptions(t) : '';
};
if (wsEl) wsEl.oninput = () => {
const s = wsEl.value.toLowerCase();
const t = wtEl ? wtEl.value : '';
const matched = (DB.employees || []).filter(e => e.staffType === t && e.name.toLowerCase().includes(s));
if (wnEl) wnEl.innerHTML = matched.map(e => `${e.name} `).join('');
};
}, 50);
}
// ── Stores Stock Movement Report Export ──────────────────────────────────────
function exportStoresStockReport() {
openModal('📊 Stock Movement Report',
`Month (leave blank for all time)
Downloads a CSV with every stock IN / OUT movement — quantities taken, remaining balance, and who took it.
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: '⬇ Download CSV', cls: '', fn: () => {
const mo = (document.getElementById('f-rep-month') || {}).value || '';
closeModal();
_doExportStockReport(mo);
}
}]);
}
window._doExportStockReport = function (mo) {
const items = DB.items || [];
if (!items.length) { toast('No items in stores'); return; }
const csvEscLocal = v => { const s = String(v == null ? '' : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
const rows = [['Date', 'Item Code', 'Item Name', 'Category', 'Location', 'Movement Type', 'Qty Moved', 'Qty Before', 'Qty After (Remaining)', 'Taken / Added By', 'Worker Type', 'Reason / Note']];
let totalIn = 0, totalOut = 0, movCount = 0;
items.forEach(it => {
const logs = (it.stockLogs || []).filter(l => !mo || (l.date || '').startsWith(mo));
logs.forEach(l => {
rows.push([l.date || '', it.code || '', it.name || '', it.cat || '', it.loc || '',
l.type === 'IN' ? 'Stock In' : 'Stock Out', l.qty || 0, l.before || 0, l.after || 0,
l.person || '', l.workerType || '', l.reason || '']);
if (l.type === 'IN') totalIn += +(l.qty || 0); else totalOut += +(l.qty || 0);
movCount++;
});
});
// Always append current stock summary
rows.push([]);
rows.push(['─── CURRENT STOCK SUMMARY ───']);
rows.push(['Item Code', 'Item Name', 'Category', 'Location', 'Current Qty', 'Unit', 'Reorder Level', 'Status']);
items.forEach(it => {
rows.push([it.code || '', it.name || '', it.cat || '', it.loc || '', it.qty || 0, it.unit || '', it.reorder || 0, it.qty <= (it.reorder || 0) ? 'Low Stock' : 'OK']);
});
rows.push([]);
rows.push(['Total Stock-In' + (mo ? ' (' + mo + ')' : ''), totalIn]);
rows.push(['Total Stock-Out' + (mo ? ' (' + mo + ')' : ''), totalOut]);
const csv = '\ufeff' + rows.map(r => r.map(csvEscLocal).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'StoresStockReport_' + (mo || 'AllTime') + '_' + todayISO() + '.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast('Stock report downloaded — ' + movCount + ' movement(s)' + (mo ? ' for ' + mo : ''));
};
// ══════════════════════════════════════════════════════════════════════
// UNIVERSAL BULK EXPORT / IMPORT — All Modules
// ══════════════════════════════════════════════════════════════════════
/** Shared helper: download an array-of-arrays as a CSV file */
function downloadCSV(rows, filename) {
const esc = v => { const s = String(v == null ? '' : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; };
const csv = '\ufeff' + rows.map(r => r.map(esc).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
// ── Universal Custom Export Engine ────────────────────────────────────────────
function formCustomExport(moduleKey) {
const MODULE_EXPORT_CONFIGS = {
progress: {
title: "Erection & Civil Progress",
getRaw: () => DB.progress || [],
dateKey: "target",
cols: [
{ k: 'disc', l: 'Discipline' },
{ k: 'activity', l: 'Activity' },
{ k: 'target', l: 'Target Date' },
{ k: 'pct', l: 'Progress %' },
{ k: 'status', l: 'Status' }
]
},
erectionSchedule: {
title: "Erection Schedule",
getRaw: () => DB.erectionSchedule || [],
dateKey: "startDate",
cols: [
{ k: 'actId', l: 'Act ID' },
{ k: 'discipline', l: 'Discipline' },
{ k: 'activity', l: 'Activity' },
{ k: 'area', l: 'Area' },
{ k: 'workType', l: 'Work Type' },
{ k: 'plannedQty', l: 'Planned Qty' },
{ k: 'uom', l: 'UOM' },
{ k: 'plannedMandays', l: 'Planned Mandays' },
{ k: 'startDate', l: 'Start Date' },
{ k: 'finishDate', l: 'Finish Date' },
{ k: 'supervisor', l: 'Supervisor' },
{ k: 'status', l: 'Status' }
]
},
erectionDailyLog: {
title: "Erection Daily Work Log",
getRaw: () => DB.erectionDailyLog || [],
dateKey: "date",
cols: [
{ k: 'date', l: 'Date' },
{ k: 'actId', l: 'Act ID' },
{ k: 'discipline', l: 'Discipline' },
{ k: 'area', l: 'Area' },
{ k: 'activity', l: 'Activity' },
{ k: 'qtyDone', l: 'Qty Done' },
{ k: 'uom', l: 'UOM' },
{ k: 'manpower', l: 'Manpower' },
{ k: 'supervisor', l: 'Supervisor' },
{ k: 'remarks', l: 'Remarks' }
]
},
proc: {
title: "Procurement (Indents & POs)",
getRaw: () => DB.proc || [],
dateKey: "at",
cols: [
{ k: 'no', l: 'Indent No' },
{ k: 'dept', l: 'Department' },
{ k: 'desc', l: 'Item Description' },
{ k: 'qty', l: 'Quantity' },
{ k: 'unit', l: 'Unit' },
{ k: 'area', l: 'Area' },
{ k: 'by', l: 'Raised By' },
{ k: 'at', l: 'Date' },
{ k: 'supplier', l: 'Supplier' },
{ k: 'value', l: 'PO Value' },
{ k: 'status', l: 'Status' }
]
},
vendors: {
title: "Vendors & Suppliers",
getRaw: () => DB.vendors || [],
dateKey: "addedAt",
cols: [
{ k: 'name', l: 'Vendor Name' },
{ k: 'location', l: 'Location / City' },
{ k: 'address', l: 'Address' },
{ k: 'contact', l: 'Contact Person' },
{ k: 'phone', l: 'Phone' },
{ k: 'email', l: 'Email' },
{ k: 'whatsapp', l: 'WhatsApp' },
{ k: 'telegram', l: 'Telegram / NIF' },
{ k: 'addedAt', l: 'Added Date' }
]
},
docs: {
title: "Company Documents & Licences",
getRaw: () => DB.docs || [],
dateKey: "date",
cols: [
{ k: 'title', l: 'Title / Ref' },
{ k: 'kind', l: 'Kind' },
{ k: 'origin', l: 'Party / Origin' },
{ k: 'date', l: 'Date / Expiry' },
{ k: 'status', l: 'Status' }
]
},
items: {
title: "Site Stores Inventory Items",
getRaw: () => DB.items || [],
cols: [
{ k: 'code', l: 'Item Code' },
{ k: 'name', l: 'Item Name' },
{ k: 'cat', l: 'Category' },
{ k: 'loc', l: 'Storage Location' },
{ k: 'qty', l: 'Stock Qty' },
{ k: 'unit', l: 'Unit' },
{ k: 'reorder', l: 'Reorder Level' },
{ k: 'rate', l: 'Replacement Rate' }
]
},
issues: {
title: "Tool Issues & Returns Log",
getRaw: () => DB.issues || [],
dateKey: "dateOut",
cols: [
{ k: 'item', l: 'Item Name' },
{ k: 'code', l: 'Item Code' },
{ k: 'qty', l: 'Quantity' },
{ k: 'unit', l: 'Unit' },
{ k: 'person', l: 'Issued To' },
{ k: 'workerId', l: 'Worker ID' },
{ k: 'dept', l: 'Department' },
{ k: 'dateOut', l: 'Date Issued' },
{ k: 'expReturn', l: 'Expected Return' },
{ k: 'returnedOn', l: 'Returned On' },
{ k: 'status', l: 'Status' },
{ k: 'fineAmount', l: 'Fine Amount' }
]
},
vehicles: {
title: "Vehicles & Equipment Log",
getRaw: () => DB.vehicles || [],
cols: [
{ k: 'name', l: 'Vehicle / Equipment Name' },
{ k: 'reg', l: 'Reg / Plate ID' },
{ k: 'type', l: 'Type' },
{ k: 'driver', l: 'Driver' },
{ k: 'meter', l: 'Odometer / Meter' },
{ k: 'lastService', l: 'Last Service' },
{ k: 'nextService', l: 'Next Service Due' },
{ k: 'status', l: 'Status' }
]
},
accounts: {
title: "Accounts & Invoice Register",
getRaw: () => DB.accounts || [],
dateKey: "date",
cols: [
{ k: 'date', l: 'Date' },
{ k: 'inv', l: 'Invoice No' },
{ k: 'nif', l: 'NIF' },
{ k: 'party', l: 'Company / Person' },
{ k: 'head', l: 'Head / Description' },
{ k: 'mode', l: 'Mode of Payment' },
{ k: 'type', l: 'Type' },
{ k: 'status', l: 'Status' },
{ k: 'amount', l: 'Amount (Kz)' },
{ k: 'vat', l: 'VAT (Kz)' },
{ k: 'comments', l: 'Comments' }
]
},
hr: {
title: "HR Staff Directory",
getRaw: () => DB.employees || [],
dateKey: "joined",
isHR: true,
cols: [
{ k: 'id', l: 'Worker ID' },
{ k: 'name', l: 'Full Name' },
{ k: 'dept', l: 'Department' },
{ k: 'role', l: 'Role / Position' },
{ k: 'staffType', l: 'Staff Category (Heading)' },
{ k: 'phone', l: 'Phone' },
{ k: 'joined', l: 'Joined Date' },
{ k: 'salary', l: 'Salary' },
{ k: 'status', l: 'Status' }
]
},
attendance: {
title: "HR Monthly Attendance Log",
getRaw: () => DB.employees || [],
isAttendance: true,
cols: [
{ k: 'id', l: 'Worker ID' },
{ k: 'name', l: 'Full Name' },
{ k: 'dept', l: 'Department' },
{ k: 'staffType', l: 'Staff Category (Heading)' }
]
},
payroll: {
title: "Monthly Payroll Register",
getRaw: () => DB.employees || [],
isHR: true,
cols: [
{ k: 'id', l: 'Worker ID' },
{ k: 'name', l: 'Full Name' },
{ k: 'dept', l: 'Department' },
{ k: 'staffType', l: 'Staff Category (Heading)' },
{ k: 'salary', l: 'Base Salary' },
{ k: 'salaryApproved', l: 'Approved Status' }
]
},
released: {
title: "Released Staff Register",
getRaw: () => (DB.employees || []).filter(e => e.status === 'Released'),
isHR: true,
cols: [
{ k: 'id', l: 'Worker ID' },
{ k: 'name', l: 'Full Name' },
{ k: 'dept', l: 'Department' },
{ k: 'staffType', l: 'Category' },
{ k: 'releaseDate', l: 'Release Date' },
{ k: 'releaseReason', l: 'Release Reason' },
{ k: 'releasedBy', l: 'Released By' }
]
},
debitNotes: {
title: "Salary Deductions & Debit Notes",
getRaw: () => DB.debitNotes || [],
dateKey: "date",
cols: [
{ k: 'no', l: 'Debit Note No' },
{ k: 'date', l: 'Date' },
{ k: 'person', l: 'Worker Name' },
{ k: 'item', l: 'Item / Reason' },
{ k: 'qty', l: 'Qty' },
{ k: 'value', l: 'Value (Kz)' },
{ k: 'reason', l: 'Reason' },
{ k: 'status', l: 'Status' }
]
},
security: {
title: "Security & Gate Log",
getRaw: () => DB.security || [],
dateKey: "date",
cols: [
{ k: 'date', l: 'Date' },
{ k: 'timeIn', l: 'Time In' },
{ k: 'timeOut', l: 'Time Out' },
{ k: 'type', l: 'Entry Type' },
{ k: 'name', l: 'Visitor / Material' },
{ k: 'reg', l: 'Reg / ID' },
{ k: 'purpose', l: 'Purpose / Remarks' }
]
},
legalDocs: {
title: "Licences & Title Deeds",
getRaw: () => DB.legalDocs || [],
dateKey: "issued",
cols: [
{ k: 'name', l: 'Document Title' },
{ k: 'type', l: 'Type' },
{ k: 'issued', l: 'Issued Date' },
{ k: 'expiry', l: 'Expiry Date' },
{ k: 'notes', l: 'Notes' }
]
}
};
const cfg = MODULE_EXPORT_CONFIGS[moduleKey] || {
title: moduleKey,
getRaw: () => Array.isArray(DB[moduleKey]) ? DB[moduleKey] : [],
cols: [{ k: 'id', l: 'ID' }]
};
const colBoxes = cfg.cols.map(c =>
`` +
` ${c.l} `
).join('');
const isHRModule = cfg.isHR || cfg.isAttendance;
const currentMonth = todayISO().slice(0, 7);
const [yr, moN] = currentMonth.split('-').map(Number);
const defaultDaysInMonth = new Date(yr, moN, 0).getDate();
if (moduleKey === 'hr' || moduleKey === 'attendance') {
openModal("⚙ Custom Export — HR & Time Office",
`
Select HR Export Type
📅 Monthly Attendance Log (Daily P/A Grid & Categories)
📁 Staff Directory (Custom Columns)
🧾 Monthly Payroll Register
📁 Group workers into category headings (Service Contract, Expatriate, Permanent Employee)
Select Columns to Include:
${colBoxes}
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Export CSV", cls: "", fn: () => {
const target = val("f-hr-exp-target") || 'attendance';
if (target === 'attendance') {
const mVal = val("f-exp-month") || currentMonth;
const startDay = val("f-exp-start-day") || '1';
const endDay = val("f-exp-end-day") || String(defaultDaysInMonth);
const groupCat = document.getElementById("f-exp-group-cat") ? document.getElementById("f-exp-group-cat").checked : true;
executeCustomExport('attendance', MODULE_EXPORT_CONFIGS.attendance, mVal, startDay, endDay, null, groupCat);
} else {
executeCustomExport(target, MODULE_EXPORT_CONFIGS[target] || cfg);
}
}
}]
);
setTimeout(() => {
const selEl = document.getElementById("f-hr-exp-target");
if (selEl) {
selEl.onchange = () => {
const isAtt = selEl.value === 'attendance';
document.getElementById("hr-exp-att-section").style.display = isAtt ? 'block' : 'none';
document.getElementById("hr-exp-cols-section").style.display = isAtt ? 'none' : 'block';
};
}
}, 50);
return;
}
// Non-HR general custom export modal
openModal(`⚙ Custom Export — ${cfg.title}`,
`
${cfg.dateKey ? `
${field('From Date', 'f-exp-from', '', 'date')}${field('To Date', 'f-exp-to', '', 'date')}
` : ''}
Select Columns to Export:
Select All
Deselect All
${colBoxes}
`,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{ label: "⬇ Download Custom CSV", cls: "", fn: () => executeCustomExport(moduleKey, cfg) }
]
);
}
function getWorkerCategoryName(emp) {
const t = emp.staffType || emp.contractType || emp.type || 'Permanent Employee';
const str = String(t).trim();
if (!str) return 'Permanent Employee';
if (str.toLowerCase().includes('contract')) return 'Service Contract';
if (str.toLowerCase().includes('expat')) return 'Expatriate';
if (str.toLowerCase().includes('perm')) return 'Permanent Employee';
return str;
}
function executeCustomExport(moduleKey, cfg) {
const selectedCols = Array.from(document.querySelectorAll('.exp-col-chk:checked')).map(c => c.value);
if (!selectedCols.length) return toast('Select at least one column to export.');
const fromDate = val('f-exp-from');
const toDate = val('f-exp-to');
const monthVal = val('f-exp-month');
const selectedEmpId = val('f-exp-emp');
const startDayVal = val('f-exp-start-day');
const endDayVal = val('f-exp-end-day');
const groupCat = document.getElementById('f-exp-group-cat') ? document.getElementById('f-exp-group-cat').checked : false;
let rawList = cfg.getRaw();
// Filter by single person if selected
if (selectedEmpId && (cfg.isHR || cfg.isAttendance)) {
rawList = rawList.filter(e => e.id === selectedEmpId);
}
// Date filtering if applicable
if (cfg.dateKey && (fromDate || toDate)) {
rawList = rawList.filter(item => {
const dt = String(item[cfg.dateKey] || item.date || item.at || item.joined || '').slice(0, 10);
if (!dt) return true;
if (fromDate && dt < fromDate) return false;
if (toDate && dt > toDate) return false;
return true;
});
}
if (!rawList || !rawList.length) {
toast('No records match your export criteria.');
closeModal();
return;
}
const csvRows = [];
if (cfg.isAttendance) {
// Attendance export with custom day range & category division headings
const mVal = monthVal || todayISO().slice(0, 7);
const [year, month] = mVal.split('-').map(Number);
const daysInMonth = new Date(year, month, 0).getDate();
let sDay = parseInt(startDayVal) || 1;
let eDay = parseInt(endDayVal) || daysInMonth;
if (sDay < 1) sDay = 1;
if (eDay > daysInMonth) eDay = daysInMonth;
if (sDay > eDay) { sDay = 1; eDay = daysInMonth; }
csvRows.push([`=== ATTENDANCE LOG (${mVal} · DAYS ${sDay} TO ${eDay}) ===`]);
csvRows.push([`=== LEGEND: P = Present, A = Absent, - = Unmarked / Not Logged ===`]);
// Group employees by category
const categoryGroups = {};
rawList.forEach(emp => {
const cat = (groupCat && !selectedEmpId) ? getWorkerCategoryName(emp) : 'All Workers';
if (!categoryGroups[cat]) categoryGroups[cat] = [];
categoryGroups[cat].push(emp);
});
Object.keys(categoryGroups).sort().forEach(catName => {
const emps = categoryGroups[catName];
if (groupCat && !selectedEmpId) {
csvRows.push([]);
csvRows.push([`=== WORKER CATEGORY: ${catName.toUpperCase()} (${emps.length} WORKERS) ===`]);
}
// Column Header
const headerRow = ["Worker ID", "Name", "Department", "Staff Category"];
for (let d = sDay; d <= eDay; d++) headerRow.push(String(d));
headerRow.push("Total Present", "Total Absent");
csvRows.push(headerRow);
emps.forEach(emp => {
const row = [emp.id || '', emp.name || '', emp.dept || '', getWorkerCategoryName(emp)];
let presCount = 0, absCount = 0;
for (let d = sDay; d <= eDay; d++) {
const dayStr = String(d).padStart(2, '0');
const dateStr = `${mVal}-${dayStr}`;
const att = (DB.attendance[dateStr] || {})[emp.id];
const st = getAttStatusStr(att);
if (st === 'present') { row.push("P"); presCount++; }
else if (st === 'absent') { row.push("A"); absCount++; }
else { row.push("-"); }
}
row.push(presCount, absCount);
csvRows.push(row);
});
});
const personTag = selectedEmpId ? `_${(rawList[0].name || '').replace(/[^a-zA-Z0-9]/g, '_')}` : '';
downloadCSV(csvRows, `Attendance${personTag}_${mVal}_Days_${sDay}_to_${eDay}.csv`);
toast(`Custom Attendance CSV downloaded — Days ${sDay} to ${eDay} (${rawList.length} worker${rawList.length > 1 ? 's' : ''})`);
closeModal();
return;
}
// Standard collection custom export (with optional category headings for HR)
if (groupCat && cfg.isHR) {
const categoryGroups = {};
rawList.forEach(item => {
const cat = getWorkerCategoryName(item);
if (!categoryGroups[cat]) categoryGroups[cat] = [];
categoryGroups[cat].push(item);
});
Object.keys(categoryGroups).sort().forEach(catName => {
const groupItems = categoryGroups[catName];
csvRows.push([]);
csvRows.push([`=== WORKER CATEGORY: ${catName.toUpperCase()} (${groupItems.length} WORKERS) ===`]);
const headers = cfg.cols.filter(c => selectedCols.includes(c.k)).map(c => c.l);
csvRows.push(headers);
groupItems.forEach(item => {
const row = cfg.cols.filter(c => selectedCols.includes(c.k)).map(c => {
if ((c.k === 'salary' || c.k === 'earnedSalary' || c.k === 'netPay' || c.k === 'otPay') && !can.seeSalaryForEmp(USER, item)) return 'Restricted';
let val = item[c.k];
if (val === undefined || val === null) val = '';
return val;
});
csvRows.push(row);
});
});
} else {
// Normal single-table custom export
const headers = cfg.cols.filter(c => selectedCols.includes(c.k)).map(c => c.l);
csvRows.push(headers);
rawList.forEach(item => {
const row = cfg.cols.filter(c => selectedCols.includes(c.k)).map(c => {
if ((c.k === 'salary' || c.k === 'earnedSalary' || c.k === 'netPay' || c.k === 'otPay') && !can.seeSalaryForEmp(USER, item)) return 'Restricted';
let val = item[c.k];
if (val === undefined || val === null) val = '';
return val;
});
csvRows.push(row);
});
}
downloadCSV(csvRows, `${moduleKey}_Custom_Export_${todayISO()}.csv`);
toast(`Custom Export downloaded — ${rawList.length} records`);
closeModal();
}
// ── 1. PROCUREMENT — Export Indents / POs ────────────────────────────
function exportProcurementCSV() {
const rows = [['Indent No', 'Date', 'Department', 'Requester', 'Delivery Location', 'Item / Description', 'Qty', 'Unit',
'Area / Spec', 'Remarks', 'Luanda Quote (Kz)', 'Lobito Quote (Kz)', 'India Quote (Kz)', 'Other Quote (Kz)',
'Selected Source', 'PO Value (Kz)', 'Supplier', 'Status', 'PI No', 'Payment Mode',
'Dispatched Date', 'Received Date']];
(DB.proc || []).forEach(p => {
const q = p.quotes || {};
const items = (p.items && p.items.length) ? p.items : [{ desc: p.desc, qty: p.qty, unit: p.unit, area: p.area, remarks: p.remarks }];
items.forEach((it, idx) => {
rows.push([
p.no || '', (p.at || p.date || '').slice(0, 10), p.dept || '', p.by || '', p.deliveryLoc || '',
it.desc || '', it.qty || 0, it.unit || '', it.area || '', it.remarks || '',
idx === 0 ? (q.luanda?.price || '') : '', idx === 0 ? (q.lobito?.price || '') : '', idx === 0 ? (q.india?.price || '') : '', idx === 0 ? (q.other?.price || '') : '',
idx === 0 ? (p.selected || '') : '', idx === 0 ? (p.value || '') : '', idx === 0 ? (p.supplier || '') : '', p.status || '',
idx === 0 ? (p.piNo || '') : '', idx === 0 ? (p.payMode || '') : '',
idx === 0 ? (p.dispatchDate || '') : '', idx === 0 ? (p.receivedDate || '') : ''
]);
});
});
rows.push([]);
rows.push(['Total Indents', (DB.proc || []).length]);
downloadCSV(rows, `Procurement_Indents_${todayISO()}.csv`);
toast('Procurement export downloaded — ' + (DB.proc || []).length + ' records');
}
// ── Export: Indents Raised (Pending Approval) ──────────────────────────
function exportIndentsRaisedCSV() {
const indents = (DB.proc || []).filter(p => p.status === "Draft" || p.status === "Indent Raised" || p.status === "Quoted" || p.status === "Quotes Compared" || p.status === "Rejected");
const rows = [['Indent No', 'Date Raised', 'Department', 'Requester', 'Item Description', 'Qty', 'Unit', 'Delivery Location', 'Status', 'Remarks']];
indents.forEach(p => {
rows.push([
p.no || '', p.dateRaised || p.at?.slice(0, 10) || '', p.dept || '', p.by || '', p.desc || '',
p.qty || 0, p.unit || 'Nos', p.deliveryLoc || '', p.status || '', p.purpose || ''
]);
});
rows.push([]);
rows.push(['Total Raised Indents', indents.length]);
downloadCSV(rows, `Indents_Raised_${todayISO()}.csv`);
toast('✓ Indents Raised exported — ' + indents.length + ' records');
}
// ── Export: Indents Approved (In Progress) ────────────────────────────
function exportIndentsApprovedCSV() {
const indents = (DB.proc || []).filter(p => p.status === "Approved" || p.status === "PO Issued");
const rows = [['Indent No', 'Date Raised', 'Department', 'Requester', 'Item Description', 'Qty', 'Supplier', 'PO Value (Kz)', 'Status', 'Remarks']];
indents.forEach(p => {
rows.push([
p.no || '', p.dateRaised || p.at?.slice(0, 10) || '', p.dept || '', p.by || '', p.desc || '',
p.qty || 0, p.supplier || '—', p.value || '', p.status || '', p.purpose || ''
]);
});
rows.push([]);
rows.push(['Total Approved Indents', indents.length]);
downloadCSV(rows, `Indents_Approved_${todayISO()}.csv`);
toast('✓ Indents Approved exported — ' + indents.length + ' records');
}
// ── Export: Indents Paid (Awaiting Dispatch) ──────────────────────────
function exportIndentsPaidCSV() {
const indents = (DB.proc || []).filter(p => p.status === "Payment Done" || p.status === "Dispatched");
const rows = [['Indent No', 'Date Raised', 'Department', 'Requester', 'Item Description', 'Qty', 'Supplier', 'PO Value (Kz)', 'Payment Mode', 'Status', 'Dispatch Date']];
indents.forEach(p => {
rows.push([
p.no || '', p.dateRaised || p.at?.slice(0, 10) || '', p.dept || '', p.by || '', p.desc || '',
p.qty || 0, p.supplier || '—', p.value || '', p.payMode || '', p.status || '', p.dispatchDate || ''
]);
});
rows.push([]);
rows.push(['Total Paid Indents', indents.length]);
downloadCSV(rows, `Indents_Paid_${todayISO()}.csv`);
toast('✓ Indents Paid exported — ' + indents.length + ' records');
}
// ── 2. VENDORS — Export All ───────────────────────────────────────────
function exportVendorsCSV() {
const rows = [['#', 'Vendor Name', 'Location / City', 'Address', 'Contact Number', 'Email',
'WhatsApp', 'Telegram / NIF', 'Material 1 Name', 'Material 1 Rate (Kz)', 'Material 1 Rate Date',
'Material 2 Name', 'Material 2 Rate (Kz)', 'Material 2 Rate Date',
'Material 3 Name', 'Material 3 Rate (Kz)', 'Material 3 Rate Date',
'Added Date']];
(DB.vendors || []).forEach((v, i) => {
const m = v.materials || [];
rows.push([
i + 1, v.name || '', v.location || '', v.address || '',
v.contact || '', v.email || '', v.whatsapp || '', v.telegram || '',
m[0]?.name || '', m[0]?.rate || '', m[0]?.rateDate || '',
m[1]?.name || '', m[1]?.rate || '', m[1]?.rateDate || '',
m[2]?.name || '', m[2]?.rate || '', m[2]?.rateDate || '',
v.addedAt || ''
]);
// If vendor has >3 materials, add extra rows
for (let j = 3; j < m.length; j++) {
rows.push([i + 1, v.name || '', '(additional material)', '', '', '', '', '',
m[j]?.name || '', m[j]?.rate || '', m[j]?.rateDate || '', '', '', '', '', '', '', '']);
}
});
downloadCSV(rows, `Vendors_${todayISO()}.csv`);
toast('Vendors exported — ' + (DB.vendors || []).length + ' vendors');
}
// ── 3. STORES — Export Full Items List ────────────────────────────────
function exportItemsCSV() {
const rows = [['Item Code', 'Item Name', 'Category', 'Location', 'Current Qty', 'Unit',
'Reorder Level', 'Returnable', 'Status', 'Total Stock-In (all time)', 'Total Stock-Out (all time)']];
(DB.items || []).forEach(it => {
const logs = it.stockLogs || [];
const totalIn = logs.filter(l => l.type === 'IN').reduce((s, l) => s + (+l.qty || 0), 0);
const totalOut = logs.filter(l => l.type === 'OUT').reduce((s, l) => s + (+l.qty || 0), 0);
rows.push([
it.code || '', it.name || '', it.cat || '', it.loc || '',
it.qty || 0, it.unit || '', it.reorder || 0,
it.returnable ? 'Yes' : 'No',
it.qty <= (it.reorder || 0) ? 'Low Stock' : 'OK',
totalIn, totalOut
]);
});
rows.push([]);
rows.push(['Total Items', (DB.items || []).length]);
downloadCSV(rows, `StoreItems_${todayISO()}.csv`);
toast('Store items exported — ' + (DB.items || []).length + ' items');
}
// ── 4. VEHICLES — Export All ─────────────────────────────────────────
function exportVehiclesCSV() {
const rows = [['Vehicle Name', 'Type', 'Reg / ID', 'Status', 'Current Meter', 'Driver',
'Last Service Date', 'Next Service Due', 'Insurance Expiry', 'Registration Expiry',
'Inspection Expiry', 'Total Service Logs', 'Total Trips', 'Total Fuel Logs']];
(DB.vehicles || []).forEach(v => {
rows.push([
v.name || '', v.type || '', v.reg || '', v.status || '', v.meter || '', v.driver || '',
v.lastService || '', v.nextService || '',
v.insExpiry || '', v.regExpiry || '', v.inspExpiry || '',
(v.logs || []).length, (v.trips || []).length, (v.fuel || []).length
]);
});
// Service logs detail sheet
rows.push([]);
rows.push(['── SERVICE LOGS DETAIL ──']);
rows.push(['Vehicle', 'Date', 'Open KM', 'Close KM', 'Work Done', 'Cost (Kz)', 'Driver']);
(DB.vehicles || []).forEach(v => {
(v.logs || []).forEach(l => {
rows.push([v.name || '', l.date || '', l.km_open || '', l.km_close || '', l.work || '', l.cost || 0, l.driver || '']);
});
});
// Trip logs
rows.push([]);
rows.push(['── TRIP LOGS DETAIL ──']);
rows.push(['Vehicle', 'Trip No', 'Date', 'Open KM', 'Close KM', 'Distance (km)', 'Destination', 'Driver', 'Approved By', 'Damage']);
(DB.vehicles || []).forEach(v => {
(v.trips || []).forEach(l => {
rows.push([v.name || '', l.no || '', l.date || '', l.openKm || '', l.closeKm || '',
((+l.closeKm || 0) - (+l.openKm || 0)), l.destination || '', l.driver || '', l.approvedBy || '', l.damage || 'OK']);
});
});
// Fuel logs
rows.push([]);
rows.push(['── FUEL LOGS DETAIL ──']);
rows.push(['Vehicle', 'Date', 'Litres', 'Cost (Kz)', 'Meter Reading', 'Filled By']);
(DB.vehicles || []).forEach(v => {
(v.fuel || []).forEach(l => {
rows.push([v.name || '', l.date || '', l.litres || '', l.cost || '', l.meter || '', l.by || '']);
});
});
downloadCSV(rows, `Vehicles_${todayISO()}.csv`);
toast('Vehicles exported — ' + (DB.vehicles || []).length + ' vehicles');
}
// ── 5. HR — Export Staff List ─────────────────────────────────────────
function exportHRStaffCSV(includeReleased) {
let list = includeReleased
? DB.employees.filter(e => e.status === 'Released')
: DB.employees.filter(e => e.status !== 'Released');
if (!isOwner(USER)) {
list = list.filter(e => !isOwnerEmp(e));
}
const label = includeReleased ? 'Released' : 'Active';
const rows = [['Employee Code', 'Name', 'Role', 'Department', 'Staff Type', 'Joined Date', 'Salary', 'Currency',
'ID Document Type', 'ID Number', 'ID Issued Date', 'ID Expiry Date', 'ID Scans Attached',
'Phone', 'Status', 'Salary Approved', 'Offer Letter', 'Contract Signed',
'Work Permit Expiry', 'Visa Expiry', 'Residencia Expiry',
'Release Date', 'Release Reason']];
list.forEach(e => {
const canSeeSal = can.seeSalaryForEmp(USER, e);
const idTypeStr = e.idDocType || (e.staffType === 'Expatriate' ? 'Passport (Expat)' : 'Bilhete de Identidade (Angolan ID)');
rows.push([
empCodeOf(e), e.name || '', e.role || '', e.dept || '', e.staffType || 'Permanent Employee',
e.joined || '', canSeeSal ? (e.salary || 0) : 'Restricted', empCur(e),
idTypeStr, e.idDocNo || '', e.idDocIssueDate || '', e.idDocExpiryDate || e.passportExpiry || '', (e.idDocFiles || []).length,
e.phone || '', e.status || 'Active',
e.salaryApproved ? 'Yes' : 'No', e.offerLetter ? 'Yes' : 'No', e.acceptedContract ? 'Yes' : 'No',
e.workPermitExpiry || '', e.visaExpiry || '', e.residenciaExpiry || '',
e.releaseDate || '', e.releaseReason || ''
]);
});
rows.push([]);
rows.push([label + ' Staff Count', list.length]);
downloadCSV(rows, `HR_${label}Staff_${todayISO()}.csv`);
toast(label + ' staff exported — ' + list.length + ' records');
}
// ── 6. SECURITY — Export Gate Log ────────────────────────────────────
function exportSecurityLogCSV() {
if (!DB.security) DB.security = [];
const rows = [['Date', 'Time In', 'Time Out', 'Entry Type', 'Name / Description',
'Vehicle No', 'Purpose', 'For / Meeting Person', 'Status', 'Security Guard', 'Notes']];
DB.security.forEach(e => {
const typeLabel = { visitor: 'Visitor', material_in: 'Material In', material_out: 'Material Out', vehicle: 'Vehicle' }[e.type] || e.type;
rows.push([
e.date || '', e.timeIn || '', e.timeOut || '', typeLabel,
e.visitorName || e.materialDesc || '', e.vehicleNo || '',
e.purpose || '', e.forPerson || '', e.status || '',
e.guard || '', e.notes || ''
]);
});
rows.push([]);
rows.push(['Total Entries', DB.security.length]);
downloadCSV(rows, `SecurityGateLog_${todayISO()}.csv`);
toast('Security gate log exported — ' + DB.security.length + ' entries');
}
// ── 7. DEDUCTIONS — Export Debit Notes ───────────────────────────────
function exportDeductionsCSV() {
const rows = [['Debit Note No', 'Date', 'Worker / Person', 'Item / Reason', 'Qty', 'Unit',
'Replacement Value (Kz)', 'Total Deduction (Kz)', 'Amount Deducted So Far (Kz)', 'Status']];
(DB.debitNotes || []).forEach(dn => {
rows.push([
dn.no || '', dn.date || '', dn.person || '', dn.item || '',
dn.qty || 1, dn.unit || 'pcs',
dn.replValue || dn.value || 0, dn.value || 0, dn.deducted || 0, dn.status || 'Pending'
]);
});
rows.push([]);
rows.push(['Total Debit Notes', (DB.debitNotes || []).length]);
const totalPending = (DB.debitNotes || []).filter(d => d.status === 'Pending').reduce((s, d) => s + (+d.value || 0), 0);
rows.push(['Total Pending (Kz)', totalPending]);
downloadCSV(rows, `SalaryDeductions_${todayISO()}.csv`);
toast('Deductions exported — ' + (DB.debitNotes || []).length + ' debit notes');
}
// ── 8. LEGAL DOCS — Export ───────────────────────────────────────────
function exportLegalCSV() {
const rows = [['Document / Title', 'Type', 'Issue Date', 'Expiry / Validity', 'Notes / Reference', 'Files Attached']];
(DB.legalDocs || []).forEach(d => {
rows.push([d.name || '', d.type || '', d.issued || '', d.expiry || '', d.notes || '', (d.files || []).length]);
});
rows.push([]);
rows.push(['Total Documents', (DB.legalDocs || []).length]);
downloadCSV(rows, `LegalDocs_${todayISO()}.csv`);
toast('Legal docs exported — ' + (DB.legalDocs || []).length + ' records');
}
// ── BULK IMPORT: VENDORS ──────────────────────────────────────────────
function formBulkVendors() {
openModal('📥 Bulk Import Vendors',
`Download the template, fill in vendor details (one row per vendor), then upload. Multiple materials per vendor should be on separate rows with the same Vendor Name.
` +
`⬇ Download Template (CSV)
` +
`Select CSV File
` +
`
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }]);
document.getElementById('btn-dl-vendor-temp').onclick = () => {
downloadCSV([
['Vendor Name*', 'Location / City', 'India Only (YES/NO)', 'Address', 'Contact Number', 'Email', 'GSTIN', 'WhatsApp', 'Item 1 Name', 'Item 1 Rate', 'Item 1 Rate Date', 'Item 2 Name', 'Item 2 Rate', 'Item 2 Rate Date', 'Item 3 Name', 'Item 3 Rate', 'Item 3 Rate Date'],
['ACME Supplies Lda', 'Luanda', 'NO', 'Rua 1 de Maio 42, Luanda', '923000001', 'acme@example.com', '', '923000001', 'MS Plates 6mm', '25000', '2026-07-01', 'Cement 50kg bags', '8500', '2026-07-01', 'Rebar 12mm', '18000', '2026-07-01'],
['Indostech Pvt Ltd', 'Bangalore', 'YES', 'Mumbai Nagar, Bangalore', '9876543210', 'contact@indostech.in', '27AABCY7777H1Z0', '919876543210', 'Electrical Components', '15000', '2026-07-15', 'Cable Glands', '450', '2026-07-15', '', '', ''],
['XYZ Comercio', 'Lobito', 'NO', 'Rua Comercio 12', '945000002', 'xyz@example.ao', '', '945000002', 'Steel Rods 12mm', '30000', '2026-07-01', 'Steel Plates 10mm', '28000', '2026-07-01', '', '', '']
], 'Vendors_BulkTemplate.csv');
toast('Vendor template downloaded');
};
document.getElementById('f-vendor-csv').onchange = async function () {
if (!this.files.length) return;
const preview = document.getElementById('vendor-import-preview');
preview.innerHTML = 'Parsing… ';
try {
const rows = await parseSpreadsheet(this.files[0]);
if (!rows.length || rows[0].length < 1) throw new Error('Invalid format');
let added = 0;
for (let i = 1; i < rows.length; i++) {
const c = rows[i];
if (!(c[0] || '').trim()) continue;
const name = (c[0] || '').trim();
const indiaOnly = (c[2] || '').trim().toUpperCase() === 'YES';
const mats = [];
// Support up to 5 items per vendor row (Item 1-5 at columns 8, 11, 14, 17, 20)
if ((c[8] || '').trim()) mats.push({ name: c[8].trim(), rate: +(c[9] || 0), rateDate: c[10] || todayISO() });
if ((c[11] || '').trim()) mats.push({ name: c[11].trim(), rate: +(c[12] || 0), rateDate: c[13] || todayISO() });
if ((c[14] || '').trim()) mats.push({ name: c[14].trim(), rate: +(c[15] || 0), rateDate: c[16] || todayISO() });
if ((c[17] || '').trim()) mats.push({ name: c[17].trim(), rate: +(c[18] || 0), rateDate: c[19] || todayISO() });
if ((c[20] || '').trim()) mats.push({ name: c[20].trim(), rate: +(c[21] || 0), rateDate: c[22] || todayISO() });
// Check if vendor already exists
let vendor = (DB.vendors || []).find(v => v.name.toLowerCase() === name.toLowerCase());
if (vendor) { // merge materials
(mats).forEach(m => { if (!vendor.materials.some(x => x.name === m.name)) vendor.materials.push(m); });
vendor.india_only = indiaOnly;
} else {
if (!DB.vendors) DB.vendors = [];
DB.vendors.push({
id: uid('vn'), name, location: (c[1] || '').trim(), address: (c[3] || '').trim(),
contact: (c[4] || '').trim(), email: (c[5] || '').trim(), gstin: (c[6] || '').trim(),
whatsapp: (c[7] || '').trim(), india_only: indiaOnly, materials: mats, addedAt: todayISO()
});
added++;
}
}
save(); toast(added + ' vendors imported'); closeModal(); render();
} catch (err) { preview.innerHTML = `Error: ${err.message} `; }
};
}
// ── BULK IMPORT: VEHICLES ─────────────────────────────────────────────
function formBulkVehicles() {
openModal('📥 Bulk Import Vehicles',
`Download the template, fill vehicle details, then upload.
` +
`⬇ Download Template (CSV)
` +
`Select CSV File
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }]);
document.getElementById('btn-dl-veh-temp').onclick = () => {
downloadCSV([
['Vehicle Name*', 'Type', 'Reg / ID*', 'Status (Running/Under Repair/Suspended)', 'Meter (hrs/km)', 'Driver Name', 'Last Service Date (YYYY-MM-DD)', 'Next Service Due (YYYY-MM-DD)', 'Insurance Expiry', 'Registration Expiry', 'Inspection Expiry'],
['Toyota Hilux', 'Pickup', 'LD-01-23-AB', 'Running', '45200', 'João Silva', '2026-05-10', '2026-11-10', '2027-01-15', '2027-03-20', '2026-12-01'],
['Caterpillar 320D', 'Excavator', 'CAT-320-001', 'Running', '8800', '', '2026-06-01', '2026-12-01', '', '', '']
], 'Vehicles_BulkTemplate.csv');
toast('Vehicle template downloaded');
};
document.getElementById('f-veh-csv').onchange = async function () {
if (!this.files.length) return;
try {
const rows = await parseSpreadsheet(this.files[0]);
let added = 0;
for (let i = 1; i < rows.length; i++) {
const c = rows[i];
if (!(c[0] || '').trim()) continue;
if (!DB.vehicles) DB.vehicles = [];
if (DB.vehicles.some(v => v.reg === (c[2] || '').trim())) continue; // skip duplicate reg
DB.vehicles.push({
id: uid('v'), name: (c[0] || '').trim(), type: (c[1] || '').trim(),
reg: (c[2] || '').trim(), status: (c[3] || 'Running').trim(), meter: (c[4] || '').trim(),
driver: (c[5] || '').trim(), lastService: (c[6] || ''), nextService: (c[7] || ''),
insExpiry: (c[8] || ''), regExpiry: (c[9] || ''), inspExpiry: (c[10] || ''),
logs: [], trips: [], fuel: []
});
added++;
}
save(); toast(added + ' vehicles imported'); closeModal(); render();
} catch (err) { alert('Import Error: ' + err.message); }
};
}
// ── BULK IMPORT: SECURITY / GATE LOG ─────────────────────────────────
function formBulkSecurity() {
openModal('📥 Bulk Import Gate Log',
`Download the template, fill gate log entries, then upload.
` +
`⬇ Download Template (CSV)
` +
`Select CSV File
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }]);
document.getElementById('btn-dl-sec-temp').onclick = () => {
downloadCSV([
['Date (YYYY-MM-DD)*', 'Time In (HH:MM)', 'Time Out (HH:MM)', 'Type (visitor/material_in/material_out/vehicle)*', 'Name / Description*', 'Vehicle No', 'Purpose', 'For / Meeting Person', 'Status (In/Cleared)', 'Guard Name', 'Notes'],
['2026-07-20', '08:30', '17:00', 'visitor', 'John Smith', '', 'Site Inspection', 'Mr. Gopala', 'Cleared', 'Guard A', 'Client visit'],
['2026-07-20', '09:00', '', 'material_in', 'Cement 100 bags', 'LD-01-23', 'PO-042 delivery', 'Store Manager', 'In', 'Guard A', '']
], 'SecurityLog_BulkTemplate.csv');
toast('Security template downloaded');
};
document.getElementById('f-sec-csv').onchange = async function () {
if (!this.files.length) return;
try {
const rows = await parseSpreadsheet(this.files[0]);
if (!DB.security) DB.security = [];
let added = 0;
for (let i = 1; i < rows.length; i++) {
const c = rows[i];
if (!(c[0] || '').trim()) continue;
DB.security.unshift({
id: uid('sec'), date: (c[0] || '').trim(), timeIn: (c[1] || '').trim(),
timeOut: (c[2] || '').trim(), type: (c[3] || 'visitor').trim(),
visitorName: (c[4] || '').trim(), materialDesc: (c[4] || '').trim(),
vehicleNo: (c[5] || '').trim(), purpose: (c[6] || '').trim(),
forPerson: (c[7] || '').trim(), status: (c[8] || 'In').trim(),
guard: (c[9] || '').trim(), notes: (c[10] || '').trim(), photos: []
});
added++;
}
save(); toast(added + ' gate entries imported'); closeModal(); render();
} catch (err) { alert('Import Error: ' + err.message); }
};
}
// ── BULK IMPORT: DEBIT NOTES / DEDUCTIONS ────────────────────────────
function formBulkDeductions() {
openModal('📥 Bulk Import Debit Notes',
`Download the template, fill debit note details, then upload.
` +
`⬇ Download Template (CSV)
` +
`Select CSV File
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }]);
document.getElementById('btn-dl-deduct-temp').onclick = () => {
downloadCSV([
['Debit Note No*', 'Date (YYYY-MM-DD)*', 'Worker / Person*', 'Item / Reason*', 'Qty', 'Unit', 'Replacement Value per unit (Kz)*', 'Status (Pending/Deducted)'],
['DN-001', '2026-07-15', 'João Silva', 'Angle Grinder — Lost', '1', 'pcs', '15000', 'Pending'],
['DN-002', '2026-07-18', 'Ahmed Khan', 'Safety Helmet — Damaged', '2', 'pcs', '1200', 'Deducted']
], 'DebitNotes_BulkTemplate.csv');
toast('Debit notes template downloaded');
};
document.getElementById('f-deduct-csv').onchange = async function () {
if (!this.files.length) return;
try {
const rows = await parseSpreadsheet(this.files[0]);
// Only accept the debit-note template (same columns as the downloaded CSV)
// Strip the hidden BOM () that Excel/CSV adds and any stray spaces before comparing
const hdr = (rows[0] || []).map(h => String(h || '').replace(/^/, '').trim().toLowerCase());
const isTemplate = hdr[0] && hdr[0].startsWith('debit note no') && hdr[1] && hdr[1].startsWith('date') && hdr[2] && hdr[2].startsWith('worker') && hdr[6] && hdr[6].includes('replacement');
if (!isTemplate) {
alert('This is not the Debit Notes import file, so nothing was imported.\n\nUse the file from "⬇ Download Template (CSV)" and fill it in. Reports exported from SiteDesk (such as the Expat Advances statement) cannot be imported here.');
return;
}
if (!DB.debitNotes) DB.debitNotes = [];
let added = 0, skipped = 0;
const existingNos = new Set(DB.debitNotes.map(d => d.no));
for (let i = 1; i < rows.length; i++) {
const c = rows[i];
if (!(c[0] || '').trim() || (c[0] || '').trim() === 'Debit Note No*') continue;
const no = (c[0] || '').trim();
if (existingNos.has(no)) continue;
const qty = +(c[4] || 1); const replPer = +(c[6] || 0);
const dateOk = /^\d{4}-\d{2}-\d{2}$/.test((c[1] || '').trim());
if (!dateOk || !(replPer > 0) || !(c[2] || '').trim()) { skipped++; continue; }
DB.debitNotes.push({
id: uid('dn'), no, date: (c[1] || todayISO()).trim(),
person: (c[2] || '').trim(), item: (c[3] || '').trim(),
qty, unit: (c[5] || 'pcs').trim(), replValue: replPer,
value: replPer * qty, deducted: 0, status: (c[7] || 'Pending').trim(), reason: ''
});
existingNos.add(no); added++;
}
save(); toast(added + ' debit notes imported' + (skipped ? ` · ${skipped} row(s) skipped (check date YYYY-MM-DD, amount, worker)` : '')); closeModal(); render();
} catch (err) { alert('Import Error: ' + err.message); }
};
}
function formVehicle(v) {
const ed = !!v;
openModal(ed ? "Edit Vehicle" : "Add Vehicle",
field("Vehicle Name", "f-name", ed ? v.name : "") + `` + field("Type", "f-type", ed ? v.type : "") + field("Reg / ID", "f-reg", ed ? v.reg : "") + `
` +
`` + field("Meter (hrs/km)", "f-meter", ed ? v.meter : "") + sel("Status", "f-status", ["Running", "Under Repair", "Suspended"], ed ? v.status : "Running") + `
` +
`` + field("Last Service", "f-last", ed ? v.lastService : todayISO(), "date") + field("Next Service Due", "f-next", ed ? v.nextService : todayISO(), "date") + `
` +
`Document Expiry Dates (alerts at 30 days)
` +
`` + field("Insurance Expiry", "f-ins", ed ? v.insExpiry : "", "date") + field("Registration Expiry", "f-reg2", ed ? v.regExpiry : "", "date") + `
` +
`` + field("Inspection Expiry", "f-insp", ed ? v.inspExpiry : "", "date") + field("Driver Name", "f-driver", ed ? v.driver : "", "text") + `
` +
`🚗 Driver Documents
` +
``,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: ed ? "Save" : "Add", cls: "", fn: async () => {
const n = val("f-name"); if (!n) return alertField();
const extras = { name: n, type: val("f-type"), reg: val("f-reg"), meter: val("f-meter"), status: val("f-status"), lastService: val("f-last"), nextService: val("f-next"), insExpiry: val("f-ins") || null, regExpiry: val("f-reg2") || null, inspExpiry: val("f-insp") || null, driver: val("f-driver") };
const dlInp = document.getElementById("fx-dl");
const addrInp = document.getElementById("fx-addr");
if (dlInp && dlInp.files.length) { const fs = await readFiles(dlInp); if (fs.length) extras.driverLicenceDoc = fs[0]; }
else if (ed && v.driverLicenceDoc) extras.driverLicenceDoc = v.driverLicenceDoc;
if (addrInp && addrInp.files.length) { const fs = await readFiles(addrInp); if (fs.length) extras.driverAddrDoc = fs[0]; }
else if (ed && v.driverAddrDoc) extras.driverAddrDoc = v.driverAddrDoc;
if (ed) { Object.assign(v, extras); }
else { DB.vehicles.push({ id: uid("v"), ...extras, logs: [], trips: [], fuel: [] }); }
save(); toast("Saved"); closeModal(); render();
}
}]);
}
function formVLog(vid) {
const v = DB.vehicles.find(x => x.id === vid);
openModal("Add Service Log — " + v.name,
`` + field("Date", "f-date", todayISO(), "date") + field("Cost (" + CUR + ")", "f-cost", "", "number") + `
` +
`` + field("Opening Meter (km/hrs)", "f-kmo", "") + field("Closing Meter (km/hrs)", "f-kmc", "") + `
` +
field("Driver", "f-driver", v.driver || "") +
`Work Done
` +
`Parts Changed (optional)
` +
``+
`Bill / Additional Photos
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save Service", cls: "", fn: async () => {
const w = val("f-work"); if (!w) return alertField();
const inp = document.getElementById("fx"); const photos = inp.files.length ? await readFiles(inp) : [];
const oldInp = document.getElementById("fx-old"); const oldParts = oldInp.files.length ? await readFiles(oldInp) : [];
const newInp = document.getElementById("fx-new"); const newParts = newInp.files.length ? await readFiles(newInp) : [];
v.logs = (v.logs || []);
v.logs.unshift({ id: uid("vl"), date: val("f-date"), km_open: val("f-kmo"), km_close: val("f-kmc"), driver: val("f-driver"), work: w, cost: +val("f-cost"), photos, oldPartPhotos: oldParts, newPartPhotos: newParts });
v.lastService = val("f-date");
save();
sendWhatsAppNotification('vehicle_service', {
recipient: val("f-driver") || v.driver || 'Fleet Manager',
phone: '+244 923 000 006',
message: `🚜 [VEHICLE SERVICE LOGGED]\nVehicle: ${v.name} (${v.reg || v.id})\nDate: ${val("f-date")}\nWork Done: ${w}\nCost: ${money(+val("f-cost"))}\nDriver: ${val("f-driver") || v.driver || '—'}`
});
logAct(`Service logged: ${v.name}`); toast("Service added"); closeModal(); render();
}
}]);
const inp = document.getElementById("fx"); const tmp = [];
inp.onchange = async () => { const fs = await readFiles(inp); tmp.push(...fs); document.getElementById("attPrev").innerHTML = tmp.map((f, i) => attEditable(f, i)).join(""); };
}
function formVTrip(vid) {
const v = DB.vehicles.find(x => x.id === vid);
const trips = v.trips || [];
const nextNo = trips.length + 1;
openModal("Add Trip Log — " + v.name,
`` + field("S.No", "f-sno", String(nextNo), "number") + field("Date", "f-date", todayISO(), "date") + `
` +
`` + field("Opening KM / Hours", "f-kmo", "") + field("Closing KM / Hours", "f-kmc", "") + `
` +
field("Destination / Place of Travel", "f-dest") +
`` + field("Driver Name", "f-driver", v.driver || "") + field("Approved By", "f-appr") + `
` +
field("Security In/Out — Damage Check (leave blank if OK)", "f-damage"),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save Trip", cls: "", fn: () => {
const dest = val("f-dest"); if (!dest) return alertField();
(v.trips = v.trips || []).unshift({ id: uid("vt"), no: val("f-sno"), date: val("f-date"), openKm: val("f-kmo"), closeKm: val("f-kmc"), destination: dest, driver: val("f-driver"), approvedBy: val("f-appr"), damage: val("f-damage") });
save(); logAct(`Trip logged: ${v.name} → ${dest}`); toast("Trip logged"); closeModal(); vehTab = "trips"; render();
}
}]);
}
function formVFuel(vid) {
const v = DB.vehicles.find(x => x.id === vid);
openModal("Add Fuel Entry — " + v.name,
`` + field("Date", "f-date", todayISO(), "date") + field("Litres", "f-lit", "", "number") + `
` +
`` + field("Cost (" + CUR + ")", "f-cost", "", "number") + field("KM at Fill", "f-km", "", "number") + `
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save", cls: "", fn: () => {
const lit = +val("f-lit"); if (!lit) return alertField();
(v.fuel = v.fuel || []).unshift({ id: uid("vf"), date: val("f-date"), litres: lit, cost: +val("f-cost") || 0, km: val("f-km") });
save(); logAct(`Fuel: ${v.name} — ${lit}L`); toast("Fuel entry saved"); closeModal(); vehTab = "fuel"; render();
}
}]);
}
// Double-entry check: returns existing accounts entries that look like the one being saved
function findAccountDuplicates(e) {
// Compare ignoring case, spacing and punctuation ("BETAMIX LIMITADA –" == "BETAMIX LIMITADA")
const norm = s => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '');
const inv = norm(e.inv), party = norm(e.party), ref = norm(e.payRef);
const out = [];
(DB.accounts || []).forEach(a => {
const reasons = [];
const aParty = norm(a.party);
const sameParty = party && aParty && (aParty === party || aParty.includes(party) || party.includes(aParty));
const sameAmt = Math.abs((+a.amount || 0) - (+e.amount || 0)) < 0.01;
if (inv && norm(a.inv) === inv) reasons.push('same invoice no.');
if (ref && norm(a.payRef) === ref) reasons.push('same payment reference');
if (sameParty && sameAmt) {
reasons.push(a.date === e.date ? 'same party, amount & date' : 'same party & amount (different date)');
}
if (reasons.length) out.push({ a, reasons });
});
return out.slice(0, 5);
}
function formAcct() {
let files = [], payFiles = [];
ensureAcctHeads();
const headOpts = activeHeads().map(x => ({ v: x.name, l: (x.code ? x.code + ' — ' : '') + x.name }));
openModal("New Entry",
`` + field("Date", "f-date", todayISO(), "date") + field("Invoice No", "f-inv") + `
` +
`` + sel("Document Type", "f-doctype", ["Factura", "Factura-Recibo", "Factura Global", "Nota de Crédito", "Recibo"]) + field("Supplier NIF", "f-nif") + `
` +
field("Company / Person", "f-party") +
`` + sel("Account Head", "f-acch", headOpts) + field("Description", "f-head") + `
` +
`` + field("Base Amount (" + CUR + ")", "f-amt", "", "number") + sel("VAT Rate", "f-ivarate", [{ v: "14", l: "14%" }, { v: "7", l: "7%" }, { v: "5", l: "5%" }, { v: "0", l: "Isento / 0%" }]) + `
` +
`` + field("VAT (" + CUR + ") — auto", "f-vat", "0", "number") + field("Withholding IRT (" + CUR + ")", "f-ret", "0", "number") + `
` +
`` + sel("Mode of Payment", "f-mode", ["BAI Bank", "KEVE Bank", "Cash", "Multicaixa", "Other"]) + field("Payment / Transaction Ref", "f-payref") + `
` +
sel("Purchase Type", "f-ptype", ["Local", "Import", "Other"]) +
field("Comments", "f-com") +
`` +
``,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save Entry", cls: "", fn: () => {
const party = val("f-party"), head = val("f-head");
if (!party && !head) return alertField();
if (!files.length) return toast("Attach the invoice / supporting documents first");
const amt = +val("f-amt") || 0, vat = +val("f-vat") || 0, ret = +val("f-ret") || 0;
// Warn about possible double entry before saving (OK = save anyway, Cancel = review)
const dupes = findAccountDuplicates({ inv: val("f-inv"), party, date: val("f-date"), amount: amt, payRef: val("f-payref") });
if (dupes.length) {
const lines = dupes.map(d => `• ${d.a.inv || 'no inv'} · ${d.a.party || d.a.head || ''} · ${money(d.a.amount)} · ${d.a.date || ''} — ${d.reasons.join(', ')}`).join('\n');
if (!confirm(`⚠ POSSIBLE DOUBLE ENTRY\nThis looks like an entry already in Accounts:\n\n${lines}\n\nOK = save anyway · Cancel = review first`)) return;
}
DB.accounts.unshift({
id: uid("ac"), date: val("f-date"), nif: val("f-nif"), inv: val("f-inv"), party, head,
acctHead: val("f-acch"), docType: val("f-doctype"), ivaRate: val("f-ivarate"),
amount: amt, vat, ret, mode: val("f-mode"), payRef: val("f-payref"),
purchaseType: val("f-ptype"), comments: val("f-com"), type: "Payment", status: "Paid", files, payFiles
});
save();
sendWhatsAppNotification('account_invoice', {
recipient: party || head || 'Finance & Owners',
phone: '+244 923 000 007',
message: `💳 [INVOICE REGISTERED]\nInvoice #${val("f-inv") || 'N/A'} for ${party || 'Vendor'}\nDescription: ${head}\nAmount: ${money(amt)} | Mode: ${val("f-mode") || 'N/A'}\nStatus: Paid`
});
logAct(`Invoice ${val("f-inv") || ''} — ${party || head}`); toast("Entry saved"); closeModal(); render();
}
}]);
const autoIva = () => {
const amt = +val("f-amt") || 0, r = +val("f-ivarate") || 0;
const vEl = document.getElementById("f-vat");
if (vEl) vEl.value = (amt * r / 100).toFixed(2);
};
const amtEl = document.getElementById("f-amt"), rateEl = document.getElementById("f-ivarate");
if (amtEl) amtEl.oninput = autoIva;
if (rateEl) rateEl.onchange = autoIva;
function refreshPrev() {
const box = document.getElementById("attPrev"); box.innerHTML = files.map((f, i) => attEditable(f, i)).join("");
box.querySelectorAll("[data-rm]").forEach(x => x.onclick = () => { files.splice(+x.dataset.rm, 1); refreshPrev(); });
}
function refreshPay() {
const box = document.getElementById("attPrevPay"); box.innerHTML = payFiles.map((f, i) => attEditable(f, i)).join("");
box.querySelectorAll("[data-rm]").forEach(x => x.onclick = () => { payFiles.splice(+x.dataset.rm, 1); refreshPay(); });
}
const inp = document.getElementById("fx");
inp.onchange = async () => { const fs = await readFiles(inp); files.push(...fs); refreshPrev(); };
const inpPay = document.getElementById("fpay");
if (inpPay) inpPay.onchange = async () => { const fs = await readFiles(inpPay); payFiles.push(...fs); refreshPay(); };
}
function formUser(edit) {
const isEdit = !!edit, u = edit || {}, curMods = u.modules || [], curDepts = u.depts || [], curSubs = u.subPerms || [];
const SUB_PERMS = {
progress: [{ key: 'progress.update', label: 'Update Progress (%, Status, Photos)' }],
procurement: [
{ key: 'procurement.india_vendors', label: '🇮🇳 India Vendors (see & add India-only vendors)' },
{ key: 'procurement.hold', label: '⏸ Put on hold & resume indents (owner reason required)' }
],
hr: [{ key: 'hr.expat_salary', label: 'View Expatriate Salaries' }]
};
const modBoxes = GRANTABLE.map(k => {
const subs = SUB_PERMS[k] || [];
const subHTML = subs.map(sp =>
`` +
` ` +
`└ ${sp.label} `
).join('');
return `` +
`
${MODLABELS[k]}` +
(subHTML ? `
${subHTML}
` : '') +
`
`;
}).join("");
const deptBoxes = DEPTS.map(d => ` ${d} `).join("");
const gmailField = field("Gmail (for 'Sign in with Google' — optional)", "f-email", isEdit ? escAttr(u.email || '') : '', "email");
const locationField = `` + sel("Login Location (HR edits restricted)", "f-loc", ["—", "India", "Angola", "Other"], isEdit ? (u.login_location || "—") : "—") + field("WhatsApp number (alerts, with country code)", "f-phone", isEdit ? escAttr(u.phone || '') : '') + `
`;
const identity = isEdit
? `` + field("Full Name", "f-name", escAttr(u.name)) + field("Username", "f-user", escAttr(u.user)) + `
` + gmailField + locationField
: field("Full Name", "f-name") + `` + field("Username", "f-user") + field("Password", "f-pass") + `
` + gmailField + locationField;
const levelSel = `Access Level
Owner — sees everything, manages logins
Custom — choose exactly what they access
Developer — Developer Console only
⚙️ Developer accounts have access to the Developer Console and To-Do List modules. They can assign and track daily tasks, create announcements, manage branding, feature flags, and view audit logs.
`;
const body = identity + levelSel +
`
Modules this login can open
${modBoxes}
💡 Sub-permissions appear below each module after ticking it.
Departments they can see — leave all unticked = all departments
${deptBoxes}
`;
openModal(isEdit ? "Edit User & Access" : "Add Login", body,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: isEdit ? "Save User & Access" : "Create Login", cls: "", fn: async () => {
const lvl = document.getElementById("f-level").value;
const isOwn = lvl === "Owner"; const isDeveloper = lvl === "Developer";
const mods = [...document.querySelectorAll(".m-chk:checked")].map(x => x.value);
const depts = [...document.querySelectorAll(".d-chk:checked")].map(x => x.value);
const subPerms = [...document.querySelectorAll(".sp-chk:checked")].map(x => x.value);
if (!isOwn && !isDeveloper && !mods.length) return toast("Tick at least one module, or choose Owner / Developer");
const role = isOwn ? "admin" : isDeveloper ? "developer" : "custom";
if (isEdit) {
const newName = val("f-name").trim();
const newUsername = val("f-user").trim();
if (!newName || !newUsername) return alertField();
const oldU = (u.username || u.user || '').toLowerCase();
if (newUsername.toLowerCase() !== oldU && DB.users.some(x => x.id !== u.id && (x.username || x.user || '').toLowerCase() === newUsername.toLowerCase())) {
return toast("Username already taken by another user");
}
if (USER && (USER.id === u.id || (USER.user && USER.user.toLowerCase() === oldU) || (USER.username && USER.username.toLowerCase() === oldU))) {
USER.user = newUsername;
USER.username = newUsername;
USER.name = newName;
USER.displayName = newName;
try { sessionStorage.setItem('sd_user', JSON.stringify(USER)); } catch (ex) { }
}
// Direct save to MongoDB via dedicated endpoint (bypasses unreliable generic dbSync)
const userId = u._id || u.id;
const newEmail = (val("f-email") || '').trim().toLowerCase();
const newLocation = val("f-loc") === "—" ? "" : val("f-loc");
if (newEmail && DB.users.some(x => x.id !== u.id && (x.email || '').toLowerCase() === newEmail)) return toast("That Gmail is already used by another user");
const newPhone = (val("f-phone") || '').trim();
const payload = { role, modules: (isOwn || isDeveloper) ? [] : mods, depts: (isOwn || isDeveloper) ? [] : depts, subPerms: (isOwn || isDeveloper) ? [] : subPerms, name: newName, username: newUsername, email: newEmail, login_location: newLocation, phone: newPhone };
try {
const result = await api('POST', `/api/users/${userId}/access`, payload);
if (!result.ok) throw new Error(result.error || 'Save failed');
} catch (err) {
return toast('❌ Save failed: ' + err.message);
}
// Update local in-memory state
u.name = newName; u.displayName = newName; u.email = newEmail;
u.user = newUsername; u.username = newUsername;
u.login_location = newLocation; u.phone = newPhone;
u.role = role; u.modules = (isOwn || isDeveloper) ? [] : mods; u.depts = (isOwn || isDeveloper) ? [] : depts; u.subPerms = (isOwn || isDeveloper) ? [] : subPerms;
lastDB = clone(DB); // sync lastDB so save() won't re-send this change
logAct(`User updated: ${u.name} (${u.user})`);
toast("✅ User & Access saved to database");
closeModal(); render(); return;
}
const n = val("f-name"), us = val("f-user"); if (!n || !us) return alertField();
const usClean = us.trim().toLowerCase();
if (DB.users.some(x => (x.user || x.username || '').toLowerCase() === usClean)) return toast("Username already exists");
const defaultP = val("f-pass") || (usClean + "123");
const newEmail = (val("f-email") || '').trim().toLowerCase();
const newLocation = val("f-loc") === "—" ? "" : val("f-loc");
if (newEmail && DB.users.some(x => (x.email || '').toLowerCase() === newEmail)) return toast("That Gmail is already used by another user");
DB.users.push({ id: uid("u"), name: n, displayName: n, user: usClean, username: usClean, pass: defaultP, email: newEmail, login_location: newLocation, phone: (val("f-phone") || '').trim(), role, modules: (isOwn || isDeveloper) ? [] : mods, depts: (isOwn || isDeveloper) ? [] : depts, subPerms: (isOwn || isDeveloper) ? [] : subPerms });
save(); logAct(`Login created: ${us}`); toast("Login created"); closeModal(); render();
}
}]);
setTimeout(() => {
document.querySelectorAll('.m-chk').forEach(chk => {
chk.addEventListener('change', () => {
const modBlock = chk.closest('.mod-block');
if (modBlock) {
const subDiv = modBlock.querySelector('.sub-perms');
if (subDiv) subDiv.style.display = chk.checked ? 'block' : 'none';
if (!chk.checked) modBlock.querySelectorAll('.sp-chk').forEach(sp => sp.checked = false);
}
});
});
}, 50);
}
function resetPass(id) {
const u = DB.users.find(x => x.id === id || x._id === id);
if (!u) return toast("User not found");
openModal("Reset Password — " + (u.name || u.displayName || u.user),
`Type a temporary password and give it to the user. They will be asked to choose their own password at next sign-in. The old password stops working straight away.
` +
field("Temporary password (at least 6 characters)", "f-pass", ""),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Set temporary password", cls: "", fn: async () => {
const p = (val("f-pass") || "").trim();
if (p.length < 6) return toast("Use at least 6 characters", false);
try {
await api('POST', `/api/users/${encodeURIComponent(u._id || u.id)}/reset-password`, { newPassword: p });
logAct(`Password reset for ${u.user || u.name}; user must change it at next sign-in`);
toast("✅ Temporary password set. The user must change it at next sign-in.");
closeModal();
} catch (e) {
toast("Reset failed: " + (e.message || "try again"), false);
}
}
}]);
}
function formDebitManual() {
const workers = DB.employees.map(e => e.name);
openModal("Raise Debit Note (Manual)",
field("Date", "f-date", todayISO(), "date") +
sel("Worker / Debitor", "f-worker", workers) +
`
✈ Expatriate advance — enter in Kz, converted to INR
${field("Advance (Kz)", "f-kzadv", "", "number")}${field("Rate: INR per 1 Kz", "f-kzrate", "", "number")}
INR to debit from salary: —
` +
field("Item / Reason", "f-item") +
field("Amount (" + CUR + ")", "f-val", "", "number") +
field("Reason / Description", "f-reason"),
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Raise Debit Note", cls: "", fn: () => {
const workerName = val("f-worker");
let itemDesc = val("f-item");
const emp = DB.employees.find(x => x.name === workerName);
const isEx = !!emp && isExpatType(emp.staffType);
const kzAmt = isEx ? (+val("f-kzadv") || 0) : 0;
const kzRate = isEx ? (+val("f-kzrate") || 0) : 0;
let valAmt = +val("f-val");
if (isEx && kzAmt) {
if (!kzRate) return toast('Enter the INR rate per 1 Kz');
valAmt = +(kzAmt * kzRate).toFixed(2);
if (!itemDesc) itemDesc = 'Expat advance';
}
if (!workerName || !itemDesc || !valAmt) return alertField();
const dnNo = "DN-" + Math.floor(180 + Math.random() * 900);
if (emp) {
emp.salaryApproved = false;
DB.deduct = DB.deduct || [];
DB.deduct.unshift({
id: uid("de"),
staffId: emp.id,
staffName: emp.name,
amount: valAmt,
reason: `${dnNo}: ${itemDesc}`,
month: todayISO().slice(0, 7),
status: "Pending"
});
}
const dn = { raisedBy: USER.displayName || USER.username, id: uid("dn"), no: dnNo, issuesId: "", date: val("f-date"), person: workerName, personRef: emp ? emp.id : "", item: itemDesc, qty: 1, value: valAmt, reason: val("f-reason"), notes: val("f-reason"), status: "Pending",
...(kzAmt ? { kind: 'expat-advance', kzAmount: kzAmt, kzRate: kzRate, currency: 'INR' } : {}) };
DB.debitNotes.unshift(dn);
save();
logAct(`Debit Note ${dnNo} raised on ${workerName}`);
toast("Debit note raised & mapped to HR");
closeModal();
render();
printDebit(dn);
}
}]);
// Expatriate: amount comes from Kz × rate and is shown in INR
const wSel = document.getElementById('f-worker'), kzA = document.getElementById('f-kzadv'), kzR = document.getElementById('f-kzrate'), valEl = document.getElementById('f-val');
const refreshExpat = () => {
const emp = DB.employees.find(x => x.name === (wSel ? wSel.value : ''));
const isEx = !!emp && isExpatType(emp.staffType);
const box = document.getElementById('expat-adv'); if (box) box.style.display = isEx ? '' : 'none';
if (isEx && valEl) {
valEl.readOnly = true;
const inr = (+(kzA && kzA.value) || 0) * (+(kzR && kzR.value) || 0);
valEl.value = inr ? inr.toFixed(2) : '';
const pv = document.getElementById('f-inr-preview');
if (pv) pv.textContent = inr ? 'INR ' + inr.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—';
} else if (valEl) valEl.readOnly = false;
};
if (wSel) wSel.onchange = refreshExpat;
if (kzA) kzA.oninput = refreshExpat;
if (kzR) kzR.oninput = refreshExpat;
refreshExpat();
}
// Month-end statement of expatriate advances (Kz advance → INR debited from salary)
function expatAdvanceStatement() {
const mo = window._expatMonth || todayISO().slice(0, 7);
const rows = (DB.debitNotes || []).filter(d => d.kzAmount && (d.date || '').startsWith(mo)).sort((a, b) => String(b.date).localeCompare(String(a.date)));
const byWorker = {};
rows.forEach(d => { const k = d.person || '—'; byWorker[k] = byWorker[k] || { kz: 0, inr: 0, n: 0 }; byWorker[k].kz += +d.kzAmount || 0; byWorker[k].inr += +d.value || 0; byWorker[k].n++; });
const totalInr = rows.reduce((s, d) => s + (+d.value || 0), 0);
const fmt = n => Number(n || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const body = `
Date Worker Advance (Kz) Rate (INR per Kz) Debited (INR) Status Debit No.
${rows.map(d => `${d.date || '—'} ${d.person || '—'} ${fmt(d.kzAmount)} Kz ${d.kzRate} INR ${fmt(d.value)} ${d.status === 'Deducted' ? '✔ Deducted' : d.status === 'Approved' ? '✔ Approved — deducts in payroll' : '⏳ Awaiting owner approval'} ${d.no} `).join('') || emptyRow(7, 'No expat advances for this month')}
Month-end summary: ${rows.length} advance(s) · total debited
INR ${fmt(totalInr)}
${Object.keys(byWorker).length ? '
' + Object.entries(byWorker).map(([w, v]) => `${w}: ${fmt(v.kz)} Kz → INR ${fmt(v.inr)} (${v.n} advance${v.n > 1 ? 's' : ''})`).join(' ') + '
' : ''}
`;
openModal('✈ Expat Advances — Month-end Statement', body, [{ label: 'Close', cls: 'secondary', fn: closeModal }]);
}
function exportExpatAdvanceXLSX(mo) {
const rows = (DB.debitNotes || []).filter(d => d.kzAmount && (d.date || '').startsWith(mo));
const data = [['Date', 'Debit No.', 'Worker', 'Advance (Kz)', 'Rate (INR per Kz)', 'Debited (INR)', 'Status', 'Deducted On']];
rows.forEach(d => data.push([d.date || '', d.no || '', d.person || '', +d.kzAmount || 0, +d.kzRate || 0, +d.value || 0, d.status || '', d.deductedOn || '']));
data.push([]);
data.push(['', '', 'Total', '', '', rows.reduce((s, d) => s + (+d.value || 0), 0)]);
const ws = XLSX.utils.aoa_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Expat Advances');
XLSX.writeFile(wb, `Expat_Advances_${mo}.xlsx`);
}
// Pinned buttons implementation for modals (replaces default modal-foot if specified)
function openModal(title, body, buttons) {
document.getElementById("modalMount").innerHTML = ``;
document.getElementById("modalX").onclick = closeModal;
document.getElementById("modalBg").onclick = e => { if (e.target.id === "modalBg") closeModal(); };
buttons.forEach((b, i) => document.querySelector(`[data-mb="${i}"]`).onclick = b.fn);
applyLang();
}
// HR Add staff with Types
function formEmp() {
openModal("Add Staff",
field("Name", "f-name") +
sel("Staff Category / Regime", "f-type", ["Service Contract", "Permanent Employee", "Expatriate", "Contractor"]) +
`` + field("Role", "f-role") + sel("Department", "f-dept", DEPTS) + `
` +
field("Joined", "f-join", todayISO(), "date") +
contactSection(null) +
(can.seeSalary(USER) ? `` + field("Salary", "f-sal", "", "number") + curSelect(null) + `
` : "") +
bankSection(null) +
`Employee Identification (ID Card / Passport)
` +
`` + sel("ID Document Type", "f-idtype", ["Bilhete de Identidade (Angolan ID)", "Passport (Expat)", "Cartão de Residência", "Driving Licence", "Other ID"]) + field("ID / Passport Number", "f-idno") + `
` +
`` + field("ID Issue Date", "f-idissued", "", "date") + field("ID Expiry Date", "f-idexpiry", "", "date") + `
` +
`Upload ID Scan (PDF/Image)
` +
`Other Document Expiry Dates (alerts at 45 days)
` +
`` + field("Work Permit Expiry", "f-wp", "", "date") + field("Visa Expiry", "f-visa", "", "date") + `
` +
`` + field("Residência Expiry", "f-res", "", "date") + field("Passport Expiry", "f-pass", "", "date") + `
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save", cls: "", fn: async () => {
const n = val("f-name"); if (!n) return alertField();
const st = val("f-type");
const salVal = (can.seeSalaryForEmp(USER, { staffType: st }) ? +val("f-sal") : 0);
const fileInp = document.getElementById("f-idfile");
const idFiles = fileInp && fileInp.files.length ? await readFiles(fileInp) : [];
const idExp = val("f-idexpiry") || null;
DB.employees.push({
id: uid("e"), empCode: nextEmpCode(st), name: n, staffType: st, role: val("f-role"), dept: val("f-dept"),
...readContactFields({}), joined: val("f-join"), salary: salVal,
salaryCurrency: document.getElementById("f-cur") ? val("f-cur") : (isExpatType(st) ? "INR" : "Kz"),
idDocType: val("f-idtype"), idDocNo: val("f-idno"), idDocIssueDate: val("f-idissued") || null,
idDocExpiryDate: idExp, idDocFiles: idFiles,
bank_details: readBankFields(st),
offerLetter: null, acceptedContract: null, salaryApproved: false,
workPermitExpiry: val("f-wp") || null, visaExpiry: val("f-visa") || null,
residenciaExpiry: val("f-res") || null, passportExpiry: val("f-pass") || idExp
});
hrAlert('add', DB.employees[DB.employees.length - 1], 'New ' + st);
save(); toast("Staff added"); closeModal(); render();
}
}]);
wireBankToggle(); wireCurDefault();
}
function timeInOut(empId) {
const d = todayISO();
const cur = (DB.attendance[d] || {})[empId] || {};
openModal("Time In / Out",
sel("Status", "f-status", ["Present", "Absent"], cur.status === 'absent' ? 'Absent' : 'Present') +
`` +
field("Time In", "f-in", cur.in || "09:00", "time") +
field("Time Out", "f-out", cur.out || "", "time") +
`
` +
`` +
field("Overtime (hrs)", "f-ot", cur.ot || 0, "number") +
`
💡 OT auto-fills if work hours > 9h (standard day)
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save", cls: "", fn: () => {
const status = val("f-status").toLowerCase();
setAtt(empId, {
status,
in: status === 'present' ? val("f-in") : "",
out: status === 'present' ? val("f-out") : "",
ot: status === 'present' ? +val("f-ot") : 0
});
toast("Attendance saved");
closeModal();
}
}]);
// Add dynamic toggle for Present/Absent
const selEl = document.getElementById("f-status");
selEl.onchange = () => {
const isAbsent = selEl.value === 'Absent';
document.getElementById("timeFields").style.display = isAbsent ? 'none' : 'grid';
document.getElementById("otField").style.display = isAbsent ? 'none' : 'block';
};
// Auto-calculate OT when Time Out changes
const outEl = document.getElementById("f-out");
const inEl = document.getElementById("f-in");
if (outEl && inEl) {
outEl.oninput = () => {
const tIn = inEl.value, tOut = outEl.value;
if (!tIn || !tOut) return;
const [ih, im] = tIn.split(':').map(Number);
const [oh, om] = tOut.split(':').map(Number);
const workedMins = (oh * 60 + om) - (ih * 60 + im);
if (workedMins > 0) {
const workedHrs = workedMins / 60;
const ot = Math.max(0, +(workedHrs - 9).toFixed(2));
const otEl = document.getElementById("f-ot");
if (otEl) otEl.value = ot;
}
};
}
}
// ── Detailed attendance export: per day Time In / Out / Hours / OT, totals per person ──
function attWorkedHrs(att) {
if (!att || !att.in || !att.out) return 0;
const [ih, im] = String(att.in).split(':').map(Number), [oh, om] = String(att.out).split(':').map(Number);
if ([ih, im, oh, om].some(isNaN)) return 0;
let mins = (oh * 60 + om) - (ih * 60 + im);
if (mins < 0) mins += 24 * 60; // night shift crossing midnight
return +(mins / 60).toFixed(2);
}
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function exportAttendanceDetailedXLSX(emps, mVal, sDay, eDay, groupCat, targetEmp) {
if (typeof XLSX === 'undefined') return toast('Excel library not loaded — please refresh', false);
const [year, month] = mVal.split('-').map(Number);
const wb = XLSX.utils.book_new();
const catOf = e => (typeof getWorkerCategoryName === 'function' ? getWorkerCategoryName(e) : (e.staffType || 'Permanent Employee'));
const sorted = emps.slice().sort((a, b) => (groupCat ? catOf(a).localeCompare(catOf(b)) : 0) || (a.name || '').localeCompare(b.name || ''));
// Per-employee day rows + totals
const perEmp = sorted.map(emp => {
const rows = []; let pres = 0, abs = 0, unmarked = 0, hrs = 0, ot = 0;
for (let d = sDay; d <= eDay; d++) {
const dateStr = `${mVal}-${String(d).padStart(2, '0')}`;
const att = (DB.attendance[dateStr] || {})[emp.id];
const st = getAttStatusStr(att);
const dow = DOW[new Date(year, month - 1, d).getDay()];
if (st === 'present') {
const h = attWorkedHrs(att), o = +(att && att.ot) || 0;
pres++; hrs += h; ot += o;
rows.push([ivaDateDMY(dateStr), dow, 'Present', att.in || '', att.out || '', h || '', o || '', (att && att.notes) || '']);
} else if (st === 'absent') { abs++; rows.push([ivaDateDMY(dateStr), dow, 'Absent', '', '', '', '', (att && att.notes) || '']); }
else { unmarked++; rows.push([ivaDateDMY(dateStr), dow, '-', '', '', '', '', '']); }
}
return { emp, rows, pres, abs, unmarked, hrs: +hrs.toFixed(2), ot: +ot.toFixed(2) };
});
// Sheet 1: Summary (one line per person)
if (!targetEmp) {
const sum = [[`ATTENDANCE SUMMARY — ${mVal} (days ${sDay} to ${eDay})`], [],
['Employee ID', 'Name', 'Department', 'Regime', 'Days Present', 'Days Absent', 'Unmarked', 'Total Hours', 'Total OT (hrs)', 'Total Hours + OT']];
let lastCat = null;
perEmp.forEach(p => {
if (groupCat && catOf(p.emp) !== lastCat) { lastCat = catOf(p.emp); sum.push([`=== ${lastCat.toUpperCase()} ===`]); }
sum.push([empCodeOf(p.emp), p.emp.name, p.emp.dept || '', catOf(p.emp), p.pres, p.abs, p.unmarked, p.hrs, p.ot, +(p.hrs + p.ot).toFixed(2)]);
});
sum.push([]);
const T = k => +perEmp.reduce((s, p) => s + p[k], 0).toFixed(2);
sum.push(['', 'GRAND TOTAL', '', `${perEmp.length} workers`, T('pres'), T('abs'), T('unmarked'), T('hrs'), T('ot'), +(T('hrs') + T('ot')).toFixed(2)]);
const ws = XLSX.utils.aoa_to_sheet(sum);
ws['!cols'] = [12, 26, 16, 20, 12, 12, 10, 12, 14, 16].map(w => ({ wch: w }));
ws['!freeze'] = { xSplit: 0, ySplit: 3 };
XLSX.utils.book_append_sheet(wb, ws, 'Summary');
}
// Sheet 2: Daily detail — one block per person, stacked
const det = [[`DAILY ATTENDANCE — ${mVal} (days ${sDay} to ${eDay})`], ['Hours = Time Out − Time In. OT as recorded at clock-out. "-" = not marked.'], []];
let lastCat = null;
perEmp.forEach(p => {
if (groupCat && catOf(p.emp) !== lastCat) { lastCat = catOf(p.emp); det.push([`=== ${lastCat.toUpperCase()} ===`], []); }
det.push([`${p.emp.name}`, `ID: ${empCodeOf(p.emp)}`, `Dept: ${p.emp.dept || '-'}`, `Regime: ${catOf(p.emp)}`]);
det.push(['Date', 'Day', 'Status', 'Time In', 'Time Out', 'Hours', 'OT (hrs)', 'Notes']);
p.rows.forEach(r => det.push(r));
det.push(['TOTAL', '', `${p.pres} present / ${p.abs} absent`, '', '', p.hrs, p.ot, `Total hours incl. OT: ${+(p.hrs + p.ot).toFixed(2)}`]);
det.push([], []);
});
const wsD = XLSX.utils.aoa_to_sheet(det);
wsD['!cols'] = [13, 6, 26, 10, 10, 9, 10, 34].map(w => ({ wch: w }));
XLSX.utils.book_append_sheet(wb, wsD, targetEmp ? 'Attendance' : 'Daily Detail');
const personTag = targetEmp ? `_${(targetEmp.name || '').replace(/[^a-zA-Z0-9]/g, '_')}` : '';
XLSX.writeFile(wb, `Attendance${personTag}_${mVal}_Days_${sDay}_to_${eDay}.xlsx`);
toast(`Attendance Excel downloaded — ${emps.length} worker${emps.length > 1 ? 's' : ''}, days ${sDay}–${eDay}`);
}
function formExportAttendance(selectedEmpId = null) {
const currentMonth = todayISO().slice(0, 7); // e.g. "2026-07"
const [yr, moN] = currentMonth.split('-').map(Number);
const defaultDaysInMonth = new Date(yr, moN, 0).getDate();
const targetEmp = selectedEmpId ? (DB.employees || []).find(e => e.id === selectedEmpId) : null;
const modalTitle = targetEmp ? `Export Attendance — ${targetEmp.name}` : "Export Monthly Attendance Log";
openModal(modalTitle,
`Select Month
${field('Start Day (e.g. 1)', 'f-att-start-day', '1', 'number')}
${field('End Day (e.g. 5, 31)', 'f-att-end-day', String(defaultDaysInMonth), 'number')}
Format
Detailed (Excel) — every day with Time In, Time Out, Hours, OT + totals at the end
Summary grid (CSV) — P / A per day only
${!targetEmp ? `
📁 Group workers into category headings (Service Contract, Expatriate, Permanent Employee)
` : ''}`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Export", cls: "", fn: () => {
const mVal = val("f-month");
if (!mVal) return alertField();
const groupCat = document.getElementById("f-att-group-cat") ? document.getElementById("f-att-group-cat").checked : false;
const fmtEl = document.querySelector('input[name="f-att-fmt"]:checked');
const fmt = fmtEl ? fmtEl.value : 'detailed';
const [year, month] = mVal.split("-").map(Number);
const daysInMonth = new Date(year, month, 0).getDate();
let sDay = parseInt(val("f-att-start-day")) || 1;
let eDay = parseInt(val("f-att-end-day")) || daysInMonth;
if (sDay < 1) sDay = 1;
if (eDay > daysInMonth) eDay = daysInMonth;
if (sDay > eDay) { sDay = 1; eDay = daysInMonth; }
let empsToExport = targetEmp ? [targetEmp] : (DB.employees || []).filter(e => e.status !== 'Released');
if (!empsToExport.length) return toast("No employees match selected filter.");
if (fmt === 'detailed') {
exportAttendanceDetailedXLSX(empsToExport, mVal, sDay, eDay, groupCat && !targetEmp, targetEmp);
closeModal();
return;
}
const csvRows = [];
csvRows.push([`=== ATTENDANCE LOG (${mVal} · DAYS ${sDay} TO ${eDay}) ===`]);
csvRows.push([`=== LEGEND: P = Present, A = Absent, - = Unmarked / Not Logged ===`]);
if (groupCat && !targetEmp) {
const categoryGroups = {};
empsToExport.forEach(emp => {
const cat = getWorkerCategoryName(emp);
if (!categoryGroups[cat]) categoryGroups[cat] = [];
categoryGroups[cat].push(emp);
});
Object.keys(categoryGroups).sort().forEach(catName => {
const emps = categoryGroups[catName];
csvRows.push([]);
csvRows.push([`=== WORKER CATEGORY: ${catName.toUpperCase()} (${emps.length} WORKERS) ===`]);
const headers = ["Employee ID", "Name", "Department", "Regime"];
for (let d = sDay; d <= eDay; d++) headers.push(String(d));
headers.push("Total Present", "Total Absent");
csvRows.push(headers);
emps.forEach(emp => {
const row = [empCodeOf(emp), emp.name, emp.dept, catName];
let presCount = 0, absCount = 0;
for (let d = sDay; d <= eDay; d++) {
const dayStr = String(d).padStart(2, '0');
const dateStr = `${mVal}-${dayStr}`;
const att = (DB.attendance[dateStr] || {})[emp.id];
const st = getAttStatusStr(att);
if (st === 'present') { row.push("P"); presCount++; }
else if (st === 'absent') { row.push("A"); absCount++; }
else { row.push("-"); }
}
row.push(presCount, absCount);
csvRows.push(row);
});
});
} else {
const headers = ["Employee ID", "Name", "Department", "Regime"];
for (let d = sDay; d <= eDay; d++) headers.push(String(d));
headers.push("Total Present", "Total Absent");
csvRows.push(headers);
empsToExport.forEach(emp => {
const row = [empCodeOf(emp), emp.name, emp.dept, emp.staffType || 'Permanent Employee'];
let presCount = 0, absCount = 0;
for (let d = sDay; d <= eDay; d++) {
const dayStr = String(d).padStart(2, '0');
const dateStr = `${mVal}-${dayStr}`;
const att = (DB.attendance[dateStr] || {})[emp.id];
const st = getAttStatusStr(att);
if (st === 'present') { row.push("P"); presCount++; }
else if (st === 'absent') { row.push("A"); absCount++; }
else { row.push("-"); }
}
row.push(presCount, absCount);
csvRows.push(row);
});
}
const personTag = targetEmp ? `_${(targetEmp.name || '').replace(/[^a-zA-Z0-9]/g, '_')}` : '';
downloadCSV(csvRows, `Attendance${personTag}_${mVal}_Days_${sDay}_to_${eDay}.csv`);
toast(`Attendance CSV downloaded — Days ${sDay} to ${eDay} (${empsToExport.length} worker${empsToExport.length > 1 ? 's' : ''})`);
closeModal();
}
}]);
}
// ── Local Storage & Legacy Fallbacks ──────────────────────────────────────────
function csvEsc(v) { v = (v == null ? '' : String(v)); return /[",\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; }
function downloadCSVTemplate() {
const headers = ["Item name / description", "Quantity", "Unit of measure", "Area of usage", "Remarks"];
const example = ["OPC 53 Cement", "300", "Bags", "Furnace bay foundation", "Deliver to site store"];
const csv = headers.map(csvEsc).join(",") + "\n" + example.map(csvEsc).join(",") + "\n";
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "Indent_Bulk_Template.csv"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast("Template downloaded");
}
// ── Shared Spreadsheet Parser (CSV + XLSX) ─────────────────────────────────────
// Returns Promise — same shape regardless of format
function parseSpreadsheet(file) {
return new Promise((resolve, reject) => {
const name = (file.name || "").toLowerCase();
const isXlsx = name.endsWith(".xlsx") || name.endsWith(".xls");
const reader = new FileReader();
if (isXlsx) {
reader.onload = e => {
try {
if (typeof XLSX === 'undefined') throw new Error("SheetJS not loaded");
const wb = XLSX.read(e.target.result, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const data = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' });
resolve(data.map(row => row.map(c => c === null || c === undefined ? '' : (typeof c === 'number' && c > 40000 && c < 60000 ? new Date(Math.round((c - 25569) * 86400000)).toISOString().slice(0, 10) : String(c)))));
} catch (err) { reject(err); }
};
reader.onerror = () => reject(new Error("File read error"));
reader.readAsArrayBuffer(file);
} else {
reader.onload = e => resolve(parseCSV(e.target.result));
reader.onerror = () => reject(new Error("File read error"));
reader.readAsText(file, "utf-8");
}
});
}
function parseCSV(text) {
text = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/^/, "");
const rows = []; let i = 0, field = "", row = [], inQ = false;
while (i < text.length) {
const c = text[i];
if (inQ) { if (c === '"') { if (text[i + 1] === '"') { field += '"'; i += 2; continue; } inQ = false; i++; continue; } field += c; i++; continue; }
if (c === '"') { inQ = true; i++; continue; }
if (c === ',') { row.push(field); field = ""; i++; continue; }
if (c === '\n') { row.push(field); rows.push(row); row = []; field = ""; i++; continue; }
field += c; i++;
}
if (field.length || row.length) { row.push(field); rows.push(row); }
return rows;
}
function downloadStaffCSVTemplate() {
const headers = ["Name", "Regime / Staff Category", "Role", "Department", "Phone", "Joined Date", "Salary"];
const example = ["John Doe", "Service Contract", "Mechanical Supervisor", "Mechanical Erection", "+244912345678", "2026-07-01", "150000"];
const csv = headers.map(csvEsc).join(",") + "\n" + example.map(csvEsc).join(",") + "\n";
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "Staff_Bulk_Template.csv";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast("Template downloaded");
}
function downloadItemCSVTemplate() {
const headers = ["Code", "Name", "Category", "Location", "Quantity", "Unit", "Reorder Level", "Returnable (Yes/No)"];
const example = ["CEM-53", "OPC 53 Cement", "Tools & Machinery", "Main Store A", "500", "Bags", "100", "No"];
const csv = headers.map(csvEsc).join(",") + "\n" + example.map(csvEsc).join(",") + "\n";
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "Items_Bulk_Template.csv";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast("Template downloaded");
}
function formBulkStaff() {
openModal("Bulk Add Staff",
`Download the template, fill in details, and upload the CSV or XLSX file.
` +
`Download Template (CSV)
` +
`Select CSV or XLSX File
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }]);
document.getElementById("btn-dl-staff-temp").onclick = downloadStaffCSVTemplate;
const fileEl = document.getElementById("f-staff-csv");
fileEl.onchange = async () => {
const files = fileEl.files;
if (!files.length) return;
try {
const rows = await parseSpreadsheet(files[0]);
if (!rows.length || rows[0].length < 4) throw new Error("Invalid CSV format. Must match template.");
const added = [];
for (let i = 1; i < rows.length; i++) {
const cols = rows[i];
if (!cols[0] || !cols[0].trim()) continue;
const name = cols[0].trim();
const regime = cols[1] ? cols[1].trim() : "Permanent Employee";
const role = cols[2] ? cols[2].trim() : "Worker";
const dept = cols[3] ? cols[3].trim() : "Civil & Structural";
const phone = cols[4] ? cols[4].trim() : "";
const joined = cols[5] ? cols[5].trim() : todayISO();
const salary = cols[6] ? +cols[6].trim() : 0;
added.push({
id: uid("e"),
name,
staffType: regime,
role,
dept,
phone,
joined,
salary,
offerLetter: null,
acceptedContract: null,
salaryApproved: false
});
}
if (!added.length) throw new Error("No staff records found.");
DB.employees.push(...added);
save();
toast(added.length + " staff members imported successfully!");
closeModal();
render();
} catch (err) {
alert("Import Error: " + err.message);
}
};
}
function formBulkItems() {
openModal("Bulk Add Store Items",
`Download the template, fill in details, and upload the CSV or XLSX file.
` +
`Download Template (CSV)
` +
`Select CSV or XLSX File
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }]);
document.getElementById("btn-dl-item-temp").onclick = downloadItemCSVTemplate;
const fileEl = document.getElementById("f-item-csv");
fileEl.onchange = async () => {
const files = fileEl.files;
if (!files.length) return;
try {
const rows = await parseSpreadsheet(files[0]);
if (!rows.length || rows[0].length < 4) throw new Error("Invalid CSV format. Must match template.");
const added = [];
for (let i = 1; i < rows.length; i++) {
const cols = rows[i];
if (!cols[1] || !cols[1].trim()) continue;
const code = cols[0] ? cols[0].trim() : "";
const name = cols[1].trim();
const cat = cols[2] ? cols[2].trim() : "Consumable";
const loc = cols[3] ? cols[3].trim() : "";
const qty = cols[4] ? +cols[4].trim() : 0;
const unit = cols[5] ? cols[5].trim() : "pcs";
const reorder = cols[6] ? +cols[6].trim() : 0;
const retStr = (cols[7] || "").trim().toLowerCase();
const returnable = retStr === 'yes' || retStr === 'y' || retStr === 'sim' || retStr === 's' || retStr === 'true';
added.push({
id: uid("i"),
code,
name,
cat,
loc,
qty,
unit,
reorder,
returnable
});
}
if (!added.length) throw new Error("No item records found.");
DB.items.push(...added);
save();
toast(added.length + " items imported successfully!");
closeModal();
render();
} catch (err) {
alert("Import Error: " + err.message);
}
};
}
// ── Bulk Add Accounts (CSV Import) ───────────────────────────────────────────
// Column layout (0-indexed) for bulk import template:
// 0: Date 1: Invoice No 2: NIF 3: Company/Person 4: Head/Purpose
// 5: Amount(Kz) 6: Retention(Kz) 7: VAT(Kz) 8: Mode of Payment
// 9: Purchase Type 10: PO Reference 11: Comments 12: Status
function downloadAcctCSVTemplate() {
const headers = [
"Date (YYYY-MM-DD)", "Invoice No", "NIF", "Company / Person", "Head / Purpose",
"Amount (Kz)", "Retention (Kz)", "VAT (Kz)", "Mode of Payment",
"Purchase Type (Import/Local/Other)", "PO Reference", "Comments", "Status (Pending/Paid)"
];
const example = [
"2026-07-01", "INV-001", "5417041234", "ACME Lda", "Civil Materials",
"500000", "0", "72500", "Bank Transfer",
"Local", "PO-042", "Cement batch", "Paid"
];
const example2 = [
"2026-07-03", "INV-002", "5417099999", "XYZ Importações Lda", "Purchase of Steel Billets",
"1200000", "60000", "174000", "Wire Transfer",
"Import", "PO-043", "Steel delivery June", "Pending"
];
const csv = [
headers.map(csvEsc).join(","),
example.map(csvEsc).join(","),
example2.map(csvEsc).join(","),
""
].join("\n");
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "Accounts_Bulk_Template.csv";
document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000);
toast("Template downloaded — 13 columns");
}
function formBulkAcct() {
openModal("Bulk Import Accounts",
`Download the template, fill in all invoice details, then upload. Duplicate invoice numbers are automatically skipped.
` +
`` +
`⬇ Download Template (CSV) ` +
`13 columns · Date, NIF, Invoice, Company, Description, Amount, Retention, VAT, Mode, Type, PO Ref, Comments, Status ` +
`
` +
`Select CSV or XLSX File
` +
`
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, { label: "Import", cls: "", fn: () => toast("Upload a CSV or XLSX file first") }]);
document.getElementById("btn-dl-acct-temp").onclick = downloadAcctCSVTemplate;
const fileEl = document.getElementById("f-acct-csv");
const preview = document.getElementById("acct-preview");
fileEl.onchange = () => {
const files = fileEl.files;
if (!files.length) return;
preview.innerHTML = 'Parsing file… ';
parseSpreadsheet(files[0]).then(rows => {
try {
if (!rows.length || rows[0].length < 4) throw new Error("Invalid format — must have at least 4 columns. Download and use the template.");
const existingInvNos = new Set(DB.accounts.map(a => (a.inv || "").toLowerCase()));
const toAdd = [];
const skipped = [];
const errors = [];
for (let i = 1; i < rows.length; i++) {
const c = rows[i];
// Skip truly empty rows
if (!c[0] && !c[3] && !c[4]) continue;
const invNo = (c[1] || "").toString().trim();
if (invNo && existingInvNos.has(invNo.toLowerCase())) { skipped.push(invNo); continue; }
const nif = (c[2] || "").toString().trim();
const party = (c[3] || "").toString().trim();
const head = (c[4] || "").toString().trim();
if (!party && !head) { errors.push("Row " + (i + 1) + ": Company/Person and Description both empty"); continue; }
const amt = parseFloat((c[5] || "").toString().replace(/,/g, '')) || 0;
const ret = parseFloat((c[6] || "").toString().replace(/,/g, '')) || 0;
const vat = parseFloat((c[7] || "").toString().replace(/,/g, '')) || 0;
const mode = (c[8] || "").toString().trim();
const ptype = ((c[9] || "").toString().trim() || "Local");
const poRef = (c[10] || "").toString().trim();
const comments = ((c[11] || "").toString().trim() + (poRef ? " | PO: " + poRef : "")).trim();
const status = (c[12] || "").toString().trim() || "Pending";
const rawDate = (c[0] || "").toString().trim();
// Handle numeric Excel dates
let dateStr = rawDate;
if (rawDate && !isNaN(+rawDate)) {
const d = new Date(Math.round((+rawDate - 25569) * 86400 * 1000));
dateStr = d.toISOString().slice(0, 10);
}
toAdd.push({
id: uid("ac"), date: dateStr || todayISO(), nif, inv: invNo, party, head,
amount: amt, ret, vat, mode, purchaseType: ptype, comments,
type: "Payment", status, files: []
});
}
// Build preview table
const previewRows = toAdd.slice(0, 8).map(a => {
const total = (+(a.amount || 0)) - (+(a.ret || 0)) + (+(a.vat || 0));
return `
${a.date} ${escH(a.inv) || '—'}
${escH(a.nif) || '—'}
${escH(a.party)} ${escH(a.head)}
${money(a.amount)}
${money(a.ret)}
${money(a.vat)}
${money(total)}
${escH(a.purchaseType)}
${escH(a.status)}
`;
}).join("");
const moreRows = toAdd.length > 8 ? `… and ${toAdd.length - 8} more entries ` : '';
let summaryHtml = `` +
`✔ ${toAdd.length} entries ready to import ` +
(skipped.length ? `${skipped.length} skipped (duplicate invoice no.) ` : '');
if (errors.length) summaryHtml += `${errors.length} row(s) with errors `;
summaryHtml += `
`;
if (errors.length) {
summaryHtml += `` +
errors.slice(0, 5).map(e => `${escH(e)} `).join('') +
(errors.length > 5 ? `… and ${errors.length - 5} more ` : '') +
` `;
}
if (toAdd.length) {
summaryHtml += `` +
`
` +
`Date Invoice No NIF Company/Person Description ` +
`Amount Retention VAT Total ` +
`Type Status ` +
`${previewRows}${moreRows}
`;
}
preview.innerHTML = summaryHtml;
const importBtn = document.querySelector("#modalMount .btn:not(.secondary)");
if (importBtn) {
if (toAdd.length) {
importBtn.textContent = "Import " + toAdd.length + " Entries";
importBtn.onclick = () => {
DB.accounts.push(...toAdd);
save();
toast(toAdd.length + " account entries imported!");
logAct("Bulk imported " + toAdd.length + " account entries");
closeModal(); render();
};
} else {
importBtn.textContent = "Import";
importBtn.onclick = () => toast("No valid entries to import");
}
}
} catch (err) {
preview.innerHTML = `⚠ ${escH(err.message)}
`;
}
}).catch(err => {
preview.innerHTML = `⚠ ${escH(err.message)}
`;
});
};
}
// ── Owner-Only Module Reset ───────────────────────────────────────────────────
function confirmResetModule(module) {
// Guard: only owners can reset
if (!isOwner(USER)) { toast('⛔ Only owners can reset module data.'); return; }
const labels = {
accounts: 'Invoice Register',
proc: 'All Indents & POs',
docs: 'All Documents & Licences',
items: 'All Store Items',
vehicles: 'All Vehicles',
hr: 'All Staff & Attendance',
debitNotes: 'All Debit Notes',
deduct: 'All Salary Deductions',
progress: 'All Progress Activities',
legalDocs: 'All Licences & Title Deeds',
issues: 'All Tool Issues Log',
audit: 'Change History / Audit Log',
activity: 'Activity Log'
};
const lbl = labels[module] || module;
openModal('⚠ Reset ' + lbl,
`
This will permanently delete ALL records in: ${lbl}
This cannot be undone. Make sure you have a backup.
`+
field('Type RESET to confirm', 'f-reset-confirm'),
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: 'Delete All', cls: '', style: 'background:var(--bad);color:#fff', fn: () => {
if (val('f-reset-confirm') !== 'RESET') return toast('Type RESET (all caps) to confirm');
// Handle each module explicitly
if (module === 'hr') { DB.employees = []; DB.attendance = {}; }
else if (module === 'proc') { DB.proc = []; }
else if (module === 'docs') { DB.docs = []; }
else if (module === 'items') { DB.items = []; }
else if (module === 'vehicles') { DB.vehicles = []; }
else if (module === 'accounts') { DB.accounts = []; }
else if (module === 'debitNotes') { DB.debitNotes = []; }
else if (module === 'deduct') { DB.debitNotes = []; } // deduct uses debitNotes
else if (module === 'progress') { DB.progress = []; }
else if (module === 'legalDocs') { DB.legalDocs = []; }
else if (module === 'issues') { DB.issues = []; }
else if (module === 'audit') { DB.audit = []; }
else if (module === 'activity') { DB.activity = []; }
else if (module === 'security') { DB.security = []; }
else if (module === 'erectionSchedule') { DB.erectionSchedule = []; }
else if (module === 'erectionDailyLog') { DB.erectionDailyLog = []; }
else if (module === 'erectionLists') { DB.erectionLists = { areas: [], workTypes: [], supervisors: [] }; }
else if (Array.isArray(DB[module])) { DB[module] = []; }
else if (DB[module] && typeof DB[module] === 'object') { DB[module] = {}; }
save(); logAct('Owner reset: ' + lbl); toast('Reset complete — ' + lbl + ' cleared'); closeModal(); render();
}
}]);
}
function deleteIndent(id) {
if (!isOwner(USER)) { toast('⛔ Only owners can delete procurement indents.'); return; }
const p = DB.proc.find(x => x.id === id); if (!p) return;
openModal("Remove?", `Remove indent ${p.no} (${p.desc})?Owners can still view & recover it from Change History .
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Remove", cls: "", style: "background:var(--bad);color:#fff", fn: () => {
logAudit('delete', p, []); DB.proc = DB.proc.filter(x => x.id !== id); save(); toast("Removed — logged"); closeModal(); render();
}
}]);
}
function recoverIndent(id) {
const a = DB.audit.find(x => x.id === id); if (!a) return;
const p = { id: uid("ic"), no: a.no, dept: a.changes.find(c => c.field === 'Department')?.to || '', desc: a.desc, qty: 0, unit: 'Nos', area: '', remarks: '', by: a.by, at: a.at, supplier: '—', value: 0, status: 'Indent Raised', files: [], quotes: { luanda: { sup: "", price: 0 }, lobito: { sup: "", price: 0 }, india: { sup: "", price: 0 } }, selected: null };
DB.proc.unshift(p);
DB.audit = DB.audit.filter(x => x.id !== id);
save(); toast("Indent recovered"); render();
}
function setAtt(empId, obj) {
const d = todayISO();
DB.attendance[d] = DB.attendance[d] || {};
DB.attendance[d][empId] = { ...(DB.attendance[d][empId] || {}), ...obj };
save();
hrView();
}
function getKarthikSigBase64() {
if (window._karthikSigData) return window._karthikSigData;
return 'data:image/png;base64,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';
}
// ── HR: 2-Tier Leave Approval System (Requirement 6) ──────────────────────────
function hrLeavesView() {
const list = DB.leaveRequests || [];
const activeStaffCount = DB.employees.filter(e => e.status !== 'Released').length;
const releasedCount = DB.employees.filter(e => e.status === 'Released').length;
const pendingCount = list.filter(l => l.status === 'Pending Dept Head' || (l.status === 'Pending Owner' && isOwner(USER))).length;
const tabBar = '' +
'Staff List ' +
'🌴 Leave Requests' + (pendingCount > 0 ? ' (' + pendingCount + ')' : '') + ' ' +
(can.seeSalary(USER) ? 'Monthly Payroll ' : '') +
'🚪 Released' + (releasedCount > 0 ? ' (' + releasedCount + ')' : '') + ' ' +
'
';
const rows = list.map(l => {
const statusPill = l.status === 'Approved' ? '✓ Approved (Final) ' :
l.status === 'Denied' ? '❌ Denied ' :
l.status === 'Pending Owner' ? '⏳ Pending Owner Approval ' :
'⏳ Pending Dept Head ';
const canReviewDept = (l.status === 'Pending Dept Head') && (isOwner(USER) || deptOK(USER, l.dept));
const canReviewOwner = (l.status === 'Pending Owner') && isOwner(USER);
return `
${escH(l.empName)} Dept: ${escH(l.dept)}
${escH(l.leaveType)}
${escH(l.startDate)} → ${escH(l.endDate)}${l.days} day(s)
${escH(l.reason || '—')}
${statusPill}
Applied: ${l.appliedAt || '—'}
${l.deptHeadApproval ? `Dept Head: ${l.deptHeadApproval.decision} by ${l.deptHeadApproval.by}
` : ''}
${l.ownerApproval ? `Owner: ${l.ownerApproval.decision} by ${l.ownerApproval.by}
` : ''}
${canReviewDept ? `Dept Review (Tier 1) ` : ''}
${canReviewOwner ? `👑 Final Review (Owner) ` : ''}
${isOwner(USER) ? `Delete ` : ''}
`;
}).join('');
view().innerHTML = `
${tabBar}
🌴 Leave Requests & 2-Tier Approvals
+ Apply Leave
Employee Leave Type Dates & Duration Reason Status Approval Log Action
${rows || emptyRow(7, 'No leave requests on record. Click "+ Apply Leave" to create one.')}
`;
}
// Apply Leave Modal
function formApplyLeave() {
const staffOpts = DB.employees.filter(e => e.status !== 'Released').map(e => ({ v: e.id, l: `${e.name} (${e.dept})` }));
if (!staffOpts.length) return toast('No active employees found to apply leave', false);
openModal('🌴 Apply Leave Request',
sel('Select Employee', 'f-lv-emp', staffOpts) +
sel('Leave Type', 'f-lv-type', [
{ v: 'Annual Leave / Férias', l: 'Annual Leave / Férias Anuais' },
{ v: 'Sick Leave / Doença', l: 'Sick Leave / Licença Médica' },
{ v: 'Maternity/Paternity', l: 'Maternity/Paternity / Maternidade' },
{ v: 'Unpaid Leave / Licença sem Vencimento', l: 'Unpaid Leave / Licença sem Vencimento' },
{ v: 'Other / Outro', l: 'Other Purpose' }
]) +
`` +
field('Start Date', 'f-lv-start', todayISO(), 'date') +
field('End Date', 'f-lv-end', todayISO(), 'date') +
`
` +
field('Reason / Justification', 'f-lv-reason'),
[{ label: 'Cancel', cls: 'secondary', fn: closeModal },
{
label: 'Submit Request', cls: '', fn: () => {
const empId = val('f-lv-emp');
const emp = DB.employees.find(e => e.id === empId);
if (!emp) return toast('Select a valid employee');
const start = val('f-lv-start');
const end = val('f-lv-end');
if (!start || !end) return toast('Please select start and end dates');
const d1 = new Date(start), d2 = new Date(end);
const days = Math.max(1, Math.round((d2 - d1) / 864e5) + 1);
const req = {
id: uid('lv'),
empId: emp.id,
empName: emp.name,
dept: emp.dept,
leaveType: val('f-lv-type'),
startDate: start,
endDate: end,
days: days,
reason: val('f-lv-reason') || 'No reason specified',
appliedAt: todayISO(),
status: 'Pending Dept Head',
deptHeadApproval: null,
ownerApproval: null
};
DB.leaveRequests = DB.leaveRequests || [];
DB.leaveRequests.unshift(req);
save();
logAct(`Leave requested for ${emp.name} (${days} days)`);
toast('Leave request submitted ✓');
closeModal();
render();
}
}]
);
}
// Tier 1 Approval: Dept Head
function reviewLeaveDeptHead(leaveId) {
const l = (DB.leaveRequests || []).find(x => x.id === leaveId);
if (!l) return;
openModal('Tier 1 Review — ' + l.empName,
`Employee: ${l.empName} (${l.dept}) Type: ${l.leaveType} · Dates: ${l.startDate} → ${l.endDate} (${l.days} days) Reason: ${l.reason}
` +
field('Dept Head Comment', 'f-dh-comment'),
[
{ label: 'Cancel', cls: 'secondary', fn: closeModal },
{
label: '❌ Deny Leave', cls: 'danger', fn: () => {
l.status = 'Denied';
l.deptHeadApproval = { by: USER.displayName || USER.username, at: todayISO(), decision: 'Denied', comment: val('f-dh-comment') };
save(); logAct(`Leave DENIED for ${l.empName} by Dept Head`); toast('Leave denied'); closeModal(); render();
}
},
{
label: '✔ Allow (Send to Owner)', cls: '', fn: () => {
l.status = 'Pending Owner';
l.deptHeadApproval = { by: USER.displayName || USER.username, at: todayISO(), decision: 'Allowed', comment: val('f-dh-comment') };
save(); logAct(`Leave ALLOWED by Dept Head for ${l.empName} (sent to Owner)`); toast('Allowed — sent to Owner for final approval'); closeModal(); render();
}
}
]
);
}
// Tier 2 Approval: Owner
function reviewLeaveOwner(leaveId) {
const l = (DB.leaveRequests || []).find(x => x.id === leaveId);
if (!l) return;
openModal('👑 Tier 2 Final Review (Owner) — ' + l.empName,
`Employee: ${l.empName} (${l.dept}) Type: ${l.leaveType} · Dates: ${l.startDate} → ${l.endDate} (${l.days} days) Dept Head Review: ${l.deptHeadApproval?.decision || 'Allowed'} by ${l.deptHeadApproval?.by || '—'}
` +
field('Owner Remarks / Notes', 'f-owner-comment'),
[
{ label: 'Cancel', cls: 'secondary', fn: closeModal },
{
label: '❌ Final Deny', cls: 'danger', fn: () => {
l.status = 'Denied';
l.ownerApproval = { by: USER.displayName || USER.username, at: todayISO(), decision: 'Denied', comment: val('f-owner-comment') };
save(); logAct(`Leave DENIED by Owner for ${l.empName}`); toast('Leave request denied'); closeModal(); render();
}
},
{
label: '👑 Final Allow & Approve', cls: '', fn: () => {
l.status = 'Approved';
l.ownerApproval = { by: USER.displayName || USER.username, at: todayISO(), decision: 'Approved', comment: val('f-owner-comment') };
// Auto-mark attendance as leave across the date range
let cur = new Date(l.startDate);
const end = new Date(l.endDate);
while (cur <= end) {
const ds = cur.toISOString().slice(0, 10);
DB.attendance[ds] = DB.attendance[ds] || {};
DB.attendance[ds][l.empId] = { status: 'leave', leaveType: l.leaveType, approvedBy: USER.displayName || USER.username };
cur.setDate(cur.getDate() + 1);
}
save(); logAct(`Leave APPROVED by Owner for ${l.empName} (${l.days} days)`); toast('Leave fully approved & attendance updated ✓'); closeModal(); render();
}
}
]
);
}
function deleteLeaveRequest(leaveId) {
if (!isOwner(USER)) return;
DB.leaveRequests = (DB.leaveRequests || []).filter(l => l.id !== leaveId);
save(); toast('Leave request removed'); render();
}
// ── Requirement 7: Angola Labour Law Uninformed Absence Notice Generator ──────
function formAngolaLabourNotice(empId) {
const e = DB.employees.find(x => x.id === empId);
if (!e) return toast('Employee record not found', false);
openModal('⚠️ Angola Labour Law Notice — ' + e.name,
`Prepare legal notice / warning for uninformed absence or excessive absenteeism as per Lei Geral do Trabalho (Lei n.º 12/23 de 27 de Dezembro) .
` +
sel('Notice Stage / Type', 'f-lgt-stage', [
{ v: '1', l: 'Notice 1 — Primeira Notificação de Falta Injustificada (Artigo 128.º LGT)' },
{ v: '2', l: 'Notice 2 — Segunda Notificação de Falta Injustificada' },
{ v: '3', l: 'Notice 3 — Notificação de Nota de Culpa / Processo Disciplinar (Artigo 206.º LGT)' },
{ v: '4', l: 'Notice 4 / Final Warning — Último Aviso Prévio Disciplinar' },
{ v: '5', l: 'Termination Notice — Comunicação de Rescisão por Justa Causa (Artigo 206.º/207.º LGT)' }
]) +
`` +
field('Missed Dates (e.g. 05/Aug, 06/Aug, 07/Aug)', 'f-lgt-dates', todayISO()) +
field('Notice Issue Date', 'f-lgt-date', todayISO(), 'date') +
`
` +
field('Observations / Justification Notes', 'f-lgt-notes', 'Faltas não justificadas sem comunicação prévia ao responsável do departamento.'),
[
{ label: 'Cancel', cls: 'secondary', fn: closeModal },
{
label: '🖨 Generate & Print Legal Document', cls: 'danger', fn: () => {
const stage = val('f-lgt-stage');
const missedDates = val('f-lgt-dates');
const issueDate = val('f-lgt-date');
const notes = val('f-lgt-notes');
printAngolaLabourNotice(e, stage, missedDates, issueDate, notes);
closeModal();
}
}
]
);
}
function printAngolaLabourNotice(e, stage, missedDates, issueDate, notes) {
const titles = {
'1': 'PRIMEIRA NOTIFICAÇÃO DE FALTA INJUSTIFICADA (1st Warning Notice)',
'2': 'SEGUNDA NOTIFICAÇÃO DE FALTA INJUSTIFICADA (2nd Warning Notice)',
'3': 'NOTIFICAÇÃO DE NOTA DE CULPA E ABERTURA DE PROCESSO DISCIPLINAR (Notice 3)',
'4': 'ÚLTIMO AVISO PRÉVIO DISCIPLINAR / FINAL WARNING (Notice 4)',
'5': 'COMUNICAÇÃO DE RESCISÃO DE CONTRATO DE TRABALHO POR JUSTA CAUSA (Termination Notice)'
};
const title = titles[stage] || titles['1'];
const w = window.open('', '_blank', 'width=900,height=750');
w.document.write(`
Notificação Disciplinar LGT — ${escH(e.name)}
${title}
Trabalhador / Employee: ${escH(e.name)}
Função / Role: ${escH(e.role)}
Departamento / Dept: ${escH(e.dept)}
Tipo de Contrato: ${escH(e.staffType || 'Permanente')}
ID / N.º de Identificação: ${escH(e.idDocNo || e.id)}
Datas das Faltas: ${escH(missedDates)}
ENQUADRAMENTO LEGAL / ANGOLAN LABOUR LAW PROCLAMATION CLAUSE:
Nos termos do Artigo 128.º da Lei Geral do Trabalho (Lei n.º 12/23 de 27 de Dezembro) , a ausência não informada nem autorizada constitui falta injustificada, com perda imediata de retribuição correspondente aos dias não trabalhados.
Nos termos dos Artigos 206.º e 207.º da referida Lei , a acumulação reiterada de faltas injustificadas configura infração disciplinar grave, sujeita a processo disciplinar e eventual rescisão do contrato de trabalho por justa causa sem direito a indemnização.
Servimo-nos da presente para notificar V. Exa. de que registou ausências não justificadas ao seu posto de trabalho nas datas acima mencionadas (${escH(missedDates)} ), sem qualquer comunicação prévia ou comprovativo médico/legal válido entregue ao Departamento de Recursos Humanos.
${stage === '1' || stage === '2' ? `
Pela presente fica formalmente ADVERTIDO(A) de que a violação do dever de assiduidade prejudica o regular funcionamento das operações fabris da MEGACORP INDÚSTRIA (SU), LDA. Solicita-se a apresentação de justificativo válido no prazo máximo de 48 horas a contar da receção desta notificação.
` : stage === '3' || stage === '4' ? `
Atento o carácter reiterado das faltas injustificadas, notificamos a instauração do presente PROCESSO DISCIPLINAR COM NOTA DE CULPA (Artigo 206.º LGT). Dispõe o trabalhador do prazo legal de 5 (cinco) dias úteis para apresentar a sua defesa escrita ou solicitar diligências de prova perante a Direção de Recursos Humanos.
` : `
Dado que o trabalhador incorreu na prática reiterada de faltas injustificadas sem qualquer resposta ou justificação válida perante a empresa, a Direção da MEGACORP INDÚSTRIA (SU), LDA comunica a RESCISÃO DO CONTRATO DE TRABALHO POR JUSTA CAUSA com efeitos imediatos, nos termos dos Artigos 206.º e 207.º da Lei Geral do Trabalho (Lei n.º 12/23).
`}
Observações / Anotações da Direção: ${escH(notes)}
Pela Direção da Empresa / Management Signature
Karthik
Managing Director / Diretor Geral
Tomai Conhecimento do Trabalhador / Worker Receipt
__________________________________________
${escH(e.name)}
Assinatura / Data: _____/_____/2026
🖶 Imprimir Notificação / Print Legal Notice
setTimeout(()=>window.print(),400);
`);
w.document.close();
}
// ── Google Translate Integration ──────────────────────────────────────────────
function googleTranslateElementInit() {
new google.translate.TranslateElement({
pageLanguage: 'en',
autoDisplay: false,
layout: google.translate.TranslateElement.InlineLayout.NONE
}, 'google_translate_element');
}
function gtTranslate(lang) {
// Close any open dropdowns
document.querySelectorAll('.globe-dd.open').forEach(d => d.classList.remove('open'));
if (lang === 'en') {
// Restore original (English) — find and reset the Google Translate cookie
var el = document.getElementById('google_translate_element');
if (el) {
var sel = el.querySelector('select.goog-te-combo');
if (sel) { sel.value = ''; sel.dispatchEvent(new Event('change')); }
}
// Also try the cookie approach
try {
document.cookie = 'googtrans=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'googtrans=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.' + window.location.hostname + ';';
var fr = document.getElementsByClassName('goog-te-banner-frame');
if (fr && fr[0]) { fr[0].style.display = 'none'; document.body.style.top = '0'; }
} catch (e) { }
location.reload();
return;
}
// Set Google Translate cookie and trigger translation
var el = document.getElementById('google_translate_element');
if (el) {
var sel = el.querySelector('select.goog-te-combo');
if (sel) {
sel.value = lang;
sel.dispatchEvent(new Event('change'));
return;
}
}
// Fallback: set cookie and reload
try {
document.cookie = 'googtrans=/en/' + lang + '; path=/';
document.cookie = 'googtrans=/en/' + lang + '; path=/; domain=.' + window.location.hostname;
} catch (e) { }
location.reload();
}
function toggleGlobeDD(btn) {
var dd = btn.nextElementSibling;
var isOpen = dd.classList.contains('open');
document.querySelectorAll('.globe-dd.open').forEach(d => d.classList.remove('open'));
if (!isOpen) dd.classList.add('open');
}
document.addEventListener('click', function (e) {
if (!e.target.closest('.globe-wrap')) document.querySelectorAll('.globe-dd.open').forEach(d => d.classList.remove('open'));
});
// No-op stubs for legacy calls (applyLang, applyLoginLang, setLang no longer do preset translation)
function applyLang() { }
function applyLoginLang() { }
function setLang(e) { applyLang(); }
function closeModal() { document.getElementById("modalMount").innerHTML = ""; }
let tt;
function toast(m, success = true) {
const t = document.getElementById("toast");
t.textContent = tr(m);
t.style.background = success ? '#10b981' : '#ef4444';
t.classList.add("show");
clearTimeout(tt);
tt = setTimeout(() => t.classList.remove("show"), 2400);
}
function openDrawer() { document.getElementById("sidebar").classList.add("open"); document.getElementById("scrim").classList.add("show"); }
function closeDrawer() { document.getElementById("sidebar").classList.remove("open"); document.getElementById("scrim").classList.remove("show"); }
const lgBtn = document.getElementById("lg-btn");
if (lgBtn) lgBtn.onclick = tryLogin;
const lgUser = document.getElementById("lg-user");
if (lgUser) lgUser.addEventListener("keydown", e => { if (e.key === "Enter") tryLogin(); });
const lgPass = document.getElementById("lg-pass");
if (lgPass) lgPass.addEventListener("keydown", e => { if (e.key === "Enter") tryLogin(); });
const lgForm = document.getElementById("lg-form");
if (lgForm) lgForm.onsubmit = e => { e.preventDefault(); tryLogin(); return false; };
const logoutEl = document.getElementById("logoutBtn");
if (logoutEl) logoutEl.onclick = logout;
const hambEl = document.getElementById("hamb");
if (hambEl) hambEl.onclick = openDrawer;
const scrimEl = document.getElementById("scrim");
if (scrimEl) scrimEl.onclick = closeDrawer;
document.addEventListener("click", e => {
const openImg = e.target.closest("[data-open]");
if (openImg) {
const w = window.open();
w.document.write(``);
w.document.title = "Attachment";
return;
}
const b = e.target.closest("[data-act]");
if (!b) return;
e.preventDefault();
handleAction(b.dataset.act, b.dataset.id, b.dataset.mode, e);
});
restore();
applyLoginLang();
// ── NEW: Edit HR Staff ─────────────────────────────────────────────────────────
function formEmpEdit(emp) {
if (!emp) return;
const canSeeSal = can.seeSalaryForEmp(USER, emp);
const defaultType = emp.staffType === 'Expatriate' ? 'Passport (Expat)' : 'Bilhete de Identidade (Angolan ID)';
openModal("Edit Staff — " + emp.name,
field("Name", "f-name", escAttr(emp.name)) +
sel("Staff Category / Regime", "f-type", ["Service Contract", "Permanent Employee", "Expatriate", "Contractor"], emp.staffType) +
`` + field("Role", "f-role", escAttr(emp.role || "")) + sel("Department", "f-dept", DEPTS, emp.dept) + `
` +
field("Joined", "f-join", emp.joined || todayISO(), "date") +
contactSection(emp) +
(canSeeSal ? `` + field("Salary", "f-sal", emp.salary || 0, "number") + curSelect(emp) + `
` : "") +
bankSection(emp) +
`Employee Identification (ID Card / Passport)
` +
`` + sel("ID Document Type", "f-idtype", ["Bilhete de Identidade (Angolan ID)", "Passport (Expat)", "Cartão de Residência", "Driving Licence", "Other ID"], emp.idDocType || defaultType) + field("ID / Passport Number", "f-idno", escAttr(emp.idDocNo || "")) + `
` +
`` + field("ID Issue Date", "f-idissued", emp.idDocIssueDate || "", "date") + field("ID Expiry Date", "f-idexpiry", emp.idDocExpiryDate || "", "date") + `
` +
`Upload ID Scan (PDF/Image)
` +
(emp.idDocFiles && emp.idDocFiles.length ? `Attached ID Scans ${attThumbs(emp.idDocFiles)}
` : '') +
`Other Document Expiry Dates (alerts at 45 days)
` +
`` + field("Work Permit Expiry", "f-wp", emp.workPermitExpiry || "", "date") + field("Visa Expiry", "f-visa", emp.visaExpiry || "", "date") + `
` +
`` + field("Residência Expiry", "f-res", emp.residenciaExpiry || "", "date") + field("Passport Expiry", "f-pass", emp.passportExpiry || "", "date") + `
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save Changes", cls: "", fn: async () => {
const n = val("f-name"); if (!n) return alertField();
const before = { name: emp.name, staffType: emp.staffType, role: emp.role, dept: emp.dept, phone: emp.phone, joined: emp.joined, salary: emp.salary, bank: JSON.stringify(emp.bank_details || {}) };
const fileInp = document.getElementById("f-idfile");
if (fileInp && fileInp.files.length) {
const nf = await readFiles(fileInp);
emp.idDocFiles = (emp.idDocFiles || []).concat(nf);
}
emp.name = n; const newType = val("f-type");
if (!emp.empCode || empPrefix(newType) !== empPrefix(emp.staffType)) { emp.empCode = nextEmpCode(newType); toast("Employee code: " + emp.empCode); }
emp.staffType = newType; emp.role = val("f-role"); emp.dept = val("f-dept");
readContactFields(emp); emp.joined = val("f-join");
emp.bank_details = readBankFields(emp.staffType);
if (before.bank !== JSON.stringify(emp.bank_details)) logAudit("edit", { id: emp.id, no: emp.id, desc: emp.name }, [{ field: "bank_details", from: "(updated)", to: emp.bank_details.bankName || "—" }]);
emp.idDocType = val("f-idtype"); emp.idDocNo = val("f-idno");
emp.idDocIssueDate = val("f-idissued") || null; emp.idDocExpiryDate = val("f-idexpiry") || null;
if (can.seeSalaryForEmp(USER, emp)) { emp.salary = +val("f-sal") || 0; if (document.getElementById("f-cur")) emp.salaryCurrency = val("f-cur"); }
emp.workPermitExpiry = val("f-wp") || null; emp.visaExpiry = val("f-visa") || null;
emp.residenciaExpiry = val("f-res") || null; emp.passportExpiry = val("f-pass") || emp.idDocExpiryDate || null;
save();
logAudit("edit", { id: emp.id, no: emp.id, desc: emp.name },
Object.keys(before).filter(k => String(before[k]) !== String(emp[k])).map(k => ({ field: k, from: before[k], to: emp[k] }))
);
hrAlert('edit', emp, Object.keys(before).filter(k => String(before[k]) !== String(emp[k])).join(', ') || 'details updated');
save(); toast("Staff record updated"); closeModal(); render();
}
}]);
wireBankToggle(); wireCurDefault();
}
// ── NEW: Edit Accounts Entry ────────────────────────────────────────────────────
function formAcctEdit(a) {
if (!a) return;
let files = a.files || [], payFiles = a.payFiles || [];
ensureAcctHeads();
const headOpts = activeHeads().map(x => ({ v: x.name, l: (x.code ? x.code + ' — ' : '') + x.name }));
openModal("Edit Entry — " + (a.inv || a.party || ""),
`` + field("Date", "f-date", a.date || todayISO(), "date") + field("Invoice No", "f-inv", escAttr(a.inv || "")) + `
` +
`` + sel("Document Type", "f-doctype", ["Factura", "Factura-Recibo", "Factura Global", "Nota de Crédito", "Recibo"], a.docType || "Factura") + field("Supplier NIF", "f-nif", escAttr(a.nif || "")) + `
` +
field("Company / Person", "f-party", escAttr(a.party || "")) +
`` + sel("Account Head", "f-acch", headOpts, a.acctHead || "") + field("Description", "f-head", escAttr(a.head || "")) + `
` +
`` + field("Base Amount (" + CUR + ")", "f-amt", a.amount || 0, "number") + sel("VAT Rate", "f-ivarate", [{ v: "14", l: "14%" }, { v: "7", l: "7%" }, { v: "5", l: "5%" }, { v: "0", l: "Isento / 0%" }], acctEffRate(a)) + `
` +
`` + field("VAT (" + CUR + ")", "f-vat", a.vat || 0, "number") + field("Withholding (" + CUR + ")", "f-ret", a.ret || 0, "number") + `
` +
`` + sel("Mode of Payment", "f-mode", ["BAI Bank", "KEVE Bank", "Cash", "Multicaixa", "Other"], a.mode || "BAI Bank") + field("Payment Ref", "f-payref", escAttr(a.payRef || "")) + `
` +
/* Payment Status: Auto-set from payment proof in linked Indent, manual override allowed */
(() => {
const linkedIndent = (DB.proc || []).find(p => (p.inv || p.invoiceNo) === (a.inv || a.invoiceNo));
const hasPaymentProof = linkedIndent && Array.isArray(linkedIndent.paymentProof) && linkedIndent.paymentProof.length > 0;
const autoStatus = hasPaymentProof ? 'Paid' : 'Pending';
const proofHint = linkedIndent ? (hasPaymentProof ? '✓ Payment proof uploaded in Procurement' : '⚠ No payment proof in Procurement') : '(No linked Indent found)';
const selectedStatus = a.status || autoStatus;
return `Payment Status ${proofHint}
Paid Pending
Auto-filled from payment proof status; change manually if needed.
`;
})() +
field("Comments", "f-com", escAttr(a.comments || "")) +
`` +
``,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Save Changes", cls: "", fn: async () => {
const party = val("f-party"), head = val("f-head");
if (!party && !head) return alertField();
const inp = document.getElementById("fx-edit");
if (inp && inp.files.length) { const nf = await readFiles(inp); files = files.concat(nf); }
const inpP = document.getElementById("fpay-edit");
if (inpP && inpP.files.length) { const nf = await readFiles(inpP); payFiles = payFiles.concat(nf); }
a.date = val("f-date"); a.inv = val("f-inv"); a.nif = val("f-nif"); a.mode = val("f-mode");
a.party = party; a.head = head; a.acctHead = val("f-acch"); a.docType = val("f-doctype");
a.ivaRate = val("f-ivarate"); a.amount = +val("f-amt") || 0; a.vat = +val("f-vat") || 0; a.ret = +val("f-ret") || 0;
a.payRef = val("f-payref"); a.status = val("f-status"); a.comments = val("f-com"); a.files = files; a.payFiles = payFiles;
save(); logAct(`Invoice ${a.inv || ''} updated — ${party || head}`); toast("Entry updated"); closeModal(); render();
}
}]);
const autoIva2 = () => { const amt = +val("f-amt") || 0, r = +val("f-ivarate") || 0; const vEl = document.getElementById("f-vat"); if (vEl) vEl.value = (amt * r / 100).toFixed(2); };
const rateEl2 = document.getElementById("f-ivarate");
if (rateEl2) rateEl2.onchange = autoIva2;
const inp = document.getElementById("fx-edit");
if (inp) inp.onchange = async () => { const nf = await readFiles(inp); files = files.concat(nf); document.getElementById("attPrevEdit").innerHTML = files.map((f, i) => attEditable(f, i)).join(""); };
const inpP = document.getElementById("fpay-edit");
if (inpP) inpP.onchange = async () => { const nf = await readFiles(inpP); payFiles = payFiles.concat(nf); document.getElementById("attPrevPayEdit").innerHTML = payFiles.map((f, i) => attEditable(f, i)).join(""); };
}
// ── NEW: Delete Vehicle Confirmation ───────────────────────────────────────────
function confirmVehDel(id) {
const v = DB.vehicles.find(x => x.id === id); if (!v) return;
openModal("Delete Vehicle?",
`
Remove ${v.name}?
All service logs, trips and fuel entries for this vehicle will be deleted permanently.
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Delete Vehicle", cls: "", style: "background:var(--bad);color:#fff", fn: () => {
DB.vehicles = DB.vehicles.filter(x => x.id !== id);
if (openVeh === id) openVeh = null;
save(); logAct(`Vehicle deleted: ${v.name}`); toast("Vehicle removed"); closeModal(); render();
}
}]);
}
// ── NEW: Reset Monthly Payroll Approvals ───────────────────────────────────────
// ── Deduction register: every deduction with purpose, who instructed it and how it was approved ──
function payDeductionLines(e, mo, r) {
const lines = [];
const ov = (getPayrollDoc(mo).rows || {})[e.id] || {};
const nm = v => v || 'not recorded';
if (r.type === 'Permanent Employee') {
if (r.inss > 0) lines.push({ type: 'INSS 3% (Segurança Social)', amount: r.inss, purpose: 'Statutory social security contribution · Contribuição obrigatória', by: 'Law (automatic)', approvedBy: 'Automatic — by law', how: 'Calculated', date: mo });
if (r.irt > 0) lines.push({ type: 'AGT IRT', amount: r.irt, purpose: 'Statutory income tax · IRT (AGT table)', by: 'Law (automatic)', approvedBy: 'Automatic — by law', how: 'Calculated', date: mo });
}
if (r.absDisc > 0) lines.push({ type: 'Faltas / Absences', amount: r.absDisc, purpose: r.faltas + ' unjustified absence day(s) · Faltas injustificadas', by: 'Attendance register (automatic)', approvedBy: e.salaryApprovedBy ? 'Payroll approved by ' + e.salaryApprovedBy + ' (' + (e.salaryApprovedAt || '') + ')' : 'Payroll not approved yet', how: 'Calculated from attendance', date: e.salaryApprovedAt || mo });
(DB.debitNotes || []).filter(dn => dn.personRef === e.id && (dn.status === 'Pending' || dn.status === 'Approved') && (dn.date || '').startsWith(mo)).forEach(dn => lines.push({
type: 'Debit note ' + (dn.no || ''), amount: +dn.value || 0, purpose: (dn.reason || '') + (dn.item ? ' · ' + dn.item : ''), by: nm(dn.raisedBy),
approvedBy: dn.approvedBy ? dn.approvedBy + (dn.approvedAt ? ' (' + dn.approvedAt + ')' : '') : 'Not approved yet (' + dn.status + ')',
how: dn.approvalMethod ? dn.approvalMethod + (dn.approvalRef ? ' · ' + dn.approvalRef : '') : (dn.approvedBy ? 'In SiteDesk (recorded before this register)' : '—'), date: dn.date }));
medList(e).filter(m => m.decision === 'salary' && m.payMonth === mo).forEach(m => lines.push({
type: 'Medical expense', amount: +m.amount || 0, purpose: (m.desc || '') + ' · Despesa médica', by: nm(m.addedBy),
approvedBy: m.decidedBy ? m.decidedBy + ' (' + (m.decidedAt || '') + ')' : '—', how: 'Owner decision in SiteDesk: deduct from salary', date: m.decidedAt || m.date || mo }));
if ((+ov.otherDisc || 0) > 0) lines.push({ type: 'Other discount', amount: +ov.otherDisc, purpose: nm(ov.odReason || ov.note), by: nm(ov.odBy), approvedBy: nm(ov.odApprovedBy), how: nm(ov.odMethod), date: mo });
return lines;
}
window.payDeductionsModal = function (empId, mo) {
const e = DB.employees.find(x => x.id === empId); if (!e) return;
if (!can.seeSalaryForEmp(USER, e)) return toast('Restricted', false);
const [yr, moN] = mo.split('-').map(Number), daysInMo = new Date(yr, moN, 0).getDate();
const r = computePayRow(e, mo, daysInMo, getPayrollDoc(mo).rows[e.id]);
const L = payDeductionLines(e, mo, r);
const tot = L.reduce((s, x) => s + x.amount, 0);
const body = L.length
? `Type Amount Purpose Instructed by Approved by How approved ` +
L.map(x => `${escH(x.type)} ${emoney(e, x.amount)} ${escH(x.purpose)} ${escH(x.by)} ${escH(x.approvedBy)} ${escH(x.how)} `).join('') +
` Total deductions ${emoney(e, tot)}
`
: 'No deductions this month.
';
openModal('Deductions — ' + escH(e.name) + ' · ' + mo, body, [{ label: 'Close', cls: 'secondary', fn: closeModal }]);
};
// Approve every not-yet-approved person in one group (locals or expatriates). Anyone with a medical decision pending is skipped.
window.approveGroup = function (mo, group) {
if (!can.seeSalary(USER)) return toast('You do not have payroll access', false);
const people = DB.employees.filter(e => !isOwnerEmp(e) && e.status !== 'Released' && (group === 'expat') === isExpatType(e.staffType) && can.seeSalaryForEmp(USER, e));
const blocked = [];
let n = 0;
people.forEach(e => {
if (e.salaryApproved) return;
if (medPendingFor(e, mo).length) { blocked.push(e.name); return; }
e.salaryApproved = true; e.salaryApprovedBy = USER.displayName || USER.username; e.salaryApprovedAt = todayISO(); e.salaryApprovedMonth = mo; n++;
});
const label = group === 'expat' ? 'Expatriates' : 'Locals';
if (n) { save(); logAct(`${label} payroll approved for ${mo} (${n} people)`); }
toast(n ? `${label}: ${n} approved ✓` : `${label}: nothing new to approve`, !blocked.length);
if (blocked.length) toast(`Not approved (medical decision pending): ${blocked.join(', ')}`, false);
render();
};
window.resetGroupApprovals = function (mo, group) {
if (!isOwner(USER)) return toast('Only owners can reset payroll approvals', false);
const label = group === 'expat' ? 'Expatriates' : 'Locals';
openModal(`Reset ${label} approvals — ${mo}`,
`Clear approval for all ${label.toLowerCase()} this group?
Only this group is reset. The other group keeps its approvals.
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: 'Reset ' + label, cls: '', style: 'background:var(--bad);color:#fff', fn: () => {
DB.employees.forEach(e => { if ((group === 'expat') === isExpatType(e.staffType)) e.salaryApproved = false; });
save(); logAct(`${label} payroll approvals reset for ${mo}`); toast(`${label} approvals reset`); closeModal(); render();
}
}]);
};
function resetPayrollApprovals(month) {
if (!isOwner(USER)) return toast("Only owners can reset payroll approvals");
const m = month || todayISO().slice(0, 7);
openModal("Reset Payroll Approvals — " + m,
`
Reset salary approval for all staff?
All 'Approved' flags will be cleared so you can start fresh approvals for the new month.
`,
[{ label: "Cancel", cls: "secondary", fn: closeModal }, {
label: "Reset Approvals", cls: "", style: "background:var(--bad);color:#fff", fn: () => {
DB.employees.forEach(e => { e.salaryApproved = false; });
save(); logAct("Payroll approvals reset for " + m); toast("All salary approvals reset"); closeModal(); render();
}
}]);
}
// ── Item Photo Gallery Modal ──────────────────────────────────────────────────
function formItemPhotoGallery(it) {
if (!it) return;
const photos = (it.photos || []).filter(f => f && f.data);
let currentIdx = 0;
function galleryHTML() {
if (!photos.length) return `No photos uploaded yet. Use the attachment button in the main table to add photos.
`;
const f = photos[currentIdx];
const isPdf = f.type === 'application/pdf';
const main = isPdf
? `📄
`
: ` `;
return `
${main}
‹
${currentIdx + 1} / ${photos.length}
›
${photos.map((p, i) => {
const isImg = p.type !== 'application/pdf';
return `
` +
(isImg ? `
` : `
📄
`) +
`
`;
}).join('')}
`;
}
window._galIdx = 0;
window._galRender = () => {
currentIdx = window._galIdx || 0;
return galleryHTML();
};
openModal(`📷 Photos — ${it.name} (${photos.length} image${photos.length !== 1 ? 's' : ''})`,
`${galleryHTML()}
`,
[{ label: "Close", cls: "secondary", fn: closeModal }]);
setTimeout(() => {
const inp = document.getElementById('gal-upload');
if (inp) inp.onchange = async () => {
const fs = await readFiles(inp);
it.photos = (it.photos || []).concat(fs);
save(); toast('Photos saved ✔'); closeModal(); render();
};
}, 50);
}
function formProcPhotoGallery(p) {
if (!p) return;
const photos = (p.storePhotos || []).filter(f => f && f.data);
if (!photos.length) { formAttUpload(p, 'storePhotos'); return; }
let currentIdx = 0;
function galleryHTML() {
const f = photos[currentIdx];
const isPdf = f.type === 'application/pdf';
const main = isPdf
? `📄
`
: ` `;
return `${main}
‹
${currentIdx + 1} / ${photos.length}
›
`;
}
window._galPIdx = 0;
window._galPRender = () => { currentIdx = window._galPIdx || 0; return galleryHTML(); };
openModal(`📷 Shipment Photos — ${p.no} (${photos.length})`,
`${galleryHTML()}
`,
[{ label: "Add More", cls: "secondary", fn: () => { closeModal(); formAttUpload(p, 'storePhotos'); } }, { label: "Close", cls: "", fn: closeModal }]);
}
// ── Developer Console View ────────────────────────────────────────────────────
let devTab = 'announcements';
function toggleCareerAccess(username) {
if (!isDev(USER)) return toast("Only Developers can modify Work With Us permissions");
const targetUser = (username || '').toLowerCase();
if (!targetUser) return;
const cfg = DB.appConfig = DB.appConfig || {};
cfg.careerAccessUsers = cfg.careerAccessUsers || [];
const idx = cfg.careerAccessUsers.indexOf(targetUser);
if (idx > -1) {
cfg.careerAccessUsers.splice(idx, 1);
toast("Access revoked for " + targetUser);
} else {
cfg.careerAccessUsers.push(targetUser);
toast("Access granted to " + targetUser);
}
save();
buildNav();
if (typeof developerView === 'function') developerView();
}
function developerView() {
if (!isDev(USER)) return view().innerHTML = `Access Denied — Developer account required
`;
if (!DB.announcements) DB.announcements = [];
if (!DB.appConfig) DB.appConfig = {};
const tabs = [
{ k: 'announcements', l: '📢 Announcements' },
{ k: 'branding', l: '🎨 Branding' },
{ k: 'flags', l: '🚩 Feature Flags' },
{ k: 'messaging', l: '📲 WhatsApp & Messaging' },
{ k: 'security', l: '🔐 Security' },
{ k: 'auditlog', l: '🕵 Audit Log' },
{ k: 'health', l: '💊 System Health' },
{ k: 'careers_perm', l: '💼 Work With Us Access' },
{ k: 'backup', l: '💾 Backup & Restore' }
];
const leadDevBadge = isLeadDev(USER)
? `👑 Lead Dev Verified `
: `🔑 Claim Lead Dev `;
const tabBar = `${tabs.map(t => `${t.l} `).join('')}${leadDevBadge}
`;
let content = '';
if (devTab === 'careers_perm') {
const cfg = DB.appConfig = DB.appConfig || {};
cfg.careerAccessUsers = cfg.careerAccessUsers || [];
const users = DB.users || [];
const userRows = users.map(u => {
const uName = (u.username || u.user || '').toLowerCase();
const hasAccess = isDev(u) || cfg.careerAccessUsers.includes(uName);
const isDeveloperUser = isDev(u);
return `
${escAttr(u.name || u.displayName || uName)} Username: ${escAttr(uName)}
${isDeveloperUser ? 'Developer' : isOwner(u) ? 'Owner' : 'Custom User'}
${isDeveloperUser ? '🟢 Granted (Dev)' : hasAccess ? '🟢 Granted by Dev' : '🔴 Disabled (Default)'}
${isDeveloperUser ? `Always Enabled ` : `
${hasAccess ? '🔒 Revoke Access' : '🔑 Grant Access'}
`}
`;
}).join('');
content = `
💼 Work With Us — Developer Access Control
🔒 Developer Isolation Policy: The "Work With Us" module is disabled by default for all administrators and users. Only developers can grant or revoke access using this panel. Administrators cannot manage this permission from Users & Access.
User / Display Name Role Work With Us Access Action
${userRows || emptyRow(4, 'No users found')}
`;
}
if (devTab === 'announcements') {
const anns = DB.announcements || [];
const annRows = anns.map(a => {
const isActive = (!a.from || a.from <= todayISO()) && (!a.to || a.to >= todayISO());
const typeColors = { info: '#3b82f6', warning: '#f97316', promotion: '#8b5cf6', maintenance: '#dc2626' };
const tc = typeColors[a.type] || '#64748b';
return `
${a.title} ${(a.message || '').slice(0, 80)}${(a.message || '').length > 80 ? '…' : ''}
${a.type || 'info'}
${(a.depts || []).length ? a.depts.join(', ') : 'All'}
${(a.roles || []).length ? a.roles.join(', ') : 'All'}
${a.from || '—'} → ${a.to || '—'}
${isActive ? '● Active' : '○ Inactive'}
Edit
Delete
`;
}).join('');
content = `
📢 Announcement Manager
+ New Announcement
💡 Announcements appear as pop-ups to targeted users when they log in. Use them for company notices, maintenance alerts, or promotions.
Title / Message Type Departments Roles Period Status Action
${annRows || emptyRow(7, 'No announcements created yet')}
`;
}
if (devTab === 'branding') {
const cfg = DB.appConfig || {};
content = `
🎨 App Branding Manager
Customize the app's company name and location subtitle. Changes take effect immediately for all users.
${field('Company Name', 'dev-co-name', escAttr(cfg.companyName || 'MEGACORP'))}
${field('Site Location Subtitle', 'dev-co-sub', escAttr(cfg.siteSubtitle || 'Site Management — Luanda, Angola'))}
${field('Company NIF', 'dev-co-nif', escAttr(cfg.companyNif || '5000975834'))}
${field('Company Email', 'dev-co-email', escAttr(cfg.companyEmail || 'ed@mcti.co.in'))}
${field('Factory Address', 'dev-factory-addr', escAttr(cfg.factoryAddress || 'ZONA INDUSTRIAL Do Municipio do Biopio, Provincia de Benguela'))}
Primary Logo (PNG/SVG recommended)
Choose Logo File
${cfg.logoData ? `
🗑 Remove Logo
`: "
No custom logo — using default logo.png
"}
💾 Save Branding
↩ Revert to Default
`;
setTimeout(() => {
const inp = document.getElementById('dev-logo-file');
if (inp) inp.onchange = async () => {
const fs = await readFiles(inp);
if (fs[0]) { DB.appConfig.logoData = fs[0].data; save(); toast('Logo saved — refresh to see changes'); developerView(); }
};
}, 50);
}
if (devTab === 'flags') {
const cfg = DB.appConfig || {};
const disabled = cfg.disabledModules || [];
const flagItems = Object.entries(MODLABELS).filter(([k]) => k !== 'dashboard' && k !== 'developer').map(([k, l]) => {
const isOff = disabled.includes(k);
return `
${isOff ? '⛔ Disabled' : '✅ Enabled'}
`;
}).join('');
content = `
🚩 Feature Flags
⚠ Disabling a module hides it from ALL users (including owners). Use with care.
${flagItems}
`;
}
if (devTab === 'messaging') {
const ms = getMessagingSettings();
const logs = DB.notificationLog || [];
const auData = paginate(logs, 'audit');
const logRows = auData.items.map(l => `
${l.timestamp ? new Date(l.timestamp).toLocaleString() : '—'}
${l.type || 'WhatsApp'}
${escH(l.recipient || 'N/A')} ${escH(l.phone || '')}
${escH(l.message || '')}${l.attachmentName ? `📄 ${escH(l.attachmentName)} ` : ''}
${l.status}
🟢 Open in WhatsApp 📲 Forward / Resend
`).join('');
content = `
📲 WhatsApp & Automated Messaging Settings
📱 Delivery Status / How It Works
⚙ Configure Rules & Gateway
${ms.enabled ? '🟢 Master Switch: AUTOMATED MESSAGING IS ACTIVE' : '🔴 Master Switch: MESSAGING IS DISABLED'}
When disabled by developer/admin, no automated WhatsApp notifications or SMS messages will be sent.
${ms.enabled ? '⏹ Turn OFF Messaging' : '▶ Turn ON Messaging'}
Active Notification Rules (Use Cases):
1. Worker Attendance Notification
Status: ${ms.notifyWorkerAtt ? '✅ Enabled' : '❌ Disabled'}
Sends WhatsApp to worker on record when attendance is logged.
2. Owner Daily Summary & CSV
Status: ${ms.notifyOwnerDailyAtt ? '✅ Enabled' : '❌ Disabled'}
Sends attendance summary + attached CSV to Owners at end of day.
3. Owner Erection Milestone Alerts
Status: ${ms.notifyOwnerErection ? '✅ Enabled' : '❌ Disabled'}
Sends instant alert to Owners when an Erection task reaches 100%.
📨 Notification Audit Log
${logs.length ? '
🗑 Clear Log ' : ''}
Timestamp Type Recipient & Phone Message / Content Delivery Status Action
${logRows || emptyRow(6, 'No automated notifications dispatched yet')}
${auData.html}
`;
}
if (devTab === 'security') {
const cfg = DB.appConfig || {};
const curLeadCode = cfg.leadDevCode || cfg.ownerDeleteCode || '1@Shivank1984';
const curOwnerCode = cfg.ownerDeleteCode || '1@Shivank1984';
const curDevCode = cfg.devDeleteCode || cfg.ownerDeleteCode || '1@Shivank1984';
const curAbsDays = cfg.absenceAlertDays || 3;
const isLead = isLeadDev(USER);
const secBlock = isLead ? `
👑 1. Lead Developer Master Claim Code
👑 Lead Dev Verified
Master secret code required to unlock Lead Developer status across the platform.
Lead Dev Master Code
💾 Save Lead Dev Master Code
🛡 2. Owner Account Delete Protection Code
Code required whenever anyone attempts to delete an Owner account.
Owner Delete Protection Code
💾 Save Owner Delete Code
💻 3. Developer Account Delete Protection Code
Code required whenever anyone attempts to delete a Developer account.
Developer Delete Protection Code
💾 Save Developer Delete Code
` : `
🔑 Lead Developer Access Control
Enter the Lead Dev Master Code to claim Lead Developer status. Once unlocked, you can view, edit, and configure all 3 security protection codes independently.
🔒 Default initial access code: 1@Shivank1984
`;
content = `
🔐 Security Settings
⚠ These settings protect critical owner and developer accounts from accidental or unauthorized deletion.
${secBlock}
🚨 Consecutive Absence Alert Threshold
If a staff member has no attendance or is marked Absent for this many consecutive days, a red alert card appears on the Dashboard. Default is 3 days .
Threshold (days)
💾 Save Threshold
${isLead ? `
🔧 Restore Karthik Account
Safely restores the karthik (Owner) account if missing. Does NOT delete or change any other user.
🔧 Restore Karthik Now
` : ''}
`;
}
if (devTab === 'auditlog') {
const allAudit = [...(DB.audit || [])].sort((a, b) => (b.at || '').localeCompare(a.at || ''));
const rows = allAudit.slice(0, 100).map(a => `
${(a.at || '').slice(0, 16).replace('T', ' ')}
${a.no || '—'}
${a.action}
${a.desc || '—'}
${a.by || '—'}
${(a.changes || []).map(c => `${c.field} : ${c.from}→${c.to}`).join('; ') || '—'}
`).join('');
content = `
Timestamp Record Action Description User Changes
${rows || emptyRow(6, 'No audit entries')}
`;
}
if (devTab === 'health') {
const colls = Object.entries(DB).map(([k, v]) => {
const cnt = Array.isArray(v) ? v.length : typeof v === 'object' && v ? Object.keys(v).length : '—';
return `${k} ${cnt}
`;
}).join('');
const totalUsers = DB.users.length;
content = `
💊 System Health
✅ Application running — ${totalUsers} user${totalUsers !== 1 ? 's' : ''} registered
${colls}
👥 Registered Users
${DB.users.map(u => `
${(u.name || '?').charAt(0).toUpperCase()}
${u.name}
${u.user} · ${ROLES[u.role] || u.role}
`).join('')}
`;
}
if (devTab === 'backup') {
if (!isLeadDev(USER)) {
content = `
👑
Lead Developer Authority Required
Full System Backup & Restore is restricted exclusively to Lead Developer accounts.
🔒 Security Isolation Policy: This module cannot be granted to custom users or owners in the Users & Access panel. You must authenticate using the Lead Developer Master Code to unlock this capability.
🔑 Claim Lead Developer Status
`;
} else {
content = `
💾 Full System Backup & Restore
LEAD DEV VERIFIED
Generate complete single-file disaster recovery backups or restore the system state atomically.
🔒 Integrity: SHA256 Checksum
📦 Packaging: ZIP (.zip)
📦
Create Full System Backup
Export entire database, files, and settings into one compressed archive.
Backup Scope Includes:
Database Records: Users, Roles, Staff, Attendance, Payroll, Indents, Procurement, Stores, Accounts, Vehicles, Legal Docs, Work With Us applications.
Stored Media & Attachments: Images, PDFs, Scans, Files, Logos, Employee Documents.
System Configurations: AppConfig, Themes, Messaging/WhatsApp settings, Feature Flags.
Integrity Manifest: Timestamps, SHA256 checksums, Record counts, App & DB versions.
Building compressed backup package & computing SHA256 checksum...
📦 Create & Download Full Backup (.zip)
⚡
Restore System from Backup
Upload a .zip backup file to restore database, files, and settings.
⚠️ Critical Warning
Restoration is atomic and complete. The current database and uploads will be replaced with the exact contents of the backup archive. Pre-flight SHA256 validation occurs automatically before execution.
Select Backup File (.zip)
Validating backup checksum and inspecting manifest...
🔍 Validate & Prepare Restore
`;
}
}
view().innerHTML = `
⚙️
Developer Console
App configuration & management tools
${tabBar}
${content}
`;
}
async function triggerFullBackup() {
const progEl = document.getElementById('bkp-progress');
const txtEl = document.getElementById('bkp-progress-txt');
if (progEl) progEl.style.display = 'flex';
if (txtEl) txtEl.textContent = 'Generating full system ZIP archive & SHA256 manifest...';
try {
const res = await fetch('/api/admin/backup', {
headers: SD_TOKEN ? { 'Authorization': 'Bearer ' + SD_TOKEN } : {}
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Backup failed' }));
throw new Error(err.error || 'Server error generating backup');
}
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sitedesk_full_backup_${todayISO()}_${Date.now()}.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
toast('✅ Full System Backup downloaded successfully!');
} catch (err) {
toast('❌ ' + err.message, false);
} finally {
if (progEl) progEl.style.display = 'none';
}
}
async function validateAndPrepareRestore() {
const fileInput = document.getElementById('f-restore-zip');
if (!fileInput || !fileInput.files.length) {
return toast('Please select a .zip backup file first.', false);
}
const file = fileInput.files[0];
const progEl = document.getElementById('rst-progress');
const txtEl = document.getElementById('rst-progress-txt');
if (progEl) progEl.style.display = 'flex';
if (txtEl) txtEl.textContent = 'Reading backup file & verifying SHA256 checksum...';
try {
const base64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = error => reject(error);
reader.readAsDataURL(file);
});
const res = await api('POST', '/api/admin/restore-validate', { zipBase64: base64 });
if (!res.valid) {
throw new Error(res.error || 'Invalid backup package');
}
const m = res.manifest || {};
const countsHtml = Object.entries(m.counts || {}).map(([k, c]) => `
${k}: ${c}
`).join('');
openModal('⚡ Confirm Full System Restore', `
⚠️ WARNING: Full System Data Replacement
Restoring this backup will completely overwrite all existing records, staff data, attendance, accounts, stores, uploaded media, and settings. This operation is non-reversible.
✅ Pre-Flight Validation Passed
SHA256 Checksum: ${m.checksum || 'Verified'}
Backup ID: ${m.backupId || 'N/A'}
Backup Created: ${m.timestamp || 'N/A'}
App / DB Version: ${m.appVersion || '1.0.0'} / ${m.databaseVersion || 'MongoDB'}
Record Counts in Backup:
${countsHtml || '
All system collections
'}
Type RESTORE below to confirm you want to perform a full system restoration:
`, `
Cancel
⚡ Confirm & Execute Restore
`);
} catch (err) {
toast('⛔ Backup Validation Failed: ' + err.message, false);
} finally {
if (progEl) progEl.style.display = 'none';
}
}
async function executeFullRestore(base64Data) {
const confirmInput = document.getElementById('f-confirm-restore-text');
if (!confirmInput || (confirmInput.value || '').trim() !== 'RESTORE') {
return toast('Please type RESTORE in capital letters to confirm.', false);
}
closeModal();
toast('⏳ Executing atomic full system restore... Please wait.', true);
try {
const res = await api('POST', '/api/admin/restore', { zipBase64: base64Data });
if (res.ok) {
toast('✅ System restored successfully! Reloading application...', true);
setTimeout(() => {
window.location.reload();
}, 1500);
} else {
throw new Error(res.error || 'Restore failed');
}
} catch (err) {
toast('❌ Restore Error: ' + err.message, false);
}
}
function formForwardNotif(id) {
const notif = (DB.notificationLog || []).find(n => n.id === id);
if (!notif) return toast("Notification record not found");
const staffOptions = (DB.employees || []).map(e => ({
val: e.phone || e.contact || '',
txt: `${e.name} (${e.role} · ${e.dept}) ${e.phone ? '— ' + e.phone : ''}`
})).filter(o => o.val);
const optHTML = staffOptions.map(o => `${escH(o.txt)} `).join('');
openModal("📲 Forward / Resend Message",
`Forward message to another team member or supplier via WhatsApp.
Select Recipient from Staff List
-- Choose Staff Member --
${optHTML}
${field("Recipient Name / Label", "fwd-name", "")}
${field("WhatsApp Phone Number (+91...)", "fwd-phone", notif.phone !== 'N/A' ? notif.phone : "")}
Message Content
${notif.attachmentName ? `📄 Attached File: ${escH(notif.attachmentName)}
` : ''}`,
[
{ label: "Cancel", cls: "secondary", fn: closeModal },
{
label: "📲 Forward via WhatsApp", cls: "", fn: () => {
const phone = document.getElementById("fwd-phone").value.trim();
const selEl = document.getElementById("fwd-staff-sel");
const selectedText = selEl.selectedIndex > 0 ? selEl.options[selEl.selectedIndex].text : '';
const recipient = document.getElementById("fwd-name").value.trim() || selectedText || "Forwarded User";
const message = document.getElementById("fwd-msg").value.trim();
if (!phone) return toast("Enter a valid WhatsApp phone number");
if (!message) return toast("Message content cannot be empty");
sendWhatsAppNotification('forwarded_message', {
recipient: recipient,
phone: phone,
message: message,
attachmentCsv: notif.attachmentCsv || null,
attachmentName: notif.attachmentName || null
});
toast("Message forwarded ✓");
closeModal();
if (typeof developerView === 'function') developerView();
}
}
]
);
}
window.saveBranding = function () {
const cfg = DB.appConfig = DB.appConfig || {};
const name = val('dev-co-name').trim() || cfg.companyName || 'MEGACORP';
const sub = val('dev-co-sub').trim() || cfg.siteSubtitle || 'Site Management — Luanda, Angola';
const nif = val('dev-co-nif').trim() || cfg.companyNif || '5000975834';
const email = val('dev-co-email').trim() || cfg.companyEmail || 'ed@mcti.co.in';
const addr = val('dev-factory-addr').trim() || cfg.factoryAddress || 'ZONA INDUSTRIAL Do Municipio do Biopio, Provincia de Benguela';
cfg.companyName = name;
cfg.siteSubtitle = sub;
cfg.companyNif = nif;
cfg.companyEmail = email;
cfg.factoryAddress = addr;
// Force lastDB stale so diff check triggers a real sync
if (lastDB) lastDB.appConfig = {};
save();
applyBranding();
toast('Branding saved ✔');
};
function applyBranding() {
const cfg = DB.appConfig || {};
const sub = cfg.siteSubtitle || 'Site Management — Luanda, Angola';
const lgsub = document.getElementById('lg-sub'); if (lgsub) lgsub.textContent = sub;
const sbb2 = document.getElementById('sb-b2'); if (sbb2) sbb2.textContent = sub;
// Logo — use custom data URI if set, otherwise fall back to embedded LOGO_DATA_URI
const logoSrc = cfg.logoData || (typeof LOGO_DATA_URI !== 'undefined' ? LOGO_DATA_URI : 'logo.png');
document.querySelectorAll('.logo-img').forEach(img => { img.src = logoSrc; img.style.display = 'block'; });
const topLogo = document.getElementById('topLogoImg'); if (topLogo) topLogo.src = logoSrc;
const lgMark = document.getElementById('lgMark'); if (lgMark) lgMark.style.display = 'none';
const sbMark = document.getElementById('sbMark'); if (sbMark) sbMark.style.display = 'none';
const lgb1 = document.getElementById('lg-b1'); if (lgb1) lgb1.style.display = 'none';
const sbb1 = document.getElementById('sb-b1'); if (sbb1) sbb1.style.display = 'none';
document.title = 'Prabandh ERP — MEGACORP INDÚSTRIA (SU), LDA';
}
window.revertBranding = function () {
if (!confirm('Reset company name and subtitle to MEGACORP defaults? Logo will not be changed.')) return;
const cfg = DB.appConfig = DB.appConfig || {};
cfg.companyName = 'MEGACORP';
cfg.siteSubtitle = 'Site Management \u2014 Luanda, Angola';
cfg.companyNif = '5000975834';
cfg.companyEmail = 'ed@mcti.co.in';
cfg.factoryAddress = 'ZONA INDUSTRIAL Do Municipio do Biopio, Provincia de Benguela';
if (lastDB) lastDB.appConfig = {};
save();
applyBranding();
developerView();
toast('Branding reverted to default \u2714');
};
window.removeLogo = function () {
if (!confirm('Remove the custom logo and revert to the default MEGACORP logo?')) return;
const cfg = DB.appConfig = DB.appConfig || {};
delete cfg.logoData;
if (lastDB) lastDB.appConfig = {};
save();
// Revert to default logo.png
document.querySelectorAll('.logo-img').forEach(img => { img.src = 'logo.png'; img.style.display = 'block'; });
const lgMark = document.getElementById('lgMark'); if (lgMark) lgMark.style.display = 'none';
const sbMark = document.getElementById('sbMark'); if (sbMark) sbMark.style.display = 'none';
developerView();
toast('Logo reverted to default ✔');
};
function formAnnouncement(ed) {
const TYPES = ['info', 'warning', 'promotion', 'maintenance'];
const ROLE_OPTS = ['Owner', 'Manager', 'Staff', 'Custom'];
const curDepts = (ed && ed.depts) || [];
const curRoles = (ed && ed.roles) || [];
const deptChecks = DEPTS.map(d => ` ${d} `).join('');
const roleChecks = ROLE_OPTS.map(r => ` ${r} `).join('');
openModal(ed ? 'Edit Announcement' : 'New Announcement',
field('Title', 'ann-title', escAttr(ed && ed.title || '')) +
`Message / Body
` +
sel('Type', 'ann-type', TYPES, ed && ed.type || 'info') +
`` + field('Show From (date)', 'ann-from', ed && ed.from || todayISO(), 'date') + field('Show Until (date)', 'ann-to', ed && ed.to || '', 'date') + `
` +
`Target Departments — leave all unchecked = all departments
${deptChecks}
` +
`Target Roles — leave unchecked = all roles
${roleChecks}
`,
[{ label: 'Cancel', cls: 'secondary', fn: closeModal }, {
label: ed ? 'Save' : 'Create', cls: '', fn: () => {
const title = val('ann-title'); if (!title) return alertField();
const message = document.getElementById('ann-msg').value.trim();
const depts = [...document.querySelectorAll('.ann-dept:checked')].map(x => x.value);
const roles = [...document.querySelectorAll('.ann-role:checked')].map(x => x.value);
if (ed) {
ed.title = title; ed.message = message; ed.type = val('ann-type');
ed.from = val('ann-from'); ed.to = val('ann-to'); ed.depts = depts; ed.roles = roles;
} else {
DB.announcements.unshift({
id: uid('ann'), title, message, type: val('ann-type'),
from: val('ann-from'), to: val('ann-to'), depts, roles, createdBy: USER.displayName || USER.username, createdAt: todayISO()
});
}
save(); toast(ed ? 'Announcement updated' : 'Announcement created ✔'); closeModal(); render();
}
}]);
}
function exportAuditLog() {
const rows = [["Timestamp", "Record No", "Action", "Description", "User", "Changes"]];
(DB.audit || []).forEach(a => {
rows.push([
(a.at || '').slice(0, 16).replace('T', ' '),
a.no || '',
a.action || '',
a.desc || '',
a.by || '',
(a.changes || []).map(c => `${c.field}: ${c.from}→${c.to}`).join('; ')
]);
});
const csv = rows.map(r => r.map(csvEsc).join(',')).join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' });
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `AuditLog_${todayISO()}.csv`;
document.body.appendChild(a); a.click(); a.remove();
toast('Audit log exported');
}
// ── Announcement popup check on login ─────────────────────────────────────────
function checkAndShowAnnouncements() {
if (!DB.announcements || !DB.announcements.length) return;
const today = todayISO();
const userRoleLabel = ROLES[USER.role] || USER.role;
const pending = DB.announcements.filter(a => {
if (a.from && a.from > today) return false;
if (a.to && a.to < today) return false;
if (a.depts && a.depts.length && !isOwner(USER)) {
const userDepts = USER.depts || [];
if (userDepts.length && !a.depts.some(d => userDepts.includes(d))) return false;
}
if (a.roles && a.roles.length && !a.roles.some(r => r.toLowerCase() === userRoleLabel.toLowerCase())) return false;
const seenKey = 'sd_seen_' + a.id;
if (sessionStorage.getItem(seenKey)) return false;
return true;
});
if (!pending.length) return;
const TYPE_STYLES = { info: { bg: '#eff6ff', border: '#3b82f6', icon: 'ℹ️' }, warning: { bg: '#fff7ed', border: '#f97316', icon: '⚠️' }, promotion: { bg: '#f5f3ff', border: '#8b5cf6', icon: '🎉' }, maintenance: { bg: '#fff7f7', border: '#dc2626', icon: '🔧' } };
let idx = 0;
function showNext() {
if (idx >= pending.length) { closeModal(); return; }
const a = pending[idx];
const s = TYPE_STYLES[a.type] || TYPE_STYLES.info;
sessionStorage.setItem('sd_seen_' + a.id, '1');
openModal(s.icon + ' ' + a.title,
`
${pending.length > 1 ? `${idx + 1} of ${pending.length} announcements
` : ''}`,
[{ label: pending.length > 1 && idx < pending.length - 1 ? 'Next →' : 'Got it', cls: '', fn: () => { idx++; showNext(); } }]);
}
showNext();
}
Install Prabandh ERP
Add to your home screen for quick access
Not now
Install App