@arcforgelabs/flow 2026.10.33 → 2026.10.35

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 (43) hide show
  1. package/README.md +7 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +2 -1
  4. package/server/api/custom-fields.js +115 -0
  5. package/server/api/invoicing.js +43 -12
  6. package/server/api/work-moves.js +80 -0
  7. package/server/app.js +12 -0
  8. package/server/auth/people.js +35 -0
  9. package/server/auth/routes.js +14 -0
  10. package/server/core/custom-fields.js +308 -0
  11. package/server/core/job-board.js +4 -0
  12. package/server/core/ledger.js +82 -2
  13. package/server/core/tracing.js +64 -0
  14. package/server/db/migrations/0018_custom_fields.sql +78 -0
  15. package/server/db/migrations/0019_people_views.sql +1 -0
  16. package/server/db/migrations/meta/0018_snapshot.json +4289 -0
  17. package/server/db/migrations/meta/0019_snapshot.json +4295 -0
  18. package/server/db/migrations/meta/_journal.json +14 -0
  19. package/server/db/schema.js +64 -1
  20. package/server/index.js +6 -1
  21. package/server/integrations/port.js +8 -1
  22. package/server/integrations/seazona/index.js +60 -2
  23. package/server/integrations/seazona/migrations/0007_stage_moves.sql +18 -0
  24. package/server/integrations/seazona/migrations/meta/0007_snapshot.json +1519 -0
  25. package/server/integrations/seazona/migrations/meta/_journal.json +7 -0
  26. package/server/integrations/seazona/schema.js +26 -0
  27. package/server/integrations/seazona/settings.js +35 -0
  28. package/server/integrations/seazona/stage-moves.js +133 -0
  29. package/server/integrations/seazona/tables.js +2 -0
  30. package/server/integrations/sentry/index.js +61 -10
  31. package/server/release-channel.js +8 -6
  32. package/server/ui/app-page.js +1 -1
  33. package/server/ui/app.js +40 -2
  34. package/server/ui/custom-fields.js +190 -0
  35. package/server/ui/flow.css +128 -10
  36. package/server/ui/invoice-notes.js +1 -1
  37. package/server/ui/invoicing.js +185 -48
  38. package/server/ui/job-board.js +1 -1
  39. package/server/ui/job-change.js +78 -0
  40. package/server/ui/job-view.js +119 -0
  41. package/server/ui/jobs.js +482 -49
  42. package/server/ui/monitoring.js +44 -2
  43. package/server/ui/routes.js +1 -1
