thatcher 1.0.80 → 1.0.82
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
|
@@ -218,6 +218,79 @@ function checkContractDateOrder(entityName, data, existingRecord) {
|
|
|
218
218
|
return null;
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
const CROSS_ENTITY_OPERATORS = {
|
|
222
|
+
equals: (a, b) => a === b,
|
|
223
|
+
not_equals: (a, b) => a !== b,
|
|
224
|
+
gt: (a, b) => Number(a) > Number(b),
|
|
225
|
+
gte: (a, b) => Number(a) >= Number(b),
|
|
226
|
+
lt: (a, b) => Number(a) < Number(b),
|
|
227
|
+
lte: (a, b) => Number(a) <= Number(b),
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Generic cross-entity rule evaluator: reads a field's cross_entity_rule
|
|
231
|
+
// definition off the SAME spec.fields[key] shape every other field metadata
|
|
232
|
+
// (visible_to/editable_by/encrypted) already lives on, so it works for any
|
|
233
|
+
// entity -- built-in or custom_entity_def-registered -- carrying one, not
|
|
234
|
+
// hardcoded to a single entity name the way checkStockBalance/
|
|
235
|
+
// checkContractDateOrder/checkResourceCapacity are. Two shapes:
|
|
236
|
+
// - simple: {ref_field, related_field, operator, value} compares the
|
|
237
|
+
// related record's field against a literal value.
|
|
238
|
+
// - aggregate: {ref_field, aggregate:'sum', aggregate_field, operator,
|
|
239
|
+
// limit_field} sums a field across every OTHER record referencing the
|
|
240
|
+
// same related entity via ref_field, and compares against a limit read
|
|
241
|
+
// from the related record's limit_field.
|
|
242
|
+
// Uses the same unscoped internal get()/list() lookup pattern
|
|
243
|
+
// checkContractDateOrder/checkStockBalance/checkResourceCapacity already
|
|
244
|
+
// use -- these run from validateEntity/validateUpdate, which today has no
|
|
245
|
+
// user context threaded through, so this does not introduce a new
|
|
246
|
+
// access-control gap relative to those three existing checks; it matches
|
|
247
|
+
// their established behavior exactly rather than inventing a new one.
|
|
248
|
+
async function checkCrossEntityRules(entityName, data, existingRecord) {
|
|
249
|
+
const spec = getSpec(entityName);
|
|
250
|
+
for (const [fieldKey, fieldDef] of Object.entries(spec.fields || {})) {
|
|
251
|
+
const rule = fieldDef.cross_entity_rule;
|
|
252
|
+
if (!rule || !rule.ref_field) continue;
|
|
253
|
+
|
|
254
|
+
const refId = data[rule.ref_field] !== undefined ? data[rule.ref_field] : existingRecord?.[rule.ref_field];
|
|
255
|
+
if (refId == null) continue;
|
|
256
|
+
|
|
257
|
+
const refFieldDef = spec.fields[rule.ref_field];
|
|
258
|
+
const relatedEntity = refFieldDef?.ref;
|
|
259
|
+
if (!relatedEntity) continue;
|
|
260
|
+
|
|
261
|
+
const { get, list } = await import('@/lib/busybase/store');
|
|
262
|
+
const related = await get(relatedEntity, refId);
|
|
263
|
+
if (!related) continue;
|
|
264
|
+
|
|
265
|
+
const operatorFn = CROSS_ENTITY_OPERATORS[rule.operator];
|
|
266
|
+
if (!operatorFn) continue;
|
|
267
|
+
|
|
268
|
+
if (rule.aggregate === 'sum') {
|
|
269
|
+
const relatedEntitySelfEntity = entityName;
|
|
270
|
+
const siblingRecords = await list(relatedEntitySelfEntity, { [rule.ref_field]: refId });
|
|
271
|
+
const selfId = existingRecord?.id;
|
|
272
|
+
const thisValue = Number(data[rule.aggregate_field] !== undefined ? data[rule.aggregate_field] : existingRecord?.[rule.aggregate_field]) || 0;
|
|
273
|
+
const othersTotal = siblingRecords
|
|
274
|
+
.filter(r => r.id !== selfId)
|
|
275
|
+
.reduce((sum, r) => sum + (Number(r[rule.aggregate_field]) || 0), 0);
|
|
276
|
+
const resultingTotal = othersTotal + thisValue;
|
|
277
|
+
const limit = Number(related[rule.limit_field]);
|
|
278
|
+
if (!Number.isFinite(limit)) continue;
|
|
279
|
+
if (!operatorFn(resultingTotal, limit)) {
|
|
280
|
+
return `${fieldKey}: sum of ${rule.aggregate_field} across ${relatedEntitySelfEntity} for this ${rule.ref_field} would be ${resultingTotal}, violating ${rule.operator} ${limit}`;
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!rule.related_field) continue;
|
|
286
|
+
const relatedValue = related[rule.related_field];
|
|
287
|
+
if (!operatorFn(relatedValue, rule.value)) {
|
|
288
|
+
return `${fieldKey}: related ${relatedEntity}.${rule.related_field} (${relatedValue}) fails rule "${rule.operator} ${rule.value}"`;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
221
294
|
const WEEKLY_CAPACITY_HOURS = 40;
|
|
222
295
|
|
|
223
296
|
// Two date ranges overlap unless one entirely precedes the other -- the
|
|
@@ -288,6 +361,9 @@ export async function validateEntity(entityName, data, existingRecord = null, op
|
|
|
288
361
|
const capacityErr = await checkResourceCapacity(entityName, data, existingRecord);
|
|
289
362
|
if (capacityErr) errors.allocated_hours_per_week = capacityErr;
|
|
290
363
|
|
|
364
|
+
const crossEntityErr = await checkCrossEntityRules(entityName, data, existingRecord);
|
|
365
|
+
if (crossEntityErr) errors._cross_entity = crossEntityErr;
|
|
366
|
+
|
|
291
367
|
return errors;
|
|
292
368
|
}
|
|
293
369
|
|
|
@@ -369,6 +445,9 @@ export async function validateUpdate(entityName, changes, existingRecord) {
|
|
|
369
445
|
const capacityErr = await checkResourceCapacity(entityName, changes, existingRecord);
|
|
370
446
|
if (capacityErr) errors.allocated_hours_per_week = capacityErr;
|
|
371
447
|
|
|
448
|
+
const crossEntityErr = await checkCrossEntityRules(entityName, changes, existingRecord);
|
|
449
|
+
if (crossEntityErr) errors._cross_entity = crossEntityErr;
|
|
450
|
+
|
|
372
451
|
return errors;
|
|
373
452
|
}
|
|
374
453
|
|
|
@@ -44,12 +44,26 @@ export function renderCustomEntityList(user, defs) {
|
|
|
44
44
|
var idx=fieldRowCount++;
|
|
45
45
|
var row=document.createElement('div');
|
|
46
46
|
row.className='field-row';
|
|
47
|
-
row.style.cssText='display:flex;gap:
|
|
48
|
-
row.innerHTML='<
|
|
47
|
+
row.style.cssText='display:flex;flex-direction:column;gap:4px;margin:4px 0;padding:6px;border:1px solid var(--color-border,#eee);border-radius:4px';
|
|
48
|
+
row.innerHTML='<div style="display:flex;gap:6px">'+
|
|
49
|
+
'<input type="text" class="form-input field-key" placeholder="key" style="flex:1">'+
|
|
49
50
|
'<select class="form-input field-type" style="flex:1">${typeOpts}</select>'+
|
|
50
51
|
'<input type="text" class="form-input field-label" placeholder="label" style="flex:1">'+
|
|
51
52
|
'<label style="display:flex;align-items:center;gap:4px;font-size:12px"><input type="checkbox" class="field-required">required</label>'+
|
|
52
|
-
'<button type="button" class="btn-ghost-clean" data-action="removeFieldRow">x</button>'
|
|
53
|
+
'<button type="button" class="btn-ghost-clean" data-action="removeFieldRow">x</button>'+
|
|
54
|
+
'</div>'+
|
|
55
|
+
'<details><summary style="font-size:12px;cursor:pointer">Cross-entity rule (optional)</summary>'+
|
|
56
|
+
'<div style="display:flex;gap:6px;margin-top:4px;flex-wrap:wrap">'+
|
|
57
|
+
'<input type="text" class="form-input rule-ref-field" placeholder="ref field, e.g. project_id" style="flex:1;min-width:120px">'+
|
|
58
|
+
'<input type="text" class="form-input rule-related-field" placeholder="related field, e.g. status" style="flex:1;min-width:120px">'+
|
|
59
|
+
'<select class="form-input rule-operator" style="flex:1;min-width:100px">'+
|
|
60
|
+
'<option value="">No rule</option><option value="equals">equals</option><option value="not_equals">not_equals</option>'+
|
|
61
|
+
'<option value="gt">gt</option><option value="gte">gte</option><option value="lt">lt</option><option value="lte">lte</option>'+
|
|
62
|
+
'</select>'+
|
|
63
|
+
'<input type="text" class="form-input rule-value" placeholder="value (simple rule)" style="flex:1;min-width:100px">'+
|
|
64
|
+
'<input type="text" class="form-input rule-aggregate-field" placeholder="aggregate field (sum, optional)" style="flex:1;min-width:140px">'+
|
|
65
|
+
'<input type="text" class="form-input rule-limit-field" placeholder="limit field on related entity (optional)" style="flex:1;min-width:160px">'+
|
|
66
|
+
'</div></details>';
|
|
53
67
|
container.appendChild(row);
|
|
54
68
|
}
|
|
55
69
|
window.addFieldRow=addFieldRow;
|
|
@@ -69,7 +83,23 @@ export function renderCustomEntityList(user, defs) {
|
|
|
69
83
|
var type=row.querySelector('.field-type').value;
|
|
70
84
|
var flabel=row.querySelector('.field-label').value.trim();
|
|
71
85
|
var required=row.querySelector('.field-required').checked;
|
|
72
|
-
if(key)
|
|
86
|
+
if(!key)return;
|
|
87
|
+
var field={key:key,type:type,label:flabel||key,required:required};
|
|
88
|
+
var ruleOperator=row.querySelector('.rule-operator').value;
|
|
89
|
+
if(ruleOperator){
|
|
90
|
+
var refField=row.querySelector('.rule-ref-field').value.trim();
|
|
91
|
+
var relatedField=row.querySelector('.rule-related-field').value.trim();
|
|
92
|
+
var aggregateField=row.querySelector('.rule-aggregate-field').value.trim();
|
|
93
|
+
var limitField=row.querySelector('.rule-limit-field').value.trim();
|
|
94
|
+
if(refField){
|
|
95
|
+
if(aggregateField&&limitField){
|
|
96
|
+
field.cross_entity_rule={ref_field:refField,aggregate:'sum',aggregate_field:aggregateField,operator:ruleOperator,limit_field:limitField};
|
|
97
|
+
} else if(relatedField){
|
|
98
|
+
field.cross_entity_rule={ref_field:refField,related_field:relatedField,operator:ruleOperator,value:row.querySelector('.rule-value').value};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
fields.push(field);
|
|
73
103
|
});
|
|
74
104
|
if(!fields.length){status.textContent='At least one field required';return}
|
|
75
105
|
status.textContent='Creating...';
|
package/src/ui/styles2.css
CHANGED
|
@@ -354,6 +354,31 @@ html, body { overflow-x: hidden; max-width: 100%; }
|
|
|
354
354
|
.form-hint { font-size: 0.75rem; }
|
|
355
355
|
}
|
|
356
356
|
|
|
357
|
+
/* Board/Kanban view: unstyled by default (relied on browser block/flow layout
|
|
358
|
+
accidentally being usable on wide desktop screens) -- explicit rules needed
|
|
359
|
+
so it doesn't collapse into an unreadable single column of stacked cards on
|
|
360
|
+
any width. */
|
|
361
|
+
.board-view { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 8px; }
|
|
362
|
+
.board-column { flex: 0 0 280px; min-width: 280px; background: var(--color-bg, #f8fafc); border-radius: var(--radius, 8px); display: flex; flex-direction: column; max-height: 80vh; }
|
|
363
|
+
.board-column-header { display: flex; justify-content: space-between; align-items: center; padding: 12px; font-weight: 600; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--color-text-muted); }
|
|
364
|
+
.board-column-count { background: var(--color-border, #e2e8f0); border-radius: 9999px; padding: 2px 8px; font-size: 12px; }
|
|
365
|
+
.board-column-body { flex: 1; overflow-y: auto; padding: 0 8px 8px; display: flex; flex-direction: column; gap: 8px; }
|
|
366
|
+
.board-column-empty { padding: 16px; text-align: center; color: var(--color-text-muted); font-size: 13px; }
|
|
367
|
+
.board-card { background: var(--color-surface, #fff); border: 1px solid var(--color-border, #e2e8f0); border-radius: 6px; padding: 10px 12px; cursor: pointer; }
|
|
368
|
+
.board-card-title { font-size: 14px; font-weight: 500; }
|
|
369
|
+
.board-card-meta { margin-top: 4px; font-size: 12px; color: var(--color-text-muted); }
|
|
370
|
+
.board-empty-state { padding: 24px; text-align: center; color: var(--color-text-muted); }
|
|
371
|
+
|
|
372
|
+
/* Kanban stays horizontally-scrolling (not stacked) on narrow viewports --
|
|
373
|
+
stacking would hide every column but the first, losing the at-a-glance
|
|
374
|
+
status overview that is the entire point of a board view. Columns shrink
|
|
375
|
+
slightly and snap-scroll for a touch-friendly swipe between stages. */
|
|
376
|
+
@media (max-width: 768px) {
|
|
377
|
+
.board-view { scroll-snap-type: x mandatory; margin-left: calc(-1 * var(--page-gutter)); margin-right: calc(-1 * var(--page-gutter)); padding-left: var(--page-gutter); padding-right: var(--page-gutter); }
|
|
378
|
+
.board-column { flex-basis: 85vw; min-width: 240px; scroll-snap-align: start; }
|
|
379
|
+
.board-card { min-height: 44px; }
|
|
380
|
+
}
|
|
381
|
+
|
|
357
382
|
/* Tablet */
|
|
358
383
|
@media (min-width: 769px) and (max-width: 1023px) {
|
|
359
384
|
:root { --page-gutter: 1.25rem; }
|