@arcforgelabs/flow 2026.10.38 → 2026.10.40

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 (47) hide show
  1. package/README.md +1 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +4 -2
  4. package/server/api/custom-fields.js +26 -9
  5. package/server/api/invoicing.js +149 -51
  6. package/server/app.js +5 -2
  7. package/server/core/alerts.js +1 -1
  8. package/server/core/automations.js +1 -1
  9. package/server/core/custom-fields.js +74 -6
  10. package/server/core/ledger.js +29 -7
  11. package/server/core/price-list.js +27 -2
  12. package/server/core/read-before-act.js +3 -2
  13. package/server/core/sending.js +36 -18
  14. package/server/db/doctor.js +18 -19
  15. package/server/db/index.js +74 -3
  16. package/server/db/migrate-all.js +3 -4
  17. package/server/db/migrations/0020_custom_field_colours.sql +21 -0
  18. package/server/db/migrations/meta/0020_snapshot.json +4377 -0
  19. package/server/db/migrations/meta/_journal.json +7 -0
  20. package/server/db/schema.js +14 -0
  21. package/server/integrations/call-guard.js +205 -15
  22. package/server/integrations/port.js +3 -2
  23. package/server/integrations/schema.js +9 -5
  24. package/server/integrations/seazona/settings.js +8 -4
  25. package/server/integrations/xero/drafts.js +4 -4
  26. package/server/integrations/xero/index.js +5 -1
  27. package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
  28. package/server/integrations/xero/migrations/meta/_journal.json +7 -0
  29. package/server/integrations/xero/mirror.js +4 -3
  30. package/server/integrations/xero/reconcile.js +25 -1
  31. package/server/integrations/xero/routes.js +24 -7
  32. package/server/integrations/xero/settings.js +2 -1
  33. package/server/ui/app.js +42 -6
  34. package/server/ui/custom-fields.js +252 -94
  35. package/server/ui/flow.css +185 -29
  36. package/server/ui/invoice-notes.js +2 -2
  37. package/server/ui/invoicing.js +92 -137
  38. package/server/ui/job-board.js +1 -1
  39. package/server/ui/job-change.js +23 -12
  40. package/server/ui/job-view.js +168 -33
  41. package/server/ui/jobs.js +316 -65
  42. package/server/ui/monitoring.js +13 -12
  43. package/server/ui/price-list.js +43 -86
  44. package/server/ui/price-match.js +93 -0
  45. package/server/ui/price-reconcile.js +357 -0
  46. package/server/ui/reconcile.js +1 -1
  47. package/server/ui/routes.js +1 -1
@@ -3,30 +3,68 @@
3
3
  // ClickUp's Custom Field Manager: one table, with a group per type that folds
4
4
  // open and closed, and one row per field with its type icon, who made it,
5
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
6
+ // fields) sit above it. A row's Edit opens ClickUp's sidecar from the right
7
+ // (name, colour, options); options show only there. Its ⋯ menu has Rename,
8
+ // Colour, Duplicate, Copy field ID, Archive and Delete. A group's pill picks
9
+ // the group's colour. Each colour picker shows the real pill in every
10
+ // colour, so a person sees what they get. The work systems' own fields
11
+ // (Seazona's stage) sit in their own group, read only: View opens their
12
+ // stages, and Date created is the day the system was connected.
13
+ // Everyone can read the page; an owner or admin changes it. The caller
10
14
  // passes in app.js's shared pieces as `ui`.
11
15
 
16
+ import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
17
+
12
18
  const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
13
19
  const TYPE_LABELS = {
14
20
  dropdown: 'Dropdown', person: 'Person', checkbox: 'Checkbox', date: 'Date', number: 'Number', text: 'Text',
15
21
  };
16
- const TYPE_HINTS = {
17
- dropdown: 'One option from a list. Makes board lanes.',
18
- person: 'One person in Flow. Makes board lanes.',
19
- checkbox: 'Yes or no. Makes board lanes.',
20
- date: 'A day, for example a promised date.',
21
- number: 'A number, for example units.',
22
- text: 'A short note.',
23
- };
24
- // Each type's icon (Lucide, served at /assets/icons/) and group colour.
22
+ // Each type's icon (Lucide, served at /assets/icons/). Group colours come
23
+ // from the server (groupColors); a field's own colour wins over its group's.
25
24
  const TYPE_ICONS = {
26
25
  dropdown: 'square-chevron-down', person: 'user-round', checkbox: 'square-check', date: 'calendar', number: 'hash', text: 'type',
27
26
  };