@@ -0,0 +1,190 @@
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`.
8
+
9
+ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
10
+ const TYPE_LABELS = {
11
+ dropdown: 'Dropdown', person: 'Person', checkbox: 'Checkbox', date: 'Date', number: 'Number', text: 'Text',
12
+ };
13
+ const TYPE_HINTS = {
14
+ dropdown: 'One option from a list. Makes board lanes.',
15
+ person: 'One person in Flow. Makes board lanes.',
16
+ checkbox: 'Yes or no. Makes board lanes.',
17
+ date: 'A day, for example a promised date.',
18
+ number: 'A number, for example units.',
19
+ text: 'A short note.',
20
+ };
21
+
22
+ export async function renderCustomFields(main, ui) {
23
+ const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
24
+ 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);
28
+
29
+ let data = null;
30
+
31
+ async function send(path, method, payload, done = 'Saved.') {
32
+ try {
33
+ const body = await flow(path, { method, ...(payload ? { body: JSON.stringify(payload) } : {}) });
34
+ if (done) toast(done, 'success');
35
+ return body;
36
+ } catch (error) {
37
+ toast(error.message, 'danger');
38
+ return null;
39
+ }
40
+ }
41
+
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
+
48
+ // The option list editor for a dropdown: one line per option (name,
49
+ // colour, move up, remove), in lane order.
50
+ function optionsEditor(start) {
51
+ let options = start.filter((o) => !o.archived).map((o) => ({ ...o }));
52
+ const list = h('div', { class: 'fx-fields fx-cf__options' });
53
+ const add = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add option');
54
+ function draw() {
55
+ list.replaceChildren(...options.map((o, i) => {
56
+ const name = h('input', { class: 'fx-input', maxlength: '40', value: o.name, 'aria-label': `Option ${i + 1}`, placeholder: 'Option name' });
57
+ name.addEventListener('input', () => { o.name = name.value; });
58
+ const color = h('select', { class: 'fx-select fx-cf__color', 'aria-label': `Colour of option ${i + 1}`, 'data-color': o.color },
59
+ data.colors.map((c) => h('option', { value: c }, c[0].toUpperCase() + c.slice(1))));
60
+ color.value = o.color ?? 'gray';
61
+ color.addEventListener('change', () => { o.color = color.value; color.dataset.color = color.value; });
62
+ const up = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', 'aria-label': `Move option ${i + 1} up`, disabled: i === 0 }, '↑');
63
+ up.addEventListener('click', () => { [options[i - 1], options[i]] = [options[i], options[i - 1]]; draw(); });
64
+ const remove = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rule__delete', type: 'button', 'aria-label': `Remove option ${i + 1}` }, 'Remove');
65
+ remove.addEventListener('click', () => { options.splice(i, 1); draw(); });
66
+ return h('div', { class: 'fx-cf__line' }, name, color, up, remove);
67
+ }));
68
+ if (!options.length) list.append(h('span', { class: 'fx-dim' }, 'No options yet.'));
69
+ }
70
+ add.addEventListener('click', () => {
71
+ options.push({ name: '', color: data.colors[options.length % data.colors.length] });
72
+ draw();
73
+ list.querySelector('.fx-cf__line:last-child input')?.focus();
74
+ });
75
+ draw();
76
+ return {
77
+ element: h('div', { class: 'fx-fields' }, list, add),
78
+ value: () => options.filter((o) => o.name.trim()).map(({ id, name, color }) => ({ ...(id ? { id } : {}), name, color })),
79
+ };
80
+ }
81
+
82
+ // The dialog for a new field (field null) or an existing one. The type is
83
+ // fixed once the field exists.
84
+ async function edit(field, draft = field) {
85
+ const label = (text, control, hint) => h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, text), control, hint ?? null);
86
+ const name = h('input', { class: 'fx-input', required: true, maxlength: '60', value: draft?.name ?? '', 'aria-label': 'Field name', placeholder: 'For example Priority' });
87
+ const type = h('select', { class: 'fx-select', 'aria-label': 'Type', disabled: Boolean(field) },
88
+ data.types.map((t) => h('option', { value: t }, TYPE_LABELS[t] ?? t)));
89
+ type.value = draft?.type ?? 'dropdown';
90
+ const typeHint = h('span', { class: 'fx-dim' }, TYPE_HINTS[type.value]);
91
+ const editor = optionsEditor(draft?.options ?? [
92
+ { name: 'To do', color: 'gray' }, { name: 'Doing', color: 'blue' }, { name: 'Done', color: 'green' },
93
+ ]);
94
+ const optionsField = label('Options', editor.element, h('span', { class: 'fx-dim' }, 'Board lanes follow this order. A removed option is kept for old jobs.'));
95
+ const showOptions = () => { optionsField.hidden = type.value !== 'dropdown'; typeHint.textContent = TYPE_HINTS[type.value]; };
96
+ type.addEventListener('change', showOptions);
97
+ showOptions();
98
+
99
+ let action = null;
100
+ const parts = [label('Name', name), label('Type', type, typeHint), optionsField];
101
+ if (field) {
102
+ const archive = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rule__delete', type: 'button' }, 'Archive field');
103
+ archive.addEventListener('click', () => { action = 'archive'; archive.closest('dialog').close(); });
104
+ parts.push(archive);
105
+ }
106
+ const ok = await dialog({ title: field ? `Edit ${field.name}` : 'Create field', body: h('div', { class: 'fx-fields fx-rule__dialog' }, ...parts), confirm: field ? 'Save' : 'Create field' });
107
+ if (action === 'archive') {
108
+ if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived: true }, 'Archived. Its values are kept.')) return load();
109
+ return null;
110
+ }
111
+ if (!ok) return null;
112
+ const payload = { name: name.value };
113
+ if (!field) payload.type = type.value;
114
+ if (type.value === 'dropdown') payload.options = editor.value();
115
+ const body = field
116
+ ? await send(`/api/custom-fields/${field.id}`, 'PUT', payload)
117
+ : await send('/api/custom-fields', 'POST', payload, 'Field created.');
118
+ if (!body) return edit(field, { ...draft, ...payload, type: type.value });
119
+ return load();
120
+ }
121
+
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();
126
+ }
127
+
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();
144
+ });
145
+ controls.push(back);
146
+ }
147
+ return row(field.name, hint, h('div', { class: 'fx-row' }, ...controls));
148
+ }
149
+
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}`));
155
+ }
156
+
157
+ 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.')));
169
+ }
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))));
172
+ }
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));
175
+ }
176
+
177
+ async function load() {
178
+ try {
179
+ const [live, all] = await Promise.all([flow('/api/custom-fields'), flow('/api/custom-fields?archived=1')]);
180
+ data = { ...live, archived: all.fields };
181
+ } catch (error) {
182
+ host.replaceChildren(notice(error.message, 'danger'));
183
+ return;
184
+ }
185
+ draw();
186
+ }
187
+
188
+ addButton.addEventListener('click', () => edit(null));
189
+ await load();
190
+ }
@@ -189,6 +189,30 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
189
189
  .fx-notice[data-tone="danger"] { border-color: var(--t-danger-brd); background: var(--t-danger-bg); color: var(--t-danger); }
