thatcher 1.0.61 → 1.0.63

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.61",
3
+ "version": "1.0.63",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,91 @@
1
+ import { get, update, remove } from './busybase/store.js';
2
+ import { validateUpdate, sanitizeData } from './validation/index.js';
3
+ import { requirePermission } from './auth-middleware.js';
4
+ import { permissionService } from '../services/permission.service.js';
5
+ import { executeHook } from './hook-engine.js';
6
+ import { logAction } from './busybase/audit.js';
7
+ import { now } from './id-helpers.js';
8
+ import { createLogger } from './logger.js';
9
+
10
+ const log = createLogger('[BulkOps]');
11
+ const MAX_BULK_IDS = 500;
12
+
13
+ async function bulkDeleteOne(entityName, spec, id, user) {
14
+ await requirePermission(user, spec, 'delete');
15
+ const existing = await get(entityName, id, { user });
16
+ if (!existing) throw new Error('Not found');
17
+ if (!permissionService.checkRowAccess(user, spec, existing)) throw new Error('Access denied');
18
+
19
+ let result;
20
+ if (spec.immutable === true && spec.immutable_strategy === 'move_to_archive') {
21
+ const archiveData = { archived: true, archived_at: now(), archived_by: user?.id };
22
+ result = await update(entityName, id, archiveData, user);
23
+ logAction(entityName, id, 'archive', user?.id, existing, archiveData);
24
+ } else if (spec.fields?.status) {
25
+ result = await update(entityName, id, { status: 'deleted' }, user);
26
+ logAction(entityName, id, 'delete', user?.id, existing, { status: 'deleted' });
27
+ } else {
28
+ result = await remove(entityName, id);
29
+ logAction(entityName, id, 'delete', user?.id, existing, null);
30
+ }
31
+ executeHook(`delete:${entityName}:after`, { entity: entityName, id, data: result, user }).catch(e => log.error(e.message));
32
+ return result;
33
+ }
34
+
35
+ async function bulkSetFieldOne(entityName, spec, id, user, field, value) {
36
+ await requirePermission(user, spec, 'edit');
37
+ const existing = await get(entityName, id, { user });
38
+ if (!existing) throw new Error('Not found');
39
+ if (!permissionService.checkRowAccess(user, spec, existing)) throw new Error('Access denied');
40
+
41
+ const rawData = { [field]: value };
42
+ permissionService.enforceEditPermissions(user, spec, rawData);
43
+
44
+ const errors = await validateUpdate(entityName, rawData, existing);
45
+ if (Object.keys(errors).length > 0) throw new Error(`Validation failed: ${JSON.stringify(errors)}`);
46
+
47
+ const sanitized = sanitizeData(entityName, rawData, spec, existing);
48
+ const record = await update(entityName, id, sanitized, user);
49
+ logAction(entityName, id, 'update', user?.id, existing, record);
50
+ executeHook(`update:${entityName}:after`, { entity: entityName, id, data: record, before: existing, after: record, user }).catch(e => log.error(e.message));
51
+ return record;
52
+ }
53
+
54
+ async function bulkTransitionOne(entityName, id, workflowName, toState, user) {
55
+ const { transition } = await import('./workflow-engine.js');
56
+ return transition(entityName, id, workflowName, toState, user, 'bulk operation');
57
+ }
58
+
59
+ export async function runBulkOperation(entityName, spec, ids, action, user) {
60
+ if (!Array.isArray(ids) || !ids.length) return { ok: false, error: 'ids array required and must be non-empty' };
61
+ if (ids.length > MAX_BULK_IDS) return { ok: false, error: `Cannot process more than ${MAX_BULK_IDS} ids in one bulk operation` };
62
+ if (!action || typeof action.type !== 'string') return { ok: false, error: 'action.type required' };
63
+
64
+ const uniqueIds = [...new Set(ids)];
65
+ const results = [];
66
+
67
+ for (const id of uniqueIds) {
68
+ try {
69
+ if (action.type === 'delete') {
70
+ await bulkDeleteOne(entityName, spec, id, user);
71
+ results.push({ id, success: true });
72
+ } else if (action.type === 'set_field') {
73
+ if (!action.field || typeof action.field !== 'string') throw new Error('action.field required');
74
+ await bulkSetFieldOne(entityName, spec, id, user, action.field, action.value);
75
+ results.push({ id, success: true });
76
+ } else if (action.type === 'transition') {
77
+ if (!action.workflow || !action.toState) throw new Error('action.workflow and action.toState required');
78
+ await bulkTransitionOne(entityName, id, action.workflow, action.toState, user);
79
+ results.push({ id, success: true });
80
+ } else {
81
+ results.push({ id, success: false, error: `Unknown action type "${action.type}"` });
82
+ }
83
+ } catch (error) {
84
+ results.push({ id, success: false, error: error.message });
85
+ }
86
+ }
87
+
88
+ const succeeded = results.filter(r => r.success).length;
89
+ const failed = results.length - succeeded;
90
+ return { ok: true, total: results.length, succeeded, failed, results };
91
+ }
@@ -68,11 +68,23 @@ const WEBHOOK_DELIVERY_ENTITY_DEFAULT = {
68
68
  },