28
- const TYPE_COLORS = { dropdown: 'green', person: 'blue', checkbox: 'purple', date: 'teal', number: 'amber', text: 'gray' };
29
27
  const FOLDED_KEY = 'flow_custom_fields_folded';
28
+ const colorName = (c) => c[0].toUpperCase() + c.slice(1);
29
+ let pickerCount = 0;
30
+
31
+ // A colour picker: a button that shows `face()` (by default the real pill,
32
+ // `sample()`) in the colour now chosen, and a popover with the pill in
33
+ // every colour.
34
+ // `onpick(color)` gets the choice; the button follows it unless onpick
35
+ // answers false.
36
+ export function colorPicker(h, { colors, value, sample, face = sample, label, onpick, disabled = false }) {
37
+ let current = value;
38
+ const id = `fx-cf-colour-${++pickerCount}`;
39
+ const button = h('button', {
40
+ class: 'fx-cf__swatch', type: 'button', 'aria-label': label, 'aria-haspopup': 'dialog', popovertarget: id, disabled, title: disabled ? null : 'Change colour',
41
+ });
42
+ const panel = h('div', { class: 'fx-pop fx-cf__palette', id, popover: 'auto', role: 'dialog', 'aria-label': label });
43
+ const draw = () => button.replaceChildren(...[face(current), disabled || face !== sample ? null : lucide(h, 'chevron-down')].filter(Boolean));
44
+ const choose = async (color) => {
45
+ panel.hidePopover();
46
+ if (await onpick(color) === false) return;
47
+ current = color;
48
+ draw();
49
+ };
50
+ panel.addEventListener('toggle', (event) => {
51
+ button.setAttribute('aria-expanded', String(event.newState === 'open'));
52
+ if (event.newState !== 'open') return;
53
+ panel.replaceChildren(h('p', { class: 'fx-pop__head' }, 'Colour'), h('div', { class: 'fx-cf__grid', role: 'radiogroup', 'aria-label': label },
54
+ colors.map((c) => h('button', {
55
+ type: 'button', class: 'fx-cf__choice', role: 'radio', 'aria-checked': String(c === current), title: colorName(c), onclick: () => choose(c),
56
+ }, sample(c), h('span', { class: 'fx-cf__choice-name' }, colorName(c)), c === current ? lucide(h, 'check') : null))));
57
+ const at = button.getBoundingClientRect();
58
+ panel.style.top = `${Math.round(Math.min(at.bottom + 6, innerHeight - panel.offsetHeight - 8))}px`;
59
+ panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
60
+ panel.querySelector('[aria-checked="true"]')?.focus();
61
+ });
62
+ draw();
63
+ return { button, panel, open: () => panel.showPopover(), set: (c) => { current = c; draw(); } };
64
+ }
65
+
66
+ // A dropdown option as it shows on a job and on the board.
67
+ export const optionPill = (h, name, color) => h('span', { class: 'fx-cf__opt', 'data-color': color }, name || 'Option');
30
68
 
31
69
  // A Lucide icon as a mask, so it takes the text colour.