190
190
  .fx-panel > .fx-notice { margin: 12px; }
191
191
 
192
+ /* The job changed after its invoice was made (job-change.js): was / now per
193
+ changed fact. Was is struck through; Now is the work system's value. */
194
+ .fx-change {
195
+ display: grid; gap: 10px; padding: 12px 14px;
196
+ border: 1px solid var(--t-danger-brd); border-radius: var(--t-radius);
197
+ background: var(--t-danger-bg); color: var(--t-text); font-size: 14px;
198
+ }
199
+ .fx-change p { margin: 0; }
200
+ .fx-change strong { color: var(--t-danger); }
201
+ .fx-change__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; }
202
+ .fx-change__acts { display: flex; flex-wrap: wrap; gap: 8px; }
203
+ .fx-change__note { font-size: 13px; color: var(--t-dim); }
204
+ .fx-change__scroll { overflow-x: auto; }
205
+ .fx-change__table { width: 100%; border-collapse: collapse; background: var(--t-paper); border: 1px solid var(--t-brd); font-size: 13.5px; }
206
+ .fx-change__table th, .fx-change__table td { padding: 8px 12px; border-bottom: 1px solid var(--t-brd); text-align: left; vertical-align: top; }
207
+ .fx-change__table thead th {
208
+ font-size: var(--t-label-size, 12px); font-weight: var(--t-label-weight, 600); letter-spacing: var(--t-label-spacing, .08em);
209
+ text-transform: var(--t-label-case, uppercase); color: var(--t-dim);
210
+ }
211
+ .fx-change__table tbody th { font-weight: 400; color: var(--t-dim); white-space: nowrap; }
212
+ .fx-change__was { background: color-mix(in srgb, var(--t-danger) 9%, transparent); text-decoration: line-through; text-decoration-thickness: 1px; }
213
+ .fx-change__now { background: color-mix(in srgb, var(--t-success) 11%, transparent); color: var(--t-strong); font-weight: 600; }
214
+ .fx-change__table .fx-num { text-align: left; font-family: var(--t-data); font-variant-numeric: tabular-nums; }
215
+
192
216
  .fx-status {
193
217
  display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px;
194
218
  border-radius: 999px; font-size: 12.5px; font-weight: 600; white-space: nowrap;
@@ -220,7 +244,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
220
244
  letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case);
221
245
  }
222
246
  .fx-table td { padding: 12px 16px; border-bottom: 1px solid var(--fx-line-soft); vertical-align: middle; }
223
- .fx-table tbody tr:last-child td { border-bottom: 0; }
247
+ .fx-table tbody:last-child tr:last-child td { border-bottom: 0; }
224
248
  .fx-table tbody tr:hover td { background: var(--fx-hover); }
225
249
  .fx-table__actions { text-align: right; width: 1%; white-space: nowrap; }
226
250
  .fx-table__actions .fx-row { flex-wrap: nowrap; }
@@ -439,14 +463,57 @@ a.fx-strong:hover { text-decoration: underline; }
439
463
  .fx-toolbar--plain .fx-search { margin-bottom: 0; }
440
464
  .fx-empty .fx-btn { margin-top: 12px; }
441
465
  .fx-panel.fx-empty { display: grid; justify-items: center; }