69
69
  };
70
70
 
71
+ const ENTITY_TEMPLATE_ENTITY_DEFAULT = {
72
+ label: 'Entity Template',
73
+ label_plural: 'Entity Templates',
74
+ system_entity: true,
75
+ fields: {
76
+ entity: { type: 'text', required: true, label: 'Entity' },
77
+ name: { type: 'text', required: true, label: 'Name' },
78
+ field_values: { type: 'json', label: 'Field Values' },
79
+ },
80
+ };
81
+
71
82
  function withWebhookDefaults(masterConfig) {
72
83
  const entities = { ...(masterConfig.entities || {}) };
73
84
  let changed = false;
74
85
  if (!entities.webhook) { entities.webhook = WEBHOOK_ENTITY_DEFAULT; changed = true; }
75
86
  if (!entities.webhook_delivery) { entities.webhook_delivery = WEBHOOK_DELIVERY_ENTITY_DEFAULT; changed = true; }
87
+ if (!entities.entity_template) { entities.entity_template = ENTITY_TEMPLATE_ENTITY_DEFAULT; changed = true; }
76
88
  return changed ? { ...masterConfig, entities } : masterConfig;
77
89
  }
78
90
 
@@ -134,6 +134,18 @@ export function createServer(options) {
134
134
  return await handleDeleteWebhook(req, res, id, thatcher, configEngine);
135
135
  }
136
136
 
137
+ if (req.method === 'POST' && id === 'bulk' && !action) {
138
+ return await handleBulkOperation(req, res, entity, thatcher, configEngine);
139
+ }
140
+
141
+ if (req.method === 'POST' && entity === 'entity_template' && id === 'create' && !action) {
142
+ return await handleCreateEntityTemplate(req, res, thatcher, configEngine);
143
+ }
144
+
145
+ if (req.method === 'POST' && entity === 'entity_template' && id && action === 'delete') {
146
+ return await handleDeleteEntityTemplate(req, res, id, thatcher, configEngine);
147
+ }
148
+
137
149
  // Check if user has custom route for this
138
150
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
139
151
  const routeExists = await fileExists(userRoutePath);
@@ -379,6 +391,132 @@ async function requireAuthedPartner(req, res) {
379
391
  return user;
380
392
  }
381
393
 