32
70
  export function lucide(h, name, label = null) {
@@ -57,10 +95,12 @@ export async function renderCustomFields(main, ui) {
57
95
  const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
58
96
  const disabled = !['owner', 'admin'].includes(me?.person.role);
59
97
  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');
98
+ const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create custom field');
61
99
  main.append(pageHeader('Custom fields', { subtitle: 'Fields for your jobs. They stay in Flow and never change Seazona or Xero.' }), host);
62
100
 
63
101
  let data = null;
102
+ const groupColor = (type) => data?.groupColors?.[type] ?? 'gray';
103
+ const colorOfField = (field) => field.color ?? groupColor(field.type);
64
104
 
65
105
  async function send(path, method, payload, done = 'Saved.') {
66
106
  try {
@@ -74,78 +114,144 @@ export async function renderCustomFields(main, ui) {
74
114
  }
75
115
 
76
116
 
77
- // The option list editor for a dropdown: one line per option (name,
78
- // colour, move up, remove), in lane order.
79
- function optionsEditor(start) {
80
- let options = start.filter((o) => !o.archived).map((o) => ({ ...o }));
81
- const list = h('div', { class: 'fx-fields fx-cf__options' });
82
- const add = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add option');
117
+ // The option list editor for a dropdown, as in ClickUp's sidecar: one
118
+ // box per option with its colour dot (the picker shows the real pill in
119
+ // every colour) and its name, in lane order, then "Add option".
120
+ function optionsEditor(start, { readOnly = false, changed = () => {} } = {}) {
121
+ const options = start.filter((o) => !o.archived).map((o) => ({ ...o }));
122
+ const list = h('div', { class: 'fx-cfs__options' });
123
+ const add = readOnly ? null : h('button', { class: 'fx-cfs__add', type: 'button' }, lucide(h, 'plus'), 'Add option');
83
124
  function draw() {
84
125
  list.replaceChildren(...options.map((o, i) => {
85
- const name = h('input', { class: 'fx-input', maxlength: '40', value: o.name, 'aria-label': `Option ${i + 1}`, placeholder: 'Option name' });
86
- name.addEventListener('input', () => { o.name = name.value; });
87
- const color = h('select', { class: 'fx-select fx-cf__color', 'aria-label': `Colour of option ${i + 1}`, 'data-color': o.color },
88
- data.colors.map((c) => h('option', { value: c }, c[0].toUpperCase() + c.slice(1))));
89
- color.value = o.color ?? 'gray';
90
- color.addEventListener('change', () => { o.color = color.value; color.dataset.color = color.value; });
91
- 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 }, '↑');
92
- up.addEventListener('click', () => { [options[i - 1], options[i]] = [options[i], options[i - 1]]; draw(); });
93
- 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');
94
- remove.addEventListener('click', () => { options.splice(i, 1); draw(); });
95
- return h('div', { class: 'fx-cf__line' }, name, color, up, remove);
126
+ const name = h('input', { class: 'fx-cfs__optname', maxlength: '40', value: o.name, 'aria-label': `Option ${i + 1}`, placeholder: 'Option name', readonly: readOnly });
127
+ const dot = (c) => h('span', { class: 'fx-cfs__dot', 'data-color': c });
128
+ const color = colorPicker(h, {
129
+ colors: data.colors, value: o.color ?? 'gray', label: `Colour of option ${i + 1}`, disabled: readOnly,
130
+ face: dot, sample: (c) => optionPill(h, o.name.trim(), c), onpick: (c) => { o.color = c; changed(); },
131
+ });
132
+ // The button shows the dot; the palette shows each colour as the pill.
133
+ color.button.classList.add('fx-cfs__colour');
134
+ name.addEventListener('input', () => color.set(o.color ?? 'gray'));
135
+ name.addEventListener('input', () => { o.name = name.value; changed(); });
136
+ const tools = readOnly ? null : h('span', { class: 'fx-cfs__tools' },
137
+ h('button', { class: 'fx-cfs__tool', type: 'button', 'aria-label': `Move option ${i + 1} up`, disabled: i === 0, onclick: () => { [options[i - 1], options[i]] = [options[i], options[i - 1]]; changed(); draw(); } }, lucide(h, 'arrow-up')),
138
+ h('button', { class: 'fx-cfs__tool', type: 'button', 'aria-label': `Remove option ${i + 1}`, onclick: () => { options.splice(i, 1); changed(); draw(); } }, lucide(h, 'x')));
139
+ return h('div', { class: 'fx-cfs__opt' }, color.button, color.panel, name, tools);
96
140
  }));
97
- if (!options.length) list.append(h('span', { class: 'fx-dim' }, 'No options yet.'));
141
+ if (!options.length) list.append(h('span', { class: 'fx-dim' }, readOnly ? 'No stages read yet.' : 'No options yet.'));
98
142
  }
99
- add.addEventListener('click', () => {
143
+ add?.addEventListener('click', () => {
100
144
  options.push({ name: '', color: data.colors[options.length % data.colors.length] });
145
+ changed();
101
146
  draw();
102
- list.querySelector('.fx-cf__line:last-child input')?.focus();
147
+ list.querySelector('.fx-cfs__opt:last-child input')?.focus();
103
148
  });
104
149
  draw();
105
150
  return {
106
- element: h('div', { class: 'fx-fields' }, list, add),
151
+ element: h('div', { class: 'fx-cfs__list' }, list, add),
107
152
  value: () => options.filter((o) => o.name.trim()).map(({ id, name, color }) => ({ ...(id ? { id } : {}), name, color })),
108
153
  };
109
154
  }
110
155
 
111
- // The dialog for a new field (field null) or an existing one. The type is
112
- // fixed once the field exists.
113
- async function edit(field, draft = field) {
114
- const label = (text, control, hint) => h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, text), control, hint ?? null);
115
- const name = h('input', { class: 'fx-input', required: true, maxlength: '60', value: draft?.name ?? '', 'aria-label': 'Field name', placeholder: 'For example Priority' });
116
- const type = h('select', { class: 'fx-select', 'aria-label': 'Type', disabled: Boolean(field) },
117
- data.types.map((t) => h('option', { value: t }, TYPE_LABELS[t] ?? t)));
156
+ // The sidecar: ClickUp's field panel, which slides in from the right of
157
+ // the manager. Edit on a row (or Create) opens it; another row's Edit
158
+ // switches it at once, so a person moves between fields quickly. The
159
+ // 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();
170
+ });
171
+
172
+ // Open the sidecar for a field (null: a new one; `draft` fills it after a
173
+ // failed save). A work system's field opens read only.
174
+ function edit(field, draft = field) {
175
+ sideFor = field?.id ?? 'new';
176
+ const readOnly = Boolean(field?.source) || disabled;
177
+ const section = (title, control, { required = false, hint = null } = {}) => h('div', { class: 'fx-cfs__section' },
178
+ h('span', { class: 'fx-cfs__label' }, title, required && !readOnly ? h('span', { class: 'fx-cfs__req', 'aria-hidden': 'true' }, '*') : null), control, hint ? h('span', { class: 'fx-dim fx-cfs__hint' }, hint) : null);
179
+ let dirty = false;
180
+ const save = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'submit', disabled: true }, field ? 'Save' : 'Create');
181
+ const changed = () => { dirty = true; save.disabled = readOnly || !name.value.trim(); };
182
+
183
+ // Head: the type (chosen for a new field, fixed after), and ×.
184
+ const type = h('select', { class: 'fx-cfs__type', 'aria-label': 'Custom field type' }, data.types.map((t) => h('option', { value: t }, TYPE_LABELS[t] ?? t)));
118
185
  type.value = draft?.type ?? 'dropdown';
119
- const typeHint = h('span', { class: 'fx-dim' }, TYPE_HINTS[type.value]);
120
- const editor = optionsEditor(draft?.options ?? [
121
- { name: 'To do', color: 'gray' }, { name: 'Doing', color: 'blue' }, { name: 'Done', color: 'green' },
122
- ]);
123
- const optionsField = label('Options', editor.element, h('span', { class: 'fx-dim' }, 'Board lanes follow this order. A removed option is kept for old jobs.'));
124
- const showOptions = () => { optionsField.hidden = type.value !== 'dropdown'; typeHint.textContent = TYPE_HINTS[type.value]; };
125
- type.addEventListener('change', showOptions);
126
- showOptions();
127
-
128
- let action = null;
129
- const parts = [label('Name', name), label('Type', type, typeHint), optionsField];
130
- if (field) {
131
- const archive = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rule__delete', type: 'button' }, 'Archive field');
132
- archive.addEventListener('click', () => { action = 'archive'; archive.closest('dialog').close(); });
133
- parts.push(archive);
134
- }
135
- 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' });
136
- if (action === 'archive') {
137
- if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived: true }, 'Archived. Its values are kept.')) return load();
138
- return null;
139
- }
140
- if (!ok) return null;
141
- const payload = { name: name.value };
142
- if (!field) payload.type = type.value;
143
- if (type.value === 'dropdown') payload.options = editor.value();
144
- const body = field
145
- ? await send(`/api/custom-fields/${field.id}`, 'PUT', payload)
146
- : await send('/api/custom-fields', 'POST', payload, 'Field created.');
147
- if (!body) return edit(field, { ...draft, ...payload, type: type.value });
148
- return load();
186
+ const typeLabel = () => h('span', { class: 'fx-cfs__typename' }, lucide(h, TYPE_ICONS[type.value]), TYPE_LABELS[type.value]);
187
+ // A new field picks its type from the head, as in ClickUp: the select
188
+ // lies over the label, so the label shows and the browser's list opens.
189
+ const typeText = h('span', {}, TYPE_LABELS[type.value]);
190
+ 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'));
192
+
193
+ 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
+ name.addEventListener('input', changed);
195
+ const nameBox = h('div', { class: 'fx-cfs__namebox' }, h('span', { class: 'fx-cfs__nameicon', 'data-color': field?.source ? 'gray' : (draft?.color ?? groupColor(type.value)) }, lucide(h, TYPE_ICONS[type.value])), name);
196
+
197
+ // The colour is not here, as in ClickUp: the row's type icon and ⋯,
198
+ // Colour change it.
199
+ const colorOf = () => draft?.color ?? groupColor(type.value);
200
+
201
+ // A work system's stages: one line each, in board order, with the
202
+ // board's stage colour as the dot. Read only.
203
+ const editor = field?.source
204
+ ? { element: h('div', { class: 'fx-cfs__options' }, orderStatuses(field.options.map((o) => ({ label: o.name, firstSeenAt: o.firstSeenAt })))
205
+ .map((label) => h('div', { class: 'fx-cfs__opt fx-cfs__opt--locked' },
206
+ h('span', { class: 'fx-cfs__dot', 'data-rank': String(statusRank(label)) }), h('span', { class: 'fx-cfs__optname' }, label)))) }
207
+ : optionsEditor(draft?.options ?? [
208
+ { name: 'To do', color: 'gray' }, { name: 'Doing', color: 'blue' }, { name: 'Done', color: 'green' },
209
+ ], { readOnly, changed });
210
+ const optionsSection = section(field?.source ? 'Stages' : 'Dropdown options', editor.element, {
211
+ required: !field?.source, hint: field?.source ? `${field.sourceLabel} owns these stages. Flow reads them and cannot change them.` : null,
212
+ });
213
+ const fit = () => {
214
+ optionsSection.hidden = type.value !== 'dropdown';
215
+ typeText.textContent = TYPE_LABELS[type.value];
216
+ headType.querySelector('.fx-lucide').style.setProperty('--icon', `url(/assets/icons/${TYPE_ICONS[type.value]}.svg)`);
217
+ nameBox.querySelector('.fx-lucide').style.setProperty('--icon', `url(/assets/icons/${TYPE_ICONS[type.value]}.svg)`);
218
+ nameBox.firstChild.dataset.color = field?.source ? 'gray' : colorOf();
219
+ };
220
+ type.addEventListener('change', () => { fit(); changed(); });
221
+
222
+ const where = field?.source ? `${field.sourceLabel} jobs` : 'Jobs';
223
+ const belongs = section('Custom field belongs to', h('div', { class: 'fx-cfs__where' }, lucide(h, 'list-checks'), where));
224
+
225
+ const remove = field && !readOnly ? h('button', { class: 'fx-cfs__delete', type: 'button', 'aria-label': `Delete ${field.name}`, title: 'Delete', onclick: () => remove(field) }, lucide(h, 'trash-2')) : null;
226
+ const cancel = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: closeSide }, readOnly ? 'Close' : 'Cancel');
227
+ const form = h('form', { class: 'fx-cfs__form' },
228
+ h('header', { class: 'fx-cfs__head' }, headType, close),
229
+ h('div', { class: 'fx-cfs__body' },
230
+ section('Custom field name', nameBox, { required: true }),
231
+ optionsSection, belongs),
232
+ h('footer', { class: 'fx-cfs__foot' }, remove, h('span', { class: 'fx-cfm__gap' }), cancel, readOnly ? null : save));
233
+ form.addEventListener('submit', async (event) => {
234
+ event.preventDefault();
235
+ if (readOnly || !dirty) return;
236
+ const payload = { name: name.value };
237
+ if (!field) payload.type = type.value;
238
+ if (type.value === 'dropdown') payload.options = editor.value();
239
+ save.disabled = true;
240
+ const body = field
241
+ ? await send(`/api/custom-fields/${field.id}`, 'PUT', payload)
242
+ : await send('/api/custom-fields', 'POST', payload, 'Custom field created.');
243
+ if (!body) { save.disabled = false; return; }
244
+ closeSide();
245
+ await load();
246
+ });
247
+ fit();
248
+ side.replaceChildren(form);
249
+ // Below the toolbar, so search and Create stay in reach.
250
+ side.style.top = `${toolbar.offsetHeight}px`;
251
+ side.hidden = false;
252
+ table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
253
+ if (field) table.querySelector(`.fx-cfm__row[data-id="${CSS.escape(field.id)}"]`)?.setAttribute('data-open', 'true');
254
+ (readOnly ? close : name).focus({ preventScroll: true });
149
255
  }
