thatcher 1.0.48 → 1.0.49

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.48",
3
+ "version": "1.0.49",
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",
package/src/index.js CHANGED
@@ -63,6 +63,9 @@ export class Thatcher {
63
63
  // Load plugins
64
64
  await this.loadPlugins();
65
65
 
66
+ const { registerAutomationEngine } = await import(resolveModule('./lib/automation-engine.js'));
67
+ registerAutomationEngine();
68
+
66
69
  // Hot reload
67
70
  if (this.options.server.hotReload) {
68
71
  this.setupHotReload();
@@ -0,0 +1,113 @@
1
+ import { hookEngine } from './hook-engine.js';
2
+ import { update as updateRecord, create as createRecord } from './busybase/store.js';
3
+ import { getConfigEngineSync } from './config-generator-engine.js';
4
+ import { createLogger } from './logger.js';
5
+
6
+ const log = createLogger('[AutomationEngine]');
7
+
8
+ const OPERATORS = {
9
+ eq: (a, b) => a === b,
10
+ neq: (a, b) => a !== b,
11
+ gt: (a, b) => Number(a) > Number(b),
12
+ gte: (a, b) => Number(a) >= Number(b),
13
+ lt: (a, b) => Number(a) < Number(b),
14
+ lte: (a, b) => Number(a) <= Number(b),
15
+ in: (a, b) => Array.isArray(b) && b.includes(a),
16
+ changed: (a, b, prev, field) => prev && prev[field] !== a,
17
+ present: (a) => a !== null && a !== undefined && a !== '',
18
+ };
19
+
20
+ function evalCondition(cond, data, prev) {
21
+ const { field, op = 'eq', value } = cond;
22
+ const actual = data[field];
23
+ const fn = OPERATORS[op];
24
+ if (!fn) { log.error(`unknown automation operator: ${op}`); return false; }
25
+ return fn(actual, value, prev, field);
26
+ }
27
+
28
+ function evalConditions(conditions, data, prev) {
29
+ if (!conditions || !conditions.length) return true;
30
+ return conditions.every(c => evalCondition(c, data, prev));
31
+ }
32
+
33
+ async function runAction(action, context) {
34
+ const { entity, id, data, user } = context;
35
+ switch (action.type) {
36
+ case 'set_field': {
37
+ const patch = { [action.field]: action.value };
38
+ await updateRecord(entity, id, patch, user);
39
+ return;
40
+ }
41
+ case 'create_entity': {
42
+ const payload = { ...(action.data || {}) };
43
+ for (const [k, v] of Object.entries(payload)) {
44
+ if (typeof v === 'string' && v.startsWith('$')) payload[k] = data[v.slice(1)];
45
+ }
46
+ await createRecord(action.entity, payload, user);
47
+ return;
48
+ }
49
+ case 'notify': {
50
+ await hookEngine.execute('automation:notify', { entity, id, data, user, message: action.message, target: action.target });
51
+ return;
52
+ }
53
+ default:
54
+ log.error(`unknown automation action type: ${action.type}`);
55
+ }
56
+ }
57
+
58
+ function normalizeContext(context) {
59
+ const data = context.data || context.record || {};
60
+ const prev = context.before || context.prev || null;
61
+ return { ...context, data, prev, entity: context.entity, id: context.id ?? data.id };
62
+ }
63
+
64
+ async function runRule(rule, rawContext) {
65
+ const context = normalizeContext(rawContext);
66
+ if (!evalConditions(rule.when, context.data, context.prev)) return;
67
+ for (const action of rule.then || []) {
68
+ try {
69
+ await runAction(action, context);
70
+ } catch (error) {
71
+ log.error(`automation rule "${rule.id || rule.name}" action failed:`, { message: error.message });
72
+ }
73
+ }
74
+ }
75
+
76
+ function getRules(entityName, trigger) {
77
+ let config;
78
+ try {
79
+ config = getConfigEngineSync().getConfig();
80
+ } catch {
81
+ return [];
82
+ }
83
+ const rules = config?.automation?.rules || [];
84
+ return rules.filter(r => (!r.entity || r.entity === entityName) && (!r.trigger || r.trigger === trigger));
85
+ }
86
+
87
+ let registered = false;
88
+
89
+ export function registerAutomationEngine() {
90
+ if (registered) return;
91
+ registered = true;
92
+
93
+ const config = getConfigEngineSync().getConfig();
94
+ const entityNames = Object.keys(config?.entities || {});
95
+
96
+ for (const entityName of entityNames) {
97
+ for (const trigger of ['create', 'update', 'delete']) {
98
+ hookEngine.register(`${trigger}:${entityName}:after`, async (context) => {
99
+ for (const rule of getRules(entityName, trigger)) await runRule(rule, context);
100
+ return context;
101
+ });
102
+ }
103
+ hookEngine.register(`transition:${entityName}`, async (context) => {
104
+ for (const rule of getRules(entityName, 'transition')) await runRule(rule, context);
105
+ return context;
106
+ });
107
+ }
108
+ }
109
+
110
+ export function dispatchAutomation(trigger, entity, context) {
111
+ const rules = getRules(entity, trigger);
112
+ return Promise.all(rules.map(rule => runRule(rule, context)));
113
+ }
@@ -124,6 +124,51 @@ export const fieldRegistry = {
124
124
  }),
125
125
  image: createSimpleType('TEXT'),
126
126
  id: createSimpleType('TEXT PRIMARY KEY'),
127
+ people: createSimpleType('TEXT', {
128
+ coerce: (val) => (val === undefined || val === '' || val === null ? null : (typeof val === 'string' ? val : JSON.stringify(val))),
129
+ format: (val, field, spec, row) => {
130
+ const ids = Array.isArray(val) ? val : (val ? [val] : []);
131
+ if (!ids.length) return null;
132
+ return ids.map(id => row?.[`${id}_display`] || row?.assignee_names?.[id] || String(id));
133
+ },
134
+ }),
135
+ link: createSimpleType('TEXT', {
136
+ format: (val) => String(val ?? ''),
137
+ isValid: (val) => { if (!val) return true; try { new URL(String(val)); return true; } catch { return false; } },
138
+ }),
139
+ file: createSimpleType('TEXT', {
140
+ coerce: (val) => (val === undefined || val === '' || val === null ? null : (typeof val === 'string' ? val : JSON.stringify(val))),
141
+ format: (val) => {
142
+ if (!val) return null;
143
+ const obj = typeof val === 'string' ? (() => { try { return JSON.parse(val); } catch { return { name: val }; } })() : val;
144
+ return { name: obj.name || obj.filename || 'file', url: obj.url || null };
145
+ },
146
+ }),
147
+ rating: createNumberType('INTEGER', (val) => {
148
+ if (val === undefined || val === '' || val === null) return null;
149
+ const num = parseInt(val, 10);
150
+ if (isNaN(num)) throw new Error(`Invalid rating: ${val}`);
151
+ return num;
152
+ }, (val, field) => ({ value: Number(val) || 0, max: field?.max || 5 })),
153
+ progress: createNumberType('REAL', (val) => {
154
+ if (val === undefined || val === '' || val === null) return null;
155
+ const num = parseFloat(val);
156
+ if (isNaN(num)) throw new Error(`Invalid progress: ${val}`);
157
+ return num;
158
+ }, (val) => Math.max(0, Math.min(100, Number(val) || 0))),
159
+ checkbox: createSimpleType('INTEGER', {
160
+ coerce: (val) => (val === true || val === 'true' || val === 'on' || val === 1 ? 1 : 0),
161
+ format: (val) => !!val,
162
+ }),
163
+ formula: {
164
+ sqlType: 'TEXT',
165
+ coerce: () => undefined,
166
+ format: (val, field, spec, row) => {
167
+ if (typeof field?.compute !== 'function') return null;
168
+ try { return field.compute(row); } catch { return null; }
169
+ },
170
+ isValid: () => true,
171
+ },
127
172
  };
