769 lines
38 KiB
HTML
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 & 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 → 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">✓ ${esc(mapped)}</span>` : '<span style="color:#f59e0b">● 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">✓ ${esc(costCentre)}</span>`
|
|
: '<span style="color:#f59e0b">● 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)} • ${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> • 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 & 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 & CAPD % > 0. For Probationer, leave BB & CAPD % as 0 until decided.
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-primary" id="btnSaveCfg">✓ 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">↳ ${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)} • ${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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|