394
+ async function handleBulkOperation(req, res, entityName, thatcher, configEngineArg) {
395
+ const user = await resolveRequestUser(req);
396
+ if (!user) {
397
+ res.writeHead(401, { 'Content-Type': 'application/json' });
398
+ res.end(JSON.stringify({ error: 'Authentication required' }));
399
+ return;
400
+ }
401
+
402
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
403
+ if (!configEngine) {
404
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
405
+ configEngine = getConfigEngineSync();
406
+ }
407
+ let spec;
408
+ try {
409
+ spec = configEngine.generateEntitySpec(entityName);
410
+ } catch (e) {
411
+ res.writeHead(404);
412
+ res.end(JSON.stringify({ error: `Entity "${entityName}" not found` }));
413
+ return;
414
+ }
415
+
416
+ let body;
417
+ try {
418
+ body = await readBody(req);
419
+ } catch (e) {
420
+ res.writeHead(400);
421
+ res.end(JSON.stringify({ error: e.message }));
422
+ return;
423
+ }
424
+ const ids = Array.isArray(body?.ids) ? body.ids : null;
425
+ const action = body?.action;
426
+ if (!ids) {
427
+ res.writeHead(400);
428
+ res.end(JSON.stringify({ error: 'ids array required' }));
429
+ return;
430
+ }
431
+
432
+ try {
433
+ const { runBulkOperation } = await import('../lib/bulk-operations.js');
434
+ const result = await runBulkOperation(entityName, spec, ids, action, user);
435
+ if (!result.ok) {
436
+ res.writeHead(400);
437
+ res.end(JSON.stringify({ error: result.error }));
438
+ return;
439
+ }
440
+ res.writeHead(200, { 'Content-Type': 'application/json' });
441
+ res.end(JSON.stringify(result));
442
+ } catch (err) {
443
+ apiLog.error(err.message);
444
+ res.writeHead(500);
445
+ res.end(JSON.stringify({ error: err.message }));
446
+ }
447
+ }
448
+
449
+ async function handleCreateEntityTemplate(req, res, thatcher, configEngineArg) {
450
+ const user = await requireAuthedPartner(req, res);
451
+ if (!user) return;
452
+
453
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
454
+ if (!configEngine) {
455
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
456
+ configEngine = getConfigEngineSync();
457
+ }
458
+
459
+ let body;
460
+ try {
461
+ body = await readBody(req);
462
+ } catch (e) {
463
+ res.writeHead(400);
464
+ res.end(JSON.stringify({ error: e.message }));
465
+ return;
466
+ }
467
+ const { entity, name, field_values } = body || {};
468
+ if (!entity || typeof entity !== 'string') {
469
+ res.writeHead(400);
470
+ res.end(JSON.stringify({ error: 'entity required' }));
471
+ return;
472
+ }
473
+ try {
474
+ configEngine.generateEntitySpec(entity);
475
+ } catch {
476
+ res.writeHead(400);
477
+ res.end(JSON.stringify({ error: `Unknown entity "${entity}"` }));
478
+ return;
479
+ }
480
+ if (!name || typeof name !== 'string' || !name.trim()) {
481
+ res.writeHead(400);
482
+ res.end(JSON.stringify({ error: 'name required' }));
483
+ return;
484
+ }
485
+
486
+ try {
487
+ const { create } = await import('../lib/busybase/store.js');
488
+ const record = await create('entity_template', { entity, name: name.trim(), field_values: field_values || {} }, user);
489
+ res.writeHead(201, { 'Content-Type': 'application/json' });
490
+ res.end(JSON.stringify({ ok: true, id: record.id }));
491
+ } catch (err) {
492
+ apiLog.error(err.message);
493
+ res.writeHead(500);
494
+ res.end(JSON.stringify({ error: err.message }));
495
+ }
496
+ }
497
+
498
+ async function handleDeleteEntityTemplate(req, res, templateId, thatcher, configEngineArg) {
499
+ const user = await requireAuthedPartner(req, res);
500
+ if (!user) return;
501
+
502
+ try {
503
+ const { remove, get } = await import('../lib/busybase/store.js');
504
+ const existing = await get('entity_template', templateId);
505
+ if (!existing) {
506
+ res.writeHead(404);
507
+ res.end(JSON.stringify({ error: 'Template not found' }));
508
+ return;
509
+ }
510
+ await remove('entity_template', templateId);
511
+ res.writeHead(200, { 'Content-Type': 'application/json' });
512
+ res.end(JSON.stringify({ ok: true }));
513
+ } catch (err) {
514
+ apiLog.error(err.message);
515
+ res.writeHead(500);
516
+ res.end(JSON.stringify({ error: err.message }));
517
+ }
518
+ }
519
+
382
520
  async function handleCreateWebhook(req, res, thatcher, configEngineArg) {
383
521
  const user = await requireAuthedPartner(req, res);
384
522
  if (!user) return;
@@ -111,12 +111,13 @@ export function renderEntityDetail(entityName, item, spec, user) {
111
111
  </div>` : `<div style="margin-bottom:1.5rem"><h1 style="font-size:1.5rem;font-weight:700">${displayNameSafe}</h1></div>`
112
112
 
113
113
  const editBtn = userCanEdit ? `<a href="/${entityName}/${item.id}/edit" class="btn btn-outline btn-sm">Edit</a>` : ''
114
+ const cloneBtn = canCreate(user, entityName) ? `<a href="/${entityName}/new?clone_from=${esc(item.id)}" class="btn btn-outline btn-sm">Clone</a>` : ''
114
115
  const delBtn = userCanDelete ? `<button data-action="showDeleteConfirm" class="btn btn-error btn-outline btn-sm">Delete</button>` : ''
115
116
 
116
117
  const content = `
117
118
  <div style="display:flex;justify-content:space-between;align-items:flex-start">
118
119
  <div style="flex:1">${headerExtra}</div>
119
- <div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${delBtn}</div>
120
+ <div style="display:flex;gap:0.5rem;margin-left:1rem">${editBtn}${cloneBtn}${delBtn}</div>
120
121
  </div>
121
122
  <div class="card-clean">
122
123
  <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>
@@ -129,7 +130,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
129
130
  return page(user, `${label} Detail`, bc, content, [script])
130
131
  }
131
132
 
132
- export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}) {
133
+ export function renderEntityForm(entityName, item, spec, user, isNew = false, refOptions = {}, templates = []) {
133
134
  const label = spec?.label || entityName
134
135
  const fields = spec?.fields || {}
135
136
  const lbl = (k, f, req) => `<label class="form-label" for="field-${k}">${esc(f.label||k)}${req ? '<span class="req">*</span>' : ''}</label>`
@@ -165,8 +166,14 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
165
166
  const bc = isNew
166
167
  ? [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { label: 'Create' }]
167
168
  : [{ href: '/', label: 'Dashboard' }, { href: `/${entityName}`, label: spec?.labelPlural || label }, { href: `/${entityName}/${item?.id}`, label: item?.name || item?.title || `#${item?.id}` }, { label: 'Edit' }]
169
+ const templatePicker = isNew && templates.length
170
+ ? `<div class="form-field full"><label class="form-label" for="template-picker">Start from template</label>
171
+ <select id="template-picker" onchange="if(this.value)window.location='/${entityName}/new?template='+this.value">
172
+ <option value="">Blank</option>${templates.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('')}
173
+ </select></div>`
174
+ : ''
168
175
  const content = `<div class="form-shell"><div style="margin-bottom:24px"><h1 style="font-size:24px;font-weight:700">${isNew ? 'Create' : 'Edit'} ${esc(label)}</h1></div>
169
- <div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${formFields}${pwField}
176
+ <div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${templatePicker}${formFields}${pwField}
170
177
  <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>
171
178
  <a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
172
179
  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;const fd=new FormData(form);const data={};for(const[k,v]of fd.entries())data[k]=v;form.querySelectorAll('input[type=checkbox]').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('input[type=number]').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';try{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}})`
@@ -30,7 +30,8 @@ function gridRow(entityName, item, columns) {
30
30
  : '';
31
31
  return `<td data-col="${esc(key)}"${editableAttrs}>${rendered}</td>`;
32
32
  }).join('');
33
- return `<tr data-row data-navigate="/${esc(entityName)}/${esc(item.id)}" style="cursor:pointer">${cells}</tr>`;
33
+ const checkboxCell = `<td style="width:32px"><input type="checkbox" class="bulk-row-select" data-row-id="${esc(item.id)}" onclick="event.stopPropagation()"></td>`;
34
+ return `<tr data-row data-navigate="/${esc(entityName)}/${esc(item.id)}" style="cursor:pointer">${checkboxCell}${cells}</tr>`;
34
35
  }