466
+ /* The Jobs toolbar: Group, Sort and Filter at the left, as small outlined
467
+ buttons that each open a menu; search and Board/List at the right. */
468
+ .fx-toolbar__end { display: flex; flex: 1 1 360px; align-items: center; justify-content: flex-end; gap: 12px; min-width: 0; margin-left: auto; }
469
+ .fx-toolbar__end .fx-search { flex: 0 1 320px; min-width: 0; }
470
+ .fx-viewbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
471
+ .fx-chip {
472
+ display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 4px 11px;
473
+ border: 1px solid var(--fx-line); border-radius: 999px; background: transparent;
474
+ color: var(--t-text); font: 600 13px/1.2 var(--t-body); white-space: nowrap; cursor: pointer;
475
+ }
476
+ .fx-chip svg { flex: none; color: var(--t-dim); }
477
+ .fx-chip:hover, .fx-chip[aria-expanded="true"] { border-color: var(--t-brd-hi, var(--fx-line)); background: var(--fx-hover); color: var(--t-strong); }
478
+ .fx-chip:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
479
+ .fx-chip--on { border-color: var(--t-accent-brd, var(--t-accent)); background: var(--t-accent-bg); color: var(--t-accent); }
480
+ .fx-chip--on svg { color: currentColor; }
481
+ .fx-pop {
482
+ position: fixed; inset: auto; margin: 0; min-width: 220px; max-width: min(320px, calc(100vw - 16px)); max-height: min(420px, calc(100vh - 32px));
483
+ overflow-y: auto; padding: 6px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg);
484
+ background: var(--t-paper); color: var(--t-text); box-shadow: 0 12px 32px rgba(0, 0, 0, .22);
485
+ }
486
+ .fx-pop__head { margin: 0; padding: 8px 10px 4px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
487
+ .fx-pop__item {
488
+ display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; border: 0; border-radius: var(--t-radius);
489
+ background: none; color: var(--t-text); font: 500 13.5px/1.3 var(--t-body); text-align: left; cursor: pointer;
490
+ }
491
+ .fx-pop__item:hover, .fx-pop__item:focus-visible { background: var(--fx-hover); color: var(--t-strong); outline: none; }
492
+ .fx-pop__item[aria-checked="true"] { color: var(--t-strong); font-weight: 600; }
493
+ .fx-pop__tick { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; color: var(--t-accent); }
494
+ .fx-pop__sep { height: 1px; margin: 6px 4px; background: var(--fx-line-soft); }
495
+ .fx-pop__foot { display: flex; justify-content: flex-end; padding: 6px 4px 2px; border-top: 1px solid var(--fx-line-soft); margin-top: 6px; }
496
+ .fx-table__group td { padding: 14px 16px 8px; background: var(--t-s1); }
497
+ .fx-table tbody tr.fx-table__group:hover td { background: var(--t-s1); }
498
+ .fx-table__group-row { display: flex; align-items: center; gap: 12px; }
499
+ .fx-table__group-row .fx-board__sum { margin-left: 0; }
500
+ .fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
442
501
  /* The board never scrolls sideways: its columns (one per work system status)
443
- wrap onto more rows when the page is narrow, and stack on a phone. A long
444
- column scrolls inside itself. */
445
- .fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); gap: 10px; align-items: start; }
446
- .fx-board__col { display: grid; gap: 8px; align-content: start; min-width: 0; padding: 10px 8px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
447
- .fx-board__head { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 2px 4px; }
502
+ are about a phone wide, wrap onto more rows when the page is narrow, and
503
+ stack on a phone. On a wide, tall screen the board fills the page down to
504
+ its bottom edge, and each column scrolls inside itself (jobs.js sets
505
+ fx-main--board while the board shows). */
506
+ .fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 256px), 1fr)); gap: 12px; align-items: start; }
507
+ .fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
508
+ .fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
448
509
  .fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
449
- .fx-board__cards { display: grid; gap: 8px; max-height: 70vh; overflow-y: auto; }
510
+ .fx-board__cards { display: grid; gap: 8px; align-content: start; min-height: 0; max-height: 70vh; padding-right: 2px; overflow-y: auto; scrollbar-width: thin; }
511
+ @media (min-width: 821px) and (min-height: 560px) {
512
+ .fx-main--board { height: 100vh; height: 100dvh; padding: 24px 24px 16px; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; }
513
+ .fx-main--board > .fx-jobs { min-height: 0; }
514
+ .fx-main--board .fx-board { height: 100%; overflow-y: auto; grid-auto-rows: 100%; align-items: stretch; }
515
+ .fx-main--board .fx-board__cards { flex: 1; max-height: none; }
516
+ }
450
517
  .fx-board__none { padding: 8px 4px; color: var(--t-dim); font-size: 13px; }
