@arcforgelabs/flow 2026.10.42 → 2026.10.44

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.
Files changed (40) hide show
  1. package/README.md +1 -0
  2. package/npm-shrinkwrap.json +4 -3
  3. package/package.json +5 -3
  4. package/server/auth/people.js +40 -2
  5. package/server/auth/routes.js +11 -0
  6. package/server/auth/session.js +22 -3
  7. package/server/core/custom-fields.js +4 -3
  8. package/server/core/invoice-rules.js +8 -3
  9. package/server/core/job-match.js +6 -2
  10. package/server/core/sending.js +4 -3
  11. package/server/integrations/sentry/index.js +9 -1
  12. package/server/release-channel.js +5 -2
  13. package/server/ui/app-page.js +1 -1
  14. package/server/ui/app.js +46 -162
  15. package/server/ui/automations.js +9 -3
  16. package/server/ui/columns.js +268 -0
  17. package/server/ui/custom-fields.js +17 -16
  18. package/server/ui/flow.css +268 -55
  19. package/server/ui/invoice-notes.js +7 -5
  20. package/server/ui/invoice-rules.js +3 -3
  21. package/server/ui/invoicing-settings.js +92 -35
  22. package/server/ui/invoicing.js +206 -119
  23. package/server/ui/job-board.js +20 -8
  24. package/server/ui/job-change.js +36 -0
  25. package/server/ui/job-view.js +255 -60
  26. package/server/ui/jobs.js +197 -145
  27. package/server/ui/kit.js +278 -0
  28. package/server/ui/list-views.js +73 -0
  29. package/server/ui/monitoring.js +1 -0
  30. package/server/ui/person.js +29 -0
  31. package/server/ui/price-list.js +2 -3
  32. package/server/ui/price-reconcile.js +7 -8
  33. package/server/ui/reconcile.js +114 -61
  34. package/server/ui/routes.js +1 -1
  35. package/server/ui/saved-views.js +141 -0
  36. package/server/ui/search.js +8 -0
  37. package/server/ui/sidecar.js +72 -0
  38. package/server/ui/tip.js +99 -0
  39. package/server/ui/view-bar.js +176 -70
  40. package/server/ui/xero-import.js +7 -10