150
256
 
151
257
  // ---- The manager: toolbar, then one table of groups ----
@@ -173,7 +279,7 @@ export async function renderCustomFields(main, ui) {
173
279
  type: 'button', class: 'fx-pop__item', role, disabled: off, title, onclick, ...(checked === null ? {} : { 'aria-checked': String(checked) }),
174
280
  }, h('span', { class: 'fx-pop__tick' }, iconName ? lucide(h, iconName) : null), h('span', {}, text));
175
281
 
176
- const search = h('input', { class: 'fx-input fx-cfm__search', type: 'search', placeholder: 'Search fields…', 'aria-label': 'Search fields' });
282
+ const search = h('input', { class: 'fx-input fx-cfm__search', type: 'search', placeholder: 'Search custom fields…', 'aria-label': 'Search custom fields' });
177
283
  search.addEventListener('input', () => { view.q = search.value.trim().toLowerCase(); draw(); });
178
284
  const typeButton = h('button', { class: 'fx-chip fx-cfm__tool', type: 'button' });
179
285
  const typeMenu = popMenu(typeButton, (close) => [
@@ -186,11 +292,12 @@ export async function renderCustomFields(main, ui) {
186
292
  ]);
187
293
  const filterButton = h('button', { class: 'fx-chip fx-cfm__tool fx-cfm__tool--dashed', type: 'button' });
188
294
  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 }),