451
518
  .fx-table--jobs tbody tr { cursor: pointer; }
452
519
 
@@ -476,9 +543,10 @@ a.fx-strong:hover { text-decoration: underline; }
476
543
  .fx-job { position: relative; display: grid; min-width: 0; padding: 11px 12px 9px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); box-shadow: var(--t-shadow); font-size: 13px; cursor: pointer; }
477
544
  .fx-job:hover { border-color: var(--t-brd-hi, var(--fx-line)); }
478
545
  .fx-job:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
479
- .fx-job__client { display: -webkit-box; overflow: hidden; color: var(--t-strong); font-size: 14px; font-weight: 600; line-height: 1.3; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
480
- .fx-job__who { display: flex; align-items: center; gap: 6px; min-width: 0; margin-top: 5px; color: var(--t-dim); font-size: 12.5px; }
481
- .fx-job__who span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
546
+ .fx-job__client { color: var(--t-strong); font-size: 14px; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; }
547
+ .fx-job__who { display: flex; align-items: flex-start; gap: 6px; min-width: 0; margin-top: 5px; color: var(--t-dim); font-size: 12.5px; }
548
+ .fx-job__who span { min-width: 0; overflow-wrap: anywhere; }
549
+ .fx-job__who svg { margin-top: 2px; }
482
550
  .fx-job__who svg, .fx-job__date svg { flex: none; color: var(--t-mute); }
483
551
  .fx-job__foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 12px; }
484
552
  .fx-job__date { display: inline-flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; white-space: nowrap; }
@@ -489,6 +557,22 @@ a.fx-strong:hover { text-decoration: underline; }
489
557
  .fx-job__problem:hover, .fx-job__problem:focus-visible { background: var(--t-warn-bg); }
490
558
  .fx-job__cell { display: inline-flex; align-items: center; gap: 6px; }
491
559
 
560
+ /* Moves on the board (jobs.js, enableDrag): the lifted card, the copy that
561
+ follows the pointer, the lane that takes it, and the "Waiting for
562
+ Seazona" chip of a stage move the work system has not made yet. */
563
+ .fx-job__wait { justify-self: start; margin-top: 8px; padding: 1px 8px; border: 1px dashed var(--t-accent); border-radius: 999px; color: var(--t-accent); font-size: 11.5px; font-weight: 600; }
564
+ .fx-job--lifted { opacity: .35; }
565
+ .fx-job--ghost { position: fixed; top: 0; left: 0; z-index: 60; pointer-events: none; box-shadow: var(--t-shadow-lg, var(--t-shadow)); cursor: grabbing; }
566
+ .fx-board--dragging, .fx-board--dragging .fx-job { cursor: grabbing; user-select: none; }
567
+ .fx-board__col--over { outline: 2px solid var(--t-accent); outline-offset: -2px; background: var(--t-accent-bg, var(--t-s1)); }
568
+ .fx-board__lane { font-weight: 600; }
569
+ .fx-board__lane .fx-stage__count { margin-left: 2px; }
570
+ /* The stage move dialog (jobs.js, moveStage). */
571
+ .fx-move__path { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; color: var(--t-dim); }
572
+ .fx-move__stage { padding: 2px 10px; border: 1px solid var(--fx-line); border-radius: 999px; color: var(--t-strong); font-size: 13px; font-weight: 600; }
573
+ .fx-move__note { margin: 0; color: var(--t-text); }
574
+ .fx-check { display: inline-flex; align-items: center; gap: 8px; color: var(--t-dim); font-size: 13.5px; cursor: pointer; }
575
+
492
576
  /* ── Job page ───────────────────────────────────────────────────────────────
493
577
  A job opened from the board: a large panel over it, like a task page, with
494
578
  the activity at the side. It stacks on a phone. */
@@ -513,6 +597,11 @@ a.fx-strong:hover { text-decoration: underline; }
513
597
  .fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
