@arcforgelabs/flow 2026.10.52 → 2026.10.53

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.52",
3
+ "version": "2026.10.53",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@arcforgelabs/flow",
9
- "version": "2026.10.52",
9
+ "version": "2026.10.53",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@clerk/backend": "3.25.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcforgelabs/flow",
3
- "version": "2026.10.52",
3
+ "version": "2026.10.53",
4
4
  "description": "Invoicing, bill entry and a financial dashboard for labs, with Seazona and Xero integrations.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,7 +3,8 @@ import { and, desc, eq, gt, isNotNull, isNull, lt, or, sql } from 'drizzle-orm';
3
3
  import { CAPTURE_DECISIONS, captures } from '../db/schema.js';
4
4
  import { FlowError, notFound } from './errors.js';
5
5
  import { matchJobs, proposeChange, readDate } from './capture-match.js';
6
- import { listFields, setJobFieldValue } from './custom-fields.js';
6
+ import { listFields, listJobFieldValues, setJobFieldValue } from './custom-fields.js';
7
+ import { JOB_FIELDS } from '../ui/capture-fields.js';
7
8
 
8
9
  // Shown when the module is off or the keys are missing. Type it still works.
9
10
  const READER_OFF = 'reader turned off';
@@ -253,7 +254,7 @@ export async function matchCapture(db, storage, organisationId, id, { jobs, fiel
253
254
  state: 'ready',
254
255
  problem: null,
255
256
  match,
256
- ...(replace ? { proposal: proposeChange(read, match, jobs) } : {}),
257
+ ...(replace ? { proposal: await toCustomFields(db, organisationId, proposeChange(read, match, jobs)) } : {}),
257
258
  updatedAt: sql`now()`,
258
259
  }).where(and(
259
260
  eq(captures.organisationId, organisationId),
@@ -305,14 +306,43 @@ function fieldValue(field, text) {
305
306
  }
306
307
  }
307
308
 
309
+ const jobOf = (jobId) => {
310
+ const at = jobId.indexOf(':');
311
+ return { sourceSystem: jobId.slice(0, at), sourceJobRef: jobId.slice(at + 1) };
312
+ };
313
+
314
+ // A custom field's stored value as the sheet shows it ("from").
315
+ function shownValue(field, value) {
316
+ if (value == null) return '';
317
+ if (field.type === 'dropdown') return field.options?.find((option) => option.id === value)?.name ?? '';
318
+ if (field.type === 'checkbox') return value ? 'Yes' : 'No';
319
+ return String(value);
320
+ }
321
+
322
+ // A read field goes to the lab's custom field of the same name ("Shade")
323
+ // when there is one: Flow owns it, so Apply writes it. Any other field stays
324
+ // the work system's; the sheet marks it to change there (maintainer, #357).
325
+ async function toCustomFields(db, organisationId, proposal) {
326
+ if (proposal.action !== 'update') return proposal;
327
+ const byName = new Map((await listFields(db, organisationId)).map((field) => [field.name.trim().toLowerCase(), field]));
328
+ const labels = new Map(JOB_FIELDS.map((field) => [field.key, field.label.toLowerCase()]));
329
+ const values = proposal.jobId
330
+ ? new Map((await listJobFieldValues(db, organisationId, { jobs: [jobOf(proposal.jobId)] })).map((row) => [row.fieldId, row.value]))
331
+ : new Map();
332
+ const changes = proposal.changes.map((change) => {
333
+ const field = byName.get(labels.get(change.field));
334
+ return field ? { ...change, field: `custom_${field.id}`, from: shownValue(field, values.get(field.id)) } : change;
335
+ }).filter((change) => change.from !== change.to);
336
+ return { ...proposal, changes };
337
+ }
338
+
308
339
  // Apply writes what Flow owns: the job's custom fields, each with its own
309
340
  // event (who, when, from and to). A field the work system owns (due date,
310
341
  // shade) stays there; the result sheet links to the job in that system.
311
342
  // Every value is checked before the first write, so a bad one writes none.
312
343
  async function applyToJob(db, organisationId, proposal, personId) {
313
344
  if (proposal?.action !== 'update' || !proposal.jobId) return;
314
- const at = proposal.jobId.indexOf(':');
315
- const job = { sourceSystem: proposal.jobId.slice(0, at), sourceJobRef: proposal.jobId.slice(at + 1) };
345
+ const job = jobOf(proposal.jobId);
316
346
  const fields = new Map((await listFields(db, organisationId)).map((field) => [`custom_${field.id}`, field]));
317
347
  const writes = proposal.changes
318
348
  .filter((change) => fields.has(change.field))
@@ -180,8 +180,14 @@ export async function renderCaptures(main, ui) {
180
180
  ? `${job.caseRef ? `Job ${job.caseRef}` : job.jobId} · ${job.clientName ?? ''}`
181
181
  : proposal.action === 'create' ? 'New job' : 'Pick a job';
182
182
 
183
+ // A field the work system owns: Apply leaves it; the person changes it
184
+ // there (until a two-way sync can send it, #357).
185
+ const system = proposal.action === 'update' && proposal.jobId ? proposal.jobId.split(':')[0] : null;
186
+ const systemName = system ? system[0].toUpperCase() + system.slice(1) : null;
187
+ const elsewhere = (change) => systemName && !change.field.startsWith('custom_');
183
188
  const changeRow = (change) => h('div', { class: 'fx-capture__change', 'data-unsure': change.sure != null && change.sure < SURE_LINE ? 'true' : 'false' },
184
- h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field),
189
+ h('span', { class: 'fx-capture__field' }, labels.get(change.field) ?? change.field,
190
+ elsewhere(change) ? h('span', { class: 'fx-capture__where', title: `Change this in ${systemName}` }, systemName) : null),
185
191
  h('span', { class: 'fx-capture__from' }, change.from || '–'),
186
192
  h('span', { class: 'fx-capture__arrow', 'aria-hidden': 'true' }, '→'),
187
193
  h('span', { class: 'fx-capture__to' }, change.to));
@@ -111,7 +111,7 @@ export async function renderCustomFields(main, ui) {
111
111
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
112
112
  const disabled = !['owner', 'admin'].includes(me?.person.role);
113
113
  const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
114
- const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create custom field');
114
+ const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled, title: 'New custom field' }, lucide(h, 'plus'), 'New');
115
115
  main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
116
116
 
117
117
  let data = null;
@@ -309,7 +309,7 @@ export async function renderCustomFields(main, ui) {
309
309
  ]);