295
+ item(view.archived ? 'check' : null, 'Show archived custom fields', () => { view.archived = !view.archived; close(); draw(); }, { role: 'menuitemcheckbox', checked: view.archived }),
190
296
  ]);
191
297
  const toolbar = h('div', { class: 'fx-cfm__bar' }, search, typeButton, typeMenu, filterButton, filterMenu, h('span', { class: 'fx-cfm__gap' }), addButton);
192
298
  const table = h('div', { class: 'fx-cfm', role: 'table', 'aria-label': 'Custom fields' });
193
299
  host.replaceChildren(toolbar, table);
300
+ host.append(side);
194
301
 
195
302
  const personName = (id) => data.people?.find((p) => p.id === id)?.name ?? null;
196
303
  const matches = (f) => !view.q || f.name.toLowerCase().includes(view.q)
@@ -216,9 +323,9 @@ export async function renderCustomFields(main, ui) {
216
323
  async function copyId(field) {
217
324
  try {
218
325
  await navigator.clipboard.writeText(field.id);
219
- toast('Field ID copied.', 'success');
326
+ toast('Custom field ID copied.', 'success');
220
327
  } catch {
221
- toast(`Field ID: ${field.id}`, 'info');
328
+ toast(`Custom field ID: ${field.id}`, 'info');
222
329
  }
223
330
  }
224
331
 
@@ -226,25 +333,60 @@ export async function renderCustomFields(main, ui) {
226
333
  if (await send(`/api/custom-fields/${field.id}/archive`, 'POST', { archived }, archived ? 'Archived. Its values are kept.' : 'Brought back.')) await load();
227
334
  }
228
335
 
229
- function rowMenu(field) {
336
+ async function setColor(field, color) {
337
+ if (!await send(`/api/custom-fields/${field.id}`, 'PUT', { color }, null)) return false;
338
+ await load();
339
+ return true;
340
+ }
341
+
342
+ async function setGroupColor(type, color) {
343
+ const body = await send(`/api/custom-fields/groups/${type}`, 'PUT', { color }, null);
344
+ if (!body) return false;
345
+ data.groupColors = body.groupColors;
346
+ draw();
347
+ return true;
348
+ }
349
+
350
+ // Delete for good, after the person says yes. Archive is the step that
351
+ // keeps the values.
352
+ async function remove(field) {
353
+ const jobs = field.valueCount ?? 0;
354
+ const body = h('div', { class: 'fx-fields' },
355
+ h('p', {}, jobs ? `${jobs} ${jobs === 1 ? 'job has' : 'jobs have'} a value in ${field.name}. Delete removes the custom field, these values and their history.` : `Delete removes ${field.name} and its history.`),
356
+ h('p', { class: 'fx-dim' }, 'You cannot undo this. To keep the values, archive the custom field.'));
357
+ if (!await dialog({ title: `Delete ${field.name}?`, body, confirm: 'Delete', tone: 'danger' })) return null;
358
+ if (!await send(`/api/custom-fields/${field.id}`, 'DELETE', null, `Deleted ${field.name}.`)) return null;
359
+ if (sideFor === field.id) closeSide();
360
+ return load();
361
+ return null;
362
+ }
363
+
364
+ function rowMenu(field, picker) {
230
365
  const button = h('button', { class: 'fx-cfm__more', type: 'button', 'aria-label': `More for ${field.name}` }, lucide(h, 'ellipsis'));
366
+ const deleteItem = (close) => {
367
+ const node = item('trash-2', 'Delete', () => { close(); remove(field); }, { disabled });
368
+ node.dataset.tone = 'danger';
369
+ return node;
370
+ };
231
371
  const panel = popMenu(button, (close) => (field.archived
232
- ? [item('archive-restore', 'Bring back', () => { close(); setArchived(field, false); }, { disabled })]
372
+ ? [item('archive-restore', 'Bring back', () => { close(); setArchived(field, false); }, { disabled }), h('div', { class: 'fx-pop__sep' }), deleteItem(close)]
233
373
  : [
234
374
  item('square-pen', 'Edit', () => { close(); edit(field); }, { disabled }),
235
375
  item('pencil', 'Rename', () => { close(); view.renaming = field.id; draw(); }, { disabled }),
376
+ item('palette', 'Colour', () => { close(); picker?.open(); }, { disabled: disabled || !picker }),
236
377
  item('copy', 'Duplicate', () => { close(); duplicate(field); }, { disabled }),
237
378
  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); }),
379
+ item('clipboard-copy', 'Copy custom field ID', () => { close(); copyId(field); }),
239
380
  h('div', { class: 'fx-pop__sep' }),
240
381
  item('archive', 'Archive', () => { close(); setArchived(field, true); }, { disabled }),
382
+ deleteItem(close),
241
383
  ]));
