thatcher 1.0.72 → 1.0.74
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
CHANGED
|
@@ -231,10 +231,32 @@ function withInventoryDefaults(masterConfig) {
|
|
|
231
231
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
const TIME_ENTRY_ENTITY_DEFAULT = {
|
|
235
|
+
label: 'Time Entry',
|
|
236
|
+
label_plural: 'Time Entries',
|
|
237
|
+
system_entity: true,
|
|
238
|
+
fields: {
|
|
239
|
+
task_id: { type: 'ref', ref: 'task', required: true, label: 'Task' },
|
|
240
|
+
user_id: { type: 'ref', ref: 'user', required: true, label: 'User' },
|
|
241
|
+
date: { type: 'date', required: true, label: 'Date' },
|
|
242
|
+
hours: { type: 'number', required: true, min: 0, max: 24, label: 'Hours' },
|
|
243
|
+
billable: { type: 'bool', default: true, label: 'Billable' },
|
|
244
|
+
rate: { type: 'currency', label: 'Rate' },
|
|
245
|
+
notes: { type: 'textarea', label: 'Notes' },
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
function withTimeTrackingDefaults(masterConfig) {
|
|
250
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
251
|
+
let changed = false;
|
|
252
|
+
if (!entities.time_entry) { entities.time_entry = TIME_ENTRY_ENTITY_DEFAULT; changed = true; }
|
|
253
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
254
|
+
}
|
|
255
|
+
|
|
234
256
|
export class ConfigGeneratorEngine {
|
|
235
257
|
constructor(masterConfig) {
|
|
236
258
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
237
|
-
this.masterConfig = deepFreeze(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))));
|
|
259
|
+
this.masterConfig = deepFreeze(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))));
|
|
238
260
|
this.specCache = new LRUCache(100);
|
|
239
261
|
this.debugMode = false;
|
|
240
262
|
this._plugins = new Map();
|
|
@@ -152,6 +152,22 @@ function resolveEnumOptions(fieldDef, entityName) {
|
|
|
152
152
|
return [];
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// A non-privileged user may only log time against their own user_id -- a
|
|
156
|
+
// clerk POSTing another user's id must be rejected the same way it would be
|
|
157
|
+
// UI-side, but server-side is the authoritative check per this session's SEC
|
|
158
|
+
// pattern. Privileged roles (partner/manager) may log time for anyone, e.g.
|
|
159
|
+
// entering a team member's hours on their behalf.
|
|
160
|
+
function checkTimeEntryOwnership(entityName, data, options) {
|
|
161
|
+
if (entityName !== 'time_entry') return null;
|
|
162
|
+
const actingUser = options?.actingUser;
|
|
163
|
+
if (!actingUser) return null;
|
|
164
|
+
if (['partner', 'admin', 'manager'].includes(actingUser.role)) return null;
|
|
165
|
+
if (data.user_id !== undefined && data.user_id !== actingUser.id) {
|
|
166
|
+
return `Cannot log time for another user`;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
155
171
|
// Stock movements are immutable history (checked at creation only, never on
|
|
156
172
|
// edit -- there is no update path for them). An outbound movement (negative
|
|
157
173
|
// quantity) that would drive the running balance below zero is invalid: the
|
|
@@ -174,7 +190,7 @@ async function checkStockBalance(entityName, data) {
|
|
|
174
190
|
return null;
|
|
175
191
|
}
|
|
176
192
|
|
|
177
|
-
export async function validateEntity(entityName, data, existingRecord = null) {
|
|
193
|
+
export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
|
|
178
194
|
const spec = getSpec(entityName);
|
|
179
195
|
const errors = {};
|
|
180
196
|
|
|
@@ -201,6 +217,9 @@ export async function validateEntity(entityName, data, existingRecord = null) {
|
|
|
201
217
|
const stockErr = await checkStockBalance(entityName, data);
|
|
202
218
|
if (stockErr) errors.quantity = stockErr;
|
|
203
219
|
|
|
220
|
+
const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
|
|
221
|
+
if (ownershipErr) errors.user_id = ownershipErr;
|
|
222
|
+
|
|
204
223
|
return errors;
|
|
205
224
|
}
|
|
206
225
|
|
package/src/server/server.js
CHANGED
|
@@ -417,7 +417,7 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
|
|
|
417
417
|
// existence and entity-specific rules (e.g. stock-movement balance
|
|
418
418
|
// enforcement below) were silently skipped for a hand-crafted create.
|
|
419
419
|
const { validateEntity, hasErrors } = await import('../lib/validation/entity-validators.js');
|
|
420
|
-
const createErrors = await validateEntity(entity, body, null);
|
|
420
|
+
const createErrors = await validateEntity(entity, body, null, { actingUser: user });
|
|
421
421
|
if (hasErrors(createErrors)) {
|
|
422
422
|
res.writeHead(400);
|
|
423
423
|
res.end(JSON.stringify({ error: 'Validation failed', errors: createErrors }));
|
|
@@ -138,9 +138,17 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
138
138
|
})()
|
|
139
139
|
: ''
|
|
140
140
|
|
|
141
|
+
// total_hours/billable_amount are computed (page-handler.js sums time_entry
|
|
142
|
+
// rows, directly for a task or joined through task for a project), same
|
|
143
|
+
// not-a-spec-field treatment as current_stock above.
|
|
144
|
+
const timeTrackingRows = (entityName === 'task' || entityName === 'project') && typeof item.total_hours === 'number'
|
|
145
|
+
? `<div class="detail-row"><span class="detail-row-label">Total Hours</span><span class="detail-row-value">${esc(String(item.total_hours))}</span></div>
|
|
146
|
+
<div class="detail-row"><span class="detail-row-label">Billable Amount</span><span class="detail-row-value">${esc('$' + (item.billable_amount / 100).toFixed(2))}</span></div>`
|
|
147
|
+
: ''
|
|
148
|
+
|
|
141
149
|
const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
|
|
142
150
|
|
|
143
|
-
const fieldRows = stockRow + visibleFields.map(([k, f]) =>
|
|
151
|
+
const fieldRows = stockRow + timeTrackingRows + visibleFields.map(([k, f]) =>
|
|
144
152
|
`<div class="detail-row">
|
|
145
153
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
146
154
|
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
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 } from '@/ui/report-renderer.js';
|
|
12
|
+
import { renderCountByFieldReport, renderCountOverTimeReport, renderSumByFieldReport, renderRollupReport } 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';
|
|
@@ -125,6 +125,30 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
125
125
|
resolvedItem = { ...resolvedItem, current_stock: currentStock };
|
|
126
126
|
} catch { resolvedItem = { ...resolvedItem, current_stock: 0 }; }
|
|
127
127
|
}
|
|
128
|
+
if (entityName === 'task') {
|
|
129
|
+
// total_hours/billable_amount are derived from time_entry history, same
|
|
130
|
+
// never-a-stored-counter discipline as product.current_stock above.
|
|
131
|
+
try {
|
|
132
|
+
const entries = await list('time_entry', { task_id: id });
|
|
133
|
+
const totalHours = entries.reduce((sum, e) => sum + (Number(e.hours) || 0), 0);
|
|
134
|
+
const billableAmount = entries.filter(e => e.billable).reduce((sum, e) => sum + (Number(e.hours) || 0) * (Number(e.rate) || 0), 0);
|
|
135
|
+
resolvedItem = { ...resolvedItem, total_hours: totalHours, billable_amount: billableAmount };
|
|
136
|
+
} catch { resolvedItem = { ...resolvedItem, total_hours: 0, billable_amount: 0 }; }
|
|
137
|
+
}
|
|
138
|
+
if (entityName === 'project') {
|
|
139
|
+
// Project-level rollup joins through task the same way the rollup report
|
|
140
|
+
// added last pass joins entity A through a ref field to entity B -- no
|
|
141
|
+
// new join logic, just the same id-lookup-then-aggregate shape.
|
|
142
|
+
try {
|
|
143
|
+
const tasks = await list('task', { project_id: id });
|
|
144
|
+
const taskIds = new Set(tasks.map(t => t.id));
|
|
145
|
+
const allEntries = await list('time_entry', {});
|
|
146
|
+
const projectEntries = allEntries.filter(e => taskIds.has(e.task_id));
|
|
147
|
+
const totalHours = projectEntries.reduce((sum, e) => sum + (Number(e.hours) || 0), 0);
|
|
148
|
+
const billableAmount = projectEntries.filter(e => e.billable).reduce((sum, e) => sum + (Number(e.hours) || 0) * (Number(e.rate) || 0), 0);
|
|
149
|
+
resolvedItem = { ...resolvedItem, total_hours: totalHours, billable_amount: billableAmount };
|
|
150
|
+
} catch { resolvedItem = { ...resolvedItem, total_hours: 0, billable_amount: 0 }; }
|
|
151
|
+
}
|
|
128
152
|
// get(...,{user}) above already enforced row/org access for this exact
|
|
129
153
|
// record (a denied/absent record returns null before this point), so
|
|
130
154
|
// fetching its audit trail here is scoped by construction -- there is no
|
|
@@ -303,6 +327,27 @@ export async function handlePage(pathname, req, res) {
|
|
|
303
327
|
const granularity = params.get('granularity') || 'month';
|
|
304
328
|
return renderCountOverTimeReport(user, entityName, spec, items, dateField, granularity);
|
|
305
329
|
}
|
|
330
|
+
if (report === 'sum-by-field') {
|
|
331
|
+
const field = params.get('field') || '';
|
|
332
|
+
const groupBy = params.get('group_by') || '';
|
|
333
|
+
return renderSumByFieldReport(user, entityName, spec, items, field, groupBy);
|
|
334
|
+
}
|
|
335
|
+
if (report === 'rollup') {
|
|
336
|
+
const refField = params.get('ref_field') || '';
|
|
337
|
+
const rollupField = params.get('rollup_field') || '';
|
|
338
|
+
// The rollup groups by a field on the RELATED entity (refField.ref), so
|
|
339
|
+
// that entity's list access must be checked the same way viewing it
|
|
340
|
+
// directly would be -- a cross-entity report must not become a side
|
|
341
|
+
// channel for reading data the user couldn't otherwise view.
|
|
342
|
+
const refFieldDef = spec.fields?.[refField];
|
|
343
|
+
const relatedEntity = refFieldDef?.ref;
|
|
344
|
+
if (!relatedEntity || !canList(user, relatedEntity)) {
|
|
345
|
+
return renderAccessDenied(user, relatedEntity || entityName, 'view');
|
|
346
|
+
}
|
|
347
|
+
const relatedSpec = getSpec(relatedEntity);
|
|
348
|
+
const relatedRecords = await list(relatedEntity, {}, { user });
|
|
349
|
+
return renderRollupReport(user, entityName, spec, items, refField, relatedEntity, relatedSpec, relatedRecords, rollupField);
|
|
350
|
+
}
|
|
306
351
|
const view = params.get('view');
|
|
307
352
|
if (view === 'board') return renderBoardView(user, entityName, spec, items);
|
|
308
353
|
if (view === 'grid') return renderGridView(user, entityName, spec, items);
|
|
@@ -89,6 +89,101 @@ export function renderCountByFieldReport(user, entityName, spec, records, field)
|
|
|
89
89
|
return page(user, `${label} Report | Thatcher`, null, content);
|
|
90
90
|
}
|
|
91
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
|
+
|
|
92
187
|
export function renderCountOverTimeReport(user, entityName, spec, records, dateField, granularity) {
|
|
93
188
|
const label = getEntityLabel(spec, true) || entityName;
|
|
94
189
|
const fieldDef = spec.fields?.[dateField];
|