|
@@ -68,25 +68,28 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Ro
|
|
|
#empty-state a{color:#3b82f6}
|
|
#empty-state a{color:#3b82f6}
|
|
|
|
|
|
|
|
/* ─── P&L Table ─── */
|
|
/* ─── P&L Table ─── */
|
|
|
-table.plt{border-collapse:collapse;white-space:nowrap;font-size:12px;min-width:100%}
|
|
|
|
|
|
|
+table.plt{border-collapse:collapse;white-space:nowrap;font-size:12px;table-layout:fixed}
|
|
|
|
|
|
|
|
/* Sticky header */
|
|
/* Sticky header */
|
|
|
table.plt thead th{
|
|
table.plt thead th{
|
|
|
background:#1e293b;color:#e2e8f0;font-weight:600;
|
|
background:#1e293b;color:#e2e8f0;font-weight:600;
|
|
|
text-align:right;position:sticky;top:0;z-index:3;
|
|
text-align:right;position:sticky;top:0;z-index:3;
|
|
|
padding:7px 10px;border-bottom:2px solid #0f172a;
|
|
padding:7px 10px;border-bottom:2px solid #0f172a;
|
|
|
- font-size:11px;letter-spacing:.2px
|
|
|
|
|
|
|
+ font-size:11px;letter-spacing:.2px;
|
|
|
|
|
+ width:100px;overflow:hidden
|
|
|
}
|
|
}
|
|
|
table.plt thead th.col-name{
|
|
table.plt thead th.col-name{
|
|
|
text-align:left;position:sticky;left:0;z-index:4;
|
|
text-align:left;position:sticky;left:0;z-index:4;
|
|
|
- width:220px;min-width:220px;max-width:220px
|
|
|
|
|
|
|
+ width:220px
|
|
|
|
|
+}
|
|
|
|
|
+/* Year-total column header */
|
|
|
|
|
+table.plt thead th.col-ytotal{
|
|
|
|
|
+ background:#0f172a;border-left:3px solid #3b82f6;color:#93c5fd;font-style:italic
|
|
|
}
|
|
}
|
|
|
-table.plt thead th.col-total{border-left:2px solid #475569}
|
|
|
|
|
|
|
|
|
|
/* Sticky name column */
|
|
/* Sticky name column */
|
|
|
table.plt td.col-name{
|
|
table.plt td.col-name{
|
|
|
position:sticky;left:0;z-index:1;
|
|
position:sticky;left:0;z-index:1;
|
|
|
- width:220px;min-width:220px;max-width:220px;
|
|
|
|
|
overflow:hidden;text-overflow:ellipsis;
|
|
overflow:hidden;text-overflow:ellipsis;
|
|
|
padding:5px 8px
|
|
padding:5px 8px
|
|
|
}
|
|
}
|
|
@@ -95,7 +98,15 @@ table.plt td.col-name{
|
|
|
table.plt td.val{text-align:right;padding:5px 10px;font-variant-numeric:tabular-nums}
|
|
table.plt td.val{text-align:right;padding:5px 10px;font-variant-numeric:tabular-nums}
|
|
|
table.plt td.val.neg{color:#ef4444}
|
|
table.plt td.val.neg{color:#ef4444}
|
|
|
table.plt td.val.zero{color:#cbd5e1}
|
|
table.plt td.val.zero{color:#cbd5e1}
|
|
|
-table.plt td.col-total{border-left:2px solid #cbd5e1}
|
|
|
|
|
|
|
+
|
|
|
|
|
+/* Year-total value cells */
|
|
|
|
|
+table.plt td.col-ytotal{
|
|
|
|
|
+ border-left:3px solid #3b82f6;font-weight:700;
|
|
|
|
|
+ background:#eff6ff!important
|
|
|
|
|
+}
|
|
|
|
|
+tr.r-subtotal td.col-ytotal{background:#bfdbfe!important}
|
|
|
|
|
+tr.r-subtotal.L0 td.col-ytotal{background:#93c5fd!important}
|
|
|
|
|
+tr.r-total td.col-ytotal{background:#1e3a5f!important;color:#bfdbfe!important}
|
|
|
|
|
|
|
|
/* indent */
|
|
/* indent */
|
|
|
.i0{padding-left:8px!important}
|
|
.i0{padding-left:8px!important}
|
|
@@ -177,7 +188,7 @@ tr.r-total td.neg{color:#fca5a5!important}
|
|
|
<span id="sel-info"></span>
|
|
<span id="sel-info"></span>
|
|
|
<div id="total-wrap">
|
|
<div id="total-wrap">
|
|
|
<input type="checkbox" id="show-total" onchange="render()">
|
|
<input type="checkbox" id="show-total" onchange="render()">
|
|
|
- <label for="show-total">Show TOTAL column</label>
|
|
|
|
|
|
|
+ <label for="show-total">Show Year Totals</label>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
@@ -337,12 +348,8 @@ async function load() {
|
|
|
const wrap = document.getElementById('tbl-wrap');
|
|
const wrap = document.getElementById('tbl-wrap');
|
|
|
wrap.classList.add('loading');
|
|
wrap.classList.add('loading');
|
|
|
|
|
|
|
|
- // Include TOTAL column id if show-total is checked
|
|
|
|
|
|
|
+ // Year totals are computed client-side — only request selected months
|
|
|
let ids = [...selected];
|
|
let ids = [...selected];
|
|
|
- if (document.getElementById('show-total').checked) {
|
|
|
|
|
- const tot = allMonths.find(m => m.is_total);
|
|
|
|
|
- if (tot && !ids.includes(tot.id)) ids.push(tot.id);
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
const res = await fetch('api.php?action=data&ids='+encodeURIComponent(ids.join(',')));
|
|
const res = await fetch('api.php?action=data&ids='+encodeURIComponent(ids.join(',')));
|
|
@@ -360,46 +367,80 @@ function render() {
|
|
|
if (!plData || !plData.rows) return;
|
|
if (!plData || !plData.rows) return;
|
|
|
|
|
|
|
|
const showTotal = document.getElementById('show-total').checked;
|
|
const showTotal = document.getElementById('show-total').checked;
|
|
|
- const cols = (plData.columns || []).filter(c => !c.is_total || showTotal);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // Only real month columns (no DB TOTAL)
|
|
|
|
|
+ const monthCols = (plData.columns || []).filter(c => !c.is_total);
|
|
|
|
|
+
|
|
|
|
|
+ // Group month columns by year, preserving order
|
|
|
|
|
+ const yearGroups = [];
|
|
|
|
|
+ monthCols.forEach(c => {
|
|
|
|
|
+ const last = yearGroups[yearGroups.length - 1];
|
|
|
|
|
+ if (last && last.year === c.year) { last.cols.push(c); }
|
|
|
|
|
+ else { yearGroups.push({ year: c.year, cols: [c] }); }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Build flat display-column list: months interleaved with optional year-total sentinels
|
|
|
|
|
+ // sentinel: { isYTotal: true, year, cols: [...month cols for this year] }
|
|
|
|
|
+ const dispCols = [];
|
|
|
|
|
+ yearGroups.forEach(yg => {
|
|
|
|
|
+ yg.cols.forEach(c => dispCols.push(c));
|
|
|
|
|
+ if (showTotal) dispCols.push({ isYTotal: true, year: yg.year, cols: yg.cols });
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
const selCount = selected.size;
|
|
const selCount = selected.size;
|
|
|
document.getElementById('sel-info').textContent =
|
|
document.getElementById('sel-info').textContent =
|
|
|
- selCount + ' month' + (selCount!==1?'s':'') + ' selected';
|
|
|
|
|
|
|
+ selCount + ' month' + (selCount !== 1 ? 's' : '') + ' selected';
|
|
|
|
|
+
|
|
|
|
|
+ // Helper: sum row values across a set of columns
|
|
|
|
|
+ function rowSum(row, cols) {
|
|
|
|
|
+ return cols.reduce((s, c) => {
|
|
|
|
|
+ const v = row.values[String(c.id)];
|
|
|
|
|
+ return s + (v !== undefined && v !== null ? parseFloat(v) || 0 : 0);
|
|
|
|
|
+ }, 0);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // Build header
|
|
|
|
|
|
|
+ // Build header row
|
|
|
let h = '<table class="plt"><thead><tr><th class="col-name">Account</th>';
|
|
let h = '<table class="plt"><thead><tr><th class="col-name">Account</th>';
|
|
|
- cols.forEach(c => {
|
|
|
|
|
- const cls = c.is_total ? ' col-total' : '';
|
|
|
|
|
- h += `<th class="${cls}">${esc(c.label)}</th>`;
|
|
|
|
|
|
|
+ dispCols.forEach(dc => {
|
|
|
|
|
+ if (dc.isYTotal) {
|
|
|
|
|
+ h += `<th class="col-ytotal">${dc.year}</th>`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ h += `<th>${esc(dc.label)}</th>`;
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
h += '</tr></thead><tbody>';
|
|
h += '</tr></thead><tbody>';
|
|
|
|
|
|
|
|
- // Render rows — skip item rows where every selected column is 0/missing
|
|
|
|
|
|
|
+ // Render data rows — skip item rows with no data in any displayed month
|
|
|
plData.rows.forEach(row => {
|
|
plData.rows.forEach(row => {
|
|
|
if (row.type === 'item') {
|
|
if (row.type === 'item') {
|
|
|
- const hasData = cols.some(c => {
|
|
|
|
|
|
|
+ const hasData = monthCols.some(c => {
|
|
|
const v = row.values[String(c.id)];
|
|
const v = row.values[String(c.id)];
|
|
|
return v !== undefined && v !== null && parseFloat(v) !== 0;
|
|
return v !== undefined && v !== null && parseFloat(v) !== 0;
|
|
|
});
|
|
});
|
|
|
if (!hasData) return;
|
|
if (!hasData) return;
|
|
|
}
|
|
}
|
|
|
- const L = row.indent; // 0, 1, or 2
|
|
|
|
|
- const rCls = 'r-'+row.type + (row.type==='header'||row.type==='subtotal' ? ' L'+L : '');
|
|
|
|
|
- const iCls = 'i'+L;
|
|
|
|
|
|
|
+
|
|
|
|
|
+ const L = row.indent;
|
|
|
|
|
+ const rCls = 'r-' + row.type + (row.type === 'header' || row.type === 'subtotal' ? ' L' + L : '');
|
|
|
|
|
+ const iCls = 'i' + L;
|
|
|
|
|
|
|
|
if (row.type === 'header') {
|
|
if (row.type === 'header') {
|
|
|
- h += `<tr class="${rCls}"><td colspan="${cols.length+1}" class="col-name ${iCls}">${esc(row.name)}</td></tr>`;
|
|
|
|
|
|
|
+ h += `<tr class="${rCls}"><td colspan="${dispCols.length + 1}" class="col-name ${iCls}">${esc(row.name)}</td></tr>`;
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
h += `<tr class="${rCls}"><td class="col-name ${iCls}">${esc(row.name)}</td>`;
|
|
h += `<tr class="${rCls}"><td class="col-name ${iCls}">${esc(row.name)}</td>`;
|
|
|
- cols.forEach(c => {
|
|
|
|
|
- const v = row.values[String(c.id)];
|
|
|
|
|
- const num = (v === undefined || v === null) ? 0 : parseFloat(v);
|
|
|
|
|
- const vcls = 'val'
|
|
|
|
|
- + (c.is_total ? ' col-total' : '')
|
|
|
|
|
- + (num < 0 ? ' neg' : (num === 0 ? ' zero' : ''));
|
|
|
|
|
- h += `<td class="${vcls}">${fmt(num)}</td>`;
|
|
|
|
|
|
|
+ dispCols.forEach(dc => {
|
|
|
|
|
+ if (dc.isYTotal) {
|
|
|
|
|
+ const sum = rowSum(row, dc.cols);
|
|
|
|
|
+ const vcls = 'val col-ytotal' + (sum < 0 ? ' neg' : sum === 0 ? ' zero' : '');
|
|
|
|
|
+ h += `<td class="${vcls}">${fmt(sum)}</td>`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const v = row.values[String(dc.id)];
|
|
|
|
|
+ const num = (v === undefined || v === null) ? 0 : parseFloat(v);
|
|
|
|
|
+ const vcls = 'val' + (num < 0 ? ' neg' : num === 0 ? ' zero' : '');
|
|
|
|
|
+ h += `<td class="${vcls}">${fmt(num)}</td>`;
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
h += '</tr>';
|
|
h += '</tr>';
|
|
|
});
|
|
});
|