242
384
  return [button, panel];
243
385
  }
244
386
 
245
387
  function nameCell(field) {
246
388
  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' });
389
+ const input = h('input', { class: 'fx-input fx-cfm__rename', value: field.name, maxlength: '60', 'aria-label': 'Custom field name' });
248
390
  input.addEventListener('keydown', (event) => {
249
391
  if (event.key === 'Enter') input.blur();
250
392
  if (event.key === 'Escape') { view.renaming = null; draw(); }
@@ -255,25 +397,35 @@ export async function renderCustomFields(main, ui) {
255
397
  }
256
398
  const editButton = field.archived || disabled || field.source ? null
257
399
  : h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'Edit', lucide(h, 'panel-right'));
400
+ const viewButton = field.source ? h('button', { class: 'fx-cfm__edit', type: 'button', onclick: () => edit(field) }, 'View', lucide(h, 'panel-right')) : null;
258
401
  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);
402
+ h('div', { class: 'fx-cfm__title' },
403
+ field.source ? lucide(h, 'lock', 'Read only') : null,
404
+ h('span', { class: 'fx-cfm__label', title: field.name }, field.name), editButton, viewButton));
261
405
  }
262
406
 
263
407
  function fieldRow(field) {
264
408
  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 },
409
+ const color = field.source ? 'gray' : colorOfField(field);
410
+ const icon = lucide(h, TYPE_ICONS[field.type], TYPE_LABELS[field.type]);
411
+ // A manager changes a field's colour from its type icon (or ⋯, Colour).
412
+ const picker = field.source || field.archived || disabled ? null : colorPicker(h, {
413
+ colors: data.colors, value: color, label: `Colour of ${field.name}`,
414
+ sample: (c) => h('span', { class: 'fx-cfm__icon', 'data-color': c }, lucide(h, TYPE_ICONS[field.type], TYPE_LABELS[field.type])),
415
+ onpick: (c) => setColor(field, c),
416
+ });
417
+ return h('div', { class: 'fx-cfm__row', role: 'row', 'data-id': field.id, 'data-open': sideFor === field.id ? 'true' : null, 'data-archived': field.archived ? 'true' : null },
266
418
  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])),
