thatcher 1.0.86 → 1.0.87
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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single source of truth for resource-allocation load math, extracted from
|
|
2
|
+
// entity-validators.js's checkResourceCapacity so the over-allocation
|
|
3
|
+
// PREVENTION check and the resource-optimizer's SUGGESTION ranking compute a
|
|
4
|
+
// user's committed load identically -- two independently-maintained copies
|
|
5
|
+
// of the same overlap/sum formula would inevitably drift.
|
|
6
|
+
export const DEFAULT_WEEKLY_CAPACITY_HOURS = 40;
|
|
7
|
+
|
|
8
|
+
// Two date ranges overlap unless one entirely precedes the other -- the
|
|
9
|
+
// standard interval-intersection test, not a same-day/exact-match check.
|
|
10
|
+
export function rangesOverlap(startA, endA, startB, endB) {
|
|
11
|
+
return Number(startA) <= Number(endB) && Number(startB) <= Number(endA);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Sum of allocated_hours_per_week across every existing allocation for
|
|
15
|
+
// userId whose date range overlaps [startDate, endDate], excluding
|
|
16
|
+
// excludeAllocationId (the record being updated, if any, so it doesn't
|
|
17
|
+
// double-count itself). Reads via list('resource_allocation', {user_id}),
|
|
18
|
+
// the same unscoped-internal lookup pattern every other entity-specific
|
|
19
|
+
// check this session (checkStockBalance/checkContractDateOrder) already
|
|
20
|
+
// uses -- no user context threaded through this layer.
|
|
21
|
+
export async function userCommittedHours(userId, startDate, endDate, excludeAllocationId = null) {
|
|
22
|
+
const { list } = await import('./busybase/store.js');
|
|
23
|
+
const existingAllocations = await list('resource_allocation', { user_id: userId });
|
|
24
|
+
const overlapping = existingAllocations.filter(a =>
|
|
25
|
+
a.id !== excludeAllocationId && rangesOverlap(startDate, endDate, a.start_date, a.end_date)
|
|
26
|
+
);
|
|
27
|
+
return overlapping.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
|
|
28
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { userCommittedHours, DEFAULT_WEEKLY_CAPACITY_HOURS } from './resource-capacity.js';
|
|
2
|
+
|
|
3
|
+
// Ranks a pool of REAL users (queried from the user list, never an arbitrary
|
|
4
|
+
// id range) by remaining weekly capacity in the given date range, using the
|
|
5
|
+
// exact same overlap-based load calculation checkResourceCapacity already
|
|
6
|
+
// enforces -- so a "best fit" suggestion and the prevention check it feeds
|
|
7
|
+
// into can never disagree about how loaded a candidate actually is.
|
|
8
|
+
// Excludes anyone already at or over capacity entirely (not just ranked
|
|
9
|
+
// last), since they are not a valid suggestion regardless of rank.
|
|
10
|
+
export async function suggestBestFitUsers(candidateUserIds, startDate, endDate, hoursNeeded, capacityHours = DEFAULT_WEEKLY_CAPACITY_HOURS) {
|
|
11
|
+
const results = [];
|
|
12
|
+
for (const userId of candidateUserIds) {
|
|
13
|
+
const committedHours = await userCommittedHours(userId, startDate, endDate);
|
|
14
|
+
const remainingCapacity = capacityHours - committedHours;
|
|
15
|
+
if (remainingCapacity < hoursNeeded) continue;
|
|
16
|
+
results.push({ user_id: userId, committed_hours: committedHours, remaining_capacity: remainingCapacity });
|
|
17
|
+
}
|
|
18
|
+
results.sort((a, b) => b.remaining_capacity - a.remaining_capacity);
|
|
19
|
+
return results;
|
|
20
|
+
}
|
|
@@ -303,19 +303,14 @@ async function checkCrossEntityRules(entityName, data, existingRecord) {
|
|
|
303
303
|
return null;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
-
const WEEKLY_CAPACITY_HOURS = 40;
|
|
307
|
-
|
|
308
|
-
// Two date ranges overlap unless one entirely precedes the other -- the
|
|
309
|
-
// standard interval-intersection test, not a same-day/exact-match check.
|
|
310
|
-
function rangesOverlap(startA, endA, startB, endB) {
|
|
311
|
-
return Number(startA) <= Number(endB) && Number(startB) <= Number(endA);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
306
|
// A user's total weekly allocation across every project they're assigned to,
|
|
315
307
|
// for any date range that overlaps the new/changed allocation, must not
|
|
316
308
|
// exceed a configurable weekly capacity. Checked against EVERY OTHER
|
|
317
309
|
// existing allocation for that user (excluding the record being updated, if
|
|
318
|
-
// any) plus the new one -- never trusting a client-supplied total.
|
|
310
|
+
// any) plus the new one -- never trusting a client-supplied total. The
|
|
311
|
+
// overlap/load math itself lives in resource-capacity.js, shared with
|
|
312
|
+
// resource-optimizer.js's suggestion ranking so both compute a user's
|
|
313
|
+
// committed load identically.
|
|
319
314
|
async function checkResourceCapacity(entityName, data, existingRecord) {
|
|
320
315
|
if (entityName !== 'resource_allocation') return null;
|
|
321
316
|
const userId = data.user_id !== undefined ? data.user_id : existingRecord?.user_id;
|
|
@@ -324,15 +319,11 @@ async function checkResourceCapacity(entityName, data, existingRecord) {
|
|
|
324
319
|
const hours = Number(data.allocated_hours_per_week !== undefined ? data.allocated_hours_per_week : existingRecord?.allocated_hours_per_week);
|
|
325
320
|
if (!userId || startDate == null || endDate == null || !Number.isFinite(hours)) return null;
|
|
326
321
|
|
|
327
|
-
const {
|
|
328
|
-
const
|
|
329
|
-
const overlapping = existingAllocations.filter(a =>
|
|
330
|
-
a.id !== existingRecord?.id && rangesOverlap(startDate, endDate, a.start_date, a.end_date)
|
|
331
|
-
);
|
|
332
|
-
const overlappingTotal = overlapping.reduce((sum, a) => sum + (Number(a.allocated_hours_per_week) || 0), 0);
|
|
322
|
+
const { userCommittedHours, DEFAULT_WEEKLY_CAPACITY_HOURS } = await import('@/lib/resource-capacity');
|
|
323
|
+
const overlappingTotal = await userCommittedHours(userId, startDate, endDate, existingRecord?.id);
|
|
333
324
|
const resultingTotal = overlappingTotal + hours;
|
|
334
|
-
if (resultingTotal >
|
|
335
|
-
return `Over-allocated: this would bring the user's weekly total to ${resultingTotal}h against overlapping allocations, exceeding the ${
|
|
325
|
+
if (resultingTotal > DEFAULT_WEEKLY_CAPACITY_HOURS) {
|
|
326
|
+
return `Over-allocated: this would bring the user's weekly total to ${resultingTotal}h against overlapping allocations, exceeding the ${DEFAULT_WEEKLY_CAPACITY_HOURS}h capacity`;
|
|
336
327
|
}
|
|
337
328
|
return null;
|
|
338
329
|
}
|
package/src/server/server.js
CHANGED
|
@@ -151,6 +151,10 @@ export function createServer(options) {
|
|
|
151
151
|
return await handleChangesSince(req, res, parts[1], parts[2], parts[4]);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
if (req.method === 'GET' && entity === 'resource-optimizer' && id === 'suggest') {
|
|
155
|
+
return await handleResourceOptimizerSuggest(req, res);
|
|
156
|
+
}
|
|
157
|
+
|
|
154
158
|
if (req.method === 'GET' && entity === 'auth' && id === 'google' && !action) {
|
|
155
159
|
return await handleOAuthGoogleStart(req, res);
|
|
156
160
|
}
|
|
@@ -1589,6 +1593,46 @@ async function handlePresenceHeartbeat(req, res, entityName, id) {
|
|
|
1589
1593
|
res.end(JSON.stringify({ ok: true }));
|
|
1590
1594
|
}
|
|
1591
1595
|
|
|
1596
|
+
async function handleResourceOptimizerSuggest(req, res) {
|
|
1597
|
+
const user = await resolveRequestUser(req);
|
|
1598
|
+
if (!user) {
|
|
1599
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1600
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1605
|
+
const startDate = Number(url.searchParams.get('start_date'));
|
|
1606
|
+
const endDate = Number(url.searchParams.get('end_date'));
|
|
1607
|
+
const hoursNeeded = Number(url.searchParams.get('hours_needed'));
|
|
1608
|
+
if (!Number.isFinite(startDate) || !Number.isFinite(endDate) || !Number.isFinite(hoursNeeded)) {
|
|
1609
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1610
|
+
res.end(JSON.stringify({ error: 'start_date, end_date, and hours_needed are required numeric query params' }));
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// Self-only unless partner/manager -- the SAME privilege boundary
|
|
1615
|
+
// resource_allocation's own checkTimeEntryOwnership-derived ownership
|
|
1616
|
+
// check already enforces on write. A non-privileged caller asking for
|
|
1617
|
+
// suggestions must not learn how loaded ANY other user is (that leaks
|
|
1618
|
+
// workload/capacity across the org), so their candidate pool is
|
|
1619
|
+
// themselves alone rather than filtered results from a broader query.
|
|
1620
|
+
const isPrivileged = ['partner', 'admin', 'manager'].includes(user.role);
|
|
1621
|
+
let candidateUserIds;
|
|
1622
|
+
if (isPrivileged) {
|
|
1623
|
+
const { list } = await import('../lib/busybase/store.js');
|
|
1624
|
+
const users = await list('user', {});
|
|
1625
|
+
candidateUserIds = users.map(u => u.id);
|
|
1626
|
+
} else {
|
|
1627
|
+
candidateUserIds = [user.id];
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
const { suggestBestFitUsers } = await import('../lib/resource-optimizer.js');
|
|
1631
|
+
const suggestions = await suggestBestFitUsers(candidateUserIds, startDate, endDate, hoursNeeded);
|
|
1632
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1633
|
+
res.end(JSON.stringify({ suggestions }));
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1592
1636
|
async function handlePresenceGet(req, res, entityName, id) {
|
|
1593
1637
|
const user = await verifyRecordAccess(req, res, entityName, id);
|
|
1594
1638
|
if (!user) return;
|
|
@@ -315,7 +315,10 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
315
315
|
}
|
|
316
316
|
if (f.type === 'ref' && refOptions[k]) {
|
|
317
317
|
const opts = refOptions[k].map(o => `<option value="${esc(o.value)}" ${val===o.value?'selected':''}>${esc(o.label)}</option>`).join('')
|
|
318
|
-
|
|
318
|
+
const suggestBtn = entityName === 'resource_allocation' && k === 'user_id'
|
|
319
|
+
? `<button type="button" class="btn-ghost-clean" style="margin-top:4px" data-action="suggestBestFitUser">Suggest best-fit user</button>`
|
|
320
|
+
: ''
|
|
321
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select>${suggestBtn}</div>`
|
|
319
322
|
}
|
|
320
323
|
if (f.type === 'textarea') return `<div class="form-field full">${lbl(k,f,f.required)}<textarea id="field-${k}" name="${k}" class="form-input" style="min-height:100px;resize:vertical" ${req} placeholder="Enter ${esc((f.label||k).toLowerCase())}">${esc(val)}</textarea></div>`
|
|
321
324
|
if (f.type === 'bool') return `<div class="form-field"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="field-${k}" name="${k}" class="checkbox checkbox-primary" ${val?'checked':''}/><span class="form-label" style="margin:0">${esc(f.label||k)}</span></label></div>`
|
|
@@ -361,7 +364,10 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
361
364
|
<div class="form-actions" style="grid-column:1/-1"><button type="submit" id="submit-btn" class="btn-primary-clean"><span class="btn-text">Save</span><span class="btn-loading-text" style="display:none">Saving...</span></button>
|
|
362
365
|
<a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
|
|
363
366
|
const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;try{const fileInputs=[...form.querySelectorAll('input[type=file][data-attachment]')];for(const fi of fileInputs){const f=fi.files&&fi.files[0];if(!f)continue;const uf=new FormData();uf.append('file',f);const ures=await fetch('/api/upload',{method:'POST',body:uf});const ud=await ures.json();if(!ures.ok)throw new Error(ud.error||'File upload failed');const hidden=document.getElementById('field-'+fi.dataset.attachment+'-value');hidden.value=JSON.stringify(ud)}const fd=new FormData(form);const data={};for(const[k,v]of fd.entries()){if(k.endsWith('[]'))continue;data[k]=v}form.querySelectorAll('input[type=checkbox]:not([name$="[]"])').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('[data-multiselect]').forEach(ms=>{const name=ms.dataset.multiselect;data[name]=[...ms.querySelectorAll('input[type=checkbox]:checked')].map(cb=>cb.value)});form.querySelectorAll('input[type=number]:not([data-currency])').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});form.querySelectorAll('input[data-currency]').forEach(inp=>{const name=inp.dataset.currency;if(inp.value!=='')data[name]=Math.round(Number(inp.value)*100)});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
|
|
364
|
-
|
|
367
|
+
const suggestScript = entityName === 'resource_allocation'
|
|
368
|
+
? `window.suggestBestFitUser=async function(){const start=document.getElementById('field-start_date');const end=document.getElementById('field-end_date');const hours=document.getElementById('field-allocated_hours_per_week');if(!start.value||!end.value||!hours.value){showToast('Fill in start date, end date, and hours first','error');return}const startTs=Math.floor(new Date(start.value).getTime()/1000);const endTs=Math.floor(new Date(end.value).getTime()/1000);try{const res=await fetch('/api/resource-optimizer/suggest?start_date='+startTs+'&end_date='+endTs+'&hours_needed='+encodeURIComponent(hours.value));const data=await res.json();if(!res.ok){showToast(data.error||'Suggestion failed','error');return}if(!data.suggestions||!data.suggestions.length){showToast('No user has enough remaining capacity','warning');return}const top=data.suggestions[0];const select=document.getElementById('field-user_id');if(select){select.value=top.user_id;showToast('Suggested user selected ('+top.remaining_capacity+'h remaining capacity)','success')}}catch(err){showToast('Error: '+err.message,'error')}}`
|
|
369
|
+
: ''
|
|
370
|
+
return page(user, `${isNew ? 'Create' : 'Edit'} ${label}`, bc, content, [script, suggestScript])
|
|
365
371
|
}
|
|
366
372
|
|
|
367
373
|
export function renderSettings(user, config = {}) {
|