thatcher 1.0.76 → 1.0.78
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
|
@@ -253,6 +253,27 @@ function withTimeTrackingDefaults(masterConfig) {
|
|
|
253
253
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
const RESOURCE_ALLOCATION_ENTITY_DEFAULT = {
|
|
257
|
+
label: 'Resource Allocation',
|
|
258
|
+
label_plural: 'Resource Allocations',
|
|
259
|
+
system_entity: true,
|
|
260
|
+
fields: {
|
|
261
|
+
user_id: { type: 'ref', ref: 'user', required: true, label: 'User' },
|
|
262
|
+
project_id: { type: 'ref', ref: 'project', required: true, label: 'Project' },
|
|
263
|
+
allocated_hours_per_week: { type: 'number', required: true, min: 0, max: 168, label: 'Allocated Hours/Week' },
|
|
264
|
+
start_date: { type: 'date', required: true, label: 'Start Date' },
|
|
265
|
+
end_date: { type: 'date', required: true, label: 'End Date' },
|
|
266
|
+
role: { type: 'text', label: 'Role' },
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
function withResourceManagementDefaults(masterConfig) {
|
|
271
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
272
|
+
let changed = false;
|
|
273
|
+
if (!entities.resource_allocation) { entities.resource_allocation = RESOURCE_ALLOCATION_ENTITY_DEFAULT; changed = true; }
|
|
274
|
+
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
275
|
+
}
|
|
276
|
+
|
|
256
277
|
const CONTRACT_LIFECYCLE_WORKFLOW = {
|
|
257
278
|
state_field: 'status',
|
|
258
279
|
stages: [
|
|
@@ -295,7 +316,7 @@ function withContractDefaults(masterConfig) {
|
|
|
295
316
|
export class ConfigGeneratorEngine {
|
|
296
317
|
constructor(masterConfig) {
|
|
297
318
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
298
|
-
this.masterConfig = deepFreeze(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))))))));
|
|
319
|
+
this.masterConfig = deepFreeze(withResourceManagementDefaults(withContractDefaults(withTimeTrackingDefaults(withInventoryDefaults(withProjectDefaults(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)))))))));
|
|
299
320
|
this.specCache = new LRUCache(100);
|
|
300
321
|
this.debugMode = false;
|
|
301
322
|
this._plugins = new Map();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// In-memory presence: who is currently viewing entity+id. No persistence --
|
|
2
|
+
// presence is inherently ephemeral, and a restart clearing it is correct
|
|
3
|
+
// behavior, not data loss. Swept on a timer the same way scheduler-engine.js
|
|
4
|
+
// sweeps due jobs, so a closed tab's viewer entry disappears on its own
|
|
5
|
+
// without requiring an explicit "leaving" signal the client might never send.
|
|
6
|
+
const STALE_AFTER_MS = 30 * 1000;
|
|
7
|
+
const SWEEP_INTERVAL_MS = 15 * 1000;
|
|
8
|
+
const presence = new Map();
|
|
9
|
+
|
|
10
|
+
let sweepHandle = null;
|
|
11
|
+
function ensureSweep() {
|
|
12
|
+
if (sweepHandle) return;
|
|
13
|
+
sweepHandle = setInterval(() => {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
for (const [key, viewers] of presence) {
|
|
16
|
+
for (const [userId, entry] of viewers) {
|
|
17
|
+
if (now - entry.lastSeenAt > STALE_AFTER_MS) viewers.delete(userId);
|
|
18
|
+
}
|
|
19
|
+
if (viewers.size === 0) presence.delete(key);
|
|
20
|
+
}
|
|
21
|
+
}, SWEEP_INTERVAL_MS);
|
|
22
|
+
if (sweepHandle.unref) sweepHandle.unref();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function keyFor(entity, id) {
|
|
26
|
+
return `${entity}:${id}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function heartbeat(entity, id, userId, userName) {
|
|
30
|
+
ensureSweep();
|
|
31
|
+
const key = keyFor(entity, id);
|
|
32
|
+
let viewers = presence.get(key);
|
|
33
|
+
if (!viewers) { viewers = new Map(); presence.set(key, viewers); }
|
|
34
|
+
viewers.set(userId, { userId, userName, lastSeenAt: Date.now() });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Excludes the requester so a solo viewer never sees themselves listed as
|
|
38
|
+
// "someone else is viewing this record" -- the indicator is meaningless (and
|
|
39
|
+
// mildly alarming) if it counts the person reading it.
|
|
40
|
+
export function getViewers(entity, id, excludeUserId) {
|
|
41
|
+
const key = keyFor(entity, id);
|
|
42
|
+
const viewers = presence.get(key);
|
|
43
|
+
if (!viewers) return [];
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
return [...viewers.values()]
|
|
46
|
+
.filter(v => v.userId !== excludeUserId && now - v.lastSeenAt <= STALE_AFTER_MS)
|
|
47
|
+
.map(v => ({ userId: v.userId, userName: v.userName }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resetPresence() {
|
|
51
|
+
presence.clear();
|
|
52
|
+
}
|
|
@@ -157,17 +157,30 @@ function resolveEnumOptions(fieldDef, entityName) {
|
|
|
157
157
|
// UI-side, but server-side is the authoritative check per this session's SEC
|
|
158
158
|
// pattern. Privileged roles (partner/manager) may log time for anyone, e.g.
|
|
159
159
|
// entering a team member's hours on their behalf.
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
// Shared self-only check: a non-privileged actingUser may only write a
|
|
161
|
+
// record whose user_id matches their own id; partner/admin/manager may write
|
|
162
|
+
// for anyone. One function so every self-only entity enforces identically
|
|
163
|
+
// rather than each growing its own slightly-different copy over time.
|
|
164
|
+
function checkSelfOnlyOwnership(data, options, actionDescription) {
|
|
162
165
|
const actingUser = options?.actingUser;
|
|
163
166
|
if (!actingUser) return null;
|
|
164
167
|
if (['partner', 'admin', 'manager'].includes(actingUser.role)) return null;
|
|
165
168
|
if (data.user_id !== undefined && data.user_id !== actingUser.id) {
|
|
166
|
-
return `Cannot
|
|
169
|
+
return `Cannot ${actionDescription} for another user`;
|
|
167
170
|
}
|
|
168
171
|
return null;
|
|
169
172
|
}
|
|
170
173
|
|
|
174
|
+
function checkTimeEntryOwnership(entityName, data, options) {
|
|
175
|
+
if (entityName !== 'time_entry') return null;
|
|
176
|
+
return checkSelfOnlyOwnership(data, options, 'log time');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function checkResourceAllocationOwnership(entityName, data, options) {
|
|
180
|
+
if (entityName !== 'resource_allocation') return null;
|
|
181
|
+
return checkSelfOnlyOwnership(data, options, 'create a resource allocation');
|
|
182
|
+
}
|
|
183
|
+
|
|
171
184
|
// Stock movements are immutable history (checked at creation only, never on
|
|
172
185
|
// edit -- there is no update path for them). An outbound movement (negative
|
|
173
186
|
// quantity) that would drive the running balance below zero is invalid: the
|
|
@@ -205,6 +218,40 @@ function checkContractDateOrder(entityName, data, existingRecord) {
|
|
|
205
218
|
return null;
|
|
206
219
|
}
|
|
207
220
|
|
|
221
|
+
const WEEKLY_CAPACITY_HOURS = 40;
|
|
222
|
+
|
|
223
|
+
// Two date ranges overlap unless one entirely precedes the other -- the
|
|
224
|
+
// standard interval-intersection test, not a same-day/exact-match check.
|
|
225
|
+
function rangesOverlap(startA, endA, startB, endB) {
|
|
226
|
+
return Number(startA) <= Number(endB) && Number(startB) <= Number(endA);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A user's total weekly allocation across every project they're assigned to,
|
|
230
|
+
// for any date range that overlaps the new/changed allocation, must not
|
|
231
|
+
// exceed a configurable weekly capacity. Checked against EVERY OTHER
|
|
232
|
+
// existing allocation for that user (excluding the record being updated, if
|
|
233
|
+
// any) plus the new one -- never trusting a client-supplied total.
|
|
234
|
+
async function checkResourceCapacity(entityName, data, existingRecord) {
|
|
235
|
+
if (entityName !== 'resource_allocation') return null;
|
|
236
|
+
const userId = data.user_id !== undefined ? data.user_id : existingRecord?.user_id;
|
|
237
|
+
const startDate = data.start_date !== undefined ? data.start_date : existingRecord?.start_date;
|
|
238
|
+
const endDate = data.end_date !== undefined ? data.end_date : existingRecord?.end_date;
|
|
239
|
+
const hours = Number(data.allocated_hours_per_week !== undefined ? data.allocated_hours_per_week : existingRecord?.allocated_hours_per_week);
|
|
240
|
+
if (!userId || startDate == null || endDate == null || !Number.isFinite(hours)) return null;
|
|
241
|
+
|
|
242
|
+
const { list } = await import('@/lib/busybase/store');
|
|
243
|
+
const existingAllocations = await list('resource_allocation', { user_id: userId });
|
|
244
|
+
const overlapping = existingAllocations.filter(a =>
|
|
245
|
+
a.id !== existingRecord?.id && rangesOverlap(startDate, endDate, a.start_date, a.end_date)
|
|
246
|
+
);
|
|
247
|
+
const overlappingTotal = overlapping.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
|
|
248
|
+
const resultingTotal = overlappingTotal + hours;
|
|
249
|
+
if (resultingTotal > WEEKLY_CAPACITY_HOURS) {
|
|
250
|
+
return `Over-allocated: this would bring the user's weekly total to ${resultingTotal}h against overlapping allocations, exceeding the ${WEEKLY_CAPACITY_HOURS}h capacity`;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
208
255
|
export async function validateEntity(entityName, data, existingRecord = null, options = {}) {
|
|
209
256
|
const spec = getSpec(entityName);
|
|
210
257
|
const errors = {};
|
|
@@ -232,12 +279,15 @@ export async function validateEntity(entityName, data, existingRecord = null, op
|
|
|
232
279
|
const stockErr = await checkStockBalance(entityName, data);
|
|
233
280
|
if (stockErr) errors.quantity = stockErr;
|
|
234
281
|
|
|
235
|
-
const ownershipErr = checkTimeEntryOwnership(entityName, data, options);
|
|
282
|
+
const ownershipErr = checkTimeEntryOwnership(entityName, data, options) || checkResourceAllocationOwnership(entityName, data, options);
|
|
236
283
|
if (ownershipErr) errors.user_id = ownershipErr;
|
|
237
284
|
|
|
238
285
|
const dateOrderErr = checkContractDateOrder(entityName, data, existingRecord);
|
|
239
286
|
if (dateOrderErr) errors.end_date = dateOrderErr;
|
|
240
287
|
|
|
288
|
+
const capacityErr = await checkResourceCapacity(entityName, data, existingRecord);
|
|
289
|
+
if (capacityErr) errors.allocated_hours_per_week = capacityErr;
|
|
290
|
+
|
|
241
291
|
return errors;
|
|
242
292
|
}
|
|
243
293
|
|
|
@@ -316,6 +366,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
|
|
|
316
366
|
const dateOrderErr = checkContractDateOrder(entityName, changes, existingRecord);
|
|
317
367
|
if (dateOrderErr) errors.end_date = dateOrderErr;
|
|
318
368
|
|
|
369
|
+
const capacityErr = await checkResourceCapacity(entityName, changes, existingRecord);
|
|
370
|
+
if (capacityErr) errors.allocated_hours_per_week = capacityErr;
|
|
371
|
+
|
|
319
372
|
return errors;
|
|
320
373
|
}
|
|
321
374
|
|
package/src/server/server.js
CHANGED
|
@@ -119,6 +119,22 @@ export function createServer(options) {
|
|
|
119
119
|
const id = parts[1] || null;
|
|
120
120
|
const action = parts[2] || null;
|
|
121
121
|
|
|
122
|
+
// presence/changes are meta-routes over an (entity,id) pair, not
|
|
123
|
+
// themselves entities -- parts[1]/parts[2] here are the TARGET
|
|
124
|
+
// entity name and record id, distinct from the entity/id/action
|
|
125
|
+
// parsed above for the generic CRUD routes.
|
|
126
|
+
if (req.method === 'POST' && entity === 'presence' && parts[1] && parts[2] && parts[3] === 'heartbeat') {
|
|
127
|
+
return await handlePresenceHeartbeat(req, res, parts[1], parts[2]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (req.method === 'GET' && entity === 'presence' && parts[1] && parts[2] && !parts[3]) {
|
|
131
|
+
return await handlePresenceGet(req, res, parts[1], parts[2]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (req.method === 'GET' && entity === 'changes' && parts[1] && parts[2] && parts[3] === 'since' && parts[4]) {
|
|
135
|
+
return await handleChangesSince(req, res, parts[1], parts[2], parts[4]);
|
|
136
|
+
}
|
|
137
|
+
|
|
122
138
|
if (req.method === 'GET' && entity === 'auth' && id === 'google' && !action) {
|
|
123
139
|
return await handleOAuthGoogleStart(req, res);
|
|
124
140
|
}
|
|
@@ -1427,6 +1443,67 @@ async function readMultipartFile(req) {
|
|
|
1427
1443
|
throw new Error('No file field found in upload');
|
|
1428
1444
|
}
|
|
1429
1445
|
|
|
1446
|
+
async function verifyRecordAccess(req, res, entityName, id) {
|
|
1447
|
+
const user = await resolveRequestUser(req);
|
|
1448
|
+
if (!user) {
|
|
1449
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1450
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
1451
|
+
return null;
|
|
1452
|
+
}
|
|
1453
|
+
const { get } = await import('../lib/busybase/store.js');
|
|
1454
|
+
let record;
|
|
1455
|
+
try {
|
|
1456
|
+
record = await get(entityName, id, { user });
|
|
1457
|
+
} catch {
|
|
1458
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1459
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
1460
|
+
return null;
|
|
1461
|
+
}
|
|
1462
|
+
if (!record) {
|
|
1463
|
+
// A record the caller cannot access and a record that doesn't exist
|
|
1464
|
+
// resolve identically here on purpose -- distinguishing them would leak
|
|
1465
|
+
// that a specific id exists to someone who isn't allowed to see it.
|
|
1466
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1467
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
return user;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
async function handlePresenceHeartbeat(req, res, entityName, id) {
|
|
1474
|
+
const user = await verifyRecordAccess(req, res, entityName, id);
|
|
1475
|
+
if (!user) return;
|
|
1476
|
+
const { heartbeat } = await import('../lib/presence-tracker.js');
|
|
1477
|
+
heartbeat(entityName, id, user.id, user.name || user.email || user.id);
|
|
1478
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1479
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
async function handlePresenceGet(req, res, entityName, id) {
|
|
1483
|
+
const user = await verifyRecordAccess(req, res, entityName, id);
|
|
1484
|
+
if (!user) return;
|
|
1485
|
+
const { getViewers } = await import('../lib/presence-tracker.js');
|
|
1486
|
+
const viewers = getViewers(entityName, id, user.id);
|
|
1487
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1488
|
+
res.end(JSON.stringify({ viewers }));
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
async function handleChangesSince(req, res, entityName, id, timestampStr) {
|
|
1492
|
+
const user = await verifyRecordAccess(req, res, entityName, id);
|
|
1493
|
+
if (!user) return;
|
|
1494
|
+
const since = Number(timestampStr);
|
|
1495
|
+
if (!Number.isFinite(since)) {
|
|
1496
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1497
|
+
res.end(JSON.stringify({ error: 'Invalid timestamp' }));
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const { getEntityAuditTrail } = await import('../lib/busybase/audit-reads.js');
|
|
1501
|
+
const trail = await getEntityAuditTrail(entityName, id);
|
|
1502
|
+
const changed = trail.some(entry => (entry.createdAt || 0) > since);
|
|
1503
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1504
|
+
res.end(JSON.stringify({ changed }));
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1430
1507
|
async function handleFileUpload(req, res, thatcher, configEngineArg) {
|
|
1431
1508
|
const user = await resolveRequestUser(req);
|
|
1432
1509
|
if (!user) {
|
|
@@ -160,9 +160,23 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
160
160
|
})()
|
|
161
161
|
: ''
|
|
162
162
|
|
|
163
|
+
// allocated_hours_per_week/weekly_capacity_hours are computed (page-handler.js
|
|
164
|
+
// sums resource_allocation rows for this user), same not-a-spec-field
|
|
165
|
+
// treatment as current_stock/total_hours above.
|
|
166
|
+
const utilizationRow = entityName === 'user' && typeof item.allocated_hours_per_week === 'number'
|
|
167
|
+
? (() => {
|
|
168
|
+
const over = item.allocated_hours_per_week > item.weekly_capacity_hours
|
|
169
|
+
const pillCls = over ? 'pill-danger' : 'pill-success'
|
|
170
|
+
return `<div class="detail-row">
|
|
171
|
+
<span class="detail-row-label">Resource Utilization</span>
|
|
172
|
+
<span class="detail-row-value"><span class="pill ${pillCls}">${esc(String(item.allocated_hours_per_week))}h / ${esc(String(item.weekly_capacity_hours))}h${over ? ' (Over-allocated)' : ''}</span></span>
|
|
173
|
+
</div>`
|
|
174
|
+
})()
|
|
175
|
+
: ''
|
|
176
|
+
|
|
163
177
|
const visibleFields = Object.entries(fields).filter(([k]) => k !== 'id' && !HIDDEN_FIELDS.has(k) && item[k] !== undefined)
|
|
164
178
|
|
|
165
|
-
const fieldRows = stockRow + timeTrackingRows + expiryRow + visibleFields.map(([k, f]) =>
|
|
179
|
+
const fieldRows = stockRow + timeTrackingRows + expiryRow + utilizationRow + visibleFields.map(([k, f]) =>
|
|
166
180
|
`<div class="detail-row">
|
|
167
181
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
168
182
|
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
|
@@ -199,6 +213,13 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
199
213
|
<div style="flex:1">${headerExtra}</div>
|
|
200
214
|
<div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${cloneBtn}${delBtn}</div>
|
|
201
215
|
</div>
|
|
216
|
+
<div id="stale-data-banner" style="display:none;margin-bottom:12px" class="card-clean">
|
|
217
|
+
<div class="card-clean-body" style="padding:10px 16px;display:flex;align-items:center;justify-content:space-between">
|
|
218
|
+
<span style="font-size:13px">This record was updated by someone else -- refresh to see changes.</span>
|
|
219
|
+
<button type="button" class="btn-ghost-clean" onclick="window.location.reload()">Refresh</button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
<div id="presence-indicator" style="display:none;margin-bottom:12px;font-size:13px;color:var(--color-text-muted)"></div>
|
|
202
223
|
<div class="card-clean">
|
|
203
224
|
<div class="card-clean-body"><div class="detail-grid">${fieldRows || '<p style="color:var(--color-text-muted);font-size:0.875rem;grid-column:1/-1">No details available</p>'}</div></div>
|
|
204
225
|
</div>
|
|
@@ -207,8 +228,38 @@ export function renderEntityDetail(entityName, item, spec, user, history = []) {
|
|
|
207
228
|
|
|
208
229
|
// Canonical gmConfirm (session-13): showDeleteConfirm runs the styled confirm then DELETEs; no bespoke dialog markup/show-hide.
|
|
209
230
|
const script = `${TOAST_SCRIPT}window.showDeleteConfirm=async()=>{const ok=await window.gmConfirm({title:'Delete ${entityName}',message:'Delete this ${entityName}? This cannot be undone.',confirmLabel:'Delete',danger:true});if(!ok)return;try{const res=await fetch('/api/${entityName}/${item.id}',{method:'DELETE'});if(res.ok){showToast('Deleted successfully','success');setTimeout(()=>{window.location='/${entityName}'},500)}else{const d=await res.json().catch(()=>({}));showToast(d.message||d.error||'Delete failed','error')}}catch(err){showToast('Error: '+err.message,'error')}}`
|
|
231
|
+
const collabScript = `(function(){
|
|
232
|
+
var entity='${entityName}',id='${esc(String(item.id))}';
|
|
233
|
+
var openedAt=Math.floor(Date.now()/1000);
|
|
234
|
+
function heartbeat(){fetch('/api/presence/'+entity+'/'+id+'/heartbeat',{method:'POST'}).catch(function(){})}
|
|
235
|
+
function pollPresence(){
|
|
236
|
+
fetch('/api/presence/'+entity+'/'+id).then(function(r){return r.json()}).then(function(d){
|
|
237
|
+
var el=document.getElementById('presence-indicator');
|
|
238
|
+
if(!el)return;
|
|
239
|
+
var viewers=d.viewers||[];
|
|
240
|
+
if(viewers.length){
|
|
241
|
+
el.style.display='block';
|
|
242
|
+
el.textContent=(viewers.length===1?'1 other person is':viewers.length+' other people are')+' currently viewing this: '+viewers.map(function(v){return v.userName}).join(', ');
|
|
243
|
+
} else {
|
|
244
|
+
el.style.display='none';
|
|
245
|
+
}
|
|
246
|
+
}).catch(function(){})
|
|
247
|
+
}
|
|
248
|
+
function pollChanges(){
|
|
249
|
+
fetch('/api/changes/'+entity+'/'+id+'/since/'+openedAt).then(function(r){return r.json()}).then(function(d){
|
|
250
|
+
if(d.changed){
|
|
251
|
+
var el=document.getElementById('stale-data-banner');
|
|
252
|
+
if(el)el.style.display='block';
|
|
253
|
+
}
|
|
254
|
+
}).catch(function(){})
|
|
255
|
+
}
|
|
256
|
+
heartbeat();pollPresence();pollChanges();
|
|
257
|
+
setInterval(heartbeat,10000);
|
|
258
|
+
setInterval(pollPresence,10000);
|
|
259
|
+
setInterval(pollChanges,8000);
|
|
260
|
+
})();`
|
|
210
261
|
const bc = [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: item.name || item.title || `#${item.id}` }]
|
|
211
|
-
return page(user, `${label} Detail`, bc, content, [script])
|
|
262
|
+
return page(user, `${label} Detail`, bc, content, [script, collabScript])
|
|
212
263
|
}
|
|
213
264
|
|
|
214
265
|
export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}, templates = []) {
|
package/src/ui/page-handler.js
CHANGED
|
@@ -158,6 +158,19 @@ async function handleGenericEntityView(user, entityName, id, req) {
|
|
|
158
158
|
const { daysUntilExpiry } = await import('@/lib/contract-expiry.js');
|
|
159
159
|
resolvedItem = { ...resolvedItem, days_until_expiry: daysUntilExpiry(resolvedItem.end_date) };
|
|
160
160
|
}
|
|
161
|
+
if (entityName === 'user') {
|
|
162
|
+
// Resource utilization reuses the same "sum grouped by owner" rollup
|
|
163
|
+
// shape as task/project total_hours above, just grouped by allocation
|
|
164
|
+
// rather than time entry -- no new aggregation logic, only overlapping
|
|
165
|
+
// (currently-active) allocations count toward the displayed total.
|
|
166
|
+
try {
|
|
167
|
+
const allocations = await list('resource_allocation', { user_id: id });
|
|
168
|
+
const nowTs = Math.floor(Date.now() / 1000);
|
|
169
|
+
const activeAllocations = allocations.filter(a => Number(a.start_date) <= nowTs && nowTs <= Number(a.end_date));
|
|
170
|
+
const allocatedHoursPerWeek = activeAllocations.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
|
|
171
|
+
resolvedItem = { ...resolvedItem, allocated_hours_per_week: allocatedHoursPerWeek, weekly_capacity_hours: 40 };
|
|
172
|
+
} catch { resolvedItem = { ...resolvedItem, allocated_hours_per_week: 0, weekly_capacity_hours: 40 }; }
|
|
173
|
+
}
|
|
161
174
|
// get(...,{user}) above already enforced row/org access for this exact
|
|
162
175
|
// record (a denied/absent record returns null before this point), so
|
|
163
176
|
// fetching its audit trail here is scoped by construction -- there is no
|