35
36
 
36
37
  const GRID_EDIT_SCRIPT = `(function(){
@@ -77,6 +78,63 @@ const GRID_EDIT_SCRIPT = `(function(){
77
78
  },true);
78
79
  })();`;
79
80
 
81
+ const BULK_OPS_SCRIPT = `(function(){
82
+ function selectedIds(){
83
+ return Array.prototype.slice.call(document.querySelectorAll('.bulk-row-select:checked')).map(function(cb){return cb.getAttribute('data-row-id')});
84
+ }
85
+ function updateToolbar(){
86
+ var ids=selectedIds();
87
+ var toolbar=document.getElementById('bulk-toolbar');
88
+ var label=document.getElementById('bulk-count-label');
89
+ if(ids.length>0){toolbar.style.display='flex';label.textContent=ids.length+' selected'}
90
+ else{toolbar.style.display='none'}
91
+ }
92
+ document.addEventListener('change',function(e){
93
+ if(e.target.classList&&e.target.classList.contains('bulk-row-select')){updateToolbar()}
94
+ if(e.target.id==='bulk-select-all'){
95
+ var checked=e.target.checked;
96
+ document.querySelectorAll('.bulk-row-select').forEach(function(cb){cb.checked=checked});
97
+ updateToolbar();
98
+ }
99
+ });
100
+ function postBulk(entity,ids,action,onDone){
101
+ var status=document.getElementById('bulk-status');
102
+ status.textContent='Processing...';
103
+ fetch('/api/'+entity+'/bulk',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,action:action})})
104
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
105
+ .then(function(res){
106
+ if(res.ok){
107
+ var failed=res.d.failed||0;
108
+ status.textContent=res.d.succeeded+' succeeded, '+failed+' failed';
109
+ if(failed===0){setTimeout(function(){location.reload()},800)}
110
+ }else{status.textContent='Error: '+(res.d.error||'bulk operation failed')}
111
+ if(onDone)onDone();
112
+ })
113
+ .catch(function(err){status.textContent='Error: '+err.message});
114
+ }
115
+ window.bulkDelete=function(entity){
116
+ var ids=selectedIds();
117
+ if(!ids.length)return;
118
+ if(!window.confirm('Delete '+ids.length+' items?'))return;
119
+ postBulk(entity,ids,{type:'delete'});
120
+ };
121
+ window.bulkSetField=function(entity){
122
+ var ids=selectedIds();
123
+ if(!ids.length)return;
124
+ var field=document.getElementById('bulk-set-field').value;
125
+ var value=document.getElementById('bulk-set-value').value;
126
+ if(!field)return;
127
+ postBulk(entity,ids,{type:'set_field',field:field,value:value});
128
+ };
129
+ window.bulkTransition=function(entity,workflow){
130
+ var ids=selectedIds();
131
+ if(!ids.length)return;
132
+ var toState=document.getElementById('bulk-transition-target').value;
133
+ if(!toState)return;
134
+ postBulk(entity,ids,{type:'transition',workflow:workflow,toState:toState});
135
+ };
136
+ })();`;
137
+
80
138
  export function renderGridView(user, entityName, spec, records, options = {}) {
81
139
  const label = getEntityLabel(spec, true) || entityName;
82
140
  const columns = getColumns(spec);
@@ -105,9 +163,32 @@ export function renderGridView(user, entityName, spec, records, options = {}) {
105
163
  const rows = records.map(item => gridRow(entityName, item, columns)).join('') ||
106
164
  emptyRow(columns.length || 1, `No ${esc(label.toLowerCase())} found`);
107
165
 
166
+ const editableFieldOpts = columns.filter(([, f]) => isEditable(f)).map(([key, f]) =>
167
+ `<option value="${esc(key)}">${esc(f.label || key)}</option>`
168
+ ).join('');
169
+
170
+ const workflowStageOpts = spec.workflowDef?.stages
171
+ ? spec.workflowDef.stages.map(s => `<option value="${esc(s.name)}">${esc(s.label || s.name)}</option>`).join('')
172
+ : '';
173
+ const transitionButton = spec.workflow && workflowStageOpts
174
+ ? `<select id="bulk-transition-target"><option value="">Transition to...</option>${workflowStageOpts}</select>
175
+ <button type="button" class="btn-ghost-clean" data-action="bulkTransition" data-args='["${esc(entityName)}","${esc(spec.workflow)}"]'>Apply</button>`
176
+ : '';
177
+
178
+ const bulkToolbar = `<div id="bulk-toolbar" style="display:none;align-items:center;gap:8px;padding:8px;background:var(--color-bg-secondary,#f5f5f5);border-radius:4px;margin-bottom:8px;flex-wrap:wrap">
179
+ <span id="bulk-count-label" style="font-size:13px;font-weight:600"></span>
180
+ <button type="button" class="btn-danger-clean" data-action="bulkDelete" data-args='["${esc(entityName)}"]'>Delete Selected</button>
181
+ <select id="bulk-set-field"><option value="">Set field...</option>${editableFieldOpts}</select>
182
+ <input type="text" id="bulk-set-value" placeholder="value" style="width:120px">
183
+ <button type="button" class="btn-ghost-clean" data-action="bulkSetField" data-args='["${esc(entityName)}"]'>Apply</button>
184
+ ${transitionButton}
185
+ <span id="bulk-status" style="font-size:13px"></span>
186
+ </div>`;
187
+
108
188
  const content = `<div class="page-header">
