@arcforgelabs/flow 2026.10.43 → 2026.10.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/npm-shrinkwrap.json +4 -3
- package/package.json +5 -3
- package/server/auth/people.js +40 -2
- package/server/auth/routes.js +11 -0
- package/server/auth/session.js +22 -3
- package/server/core/custom-fields.js +4 -3
- package/server/core/invoice-rules.js +8 -3
- package/server/core/job-match.js +6 -2
- package/server/core/sending.js +4 -3
- package/server/release-channel.js +5 -2
- package/server/ui/app.js +11 -172
- package/server/ui/columns.js +268 -0
- package/server/ui/flow.css +203 -30
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +52 -29
- package/server/ui/invoicing.js +190 -69
- package/server/ui/job-view.js +255 -60
- package/server/ui/jobs.js +133 -137
- package/server/ui/kit.js +278 -0
- package/server/ui/list-views.js +73 -0
- package/server/ui/person.js +29 -0
- package/server/ui/reconcile.js +114 -61
- package/server/ui/routes.js +1 -1
- package/server/ui/saved-views.js +141 -0
- package/server/ui/search.js +8 -0
- package/server/ui/view-bar.js +176 -70
package/server/ui/job-view.js
CHANGED
|
@@ -23,42 +23,61 @@ export const COLUMNS = {
|
|
|
23
23
|
};
|
|
24
24
|
export const DEFAULT_COLUMNS = Object.freeze(['practitioner', 'patient', 'stage', 'assignee', 'due', 'total']);
|
|
25
25
|
|
|
26
|
-
// Filters, like ClickUp's: rows of field, operator and value
|
|
27
|
-
// ('all') or any
|
|
28
|
-
//
|
|
29
|
-
//
|
|
26
|
+
// Filters, like ClickUp's: groups of rows of field, operator and value. The
|
|
27
|
+
// rows in a group all match ('all') or any one matches ('any'); the groups
|
|
28
|
+
// join the same way (the view's `match`). A custom field filters too
|
|
29
|
+
// ('field:<id>'); its kind comes from its type. Each kind has ClickUp's
|
|
30
|
+
// operators, in the order ClickUp's menu shows them.
|
|
30
31
|
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' },
|
|
32
|
+
status: { label: 'Status', kind: 'choice', icon: 'circle-dot' },
|
|
33
|
+
stage: { label: 'Invoice stage', kind: 'choice', icon: 'receipt' },
|
|
34
|
+
client: { label: 'Client', kind: 'choice', icon: 'building-2' },
|
|
35
|
+
practitioner: { label: 'Practitioner', kind: 'choice', icon: 'stethoscope' },
|
|
36
|
+
assignee: { label: 'Assignee', kind: 'person', icon: 'user-round' },
|
|
37
|
+
due: { label: 'Due', kind: 'date', icon: 'calendar' },
|
|
38
|
+
ordered: { label: 'Requested', kind: 'date', icon: 'calendar' },
|
|
39
|
+
shipped: { label: 'Shipped', kind: 'date', icon: 'calendar' },
|
|
40
|
+
total: { label: 'Total', kind: 'number', icon: 'dollar-sign' },
|
|
41
|
+
problem: { label: 'Problem', kind: 'check', icon: 'triangle-alert' },
|
|
41
42
|
};
|
|
42
|
-
const CHOICE_OPS = { is: '
|
|
43
|
+
const CHOICE_OPS = { is: 'Is', not: 'Is not', set: 'Is set', unset: 'Is not set' };
|
|
43
44
|
export const OPERATORS = {
|
|
44
45
|
choice: CHOICE_OPS,
|
|
45
46
|
person: CHOICE_OPS,
|
|
46
|
-
date
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
// A date row's range is its value (DATE_VALUES), as on ClickUp.
|
|
48
|
+
date: CHOICE_OPS,
|
|
49
|
+
number: { set: 'Is set', unset: 'Is not set', gt: 'Greater than', lt: 'Less than', eq: 'Equal to' },
|
|
50
|
+
check: { yes: 'Is checked', no: 'Is not checked' },
|
|
51
|
+
text: { has: 'Contains', hasnt: 'Does not contain', is: 'Is', set: 'Is set', unset: 'Is not set' },
|
|
52
|
+
};
|
|
53
|
+
// The ranges a date row can match, as ClickUp's list names them. 'on',
|
|
54
|
+
// 'before' and 'after' take a day ('on:2026-03-10'), 'between' two days
|
|
55
|
+
// ('between:2026-03-01..2026-03-31'), 'last' and 'next' a count of days
|
|
56
|
+
// ('next:14').
|
|
57
|
+
export const DATE_VALUES = {
|
|
58
|
+
today: 'Today', yesterday: 'Yesterday', tomorrow: 'Tomorrow', next7: 'Next 7 days', last7: 'Last 7 days',
|
|
59
|
+
thisweek: 'This week', nextweek: 'Next week', lastweek: 'Last week',
|
|
60
|
+
lastmonth: 'Last month', thismonth: 'This month', nextmonth: 'Next month', earlier: 'Today & Earlier',
|
|
61
|
+
lastquarter: 'Last quarter', thisquarter: 'This quarter', nextquarter: 'Next quarter',
|
|
62
|
+
overdue: 'Overdue', later: 'Later than Today', last: 'Last', next: 'Next',
|
|
63
|
+
lastyear: 'Last year', thisyear: 'This year', nextyear: 'Next year',
|
|
64
|
+
on: 'Exact date', before: 'Before date', after: 'After date', between: 'Date range',
|
|
53
65
|
};
|
|
54
66
|
export const KIND_OF_TYPE = { dropdown: 'choice', person: 'person', checkbox: 'check', date: 'date', number: 'number', text: 'text' };
|
|
67
|
+
// Each kind's icon in the field list (Lucide, /assets/icons/), as ClickUp
|
|
68
|
+
// shows a field's type beside its name.
|
|
69
|
+
export const KIND_ICONS = { choice: 'square-chevron-down', person: 'user-round', check: 'square-check', date: 'calendar', number: 'hash', text: 'type' };
|
|
55
70
|
export const MATCHES = ['all', 'any'];
|
|
56
71
|
// Operators that need no value.
|
|
57
|
-
const NO_VALUE = new Set(['set', 'unset', '
|
|
72
|
+
const NO_VALUE = new Set(['set', 'unset', 'yes', 'no']);
|
|
58
73
|
const ALL_OPS = new Set(Object.values(OPERATORS).flatMap(Object.keys));
|
|
74
|
+
// Filters saved before dates took ClickUp's form had the range as the
|
|
75
|
+
// operator ('next7', 'before' with a day). They load as "Is <range>".
|
|
76
|
+
const OLD_DATE_OPS = new Set(['overdue', 'today', 'next7', 'last7', 'before', 'after', 'on']);
|
|
59
77
|
|
|
60
78
|
export const DEFAULT_VIEW = Object.freeze({
|
|
61
|
-
layout: 'board', group: 'status', groupDir: 'asc', sorts: Object.freeze([]), match: 'all',
|
|
79
|
+
layout: 'board', group: 'status', groupDir: 'asc', sorts: Object.freeze([]), match: 'all', groups: Object.freeze([]), columns: DEFAULT_COLUMNS,
|
|
80
|
+
widths: Object.freeze({}),
|
|
62
81
|
});
|
|
63
82
|
|
|
64
83
|
// Group by one of the lab's custom fields (docs/CUSTOM_FIELDS.md):
|
|
@@ -71,6 +90,7 @@ const MAX_VALUES = 100;
|
|
|
71
90
|
const MAX_COLUMNS = 40;
|
|
72
91
|
const MAX_LABEL = 120;
|
|
73
92
|
const MAX_FILTERS = 20;
|
|
93
|
+
const MAX_GROUPS = 10;
|
|
74
94
|
const MAX_SORTS = 5;
|
|
75
95
|
|
|
76
96
|
const cleanLabels = (list) => [...new Set(list.filter((label) => typeof label === 'string' && label.length > 0 && label.length <= MAX_LABEL))].slice(0, MAX_VALUES);
|
|
@@ -82,23 +102,48 @@ function cleanValue(value) {
|
|
|
82
102
|
return null;
|
|
83
103
|
}
|
|
84
104
|
|
|
85
|
-
|
|
105
|
+
// One row, checked. `kindOf(field)` is the field's kind, '*' for a custom
|
|
106
|
+
// field (its type is not known here, so any operator stands and the page
|
|
107
|
+
// skips one that does not fit), or null for a field the list does not have.
|
|
108
|
+
function cleanFilter(filter, kindOf) {
|
|
86
109
|
if (!filter || typeof filter !== 'object') return null;
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
110
|
+
const kind = kindOf(filter.field ?? '');
|
|
111
|
+
if (!kind) return null;
|
|
112
|
+
if (OLD_DATE_OPS.has(filter.op) && (kind === 'date' || kind === '*')) {
|
|
113
|
+
const day = typeof filter.value === 'string' && ISO_DAY.test(filter.value) ? filter.value : null;
|
|
114
|
+
const range = ['before', 'after', 'on'].includes(filter.op) ? (day ? `${filter.op}:${day}` : null) : filter.op;
|
|
115
|
+
return { field: filter.field, op: 'is', value: range };
|
|
116
|
+
}
|
|
117
|
+
const ok = kind === '*' ? ALL_OPS.has(filter.op) : Object.hasOwn(OPERATORS[kind] ?? {}, filter.op ?? '');
|
|
92
118
|
return ok ? { field: filter.field, op: filter.op, value: NO_VALUE.has(filter.op) ? null : cleanValue(filter.value) } : null;
|
|
93
119
|
}
|
|
94
120
|
|
|
121
|
+
// A filter as Flow keeps it: { match, groups: [{ match, filters }] }. A
|
|
122
|
+
// filter saved before groups ({ match, filters }) loads as one group.
|
|
123
|
+
export function cleanFilterView(value, kindOf) {
|
|
124
|
+
const input = value && typeof value === 'object' ? value : {};
|
|
125
|
+
const match = (m) => (MATCHES.includes(m) ? m : 'all');
|
|
126
|
+
const list = Array.isArray(input.groups) ? input.groups
|
|
127
|
+
: Array.isArray(input.filters) ? [{ match: input.match, filters: input.filters }] : [];
|
|
128
|
+
let room = MAX_FILTERS;
|
|
129
|
+
const groups = list.slice(0, MAX_GROUPS).map((g) => {
|
|
130
|
+
const filters = (Array.isArray(g?.filters) ? g.filters : []).map((f) => cleanFilter(f, kindOf)).filter(Boolean).slice(0, room);
|
|
131
|
+
room -= filters.length;
|
|
132
|
+
return { match: match(g?.match), filters };
|
|
133
|
+
}).filter((g) => g.filters.length);
|
|
134
|
+
return { match: Array.isArray(input.groups) ? match(input.match) : 'all', groups };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A job filter field's kind, for cleanFilterView.
|
|
138
|
+
const jobKind = (field) => FILTER_FIELDS[field]?.kind ?? (FIELD_GROUP.test(field) ? '*' : null);
|
|
139
|
+
|
|
95
140
|
// A view saved before filter rows had fixed filters: hidden statuses, late
|
|
96
141
|
// jobs, jobs with a problem, and "Assignee: me". They become rows.
|
|
97
142
|
function oldFilters(value) {
|
|
98
143
|
const filters = [];
|
|
99
144
|
const hide = Array.isArray(value.hide) ? cleanLabels(value.hide) : [];
|
|
100
145
|
if (hide.length) filters.push({ field: 'status', op: 'not', value: hide });
|
|
101
|
-
if (value.late === true) filters.push({ field: 'due', op: '
|
|
146
|
+
if (value.late === true) filters.push({ field: 'due', op: 'is', value: 'overdue' });
|
|
102
147
|
if (value.problems === true) filters.push({ field: 'problem', op: 'yes', value: null });
|
|
103
148
|
if (value.mine === true) filters.push({ field: 'assignee', op: 'is', value: ['me'] });
|
|
104
149
|
return filters;
|
|
@@ -123,27 +168,41 @@ export function normaliseJobsView(input) {
|
|
|
123
168
|
group: FIELD_GROUP.test(value.group ?? '') ? value.group : pick('group', Object.keys(GROUPS)),
|
|
124
169
|
groupDir: pick('groupDir', DIRS),
|
|
125
170
|
sorts: cleanSorts(value),
|
|
126
|
-
|
|
127
|
-
filters: (Array.isArray(value.filters) ? value.filters.map(cleanFilter).filter(Boolean) : oldFilters(value)).slice(0, MAX_FILTERS),
|
|
171
|
+
...cleanFilterView(Array.isArray(value.groups) || Array.isArray(value.filters) ? value : { filters: oldFilters(value) }, jobKind),
|
|
128
172
|
columns: Array.isArray(value.columns)
|
|
129
173
|
? [...new Set(value.columns.filter((c) => Object.hasOwn(COLUMNS, c) || FIELD_GROUP.test(c ?? '')))].slice(0, MAX_COLUMNS)
|
|
130
174
|
: [...DEFAULT_COLUMNS],
|
|
175
|
+
widths: cleanWidths(value.widths),
|
|
131
176
|
};
|
|
132
177
|
}
|
|
133
178
|
|
|
134
179
|
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
180
|
+
const DAY_RANGE = /^between:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/;
|
|
181
|
+
// Is a date row's value a range Flow knows?
|
|
182
|
+
export function dateValueIsOn(value) {
|
|
183
|
+
if (typeof value !== 'string') return false;
|
|
184
|
+
if (Object.hasOwn(DATE_VALUES, value)) return !['on', 'before', 'after', 'between', 'last', 'next'].includes(value);
|
|
185
|
+
const [kind, rest = ''] = value.split(':');
|
|
186
|
+
if (['on', 'before', 'after'].includes(kind)) return ISO_DAY.test(rest);
|
|
187
|
+
if (['last', 'next'].includes(kind)) return /^[1-9]\d{0,3}$/.test(rest);
|
|
188
|
+
return DAY_RANGE.test(value);
|
|
189
|
+
}
|
|
190
|
+
|
|
135
191
|
// 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) {
|
|
192
|
+
// (no value yet) keeps every job. `kind` is the row's field kind.
|
|
193
|
+
export function filterIsOn(filter, kind = null) {
|
|
138
194
|
if (NO_VALUE.has(filter.op)) return true;
|
|
139
|
-
if (
|
|
195
|
+
if (kind === 'date' || (kind == null && typeof filter.value === 'string' && dateValueIsOn(filter.value))) return dateValueIsOn(filter.value);
|
|
140
196
|
if (['eq', 'gt', 'lt'].includes(filter.op)) return typeof filter.value === 'number';
|
|
141
197
|
return Array.isArray(filter.value) ? filter.value.length > 0 : typeof filter.value === 'string' && filter.value.trim().length > 0;
|
|
142
198
|
}
|
|
143
199
|
|
|
200
|
+
// Every row of a view, in its groups' order.
|
|
201
|
+
export const filterRows = (view) => (view.groups ?? []).flatMap((g) => g.filters);
|
|
202
|
+
|
|
144
203
|
// How many filters are on: shown on the Filter button.
|
|
145
|
-
export function filterCount(view) {
|
|
146
|
-
return view.
|
|
204
|
+
export function filterCount(view, kindOf = () => null) {
|
|
205
|
+
return filterRows(view).filter((f) => filterIsOn(f, kindOf(f.field))).length;
|
|
147
206
|
}
|
|
148
207
|
|
|
149
208
|
const text = (value) => (value ?? '').toString().toLowerCase();
|
|
@@ -152,13 +211,74 @@ const addDays = (iso, days) => {
|
|
|
152
211
|
at.setUTCDate(at.getUTCDate() + days);
|
|
153
212
|
return at.toISOString().slice(0, 10);
|
|
154
213
|
};
|
|
214
|
+
const parts = (iso) => iso.split('-').map(Number);
|
|
215
|
+
const dayIso = (y, m, d) => new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
|
|
216
|
+
// The first day of the week (Monday, as in Australia) that holds `iso`.
|
|
217
|
+
const weekStart = (iso) => addDays(iso, -((new Date(`${iso}T00:00:00Z`).getUTCDay() + 6) % 7));
|
|
218
|
+
const monthRange = (y, m) => ({ from: dayIso(y, m, 1), to: dayIso(y, m + 1, 0) });
|
|
219
|
+
const quarterRange = (y, q) => ({ from: dayIso(y, q * 3 + 1, 1), to: dayIso(y, q * 3 + 4, 0) });
|
|
155
220
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
export function
|
|
221
|
+
// The days a date value matches, both ends kept: { from, to }, where a
|
|
222
|
+
// missing end is open. Null for 'overdue' (it needs the job) or a value
|
|
223
|
+
// Flow does not know.
|
|
224
|
+
export function dateRange(value, today) {
|
|
225
|
+
const [y, m] = parts(today);
|
|
226
|
+
const q = Math.floor((m - 1) / 3);
|
|
227
|
+
const week = weekStart(today);
|
|
228
|
+
switch (value) {
|
|
229
|
+
case 'today': return { from: today, to: today };
|
|
230
|
+
case 'yesterday': return { from: addDays(today, -1), to: addDays(today, -1) };
|
|
231
|
+
case 'tomorrow': return { from: addDays(today, 1), to: addDays(today, 1) };
|
|
232
|
+
case 'next7': return { from: today, to: addDays(today, 7) };
|
|
233
|
+
case 'last7': return { from: addDays(today, -7), to: addDays(today, -1) };
|
|
234
|
+
case 'thisweek': return { from: week, to: addDays(week, 6) };
|
|
235
|
+
case 'nextweek': return { from: addDays(week, 7), to: addDays(week, 13) };
|
|
236
|
+
case 'lastweek': return { from: addDays(week, -7), to: addDays(week, -1) };
|
|
237
|
+
case 'thismonth': return monthRange(y, m);
|
|
238
|
+
case 'nextmonth': return monthRange(y, m + 1);
|
|
239
|
+
case 'lastmonth': return monthRange(y, m - 1);
|
|
240
|
+
case 'earlier': return { from: null, to: today };
|
|
241
|
+
case 'later': return { from: addDays(today, 1), to: null };
|
|
242
|
+
case 'thisquarter': return quarterRange(y, q);
|
|
243
|
+
case 'nextquarter': return quarterRange(y, q + 1);
|
|
244
|
+
case 'lastquarter': return quarterRange(y, q - 1);
|
|
245
|
+
case 'thisyear': return { from: dayIso(y, 1, 1), to: dayIso(y, 12, 31) };
|
|
246
|
+
case 'nextyear': return { from: dayIso(y + 1, 1, 1), to: dayIso(y + 1, 12, 31) };
|
|
247
|
+
case 'lastyear': return { from: dayIso(y - 1, 1, 1), to: dayIso(y - 1, 12, 31) };
|
|
248
|
+
default: break;
|
|
249
|
+
}
|
|
250
|
+
if (!dateValueIsOn(value) || value === 'overdue') return null;
|
|
251
|
+
const between = DAY_RANGE.exec(value);
|
|
252
|
+
if (between) return between[1] <= between[2] ? { from: between[1], to: between[2] } : { from: between[2], to: between[1] };
|
|
253
|
+
const [kind, rest] = value.split(':');
|
|
254
|
+
if (kind === 'on') return { from: rest, to: rest };
|
|
255
|
+
if (kind === 'before') return { from: null, to: addDays(rest, -1) };
|
|
256
|
+
if (kind === 'after') return { from: addDays(rest, 1), to: null };
|
|
257
|
+
if (kind === 'last') return { from: addDays(today, -Number(rest)), to: addDays(today, -1) };
|
|
258
|
+
if (kind === 'next') return { from: today, to: addDays(today, Number(rest)) };
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Is a day (yyyy-mm-dd) in a date value's range? `late` says if the job is
|
|
263
|
+
// late, for "Overdue" on a job's due date.
|
|
264
|
+
function inDateValue(day, want, today, late) {
|
|
265
|
+
if (want === 'overdue') return late ? late() : day != null && day < today;
|
|
266
|
+
const range = dateRange(want, today);
|
|
267
|
+
if (!range || day == null) return false;
|
|
268
|
+
return (range.from == null || day >= range.from) && (range.to == null || day <= range.to);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Does one value pass one filter row? `kind` is the field's kind; `late`
|
|
272
|
+
// says if the job is late (for "Overdue" on a job's due date); `me` is the
|
|
273
|
+
// person looking (for "Me" in a person filter); `today` is yyyy-mm-dd.
|
|
274
|
+
export function matchesFilter(filter, value, { today, me = null, late = null, kind = null } = {}) {
|
|
160
275
|
const want = filter.value;
|
|
161
276
|
const empty = value == null || value === '' || value === false;
|
|
277
|
+
const day = typeof value === 'string' && ISO_DAY.test(value.slice(0, 10)) ? value.slice(0, 10) : null;
|
|
278
|
+
if (kind === 'date' && ['is', 'not'].includes(filter.op)) {
|
|
279
|
+
const inside = inDateValue(day, want, today, late);
|
|
280
|
+
return filter.op === 'is' ? inside : day != null && !inside;
|
|
281
|
+
}
|
|
162
282
|
switch (filter.op) {
|
|
163
283
|
case 'set': return !empty;
|
|
164
284
|
case 'unset': return empty;
|
|
@@ -167,38 +287,39 @@ export function matchesFilter(filter, value, { today, me = null, late = null } =
|
|
|
167
287
|
case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).includes(value) : !empty && text(value) === text(want).trim();
|
|
168
288
|
case 'not': return !want.map((w) => (w === 'me' ? me : w)).includes(value);
|
|
169
289
|
case 'has': return text(value).includes(text(want).trim());
|
|
290
|
+
case 'hasnt': return !text(value).includes(text(want).trim());
|
|
170
291
|
case 'eq': return typeof value === 'number' && value === want;
|
|
171
292
|
case 'gt': return typeof value === 'number' && value > want;
|
|
172
293
|
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
294
|
default: return true;
|
|
181
295
|
}
|
|
182
296
|
}
|
|
183
297
|
|
|
184
298
|
// The jobs the filter keeps. `valueOf(job, field)` reads a job's value for
|
|
185
299
|
// 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.
|
|
300
|
+
// gone (its rows are skipped); `late(job)` says if a job is late. A group
|
|
301
|
+
// keeps a job when all its rows pass ('all') or one does ('any'); the
|
|
302
|
+
// groups join the same way. A group with no complete row is left out.
|
|
187
303
|
export function filterJobs(jobs, view, { valueOf, kindOf = (field) => FILTER_FIELDS[field]?.kind ?? null, late = () => false, me = null, today }) {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
304
|
+
const live = (f) => kindOf(f.field) && Object.hasOwn(OPERATORS[kindOf(f.field)], f.op) && filterIsOn(f, kindOf(f.field));
|
|
305
|
+
const groups = (view.groups ?? []).map((g) => ({ match: g.match, rows: g.filters.filter(live) })).filter((g) => g.rows.length);
|
|
306
|
+
if (!groups.length) return [...jobs];
|
|
307
|
+
const pass = (job) => (f) => matchesFilter(f, valueOf(job, f.field), { today, me, kind: kindOf(f.field), late: f.field === 'due' ? () => late(job) : null });
|
|
308
|
+
const passGroup = (job) => (g) => (g.match === 'any' ? g.rows.some(pass(job)) : g.rows.every(pass(job)));
|
|
309
|
+
return jobs.filter((job) => (view.match === 'any' ? groups.some(passGroup(job)) : groups.every(passGroup(job))));
|
|
192
310
|
}
|
|
193
311
|
|
|
194
312
|
// The status lanes to show: a status filter hides the lanes it leaves out,
|
|
195
|
-
// even empty ones.
|
|
196
|
-
//
|
|
313
|
+
// even empty ones. A row whose group (or the groups' join) is "any" can be
|
|
314
|
+
// passed another way, so it hides no lane.
|
|
197
315
|
export function keptStatuses(view, labels) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
316
|
+
const groups = (view.groups ?? []).filter((g) => g.filters.some((f) => filterIsOn(f)));
|
|
317
|
+
if (view.match === 'any' && groups.length > 1) return labels;
|
|
318
|
+
return groups.filter((g) => g.match !== 'any' || g.filters.length === 1)
|
|
319
|
+
.flatMap((g) => g.filters)
|
|
320
|
+
.filter((f) => f.field === 'status' && filterIsOn(f) && Array.isArray(f.value)).reduce((kept, f) => (f.op === 'is'
|
|
321
|
+
? kept.filter((label) => f.value.includes(label))
|
|
322
|
+
: f.op === 'not' ? kept.filter((label) => !f.value.includes(label)) : kept), labels);
|
|
202
323
|
}
|
|
203
324
|
|
|
204
325
|
const dayOf = (job) => job.dueOn ?? job.orderedOn ?? job.jobDate ?? null;
|
|
@@ -275,3 +396,77 @@ export function laneOf(lanes, value) {
|
|
|
275
396
|
if (value === false || value == null) return lanes[0].label;
|
|
276
397
|
return (lanes.find((lane) => lane.value === value) ?? lanes[0]).label;
|
|
277
398
|
}
|
|
399
|
+
|
|
400
|
+
// ── Columns (columns.js draws them) ──
|
|
401
|
+
// Each kind of column starts at a width and never gets narrower than its
|
|
402
|
+
// minimum, so a head and a short value always fit.
|
|
403
|
+
export const COLUMN_WIDTH = Object.freeze({
|
|
404
|
+
client: 240, text: 176, case: 96, date: 120, total: 112, stage: 168, person: 96, status: 128, link: 96, icon: 48, number: 112,
|
|
405
|
+
});
|
|
406
|
+
export const COLUMN_MIN = Object.freeze({
|
|
407
|
+
client: 140, text: 112, case: 64, date: 76, total: 84, stage: 104, person: 64, status: 92, link: 72, icon: 40, number: 80,
|
|
408
|
+
});
|
|
409
|
+
export const MAX_WIDTH = 800;
|
|
410
|
+
const COLUMN_ID = /^[a-z][a-z0-9_-]{0,31}(:[0-9a-f-]{36})?$/i;
|
|
411
|
+
|
|
412
|
+
export const minWidth = (kind) => COLUMN_MIN[kind] ?? COLUMN_MIN.text;
|
|
413
|
+
export const startWidth = (kind) => COLUMN_WIDTH[kind] ?? COLUMN_WIDTH.text;
|
|
414
|
+
// A column's width now: the person's width (kept to the kind's minimum and
|
|
415
|
+
// the most), else the kind's start width.
|
|
416
|
+
export function widthOf(column, widths) {
|
|
417
|
+
const saved = widths?.[column.id];
|
|
418
|
+
return Number.isFinite(saved) ? Math.round(Math.min(MAX_WIDTH, Math.max(minWidth(column.kind), saved))) : startWidth(column.kind);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Saved widths as Flow keeps them: known-looking ids, whole pixels in range.
|
|
422
|
+
// The minimum per kind applies when a width is drawn (widthOf), since the
|
|
423
|
+
// server does not know a custom column's kind.
|
|
424
|
+
export function cleanWidths(input) {
|
|
425
|
+
const out = {};
|
|
426
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return out;
|
|
427
|
+
for (const [id, px] of Object.entries(input).slice(0, MAX_COLUMNS)) {
|
|
428
|
+
if (COLUMN_ID.test(id) && Number.isFinite(px) && px >= COLUMN_MIN.icon && px <= MAX_WIDTH) out[id] = Math.round(px);
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// A saved column order: ids `known(id)` allows, each once.
|
|
434
|
+
export function cleanColumnIds(input, known) {
|
|
435
|
+
if (!Array.isArray(input)) return null;
|
|
436
|
+
return [...new Set(input.filter((id) => typeof id === 'string' && COLUMN_ID.test(id) && known(id)))].slice(0, MAX_COLUMNS);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The ids with `id` put at place `to` (0 is the start).
|
|
440
|
+
export function moveColumn(ids, id, to) {
|
|
441
|
+
const rest = ids.filter((c) => c !== id);
|
|
442
|
+
const at = Math.max(0, Math.min(rest.length, to));
|
|
443
|
+
return [...rest.slice(0, at), id, ...rest.slice(at)];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// The columns in a view's order: the ones it names first, in its order, and
|
|
447
|
+
// any it does not name after them, as the page lists them.
|
|
448
|
+
export function inOrder(columns, ids) {
|
|
449
|
+
if (!Array.isArray(ids)) return columns;
|
|
450
|
+
const at = new Map(ids.map((id, n) => [id, n]));
|
|
451
|
+
return columns.map((c, n) => [c, at.has(c.id) ? at.get(c.id) : ids.length + n]).sort((a, b) => a[1] - b[1]).map(([c]) => c);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// The columns of the Invoices and Reconcile lists. On Pending, Reference
|
|
455
|
+
// shows the invoice's problem; Number shows on the tabs with numbers only.
|
|
456
|
+
export const INVOICE_COLUMNS = Object.freeze({
|
|
457
|
+
number: 'Number', client: 'Client', reference: 'Reference', date: 'Date', total: 'Total', status: 'Status', linked: 'Linked',
|
|
458
|
+
});
|
|
459
|
+
export const RECONCILE_COLUMNS = Object.freeze({
|
|
460
|
+
kind: 'Type', client: 'Client', detail: 'Detail', date: 'Date', total: 'Total', possible: 'Possible',
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// A person's view of a list with set columns (Invoices, Reconcile): the
|
|
464
|
+
// shown columns in order (every one when none is saved) and their widths.
|
|
465
|
+
export function normaliseListView(input, columns) {
|
|
466
|
+
const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
467
|
+
const ids = Object.keys(columns);
|
|
468
|
+
return {
|
|
469
|
+
columns: cleanColumnIds(value.columns, (id) => ids.includes(id)) ?? ids,
|
|
470
|
+
widths: cleanWidths(value.widths),
|
|
471
|
+
};
|
|
472
|
+
}
|