514
598
  .fx-task__fixrow { padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
515
599
  .fx-task__fixrow:first-of-type { border-top: 0; }
600
+ .fx-task__custom { margin-top: 24px; }
601
+ .fx-task__custom h3 { margin: 0 0 4px; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
602
+ .fx-task__input { width: 100%; min-width: 0; }
603
+ .fx-task__move { display: grid; gap: 10px; max-width: 420px; margin-top: 8px; }
604
+ .fx-task__wait { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 10px 12px; border: 1px dashed var(--t-accent); border-radius: var(--t-radius); font-size: 13.5px; }
516
605
  .fx-task__lines { margin-top: 24px; }
517
606
  .fx-task__lines h3 { margin-bottom: 8px; }
518
607
  .fx-task__lines table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
@@ -755,6 +844,18 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
755
844
  .fx-price__systems { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
756
845
  .fx-set__control .fx-price__link { width: 260px; min-width: 0; }
757
846
  @media (max-width: 560px) { .fx-set__control .fx-price__link { width: 150px; } }
847
+ /* Custom fields (custom-fields.js): option chips with a colour dot, and the
848
+ dialog's option lines. The option colours are fixed, like ClickUp's. */
849
+ .fx-cf__opt { display: inline-flex; align-items: center; gap: 6px; padding: 2px 9px; border: 1px solid var(--fx-line); border-radius: 999px; font-size: 12.5px; color: var(--t-text); white-space: nowrap; }
850
+ .fx-cf__opt::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--cf); flex: none; }
851
+ [data-color="gray"] { --cf: #8a8f98; } [data-color="blue"] { --cf: #3b82f6; } [data-color="teal"] { --cf: #14b8a6; }
852
+ [data-color="green"] { --cf: #22a35a; } [data-color="amber"] { --cf: #e0a019; } [data-color="red"] { --cf: #e5484d; }
853
+ [data-color="purple"] { --cf: #8b5cf6; } [data-color="pink"] { --cf: #e5469a; }
854
+ .fx-cf__options { display: grid; gap: 8px; }
855
+ .fx-cf__line { display: grid; grid-template-columns: minmax(0, 1fr) 120px auto auto; gap: 8px; align-items: center; }
856
+ .fx-cf__line .fx-input, .fx-cf__line .fx-select { width: 100%; min-width: 0; }
857
+ .fx-cf__color { border-left: 4px solid var(--cf); }
858
+ @media (max-width: 560px) { .fx-cf__line { grid-template-columns: minmax(0, 1fr) 96px; } }
758
859
  /* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
759
860
  rule dialog's condition lines. */
760
861
  .fx-rule__controls { align-items: center; gap: 10px; }
@@ -827,3 +928,20 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
827
928
  .fx-pager__step { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid var(--fx-line-soft); border-radius: 999px; background: transparent; color: var(--t-text); cursor: pointer; transition: background 120ms var(--fx-ease), border-color 120ms var(--fx-ease); }
828
929
  .fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
829
930
  .fx-pager__step:disabled { opacity: 0.35; cursor: default; }
931
+
932
+ /* New invoice from a job: a quiet strip above the form, and a searchable
933
+ list of jobs with no invoice. */
934
+ .fx-fromjob {
935
+ display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px;
936
+ padding: 12px 16px; border: 1px dashed var(--fx-line); border-radius: var(--t-radius);
937
+ }
938
+ .fx-fromjob__text { display: grid; gap: 2px; font-size: 14px; }
939
+ .fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
940
+ .fx-pick__body { display: grid; gap: 12px; }
941
+ .fx-pick { display: grid; max-height: min(420px, 55vh); overflow-y: auto; border: 1px solid var(--fx-line); border-radius: var(--t-radius); }
942
+ .fx-pick__row { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--fx-line); cursor: pointer; }
943
+ .fx-pick__row:last-child { border-bottom: 0; }
944
+ .fx-pick__row:hover, .fx-pick__row:has(input:checked) { background: var(--fx-hover); }
945
+ .fx-pick__row[hidden] { display: none; }
946
+ .fx-pick__main { display: grid; gap: 2px; min-width: 0; }
947
+ .fx-pick > .fx-dim { padding: 14px; }
@@ -21,6 +21,6 @@ export function sendModeNote(to, sendMode, what = 'each invoice') {
21
21
  // The line under a draft's title, one fact per sentence.
22
22
  export function draftNote(to, invoice, ready) {
23
23
  if (!to) return ready ? 'A person checked it. A saved change keeps it ready.' : 'Flow gives it a number when you issue it.';
24
- if (!ready) return `Mark it ready, then send it to ${to}.`;
24
+ if (!ready) return `Finalise it, then send it to ${to}.`;
25
25
  return sendModeNote(to, invoice?.sendMode, 'this invoice');
26
26
  }