109
189
  <div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} total ${esc(label.toLowerCase())}</p></div>
110
190
  </div>
191
+ ${bulkToolbar}
111
192
  <div class="table-wrap">
112
193
  <div class="table-toolbar">
113
194
  <div class="table-search"><input id="search-input" type="text" placeholder="Search ${esc(label.toLowerCase())}..."></div>
@@ -115,10 +196,10 @@ export function renderGridView(user, entityName, spec, records, options = {}) {
115
196
  <span class="table-count" id="row-count">${records.length} items</span>
116
197
  </div>
117
198
  <table class="data-table" role="grid" data-page-size="${esc(pageSize)}">
118
- <thead><tr>${headerCells}</tr></thead>
199
+ <thead><tr><th style="width:32px"><input type="checkbox" id="bulk-select-all"></th>${headerCells}</tr></thead>
119
200
  <tbody>${rows}</tbody>
120
201
  </table>
121
202
  </div>`;
122
203
 
123
- return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT, GRID_EDIT_SCRIPT]);
204
+ return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT, GRID_EDIT_SCRIPT, BULK_OPS_SCRIPT]);
124
205
  }
@@ -9,6 +9,7 @@ import { renderJobManagement } from '@/ui/job-management-renderer.js';
9
9
  import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-renderer.js';
10
10
  import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/ui/rbac-renderer.js';
11
11
  import { renderWebhookList, renderWebhookDetail } from '@/ui/webhook-renderer.js';
12
+ import { renderTemplateList as renderEntityTemplateList } from '@/ui/template-renderer.js';
12
13
  import { isPartner, isManager } from '@/ui/permissions-ui.js';
13
14
  import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
14
15
  import { fileURLToPath } from 'url';
@@ -178,5 +179,17 @@ export async function handleAdminPage(normalized, segments, user) {
178
179
  try { deliveries = (await list('webhook_delivery', { webhook_id: segments[2] }, { sort: { field: 'created_at', dir: 'DESC' } })).slice(0, 20); } catch {}
179
180
  return renderWebhookDetail(user, webhook, deliveries);
180
181
  }
182
+ if (normalized === '/admin/templates') {
183
+ const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
184
+ const engine = getConfigEngineSync();
185
+ const entityNames = engine.getAllEntities().filter(e => e !== 'entity_template' && e !== 'webhook' && e !== 'webhook_delivery' && e !== 'user_organization');
186
+ let allTemplates = []; try { allTemplates = await list('entity_template', {}); } catch {}
187
+ const templatesByEntity = {};
188
+ for (const t of allTemplates) {
189
+ if (!templatesByEntity[t.entity]) templatesByEntity[t.entity] = [];
190
+ templatesByEntity[t.entity].push(t);
191
+ }
192
+ return renderEntityTemplateList(user, entityNames, templatesByEntity);
193
+ }
181
194
  return null;
182
195
  }
@@ -72,14 +72,43 @@ async function handleSearch(user, req) {
72
72
  }
73
73
  return renderAdvancedSearch(user, results, { teams, entityNames: allEntityNames });
74
74
  }
75
- async function handleGenericEntityView(user, entityName, id) {
75
+ const SYSTEM_FIELDS_TO_STRIP = new Set(['id', 'created_at', 'created_by', 'updated_at', 'status', 'organization_id']);
76
+
77
+ async function handleGenericEntityView(user, entityName, id, req) {
76
78
  const spec = getSpec(entityName); if (!spec) return null;
77
79
  if (isClientUser(user) && !canClientAccessEntity(user, entityName)) return renderAccessDenied(user, entityName, 'view');
78
80
  if (id === 'new') {
79
81
  if (!canCreate(user, entityName)) return renderAccessDenied(user, entityName, 'create');
80
82
  const resolvedSpec = resolveEnumOptions(spec);
81
83
  const { renderEntityForm: lazyEntityForm } = await lazyRenderer('entity-renderer.js');
82
- return lazyEntityForm(entityName, null, resolvedSpec, user, true, await getRefOptions(resolvedSpec));
84
+
85
+ let prefill = null;
86
+ const params = req ? reqUrl(req).searchParams : null;
87
+ const cloneFromId = params?.get('clone_from');
88
+ const templateId = params?.get('template');
89
+ if (cloneFromId) {
90
+ const source = await get(entityName, cloneFromId, { user });
91
+ if (source) {
92
+ prefill = {};
93
+ for (const [key, value] of Object.entries(source)) {
94
+ if (!SYSTEM_FIELDS_TO_STRIP.has(key)) prefill[key] = value;
95
+ }
96
+ }
97
+ } else if (templateId) {
98
+ const template = await get('entity_template', templateId);
99
+ if (template && template.entity === entityName) {
100
+ try {
101
+ prefill = typeof template.field_values === 'string' ? JSON.parse(template.field_values || '{}') : (template.field_values || {});
102
+ } catch {
103
+ prefill = {};
104
+ }
105
+ }
106
+ }
107
+
108
+ let templates = [];
109
+ try { templates = await list('entity_template', { entity: entityName }); } catch {}
110
+
111
+ return lazyEntityForm(entityName, prefill, resolvedSpec, user, true, await getRefOptions(resolvedSpec), templates);
83
112
  }
84
113
  if (!canView(user, entityName)) return renderAccessDenied(user, entityName, 'view');
85
114
  const item = await get(entityName, id, { user }); if (!item) return null;
@@ -264,7 +293,7 @@ export async function handlePage(pathname, req, res) {
264
293
  const client = await get('client', segments[1], { user }); if (!client) return null;
265
294
  return renderClientDashboard(user, client, await getClientDashboardStats(segments[1]));
266
295
  }
267
- if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1]);
296
+ if (segments.length === 2) return handleGenericEntityView(user, segments[0], segments[1], req);
268
297
  if (segments.length === 3 && segments[2] === 'edit') return handleGenericEntityEdit(user, segments[0], segments[1]);
269
298
 
270
299
  return null;
@@ -0,0 +1,48 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc } from '@/ui/render-helpers.js';
3
+
4
+ export function renderTemplateList(user, entityNames, templatesByEntity) {
5
+ const sections = entityNames.map(entityName => {
6
+ const templates = templatesByEntity[entityName] || [];
7
+ const rows = templates.map(t =>
8
+ `<tr><td>${esc(t.name)}</td><td><button type="button" class="btn-ghost-clean" data-action="deleteTemplate" data-args='["${esc(t.id)}"]'>Delete</button></td></tr>`
9
+ ).join('') || '<tr><td colspan="2">No templates</td></tr>';
10
+ return `<div class="card-clean" style="margin-bottom:16px"><div class="card-clean-body">
11
+ <h3 style="margin-bottom:8px">${esc(entityName)}</h3>
12
+ <table class="data-table"><thead><tr><th>Name</th><th></th></tr></thead><tbody>${rows}</tbody></table>
13
+ <div style="margin-top:8px;display:flex;gap:8px">
14
+ <input type="text" id="new-tpl-name-${esc(entityName)}" placeholder="Template name" style="flex:1">
15
+ <button type="button" class="btn-primary-clean" data-action="createTemplate" data-args='["${esc(entityName)}"]'>Add (blank)</button>
16
+ </div>
17
+ </div></div>`;
18
+ }).join('');
19
+
20
+ const content = `<div class="page-header"><h1 class="page-title">Record Templates</h1></div>
21
+ <p style="font-size:13px;color:var(--color-text-muted,#666);margin-bottom:16px">Templates created here start blank; edit field_values via the entity_template record to set starter values.</p>
22
+ ${sections}
23
+ <span id="tpl-status" style="font-size:13px"></span>`;
24
+
25
+ const script = `(function(){
26
+ window.createTemplate=function(entity){
27
+ var input=document.getElementById('new-tpl-name-'+entity);
28
+ var name=(input.value||'').trim();
29
+ var status=document.getElementById('tpl-status');
30
+ if(!name){status.textContent='Name required';return}
31
+ status.textContent='Creating...';
32
+ fetch('/api/entity_template/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({entity:entity,name:name,field_values:{}})})
33
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
34
+ .then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'create failed')}})
35
+ .catch(function(err){status.textContent='Error: '+err.message});
36
+ };
37
+ window.deleteTemplate=function(id){
38
+ var status=document.getElementById('tpl-status');
39
+ status.textContent='Deleting...';
40
+ fetch('/api/entity_template/'+id+'/delete',{method:'POST'})
41
+ .then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
42
+ .then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'delete failed')}})
43
+ .catch(function(err){status.textContent='Error: '+err.message});
44
+ };
45
+ })();`;
46
+
47
+ return page(user, 'Record Templates | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Templates' }], content, [script]);
48
+ }