@@ -0,0 +1,268 @@
1
+ // A list's columns, as ClickUp's: drag a head's right edge to make the
2
+ // column wider or narrower (never under its kind's minimum), drag a head
3
+ // to move the column, and click or right-click a head for its menu (Move to
4
+ // start, Move to end, Hide; + shows a hidden column again). Jobs, Invoices
5
+ // and Reconcile build their tables here, so every list works the same.
6
+ //
7
+ // The view a person keeps for a list (with their account, PUT
8
+ // /api/me/views/:page) holds two keys from here:
9
+ // columns the shown columns' ids, in order
10
+ // widths { [id]: px } for the columns the person made wider or narrower
11
+ // The rules (no DOM) are in job-view.js ("Columns"), so the server checks a
12
+ // saved view with them (server/auth/people.js) and tests read them.
13
+ //
14
+ // columnTable(h, config), once per page, answers { table, menu, openMenu }:
15
+ // order() the shown columns' ids, as the view keeps them
16
+ // widths() the saved widths
17
+ // change(patch) saves { columns } or { widths }
18
+ // hidden() [{ id, label }] the + menu lists (no hidden: no +)
19
+ // extra(column, item) menu nodes before Move (Sort, Edit custom field)
20
+ // table({ columns, rows, lead, className }), per draw, answers the table:
21
+ // columns the shown columns, in order: { id, label, kind, cell(row),
22
+ // num?, className?, sort?(): node, icon?, fixed? }. An icon
23
+ // shows in place of the label (the label is its name). A fixed
24
+ // column keeps its place: no drag, no Move, no Hide.
25
+ // rows the <tbody> nodes (rowCells builds a row's cells)
26
+ // lead <th> nodes before the columns (the tick column)
27
+ import { lucide } from '/assets/ui/custom-fields.js';
28
+ import { MAX_WIDTH, minWidth, moveColumn, widthOf } from '/assets/ui/job-view.js';
29
+
30
+ // The tick column at the start and the + column at the end.
31
+ const LEAD_WIDTH = 44;
32
+ const TAIL_WIDTH = 40;
33
+
34
+ // A row's cells for the shown columns: a class per kind, the label for
35
+ // the phone layout, the full text as the cell's name on hover.
36
+ export function rowCells(h, columns, row) {
37
+ return columns.map((c) => {
38
+ const td = h('td', {
39
+ class: [`fx-c-${c.kind}`, c.num ? 'fx-num' : null, c.className].filter(Boolean).join(' '),
40
+ 'data-label': c.label,
41
+ }, c.cell(row));
42
+ // A cell stays on one line and ends in "…" when the column is narrow
43
+ // (flow.css, .fx-table--sized); the whole text shows on hover.
44
+ const text = td.textContent?.trim();
45
+ if (text && text !== '—') td.title = text;
46
+ return td;
47
+ });
48
+ }
49
+
50
+ // ── The table ──────────────────────────────────────────────────────────────
51
+
52
+ // One menu for every column head, as a popover: made at the first open and
53
+ // kept for every page after.
54
+ function columnPopover(h) {
55
+ let pop = null;
56
+ const close = () => pop?.hidePopover?.();
57
+ const item = (icon, text, run) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { close(); run(); } },
58
+ h('span', { class: 'fx-pop__tick' }, lucide(h, icon)), h('span', {}, text));
59
+ const sep = () => h('div', { class: 'fx-pop__sep' });
60
+ // Open with these nodes, at the pointer or under the node clicked.
61
+ function show(parts, event) {
62
+ pop = document.getElementById('fx-colmenu');
63
+ if (!pop) {
64
+ pop = h('div', { class: 'fx-pop', id: 'fx-colmenu', popover: 'auto', role: 'menu' });
65
+ document.body.append(pop);
66
+ }
67
+ pop.replaceChildren(...parts);
68
+ pop.showPopover?.();
69
+ const box = event.currentTarget?.getBoundingClientRect?.();
70
+ const x = event.clientX || box?.left || 0;
71
+ const y = event.clientY || box?.bottom || 0;
72
+ pop.style.top = `${Math.round(Math.min(y + 4, innerHeight - pop.offsetHeight - 8))}px`;
73
+ pop.style.left = `${Math.round(Math.max(8, Math.min(x, innerWidth - pop.offsetWidth - 8)))}px`;
74
+ }
75
+ return { close, item, sep, show };
76
+ }
77
+
78
+ // A popover opened while the right button is still down closes again on
79
+ // its release (light dismiss), so open it after the release.
80
+ export function onRightButton(event, open) {
81
+ if (event.buttons & 2) addEventListener('pointerup', () => setTimeout(open), { once: true });
82
+ else open();
83
+ }
84
+
85
+ export function columnTable(h, config) {
86
+ const menu = columnPopover(h);
87
+ const ids = () => config.order?.() ?? [];
88
+ const widths = () => config.widths?.() ?? {};
89
+
90
+ function openMenu(column, event) {
91
+ const parts = [];
92
+ if (column) {
93
+ parts.push(...(config.extra?.(column, menu.item) ?? []));
94
+ if (!column.fixed) {
95
+ const order = ids();
96
+ parts.push(menu.item('arrow-left-to-line', 'Move to start', () => config.change({ columns: moveColumn(order, column.id, 0) })),
97
+ menu.item('arrow-right-to-line', 'Move to end', () => config.change({ columns: moveColumn(order, column.id, order.length) })));
98
+ if (config.hidden) parts.push(menu.sep(), menu.item('eye-off', 'Hide column', () => config.change({ columns: order.filter((id) => id !== column.id) })));
99
+ }
100
+ if (widths()[column.id] != null) {
101
+ parts.push(menu.item('move-horizontal', 'Reset width', () => config.change({ widths: withoutKey(widths(), column.id) })));
102
+ }
103
+ } else {
104
+ const hidden = config.hidden?.() ?? [];
105
+ parts.push(...hidden.map((c) => h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitem', onclick: () => { menu.close(); config.change({ columns: [...ids(), c.id] }); } }, c.label)));
106
+ if (!hidden.length) parts.push(h('p', { class: 'fx-pop__head' }, 'Every column shows'));
107
+ }
108
+ if (parts.length) menu.show(parts, event);
109
+ }
110
+
111
+ function table({ columns, rows, lead = [], className = '' }) {
112
+ const saved = widths();
113
+ // Each column keeps its own width: the table is as wide as its columns,
114
+ // never stretched to the page. Space left over stays empty at the right.
115
+ // A resize changes the table only; the tools stay put.
116
+ const cols = columns.map((c) => h('col', { style: { width: `${widthOf(c, saved)}px` } }));
117
+ const leadCols = lead.map(() => h('col', { style: { width: `${LEAD_WIDTH}px` } }));
118
+ const tail = config.hidden ? h('th', { class: 'fx-th-add' }, h('button', {
119
+ type: 'button', class: 'fx-th fx-th--add', 'aria-label': 'Show a column', title: 'Show a column', onclick: (event) => openMenu(null, event),
120
+ }, '+')) : h('th', { class: 'fx-th-end', 'aria-hidden': 'true' });
121
+ const heads = columns.map((c, n) => head(c, n));
122
+ const node = h('table', { class: `fx-table fx-table--sized ${className}`.trim() },
123
+ h('colgroup', {}, leadCols, cols, h('col', { style: { width: `${TAIL_WIDTH}px` } })),
124
+ h('thead', {}, h('tr', {}, lead, heads, tail)),
125
+ rows);
126
+ // The table is as wide as its columns; past the page it scrolls sideways.
127
+ const fit = () => { node.style.width = `${columns.reduce((sum, c) => sum + widthOf(c, widths()), 0) + lead.length * LEAD_WIDTH + TAIL_WIDTH}px`; };
128
+ fit();
129
+ return h('div', { class: 'fx-table-scroll' }, node);
130
+
131
+ function head(c, n) {
132
+ const button = h('button', {
133
+ type: 'button', class: c.fixed ? 'fx-th fx-th--fixed' : 'fx-th', 'aria-haspopup': 'menu',
134
+ title: c.fixed ? c.label : `${c.label}. Drag to move, click for the menu`,
135
+ onclick: (event) => { if (!dragged) openMenu(c, event); },
136
+ }, c.icon ? lucide(h, c.icon, c.label) : c.label);
137
+ const grip = h('span', {
138
+ class: 'fx-th-grip', role: 'separator', tabindex: '0', 'aria-orientation': 'vertical',
139
+ 'aria-label': `${c.label} width`, title: 'Drag to resize. Double-click to reset',
140
+ 'aria-valuemin': String(minWidth(c.kind)), 'aria-valuemax': String(MAX_WIDTH), 'aria-valuenow': String(widthOf(c, widths())),
141
+ });
142
+ const th = h('th', {
143
+ class: [c.num ? 'fx-num' : null, c.fixed ? null : 'fx-th--drag'].filter(Boolean).join(' ') || null,
144
+ 'data-col': c.id,
145
+ oncontextmenu: (event) => { event.preventDefault(); onRightButton(event, () => openMenu(c, event)); },
146
+ }, h('span', { class: 'fx-th-cell' }, button, c.sort?.() ?? null), grip);
147
+ resizer(grip, c, n);
148
+ if (!c.fixed) dragger(button, th, c);
149
+ return th;
150
+ }
151
+
152
+ // Drag the edge: the column follows the pointer, kept to its minimum;
153
+ // the width saves on release. Arrow keys step it; a double-click resets.
154
+ function resizer(grip, c, n) {
155
+ const col = cols[n];
156
+ const set = (px) => {
157
+ const w = Math.round(Math.min(MAX_WIDTH, Math.max(minWidth(c.kind), px)));
158
+ col.style.width = `${w}px`;
159
+ grip.setAttribute('aria-valuenow', String(w));
160
+ return w;
161
+ };
162
+ const save = (w) => config.change({ widths: { ...widths(), [c.id]: w } });
163
+ grip.addEventListener('pointerdown', (event) => {
164
+ if (event.button !== 0) return;
165
+ event.preventDefault();
166
+ event.stopPropagation();
167
+ grip.setPointerCapture(event.pointerId);
168
+ const from = event.clientX;
169
+ const start = col.getBoundingClientRect().width || widthOf(c, widths());
170
+ const base = node.getBoundingClientRect().width;
171
+ let w = start;
172
+ node.dataset.resizing = '';
173
+ const move = (e) => {
174
+ w = set(start + e.clientX - from);
175
+ node.style.width = `${base + w - start}px`;
176
+ };
177
+ const up = () => {
178
+ grip.removeEventListener('pointermove', move);
179
+ delete node.dataset.resizing;
180
+ if (Math.round(w) !== Math.round(start)) save(w);
181
+ };
182
+ grip.addEventListener('pointermove', move);
183
+ grip.addEventListener('pointerup', up, { once: true });
184
+ grip.addEventListener('pointercancel', up, { once: true });
185
+ });
186
+ grip.addEventListener('click', (event) => event.stopPropagation());
187
+ grip.addEventListener('dblclick', (event) => {
188
+ event.stopPropagation();
189
+ config.change({ widths: withoutKey(widths(), c.id) });
190
+ });
191
+ grip.addEventListener('keydown', (event) => {
192
+ const step = event.key === 'ArrowRight' ? 16 : event.key === 'ArrowLeft' ? -16 : 0;
193
+ if (!step) return;
194
+ event.preventDefault();
195
+ save(set(widthOf(c, widths()) + step));
196
+ });
197
+ }
198
+ }
199
+
200
+ // Drag a head: past a few pixels it lifts, a line shows where it lands,
201
+ // and the release moves it there. A click without a drag opens the menu.
202
+ let dragged = false;
203
+ function dragger(button, th, c) {
204
+ button.addEventListener('pointerdown', (event) => {
205
+ if (event.button !== 0) return;
206
+ const row = th.parentElement;
207
+ const from = event.clientX;
208
+ let target = null;
209
+ dragged = false;
210
+ const heads = () => [...row.querySelectorAll('th.fx-th--drag')];
211
+ const clear = () => { for (const other of row.children) other.removeAttribute('data-drop'); };
212
+ const move = (e) => {
213
+ if (!dragged && Math.abs(e.clientX - from) < 6) return;
214
+ if (!dragged) {
215
+ dragged = true;
216
+ button.setPointerCapture(event.pointerId);
217
+ th.dataset.dragging = '';
218
+ }
219
+ clear();
220
+ target = null;
221
+ for (const other of heads()) {
222
+ const box = other.getBoundingClientRect();
223
+ if (e.clientX >= box.left && e.clientX < box.right && other !== th) {
224
+ const side = e.clientX < box.left + box.width / 2 ? 'before' : 'after';
225
+ other.dataset.drop = side;
226
+ target = { id: other.dataset.col, side };
227
+ }
228
+ }
229
+ };
230
+ const up = () => {
231
+ removeEventListener('pointermove', move);
232
+ delete th.dataset.dragging;
233
+ clear();
234
+ if (dragged && target) {
235
+ const order = ids().filter((id) => id !== c.id);
236
+ const at = order.indexOf(target.id) + (target.side === 'after' ? 1 : 0);
237
+ config.change({ columns: moveColumn(ids(), c.id, at) });
238
+ }
239
+ // The click that follows a drag does not open the menu.
240
+ setTimeout(() => { dragged = false; });
241
+ };
242
+ addEventListener('pointermove', move);
243
+ addEventListener('pointerup', up, { once: true });
244
+ addEventListener('pointercancel', up, { once: true });
245
+ });
246
+ }
247
+
248
+ return { table, menu, openMenu };
249
+ }
250
+
251
+ function withoutKey(object, key) {
252
+ const { [key]: _gone, ...rest } = object;
253
+ return rest;
254
+ }
255
+
256
+ // Saves a page's view with the person's account (PUT /api/me/views/:page)
257
+ // shortly after a change, so a few quick changes make one request.
258
+ export function viewSaver(ui, page) {
259
+ let timer = null;
260
+ return (view) => {
261
+ if (ui.me) ui.me.views = { ...ui.me.views, [page]: view };
262
+ clearTimeout(timer);
263
+ timer = setTimeout(() => {
264
+ ui.flow(`/api/me/views/${page}`, { method: 'PUT', body: JSON.stringify(view) })
265
+ .catch(() => ui.toast?.('Flow could not save your view. It applies on this page only.', 'danger'));
266
+ }, 500);
267
+ };
268
+ }
@@ -14,6 +14,7 @@
14
14
  // passes in app.js's shared pieces as `ui`.
15
15
 
16
16
  import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
17
+ import { sidecar } from '/assets/ui/sidecar.js';
17
18
 
18
19
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
19
20
  const TYPE_LABELS = {
@@ -96,7 +97,7 @@ export async function renderCustomFields(main, ui) {
96
97
  const disabled = !['owner', 'admin'].includes(me?.person.role);
97
98
  const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
98
99
  const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create custom field');
99
- main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.' }), host);
100
+ main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
100
101
 
101
102
  let data = null;
102
103
  const groupColor = (type) => data?.groupColors?.[type] ?? 'gray';
@@ -157,17 +158,19 @@ export async function renderCustomFields(main, ui) {
157
158
  // the manager. Edit on a row (or Create) opens it; another row's Edit
158
159
  // switches it at once, so a person moves between fields quickly. The
159
160
  // table stays usable behind it. Save closes it; so do Cancel, × and Esc.
160
- const side = h('aside', { class: 'fx-cfs', 'aria-label': 'Custom field', hidden: true });
161
- let sideFor = null;
162
- function closeSide() {
163
- side.hidden = true;
164
- side.replaceChildren();
165
- sideFor = null;
166
- table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
167
- }
168
- document.addEventListener('keydown', (event) => {
169
- if (event.key === 'Escape' && side.isConnected && !side.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) closeSide();
161
+ // The shared sidecar (sidecar.js), over the table.
162
+ const panel = sidecar(h, {
163
+ label: 'Custom field', overlay: true, escape: true,
164
+ onChange: (tab) => {
165
+ if (tab !== null) return;
166
+ sideFor = null;
167
+ table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
168
+ },
170
169
  });
170
+ const side = panel.element;
171
+ side.classList.add('fx-cfs');
172
+ let sideFor = null;
173
+ const closeSide = () => panel.close();
171
174
 
172
175
  // Open the sidecar for a field (null: a new one; `draft` fills it after a
173
176
  // failed save). A work system's field opens read only.
@@ -188,7 +191,7 @@ export async function renderCustomFields(main, ui) {
188
191
  // lies over the label, so the label shows and the browser's list opens.
189
192
  const typeText = h('span', {}, TYPE_LABELS[type.value]);
190
193
  const headType = field ? typeLabel() : h('label', { class: 'fx-cfs__typepick' }, lucide(h, TYPE_ICONS[type.value]), typeText, lucide(h, 'chevron-down'), type);
191
- const close = h('button', { class: 'fx-cfs__close', type: 'button', 'aria-label': 'Close', onclick: closeSide }, lucide(h, 'x'));
194
+ const close = panel.closeButton();
192
195
 
193
196
  const name = h('input', { class: 'fx-cfs__name', required: true, maxlength: '60', value: draft?.name ?? '', 'aria-label': 'Custom field name', placeholder: 'For example Priority', readonly: readOnly });
194
197
  name.addEventListener('input', changed);
@@ -245,10 +248,9 @@ export async function renderCustomFields(main, ui) {
245
248
  await load();
246
249
  });
247
250
  fit();
248
- side.replaceChildren(form);
251
+ panel.show(form);
249
252
  // Below the toolbar, so search and Create stay in reach.
250
253
  side.style.top = `${toolbar.offsetHeight}px`;
251
- side.hidden = false;
252
254
  table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
253
255
  if (field) table.querySelector(`.fx-cfm__row[data-id="${CSS.escape(field.id)}"]`)?.setAttribute('data-open', 'true');
254
256
  (readOnly ? close : name).focus({ preventScroll: true });
@@ -493,8 +495,7 @@ export async function renderCustomFields(main, ui) {
493
495
  }
494
496
  table.replaceChildren(...parts);
495
497
  // The count is what the table shows, the work systems' fields too.
496
- foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`),
497
- h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
498
+ foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
498
499
  }
499
500
  const foot = h('div', { class: 'fx-cfm__foot' });
500
501
  host.append(foot);