thatcher 1.0.87 → 1.0.89
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/insights-engine.js +133 -0
- package/src/ui/insights-renderer.js +47 -0
- package/src/ui/page-handler.js +18 -1
- package/src/ui/report-renderer.js +345 -259
package/package.json
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// A synthesis layer over data already computed this session -- reads the
|
|
2
|
+
// outputs of inventory-forecast.js/demand-forecast.js/resource-capacity.js/
|
|
3
|
+
// contract-expiry.js, never reimplements their math. Deliberately rule-based
|
|
4
|
+
// rather than an external LLM call: no API key, no network dependency, no
|
|
5
|
+
// cost, and every rule is deterministic and directly witnessable via
|
|
6
|
+
// exec_js the same way every other feature this session has been.
|
|
7
|
+
import { daysUntilStockout, isReorderDue } from './inventory-forecast.js';
|
|
8
|
+
import { daysUntilExpiry } from './contract-expiry.js';
|
|
9
|
+
import { userCommittedHours, DEFAULT_WEEKLY_CAPACITY_HOURS } from './resource-capacity.js';
|
|
10
|
+
|
|
11
|
+
const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 };
|
|
12
|
+
|
|
13
|
+
function insight(severity, category, message, entity, entityId) {
|
|
14
|
+
return { severity, category, message, entity, entity_id: entityId };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Product X will stock out in N days with no reorder currently scheduled --
|
|
18
|
+
// "no reorder scheduled" here means the reorder isn't yet due (isReorderDue
|
|
19
|
+
// false) but stockout is imminent, distinct from the existing product
|
|
20
|
+
// detail-view warning which only flags the reorder-due boundary itself.
|
|
21
|
+
export function stockoutRiskInsights(products, movementsByProductId) {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const product of products) {
|
|
24
|
+
const movements = movementsByProductId[product.id] || [];
|
|
25
|
+
const currentStock = movements.reduce((sum, m) => sum + (Number(m.quantity) || 0), 0);
|
|
26
|
+
const avgDailyConsumption = movements
|
|
27
|
+
.filter(m => Number(m.quantity) < 0)
|
|
28
|
+
.reduce((sum, m) => sum + Math.abs(Number(m.quantity) || 0), 0) / 90;
|
|
29
|
+
const days = daysUntilStockout(currentStock, avgDailyConsumption);
|
|
30
|
+
if (days == null) continue;
|
|
31
|
+
if (days <= 14) {
|
|
32
|
+
const severity = days <= 3 ? 'critical' : 'warning';
|
|
33
|
+
results.push(insight(severity, 'inventory', `${product.name || product.id} will stock out in ${Math.round(days)} days`, 'product', product.id));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Contract Y expires in N days with no renewal opportunity linked --
|
|
40
|
+
// cross-references contract.opportunity_id, since a contract already headed
|
|
41
|
+
// toward renewal via a tracked deal is a materially different situation
|
|
42
|
+
// from one with nothing lined up.
|
|
43
|
+
export function contractExpiryInsights(contracts) {
|
|
44
|
+
const results = [];
|
|
45
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
46
|
+
for (const contract of contracts) {
|
|
47
|
+
if (contract.status !== 'active' || contract.end_date == null) continue;
|
|
48
|
+
const days = daysUntilExpiry(contract.end_date, nowSeconds);
|
|
49
|
+
if (days > (contract.notice_period_days ?? 30)) continue;
|
|
50
|
+
if (contract.opportunity_id) continue;
|
|
51
|
+
const severity = days <= 0 ? 'critical' : 'warning';
|
|
52
|
+
results.push(insight(severity, 'contract', `${contract.name || contract.id} expires in ${days} days with no renewal opportunity linked`, 'contract', contract.id));
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// User Z is at N% capacity across active allocations -- a straightforward
|
|
58
|
+
// utilization-threshold rule reusing the exact load calculation
|
|
59
|
+
// resource-optimizer.js's suggestion ranking already relies on.
|
|
60
|
+
export async function capacityInsights(users, windowStart, windowEnd) {
|
|
61
|
+
const results = [];
|
|
62
|
+
for (const user of users) {
|
|
63
|
+
const committed = await userCommittedHours(user.id, windowStart, windowEnd);
|
|
64
|
+
const pct = Math.round((committed / DEFAULT_WEEKLY_CAPACITY_HOURS) * 100);
|
|
65
|
+
if (pct >= 90) {
|
|
66
|
+
const severity = pct >= 100 ? 'critical' : 'warning';
|
|
67
|
+
results.push(insight(severity, 'resource', `${user.name || user.id} is at ${pct}% capacity`, 'user', user.id));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Opportunity pipeline for month N shows a >30% drop from month N-1 --
|
|
74
|
+
// compares adjacent buckets from demand-forecast.js's own projectDemandByMonth
|
|
75
|
+
// output, never recomputing the bucket sums itself.
|
|
76
|
+
export function pipelineDropInsights(monthlyBuckets) {
|
|
77
|
+
const results = [];
|
|
78
|
+
for (let i = 1; i < monthlyBuckets.length; i++) {
|
|
79
|
+
const [prevKey, prevValue] = monthlyBuckets[i - 1];
|
|
80
|
+
const [curKey, curValue] = monthlyBuckets[i];
|
|
81
|
+
if (prevValue <= 0) continue;
|
|
82
|
+
const dropPct = ((prevValue - curValue) / prevValue) * 100;
|
|
83
|
+
if (dropPct > 30) {
|
|
84
|
+
results.push(insight('warning', 'pipeline', `Projected pipeline for ${curKey} is ${Math.round(dropPct)}% lower than ${prevKey}`, 'opportunity', curKey));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function sortBySeverity(insights) {
|
|
91
|
+
return [...insights].sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Orchestrator: every list() call here passes {user}, the SAME row/org-access
|
|
95
|
+
// scoping every entity route in the codebase already applies -- an insight
|
|
96
|
+
// can never surface a record the requesting user couldn't otherwise see,
|
|
97
|
+
// because it is generated from the identical scoped query that record's own
|
|
98
|
+
// list view would use. entityType optionally narrows which rule categories
|
|
99
|
+
// run (e.g. 'product' -> only stockout insights), skipping the rest rather
|
|
100
|
+
// than fetching data for rules that won't run.
|
|
101
|
+
export async function generateInsights(user, entityType = null) {
|
|
102
|
+
const { list } = await import('./busybase/store.js');
|
|
103
|
+
const results = [];
|
|
104
|
+
|
|
105
|
+
if (!entityType || entityType === 'product') {
|
|
106
|
+
const products = await list('product', {}, { user });
|
|
107
|
+
const movementsByProductId = {};
|
|
108
|
+
for (const product of products) {
|
|
109
|
+
movementsByProductId[product.id] = await list('stock_movement', { product_id: product.id });
|
|
110
|
+
}
|
|
111
|
+
results.push(...stockoutRiskInsights(products, movementsByProductId));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!entityType || entityType === 'contract') {
|
|
115
|
+
const contracts = await list('contract', {}, { user });
|
|
116
|
+
results.push(...contractExpiryInsights(contracts));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!entityType || entityType === 'user' || entityType === 'resource_allocation') {
|
|
120
|
+
const users = await list('user', {}, { user });
|
|
121
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
122
|
+
results.push(...(await capacityInsights(users, nowSeconds, nowSeconds + 30 * 86400)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!entityType || entityType === 'opportunity') {
|
|
126
|
+
const { projectDemandByMonth } = await import('./demand-forecast.js');
|
|
127
|
+
const opportunities = await list('opportunity', {}, { user });
|
|
128
|
+
const buckets = projectDemandByMonth(opportunities);
|
|
129
|
+
results.push(...pipelineDropInsights(buckets));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return sortBySeverity(results);
|
|
133
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc, emptyRow } from '@/ui/render-helpers.js';
|
|
3
|
+
|
|
4
|
+
const SEVERITY_PILL = { critical: 'pill-danger', warning: 'pill-warning', info: 'pill-neutral' };
|
|
5
|
+
const ENTITY_ROUTE = { product: 'product', contract: 'contract', user: 'user', resource_allocation: 'resource_allocation' };
|
|
6
|
+
|
|
7
|
+
function insightRow(insight) {
|
|
8
|
+
const pillCls = SEVERITY_PILL[insight.severity] || 'pill-neutral';
|
|
9
|
+
const entityPath = ENTITY_ROUTE[insight.entity];
|
|
10
|
+
// opportunity-pipeline insights carry a month-string entity_id (e.g.
|
|
11
|
+
// '2026-09'), not a real record id -- there is no single record to link
|
|
12
|
+
// to for a whole-pipeline observation, so only entities with a real
|
|
13
|
+
// per-record id get a link.
|
|
14
|
+
const link = entityPath && insight.entity !== 'opportunity'
|
|
15
|
+
? `<a href="/${esc(entityPath)}/${esc(insight.entity_id)}" class="text-primary hover:underline">View</a>`
|
|
16
|
+
: '';
|
|
17
|
+
return `<tr>
|
|
18
|
+
<td><span class="pill ${pillCls}">${esc(insight.severity)}</span></td>
|
|
19
|
+
<td>${esc(insight.category)}</td>
|
|
20
|
+
<td>${esc(insight.message)}</td>
|
|
21
|
+
<td>${link}</td>
|
|
22
|
+
</tr>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderInsightsPage(user, insights = []) {
|
|
26
|
+
const rows = insights.map(insightRow).join('') || emptyRow(4, 'No insights right now');
|
|
27
|
+
const counts = insights.reduce((acc, i) => { acc[i.severity] = (acc[i.severity] || 0) + 1; return acc; }, {});
|
|
28
|
+
|
|
29
|
+
const content = `
|
|
30
|
+
<div class="page-header">
|
|
31
|
+
<h1 class="page-title">Insights</h1>
|
|
32
|
+
<p class="page-subtitle">${esc(String(counts.critical || 0))} critical, ${esc(String(counts.warning || 0))} warning, ${esc(String(counts.info || 0))} info</p>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="card-clean">
|
|
35
|
+
<div class="card-clean-body">
|
|
36
|
+
<div class="table-wrap">
|
|
37
|
+
<table class="data-table">
|
|
38
|
+
<thead><tr><th>Severity</th><th>Category</th><th>Insight</th><th>Link</th></tr></thead>
|
|
39
|
+
<tbody>${rows}</tbody>
|
|
40
|
+
</table>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
return page(user, 'Insights | Thatcher', null, content, []);
|
|
47
|
+
}
|
package/src/ui/page-handler.js
CHANGED
|
@@ -9,7 +9,7 @@ import { renderEngagementGrid } from '@/ui/engagement-grid-renderer.js';
|
|
|
9
9
|
import { renderBoardView } from '@/ui/board-view-renderer.js';
|
|
10
10
|
import { renderGridView } from '@/ui/grid-view-renderer.js';
|
|
11
11
|
import { renderCalendarView, renderTimelineView } from '@/ui/calendar-view-renderer.js';
|
|
12
|
-
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport } from '@/ui/report-renderer.js';
|
|
12
|
+
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport, renderInventoryForecastReport, renderDemandForecastReport, renderPivotReport } from '@/ui/report-renderer.js';
|
|
13
13
|
import { renderClientProgress } from '@/ui/client-progress-renderer.js';
|
|
14
14
|
import { renderLetterWorkflow } from '@/ui/letter-workflow-renderer.js';
|
|
15
15
|
import { renderAdvancedSearch } from '@/ui/advanced-search-renderer.js';
|
|
@@ -241,6 +241,16 @@ export async function handlePage(pathname, req, res) {
|
|
|
241
241
|
if (!user) { res.writeHead(302, { Location: '/login' }); res.end(); return REDIRECT; }
|
|
242
242
|
if (normalized === '/unauthorized') return renderAccessDenied(user, 'system', 'access');
|
|
243
243
|
if (normalized === '/notifications') { let notifs=[]; try{notifs=await list('notification',{user_id:user.id},{sort:{field:'created_at',dir:'DESC'},limit:100,user})}catch{} const{renderNotificationsPage}=await lazyRenderer('notifications-renderer.js'); return renderNotificationsPage(user,notifs); }
|
|
244
|
+
if (normalized === '/insights') {
|
|
245
|
+
// generateInsights internally scopes every underlying list() call to
|
|
246
|
+
// {user} -- no separate access check needed here, the insights
|
|
247
|
+
// themselves can never surface a record this user couldn't already see.
|
|
248
|
+
const { generateInsights } = await import('@/lib/insights-engine.js');
|
|
249
|
+
let insights = [];
|
|
250
|
+
try { insights = await generateInsights(user); } catch {}
|
|
251
|
+
const { renderInsightsPage } = await lazyRenderer('insights-renderer.js');
|
|
252
|
+
return renderInsightsPage(user, insights);
|
|
253
|
+
}
|
|
244
254
|
if (normalized === '/' || normalized === '/dashboard') return renderDashboard(user, await getDashboardStats(user));
|
|
245
255
|
if (normalized.startsWith('/admin/') || normalized === '/admin/jobs') return handleAdminPage(normalized, segments, user, req);
|
|
246
256
|
if (segments[0] === 'client' && segments.length === 3 && ['dashboard', 'users', 'progress'].includes(segments[2])) return handleClientSubRoute(user, segments[1], segments[2]);
|
|
@@ -360,6 +370,13 @@ export async function handlePage(pathname, req, res) {
|
|
|
360
370
|
const groupBy = params.get('group_by') || '';
|
|
361
371
|
return renderSumByFieldReport(user, entityName, spec, items, field, groupBy);
|
|
362
372
|
}
|
|
373
|
+
if (report === 'pivot') {
|
|
374
|
+
const rowField = params.get('row_field') || '';
|
|
375
|
+
const colField = params.get('col_field') || '';
|
|
376
|
+
const valueField = params.get('value_field') || '';
|
|
377
|
+
const agg = params.get('agg') || 'count';
|
|
378
|
+
return renderPivotReport(user, entityName, spec, items, rowField, colField, valueField, agg);
|
|
379
|
+
}
|
|
363
380
|
if (report === 'rollup') {
|
|
364
381
|
const refField = params.get('ref_field') || '';
|
|
365
382
|
const rollupField = params.get('rollup_field') || '';
|
|
@@ -1,259 +1,345 @@
|
|
|
1
|
-
import { page } from '@/ui/layout.js';
|
|
2
|
-
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
-
import { emptyState } from '@/ui/format-helpers.js';
|
|
4
|
-
import { getEntityLabel } from '@/config/spec-helpers.js';
|
|
5
|
-
|
|
6
|
-
const MAX_BUCKETS = 15;
|
|
7
|
-
|
|
8
|
-
function bucketLabel(spec, field, rawValue) {
|
|
9
|
-
if (rawValue === null || rawValue === undefined || rawValue === '') return '(empty)';
|
|
10
|
-
const fieldDef = spec.fields?.[field];
|
|
11
|
-
if (fieldDef?.type === 'enum' && Array.isArray(fieldDef.options)) {
|
|
12
|
-
const opt = fieldDef.options.find(o => String(o.value ?? o) === String(rawValue));
|
|
13
|
-
return opt ? String(opt.label || opt.value || opt) : String(rawValue);
|
|
14
|
-
}
|
|
15
|
-
return String(rawValue);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function countByField(records, spec, field) {
|
|
19
|
-
const counts = new Map();
|
|
20
|
-
for (const r of records) {
|
|
21
|
-
const label = bucketLabel(spec, field, r[field]);
|
|
22
|
-
counts.set(label, (counts.get(label) || 0) + 1);
|
|
23
|
-
}
|
|
24
|
-
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
25
|
-
if (sorted.length <= MAX_BUCKETS) return sorted;
|
|
26
|
-
const top = sorted.slice(0, MAX_BUCKETS - 1);
|
|
27
|
-
const otherCount = sorted.slice(MAX_BUCKETS - 1).reduce((sum, [, c]) => sum + c, 0);
|
|
28
|
-
return [...top, ['(other)', otherCount]];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function barChart(buckets) {
|
|
32
|
-
if (!buckets.length) return emptyState('No data to report on', 'bar-chart');
|
|
33
|
-
const max = Math.max(...buckets.map(([, c]) => c), 1);
|
|
34
|
-
const rows = buckets.map(([label, count]) => {
|
|
35
|
-
const pct = Math.round((count / max) * 100);
|
|
36
|
-
return `<div class="report-bar-row" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
|
|
37
|
-
<div style="min-width:140px;max-width:220px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(label)}">${esc(label)}</div>
|
|
38
|
-
<div style="flex:1;height:20px;background:var(--color-border,#eee);border-radius:4px;overflow:hidden">
|
|
39
|
-
<div style="height:100%;width:${pct}%;background:var(--color-primary,#3b82f6);border-radius:4px"></div>
|
|
40
|
-
</div>
|
|
41
|
-
<div style="min-width:40px;text-align:right;font-size:13px;font-weight:600">${count}</div>
|
|
42
|
-
</div>`;
|
|
43
|
-
}).join('');
|
|
44
|
-
return `<div class="report-bar-chart">${rows}</div>`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function bucketDateKey(dateValue, granularity) {
|
|
48
|
-
if (dateValue === null || dateValue === undefined || dateValue === '') return null;
|
|
49
|
-
const num = Number(dateValue);
|
|
50
|
-
const d = !isNaN(num) && num > 0 ? new Date(num * 1000) : new Date(dateValue);
|
|
51
|
-
if (isNaN(d.getTime())) return null;
|
|
52
|
-
const y = d.getFullYear();
|
|
53
|
-
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
54
|
-
const day = String(d.getDate()).padStart(2, '0');
|
|
55
|
-
if (granularity === 'day') return `${y}-${m}-${day}`;
|
|
56
|
-
if (granularity === 'week') {
|
|
57
|
-
const onejan = new Date(y, 0, 1);
|
|
58
|
-
const week = Math.ceil((((d - onejan) / 86400000) + onejan.getDay() + 1) / 7);
|
|
59
|
-
return `${y}-W${String(week).padStart(2, '0')}`;
|
|
60
|
-
}
|
|
61
|
-
return `${y}-${m}`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function countOverTime(records, dateField, granularity = 'month') {
|
|
65
|
-
const counts = new Map();
|
|
66
|
-
let unresolved = 0;
|
|
67
|
-
for (const r of records) {
|
|
68
|
-
const key = bucketDateKey(r[dateField], granularity);
|
|
69
|
-
if (key === null) { unresolved++; continue; }
|
|
70
|
-
counts.set(key, (counts.get(key) || 0) + 1);
|
|
71
|
-
}
|
|
72
|
-
const sorted = [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
73
|
-
return { buckets: sorted.length > MAX_BUCKETS ? sorted.slice(-MAX_BUCKETS) : sorted, unresolved, truncated: sorted.length > MAX_BUCKETS };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function renderCountByFieldReport(user, entityName, spec, records, field) {
|
|
77
|
-
const label = getEntityLabel(spec, true) || entityName;
|
|
78
|
-
const fieldDef = spec.fields?.[field];
|
|
79
|
-
if (!fieldDef) {
|
|
80
|
-
return page(user, `${label} | Report`, null,
|
|
81
|
-
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
82
|
-
<div class="report-empty-state">Unknown field "${esc(field)}" for this entity.</div>`);
|
|
83
|
-
}
|
|
84
|
-
const buckets = countByField(records, spec, field);
|
|
85
|
-
const content = `<div class="page-header">
|
|
86
|
-
<div><h1 class="page-title">${esc(label)} by ${esc(fieldDef.label || field)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
87
|
-
</div>
|
|
88
|
-
${barChart(buckets)}`;
|
|
89
|
-
return page(user, `${label} Report | Thatcher`, null, content);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function sumByField(records, spec, field, groupBy) {
|
|
93
|
-
const sums = new Map();
|
|
94
|
-
for (const r of records) {
|
|
95
|
-
const raw = r[field];
|
|
96
|
-
const num = typeof raw === 'string' ? Number(raw) : raw;
|
|
97
|
-
if (typeof num !== 'number' || isNaN(num)) continue;
|
|
98
|
-
const label = bucketLabel(spec, groupBy, r[groupBy]);
|
|
99
|
-
sums.set(label, (sums.get(label) || 0) + num);
|
|
100
|
-
}
|
|
101
|
-
const sorted = [...sums.entries()].sort((a, b) => b[1] - a[1]);
|
|
102
|
-
if (sorted.length <= MAX_BUCKETS) return sorted;
|
|
103
|
-
const top = sorted.slice(0, MAX_BUCKETS - 1);
|
|
104
|
-
const otherSum = sorted.slice(MAX_BUCKETS - 1).reduce((sum, [, v]) => sum + v, 0);
|
|
105
|
-
return [...top, ['(other)', otherSum]];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function formatSumValue(value, fieldDef) {
|
|
109
|
-
if (fieldDef?.type === 'currency') return (fieldDef.currency_symbol || '$') + (value / 100).toFixed(2);
|
|
110
|
-
return String(Math.round(value * 100) / 100);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function sumBarChart(buckets, fieldDef) {
|
|
114
|
-
if (!buckets.length) return emptyState('No data to report on', 'bar-chart');
|
|
115
|
-
const max = Math.max(...buckets.map(([, v]) => Math.abs(v)), 1);
|
|
116
|
-
const rows = buckets.map(([label, value]) => {
|
|
117
|
-
const pct = Math.round((Math.abs(value) / max) * 100);
|
|
118
|
-
return `<div class="report-bar-row" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
|
|
119
|
-
<div style="min-width:140px;max-width:220px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(label)}">${esc(label)}</div>
|
|
120
|
-
<div style="flex:1;height:20px;background:var(--color-border,#eee);border-radius:4px;overflow:hidden">
|
|
121
|
-
<div style="height:100%;width:${pct}%;background:var(--color-primary,#3b82f6);border-radius:4px"></div>
|
|
122
|
-
</div>
|
|
123
|
-
<div style="min-width:80px;text-align:right;font-size:13px;font-weight:600">${esc(formatSumValue(value, fieldDef))}</div>
|
|
124
|
-
</div>`;
|
|
125
|
-
}).join('');
|
|
126
|
-
return `<div class="report-bar-chart">${rows}</div>`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function renderSumByFieldReport(user, entityName, spec, records, field, groupBy) {
|
|
130
|
-
const label = getEntityLabel(spec, true) || entityName;
|
|
131
|
-
const fieldDef = spec.fields?.[field];
|
|
132
|
-
const groupByDef = spec.fields?.[groupBy];
|
|
133
|
-
if (!fieldDef || !groupByDef) {
|
|
134
|
-
return page(user, `${label} | Report`, null,
|
|
135
|
-
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
136
|
-
<div class="report-empty-state">Unknown field "${esc(!fieldDef ? field : groupBy)}" for this entity.</div>`);
|
|
137
|
-
}
|
|
138
|
-
const buckets = sumByField(records, spec, field, groupBy);
|
|
139
|
-
const content = `<div class="page-header">
|
|
140
|
-
<div><h1 class="page-title">${esc(label)}: ${esc(fieldDef.label || field)} by ${esc(groupByDef.label || groupBy)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
141
|
-
</div>
|
|
142
|
-
${sumBarChart(buckets, fieldDef)}`;
|
|
143
|
-
return page(user, `${label} Report | Thatcher`, null, content);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Cross-entity rollup: entity A's records are grouped by a field on related
|
|
147
|
-
// entity B, joined through A's ref field. relatedRecords must already be the
|
|
148
|
-
// CALLER's row/org-scoped list() result for B -- this function only joins
|
|
149
|
-
// and counts, it enforces no access itself (the access decision -- can this
|
|
150
|
-
// user list B at all -- is made by the caller before relatedRecords exists).
|
|
151
|
-
export function rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField) {
|
|
152
|
-
const relatedById = new Map(relatedRecords.map(r => [String(r.id), r]));
|
|
153
|
-
const counts = new Map();
|
|
154
|
-
let unmatched = 0;
|
|
155
|
-
for (const r of records) {
|
|
156
|
-
const refId = r[refField];
|
|
157
|
-
const related = refId != null ? relatedById.get(String(refId)) : null;
|
|
158
|
-
if (!related) { unmatched++; continue; }
|
|
159
|
-
const label = bucketLabel(relatedSpec, rollupField, related[rollupField]);
|
|
160
|
-
counts.set(label, (counts.get(label) || 0) + 1);
|
|
161
|
-
}
|
|
162
|
-
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
163
|
-
return { buckets: sorted.length > MAX_BUCKETS ? sorted.slice(0, MAX_BUCKETS) : sorted, unmatched };
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function renderRollupReport(user, entityName, spec, records, refField, relatedEntity, relatedSpec, relatedRecords, rollupField) {
|
|
167
|
-
const label = getEntityLabel(spec, true) || entityName;
|
|
168
|
-
const relatedLabel = getEntityLabel(relatedSpec, true) || relatedEntity;
|
|
169
|
-
const rollupFieldDef = relatedSpec?.fields?.[rollupField];
|
|
170
|
-
if (!spec.fields?.[refField] || !rollupFieldDef) {
|
|
171
|
-
return page(user, `${label} | Report`, null,
|
|
172
|
-
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
173
|
-
<div class="report-empty-state">Unknown field for this rollup.</div>`);
|
|
174
|
-
}
|
|
175
|
-
const { buckets, unmatched } = rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField);
|
|
176
|
-
const notice = unmatched
|
|
177
|
-
? `<div class="report-notice">${unmatched} record${unmatched === 1 ? '' : 's'} without a resolvable ${esc(relatedLabel)} excluded</div>`
|
|
178
|
-
: '';
|
|
179
|
-
const content = `<div class="page-header">
|
|
180
|
-
<div><h1 class="page-title">${esc(label)} by ${esc(relatedLabel)}.${esc(rollupFieldDef.label || rollupField)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
181
|
-
</div>
|
|
182
|
-
${notice}
|
|
183
|
-
${barChart(buckets)}`;
|
|
184
|
-
return page(user, `${label} Report | Thatcher`, null, content);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export function renderCountOverTimeReport(user, entityName, spec, records, dateField, granularity) {
|
|
188
|
-
const label = getEntityLabel(spec, true) || entityName;
|
|
189
|
-
const fieldDef = spec.fields?.[dateField];
|
|
190
|
-
if (!fieldDef) {
|
|
191
|
-
return page(user, `${label} | Report`, null,
|
|
192
|
-
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
193
|
-
<div class="report-empty-state">Unknown date field "${esc(dateField)}" for this entity.</div>`);
|
|
194
|
-
}
|
|
195
|
-
const { buckets, unresolved, truncated } = countOverTime(records, dateField, granularity);
|
|
196
|
-
const notice = unresolved
|
|
197
|
-
? `<div class="report-notice">${unresolved} record${unresolved === 1 ? '' : 's'} without a resolvable date excluded</div>`
|
|
198
|
-
: '';
|
|
199
|
-
const truncatedNotice = truncated
|
|
200
|
-
? `<div class="report-notice">Showing the most recent ${buckets.length} periods only</div>`
|
|
201
|
-
: '';
|
|
202
|
-
const content = `<div class="page-header">
|
|
203
|
-
<div><h1 class="page-title">${esc(label)} over time</h1><p class="page-subtitle">${records.length} total records, grouped by ${esc(granularity)}</p></div>
|
|
204
|
-
</div>
|
|
205
|
-
${notice}${truncatedNotice}
|
|
206
|
-
${barChart(buckets)}`;
|
|
207
|
-
return page(user, `${label} Report | Thatcher`, null, content);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// One table per-product rather than a bar chart: unlike count-by-field/
|
|
211
|
-
// sum-by-field's single grouped metric, a forecast is inherently multiple
|
|
212
|
-
// values per row (stock, consumption rate, days-out, reorder date) -- a bar
|
|
213
|
-
// chart of any single one of those would discard the others, so a table is
|
|
214
|
-
// the correct shape here, reusing the same page()/esc() rendering discipline
|
|
215
|
-
// as every other report rather than inventing a new chart type.
|
|
216
|
-
export function renderInventoryForecastReport(user, spec, forecastRows) {
|
|
217
|
-
const label = getEntityLabel(spec, true) || 'Products';
|
|
218
|
-
const rows = forecastRows.map(p => {
|
|
219
|
-
const daysLabel = p.days_until_stockout == null ? 'Unknown' : String(Math.round(p.days_until_stockout))
|
|
220
|
-
const reorderLabel = p.reorder_date == null ? '-' : new Date(p.reorder_date * 1000).toISOString().slice(0, 10)
|
|
221
|
-
const rowCls = p.reorder_due ? 'style="background:var(--color-danger-bg,#fee2e2)"' : ''
|
|
222
|
-
return `<tr ${rowCls}>
|
|
223
|
-
<td>${esc(p.name || p.id)}</td>
|
|
224
|
-
<td>${esc(String(p.current_stock))}</td>
|
|
225
|
-
<td>${esc((p.avg_daily_consumption || 0).toFixed(2))}</td>
|
|
226
|
-
<td>${esc(daysLabel)}</td>
|
|
227
|
-
<td>${esc(reorderLabel)}${p.reorder_due ? ' <span class="pill pill-danger">Reorder Now</span>' : ''}</td>
|
|
228
|
-
</tr>`;
|
|
229
|
-
}).join('') || emptyState('No products to forecast', 'bar-chart');
|
|
230
|
-
|
|
231
|
-
const content = `<div class="page-header">
|
|
232
|
-
<div><h1 class="page-title">${esc(label)}: Inventory Forecast</h1><p class="page-subtitle">${forecastRows.length} products</p></div>
|
|
233
|
-
</div>
|
|
234
|
-
<div class="table-wrap"><table class="data-table">
|
|
235
|
-
<thead><tr><th>Product</th><th>Current Stock</th><th>Avg Daily Use</th><th>Days Until Stockout</th><th>Suggested Reorder</th></tr></thead>
|
|
236
|
-
<tbody>${rows}</tbody>
|
|
237
|
-
</table></div>`;
|
|
238
|
-
return page(user, `Inventory Forecast | Thatcher`, null, content);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Reuses sumBarChart (already built for sum-by-field's currency-aware bar
|
|
242
|
-
// rendering) rather than inventing a third chart type -- a demand-by-month
|
|
243
|
-
// bucket is the exact same [label, numericValue] shape sum-by-field already
|
|
244
|
-
// renders, just currency-formatted since weighted_value derives from a
|
|
245
|
-
// currency field.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
import { emptyState } from '@/ui/format-helpers.js';
|
|
4
|
+
import { getEntityLabel } from '@/config/spec-helpers.js';
|
|
5
|
+
|
|
6
|
+
const MAX_BUCKETS = 15;
|
|
7
|
+
|
|
8
|
+
function bucketLabel(spec, field, rawValue) {
|
|
9
|
+
if (rawValue === null || rawValue === undefined || rawValue === '') return '(empty)';
|
|
10
|
+
const fieldDef = spec.fields?.[field];
|
|
11
|
+
if (fieldDef?.type === 'enum' && Array.isArray(fieldDef.options)) {
|
|
12
|
+
const opt = fieldDef.options.find(o => String(o.value ?? o) === String(rawValue));
|
|
13
|
+
return opt ? String(opt.label || opt.value || opt) : String(rawValue);
|
|
14
|
+
}
|
|
15
|
+
return String(rawValue);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function countByField(records, spec, field) {
|
|
19
|
+
const counts = new Map();
|
|
20
|
+
for (const r of records) {
|
|
21
|
+
const label = bucketLabel(spec, field, r[field]);
|
|
22
|
+
counts.set(label, (counts.get(label) || 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
25
|
+
if (sorted.length <= MAX_BUCKETS) return sorted;
|
|
26
|
+
const top = sorted.slice(0, MAX_BUCKETS - 1);
|
|
27
|
+
const otherCount = sorted.slice(MAX_BUCKETS - 1).reduce((sum, [, c]) => sum + c, 0);
|
|
28
|
+
return [...top, ['(other)', otherCount]];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function barChart(buckets) {
|
|
32
|
+
if (!buckets.length) return emptyState('No data to report on', 'bar-chart');
|
|
33
|
+
const max = Math.max(...buckets.map(([, c]) => c), 1);
|
|
34
|
+
const rows = buckets.map(([label, count]) => {
|
|
35
|
+
const pct = Math.round((count / max) * 100);
|
|
36
|
+
return `<div class="report-bar-row" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
|
|
37
|
+
<div style="min-width:140px;max-width:220px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(label)}">${esc(label)}</div>
|
|
38
|
+
<div style="flex:1;height:20px;background:var(--color-border,#eee);border-radius:4px;overflow:hidden">
|
|
39
|
+
<div style="height:100%;width:${pct}%;background:var(--color-primary,#3b82f6);border-radius:4px"></div>
|
|
40
|
+
</div>
|
|
41
|
+
<div style="min-width:40px;text-align:right;font-size:13px;font-weight:600">${count}</div>
|
|
42
|
+
</div>`;
|
|
43
|
+
}).join('');
|
|
44
|
+
return `<div class="report-bar-chart">${rows}</div>`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function bucketDateKey(dateValue, granularity) {
|
|
48
|
+
if (dateValue === null || dateValue === undefined || dateValue === '') return null;
|
|
49
|
+
const num = Number(dateValue);
|
|
50
|
+
const d = !isNaN(num) && num > 0 ? new Date(num * 1000) : new Date(dateValue);
|
|
51
|
+
if (isNaN(d.getTime())) return null;
|
|
52
|
+
const y = d.getFullYear();
|
|
53
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
54
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
55
|
+
if (granularity === 'day') return `${y}-${m}-${day}`;
|
|
56
|
+
if (granularity === 'week') {
|
|
57
|
+
const onejan = new Date(y, 0, 1);
|
|
58
|
+
const week = Math.ceil((((d - onejan) / 86400000) + onejan.getDay() + 1) / 7);
|
|
59
|
+
return `${y}-W${String(week).padStart(2, '0')}`;
|
|
60
|
+
}
|
|
61
|
+
return `${y}-${m}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function countOverTime(records, dateField, granularity = 'month') {
|
|
65
|
+
const counts = new Map();
|
|
66
|
+
let unresolved = 0;
|
|
67
|
+
for (const r of records) {
|
|
68
|
+
const key = bucketDateKey(r[dateField], granularity);
|
|
69
|
+
if (key === null) { unresolved++; continue; }
|
|
70
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
const sorted = [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
73
|
+
return { buckets: sorted.length > MAX_BUCKETS ? sorted.slice(-MAX_BUCKETS) : sorted, unresolved, truncated: sorted.length > MAX_BUCKETS };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderCountByFieldReport(user, entityName, spec, records, field) {
|
|
77
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
78
|
+
const fieldDef = spec.fields?.[field];
|
|
79
|
+
if (!fieldDef) {
|
|
80
|
+
return page(user, `${label} | Report`, null,
|
|
81
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
82
|
+
<div class="report-empty-state">Unknown field "${esc(field)}" for this entity.</div>`);
|
|
83
|
+
}
|
|
84
|
+
const buckets = countByField(records, spec, field);
|
|
85
|
+
const content = `<div class="page-header">
|
|
86
|
+
<div><h1 class="page-title">${esc(label)} by ${esc(fieldDef.label || field)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
87
|
+
</div>
|
|
88
|
+
${barChart(buckets)}`;
|
|
89
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function sumByField(records, spec, field, groupBy) {
|
|
93
|
+
const sums = new Map();
|
|
94
|
+
for (const r of records) {
|
|
95
|
+
const raw = r[field];
|
|
96
|
+
const num = typeof raw === 'string' ? Number(raw) : raw;
|
|
97
|
+
if (typeof num !== 'number' || isNaN(num)) continue;
|
|
98
|
+
const label = bucketLabel(spec, groupBy, r[groupBy]);
|
|
99
|
+
sums.set(label, (sums.get(label) || 0) + num);
|
|
100
|
+
}
|
|
101
|
+
const sorted = [...sums.entries()].sort((a, b) => b[1] - a[1]);
|
|
102
|
+
if (sorted.length <= MAX_BUCKETS) return sorted;
|
|
103
|
+
const top = sorted.slice(0, MAX_BUCKETS - 1);
|
|
104
|
+
const otherSum = sorted.slice(MAX_BUCKETS - 1).reduce((sum, [, v]) => sum + v, 0);
|
|
105
|
+
return [...top, ['(other)', otherSum]];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatSumValue(value, fieldDef) {
|
|
109
|
+
if (fieldDef?.type === 'currency') return (fieldDef.currency_symbol || '$') + (value / 100).toFixed(2);
|
|
110
|
+
return String(Math.round(value * 100) / 100);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sumBarChart(buckets, fieldDef) {
|
|
114
|
+
if (!buckets.length) return emptyState('No data to report on', 'bar-chart');
|
|
115
|
+
const max = Math.max(...buckets.map(([, v]) => Math.abs(v)), 1);
|
|
116
|
+
const rows = buckets.map(([label, value]) => {
|
|
117
|
+
const pct = Math.round((Math.abs(value) / max) * 100);
|
|
118
|
+
return `<div class="report-bar-row" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
|
|
119
|
+
<div style="min-width:140px;max-width:220px;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(label)}">${esc(label)}</div>
|
|
120
|
+
<div style="flex:1;height:20px;background:var(--color-border,#eee);border-radius:4px;overflow:hidden">
|
|
121
|
+
<div style="height:100%;width:${pct}%;background:var(--color-primary,#3b82f6);border-radius:4px"></div>
|
|
122
|
+
</div>
|
|
123
|
+
<div style="min-width:80px;text-align:right;font-size:13px;font-weight:600">${esc(formatSumValue(value, fieldDef))}</div>
|
|
124
|
+
</div>`;
|
|
125
|
+
}).join('');
|
|
126
|
+
return `<div class="report-bar-chart">${rows}</div>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function renderSumByFieldReport(user, entityName, spec, records, field, groupBy) {
|
|
130
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
131
|
+
const fieldDef = spec.fields?.[field];
|
|
132
|
+
const groupByDef = spec.fields?.[groupBy];
|
|
133
|
+
if (!fieldDef || !groupByDef) {
|
|
134
|
+
return page(user, `${label} | Report`, null,
|
|
135
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
136
|
+
<div class="report-empty-state">Unknown field "${esc(!fieldDef ? field : groupBy)}" for this entity.</div>`);
|
|
137
|
+
}
|
|
138
|
+
const buckets = sumByField(records, spec, field, groupBy);
|
|
139
|
+
const content = `<div class="page-header">
|
|
140
|
+
<div><h1 class="page-title">${esc(label)}: ${esc(fieldDef.label || field)} by ${esc(groupByDef.label || groupBy)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
141
|
+
</div>
|
|
142
|
+
${sumBarChart(buckets, fieldDef)}`;
|
|
143
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Cross-entity rollup: entity A's records are grouped by a field on related
|
|
147
|
+
// entity B, joined through A's ref field. relatedRecords must already be the
|
|
148
|
+
// CALLER's row/org-scoped list() result for B -- this function only joins
|
|
149
|
+
// and counts, it enforces no access itself (the access decision -- can this
|
|
150
|
+
// user list B at all -- is made by the caller before relatedRecords exists).
|
|
151
|
+
export function rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField) {
|
|
152
|
+
const relatedById = new Map(relatedRecords.map(r => [String(r.id), r]));
|
|
153
|
+
const counts = new Map();
|
|
154
|
+
let unmatched = 0;
|
|
155
|
+
for (const r of records) {
|
|
156
|
+
const refId = r[refField];
|
|
157
|
+
const related = refId != null ? relatedById.get(String(refId)) : null;
|
|
158
|
+
if (!related) { unmatched++; continue; }
|
|
159
|
+
const label = bucketLabel(relatedSpec, rollupField, related[rollupField]);
|
|
160
|
+
counts.set(label, (counts.get(label) || 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
163
|
+
return { buckets: sorted.length > MAX_BUCKETS ? sorted.slice(0, MAX_BUCKETS) : sorted, unmatched };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderRollupReport(user, entityName, spec, records, refField, relatedEntity, relatedSpec, relatedRecords, rollupField) {
|
|
167
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
168
|
+
const relatedLabel = getEntityLabel(relatedSpec, true) || relatedEntity;
|
|
169
|
+
const rollupFieldDef = relatedSpec?.fields?.[rollupField];
|
|
170
|
+
if (!spec.fields?.[refField] || !rollupFieldDef) {
|
|
171
|
+
return page(user, `${label} | Report`, null,
|
|
172
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
173
|
+
<div class="report-empty-state">Unknown field for this rollup.</div>`);
|
|
174
|
+
}
|
|
175
|
+
const { buckets, unmatched } = rollupByRelatedField(records, refField, relatedRecords, relatedSpec, rollupField);
|
|
176
|
+
const notice = unmatched
|
|
177
|
+
? `<div class="report-notice">${unmatched} record${unmatched === 1 ? '' : 's'} without a resolvable ${esc(relatedLabel)} excluded</div>`
|
|
178
|
+
: '';
|
|
179
|
+
const content = `<div class="page-header">
|
|
180
|
+
<div><h1 class="page-title">${esc(label)} by ${esc(relatedLabel)}.${esc(rollupFieldDef.label || rollupField)}</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
181
|
+
</div>
|
|
182
|
+
${notice}
|
|
183
|
+
${barChart(buckets)}`;
|
|
184
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function renderCountOverTimeReport(user, entityName, spec, records, dateField, granularity) {
|
|
188
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
189
|
+
const fieldDef = spec.fields?.[dateField];
|
|
190
|
+
if (!fieldDef) {
|
|
191
|
+
return page(user, `${label} | Report`, null,
|
|
192
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
193
|
+
<div class="report-empty-state">Unknown date field "${esc(dateField)}" for this entity.</div>`);
|
|
194
|
+
}
|
|
195
|
+
const { buckets, unresolved, truncated } = countOverTime(records, dateField, granularity);
|
|
196
|
+
const notice = unresolved
|
|
197
|
+
? `<div class="report-notice">${unresolved} record${unresolved === 1 ? '' : 's'} without a resolvable date excluded</div>`
|
|
198
|
+
: '';
|
|
199
|
+
const truncatedNotice = truncated
|
|
200
|
+
? `<div class="report-notice">Showing the most recent ${buckets.length} periods only</div>`
|
|
201
|
+
: '';
|
|
202
|
+
const content = `<div class="page-header">
|
|
203
|
+
<div><h1 class="page-title">${esc(label)} over time</h1><p class="page-subtitle">${records.length} total records, grouped by ${esc(granularity)}</p></div>
|
|
204
|
+
</div>
|
|
205
|
+
${notice}${truncatedNotice}
|
|
206
|
+
${barChart(buckets)}`;
|
|
207
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// One table per-product rather than a bar chart: unlike count-by-field/
|
|
211
|
+
// sum-by-field's single grouped metric, a forecast is inherently multiple
|
|
212
|
+
// values per row (stock, consumption rate, days-out, reorder date) -- a bar
|
|
213
|
+
// chart of any single one of those would discard the others, so a table is
|
|
214
|
+
// the correct shape here, reusing the same page()/esc() rendering discipline
|
|
215
|
+
// as every other report rather than inventing a new chart type.
|
|
216
|
+
export function renderInventoryForecastReport(user, spec, forecastRows) {
|
|
217
|
+
const label = getEntityLabel(spec, true) || 'Products';
|
|
218
|
+
const rows = forecastRows.map(p => {
|
|
219
|
+
const daysLabel = p.days_until_stockout == null ? 'Unknown' : String(Math.round(p.days_until_stockout))
|
|
220
|
+
const reorderLabel = p.reorder_date == null ? '-' : new Date(p.reorder_date * 1000).toISOString().slice(0, 10)
|
|
221
|
+
const rowCls = p.reorder_due ? 'style="background:var(--color-danger-bg,#fee2e2)"' : ''
|
|
222
|
+
return `<tr ${rowCls}>
|
|
223
|
+
<td>${esc(p.name || p.id)}</td>
|
|
224
|
+
<td>${esc(String(p.current_stock))}</td>
|
|
225
|
+
<td>${esc((p.avg_daily_consumption || 0).toFixed(2))}</td>
|
|
226
|
+
<td>${esc(daysLabel)}</td>
|
|
227
|
+
<td>${esc(reorderLabel)}${p.reorder_due ? ' <span class="pill pill-danger">Reorder Now</span>' : ''}</td>
|
|
228
|
+
</tr>`;
|
|
229
|
+
}).join('') || emptyState('No products to forecast', 'bar-chart');
|
|
230
|
+
|
|
231
|
+
const content = `<div class="page-header">
|
|
232
|
+
<div><h1 class="page-title">${esc(label)}: Inventory Forecast</h1><p class="page-subtitle">${forecastRows.length} products</p></div>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="table-wrap"><table class="data-table">
|
|
235
|
+
<thead><tr><th>Product</th><th>Current Stock</th><th>Avg Daily Use</th><th>Days Until Stockout</th><th>Suggested Reorder</th></tr></thead>
|
|
236
|
+
<tbody>${rows}</tbody>
|
|
237
|
+
</table></div>`;
|
|
238
|
+
return page(user, `Inventory Forecast | Thatcher`, null, content);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Reuses sumBarChart (already built for sum-by-field's currency-aware bar
|
|
242
|
+
// rendering) rather than inventing a third chart type -- a demand-by-month
|
|
243
|
+
// bucket is the exact same [label, numericValue] shape sum-by-field already
|
|
244
|
+
// renders, just currency-formatted since weighted_value derives from a
|
|
245
|
+
// currency field.
|
|
246
|
+
// Two-dimensional cross-tab: row_field x col_field, cell = aggregated
|
|
247
|
+
// value_field. Distinct from countByField/sumByField (single-dimension
|
|
248
|
+
// group-bys) -- this is the only report shape that can answer "how does X
|
|
249
|
+
// break down by Y AND Z simultaneously". Both axes independently collapse
|
|
250
|
+
// past MAX_BUCKETS into an '(other)' bucket the same way countByField does,
|
|
251
|
+
// so a high-cardinality field on either axis can't blow up the table.
|
|
252
|
+
const PIVOT_AGGREGATORS = {
|
|
253
|
+
sum: (values) => values.reduce((a, b) => a + b, 0),
|
|
254
|
+
count: (values) => values.length,
|
|
255
|
+
avg: (values) => values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
function topBucketLabels(records, spec, field) {
|
|
259
|
+
const counts = new Map();
|
|
260
|
+
for (const r of records) {
|
|
261
|
+
const label = bucketLabel(spec, field, r[field]);
|
|
262
|
+
counts.set(label, (counts.get(label) || 0) + 1);
|
|
263
|
+
}
|
|
264
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([label]) => label);
|
|
265
|
+
if (sorted.length <= MAX_BUCKETS) return new Set(sorted);
|
|
266
|
+
return new Set(sorted.slice(0, MAX_BUCKETS - 1));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function pivotByFields(records, spec, rowField, colField, valueField, agg = 'count') {
|
|
270
|
+
const aggregator = PIVOT_AGGREGATORS[agg] || PIVOT_AGGREGATORS.count;
|
|
271
|
+
const rowKeep = topBucketLabels(records, spec, rowField);
|
|
272
|
+
const colKeep = topBucketLabels(records, spec, colField);
|
|
273
|
+
const cellValues = new Map();
|
|
274
|
+
const rowLabels = new Set();
|
|
275
|
+
const colLabels = new Set();
|
|
276
|
+
|
|
277
|
+
for (const r of records) {
|
|
278
|
+
const rawRow = bucketLabel(spec, rowField, r[rowField]);
|
|
279
|
+
const rawCol = bucketLabel(spec, colField, r[colField]);
|
|
280
|
+
const rowLabel = rowKeep.has(rawRow) ? rawRow : '(other)';
|
|
281
|
+
const colLabel = colKeep.has(rawCol) ? rawCol : '(other)';
|
|
282
|
+
rowLabels.add(rowLabel);
|
|
283
|
+
colLabels.add(colLabel);
|
|
284
|
+
const key = rowLabel + '' + colLabel;
|
|
285
|
+
const raw = agg === 'count' ? 1 : (typeof r[valueField] === 'string' ? Number(r[valueField]) : r[valueField]);
|
|
286
|
+
if (agg !== 'count' && (typeof raw !== 'number' || isNaN(raw))) continue;
|
|
287
|
+
if (!cellValues.has(key)) cellValues.set(key, []);
|
|
288
|
+
cellValues.get(key).push(agg === 'count' ? 1 : raw);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const rows = [...rowLabels].sort((a, b) => a === '(other)' ? 1 : b === '(other)' ? -1 : a.localeCompare(b));
|
|
292
|
+
const cols = [...colLabels].sort((a, b) => a === '(other)' ? 1 : b === '(other)' ? -1 : a.localeCompare(b));
|
|
293
|
+
const cells = rows.map(rowLabel =>
|
|
294
|
+
cols.map(colLabel => {
|
|
295
|
+
const values = cellValues.get(rowLabel + '' + colLabel);
|
|
296
|
+
return values ? aggregator(values) : 0;
|
|
297
|
+
})
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
return { rows, cols, cells };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function renderPivotReport(user, entityName, spec, records, rowField, colField, valueField, agg) {
|
|
304
|
+
const label = getEntityLabel(spec, true) || entityName;
|
|
305
|
+
const rowFieldDef = spec.fields?.[rowField];
|
|
306
|
+
const colFieldDef = spec.fields?.[colField];
|
|
307
|
+
const validAgg = PIVOT_AGGREGATORS[agg] ? agg : 'count';
|
|
308
|
+
const valueFieldDef = validAgg === 'count' ? null : spec.fields?.[valueField];
|
|
309
|
+
if (!rowFieldDef || !colFieldDef || (validAgg !== 'count' && !valueFieldDef)) {
|
|
310
|
+
const badField = !rowFieldDef ? rowField : !colFieldDef ? colField : valueField;
|
|
311
|
+
return page(user, `${label} | Report`, null,
|
|
312
|
+
`<div class="page-header"><h1 class="page-title">${esc(label)}</h1></div>
|
|
313
|
+
<div class="report-empty-state">Unknown field "${esc(badField)}" for this entity.</div>`);
|
|
314
|
+
}
|
|
315
|
+
const { rows, cols, cells } = pivotByFields(records, spec, rowField, colField, valueField, validAgg);
|
|
316
|
+
const formatCell = (v) => validAgg === 'avg' ? String(Math.round(v * 100) / 100) : (valueFieldDef ? formatSumValue(v, valueFieldDef) : String(v));
|
|
317
|
+
const headerCells = cols.map(c => `<th>${esc(c)}</th>`).join('');
|
|
318
|
+
const bodyRows = rows.map((r, i) => {
|
|
319
|
+
const dataCells = cells[i].map(v => `<td style="text-align:right">${esc(formatCell(v))}</td>`).join('');
|
|
320
|
+
return `<tr><td>${esc(r)}</td>${dataCells}</tr>`;
|
|
321
|
+
}).join('') || emptyState('No data to report on', 'bar-chart');
|
|
322
|
+
const content = `<div class="page-header">
|
|
323
|
+
<div><h1 class="page-title">${esc(label)}: ${esc(rowFieldDef.label || rowField)} × ${esc(colFieldDef.label || colField)} (${esc(validAgg)}${valueFieldDef ? ' of ' + esc(valueFieldDef.label || valueField) : ''})</h1><p class="page-subtitle">${records.length} total records</p></div>
|
|
324
|
+
</div>
|
|
325
|
+
<div class="table-wrap"><table class="data-table">
|
|
326
|
+
<thead><tr><th></th>${headerCells}</tr></thead>
|
|
327
|
+
<tbody>${bodyRows}</tbody>
|
|
328
|
+
</table></div>`;
|
|
329
|
+
return page(user, `${label} Report | Thatcher`, null, content);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function renderDemandForecastReport(user, spec, buckets, totalOpportunities) {
|
|
333
|
+
const label = getEntityLabel(spec, true) || 'Opportunities';
|
|
334
|
+
const valueFieldDef = spec.fields?.value || { type: 'currency' };
|
|
335
|
+
const totalProjected = buckets.reduce((sum, [, v]) => sum + v, 0);
|
|
336
|
+
const notice = !buckets.length
|
|
337
|
+
? `<div class="report-notice">No open opportunities with a future expected close date</div>`
|
|
338
|
+
: '';
|
|
339
|
+
const content = `<div class="page-header">
|
|
340
|
+
<div><h1 class="page-title">${esc(label)}: Demand Forecast</h1><p class="page-subtitle">${totalOpportunities} total opportunities, ${esc(formatSumValue(totalProjected, valueFieldDef))} projected across ${buckets.length} future month${buckets.length === 1 ? '' : 's'}</p></div>
|
|
341
|
+
</div>
|
|
342
|
+
${notice}
|
|
343
|
+
${sumBarChart(buckets, valueFieldDef)}`;
|
|
344
|
+
return page(user, `Demand Forecast | Thatcher`, null, content);
|
|
345
|
+
}
|