310
310
  const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
311
311
  const filterMenu = popMenu(filterButton, (close) => [
312
- item(view.archived ? 'check' : null, 'Show archived custom fields', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
312
+ item(view.archived ? 'check' : null, 'Archived', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
313
313
  ]);
314
314
  const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
315
315
  const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
@@ -505,15 +505,9 @@ export async function renderCustomFields(main, ui) {
505
505
  parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
506
506
  }
507
507
  if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom field matches.'));
508
- if (!data.fields.length && !view.q && !view.types.size) {
509
- parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields yet. Create one, for example Priority or Technician, to group the Jobs board by it.'));
510
- }
508
+ else if (!count) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom fields'));
511
509
  table.replaceChildren(...parts);
512
- // The count is what the table shows, the work systems' fields too.
513
- foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
514
510
  }
515
- const foot = h('div', { class: 'fx-cfm__foot' });
516
- host.append(foot);
517
511
 
518
512
  async function load() {
519
513
  try {
@@ -1093,6 +1093,7 @@ th:hover > .fx-th-grip::after { background: var(--t-brd-hi); }
1093
1093
  min-height: 44px; padding: 4px 8px; border-bottom: 1px solid var(--fx-line-soft);
1094
1094
  }
1095
1095
  .fx-capture__field { color: var(--t-dim); }
1096
+ .fx-capture__where { display: block; font-size: 12px; color: var(--t-dim); }
1096
1097
  .fx-capture__from { color: var(--t-dim); text-decoration: line-through; overflow-wrap: anywhere; }
1097
1098
  .fx-capture__arrow { color: var(--t-dim); }
1098
1099
  .fx-capture__to { color: var(--t-strong); font-weight: 600; overflow-wrap: anywhere; }
@@ -1682,7 +1683,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1682
1683
  .fx-cfm__create .fx-lucide { width: 14px; height: 14px; }
1683
1684
  .fx-cfm__empty { margin: 0; padding: 20px 16px; color: var(--t-dim); }
1684
1685
  .fx-cfm > .fx-notice { margin: 12px 16px 0; }
1685
- .fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
1686
1686
  .fx-pop__item:disabled { opacity: .5; cursor: default; }
1687
1687
  .fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
1688
1688
  .fx-pop__item .fx-lucide { width: 15px; height: 15px; }
@@ -1759,6 +1759,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
1759
1759
  .fx-cfm__head, .fx-cfm__row { padding-left: 16px; }
1760
1760
  .fx-cfm__create { padding-left: 16px; }
1761
1761
  .fx-cfm__search { flex: 1 1 100%; width: auto; }
1762
+ .fx-cfm__bar :is(.fx-cfm__search, .fx-cfm__tool, .fx-btn) { height: auto; min-height: 44px; }
1762
1763
  .fx-cfm__edit { display: none !important; }
1763
1764
  /* Custom fields uses the same phone slots as a column list: name and the
1764
1765
  menu on line 1, up to three facts on line 2, 56px. */