Files
John eead8f5ffd
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s
sale order
2026-10-05 18:45:17 +05:30

769 lines
38 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<script src="/auth-guard.js?v=4"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Salary Cost Centre | MITRA ERP Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Space Grotesk',sans-serif;background:#f0f2f5;color:#1a1a2e;min-height:100vh}
.pg{padding:20px 24px}
.hdr{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:12px;margin-bottom:18px}
.hdr h1{font-size:20px;font-weight:700;color:#1a1a2e}.hdr h1 span{color:#16a34a}
.btn{height:36px;padding:0 16px;border-radius:8px;font-size:12px;font-weight:700;font-family:inherit;border:none;cursor:pointer;transition:all .15s;display:inline-flex;align-items:center;gap:6px;white-space:nowrap}
.btn-primary{background:#16a34a;color:#fff}.btn-primary:hover{background:#15803d}
.btn-primary:disabled{background:#b0bec5;cursor:not-allowed}
.btn-outline{background:#fff;color:#16a34a;border:1.5px solid #16a34a}.btn-outline:hover{background:#f0fdf4}
.btn-danger{background:#ef4444;color:#fff}.btn-danger:hover{background:#dc2626}
.card{background:#fff;border-radius:14px;box-shadow:0 1px 8px rgba(0,0,0,.08);overflow:hidden}
.filter-bar{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;padding:16px 20px;border-bottom:1px solid #e8eaed}
.fg{display:flex;flex-direction:column;gap:5px}
.fg label{font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.6px}
.fg input,.fg select{height:36px;border:1.5px solid #dde1e7;border-radius:8px;padding:0 12px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s}
.fg input:focus,.fg select:focus{border-color:#16a34a;box-shadow:0 0 0 3px rgba(22,163,74,.08)}
.summary-chips{display:flex;gap:8px;flex-wrap:wrap;padding:12px 20px;border-bottom:1px solid #e8eaed;background:#f8fafb}
.chip{background:#fff;border:1.5px solid #bbf7d0;border-radius:10px;padding:6px 14px;font-size:11px;font-weight:600;color:#16a34a;min-width:90px}
.chip span{font-size:15px;font-weight:700;display:block;color:#1a1a2e;margin-top:1px}
.tabs{display:flex;justify-content:space-evenly;border-bottom:2px solid #e8eaed;background:#fff}
.tab{flex:1;text-align:center;padding:14px 10px;font-size:12px;font-weight:700;color:#6b7280;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-2px;white-space:nowrap;transition:all .15s}
.tab:hover{color:#16a34a;background:#f0fdf4}
.tab.active{color:#16a34a;border-bottom-color:#16a34a;background:#f0fdf4}
.tab-badge{display:inline-block;background:#dcfce7;color:#16a34a;border-radius:10px;padding:1px 7px;font-size:10px;font-weight:700;margin-left:5px}
.tab.active .tab-badge{background:#16a34a;color:#fff}
.tab-content{display:none}.tab-content.active{display:block}
.tbl-wrap{overflow-x:auto;max-height:52vh}
.tbl-wrap::-webkit-scrollbar{height:6px;width:6px}
.tbl-wrap::-webkit-scrollbar-thumb{background:#c1c7ce;border-radius:4px}
table{border-collapse:collapse;min-width:100%;font-size:12px}
thead th{background:#f0fdf4;color:#374151;font-weight:700;padding:9px 12px;text-align:left;white-space:nowrap;position:sticky;top:0;z-index:2;border-bottom:2px solid #bbf7d0;font-size:10px;text-transform:uppercase;letter-spacing:.5px}
tbody tr{border-bottom:1px solid #f0f2f4;transition:background .1s}
tbody tr:hover{background:#f0fdf4}
tbody td{padding:8px 12px;color:#374151;white-space:nowrap}
tbody td.num{text-align:right;font-variant-numeric:tabular-nums}
.empty{text-align:center;padding:48px 20px;color:#9ca3af;font-size:13px}
.status-bar{display:flex;align-items:center;gap:10px;padding:10px 20px;background:#f8fafb;border-top:1px solid #e8eaed;font-size:12px;color:#6b7280;border-radius:0 0 14px 14px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#d1d5db;flex-shrink:0}
.status-dot.ok{background:#16a34a}.status-dot.err{background:#ef4444}.status-dot.loading{background:#f59e0b;animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.toast{position:fixed;bottom:24px;right:24px;z-index:9999;padding:12px 20px;border-radius:10px;font-size:13px;font-weight:600;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.15);transform:translateY(20px);opacity:0;transition:all .3s;pointer-events:none;max-width:360px}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#16a34a}.toast.error{background:#ef4444}.toast.info{background:#3b82f6}
/* Upload pane */
.upload-zone{border:2px dashed #bbf7d0;border-radius:12px;padding:40px 20px;text-align:center;cursor:pointer;transition:all .2s;background:#f0fdf4;margin:20px}
.upload-zone:hover,.upload-zone.drag{border-color:#16a34a;background:#dcfce7}
.upload-zone input{display:none}
.upload-zone .icon{font-size:40px;margin-bottom:12px}
.upload-zone p{color:#374151;font-size:14px;font-weight:600}
.upload-zone small{color:#9ca3af;font-size:12px}
.upload-results{padding:0 20px 20px}
.sheet-badge{display:inline-flex;align-items:center;gap:6px;background:#dcfce7;color:#15803d;border-radius:8px;padding:4px 10px;font-size:11px;font-weight:700;margin:4px}
.sheet-badge.skipped{background:#fef3c7;color:#92400e}
/* Configure sub-tabs */
.cfg-subtabs{display:flex;gap:0;border-bottom:2px solid #e8eaed;background:#f8fafb;padding:0 20px}
.cfg-subtab{padding:10px 16px;font-size:12px;font-weight:700;color:#6b7280;cursor:pointer;border-bottom:3px solid transparent;margin-bottom:-2px;white-space:nowrap;background:none;border-top:none;border-left:none;border-right:none;font-family:inherit}
.cfg-subtab:hover{color:#16a34a}
.cfg-subtab.active{color:#16a34a;border-bottom-color:#16a34a}
.cfg-subpane{display:none}.cfg-subpane.active{display:block}
/* Dept mapping table */
.dept-map-wrap{padding:16px 20px;max-height:55vh;overflow-y:auto}
.dept-map-table{width:100%;border-collapse:collapse;font-size:12px}
.dept-map-table th{background:#f0fdf4;font-size:10px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.5px;padding:8px 12px;border-bottom:2px solid #bbf7d0;text-align:left;position:sticky;top:0}
.dept-map-table td{padding:8px 12px;border-bottom:1px solid #f0f2f4}
.dept-map-table tr:hover td{background:#f0fdf4}
.dept-map-select{height:32px;border:1.5px solid #dde1e7;border-radius:7px;padding:0 8px;font-size:12px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;min-width:200px;transition:border-color .15s}
.dept-map-select:focus{border-color:#16a34a}
.dept-map-select.mapped{border-color:#16a34a;background:#f0fdf4;color:#15803d;font-weight:600}
.dept-map-select.unmapped{border-color:#f59e0b;background:#fffbeb}
/* Configure pane */
.cfg-layout{display:flex;min-height:400px}
.cfg-sidebar{width:220px;flex-shrink:0;border-right:1px solid #e8eaed;overflow-y:auto;max-height:60vh}
.cfg-sheet-item{padding:12px 16px;cursor:pointer;border-bottom:1px solid #f3f4f6;transition:background .1s;font-size:12px}
.cfg-sheet-item:hover{background:#f0fdf4}
.cfg-sheet-item.active{background:#dcfce7;border-left:3px solid #16a34a}
.cfg-sheet-item .sname{font-weight:700;color:#1a1a2e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px}
.cfg-sheet-item .snet{font-size:11px;color:#6b7280;margin-top:2px}
.cfg-panel{flex:1;padding:20px;overflow-y:auto;max-height:60vh}
.cfg-panel h3{font-size:13px;font-weight:700;color:#1a1a2e;margin-bottom:4px}
.cfg-panel .cfg-sub{font-size:11px;color:#6b7280;margin-bottom:16px}
.cc-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px}
.cc-item{display:flex;flex-direction:column;gap:4px}
.cc-item label{font-size:10px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cc-item input{height:34px;border:1.5px solid #dde1e7;border-radius:7px;padding:0 8px 0 10px;font-size:13px;font-family:inherit;color:#1a1a2e;background:#fff;outline:none;transition:border-color .15s;width:100%}
.cc-item input:focus{border-color:#16a34a;box-shadow:0 0 0 3px rgba(22,163,74,.08)}
.cc-item.bb-capd input{border-color:#f59e0b;background:#fffbeb}
.total-bar{display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;font-size:12px;font-weight:700;margin-bottom:16px;background:#f8fafb;border:1.5px solid #e8eaed}
.total-bar.ok{background:#dcfce7;border-color:#bbf7d0;color:#15803d}
.total-bar.over{background:#fee2e2;border-color:#fca5a5;color:#dc2626}
.total-bar.under{background:#fef3c7;border-color:#fde68a;color:#92400e}
.bb-split{background:#fffbeb;border:1.5px solid #fde68a;border-radius:8px;padding:14px;margin-bottom:16px}
.bb-split h4{font-size:11px;font-weight:700;color:#92400e;text-transform:uppercase;letter-spacing:.4px;margin-bottom:10px}
.bb-split-row{display:flex;align-items:center;gap:10px;font-size:12px}
.bb-split-row input{width:70px;height:30px;border:1.5px solid #fde68a;border-radius:6px;padding:0 8px;font-size:13px;font-family:inherit;outline:none}
.bb-split-row input:focus{border-color:#f59e0b}
.bb-split-total{font-size:11px;font-weight:700;margin-left:8px}
.bb-split-total.ok{color:#16a34a}.bb-split-total.bad{color:#ef4444}
/* Summary pane */
.summary-table-wrap{padding:20px}
.summary-section-title{font-size:11px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:16px 0 8px}
.grand-total-row td{font-weight:700;background:#f0fdf4;color:#15803d}
.pct-bar{display:inline-block;height:6px;background:#16a34a;border-radius:3px;vertical-align:middle;margin-left:6px;opacity:.6}
</style>
<link rel="stylesheet" href="/theme.css?v=3"/>
</head>
<body>
<script src="/sidebar.js?v=31"></script>
<div class="pg">
<div class="hdr">
<h1><span>Salary</span> Cost Centre</h1>
<div style="display:flex;gap:8px">
<button class="btn btn-outline" id="btnExport" disabled>Export Excel</button>
</div>
</div>
<div class="card">
<div class="filter-bar">
<div class="fg">
<label>Period (Month)</label>
<input type="month" id="periodInput">
</div>
<button class="btn btn-primary" id="btnLoad">Load</button>
</div>
<div class="summary-chips" id="summaryChips" style="display:none">
<div class="chip">Sheets<span id="cntSheets">—</span></div>
<div class="chip">Employees<span id="cntEmps">—</span></div>
<div class="chip">Total Gross<span id="cntGross">—</span></div>
<div class="chip">Total Net<span id="cntNet">—</span></div>
</div>
<div class="tabs">
<div class="tab active" data-tab="upload">Upload</div>
<div class="tab" data-tab="data">Data <span class="tab-badge" id="tbData">0</span></div>
<div class="tab" data-tab="configure">Configure</div>
<div class="tab" data-tab="summary">Summary</div>
</div>
<!-- UPLOAD TAB -->
<div class="tab-content active" id="pane-upload">
<div style="padding:20px">
<div class="fg" style="max-width:200px;margin-bottom:16px">
<label>Upload Period</label>
<input type="month" id="uploadPeriod">
</div>
<div class="upload-zone" id="dropZone">
<input type="file" id="fileInput" accept=".xlsx,.xls">
<div class="icon"></div>
<p>Drop salary Excel here or click to browse</p>
<small>All sheets will be parsed automatically</small>
</div>
<div style="text-align:center;margin-top:4px">
<button class="btn btn-primary" id="btnUpload" disabled>Upload &amp; Parse</button>
</div>
</div>
<div class="upload-results" id="uploadResults" style="display:none">
<div style="font-size:12px;font-weight:700;color:#374151;margin-bottom:10px">Sheets Detected:</div>
<div id="sheetBadges"></div>
</div>
</div>
<!-- DATA TAB -->
<div class="tab-content" id="pane-data">
<div style="padding:12px 20px;border-bottom:1px solid #e8eaed;display:flex;align-items:center;gap:12px">
<div class="fg">
<label>Sheet</label>
<select id="sheetSelect" style="min-width:200px"><option value="">— select sheet —</option></select>
</div>
<div style="font-size:12px;color:#6b7280;margin-top:18px" id="rowCountLabel"></div>
</div>
<div class="tbl-wrap">
<div class="empty" id="dataEmpty">Select a period and sheet to view data</div>
<table id="dataTable" style="display:none">
<thead><tr>
<th>Sr</th><th>Emp Code</th><th>Name</th><th>Department</th>
<th style="text-align:right">Gross</th><th style="text-align:right">Net Pay</th>
</tr></thead>
<tbody id="dataTbody"></tbody>
</table>
</div>
</div>
<!-- CONFIGURE TAB -->
<div class="tab-content" id="pane-configure">
<div class="cfg-subtabs">
<button class="cfg-subtab active" data-sub="deptmap">Dept &rarr; Cost Centre</button>
<button class="cfg-subtab" data-sub="sheetalloc">Sheet % Allocation</button>
</div>
<!-- Dept Mapping sub-pane -->
<div class="cfg-subpane active" id="sub-deptmap">
<div style="padding:12px 20px 0;display:flex;align-items:center;justify-content:space-between">
<div style="font-size:12px;color:#6b7280">Map each department / cost centre value from Excel to one of the 13 cost centres. Auto-saves on change.</div>
<button class="btn btn-outline" id="btnReloadDepts" style="height:30px;font-size:11px">Refresh</button>
</div>
<div class="dept-map-wrap" id="deptMapWrap">
<div class="empty">Load a period to see department values</div>
</div>
</div>
<!-- Sheet Allocation sub-pane -->
<div class="cfg-subpane" id="sub-sheetalloc">
<div class="cfg-layout">
<div class="cfg-sidebar" id="cfgSidebar">
<div class="empty" style="padding:20px;font-size:12px">Load a period first</div>
</div>
<div class="cfg-panel" id="cfgPanel">
<div class="empty">Select a salary sheet from the left to configure % allocations (used as fallback for unmapped departments)</div>
</div>
</div>
</div>
</div>
<!-- SUMMARY TAB -->
<div class="tab-content" id="pane-summary">
<div class="summary-table-wrap" id="summaryContent">
<div class="empty">Load a period to view cost centre summary</div>
</div>
</div>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<span id="statusMsg">Select a period and click Load</span>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function(){
const tok = sessionStorage.getItem('portal_token');
if (!tok) { window.location.href = '/'; return; }
const hdrs = { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' };
const COST_CENTRES = [
'Administration overhead','BB & CAPD','BLOOD BAG','Boiler','CAPD','Diff','Equipment',
'FACTORY OVERHEAD','POWER & FUEL','QA','QC','R&D',
'Selling & distribution overhead','STENT',
];
let currentPeriod = '';
let sheetsData = [];
let configData = {};
let bbRatioData = {};
let selectedCfgSheet = null;
let summaryData = null;
let parsedFile = null;
let deptMappingsData = {}; // { dept_name: cost_centre }
// ── Init ──────────────────────────────────────────────────────────────────
(function(){
const now = new Date();
const val = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2,'0');
document.getElementById('periodInput').value = val;
document.getElementById('uploadPeriod').value = val;
})();
// ── Tabs ──────────────────────────────────────────────────────────────────
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active'));
t.classList.add('active');
document.getElementById('pane-' + t.dataset.tab).classList.add('active');
if (t.dataset.tab === 'configure' && currentPeriod) { renderCfgSidebar(); loadDeptMappings(); }
if (t.dataset.tab === 'summary' && currentPeriod) loadSummary();
}));
// Configure sub-tabs
document.querySelectorAll('.cfg-subtab').forEach(t => t.addEventListener('click', () => {
document.querySelectorAll('.cfg-subtab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.cfg-subpane').forEach(x => x.classList.remove('active'));
t.classList.add('active');
document.getElementById('sub-' + t.dataset.sub).classList.add('active');
if (t.dataset.sub === 'deptmap' && currentPeriod) loadDeptMappings();
}));
document.getElementById('btnReloadDepts').addEventListener('click', () => loadDeptMappings());
// ── Load ──────────────────────────────────────────────────────────────────
document.getElementById('btnLoad').addEventListener('click', () => {
currentPeriod = document.getElementById('periodInput').value;
if (!currentPeriod) { showToast('Select a period','error'); return; }
loadSheets();
});
async function loadSheets() {
setStatus('Loading…','loading');
try {
const r = await fetch(`/api/salary/sheets?period=${currentPeriod}`,{headers:hdrs}).then(r=>r.json());
if (!r.success) { setStatus(r.message,'err'); return; }
sheetsData = r.data || [];
renderChips();
renderSheetSelect();
renderCfgSidebar();
await loadConfig();
setStatus(`Loaded ${sheetsData.length} sheets for ${currentPeriod}`,'ok');
document.getElementById('btnExport').disabled = sheetsData.length === 0;
document.getElementById('tbData').textContent = sheetsData.reduce((s,x) => s + (x.row_count||0), 0);
document.getElementById('summaryChips').style.display = 'flex';
} catch(e) { setStatus('Error: '+e.message,'err'); }
}
function renderChips() {
const totalEmps = sheetsData.reduce((s,x)=>s+(x.row_count||0),0);
const totalGross = sheetsData.reduce((s,x)=>s+(parseFloat(x.total_gross)||0),0);
const totalNet = sheetsData.reduce((s,x)=>s+(parseFloat(x.total_net)||0),0);
document.getElementById('cntSheets').textContent = sheetsData.length;
document.getElementById('cntEmps').textContent = totalEmps;
document.getElementById('cntGross').textContent = fmtNum(totalGross);
document.getElementById('cntNet').textContent = fmtNum(totalNet);
}
function renderSheetSelect() {
const sel = document.getElementById('sheetSelect');
sel.innerHTML = '<option value="">— select sheet —</option>' +
sheetsData.map(s => `<option value="${esc(s.sheet_name)}">${esc(s.sheet_name)} (${s.row_count} rows)</option>`).join('');
}
// ── Dept Mapping ──────────────────────────────────────────────────────────
async function loadDeptMappings() {
const wrap = document.getElementById('deptMapWrap');
wrap.innerHTML = '<div class="empty">Loading…</div>';
try {
const r = await fetch(`/api/salary/dept-mappings?period=${currentPeriod}`,{headers:hdrs}).then(r=>r.json());
if (!r.success) { wrap.innerHTML = `<div class="empty">${esc(r.message)}</div>`; return; }
deptMappingsData = {};
(r.mappings || []).forEach(m => { deptMappingsData[m.dept_name] = m.cost_centre || ''; });
const depts = r.depts || [];
if (!depts.length) { wrap.innerHTML = '<div class="empty">No department data found for this period. Upload salary Excel first.</div>'; return; }
const opts = ['<option value="">— Not mapped —</option>',
...COST_CENTRES.map(cc => `<option value="${esc(cc)}">${esc(cc)}</option>`)
].join('');
wrap.innerHTML = `
<table class="dept-map-table">
<thead><tr>
<th>Department / Cost Centre (from Excel)</th>
<th style="text-align:right">Employees</th>
<th style="text-align:right">Total Net Pay</th>
<th>Map to Cost Centre</th>
<th>Status</th>
</tr></thead>
<tbody>${depts.map(d => {
const mapped = deptMappingsData[d.dept_name] || '';
return `<tr>
<td><b>${esc(d.dept_name)}</b></td>
<td style="text-align:right">${d.emp_count}</td>
<td style="text-align:right">${fmtNum(parseFloat(d.total_net)||0)}</td>
<td><select class="dept-map-select ${mapped?'mapped':'unmapped'}" data-dept="${esc(d.dept_name)}">
${opts}
</select></td>
<td id="dms-${esc(d.dept_name.replace(/[^a-z0-9]/gi,'_'))}">
${mapped ? `<span style="color:#16a34a;font-weight:700">&#10003; ${esc(mapped)}</span>` : '<span style="color:#f59e0b">&#9679; Unmapped</span>'}
</td>
</tr>`;
}).join('')}</tbody>
</table>`;
// Set current values
wrap.querySelectorAll('.dept-map-select').forEach(sel => {
const dept = sel.dataset.dept;
sel.value = deptMappingsData[dept] || '';
sel.addEventListener('change', () => saveDeptMapping(dept, sel.value, sel));
});
} catch(e) { wrap.innerHTML = `<div class="empty">Error: ${esc(e.message)}</div>`; }
}
async function saveDeptMapping(deptName, costCentre, selEl) {
try {
const r = await fetch('/api/salary/dept-mapping', {
method: 'POST', headers: hdrs,
body: JSON.stringify({ dept_name: deptName, cost_centre: costCentre }),
}).then(r => r.json());
if (r.success) {
deptMappingsData[deptName] = costCentre;
if (selEl) {
selEl.className = 'dept-map-select ' + (costCentre ? 'mapped' : 'unmapped');
const key = deptName.replace(/[^a-z0-9]/gi,'_');
const statusEl = document.getElementById('dms-' + key);
if (statusEl) statusEl.innerHTML = costCentre
? `<span style="color:#16a34a;font-weight:700">&#10003; ${esc(costCentre)}</span>`
: '<span style="color:#f59e0b">&#9679; Unmapped</span>';
}
showToast(`Saved: ${deptName} → ${costCentre || 'unmapped'}`, 'success');
} else { showToast(r.message, 'error'); }
} catch(e) { showToast(e.message, 'error'); }
}
// ── Config ────────────────────────────────────────────────────────────────
async function loadConfig() {
const r = await fetch('/api/salary/config',{headers:hdrs}).then(r=>r.json());
if (!r.success) return;
configData = {};
(r.config || []).forEach(c => {
if (!configData[c.sheet_name]) configData[c.sheet_name] = {};
configData[c.sheet_name][c.cost_centre] = parseFloat(c.alloc_pct) || 0;
});
bbRatioData = {};
(r.bbRatios || []).forEach(b => { bbRatioData[b.sheet_name] = b; });
}
function renderCfgSidebar() {
const sb = document.getElementById('cfgSidebar');
if (!sheetsData.length) {
sb.innerHTML = '<div class="empty" style="padding:20px;font-size:12px">Load a period first</div>';
return;
}
sb.innerHTML = sheetsData.map(s => `
<div class="cfg-sheet-item${selectedCfgSheet===s.sheet_name?' active':''}" data-sheet="${esc(s.sheet_name)}">
<div class="sname" title="${esc(s.sheet_name)}">${esc(s.sheet_name)}</div>
<div class="snet">Net: ${fmtNum(parseFloat(s.total_net)||0)} &bull; ${s.row_count} emp</div>
</div>`).join('');
sb.querySelectorAll('.cfg-sheet-item').forEach(el =>
el.addEventListener('click', () => {
selectedCfgSheet = el.dataset.sheet;
sb.querySelectorAll('.cfg-sheet-item').forEach(x => x.classList.remove('active'));
el.classList.add('active');
renderCfgPanel(selectedCfgSheet);
})
);
}
function renderCfgPanel(sheetName) {
const panel = document.getElementById('cfgPanel');
const alloc = configData[sheetName] || {};
const bb = bbRatioData[sheetName] || { bb_pct: 70, capd_pct: 30 };
const sheet = sheetsData.find(s => s.sheet_name === sheetName);
const netAmt = sheet ? parseFloat(sheet.total_net) || 0 : 0;
const ccInputs = COST_CENTRES.map(cc => {
const pct = alloc[cc] || 0;
const isBB = cc === 'BB & CAPD';
return `<div class="cc-item${isBB?' bb-capd':''}">
<label title="${esc(cc)}">${esc(cc)}</label>
<input type="number" min="0" max="100" step="0.01" value="${pct}" data-cc="${esc(cc)}"
placeholder="0" style="text-align:right">
</div>`;
}).join('');
panel.innerHTML = `
<h3>${esc(sheetName)}</h3>
<div class="cfg-sub">Net Pay: <b>${fmtNum(netAmt)}</b> &bull; Configure % allocation to each cost centre</div>
<div class="total-bar" id="cfgTotalBar">Total: 0%</div>
<div class="cc-grid">${ccInputs}</div>
<div class="bb-split" id="bbSplitBox">
<h4>BB &amp; CAPD Split Ratio</h4>
<div class="bb-split-row">
<span>BLOOD BAG:</span>
<input type="number" id="bbPct" min="0" max="100" step="0.01" value="${parseFloat(bb.bb_pct)||70}">
<span>%</span>
<span style="margin:0 8px">|</span>
<span>CAPD:</span>
<input type="number" id="capdPct" min="0" max="100" step="0.01" value="${parseFloat(bb.capd_pct)||30}">
<span>%</span>
<span class="bb-split-total" id="bbSplitTotal"></span>
</div>
<div style="font-size:11px;color:#92400e;margin-top:6px">
Applied when BB &amp; CAPD % &gt; 0. For Probationer, leave BB &amp; CAPD % as 0 until decided.
</div>
</div>
<button class="btn btn-primary" id="btnSaveCfg">&#10003; Save Configuration</button>`;
// Live total update
function updateTotal() {
const inputs = panel.querySelectorAll('input[data-cc]');
let total = 0;
inputs.forEach(i => { total += parseFloat(i.value) || 0; });
total = Math.round(total * 100) / 100;
const bar = document.getElementById('cfgTotalBar');
bar.textContent = `Total: ${total}%`;
bar.className = 'total-bar ' + (total === 100 ? 'ok' : total > 100 ? 'over' : 'under');
}
function updateBBTotal() {
const bb = parseFloat(document.getElementById('bbPct').value) || 0;
const cap = parseFloat(document.getElementById('capdPct').value) || 0;
const tot = Math.round((bb + cap) * 100) / 100;
const el = document.getElementById('bbSplitTotal');
el.textContent = `= ${tot}%`;
el.className = 'bb-split-total ' + (tot === 100 ? 'ok' : 'bad');
}
panel.querySelectorAll('input[data-cc]').forEach(i => i.addEventListener('input', updateTotal));
document.getElementById('bbPct') .addEventListener('input', updateBBTotal);
document.getElementById('capdPct').addEventListener('input', updateBBTotal);
updateTotal(); updateBBTotal();
document.getElementById('btnSaveCfg').addEventListener('click', () => saveCfg(sheetName));
}
async function saveCfg(sheetName) {
const panel = document.getElementById('cfgPanel');
const inputs = panel.querySelectorAll('input[data-cc]');
const entries = [];
inputs.forEach(i => {
entries.push({ sheet_name: sheetName, cost_centre: i.dataset.cc, alloc_pct: parseFloat(i.value) || 0 });
});
const bbPct = parseFloat(document.getElementById('bbPct').value) || 70;
const capdPct = parseFloat(document.getElementById('capdPct').value) || 30;
try {
const r = await fetch('/api/salary/config', {
method: 'POST', headers: hdrs,
body: JSON.stringify({ entries, bbRatios: [{ sheet_name: sheetName, bb_pct: bbPct, capd_pct: capdPct }] }),
}).then(r => r.json());
if (r.success) {
if (!configData[sheetName]) configData[sheetName] = {};
entries.forEach(e => { configData[sheetName][e.cost_centre] = e.alloc_pct; });
bbRatioData[sheetName] = { bb_pct: bbPct, capd_pct: capdPct };
showToast('Configuration saved','success');
} else { showToast(r.message,'error'); }
} catch(e) { showToast(e.message,'error'); }
}
// ── Data tab ──────────────────────────────────────────────────────────────
document.getElementById('sheetSelect').addEventListener('change', async function() {
const sheet = this.value;
if (!sheet || !currentPeriod) return;
try {
const r = await fetch(`/api/salary/rows?period=${currentPeriod}&sheet=${encodeURIComponent(sheet)}`,{headers:hdrs}).then(r=>r.json());
if (!r.success) { showToast(r.message,'error'); return; }
renderDataTable(r.data || []);
document.getElementById('rowCountLabel').textContent = `${(r.data||[]).length} employees`;
} catch(e) { showToast(e.message,'error'); }
});
function renderDataTable(rows) {
const tbl = document.getElementById('dataTable');
const empty = document.getElementById('dataEmpty');
if (!rows.length) { tbl.style.display='none'; empty.style.display='block'; empty.textContent='No data'; return; }
document.getElementById('dataTbody').innerHTML = rows.map(r => `<tr>
<td>${esc(r.sr_no||'')}</td>
<td>${esc(r.emp_code||'')}</td>
<td>${esc(r.emp_name||'')}</td>
<td>${esc(r.department||'')}</td>
<td class="num">${fmtNum(parseFloat(r.gross_amt)||0)}</td>
<td class="num">${fmtNum(parseFloat(r.net_pay)||0)}</td>
</tr>`).join('');
tbl.style.display = 'table'; empty.style.display = 'none';
}
// ── Summary ───────────────────────────────────────────────────────────────
async function loadSummary() {
if (!currentPeriod) return;
setStatus('Computing summary…','loading');
try {
const r = await fetch(`/api/salary/summary?period=${currentPeriod}`,{headers:hdrs}).then(r=>r.json());
if (!r.success) { setStatus(r.message,'err'); return; }
summaryData = r.data;
renderSummary(summaryData);
document.getElementById('btnExport').disabled = false;
setStatus('Summary computed','ok');
} catch(e) { setStatus(e.message,'err'); }
}
function renderSummary(data) {
const el = document.getElementById('summaryContent');
if (!data) { el.innerHTML = '<div class="empty">No data</div>'; return; }
const total = data.total || {};
const grandTotal = Object.values(total).reduce((s,v)=>s+(v||0),0);
const ccRows = COST_CENTRES.map(cc => {
const amt = total[cc] || 0;
const pct = grandTotal > 0 ? (amt / grandTotal * 100).toFixed(1) : '0.0';
const barW = grandTotal > 0 ? Math.round(amt / grandTotal * 80) : 0;
return `<tr>
<td><b>${esc(cc)}</b></td>
<td class="num">${fmtNum(amt)}</td>
<td class="num">${pct}%<span class="pct-bar" style="width:${barW}px"></span></td>
</tr>`;
}).join('');
// Sheet breakdown
const sheetRows = Object.entries(data.by_sheet || {}).map(([sname, info]) => {
const allocated = Object.values(info.allocation).reduce((s,v)=>s+(v||0),0);
const unalloc = (info.total_net||0) - allocated;
const hasUnalloc = Math.abs(unalloc) > 1;
const unmapped = info.unmapped_depts || {};
const unmappedEntries = Object.entries(unmapped);
const detailRows = hasUnalloc && unmappedEntries.length ? unmappedEntries.map(([dept, d]) => {
const pctLabel = d.pct_applied > 0 ? ` (${d.pct_applied.toFixed(0)}% sheet allocation applied)` : ' (no allocation — map this dept)';
return `<tr style="background:#fef9f9;font-size:11px">
<td style="padding-left:32px;color:#888">&#8627; ${esc(dept)}</td>
<td class="num" style="color:#888">${d.emp}</td>
<td class="num" style="color:#888">${fmtNum(d.net)}</td>
<td colspan="2" style="color:#f59e0b;font-size:10px">${pctLabel}</td>
</tr>`;
}).join('') : '';
return `<tr>
<td>${esc(sname)}</td>
<td class="num">${info.row_count || 0}</td>
<td class="num">${fmtNum(info.total_net||0)}</td>
<td class="num">${fmtNum(allocated)}</td>
<td class="num" style="color:${hasUnalloc?'#ef4444':'#16a34a'}">${fmtNum(unalloc)}</td>
</tr>${detailRows}`;
}).join('');
el.innerHTML = `
<div class="summary-section-title">Cost Centre Allocation — ${currentPeriod}</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Cost Centre</th><th style="text-align:right">Net Salary</th><th style="text-align:right">% of Total</th></tr></thead>
<tbody>
${ccRows}
<tr class="grand-total-row">
<td>GRAND TOTAL</td>
<td class="num">${fmtNum(grandTotal)}</td>
<td class="num">100%</td>
</tr>
</tbody>
</table>
</div>
<div class="summary-section-title" style="margin-top:24px">Sheet-wise Breakdown</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>Sheet / Salary Type</th><th style="text-align:right">Employees</th><th style="text-align:right">Total Net</th><th style="text-align:right">Allocated</th><th style="text-align:right">Unallocated</th></tr></thead>
<tbody>${sheetRows}</tbody>
</table>
</div>`;
}
// ── Upload ────────────────────────────────────────────────────────────────
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const btnUpload = document.getElementById('btnUpload');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag'); handleFile(e.dataTransfer.files[0]); });
fileInput.addEventListener('change', () => handleFile(fileInput.files[0]));
function handleFile(file) {
if (!file) return;
dropZone.querySelector('p').textContent = file.name;
dropZone.querySelector('small').textContent = `Size: ${(file.size/1024).toFixed(1)} KB`;
btnUpload.disabled = false;
const reader = new FileReader();
reader.onload = e => {
try {
const wb = XLSX.read(e.target.result, { type: 'array' });
const sheets = {};
wb.SheetNames.forEach(name => {
sheets[name] = XLSX.utils.sheet_to_json(wb.Sheets[name], { header: 1, defval: '' });
});
parsedFile = { sheets };
dropZone.querySelector('small').textContent = `${wb.SheetNames.length} sheets found: ${wb.SheetNames.join(', ')}`;
} catch(err) { showToast('Error reading file: '+err.message,'error'); btnUpload.disabled = true; }
};
reader.readAsArrayBuffer(file);
}
btnUpload.addEventListener('click', async () => {
const period = document.getElementById('uploadPeriod').value;
if (!period) { showToast('Select upload period','error'); return; }
if (!parsedFile) { showToast('No file loaded','error'); return; }
btnUpload.disabled = true;
setStatus('Uploading…','loading');
try {
const r = await fetch('/api/salary/upload', {
method: 'POST', headers: hdrs,
body: JSON.stringify({ period, sheets: parsedFile.sheets }),
}).then(r => r.json());
if (!r.success) { showToast(r.message,'error'); setStatus(r.message,'err'); return; }
const results = r.data || [];
const badges = results.map(x =>
`<span class="sheet-badge${x.skipped?' skipped':''}">
${esc(x.sheet)} &bull; ${x.rows} rows${x.skipped?' (skipped — no Net Pay column)':''}
</span>`).join('');
document.getElementById('sheetBadges').innerHTML = badges;
document.getElementById('uploadResults').style.display = 'block';
showToast(`Uploaded ${results.filter(x=>!x.skipped).length} sheets successfully`,'success');
setStatus('Upload complete','ok');
// Auto-load sheets if period matches
if (period === document.getElementById('periodInput').value) {
currentPeriod = period;
await loadSheets();
await loadDeptMappings();
}
} catch(e) { showToast(e.message,'error'); setStatus(e.message,'err'); }
finally { btnUpload.disabled = false; }
});
// ── Export ────────────────────────────────────────────────────────────────
document.getElementById('btnExport').addEventListener('click', () => {
if (!summaryData && currentPeriod) { loadSummary().then(exportExcel); return; }
exportExcel();
});
function exportExcel() {
if (!summaryData) { showToast('Load summary first','error'); return; }
try {
const wb = XLSX.utils.book_new();
const total = summaryData.total || {};
const grandTotal = Object.values(total).reduce((s,v)=>s+(v||0),0);
// Summary sheet
const summRows = [['Cost Centre','Net Salary','% of Total'],
...COST_CENTRES.map(cc => [cc, parseFloat((total[cc]||0).toFixed(2)), grandTotal>0?parseFloat((total[cc]||0/grandTotal*100).toFixed(2)):0]),
['GRAND TOTAL', parseFloat(grandTotal.toFixed(2)), 100]
];
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summRows), 'Summary');
// Sheet-wise breakdown
Object.entries(summaryData.by_sheet || {}).forEach(([sname, info]) => {
const rows = [
['Cost Centre', 'Amount'],
...COST_CENTRES.map(cc => [cc, parseFloat(((info.allocation||{})[cc]||0).toFixed(2))]),
['Total Net', parseFloat((info.total_net||0).toFixed(2))],
];
const wsName = sname.replace(/[:\\\/\?\*\[\]]/g,'').trim().slice(0,31) || 'Sheet';
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(rows), wsName);
});
XLSX.writeFile(wb, `Salary_CostCentre_${currentPeriod}.xlsx`);
showToast('Excel downloaded','success');
} catch(e) { showToast('Export failed: '+e.message,'error'); }
}
// ── Helpers ───────────────────────────────────────────────────────────────
function setStatus(msg, st) {
document.getElementById('statusMsg').textContent = msg;
document.getElementById('statusDot').className = 'status-dot' + (st ? ' '+st : '');
}
function showToast(msg, type='info') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `toast ${type} show`;
setTimeout(() => t.classList.remove('show'), 3500);
}
function fmtNum(v, dec=2) {
return Number(v||0).toLocaleString('en-IN',{minimumFractionDigits:dec,maximumFractionDigits:dec});
}
function esc(s) {
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
})();
</script>
</body>
</html>