@arcforgelabs/flow 2026.10.34 → 2026.10.36

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.
@@ -134,6 +134,13 @@
134
134
  "when": 1792472460808,
135
135
  "tag": "0018_custom_fields",
136
136
  "breakpoints": true
137
+ },
138
+ {
139
+ "idx": 19,
140
+ "version": "7",
141
+ "when": 1792474607940,
142
+ "tag": "0019_people_views",
143
+ "breakpoints": true
137
144
  }
138
145
  ]
139
146
  }
@@ -177,7 +177,10 @@ export const PERSON_STATUSES = ['active', 'pending', 'disabled'];
177
177
  // are stored lower-case. `clerk_user_id` is set on the person's first verified
178
178
  // sign-in; until then the row is an invitation. `appearance` is the person's
179
179
  // own design system, light or dark and accent (server/auth/people.js,
180
- // normaliseAppearance); null until they first choose.
180
+ // normaliseAppearance); null until they first choose. `views` is the
181
+ // person's own view of each page, such as how the Jobs page groups, sorts
182
+ // and filters ({ jobs: … }, server/ui/job-view.js); null until they first
183
+ // change one.
181
184
  export const people = pgTable('people', {
182
185
  id: id(),
183
186
  email: text('email').notNull(),
@@ -188,6 +191,7 @@ export const people = pgTable('people', {
188
191
  invitedBy: uuid('invited_by').references(() => people.id, { onDelete: 'set null' }),
189
192
  linkedAt: timestamp('linked_at', { withTimezone: true }),
190
193
  appearance: jsonb('appearance'),
194
+ views: jsonb('views'),
191
195
  createdAt: createdAt(),
192
196
  updatedAt: updatedAt(),
193
197
  }, (t) => [
@@ -23,6 +23,7 @@ export const CAPABILITIES = Object.freeze([
23
23
  'checkInvoice', // does the accounting system hold an invoice for a billable job? (the ledger's liveCheck)
24
24
  'billableJobs', // billable jobs by day from the work system's copy in Flow, for the daily reconcile; never a live call
25
25
  'jobReferenceFields', // { patient }: the fields a job gives the invoice Reference (server/core/references.js)
26
+ 'jobLink', // { url, exact } or null: the link to one job in the work system's own web app, for "Open in" on a job's problem; never a live call
26
27
  'listWork', // the work system's jobs from its copy in Flow, open and lately finished, for the Jobs board; a job may carry `pendingMove`
27
28
  'moveWork', // { sourceJobRef, toStatus, personId }: a person moved a job to another stage on the Jobs board; answers what happens (a hand-off when the system's API cannot change it)
28
29
  'cancelMove', // { sourceJobRef }: take back a move the work system has not made yet
@@ -8,13 +8,13 @@ import { readBillableJobs } from './reconcile.js';
8
8
  import { SEAZONA_CALL_GUARD } from './governor.js';
9
9
  import { createSeazonaRouter } from './routes.js';
10
10
  import {
11
- cancelStageMove, expiredStageMoves, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
11
+ cancelStageMove, expiredStageMoves, handOff, readPersonSettings, requestStageMove, settleStageMoves, waitingStageMoves, writePersonSetting,
12
12
  } from './stage-moves.js';
13
13
  import { orderPatient } from './patients.js';
14
14
  import { createSeazonaScheduler } from './scheduler.js';
15
15
  import { readSweepHealth } from './sweep.js';
16
16
  import { resetFailedLanes, resetStoppedLanes } from './store.js';
17
- import { describeSeazonaSettings, resolveSeazonaSettings } from './settings.js';
17
+ import { describeSeazonaSettings, orderLink, resolveSeazonaSettings } from './settings.js';
18
18
  import { seazonaSchema } from './tables.js';
19
19
 
20
20
  // The Seazona adapter (docs/SEAZONA.md). FLOW_INTEGRATIONS=seazona turns it
@@ -51,7 +51,7 @@ export const SEAZONA_SETUP = {
51
51
  { name: 'SEAZONA_API_BASE_URL', label: 'API address', kind: 'url', help: 'The API root of your account, https://…' },
52
52
  { name: 'SEAZONA_API_CLIENT_ID', label: 'Client id' },
53
53
  { name: 'SEAZONA_API_CLIENT_SECRET', label: 'Client secret', secret: true, help: 'Stored encrypted. Flow never shows it again.' },
54
- { name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. The address of one order in Seazona, with {ref} for the order number. Stage moves on the Jobs board open it.' },
54
+ { name: 'SEAZONA_ORDER_URL', label: 'Order address', kind: 'url', help: 'Optional. Open any order in Seazona and paste its address here, or write {ref} where the order id goes. Stage moves on the Jobs board and Open in Seazona use it.' },
55
55
  { name: 'SEAZONA_KEEP_PATIENT_REFERENCE', label: 'Keep patient names', kind: 'onoff', help: 'On: Flow keeps the patient name of each job for the invoice Reference.' },
56
56
  ],
57
57
  };
@@ -225,9 +225,15 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
225
225
  const [work, waiting] = await Promise.all([listWork(db, { now: now() }), waitingStageMoves(db)]);
226
226
  // A stage move Seazona has not made yet (stage-moves.js): the card
227
227
  // shows in its new lane, waiting for Seazona.
228
+ // Each card also carries the link that opens its order in Seazona.
229
+ const { settings } = current();
228
230
  for (const item of work.items) {
231
+ item.link = orderLink(settings, { sourceJobRef: item.sourceJobRef });
229
232
  const move = waiting.get(item.sourceJobRef);
230
- if (move) item.pendingMove = { to: move.toStatus, from: move.fromStatus, requestedAt: move.requestedAt.toISOString(), expiresAt: move.expiresAt.toISOString() };
233
+ if (move) {
234
+ const { to, from, requestedAt, expiresAt, openUrl, openExact } = handOff(move, { settings });
235
+ item.pendingMove = { to, from, requestedAt, expiresAt, openUrl, openExact };
236
+ }
231
237
  }
232
238
  return work;
233
239
  },
@@ -237,7 +243,7 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
237
243
  // records a hand-off and answers what the person does in Seazona.
238
244
  async moveWork({ sourceJobRef, toStatus, personId = null }) {
239
245
  const { db, settings } = current();
240
- return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId, orderUrlTemplate: settings.orderUrlTemplate, now: now() });
246
+ return requestStageMove(db, { orderId: sourceJobRef, toStatus, personId, settings, now: now() });
241
247
  },
242
248
 
243
249
  async cancelMove({ sourceJobRef }) {
@@ -267,6 +273,12 @@ export function createSeazonaAdapter({ env = null, fetchImpl = null, now = Date.
267
273
  return historyJobIssues(current().db, refs);
268
274
  },
269
275
 
276
+ // The link to one order in Seazona's web app, for "Open in Seazona" on
277
+ // a job's problem (settings.js, orderLink). Never calls Seazona.
278
+ jobLink({ sourceJobRef }) {
279
+ return runtime ? orderLink(runtime.settings, { sourceJobRef }) : null;
280
+ },
281
+
270
282
  // Seazona's products, for the price list (server/core/price-list.js).
271
283
  // Reads Flow's copy only.
272
284
  async listPriceItems() {
@@ -55,9 +55,18 @@ export function normaliseBaseUrl(value) {
55
55
  return `${url.origin}${path}/v1`;
56
56
  }
57
57
 
58
- function orderUrlTemplate(value) {
59
- const raw = text(value);
60
- if (!raw.includes('{ref}')) return null;
58
+ // SEAZONA_ORDER_URL: the address of one order in Seazona's own web app, with
59
+ // {ref} where the order id goes. Seazona's API guide gives no order page
60
+ // address, and each lab has its own, so a person may also paste the address
61
+ // of any one order as the browser shows it: the order id in it (a GUID)
62
+ // becomes {ref}. Answers the template, or null: https only, and an order id
63
+ // or {ref} in it. It never throws; an address Flow cannot read turns it off.
64
+ const ORDER_ID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
65
+
66
+ export function orderUrlTemplate(value) {
67
+ const given = text(value);
68
+ const raw = given.includes('{ref}') ? given : ORDER_ID.test(given) ? given.replace(ORDER_ID, '{ref}') : '';
69
+ if (!raw) return null;
61
70
  try {
62
71
  return new URL(raw.replaceAll('{ref}', 'x')).protocol === 'https:' ? raw : null;
63
72
  } catch {
@@ -65,6 +74,18 @@ function orderUrlTemplate(value) {
65
74
  }
66
75
  }
67
76
 
77
+ // The link to one order for "Open in Seazona": the order's own page when the
78
+ // installation set SEAZONA_ORDER_URL, else the home page of the lab's
79
+ // Seazona web app when the API address names it (https://{lab}.seazonaapi.net),
80
+ // else null. `exact` is true only for the order's own page.
81
+ export function orderLink(settings, { sourceJobRef } = {}) {
82
+ const template = settings?.orderUrlTemplate ?? null;
83
+ if (template && sourceJobRef) return { url: template.replaceAll('{ref}', encodeURIComponent(sourceJobRef)), exact: true };
84
+ const host = settings?.baseUrl ? new URL(settings.baseUrl).hostname : '';
85
+ const lab = /^([a-z0-9-]+)\.seazonaapi\.net$/i.exec(host)?.[1];
86
+ return lab ? { url: `https://${lab}.seazona.net/`, exact: false } : null;
87
+ }
88
+
68
89
  // Returns the resolved settings, or throws SeazonaSettingsError. `mode` is
69
90
  // null when SEAZONA_MODE is unset: the adapter is then not configured.
70
91
  export function resolveSeazonaSettings(env = process.env) {
@@ -1,6 +1,7 @@
1
1
  import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm';
2
2
  import { FlowError } from '../../core/errors.js';
3
3
  import { orderStatusLabel } from './map.js';
4
+ import { orderLink } from './settings.js';
4
5
  import { seazonaOrders, seazonaPersonSettings, seazonaStageMoves } from './schema.js';
5
6
 
6
7
  // Stage moves on the Jobs board (docs/SEAZONA.md, "Stage moves"). Seazona's
@@ -16,16 +17,9 @@ export const HAND_OFF_MS = 24 * 60 * 60_000;
16
17
  const EXPIRED_ALERT_MS = 7 * 24 * 60 * 60_000;
17
18
  const MAX_STATUS = 80;
18
19
 
19
- // The address that opens one order in Seazona, from SEAZONA_ORDER_URL with
20
- // its {ref} placeholder, or null when the installation has not set one.
21
- export function orderUrl(template, ref) {
22
- if (!template || !template.includes('{ref}')) return null;
23
- return template.replaceAll('{ref}', encodeURIComponent(ref));
24
- }
25
-
26
20
  // Record a person's move of one order to `toStatus`, and answer the hand-off
27
21
  // the board shows. A second move of the same order replaces the first.
28
- export async function requestStageMove(db, { orderId, toStatus, personId = null, orderUrlTemplate = null, now = Date.now() }) {
22
+ export async function requestStageMove(db, { orderId, toStatus, personId = null, settings = null, now = Date.now() }) {
29
23
  const to = typeof toStatus === 'string' ? toStatus.trim() : '';
30
24
  if (!to || to.length > MAX_STATUS) throw new FlowError('invalid', 'toStatus must be a Seazona status');
31
25
  const [order] = await db.select().from(seazonaOrders).where(eq(seazonaOrders.id, String(orderId ?? '')));
@@ -41,7 +35,7 @@ export async function requestStageMove(db, { orderId, toStatus, personId = null,
41
35
  requestedAt: at, expiresAt: new Date(at.getTime() + HAND_OFF_MS), state: 'waiting', settledAt: null,
42
36
  };
43
37
  await db.insert(seazonaStageMoves).values(row).onConflictDoUpdate({ target: seazonaStageMoves.orderId, set: row });
44
- return handOff(row, { orderUrlTemplate });
38
+ return handOff(row, { settings });
45
39
  }
46
40
 
47
41
  // Take a waiting move back: the card returns to Seazona's stage.
@@ -92,8 +86,11 @@ export async function expiredStageMoves(db, { now = Date.now() } = {}) {
92
86
  ));
93
87
  }
94
88
 
95
- // What the board shows for a waiting move.
96
- export function handOff(row, { orderUrlTemplate = null } = {}) {
89
+ // What the board shows for a waiting move. `link` opens the order in
90
+ // Seazona's web app (settings.js, orderLink); `exact` is false when it opens
91
+ // only the lab's Seazona home page.
92
+ export function handOff(row, { settings = null } = {}) {
93
+ const link = orderLink(settings, { sourceJobRef: row.orderId });
97
94
  return {
98
95
  kind: 'hand_off',
99
96
  system: 'seazona',
@@ -104,7 +101,8 @@ export function handOff(row, { orderUrlTemplate = null } = {}) {
104
101
  state: row.state,
105
102
  requestedAt: row.requestedAt.toISOString(),
106
103
  expiresAt: row.expiresAt.toISOString(),
107
- openUrl: orderUrl(orderUrlTemplate, row.orderId),
104
+ openUrl: link?.url ?? null,
105
+ openExact: Boolean(link?.exact),
108
106
  };
109
107
  }
110
108
 
@@ -2,8 +2,9 @@
2
2
  // GitHub Releases (with npm's dist-tags as the fallback). Used by the Replit
3
3
  // deploy (scripts/deploy-release.js, FLOW_DEPLOY_REF=stable|beta).
4
4
  //
5
- // Release names follow docs/RELEASING.md: vYYYY.M.N is stable, and
6
- // vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N.
5
+ // Release names follow docs/RELEASING.md: vYYYY.M.N is stable,
6
+ // vYYYY.M.N-beta.B is a beta of the upcoming YYYY.M.N, and vYYYY.M.N-C is a
7
+ // stable correction of YYYY.M.N.
7
8
  //
8
9
  // stable GitHub's latest release (what Flow Release Promote points at, so
9
10
  // a rollback is followed), else npm `latest`
@@ -13,7 +14,7 @@
13
14
  // Nothing here sends anything about the installation: each request is an
14
15
  // anonymous GET of public data.
15
16
 
16
- const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*))?$/;
17
+ const TAG = /^v(20\d{2})\.([1-9]|1[0-2])\.([1-9]\d*)(?:-beta\.([1-9]\d*)|-([1-9]\d*))?$/;
17
18
  export const CHANNELS = ["stable", "beta"];
18
19
  export const DEFAULT_GITHUB_API = "https://api.github.com";
19
20
  export const DEFAULT_GITHUB_DOWNLOADS = "https://github.com";
@@ -35,8 +36,9 @@ export function parseReleaseTag(value) {
35
36
  tag,
36
37
  version: tag.slice(1),
37
38
  channel: beta ? "beta" : "stable",
38
- // A stable release sorts after every beta of its own version.
39
- parts: [Number(match[1]), Number(match[2]), Number(match[3]), beta ?? Number.POSITIVE_INFINITY],
39
+ // A stable release sorts after every beta of its own version, and its
40
+ // corrections after it.
41
+ parts: [Number(match[1]), Number(match[2]), Number(match[3]), beta ?? Number.POSITIVE_INFINITY, Number(match[5] ?? 0)],
40
42
  };
41
43
  }
42
44
 
@@ -44,7 +46,7 @@ export function parseReleaseTag(value) {
44
46
  export function compareReleases(a, b) {
45
47
  const [x, y] = [parseReleaseTag(a), parseReleaseTag(b)];
46
48
  if (!x || !y) throw new ReleaseUnavailable(`cannot compare ${a} with ${b}`);
47
- for (let index = 0; index < 4; index += 1) {
49
+ for (let index = 0; index < 5; index += 1) {
48
50
  if (x.parts[index] !== y.parts[index]) return x.parts[index] < y.parts[index] ? -1 : 1;
49
51
  }
50
52
  return 0;
@@ -1,10 +1,13 @@
1
1
  // Settings, Custom fields (/custom-fields; server/core/custom-fields.js,
2
- // docs/CUSTOM_FIELDS.md). The fields a lab makes for its jobs, like ClickUp's
3
- // custom fields: each row is one field, with its type and, for a dropdown,
4
- // its options in lane order. Create field and Edit open a dialog. The work
5
- // systems' own fields (Seazona's stage) show below, read only: Flow reads
6
- // them and never changes them here. Everyone can read the page; an owner or
7
- // admin changes it. The caller passes in app.js's shared pieces as `ui`.
2
+ // docs/CUSTOM_FIELDS.md). The fields a lab makes for its jobs, laid out like
3
+ // ClickUp's Custom Field Manager: one table, with a group per type that folds
4
+ // open and closed, and one row per field with its type icon, who made it,
5
+ // when, and where it applies. Search, a type filter and Filter (archived
6
+ // fields) sit above it. A row's Edit opens the field's dialog (name and
7
+ // options); its ⋯ menu has Rename, Duplicate, Copy field ID and Archive. The
8
+ // work systems' own fields (Seazona's stage) sit in their own group, read
9
+ // only. Everyone can read the page; an owner or admin changes it. The caller
10
+ // passes in app.js's shared pieces as `ui`.
8
11
 
9
12
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
10
13
  const TYPE_LABELS = {
@@ -18,13 +21,44 @@ const TYPE_HINTS = {
18
21
  number: 'A number, for example units.',
19
22
  text: 'A short note.',
20
23
  };
24
+ // Each type's icon (Lucide, served at /assets/icons/) and group colour.
25
+ const TYPE_ICONS = {
26
+ dropdown: 'square-chevron-down', person: 'user-round', checkbox: 'square-check', date: 'calendar', number: 'hash', text: 'type',
27
+ };
28
+ const TYPE_COLORS = { dropdown: 'green', person: 'blue', checkbox: 'purple', date: 'teal', number: 'amber', text: 'gray' };
29
+ const FOLDED_KEY = 'flow_custom_fields_folded';
30
+
31
+ // A Lucide icon as a mask, so it takes the text colour.
32
+ export function lucide(h, name, label = null) {
33
+ return h('span', { class: 'fx-lucide', style: `--icon: url(/assets/icons/${name}.svg)`, ...(label ? { role: 'img', 'aria-label': label, title: label } : { 'aria-hidden': 'true' }) });
34
+ }
35
+
36
+ // "Today", "Yesterday", else d/m/yy, like ClickUp's Date created.
37
+ export function createdDay(iso, now = new Date()) {
38
+ if (!iso) return '';
39
+ const day = new Date(iso);
40
+ const start = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
41
+ const days = Math.round((start(now) - start(day)) / 86_400_000);
42
+ if (days === 0) return 'Today';
43
+ if (days === 1) return 'Yesterday';
44
+ return `${day.getDate()}/${day.getMonth() + 1}/${String(day.getFullYear()).slice(2)}`;
45
+ }
46
+
47
+ const initials = (name) => (name ?? '').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join('') || '?';
48
+
49
+ function readFolded() {
50
+ try { return new Set(JSON.parse(localStorage.getItem(FOLDED_KEY) ?? '[]')); } catch { return new Set(); }
51
+ }
52
+ function saveFolded(set) {
53
+ try { localStorage.setItem(FOLDED_KEY, JSON.stringify([...set])); } catch { /* this browser keeps no state */ }
54
+ }
21
55
 
22
56
  export async function renderCustomFields(main, ui) {
23
57
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
24
58
  const disabled = !['owner', 'admin'].includes(me?.person.role);
25
- const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
26
- const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Create field');
27
- main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.', actions: [addButton] }), host);
59
+ const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
60
+ const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create field');
61
+ main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.' }), host);
28
62
 
29
63
  let data = null;
30
64
 
@@ -39,11 +73,6 @@ export async function renderCustomFields(main, ui) {
39
73
  }
40
74
  }
41
75
 
42
- const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
43
- h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
44
- h('div', { class: 'fx-set__control fx-rule__controls' }, control));
45
- const optionChip = (o) => h('span', { class: 'fx-cf__opt', 'data-color': o.color ?? 'gray' }, o.name);
46
- const typeChip = (type) => h('span', { class: 'fx-chip fx-chip--static' }, TYPE_LABELS[type] ?? type);
47
76
 
48
77
  // The option list editor for a dropdown: one line per option (name,
49
78
  // colour, move up, remove), in lane order.
@@ -119,60 +148,198 @@ export async function renderCustomFields(main, ui) {
119
148
  return load();
120
149
  }
121
150
 
122
- async function move(index, by) {
123
- const ids = data.fields.map((f) => f.id);
124
- [ids[index], ids[index + by]] = [ids[index + by], ids[index]];
125
- if (await send('/api/custom-fields/order', 'PUT', { ids }, null)) await load();
151
+ // ---- The manager: toolbar, then one table of groups ----
152
+ const view = { q: '', types: new Set(), archived: false, desc: false, folded: readFolded(), renaming: null };
153
+ let menuCount = 0;
154
+
155
+ // A popover menu under a button (the Jobs board's toolbar menus work the same way).
156
+ function popMenu(button, build) {
157
+ const id = `fx-cf-menu-${++menuCount}`;
158
+ const panel = h('div', { class: 'fx-pop', id, popover: 'auto', role: 'menu' });
159
+ button.setAttribute('popovertarget', id);
160
+ button.setAttribute('aria-haspopup', 'menu');
161
+ button.setAttribute('aria-expanded', 'false');
162
+ panel.addEventListener('toggle', (event) => {
163
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
164
+ if (event.newState !== 'open') return;
165
+ panel.replaceChildren(...build(() => panel.hidePopover()).filter(Boolean));
166
+ const at = button.getBoundingClientRect();
167
+ panel.style.top = `${Math.round(at.bottom + 6)}px`;
168
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
169
+ });
170
+ return panel;
171
+ }
172
+ const item = (iconName, text, onclick, { disabled: off = false, title = null, role = 'menuitem', checked = null } = {}) => h('button', {
173
+ type: 'button', class: 'fx-pop__item', role, disabled: off, title, onclick, ...(checked === null ? {} : { 'aria-checked': String(checked) }),
174
+ }, h('span', { class: 'fx-pop__tick' }, iconName ? lucide(h, iconName) : null), h('span', {}, text));
175
+
176
+ const search = h('input', { class: 'fx-input fx-cfm__search', type: 'search', placeholder: 'Search fields…', 'aria-label': 'Search fields' });
177
+ search.addEventListener('input', () => { view.q = search.value.trim().toLowerCase(); draw(); });
178
+ const typeButton = h('button', { class: 'fx-chip fx-cfm__tool', type: 'button' });
179
+ const typeMenu = popMenu(typeButton, (close) => [
180
+ ...data.types.map((t) => item(view.types.has(t) ? 'check' : null, TYPE_LABELS[t], () => {
181
+ if (view.types.has(t)) view.types.delete(t); else view.types.add(t);
182
+ close(); draw();
183
+ }, { role: 'menuitemcheckbox', checked: view.types.has(t) })),
184
+ view.types.size ? h('div', { class: 'fx-pop__sep' }) : null,
185
+ view.types.size ? item(null, 'All types', () => { view.types.clear(); close(); draw(); }) : null,
186
+ ]);
187
+ const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
188
+ const filterMenu = popMenu(filterButton, (close) => [
189
+ item(view.archived ? 'check' : null, 'Show archived fields', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
190
+ ]);
191
+ const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
192
+ const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
193
+ host.replaceChildren(toolbar, table);
194
+
195
+ const personName = (id) => data.people?.find((p) => p.id === id)?.name ?? null;
196
+ const matches = (f) => !view.q || f.name.toLowerCase().includes(view.q)
197
+ || (f.options ?? []).some((o) => !o.archived && o.name.toLowerCase().includes(view.q));
198
+ const byName = (a, b) => a.name.localeCompare(b.name) * (view.desc ? -1 : 1);
199
+
200
+ async function rename(field, name) {
201
+ view.renaming = null;
202
+ const clean = name.trim();
203
+ if (clean && clean !== field.name && await send(`/api/custom-fields/${field.id}`, 'PUT', { name: clean }, 'Renamed.')) return load();
204
+ return draw();
205
+ }
206
+
207
+ async function duplicate(field) {
208
+ const taken = new Set(data.fields.map((f) => f.name.toLowerCase()));
209
+ let name = `${field.name} copy`.slice(0, 60);
210
+ for (let n = 2; taken.has(name.toLowerCase()); n += 1) name = `${field.name} copy ${n}`.slice(0, 60);
211
+ const payload = { name, type: field.type };
212
+ if (field.type === 'dropdown') payload.options = field.options.filter((o) => !o.archived).map(({ name: n, color }) => ({ name: n, color }));
213
+ if (await send('/api/custom-fields', 'POST', payload, `Made ${name}.`)) await load();
126
214
  }
127
215
 
128
- function fieldRow(field, index) {
129
- const hint = h('div', { class: 'fx-price__hint' }, typeChip(field.type),
130
- ...(field.type === 'dropdown' ? field.options.filter((o) => !o.archived).map(optionChip) : [h('span', { class: 'fx-set__hint' }, TYPE_HINTS[field.type])]));
131
- const controls = [];
132
- if (index !== null) {
133
- const up = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled: disabled || index === 0, 'aria-label': `Move ${field.name} up` }, '↑');
134
- up.addEventListener('click', () => move(index, -1));
135
- const down = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled: disabled || index === data.fields.length - 1, 'aria-label': `Move ${field.name} down` }, '↓');
136
- down.addEventListener('click', () => move(index, 1));
137
- const editButton = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled }, 'Edit');
138
- editButton.addEventListener('click', () => edit(field));
139
- controls.push(up, down, editButton);
140
- } else {
141
- const back = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', disabled }, 'Bring back');
142
- back.addEventListener('click', async () => {
143
- if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived: false }, 'Brought back.')) await load();
216
+ async function copyId(field) {
217
+ try {
218
+ await navigator.clipboard.writeText(field.id);
219
+ toast('Field ID copied.', 'success');
220
+ } catch {
221
+ toast(`Field ID: ${field.id}`, 'info');
222
+ }
223
+ }
224
+
225
+ async function setArchived(field, archived) {
226
+ if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived }, archived ? 'Archived. Its values are kept.' : 'Brought back.')) await load();
227
+ }
228
+
229
+ function rowMenu(field) {
230
+ const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': `More for ${field.name}` }, lucide(h, 'ellipsis'));
231
+ const panel = popMenu(button, (close) => (field.archived
232
+ ? [item('archive-restore', 'Bring back', () => { close(); setArchived(field, false); }, { disabled })]
233
+ : [
234
+ item('square-pen', 'Edit', () => { close(); edit(field); }, { disabled }),
235
+ item('pencil', 'Rename', () => { close(); view.renaming = field.id; draw(); }, { disabled }),
236
+ item('copy', 'Duplicate', () => { close(); duplicate(field); }, { disabled }),
237
+ item('repeat', 'Convert', null, { disabled: true, title: 'Changing a field\'s type is not in Flow yet.' }),
238
+ item('clipboard-copy', 'Copy field ID', () => { close(); copyId(field); }),
239
+ h('div', { class: 'fx-pop__sep' }),
240
+ item('archive', 'Archive', () => { close(); setArchived(field, true); }, { disabled }),
241
+ ]));
242
+ return [button, panel];
243
+ }
244
+
245
+ function nameCell(field) {
246
+ if (view.renaming === field.id) {
247
+ const input = h('input', { class: 'fx-input fx-cfm__rename', value: field.name, maxlength: '60', 'aria-label': 'Field name' });
248
+ input.addEventListener('keydown', (event) => {
249
+ if (event.key === 'Enter') input.blur();
250
+ if (event.key === 'Escape') { view.renaming = null; draw(); }
144
251
  });
145
- controls.push(back);
252
+ input.addEventListener('blur', () => { if (view.renaming === field.id) rename(field, input.value); });
253
+ queueMicrotask(() => { input.focus(); input.select(); });
254
+ return h('div', { class: 'fx-cfm__name', role: 'cell' }, input);
146
255
  }
147
- return row(field.name, hint, h('div', { class: 'fx-row' }, ...controls));
256
+ const editButton = field.archived || disabled || field.source ? null
257
+ : h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
258
+ return h('div', { class: 'fx-cfm__name', role: 'cell' },
259
+ field.source ? lucide(h, 'lock', 'Read only') : null,
260
+ h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton);
148
261
  }
149
262
 
150
- function systemRow(field) {
151
- const options = field.options.slice(0, 12).map((o) => h('span', { class: 'fx-cf__opt', 'data-color': o.final ? 'green' : 'gray' }, o.name));
152
- if (field.options.length > 12) options.push(h('span', { class: 'fx-set__hint' }, `${field.options.length - 12} more`));
153
- return row(`${field.sourceLabel} ${field.name.toLowerCase()}`, h('div', { class: 'fx-price__hint' }, typeChip(field.type), ...options),
154
- h('span', { class: 'fx-status', 'data-tone': 'mute' }, `Read only · from ${field.sourceLabel}`));
263
+ function fieldRow(field) {
264
+ const by = field.source ? field.sourceLabel : personName(field.createdBy);
265
+ return h('div', { class: 'fx-cfm__row', role: 'row', 'data-archived': field.archived ? 'true' : null },
266
+ nameCell(field),
267
+ h('div', { class: 'fx-cfm__type', role: 'cell', 'data-color': TYPE_COLORS[field.type] }, lucide(h, TYPE_ICONS[field.type], TYPE_LABELS[field.type])),
268
+ h('div', { class: 'fx-cfm__by', role: 'cell' }, by ? h('span', { class: 'fx-cfm__avatar', title: by }, field.source ? lucide(h, 'plug') : initials(by)) : h('span', { class: 'fx-dim' }, '—')),
269
+ h('div', { class: 'fx-cfm__date', role: 'cell' }, field.source ? h('span', { class: 'fx-dim' }, '—') : createdDay(field.createdAt)),
270
+ h('div', { class: 'fx-cfm__where', role: 'cell' }, field.source ? `${field.sourceLabel} jobs` : 'Jobs'),
271
+ h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field)));
272
+ }
273
+
274
+ // One group: a header that folds it, its rows, and "Create … field". A
275
+ // search opens every group that has a match.
276
+ function group(key, pill, rows, { create = null } = {}) {
277
+ const open = Boolean(view.q) || !view.folded.has(key);
278
+ const toggle = h('button', { class: 'fx-cfm__fold', type: 'button', 'aria-expanded': String(open), 'aria-label': `${open ? 'Fold' : 'Open'} ${pill.text}` }, lucide(h, 'chevron-down'));
279
+ toggle.addEventListener('click', () => {
280
+ if (open) view.folded.add(key); else view.folded.delete(key);
281
+ saveFolded(view.folded);
282
+ draw();
283
+ });
284
+ const head = h('div', { class: 'fx-cfm__group', role: 'row' }, toggle,
285
+ h('span', { class: 'fx-cfm__pill', 'data-color': pill.color }, pill.icon ? lucide(h, pill.icon) : null, pill.text),
286
+ h('span', { class: 'fx-cfm__count' }, String(rows.length)));
287
+ if (!open) return [head];
288
+ const out = [head, ...rows.map(fieldRow)];
289
+ if (create) out.push(create);
290
+ return out;
155
291
  }
156
292
 
157
293
  function draw() {
158
- const archived = data.archived.filter((f) => f.archived);
159
- const sections = [
160
- disabled ? notice('Only an owner or admin can change these. Everyone can set a job\'s values.', 'info') : null,
161
- h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `Your fields · ${data.fields.length}`),
162
- ...(data.fields.length ? data.fields.map(fieldRow)
163
- : [h('p', { class: 'fx-dim' }, 'No fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.')])),
164
- ];
165
- if (data.systemFields.length) {
166
- sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'From integrations'),
167
- ...data.systemFields.map(systemRow),
168
- h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, 'Flow reads these. Change them in their own system.')));
294
+ if (!data) return;
295
+ typeButton.replaceChildren(lucide(h, 'layers'), h('span', {}, view.types.size === 1 ? TYPE_LABELS[[...view.types][0]] : view.types.size ? `${view.types.size} types` : 'Field type'), lucide(h, 'chevron-down'));
296
+ typeButton.classList.toggle('fx-chip--on', view.types.size > 0);
297
+ filterButton.replaceChildren(lucide(h, 'list-filter'), h('span', {}, view.archived ? 'Archived shown' : 'Filter'), lucide(h, 'chevron-down'));
298
+ filterButton.classList.toggle('fx-chip--on', view.archived);
299
+
300
+ const shown = (type) => !view.types.size || view.types.has(type);
301
+ const sortHead = h('button', { class: 'fx-cfm__sort', type: 'button', 'aria-label': `Sort by name, ${view.desc ? 'Z to A' : 'A to Z'}` }, 'Name', lucide(h, view.desc ? 'arrow-up-z-a' : 'arrow-down-a-z'));
302
+ sortHead.addEventListener('click', () => { view.desc = !view.desc; draw(); });
303
+ const parts = [h('div', { class: 'fx-cfm__head', role: 'row' },
304
+ h('div', { role: 'columnheader' }, sortHead), h('div', { role: 'columnheader' }, 'Type'), h('div', { role: 'columnheader', class: 'fx-cfm__by' }, 'Created by'),
305
+ h('div', { role: 'columnheader', class: 'fx-cfm__date' }, 'Date created'), h('div', { role: 'columnheader', class: 'fx-cfm__where' }, 'Location'), h('div', { role: 'columnheader' }))];
306
+ if (disabled) parts.unshift(notice('Only an owner or admin can change these. Everyone can set a job\'s values.', 'info'));
307
+
308
+ // Type groups in ClickUp's order: by type name.
309
+ const types = [...data.types].sort((a, b) => TYPE_LABELS[a].localeCompare(TYPE_LABELS[b]));
310
+ let count = 0;
311
+ for (const type of types) {
312
+ if (!shown(type)) continue;
313
+ const fields = data.fields.filter((f) => f.type === type && matches(f)).sort(byName);
314
+ if (!fields.length && (view.q || view.types.size === 0)) continue;
315
+ count += fields.length;
316
+ const create = view.q || disabled ? null : h('button', { class: 'fx-cfm__create', type: 'button', onclick: () => edit(null, { type, name: '', options: undefined }) },
317
+ lucide(h, 'plus'), `Create ${TYPE_LABELS[type].toLowerCase()} field`);
318
+ parts.push(...group(type, { text: TYPE_LABELS[type], icon: TYPE_ICONS[type], color: TYPE_COLORS[type] }, fields, { create }));
319
+ }
320
+ // The work systems' own fields: one read-only group per system.
321
+ for (const source of [...new Set(data.systemFields.map((f) => f.source))]) {
322
+ const fields = data.systemFields.filter((f) => f.source === source && shown(f.type))
323
+ .map((f) => ({ ...f, id: `${f.source}:${f.name}`, name: `${f.sourceLabel} ${f.name.toLowerCase()}` })).filter(matches);
324
+ if (!fields.length) continue;
325
+ count += fields.length;
326
+ parts.push(...group(`source:${source}`, { text: `From ${fields[0].sourceLabel}`, icon: 'lock', color: 'gray' }, fields));
327
+ }
328
+ if (view.archived) {
329
+ const fields = data.archived.filter((f) => f.archived && shown(f.type) && matches(f)).sort(byName);
330
+ count += fields.length;
331
+ parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
169
332
  }
170
- if (archived.length) {
171
- sections.push(h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, `Archived · ${archived.length}`), ...archived.map((f) => fieldRow(f, null))));
333
+ if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No field matches.'));
334
+ if (!data.fields.length && !view.q && !view.types.size) {
335
+ parts.push(h('p', { class: 'fx-cfm__empty' }, 'No fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
172
336
  }
173
- sections.push(h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work')));
174
- host.replaceChildren(...sections.filter(Boolean));
337
+ table.replaceChildren(...parts);
338
+ foot.replaceChildren(h('span', {}, `${data.fields.length} ${data.fields.length === 1 ? 'field' : 'fields'}`),
339
+ h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
175
340
  }
341
+ const foot = h('div', { class: 'fx-cfm__foot' });
342
+ host.append(foot);
176
343
 
177
344
  async function load() {
178
345
  try {