419
+ h('div', { class: 'fx-cfm__type', role: 'cell' }, picker ? [picker.button, picker.panel] : h('span', { class: 'fx-cfm__icon', 'data-color': color }, icon)),
268
420
  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)),
421
+ h('div', { class: 'fx-cfm__date', role: 'cell', title: field.source ? `${field.sourceLabel} connected` : null }, field.createdAt ? createdDay(field.createdAt) : h('span', { class: 'fx-dim' }, '—')),
270
422
  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)));
423
+ h('div', { class: 'fx-cfm__end', role: 'cell' }, field.source ? null : rowMenu(field, picker)));
272
424
  }
273
425
 
274
426
  // One group: a header that folds it, its rows, and "Create … field". A
275
427
  // search opens every group that has a match.
276
- function group(key, pill, rows, { create = null } = {}) {
428
+ function group(key, pill, rows, { create = null, type = null } = {}) {
277
429
  const open = Boolean(view.q) || !view.folded.has(key);
278
430
  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
431
  toggle.addEventListener('click', () => {
@@ -281,8 +433,13 @@ export async function renderCustomFields(main, ui) {
281
433
  saveFolded(view.folded);
282
434
  draw();
283
435
  });
436
+ const groupPill = (c) => h('span', { class: 'fx-cfm__pill', 'data-color': c }, pill.icon ? lucide(h, pill.icon) : null, pill.text);
437
+ // A type group's pill picks the group's colour.
438
+ const picker = type && !disabled ? colorPicker(h, {
439
+ colors: data.colors, value: pill.color, label: `Colour of the ${pill.text} group`, sample: groupPill, onpick: (c) => setGroupColor(type, c),
440
+ }) : null;
284
441
  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),
442
+ picker ? [picker.button, picker.panel] : groupPill(pill.color),
286
443
  h('span', { class: 'fx-cfm__count' }, String(rows.length)));
287
444
  if (!open) return [head];
288
445
  const out = [head, ...rows.map(fieldRow)];
@@ -292,7 +449,7 @@ export async function renderCustomFields(main, ui) {
292
449
 
293
450
  function draw() {
294
451
  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'));
452
+ typeButton.replaceChildren(lucide(h, 'layers'), h('span', {}, view.types.size === 1 ? TYPE_LABELS[[...view.types][0]] : view.types.size ? `${view.types.size} types` : 'Type'), lucide(h, 'chevron-down'));
296
453
  typeButton.classList.toggle('fx-chip--on', view.types.size > 0);
297
454
  filterButton.replaceChildren(lucide(h, 'list-filter'), h('span', {}, view.archived ? 'Archived shown' : 'Filter'), lucide(h, 'chevron-down'));
298
455
  filterButton.classList.toggle('fx-chip--on', view.archived);
@@ -315,7 +472,7 @@ export async function renderCustomFields(main, ui) {
315
472
  count += fields.length;
316
473
  const create = view.q || disabled ? null : h('button', { class: 'fx-cfm__create', type: 'button', onclick: () => edit(null, { type, name: '', options: undefined }) },
317
474
  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 }));
475
+ parts.push(...group(type, { text: TYPE_LABELS[type], icon: TYPE_ICONS[type], color: groupColor(type) }, fields, { create, type }));
319
476
  }
320
477
  // The work systems' own fields: one read-only group per system.
321
478
  for (const source of [...new Set(data.systemFields.map((f) => f.source))]) {
@@ -330,12 +487,13 @@ export async function renderCustomFields(main, ui) {
330
487
  count += fields.length;
331
488
  parts.push(...group('archived', { text: 'Archived', icon: 'archive', color: 'gray' }, fields));
332
489
  }
333
- if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No field matches.'));
490
+ if (!count && (view.q || view.types.size)) parts.push(h('p', { class: 'fx-cfm__empty' }, 'No custom field matches.'));
334
491
  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.'));
492
+ 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.'));
336
493
  }
337
494
  table.replaceChildren(...parts);
338
- foot.replaceChildren(h('span', {}, `${data.fields.length} ${data.fields.length === 1 ? 'field' : 'fields'}`),
495
+ // The count is what the table shows, the work systems' fields too.
496
+ foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`),
339
497
  h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
340
498
  }
341
499
  const foot = h('div', { class: 'fx-cfm__foot' });