thatcher 1.0.88 → 1.0.90
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/config-generator-engine.js +32 -1
- package/src/lib/integration-sync-engine.js +82 -0
- package/src/lib/scheduler-engine.js +20 -0
- package/src/server/server.js +8 -0
- package/src/ui/entity-renderer.js +9 -0
- package/src/ui/page-handler.js +8 -1
- package/src/ui/report-renderer.js +345 -259
package/package.json
CHANGED
|
@@ -277,6 +277,37 @@ function withResourceManagementDefaults(masterConfig) {
|
|
|
277
277
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
// Generic outbound CRM-sync connector config, not a bespoke Salesforce/HubSpot
|
|
281
|
+
// integration -- provider/base_url/entity_mappings make it configurable to
|
|
282
|
+
// any downstream system's sync endpoint. organization_id is an EXPLICIT
|
|
283
|
+
// manually-defined field despite system_entity:true (same precedent as
|
|
284
|
+
// USER_ORGANIZATION_ENTITY_DEFAULT above) because integration credentials
|
|
285
|
+
// leaking cross-org via the system_entity multi-tenancy exemption would be a
|
|
286
|
+
// real vulnerability, not the usual global-by-design system entity case.
|
|
287
|
+
const INTEGRATION_CONNECTION_ENTITY_DEFAULT = {
|
|
288
|
+
label: 'Integration Connection',
|
|
289
|
+
label_plural: 'Integration Connections',
|
|
290
|
+
system_entity: true,
|
|
291
|
+
fields: {
|
|
292
|
+
organization_id: { type: 'ref', ref: 'organization', label: 'Organization' },
|
|
293
|
+
provider: { type: 'text', required: true, label: 'Provider' },
|
|
294
|
+
base_url: { type: 'text', required: true, label: 'Base URL' },
|
|
295
|
+
auth_type: { type: 'enum', required: true, label: 'Auth Type', options: [{ value: 'api_key', label: 'API Key' }, { value: 'oauth_token', label: 'OAuth Token' }] },
|
|
296
|
+
credential: { type: 'text', required: true, label: 'Credential', encrypted: true },
|
|
297
|
+
entity_mappings: { type: 'json', label: 'Entity Mappings' },
|
|
298
|
+
sync_direction: { type: 'enum', required: true, default: 'outbound', label: 'Sync Direction', options: [{ value: 'outbound', label: 'Outbound' }, { value: 'inbound', label: 'Inbound' }, { value: 'bidirectional', label: 'Bidirectional' }] },
|
|
299
|
+
enabled: { type: 'bool', default: true, label: 'Enabled' },
|
|
300
|
+
last_synced_at: { type: 'int', readonly: true, label: 'Last Synced At' },
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
function withIntegrationDefaults(masterConfig) {
|
|
305
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
306
|
+
let changed = false;
|
|
307
|
+
if (!entities.integration_connection) { entities.integration_connection = INTEGRATION_CONNECTION_ENTITY_DEFAULT; changed = true; }
|
|
308
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
309
|
+
}
|
|
310
|
+
|
|
280
311
|
const CUSTOM_ENTITY_DEF_ENTITY_DEFAULT = {
|
|
281
312
|
label: 'Custom Entity Definition',
|
|
282
313
|
label_plural: 'Custom Entity Definitions',
|
|
@@ -398,7 +429,7 @@ function withContractDefaults(masterConfig) {
|
|
|
398
429
|
export class ConfigGeneratorEngine {
|
|
399
430
|
constructor(masterConfig) {
|
|
400
431
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
401
|
-
this.masterConfig = deepFreeze(withCustomEntityDefaults(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))))));
|
|
432
|
+
this.masterConfig = deepFreeze(withCustomEntityDefaults(withIntegrationDefaults(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))))))));
|
|
402
433
|
this.specCache = new LRUCache(100);
|
|
403
434
|
this.debugMode = false;
|
|
404
435
|
this._plugins = new Map();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { list } from './busybase/store.js';
|
|
2
|
+
import { validateWebhookUrl } from './webhook-engine.js';
|
|
3
|
+
import { createLogger } from './logger.js';
|
|
4
|
+
|
|
5
|
+
const log = createLogger('[IntegrationSync]');
|
|
6
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
7
|
+
|
|
8
|
+
// Records changed since the connection's last sync, read through the SAME
|
|
9
|
+
// {user}-scoped list() every interactive read path uses -- a sync job cannot
|
|
10
|
+
// reach records outside its owner's org just because it runs unattended.
|
|
11
|
+
export async function getRecordsChangedSince(entityName, sinceTs, user) {
|
|
12
|
+
const records = await list(entityName, {}, { user });
|
|
13
|
+
return records.filter(r => (r.updated_at || 0) > (sinceTs || 0));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function authHeader(connection) {
|
|
17
|
+
if (connection.auth_type === 'oauth_token') return { Authorization: `Bearer ${connection.credential}` };
|
|
18
|
+
return { 'X-Api-Key': connection.credential };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mapRecordFields(record, mapping) {
|
|
22
|
+
if (!mapping || typeof mapping !== 'object') return record;
|
|
23
|
+
const mapped = {};
|
|
24
|
+
for (const [localField, remoteField] of Object.entries(mapping)) {
|
|
25
|
+
mapped[remoteField] = record[localField];
|
|
26
|
+
}
|
|
27
|
+
return mapped;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// connection.credential arrives here already decrypted (store.js decrypts on
|
|
31
|
+
// every list()/get()) -- this function only reads it into the auth header,
|
|
32
|
+
// never logs or persists it further.
|
|
33
|
+
export async function syncRecordToIntegration(connection, entityName, record) {
|
|
34
|
+
const mappings = typeof connection.entity_mappings === 'string'
|
|
35
|
+
? JSON.parse(connection.entity_mappings || '{}')
|
|
36
|
+
: (connection.entity_mappings || {});
|
|
37
|
+
const entityMapping = mappings[entityName];
|
|
38
|
+
if (!entityMapping) return { success: false, error: `No entity_mapping for "${entityName}"` };
|
|
39
|
+
|
|
40
|
+
const targetUrl = `${connection.base_url.replace(/\/$/, '')}/api/sync/${entityMapping}`;
|
|
41
|
+
// Same SSRF guard webhook-engine.js's dispatchWebhooks applies before any
|
|
42
|
+
// outbound fetch -- reused verbatim, not reimplemented, so a connection
|
|
43
|
+
// pointed at a private/loopback/link-local target is rejected here, before
|
|
44
|
+
// any network call is made.
|
|
45
|
+
const validation = await validateWebhookUrl(targetUrl);
|
|
46
|
+
if (!validation.ok) {
|
|
47
|
+
log.error(`integration_connection ${connection.id} target rejected: ${validation.error}`);
|
|
48
|
+
return { success: false, error: validation.error };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const payload = mapRecordFields(record, mappings[`${entityName}_fields`] || mappings.fields);
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(targetUrl, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json', ...authHeader(connection) },
|
|
58
|
+
body: JSON.stringify(payload),
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
});
|
|
61
|
+
return { success: res.ok, statusCode: res.status, error: res.ok ? null : `HTTP ${res.status}` };
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return { success: false, statusCode: 0, error: e.message };
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timeout);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function syncConnection(connection, user) {
|
|
70
|
+
const mappings = typeof connection.entity_mappings === 'string'
|
|
71
|
+
? JSON.parse(connection.entity_mappings || '{}')
|
|
72
|
+
: (connection.entity_mappings || {});
|
|
73
|
+
const entityNames = Object.keys(mappings).filter(k => !k.endsWith('_fields') && k !== 'fields');
|
|
74
|
+
const results = [];
|
|
75
|
+
for (const entityName of entityNames) {
|
|
76
|
+
const changed = await getRecordsChangedSince(entityName, connection.last_synced_at, user);
|
|
77
|
+
for (const record of changed) {
|
|
78
|
+
results.push({ entity: entityName, id: record.id, ...(await syncRecordToIntegration(connection, entityName, record)) });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return results;
|
|
82
|
+
}
|
|
@@ -47,6 +47,26 @@ async function runOneJob(job, nowTs) {
|
|
|
47
47
|
targets = targets.filter(c => isWithinNoticeWindow(c, nowTs));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// integration_sync is a distinct action shape from bulk field-updates --
|
|
51
|
+
// it fans out to an external system per connection rather than mutating
|
|
52
|
+
// Thatcher records, so it bypasses runBulkOperation entirely but still
|
|
53
|
+
// rides the SAME scheduled_job table/timer, not a new cron mechanism.
|
|
54
|
+
if (job.entity === 'integration_connection' && action?.type === 'integration_sync') {
|
|
55
|
+
const { syncConnection } = await import('./integration-sync-engine.js');
|
|
56
|
+
const syncResults = [];
|
|
57
|
+
for (const connection of targets) {
|
|
58
|
+
if (!connection.enabled) continue;
|
|
59
|
+
const results = await syncConnection(connection, owner);
|
|
60
|
+
syncResults.push(...results);
|
|
61
|
+
await update('integration_connection', connection.id, { last_synced_at: nowTs });
|
|
62
|
+
}
|
|
63
|
+
const nextRunAt = nowTs + job.interval_minutes * 60;
|
|
64
|
+
await update('scheduled_job', job.id, { last_run_at: nowTs, next_run_at: nextRunAt });
|
|
65
|
+
const succeeded = syncResults.filter(r => r.success).length;
|
|
66
|
+
log.info(`Job "${job.name}" ran: ${succeeded}/${syncResults.length} synced`);
|
|
67
|
+
return { job_id: job.id, ok: true, total: syncResults.length, succeeded, failed: syncResults.length - succeeded, results: syncResults };
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
const ids = targets.map(r => r.id);
|
|
51
71
|
|
|
52
72
|
let result = { ok: true, total: 0, succeeded: 0, failed: 0, results: [] };
|
package/src/server/server.js
CHANGED
|
@@ -493,6 +493,14 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
493
493
|
return;
|
|
494
494
|
}
|
|
495
495
|
permissionService.enforceEditPermissions(user, spec, body);
|
|
496
|
+
// The edit form renders encrypted:true fields blank with a "leave
|
|
497
|
+
// blank to keep unchanged" note -- a blank submission must not
|
|
498
|
+
// overwrite the stored secret, since update() spreads body directly
|
|
499
|
+
// into the patch and encryptFields only skips ABSENT keys, not
|
|
500
|
+
// empty-string values actually present in the object.
|
|
501
|
+
for (const [fieldKey, fieldDef] of Object.entries(spec.fields || {})) {
|
|
502
|
+
if (fieldDef.encrypted && body[fieldKey] === '') delete body[fieldKey];
|
|
503
|
+
}
|
|
496
504
|
{
|
|
497
505
|
// validateUpdate is the authoritative server-side check for both
|
|
498
506
|
// generic field-type constraints AND entity-specific business
|
|
@@ -65,6 +65,11 @@ function roleLabel(r) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function formatFieldValue(k, v, entityName, f) {
|
|
68
|
+
// encrypted:true fields decrypt on every list()/get() read (store.js) so
|
|
69
|
+
// the plaintext DOES reach this renderer -- redact here, the display
|
|
70
|
+
// boundary, rather than in store.js which legitimately serves the real
|
|
71
|
+
// value to business logic that needs it.
|
|
72
|
+
if (f?.encrypted) return v ? '<span class="pill pill-neutral">••••••••</span>' : '-'
|
|
68
73
|
if (entityName === 'user' && k === 'role') return `<span class="pill pill-neutral">${roleLabel(v)}</span>`
|
|
69
74
|
if (entityName === 'user' && k === 'status') {
|
|
70
75
|
const cls = v === 'active' ? 'pill-success' : v === 'deleted' ? 'pill-danger' : 'pill-neutral'
|
|
@@ -346,6 +351,10 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
346
351
|
const existingNote = existing?.filename ? `<div style="font-size:0.8rem;color:var(--color-text-muted)" data-existing-file="${k}">Current: ${esc(existing.filename)}</div>` : ''
|
|
347
352
|
return `<div class="form-field">${lbl(k,f,f.required)}<input type="file" id="field-${k}" data-attachment="${k}" class="form-input" ${existing ? '' : req}/>${existingNote}<input type="hidden" id="field-${k}-value" name="${k}" value="${existing ? esc(JSON.stringify(existing)) : ''}"/></div>`
|
|
348
353
|
}
|
|
354
|
+
if (f.encrypted) {
|
|
355
|
+
const encNote = val ? `<div style="font-size:0.8rem;color:var(--color-text-muted)">Currently set. Leave blank to keep unchanged.</div>` : ''
|
|
356
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<input type="password" id="field-${k}" name="${k}" value="" class="form-input" autocomplete="new-password" placeholder="Enter ${esc((f.label||k).toLowerCase())}"/>${encNote}</div>`
|
|
357
|
+
}
|
|
349
358
|
return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${esc(val)}" class="form-input" ${req} ${placeholder}/></div>`
|
|
350
359
|
}).join('\n')
|
|
351
360
|
|
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';
|
|
@@ -370,6 +370,13 @@ export async function handlePage(pathname, req, res) {
|
|
|
370
370
|
const groupBy = params.get('group_by') || '';
|
|
371
371
|
return renderSumByFieldReport(user, entityName, spec, items, field, groupBy);
|
|
372
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
|
+
}
|
|
373
380
|
if (report === 'rollup') {
|
|
374
381
|
const refField = params.get('ref_field') || '';
|
|
375
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
|
+
}
|