@arcforgelabs/flow 2026.10.37 → 2026.10.39
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.
- package/README.md +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +27 -9
- package/server/api/invoicing.js +198 -51
- package/server/app.js +5 -2
- package/server/core/alerts.js +1 -1
- package/server/core/automations.js +1 -1
- package/server/core/custom-fields.js +89 -3
- package/server/core/job-board.js +2 -1
- package/server/core/job-match.js +241 -0
- package/server/core/ledger.js +109 -8
- package/server/core/price-list.js +27 -2
- package/server/core/read-before-act.js +3 -2
- package/server/core/sending.js +36 -18
- package/server/db/doctor.js +18 -19
- package/server/db/index.js +74 -3
- package/server/db/migrate-all.js +3 -4
- package/server/db/migrations/0020_custom_field_colours.sql +21 -0
- package/server/db/migrations/meta/0020_snapshot.json +4377 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +14 -0
- package/server/integrations/call-guard.js +148 -0
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/feed.js +4 -1
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/fit.js +108 -0
- package/server/integrations/xero/import.js +6 -1
- package/server/integrations/xero/index.js +6 -2
- package/server/integrations/xero/ledger.js +3 -0
- package/server/integrations/xero/links.js +293 -0
- package/server/integrations/xero/migrations/0008_xero_invoice_links.sql +26 -0
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +14 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +42 -12
- package/server/integrations/xero/routes.js +66 -7
- package/server/integrations/xero/settings.js +6 -5
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +89 -3
- package/server/ui/bulk-bar.js +4 -3
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +326 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +9 -7
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +189 -31
- package/server/ui/jobs.js +738 -70
- package/server/ui/price-list.js +43 -86
- package/server/ui/price-match.js +93 -0
- package/server/ui/price-reconcile.js +357 -0
- package/server/ui/reconcile.js +275 -0
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-import.js +329 -0
|
@@ -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
|
|
7
|
-
// options);
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
78
|
-
// colour
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
const
|
|
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-
|
|
86
|
-
|
|
87
|
-
const color = h
|
|
88
|
-
data.colors.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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-
|
|
147
|
+
list.querySelector('.fx-cfs__opt:last-child input')?.focus();
|
|
103
148
|
});
|
|
104
149
|
draw();
|
|
105
150
|
return {
|
|
106
|
-
element: h('div', { class: 'fx-
|
|
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
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
]);
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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('
|
|
326
|
+
toast('Custom field ID copied.', 'success');
|
|
220
327
|
} catch {
|
|
221
|
-
toast(`
|
|
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
|
|
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': '
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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':
|
|
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.
|
|
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
|
-
|
|
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` : '
|
|
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:
|
|
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
|
-
|
|
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' });
|