@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.
- package/README.md +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +4 -2
- package/server/api/custom-fields.js +26 -9
- package/server/api/invoicing.js +149 -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 +74 -6
- package/server/core/ledger.js +29 -7
- 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 +205 -15
- package/server/integrations/port.js +3 -2
- package/server/integrations/schema.js +9 -5
- package/server/integrations/seazona/settings.js +8 -4
- package/server/integrations/xero/drafts.js +4 -4
- package/server/integrations/xero/index.js +5 -1
- package/server/integrations/xero/migrations/0009_xero_sent_to_contact.sql +3 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +4 -3
- package/server/integrations/xero/reconcile.js +25 -1
- package/server/integrations/xero/routes.js +24 -7
- package/server/integrations/xero/settings.js +2 -1
- package/server/ui/app.js +42 -6
- package/server/ui/custom-fields.js +252 -94
- package/server/ui/flow.css +185 -29
- package/server/ui/invoice-notes.js +2 -2
- package/server/ui/invoicing.js +92 -137
- package/server/ui/job-board.js +1 -1
- package/server/ui/job-change.js +23 -12
- package/server/ui/job-view.js +168 -33
- package/server/ui/jobs.js +316 -65
- package/server/ui/monitoring.js +13 -12
- 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 +1 -1
- package/server/ui/routes.js +1 -1
package/server/ui/job-view.js
CHANGED
|
@@ -8,7 +8,9 @@ export const LAYOUTS = ['board', 'list'];
|
|
|
8
8
|
// 'none' is one plain list with no group heads. The board has a lane per
|
|
9
9
|
// group, so there it shows the status lanes.
|
|
10
10
|
export const GROUPS = { status: 'Status', stage: 'Invoice stage', client: 'Client', practitioner: 'Practitioner', none: 'None' };
|
|
11
|
-
|
|
11
|
+
// What a job can be sorted by. A custom field can sort too ('field:<id>').
|
|
12
|
+
// No sort keeps the order the lab system gives.
|
|
13
|
+
export const SORTS = { due: 'Due', ordered: 'Requested', shipped: 'Shipped', client: 'Client', practitioner: 'Practitioner', total: 'Total' };
|
|
12
14
|
export const DIRS = ['asc', 'desc'];
|
|
13
15
|
// The list's columns, in order. Client is always the first column. One
|
|
14
16
|
// column holds one field: each date has its own column. A custom field is
|
|
@@ -20,8 +22,43 @@ export const COLUMNS = {
|
|
|
20
22
|
ordered: 'Requested', due: 'Due', shipped: 'Shipped', total: 'Total',
|
|
21
23
|
};
|
|
22
24
|
export const DEFAULT_COLUMNS = Object.freeze(['practitioner', 'patient', 'stage', 'assignee', 'due', 'total']);
|
|
25
|
+
|
|
26
|
+
// Filters, like ClickUp's: rows of field, operator and value, all of them
|
|
27
|
+
// ('all') or any of them ('any') must match. A custom field filters too
|
|
28
|
+
// ('field:<id>'); its kind comes from its type. Each kind has its
|
|
29
|
+
// operators, in the order the menu shows them.
|
|
30
|
+
export const FILTER_FIELDS = {
|
|
31
|
+
status: { label: 'Status', kind: 'choice' },
|
|
32
|
+
stage: { label: 'Invoice stage', kind: 'choice' },
|
|
33
|
+
client: { label: 'Client', kind: 'choice' },
|
|
34
|
+
practitioner: { label: 'Practitioner', kind: 'choice' },
|
|
35
|
+
assignee: { label: 'Assignee', kind: 'person' },
|
|
36
|
+
due: { label: 'Due', kind: 'date' },
|
|
37
|
+
ordered: { label: 'Requested', kind: 'date' },
|
|
38
|
+
shipped: { label: 'Shipped', kind: 'date' },
|
|
39
|
+
total: { label: 'Total', kind: 'number' },
|
|
40
|
+
problem: { label: 'Problem', kind: 'check' },
|
|
41
|
+
};
|
|
42
|
+
const CHOICE_OPS = { is: 'is', not: 'is not', set: 'is set', unset: 'is not set' };
|
|
43
|
+
export const OPERATORS = {
|
|
44
|
+
choice: CHOICE_OPS,
|
|
45
|
+
person: CHOICE_OPS,
|
|
46
|
+
date: {
|
|
47
|
+
overdue: 'is overdue', today: 'is today', next7: 'is in the next 7 days', last7: 'was in the last 7 days',
|
|
48
|
+
before: 'is before', after: 'is after', on: 'is on', set: 'is set', unset: 'is not set',
|
|
49
|
+
},
|
|
50
|
+
number: { eq: 'is', gt: 'is more than', lt: 'is less than', set: 'is set', unset: 'is not set' },
|
|
51
|
+
check: { yes: 'is ticked', no: 'is not ticked' },
|
|
52
|
+
text: { has: 'contains', is: 'is', set: 'is set', unset: 'is not set' },
|
|
53
|
+
};
|
|
54
|
+
export const KIND_OF_TYPE = { dropdown: 'choice', person: 'person', checkbox: 'check', date: 'date', number: 'number', text: 'text' };
|
|
55
|
+
export const MATCHES = ['all', 'any'];
|
|
56
|
+
// Operators that need no value.
|
|
57
|
+
const NO_VALUE = new Set(['set', 'unset', 'overdue', 'today', 'next7', 'last7', 'yes', 'no']);
|
|
58
|
+
const ALL_OPS = new Set(Object.values(OPERATORS).flatMap(Object.keys));
|
|
59
|
+
|
|
23
60
|
export const DEFAULT_VIEW = Object.freeze({
|
|
24
|
-
layout: 'board', group: 'status',
|
|
61
|
+
layout: 'board', group: 'status', groupDir: 'asc', sorts: Object.freeze([]), match: 'all', filters: Object.freeze([]), columns: DEFAULT_COLUMNS,
|
|
25
62
|
});
|
|
26
63
|
|
|
27
64
|
// Group by one of the lab's custom fields (docs/CUSTOM_FIELDS.md):
|
|
@@ -30,51 +67,141 @@ const FIELD_GROUP = /^field:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9
|
|
|
30
67
|
export const fieldGroup = (fieldId) => `field:${fieldId}`;
|
|
31
68
|
export const fieldIdOf = (group) => (FIELD_GROUP.test(group ?? '') ? group.slice(6) : null);
|
|
32
69
|
|
|
33
|
-
const
|
|
70
|
+
const MAX_VALUES = 100;
|
|
34
71
|
const MAX_COLUMNS = 40;
|
|
35
72
|
const MAX_LABEL = 120;
|
|
73
|
+
const MAX_FILTERS = 20;
|
|
74
|
+
const MAX_SORTS = 5;
|
|
75
|
+
|
|
76
|
+
const cleanLabels = (list) => [...new Set(list.filter((label) => typeof label === 'string' && label.length > 0 && label.length <= MAX_LABEL))].slice(0, MAX_VALUES);
|
|
77
|
+
// A filter's value: a list of choices, a word or date, or a number.
|
|
78
|
+
function cleanValue(value) {
|
|
79
|
+
if (Array.isArray(value)) return cleanLabels(value);
|
|
80
|
+
if (typeof value === 'string') return value.length <= MAX_LABEL ? value : null;
|
|
81
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cleanFilter(filter) {
|
|
86
|
+
if (!filter || typeof filter !== 'object') return null;
|
|
87
|
+
const builtIn = Object.hasOwn(FILTER_FIELDS, filter.field ?? '') ? FILTER_FIELDS[filter.field] : null;
|
|
88
|
+
if (!builtIn && !FIELD_GROUP.test(filter.field ?? '')) return null;
|
|
89
|
+
// A custom field's type is not known here, so any operator stands; the
|
|
90
|
+
// page skips one that does not fit.
|
|
91
|
+
const ok = builtIn ? Object.hasOwn(OPERATORS[builtIn.kind], filter.op ?? '') : ALL_OPS.has(filter.op);
|
|
92
|
+
return ok ? { field: filter.field, op: filter.op, value: NO_VALUE.has(filter.op) ? null : cleanValue(filter.value) } : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A view saved before filter rows had fixed filters: hidden statuses, late
|
|
96
|
+
// jobs, jobs with a problem, and "Assignee: me". They become rows.
|
|
97
|
+
function oldFilters(value) {
|
|
98
|
+
const filters = [];
|
|
99
|
+
const hide = Array.isArray(value.hide) ? cleanLabels(value.hide) : [];
|
|
100
|
+
if (hide.length) filters.push({ field: 'status', op: 'not', value: hide });
|
|
101
|
+
if (value.late === true) filters.push({ field: 'due', op: 'overdue', value: null });
|
|
102
|
+
if (value.problems === true) filters.push({ field: 'problem', op: 'yes', value: null });
|
|
103
|
+
if (value.mine === true) filters.push({ field: 'assignee', op: 'is', value: ['me'] });
|
|
104
|
+
return filters;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function cleanSorts(value) {
|
|
108
|
+
// A view saved before sort rows had one sort and a direction.
|
|
109
|
+
const list = Array.isArray(value.sorts) ? value.sorts
|
|
110
|
+
: Object.hasOwn(SORTS, value.sort ?? '') ? [{ by: value.sort, dir: value.dir }] : [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
return list.filter((s) => s && typeof s === 'object' && (Object.hasOwn(SORTS, s.by ?? '') || FIELD_GROUP.test(s.by ?? '')) && !seen.has(s.by) && seen.add(s.by))
|
|
113
|
+
.map((s) => ({ by: s.by, dir: DIRS.includes(s.dir) ? s.dir : 'asc' })).slice(0, MAX_SORTS);
|
|
114
|
+
}
|
|
36
115
|
|
|
37
116
|
// A view as Flow stores it: only the known keys, each checked. Anything else
|
|
38
117
|
// falls back to the default, so a bad saved view never breaks the page.
|
|
39
118
|
export function normaliseJobsView(input) {
|
|
40
119
|
const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
41
120
|
const pick = (key, allowed) => (allowed.includes(value[key]) ? value[key] : DEFAULT_VIEW[key]);
|
|
42
|
-
const hide = Array.isArray(value.hide)
|
|
43
|
-
? [...new Set(value.hide.filter((label) => typeof label === 'string' && label.length > 0 && label.length <= MAX_LABEL))].slice(0, MAX_HIDDEN)
|
|
44
|
-
: [];
|
|
45
121
|
return {
|
|
46
122
|
layout: pick('layout', LAYOUTS),
|
|
47
123
|
group: FIELD_GROUP.test(value.group ?? '') ? value.group : pick('group', Object.keys(GROUPS)),
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
problems: value.problems === true,
|
|
53
|
-
mine: value.mine === true,
|
|
124
|
+
groupDir: pick('groupDir', DIRS),
|
|
125
|
+
sorts: cleanSorts(value),
|
|
126
|
+
match: pick('match', MATCHES),
|
|
127
|
+
filters: (Array.isArray(value.filters) ? value.filters.map(cleanFilter).filter(Boolean) : oldFilters(value)).slice(0, MAX_FILTERS),
|
|
54
128
|
columns: Array.isArray(value.columns)
|
|
55
129
|
? [...new Set(value.columns.filter((c) => Object.hasOwn(COLUMNS, c) || FIELD_GROUP.test(c ?? '')))].slice(0, MAX_COLUMNS)
|
|
56
130
|
: [...DEFAULT_COLUMNS],
|
|
57
131
|
};
|
|
58
132
|
}
|
|
59
133
|
|
|
134
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
135
|
+
// A row filters only once it is complete: a row still being filled in
|
|
136
|
+
// (no value yet) keeps every job.
|
|
137
|
+
export function filterIsOn(filter) {
|
|
138
|
+
if (NO_VALUE.has(filter.op)) return true;
|
|
139
|
+
if (['before', 'after', 'on'].includes(filter.op)) return ISO_DAY.test(filter.value ?? '');
|
|
140
|
+
if (['eq', 'gt', 'lt'].includes(filter.op)) return typeof filter.value === 'number';
|
|
141
|
+
return Array.isArray(filter.value) ? filter.value.length > 0 : typeof filter.value === 'string' && filter.value.trim().length > 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
60
144
|
// How many filters are on: shown on the Filter button.
|
|
61
145
|
export function filterCount(view) {
|
|
62
|
-
return view.
|
|
146
|
+
return view.filters.filter(filterIsOn).length;
|
|
63
147
|
}
|
|
64
148
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
149
|
+
const text = (value) => (value ?? '').toString().toLowerCase();
|
|
150
|
+
const addDays = (iso, days) => {
|
|
151
|
+
const at = new Date(`${iso}T00:00:00Z`);
|
|
152
|
+
at.setUTCDate(at.getUTCDate() + days);
|
|
153
|
+
return at.toISOString().slice(0, 10);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Does one value pass one filter row? `late` says if the job is late (for
|
|
157
|
+
// "is overdue" on a job's due date); `me` is the person looking (for "me"
|
|
158
|
+
// in a person filter); `today` is yyyy-mm-dd.
|
|
159
|
+
export function matchesFilter(filter, value, { today, me = null, late = null } = {}) {
|
|
160
|
+
const want = filter.value;
|
|
161
|
+
const empty = value == null || value === '' || value === false;
|
|
162
|
+
switch (filter.op) {
|
|
163
|
+
case 'set': return !empty;
|
|
164
|
+
case 'unset': return empty;
|
|
165
|
+
case 'yes': return value === true;
|
|
166
|
+
case 'no': return value !== true;
|
|
167
|
+
case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).includes(value) : !empty && text(value) === text(want).trim();
|
|
168
|
+
case 'not': return !want.map((w) => (w === 'me' ? me : w)).includes(value);
|
|
169
|
+
case 'has': return text(value).includes(text(want).trim());
|
|
170
|
+
case 'eq': return typeof value === 'number' && value === want;
|
|
171
|
+
case 'gt': return typeof value === 'number' && value > want;
|
|
172
|
+
case 'lt': return typeof value === 'number' && value < want;
|
|
173
|
+
case 'overdue': return late ? late() : !empty && value < today;
|
|
174
|
+
case 'today': return value === today;
|
|
175
|
+
case 'next7': return !empty && value >= today && value <= addDays(today, 7);
|
|
176
|
+
case 'last7': return !empty && value < today && value >= addDays(today, -7);
|
|
177
|
+
case 'before': return !empty && value < want;
|
|
178
|
+
case 'after': return !empty && value > want;
|
|
179
|
+
case 'on': return value === want;
|
|
180
|
+
default: return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// The jobs the filter keeps. `valueOf(job, field)` reads a job's value for
|
|
185
|
+
// a filter field; `kindOf(field)` gives its kind, or null when the field is
|
|
186
|
+
// gone (its rows are skipped); `late(job)` says if a job is late.
|
|
187
|
+
export function filterJobs(jobs, view, { valueOf, kindOf = (field) => FILTER_FIELDS[field]?.kind ?? null, late = () => false, me = null, today }) {
|
|
188
|
+
const rows = view.filters.filter((f) => filterIsOn(f) && kindOf(f.field) && Object.hasOwn(OPERATORS[kindOf(f.field)], f.op));
|
|
189
|
+
if (!rows.length) return [...jobs];
|
|
190
|
+
const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, late: f.field === 'due' ? () => late(job) : null });
|
|
191
|
+
return jobs.filter((job) => (view.match === 'any' ? rows.some(pass(job)) : rows.every(pass(job))));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The status lanes to show: a status filter hides the lanes it leaves out,
|
|
195
|
+
// even empty ones. With "any" a lane can still match another row, so all
|
|
196
|
+
// lanes show.
|
|
197
|
+
export function keptStatuses(view, labels) {
|
|
198
|
+
if (view.match === 'any') return labels;
|
|
199
|
+
return view.filters.filter((f) => f.field === 'status' && filterIsOn(f) && Array.isArray(f.value)).reduce((kept, f) => (f.op === 'is'
|
|
200
|
+
? kept.filter((label) => f.value.includes(label))
|
|
201
|
+
: f.op === 'not' ? kept.filter((label) => !f.value.includes(label)) : kept), labels);
|
|
74
202
|
}
|
|
75
203
|
|
|
76
204
|
const dayOf = (job) => job.dueOn ?? job.orderedOn ?? job.jobDate ?? null;
|
|
77
|
-
const text = (value) => (value ?? '').toString().toLowerCase();
|
|
78
205
|
const KEYS = {
|
|
79
206
|
due: dayOf,
|
|
80
207
|
ordered: (job) => job.orderedOn ?? null,
|
|
@@ -84,16 +211,24 @@ const KEYS = {
|
|
|
84
211
|
total: (job) => job.totalCents ?? null,
|
|
85
212
|
};
|
|
86
213
|
|
|
87
|
-
// The jobs in the view's order
|
|
88
|
-
// gives. A job with no value for
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return jobs.map((job, i) => [job,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
214
|
+
// The jobs in the view's order: by each sort in turn. No sort keeps the
|
|
215
|
+
// order the API gives. A job with no value for a key goes last, in either
|
|
216
|
+
// direction. `keyOf(job, by)` reads a custom field's sort key.
|
|
217
|
+
export function sortJobs(jobs, view, keyOf = () => null) {
|
|
218
|
+
const keys = view.sorts.map((s) => ({ read: KEYS[s.by] ?? ((job) => keyOf(job, s.by)), sign: s.dir === 'desc' ? -1 : 1 }));
|
|
219
|
+
if (!keys.length) return [...jobs];
|
|
220
|
+
return jobs.map((job, i) => [job, keys.map((k) => k.read(job)), i]).sort(([, a, i], [, b, j]) => {
|
|
221
|
+
for (let n = 0; n < keys.length; n += 1) {
|
|
222
|
+
const x = a[n];
|
|
223
|
+
const y = b[n];
|
|
224
|
+
if (x == null || y == null) {
|
|
225
|
+
if (x == null && y == null) continue;
|
|
226
|
+
return (x == null) - (y == null);
|
|
227
|
+
}
|
|
228
|
+
const order = typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y));
|
|
229
|
+
if (order) return order * keys[n].sign;
|
|
230
|
+
}
|
|
231
|
+
return i - j;
|
|
97
232
|
}).map(([job]) => job);
|
|
98
233
|
}
|
|
99
234
|
|