128
173
 
129
174
  export function getFieldHandler(type) {
@@ -0,0 +1,76 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc, statusPill, TABLE_SCRIPT } from '@/ui/render-helpers.js';
3
+ import { getStateField, getStageLabels } from '@/lib/workflow-engine.js';
4
+
5
+ function cardTitleField(spec) {
6
+ if (spec.list?.titleField) return spec.list.titleField;
7
+ const candidates = ['name', 'title', 'label'];
8
+ for (const c of candidates) if (spec.fields?.[c]) return c;
9
+ const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
10
+ return first || 'id';
11
+ }
12
+
13
+ function boardCard(entityName, record, titleField) {
14
+ const title = esc(record[titleField] ?? record.id);
15
+ const status = record.status !== undefined ? statusPill(record.status) : '';
16
+ return `<div class="board-card" draggable="true" data-id="${esc(record.id)}" data-navigate="/${esc(entityName)}/${esc(record.id)}">
17
+ <div class="board-card-title">${title}</div>
18
+ ${status ? `<div class="board-card-meta">${status}</div>` : ''}
19
+ </div>`;
20
+ }
21
+
22
+ function boardColumn(entityName, stageKey, stageLabel, records, titleField) {
23
+ const cards = records.map(r => boardCard(entityName, r, titleField)).join('') ||
24
+ `<div class="board-column-empty">No items</div>`;
25
+ return `<div class="board-column" data-stage="${esc(stageKey)}">
26
+ <div class="board-column-header"><span>${esc(stageLabel)}</span><span class="board-column-count">${records.length}</span></div>
27
+ <div class="board-column-body" data-drop-zone="${esc(stageKey)}">${cards}</div>
28
+ </div>`;
29
+ }
30
+
31
+ const BOARD_SCRIPT = `(function(){
32
+ document.addEventListener('dragstart',e=>{const c=e.target.closest('.board-card');if(!c)return;e.dataTransfer.setData('text/plain',c.dataset.id);c.classList.add('dragging')});
33
+ document.addEventListener('dragend',e=>{const c=e.target.closest('.board-card');if(c)c.classList.remove('dragging')});
34
+ document.addEventListener('dragover',e=>{const z=e.target.closest('[data-drop-zone]');if(z)e.preventDefault()});
35
+ document.addEventListener('drop',async e=>{
36
+ const zone=e.target.closest('[data-drop-zone]');if(!zone)return;e.preventDefault();
37
+ const id=e.dataTransfer.getData('text/plain');if(!id)return;
38
+ const toStage=zone.dataset.dropZone;
39
+ const board=document.getElementById('board-root');
40
+ const entity=board.dataset.entity, workflow=board.dataset.workflow;
41
+ try{
42
+ const r=await fetch('/api/'+entity+'/'+id+'/transition',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({workflow,toState:toStage})});
43
+ if(r.ok)location.reload();else{const d=await r.json().catch(()=>({}));showToast(d.error||'Transition failed','error')}
44
+ }catch(err){showToast(err.message,'error')}
45
+ });
46
+ })();`;
47
+
48
+ export function renderBoardView(user, entityName, spec, records, options = {}) {
49
+ const workflowName = options.workflow || spec.workflow;
50
+ if (!workflowName) {
51
+ return page(user, `${spec.labelPlural || spec.label} | Board`, null,
52
+ `<div class="board-empty-state">This entity has no workflow configured; board view requires a <code>workflow</code> key on the entity spec.</div>`);
53
+ }
54
+ const stateField = getStateField(workflowName);
55
+ const stageLabels = getStageLabels(workflowName);
56
+ const titleField = cardTitleField(spec);
57
+
58
+ const grouped = {};
59
+ for (const key of Object.keys(stageLabels)) grouped[key] = [];
60
+ for (const r of records) {
61
+ const stage = r[stateField];
62
+ if (!grouped[stage]) grouped[stage] = [];
63
+ grouped[stage].push(r);
64
+ }
65
+
66
+ const columns = Object.entries(stageLabels)
67
+ .map(([key, label]) => boardColumn(entityName, key, label, grouped[key] || [], titleField))
68
+ .join('');
69
+
70
+ const content = `<div class="page-header">
71
+ <div><h1 class="page-title">${esc(spec.labelPlural || spec.label)}</h1><p class="page-subtitle">${records.length} items</p></div>
72
+ </div>
73
+ <div id="board-root" class="board-view" data-entity="${esc(entityName)}" data-workflow="${esc(workflowName)}">${columns}</div>`;
74
+
75
+ return page(user, `${spec.labelPlural || spec.label} | Board`, null, content, [TABLE_SCRIPT, BOARD_SCRIPT]);
76
+ }
@@ -0,0 +1,196 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc } from '@/ui/render-helpers.js';
3
+ import { getEntityLabel } from '@/config/spec-helpers.js';
4
+
5
+ const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'];
6
+ const DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
7
+
8
+ function calendarDateField(spec) {
9
+ if (spec.list?.dateField) return spec.list.dateField;
10
+ const entries = Object.entries(spec.fields || {});
11
+ const found = entries.find(([, f]) => f.type === 'date' || f.type === 'timestamp');
12
+ return found ? found[0] : null;
13
+ }
14
+
15
+ function toDateOrNull(value) {
16
+ if (value === null || value === undefined || value === '') return null;
17
+ const num = Number(value);
18
+ if (!isNaN(num) && num > 0) return new Date(num * 1000);
19
+ const d = new Date(value);
20
+ return isNaN(d) ? null : d;
21
+ }
22
+
23
+ function dayKey(d) {
24
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
25
+ }
26
+
27
+ function calendarChip(entityName, record, titleField) {
28
+ const title = esc(record[titleField] ?? record.id);
29
+ return `<div class="calendar-chip" data-navigate="/${esc(entityName)}/${esc(record.id)}">${title}</div>`;
30
+ }
31
+
32
+ function calendarTitleField(spec) {
33
+ if (spec.list?.titleField) return spec.list.titleField;
34
+ const candidates = ['name', 'title', 'label'];
35
+ for (const c of candidates) if (spec.fields?.[c]) return c;
36
+ const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
37
+ return first || 'id';
38
+ }
39
+
40
+ export function renderCalendarView(user, entityName, spec, records, options = {}) {
41
+ const label = getEntityLabel(spec, true) || entityName;
42
+ const dateField = calendarDateField(spec);
43
+ const titleField = calendarTitleField(spec);
44
+
45
+ const now = new Date();
46
+ const year = Number(options.year) || now.getFullYear();
47
+ const month = options.month !== undefined ? Number(options.month) : now.getMonth();
48
+
49
+ const byDay = {};
50
+ const undated = [];
51
+
52
+ for (const r of records) {
53
+ const d = dateField ? toDateOrNull(r[dateField]) : null;
54
+ if (!d) { undated.push(r); continue; }
55
+ const key = dayKey(d);
56
+ if (!byDay[key]) byDay[key] = [];
57
+ byDay[key].push(r);
58
+ }
59
+
60
+ const firstOfMonth = new Date(year, month, 1);
61
+ const startWeekday = firstOfMonth.getDay();
62
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
63
+
64
+ const cells = [];
65
+ for (let i = 0; i < startWeekday; i++) cells.push('<div class="calendar-cell calendar-cell-empty"></div>');
66
+ for (let day = 1; day <= daysInMonth; day++) {
67
+ const key = `${year}-${month}-${day}`;
68
+ const dayRecords = byDay[key] || [];
69
+ const chips = dayRecords.map(r => calendarChip(entityName, r, titleField)).join('');
70
+ const isToday = year === now.getFullYear() && month === now.getMonth() && day === now.getDate();
71
+ cells.push(`<div class="calendar-cell${isToday ? ' calendar-cell-today' : ''}">
72
+ <div class="calendar-cell-date">${day}</div>
73
+ <div class="calendar-cell-chips">${chips}</div>
74
+ </div>`);
75
+ }
76
+
77
+ const totalCells = startWeekday + daysInMonth;
78
+ const trailing = (7 - (totalCells % 7)) % 7;
79
+ for (let i = 0; i < trailing; i++) cells.push('<div class="calendar-cell calendar-cell-empty"></div>');
80
+
81
+ const dayHeaders = DAY_NAMES.map(d => `<div class="calendar-day-header">${d}</div>`).join('');
82
+
83
+ let prevMonth = month - 1, prevYear = year;
84
+ if (prevMonth < 0) { prevMonth = 11; prevYear -= 1; }
85
+ let nextMonth = month + 1, nextYear = year;
86
+ if (nextMonth > 11) { nextMonth = 0; nextYear += 1; }
87
+
88
+ const undatedSection = undated.length
89
+ ? `<div class="calendar-undated">
90
+ <h3>Undated (${undated.length})</h3>
91
+ <div class="calendar-undated-list">${undated.map(r => calendarChip(entityName, r, titleField)).join('')}</div>
92
+ </div>`
93
+ : '';
94
+
95
+ const noDateFieldNotice = !dateField
96
+ ? `<div class="calendar-empty-state">This entity has no date field configured; all records are shown as undated.</div>`
97
+ : '';
98
+
99
+ const content = `<div class="page-header">
100
+ <div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
101
+ </div>
102
+ ${noDateFieldNotice}
103
+ <div class="calendar-toolbar">
104
+ <a class="calendar-nav-link" href="?month=${prevMonth}&year=${prevYear}">&laquo; Prev</a>
105
+ <span class="calendar-month-label">${MONTH_NAMES[month]} ${year}</span>
106
+ <a class="calendar-nav-link" href="?month=${nextMonth}&year=${nextYear}">Next &raquo;</a>
107
+ </div>
108
+ <div class="calendar-grid">
109
+ ${dayHeaders}
110
+ ${cells.join('')}
111
+ </div>
112
+ ${undatedSection}`;
113
+
114
+ return page(user, `${label} | Calendar`, null, content);
115
+ }
116
+
117
+ function timelineFields(spec) {
118
+ if (spec.list?.timelineStart && spec.list?.timelineEnd) {
119
+ return [spec.list.timelineStart, spec.list.timelineEnd];
120
+ }
121
+ const fields = spec.fields || {};
122
+ const startCandidates = ['start_date', 'start'];
123
+ const endCandidates = ['end_date', 'due_date', 'end', 'deadline'];
124
+ const start = startCandidates.find(k => fields[k]) || null;
125
+ const end = endCandidates.find(k => fields[k]) || null;
126
+ return [start, end];
127
+ }
128
+
129
+ function timelineTitleField(spec) {
130
+ if (spec.list?.titleField) return spec.list.titleField;
131
+ const candidates = ['name', 'title', 'label'];
132
+ for (const c of candidates) if (spec.fields?.[c]) return c;
133
+ const first = Object.keys(spec.fields || {}).find(k => spec.fields[k]?.type === 'text');
134
+ return first || 'id';
135
+ }
136
+
137
+ export function renderTimelineView(user, entityName, spec, records, options = {}) {
138
+ const label = getEntityLabel(spec, true) || entityName;
139
+ const [startField, endField] = timelineFields(spec);
140
+ const titleField = timelineTitleField(spec);
141
+
142
+ const plotted = [];
143
+ let missing = 0;
144
+
145
+ for (const r of records) {
146
+ const start = startField ? toDateOrNull(r[startField]) : null;
147
+ const end = endField ? toDateOrNull(r[endField]) : null;
148
+ if (!start || !end) { missing++; continue; }
149
+ plotted.push({ record: r, start, end });
150
+ }
151
+
152
+ if (!startField || !endField) {
153
+ return page(user, `${label} | Timeline`, null,
154
+ `<div class="page-header">
155
+ <div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
156
+ </div>
157
+ <div class="timeline-empty-state">This entity has no start/end date fields configured; timeline view requires <code>list.timelineStart</code>/<code>list.timelineEnd</code> or matching field names.</div>`);
158
+ }
159
+
160
+ const missingNotice = missing
161
+ ? `<div class="timeline-missing-notice">${missing} item${missing === 1 ? '' : 's'} without dates not shown</div>`
162
+ : '';
163
+
164
+ if (!plotted.length) {
165
+ const content = `<div class="page-header">
166
+ <div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
167
+ </div>
168
+ ${missingNotice}
169
+ <div class="timeline-empty-state">No items have both a start and end date.</div>`;
170
+ return page(user, `${label} | Timeline`, null, content);
171
+ }
172
+
173
+ const minTime = Math.min(...plotted.map(p => p.start.getTime()));
174
+ const maxTime = Math.max(...plotted.map(p => p.end.getTime()));
175
+ const span = Math.max(1, maxTime - minTime);
176
+
177
+ const rows = plotted.map(({ record, start, end }) => {
178
+ const title = esc(record[titleField] ?? record.id);
179
+ const offsetPct = ((start.getTime() - minTime) / span) * 100;
180
+ const widthPct = Math.max(1, ((end.getTime() - start.getTime()) / span) * 100);
181
+ return `<div class="timeline-row">
182
+ <div class="timeline-row-label">${title}</div>
183
+ <div class="timeline-row-track">
184
+ <div class="timeline-bar" data-navigate="/${esc(entityName)}/${esc(record.id)}" style="margin-left:${offsetPct}%;width:${widthPct}%"></div>
185
+ </div>
186
+ </div>`;
187
+ }).join('');
188
+
189
+ const content = `<div class="page-header">
190
+ <div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} items</p></div>
191
+ </div>
192
+ ${missingNotice}
193
+ <div class="timeline-view">${rows}</div>`;
194
+
195
+ return page(user, `${label} | Timeline`, null, content);
196
+ }
@@ -0,0 +1,78 @@
1
+ import { page } from '@/ui/layout.js';
2
+ import { esc, fmtVal, TABLE_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
3
+ import { getDefaultSort, getAvailableFilters, getPageSize, getEntityLabel } from '@/config/spec-helpers.js';
4
+
5
+ const EMBEDDED_TYPES = new Set(['json', 'embedded']);
6
+
7
+ function getColumns(spec) {
8
+ const fields = spec?.fields || {};
9
+ const override = spec?.list?.columns;
10
+ if (Array.isArray(override) && override.length) {
11
+ return override
12
+ .filter(key => fields[key])
13
+ .map(key => [key, fields[key]]);
14
+ }
15
+ return Object.entries(fields).filter(([, f]) =>
16
+ !f.hidden && !EMBEDDED_TYPES.has(f.type)
17
+ );
18
+ }
19
+
20
+ function isEditable(field) {
21
+ return field && field.readonly !== true;
22
+ }
23
+
24
+ function gridRow(entityName, item, columns) {
25
+ const cells = columns.map(([key, field]) => {
26
+ const value = item[key];
27
+ const rendered = fmtVal(value, key, item);
28
+ const editableAttr = isEditable(field) ? ` data-editable="${esc(key)}"` : '';
29
+ return `<td data-col="${esc(key)}"${editableAttr}>${rendered}</td>`;
30
+ }).join('');
31
+ return `<tr data-row data-navigate="/${esc(entityName)}/${esc(item.id)}" style="cursor:pointer">${cells}</tr>`;
32
+ }
33
+
34
+ export function renderGridView(user, entityName, spec, records, options = {}) {
35
+ const label = getEntityLabel(spec, true) || entityName;
36
+ const columns = getColumns(spec);
37
+ const defaultSort = getDefaultSort(spec);
38
+ const filters = getAvailableFilters(spec);
39
+ const pageSize = getPageSize(spec);
40
+
41
+ const headerCells = columns.map(([key, field]) => {
42
+ const colLabel = esc(field?.label || key);
43
+ const isDefaultSort = key === defaultSort.field;
44
+ return `<th data-sort="${esc(key)}" aria-label="Sort by ${colLabel}"${isDefaultSort ? ` class="sort-${esc(defaultSort.dir)}"` : ''}>${colLabel}</th>`;
45
+ }).join('');
46
+
47
+ const filterControls = filters.map(f => {
48
+ const fieldKey = typeof f === 'string' ? f : f.field;
49
+ const fieldSpec = spec.fields?.[fieldKey];
50
+ const filterLabel = esc(fieldSpec?.label || fieldKey);
51
+ const opts = (fieldSpec?.options || f.options || []).map(o => {
52
+ const value = typeof o === 'string' ? o : o.value;
53
+ const optLabel = typeof o === 'string' ? o : (o.label || o.value);
54
+ return `<option value="${esc(value)}">${esc(optLabel)}</option>`;
55
+ }).join('');
56
+ return `<div class="table-filter"><select data-filter="${esc(fieldKey)}" id="filter-${esc(fieldKey)}" aria-label="Filter by ${filterLabel}"><option value="">All ${filterLabel}</option>${opts}</select></div>`;
57
+ }).join('');
58
+
59
+ const rows = records.map(item => gridRow(entityName, item, columns)).join('') ||
60
+ emptyRow(columns.length || 1, `No ${esc(label.toLowerCase())} found`);
61
+
62
+ const content = `<div class="page-header">
63
+ <div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} total ${esc(label.toLowerCase())}</p></div>
64
+ </div>
65
+ <div class="table-wrap">
66
+ <div class="table-toolbar">
67
+ <div class="table-search"><input id="search-input" type="text" placeholder="Search ${esc(label.toLowerCase())}..."></div>
68
+ ${filterControls}
69
+ <span class="table-count" id="row-count">${records.length} items</span>
70
+ </div>
71
+ <table class="data-table" role="grid" data-page-size="${esc(pageSize)}">
72
+ <thead><tr>${headerCells}</tr></thead>
73
+ <tbody>${rows}</tbody>
74
+ </table>
75
+ </div>`;
76
+
77
+ return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT]);
78
+ }