thatcher 1.0.53 → 1.0.55

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.53",
3
+ "version": "1.0.55",
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",
@@ -19,6 +19,22 @@ export function getSpec(name, configEngine) {
19
19
  }
20
20
  }
21
21
 
22
+ export function getAllEntityNames(configEngine) {
23
+ if (!configEngine) {
24
+ const g = globalThis.__thatcherConfigEngine;
25
+ if (g) configEngine = g;
26
+ }
27
+ if (!configEngine) return [];
28
+ try {
29
+ return configEngine.getAllEntities().filter(name => {
30
+ const spec = getSpec(name, configEngine);
31
+ return spec && !spec.embedded && !spec.system_entity;
32
+ });
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
22
38
  export function getNavItems(configEngine) {
23
39
  try {
24
40
  const allEntities = configEngine.getAllEntities();
@@ -12,14 +12,18 @@ function resultCard(item, entityType) {
12
12
  return `<div class="card-clean" style="margin-bottom:8px;cursor:pointer" data-navigate="/${entityType}/${item.id}"><div class="card-clean-body" style="padding:0.75rem"><div class="flex items-start justify-between"><div class="flex-1"><div class="flex items-center gap-2 mb-1"><span class="badge badge-sm bg-gray-100 text-gray-600">${typeLabel}</span>${sts}</div><div class="font-medium">${esc(title)}</div>${subtitle ? `<div class="text-xs text-gray-500 mt-0.5">${esc(subtitle)}</div>` : ''}</div><div class="text-xs text-gray-400">${date}</div></div></div></div>`;
13
13
  }
14
14
 
15
- function filterPanel(teams, stages) {
15
+ function entityLabel(name) {
16
+ return name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, ' ');
17
+ }
18
+
19
+ function filterPanel(teams, entityNames) {
16
20
  const teamOpts = teams.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');
17
- const stageOpts = stages.map(s => `<option value="${s}">${s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</option>`).join('');
18
- return `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body"><div class="grid grid-cols-1 md:grid-cols-4 gap-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="search-query">Search</label><input type="text" id="search-query" class="input input-bordered input-sm w-full" placeholder="Search across all entities..."/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-entity">Entity Type</label><select id="filter-entity" class="select select-bordered select-sm w-full"><option value="">All Types</option><option value="engagement">Engagements</option><option value="client">Clients</option><option value="rfi">RFIs</option><option value="review">Reviews</option><option value="user">Users</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-status">Status</label><select id="filter-status" class="select select-bordered select-sm w-full"><option value="">All Statuses</option><option value="active">Active</option><option value="pending">Pending</option><option value="completed">Completed</option><option value="archived">Archived</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-stage">Stage</label><select id="filter-stage" class="select select-bordered select-sm w-full"><option value="">All Stages</option>${stageOpts}</select></div></div><div class="grid grid-cols-1 md:grid-cols-4 gap-3 mt-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-team">Team</label><select id="filter-team" class="select select-bordered select-sm w-full"><option value="">All Teams</option>${teamOpts}</select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-from">Date From</label><input type="date" id="filter-from" class="input input-bordered input-sm w-full"/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-to">Date To</label><input type="date" id="filter-to" class="input input-bordered input-sm w-full"/></div><div class="flex items-end"><button class="btn btn-primary btn-sm w-full" data-action="doSearch">Search</button></div></div></div></div>`;
21
+ const entityOpts = entityNames.map(e => `<option value="${esc(e)}">${esc(entityLabel(e))}</option>`).join('');
22
+ return `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body"><div class="grid grid-cols-1 md:grid-cols-4 gap-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="search-query">Search</label><input type="text" id="search-query" class="input input-bordered input-sm w-full" placeholder="Search across all entities..."/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-entity">Entity Type</label><select id="filter-entity" class="select select-bordered select-sm w-full"><option value="">All Types</option>${entityOpts}</select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-status">Status</label><select id="filter-status" class="select select-bordered select-sm w-full"><option value="">All Statuses</option><option value="active">Active</option><option value="pending">Pending</option><option value="completed">Completed</option><option value="archived">Archived</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-team">Team</label><select id="filter-team" class="select select-bordered select-sm w-full"><option value="">All Teams</option>${teamOpts}</select></div></div><div class="grid grid-cols-1 md:grid-cols-4 gap-3 mt-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-from">Date From</label><input type="date" id="filter-from" class="input input-bordered input-sm w-full"/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-to">Date To</label><input type="date" id="filter-to" class="input input-bordered input-sm w-full"/></div><div class="flex items-end"><button class="btn btn-primary btn-sm w-full" data-action="doSearch">Search</button></div></div></div></div>`;
19
23
  }
20
24
 
21
25
  export function renderAdvancedSearch(user, results = {}, options = {}) {
22
- const { teams = [], stages = [] } = options;
26
+ const { teams = [], entityNames = [] } = options;
23
27
  const allResults = [];
24
28
  for (const [entityType, items] of Object.entries(results)) {
25
29
  (items || []).forEach(item => allResults.push({ ...item, _type: entityType }));
@@ -33,11 +37,11 @@ export function renderAdvancedSearch(user, results = {}, options = {}) {
33
37
 
34
38
  const resultCards = allResults.length > 0
35
39
  ? allResults.map(r => resultCard(r, r._type)).join('')
36
- : emptyState('Enter a search query to find engagements, clients, RFIs, and reviews', 'search');
40
+ : emptyState('Enter a search query to find records across every entity', 'search');
37
41
 
38
- const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Advanced Search</h1></div>${filterPanel(teams, stages)}<div class="flex items-center gap-2 mb-4"><span class="text-sm text-gray-500">${totalCount} result${totalCount !== 1 ? 's' : ''}</span>${countBadges}</div><div id="search-results">${resultCards}</div>`;
42
+ const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Advanced Search</h1></div>${filterPanel(teams, entityNames)}<div class="flex items-center gap-2 mb-4"><span class="text-sm text-gray-500">${totalCount} result${totalCount !== 1 ? 's' : ''}</span>${countBadges}</div><div id="search-results">${resultCards}</div>`;
39
43
 
40
- const searchScript = `window.doSearch=async function(){const q=document.getElementById('search-query')?.value||'';const entity=document.getElementById('filter-entity')?.value||'';const status=document.getElementById('filter-status')?.value||'';const stage=document.getElementById('filter-stage')?.value||'';const team=document.getElementById('filter-team')?.value||'';const from=document.getElementById('filter-from')?.value||'';const to=document.getElementById('filter-to')?.value||'';const params=new URLSearchParams();if(q)params.set('q',q);if(entity)params.set('entity',entity);if(status)params.set('status',status);if(stage)params.set('stage',stage);if(team)params.set('team',team);if(from)params.set('from',from);if(to)params.set('to',to);window.location='/search?'+params.toString()};document.getElementById('search-query')?.addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})`;
44
+ const searchScript = `window.doSearch=async function(){const q=document.getElementById('search-query')?.value||'';const entity=document.getElementById('filter-entity')?.value||'';const status=document.getElementById('filter-status')?.value||'';const team=document.getElementById('filter-team')?.value||'';const from=document.getElementById('filter-from')?.value||'';const to=document.getElementById('filter-to')?.value||'';const params=new URLSearchParams();if(q)params.set('q',q);if(entity)params.set('entity',entity);if(status)params.set('status',status);if(team)params.set('team',team);if(from)params.set('from',from);if(to)params.set('to',to);window.location='/search?'+params.toString()};document.getElementById('search-query')?.addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})`;
41
45
 
42
46
  return page(user, 'Search | Thatcher', [{ href: '/', label: 'Dashboard' }, { label: 'Search' }], content, [searchScript]);
43
47
  }
@@ -1,6 +1,6 @@
1
1
  import { getUser, setCurrentRequest } from '@/engine.server.js';
2
2
  import { hasGoogleAuth } from '@/config/env.js';
3
- import { getSpec } from '@/config/spec-helpers.js';
3
+ import { getSpec, getAllEntityNames } from '@/config/spec-helpers.js';
4
4
  import { list, get } from '@/lib/busybase/store.js';
5
5
  import { renderLogin, renderDashboard, renderAccessDenied, renderPasswordReset, renderPasswordResetConfirm, REDIRECT } from '@/ui/renderer.js';
6
6
  import { renderClientDashboard, renderClientList } from '@/ui/client-renderer.js';
@@ -63,11 +63,13 @@ async function handleSearch(user, req) {
63
63
  const url = reqUrl(req);
64
64
  const q = url.searchParams.get('q') || '', entityFilter = url.searchParams.get('entity') || '', statusFilter = url.searchParams.get('status') || '';
65
65
  let teams = []; try { teams = await list('team', {}); } catch {}
66
+ const allEntityNames = getAllEntityNames().filter(eName => canList(user, eName));
67
+ const searchableEntities = entityFilter ? allEntityNames.filter(e => e === entityFilter) : allEntityNames;
66
68
  const results = {};
67
- for (const eName of (entityFilter ? [entityFilter] : ['engagement', 'client', 'rfi', 'review'])) {
69
+ for (const eName of searchableEntities) {
68
70
  try { let items = await list(eName, {}); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
69
71
  }
70
- return renderAdvancedSearch(user, results, { teams, stages: ['info_gathering', 'commencement', 'team_execution', 'partner_review', 'finalization', 'closeout'] });
72
+ return renderAdvancedSearch(user, results, { teams, entityNames: allEntityNames });
71
73
  }
72
74
  async function handleGenericEntityView(user, entityName, id) {
73
75
  const spec = getSpec(entityName); if (!spec) return null;