@arcforgelabs/flow 2026.10.42 → 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/integrations/sentry/index.js +9 -1
- package/server/release-channel.js +5 -2
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +46 -162
- package/server/ui/automations.js +9 -3
- package/server/ui/columns.js +268 -0
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +268 -55
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +3 -3
- package/server/ui/invoicing-settings.js +92 -35
- package/server/ui/invoicing.js +206 -119
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/job-view.js +255 -60
- package/server/ui/jobs.js +197 -145
- package/server/ui/kit.js +278 -0
- package/server/ui/list-views.js +73 -0
- package/server/ui/monitoring.js +1 -0
- package/server/ui/person.js +29 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- 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/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/view-bar.js +176 -70
- package/server/ui/xero-import.js +7 -10
package/server/ui/view-bar.js
CHANGED
|
@@ -9,19 +9,22 @@
|
|
|
9
9
|
// option(text, checked, onclick, role, icon) a row in a panel, with a tick.
|
|
10
10
|
// title, choose, bin, addButton, rowsFoot, clearAll the parts of a rows
|
|
11
11
|
// panel: a title, rows of selects with a bin, a foot with Add and Clear.
|
|
12
|
-
// filterMenu(config) the Filter chip:
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
12
|
+
// filterMenu(config) the Filter chip, as ClickUp's: groups of rows of
|
|
13
|
+
// field, operator and value, And / Or in a group and between groups,
|
|
14
|
+
// "Add nested filter", "Add filter" and "Clear all" (job-view.js has
|
|
15
|
+
// the rules and filterJobs, which keeps the rows that pass). config:
|
|
16
|
+
// fields() [{ value, label, icon? } | { group, options }]
|
|
16
17
|
// kindOf(field) 'choice' | 'person' | 'date' | 'number' | 'check' | 'text' | null
|
|
17
18
|
// opsOf(field) { op: words }, or null for OPERATORS[kindOf(field)]
|
|
18
19
|
// choicesOf(field) [{ value, label, icon? }] for a choice field
|
|
19
|
-
// view() {
|
|
20
|
-
// change(patch) saves {
|
|
20
|
+
// view() { match, groups: [{ match, filters }] }
|
|
21
|
+
// change(patch) saves { groups } or { match }
|
|
21
22
|
// first the field a new row starts on
|
|
22
23
|
// empty the words when no filter is on
|
|
23
24
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
24
|
-
import { filterCount, OPERATORS } from '/assets/ui/job-view.js';
|
|
25
|
+
import { DATE_VALUES, dateValueIsOn, filterCount, KIND_ICONS, OPERATORS } from '/assets/ui/job-view.js';
|
|
26
|
+
|
|
27
|
+
const DAY_BETWEEN = /^between:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/;
|
|
25
28
|
|
|
26
29
|
export const ICONS = {
|
|
27
30
|
group: '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.8 14.5 5 8 8.2 1.5 5z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="m1.5 8.2 6.5 3.2 6.5-3.2M1.5 11.2 8 14.4l6.5-3.2" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/></svg>',
|
|
@@ -88,6 +91,17 @@ export function viewBar(h) {
|
|
|
88
91
|
type: 'button', class: 'fx-pop__item', role, 'aria-checked': String(checked), onclick,
|
|
89
92
|
}, h('span', { class: 'fx-pop__tick' }, checked ? svg(ICONS.tick) : null), icon ? lucide(h, icon) : null, h('span', {}, text));
|
|
90
93
|
|
|
94
|
+
// A choice in a list where many can be ticked, as ClickUp's value list:
|
|
95
|
+
// the choice's mark at the left only when it has one (a Lucide `icon`, a
|
|
96
|
+
// custom field `color`, or a status `rank`), its name, a tick box at the
|
|
97
|
+
// right.
|
|
98
|
+
const mark = (c) => (c.icon ? lucide(h, c.icon)
|
|
99
|
+
: c.color ? h('span', { class: 'fx-dot', 'data-color': c.color, 'aria-hidden': 'true' })
|
|
100
|
+
: c.rank != null ? h('span', { class: 'fx-dot fx-dot--stage', 'data-rank': String(c.rank), 'aria-hidden': 'true' }) : null);
|
|
101
|
+
const checkOption = (c, checked, onclick) => h('button', {
|
|
102
|
+
type: 'button', class: 'fx-pop__item fx-pop__item--check', role: 'menuitemcheckbox', 'aria-checked': String(checked), onclick,
|
|
103
|
+
}, mark(c), h('span', { class: 'fx-pop__text' }, c.label), h('span', { class: 'fx-pop__box', 'aria-hidden': 'true' }, checked ? svg(ICONS.tick) : null));
|
|
104
|
+
|
|
91
105
|
// The parts of a rows panel (Group by, Sort, Filters): a title, rows of
|
|
92
106
|
// selects with a bin at the end, and a foot with "Add" and "Clear all".
|
|
93
107
|
const title = (text) => h('p', { class: 'fx-rows__title' }, text);
|
|
@@ -101,113 +115,205 @@ export function viewBar(h) {
|
|
|
101
115
|
const rowsFoot = (...parts) => h('div', { class: 'fx-rows__foot' }, ...parts);
|
|
102
116
|
const clearAll = (onclick) => h('button', { type: 'button', class: 'fx-btn fx-btn--ghost fx-btn--sm', onclick }, 'Clear all');
|
|
103
117
|
|
|
118
|
+
// A button that opens a list to pick one choice from, as ClickUp's menus
|
|
119
|
+
// in a filter row: options [{ value, label, icon? }], a search box when
|
|
120
|
+
// `search` is on, and a tick on the chosen one.
|
|
121
|
+
function picker({ label, value, options, onpick, search = false, empty = 'Choose…', extra = '' }) {
|
|
122
|
+
const id = `fx-view-pick-${++choiceCount}`;
|
|
123
|
+
const chosen = options.find((o) => o.value === value) ?? null;
|
|
124
|
+
const button = h('button', {
|
|
125
|
+
type: 'button', class: `fx-select fx-select--sm fx-choice ${extra}`.trim(), popovertarget: id, 'aria-label': label, title: chosen?.label ?? label,
|
|
126
|
+
}, chosen?.icon ? lucide(h, chosen.icon) : null, h('span', { class: 'fx-choice__text' }, chosen?.label ?? empty));
|
|
127
|
+
const pop = h('div', { class: 'fx-pop fx-pop--choices', id, popover: 'auto', role: 'menu' });
|
|
128
|
+
const find = search ? h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Search…', 'aria-label': `Search ${label.toLowerCase()}` }) : null;
|
|
129
|
+
const items = h('div', {});
|
|
130
|
+
const drawItems = () => {
|
|
131
|
+
const q = find?.value.trim().toLowerCase() ?? '';
|
|
132
|
+
const shown = options.filter((o) => !q || o.label.toLowerCase().includes(q));
|
|
133
|
+
items.replaceChildren(...(shown.length ? shown.map((o) => option(o.label, o.value === value, () => { pop.hidePopover(); onpick(o.value); }, 'menuitemradio', o.icon ?? null))
|
|
134
|
+
: [h('p', { class: 'fx-rows__empty' }, 'Nothing matches.')]));
|
|
135
|
+
};
|
|
136
|
+
find?.addEventListener('input', drawItems);
|
|
137
|
+
pop.append(...[find, items].filter(Boolean));
|
|
138
|
+
pop.addEventListener('toggle', (event) => {
|
|
139
|
+
if (event.newState !== 'open') return;
|
|
140
|
+
if (find) find.value = '';
|
|
141
|
+
drawItems();
|
|
142
|
+
place(button, pop);
|
|
143
|
+
find?.focus();
|
|
144
|
+
});
|
|
145
|
+
return [button, pop];
|
|
146
|
+
}
|
|
147
|
+
const place = (button, pop) => {
|
|
148
|
+
const at = button.getBoundingClientRect();
|
|
149
|
+
pop.style.top = `${Math.round(at.bottom + 4)}px`;
|
|
150
|
+
pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
|
|
151
|
+
};
|
|
152
|
+
|
|
104
153
|
// ── Filters ──
|
|
105
|
-
//
|
|
106
|
-
//
|
|
154
|
+
// As ClickUp's: groups of rows of field, operator and value, with a bin on
|
|
155
|
+
// each row. A group's second row has And / Or for the rows in it; the
|
|
156
|
+
// second group has And / Or between the groups. "Add nested filter" adds a
|
|
157
|
+
// row to a group, "Add filter" a new group, "Clear all" empties it.
|
|
107
158
|
function filterMenu({ fields, kindOf, opsOf: ownOps = () => null, choicesOf = () => [], view, change, first, empty = 'No filters. Every row shows.' }) {
|
|
108
159
|
const opsOf = (field) => ownOps(field) ?? OPERATORS[kindOf(field)] ?? {};
|
|
109
|
-
//
|
|
160
|
+
// The field list: one list, as ClickUp's, each field with its type icon.
|
|
161
|
+
const fieldList = () => fields().flatMap((o) => (o.group ? o.options : [o]))
|
|
162
|
+
.map((o) => ({ ...o, icon: o.icon ?? KIND_ICONS[kindOf(o.value)] ?? null }));
|
|
163
|
+
// A row's value when its field or operator changes: kept when it fits.
|
|
110
164
|
function valueFor(field, op, old) {
|
|
111
165
|
const kind = kindOf(field);
|
|
166
|
+
if (kind === 'date' && ['is', 'not'].includes(op)) return typeof old === 'string' && dateValueIsOn(old) ? old : 'today';
|
|
112
167
|
if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return Array.isArray(old) ? old : [];
|
|
113
|
-
if (['before', 'after', 'on'].includes(op)) return typeof old === 'string' && /^\d{4}-/.test(old) ? old : today();
|
|
114
168
|
if (['eq', 'gt', 'lt'].includes(op)) return typeof old === 'number' ? old : null;
|
|
115
|
-
if (['has', 'is'].includes(op)) return typeof old === 'string' ? old : '';
|
|
169
|
+
if (['has', 'hasnt', 'is'].includes(op)) return typeof old === 'string' ? old : '';
|
|
116
170
|
return null;
|
|
117
171
|
}
|
|
118
|
-
const
|
|
119
|
-
const
|
|
172
|
+
const groups = () => view().groups;
|
|
173
|
+
const setGroups = (next) => change({ groups: next.filter((g) => g.filters.length) });
|
|
174
|
+
const newRow = (field = first) => {
|
|
175
|
+
const op = Object.keys(opsOf(field))[0];
|
|
176
|
+
return { field, op, value: valueFor(field, op, null) };
|
|
177
|
+
};
|
|
178
|
+
const setRow = (gi, ri, patch) => setGroups(groups().map((g, n) => (n === gi ? { ...g, filters: g.filters.map((f, m) => (m === ri ? { ...f, ...patch } : f)) } : g)));
|
|
179
|
+
const dropRow = (gi, ri) => setGroups(groups().map((g, n) => (n === gi ? { ...g, filters: g.filters.filter((_, m) => m !== ri) } : g)));
|
|
120
180
|
// A choice row's value: a button that opens a list of choices to tick,
|
|
121
|
-
// with a search
|
|
122
|
-
function choiceValue(
|
|
181
|
+
// with a search and "Select all", as ClickUp's.
|
|
182
|
+
function choiceValue(gi, ri, filter) {
|
|
123
183
|
const choices = choicesOf(filter.field);
|
|
124
184
|
const named = () => {
|
|
125
185
|
const on = choices.filter((c) => filter.value.includes(c.value)).map((c) => c.label);
|
|
126
|
-
return on.length ? on.join(', ') : '
|
|
186
|
+
return on.length ? on.join(', ') : 'Select option';
|
|
127
187
|
};
|
|
128
188
|
const id = `fx-view-choice-${++choiceCount}`;
|
|
129
189
|
const button = h('button', { type: 'button', class: 'fx-select fx-select--sm fx-rows__value', popovertarget: id, title: named() }, named());
|
|
130
190
|
const pop = h('div', { class: 'fx-pop fx-pop--choices', id, popover: 'auto', role: 'menu' });
|
|
131
|
-
const find =
|
|
191
|
+
const find = h('input', { class: 'fx-input fx-input--sm', type: 'search', placeholder: 'Search…', 'aria-label': 'Search the choices' });
|
|
132
192
|
const items = h('div', {});
|
|
193
|
+
const save = (value) => {
|
|
194
|
+
filter = { ...filter, value };
|
|
195
|
+
setRow(gi, ri, { value });
|
|
196
|
+
button.textContent = named();
|
|
197
|
+
button.title = named();
|
|
198
|
+
drawItems();
|
|
199
|
+
};
|
|
200
|
+
const all = h('button', { type: 'button', class: 'fx-pop__link', onclick: () => save(filter.value.length === choices.length ? [] : choices.map((c) => c.value)) });
|
|
133
201
|
const drawItems = () => {
|
|
134
|
-
const q = find
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
button.title = named();
|
|
140
|
-
drawItems();
|
|
141
|
-
}, 'menuitemcheckbox', c.icon ?? null)));
|
|
202
|
+
const q = find.value.trim().toLowerCase();
|
|
203
|
+
all.textContent = filter.value.length === choices.length && choices.length ? 'Clear' : 'Select all';
|
|
204
|
+
items.replaceChildren(...choices.filter((c) => !q || c.label.toLowerCase().includes(q)).map((c) => checkOption(c, filter.value.includes(c.value), () => {
|
|
205
|
+
save(filter.value.includes(c.value) ? filter.value.filter((v) => v !== c.value) : [...filter.value, c.value]);
|
|
206
|
+
})));
|
|
142
207
|
};
|
|
143
|
-
find
|
|
144
|
-
pop.append(
|
|
208
|
+
find.addEventListener('input', drawItems);
|
|
209
|
+
pop.append(find, h('div', { class: 'fx-pop__bar' }, h('span', { class: 'fx-pop__head' }, fieldList().find((f) => f.value === filter.field)?.label ?? 'Options'), all), items);
|
|
145
210
|
pop.addEventListener('toggle', (event) => {
|
|
146
211
|
if (event.newState !== 'open') return;
|
|
147
212
|
drawItems();
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
pop.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - pop.offsetWidth - 8)))}px`;
|
|
151
|
-
find?.focus();
|
|
213
|
+
place(button, pop);
|
|
214
|
+
find.focus();
|
|
152
215
|
});
|
|
153
216
|
return [button, pop];
|
|
154
217
|
}
|
|
155
|
-
|
|
218
|
+
// A date row's value: ClickUp's list of ranges, then a day, two days or
|
|
219
|
+
// a count of days when the range needs one.
|
|
220
|
+
function dateValue(gi, ri, filter) {
|
|
221
|
+
const value = filter.value ?? 'today';
|
|
222
|
+
const [kind, rest = ''] = value.split(':');
|
|
223
|
+
const range = DAY_BETWEEN.exec(value);
|
|
224
|
+
const base = range ? 'between' : kind;
|
|
225
|
+
const set = (next) => { setRow(gi, ri, { value: next }); };
|
|
226
|
+
const pick = picker({
|
|
227
|
+
label: 'Date', value: base, search: true, extra: 'fx-rows__value',
|
|
228
|
+
options: Object.entries(DATE_VALUES).map(([v, l]) => ({ value: v, label: l })),
|
|
229
|
+
onpick: (v) => {
|
|
230
|
+
const day = today();
|
|
231
|
+
set({ on: `on:${day}`, before: `before:${day}`, after: `after:${day}`, between: `between:${day}..${day}`, last: 'last:7', next: 'next:7' }[v] ?? v);
|
|
232
|
+
redrawPanel();
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
const dayInput = (at, onchange) => h('input', { class: 'fx-input fx-input--sm', type: 'date', 'aria-label': 'Day', value: at, onchange: (event) => { if (event.target.value) onchange(event.target.value); } });
|
|
236
|
+
let more = null;
|
|
237
|
+
if (['on', 'before', 'after'].includes(base)) more = dayInput(rest, (d) => set(`${base}:${d}`));
|
|
238
|
+
if (range) {
|
|
239
|
+
more = h('span', { class: 'fx-rows__days' },
|
|
240
|
+
dayInput(range[1], (d) => set(`between:${d}..${range[2]}`)), dayInput(range[2], (d) => set(`between:${range[1]}..${d}`)));
|
|
241
|
+
}
|
|
242
|
+
if (['last', 'next'].includes(base)) {
|
|
243
|
+
more = h('span', { class: 'fx-rows__days' }, h('input', {
|
|
244
|
+
class: 'fx-input fx-input--sm', type: 'number', min: '1', max: '9999', step: '1', 'aria-label': 'Days', value: rest,
|
|
245
|
+
oninput: (event) => { const n = Number(event.target.value); if (Number.isInteger(n) && n > 0 && n < 10000) set(`${base}:${n}`); },
|
|
246
|
+
}), h('span', { class: 'fx-rows__lead' }, 'days'));
|
|
247
|
+
}
|
|
248
|
+
return h('span', { class: 'fx-rows__value fx-rows__date' }, ...pick, more);
|
|
249
|
+
}
|
|
250
|
+
function valueControl(gi, ri, filter) {
|
|
156
251
|
const kind = kindOf(filter.field);
|
|
157
252
|
const { op } = filter;
|
|
158
|
-
if (
|
|
159
|
-
if (['
|
|
160
|
-
return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'date', 'aria-label': 'Date', value: filter.value ?? '', onchange: (event) => setRow(i, { value: event.target.value || null }) });
|
|
161
|
-
}
|
|
253
|
+
if (kind === 'date' && ['is', 'not'].includes(op)) return dateValue(gi, ri, filter);
|
|
254
|
+
if (['is', 'not'].includes(op) && ['choice', 'person'].includes(kind)) return choiceValue(gi, ri, filter);
|
|
162
255
|
if (['eq', 'gt', 'lt'].includes(op)) {
|
|
163
256
|
return h('input', {
|
|
164
257
|
class: 'fx-input fx-input--sm fx-rows__value', type: 'number', step: 'any', inputmode: 'decimal', 'aria-label': 'Number', value: filter.value ?? '',
|
|
165
|
-
oninput: (event) => setRow(
|
|
258
|
+
oninput: (event) => setRow(gi, ri, { value: event.target.value === '' ? null : Number(event.target.value) }),
|
|
166
259
|
});
|
|
167
260
|
}
|
|
168
|
-
if (['has', 'is'].includes(op)) {
|
|
169
|
-
return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'text', 'aria-label': 'Text', value: filter.value ?? '', oninput: (event) => setRow(
|
|
261
|
+
if (['has', 'hasnt', 'is'].includes(op)) {
|
|
262
|
+
return h('input', { class: 'fx-input fx-input--sm fx-rows__value', type: 'text', placeholder: 'Search…', 'aria-label': 'Text', value: filter.value ?? '', oninput: (event) => setRow(gi, ri, { value: event.target.value }) });
|
|
170
263
|
}
|
|
171
264
|
return h('span', { class: 'fx-rows__value fx-rows__value--none' });
|
|
172
265
|
}
|
|
266
|
+
// And / Or: a small menu on the second row (or group), the word after it.
|
|
267
|
+
const joinControl = (label, match, onpick) => picker({
|
|
268
|
+
label, value: match, extra: 'fx-rows__join', options: [{ value: 'all', label: 'AND' }, { value: 'any', label: 'OR' }], onpick,
|
|
269
|
+
});
|
|
270
|
+
let redrawPanel = () => {};
|
|
173
271
|
const control = menu(ICONS.filter, (redraw) => {
|
|
174
|
-
|
|
175
|
-
const list =
|
|
272
|
+
redrawPanel = redraw;
|
|
273
|
+
const list = fieldList();
|
|
274
|
+
const all = groups();
|
|
275
|
+
const { match } = view();
|
|
276
|
+
const row = (gi, g, filter, ri) => {
|
|
277
|
+
const lead = ri === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
|
|
278
|
+
: ri === 1 ? joinControl('Match in this group', g.match, (value) => { setGroups(all.map((x, n) => (n === gi ? { ...x, match: value } : x))); redraw(); })
|
|
279
|
+
: h('span', { class: 'fx-rows__lead fx-rows__word' }, g.match === 'any' ? 'OR' : 'AND');
|
|
280
|
+
const remove = bin('Remove this filter', () => { dropRow(gi, ri); redraw(); });
|
|
281
|
+
if (!kindOf(filter.field)) return h('div', { class: 'fx-rows__row' }, lead, h('span', { class: 'fx-rows__gone' }, 'A custom field that was deleted'), remove);
|
|
282
|
+
const ops = opsOf(filter.field);
|
|
283
|
+
return h('div', { class: 'fx-rows__row' },
|
|
284
|
+
lead,
|
|
285
|
+
...picker({
|
|
286
|
+
label: 'Field', value: filter.field, options: list, search: true,
|
|
287
|
+
onpick: (field) => { const op = Object.keys(opsOf(field))[0]; setRow(gi, ri, { field, op, value: valueFor(field, op, null) }); redraw(); },
|
|
288
|
+
}),
|
|
289
|
+
...picker({
|
|
290
|
+
label: 'Operator', value: filter.op, options: Object.entries(ops).map(([value, label]) => ({ value, label })),
|
|
291
|
+
onpick: (op) => { setRow(gi, ri, { op, value: valueFor(filter.field, op, filter.value) }); redraw(); },
|
|
292
|
+
}),
|
|
293
|
+
valueControl(gi, ri, filter),
|
|
294
|
+
remove);
|
|
295
|
+
};
|
|
296
|
+
const count = all.reduce((n, g) => n + g.filters.length, 0);
|
|
297
|
+
const box = (g, gi) => h('div', { class: 'fx-fgroup' },
|
|
298
|
+
h('div', { class: 'fx-rows' }, g.filters.map((filter, ri) => row(gi, g, filter, ri))),
|
|
299
|
+
count < 20 ? h('button', { type: 'button', class: 'fx-fgroup__add', onclick: () => { setGroups(all.map((x, n) => (n === gi ? { ...x, filters: [...x.filters, newRow()] } : x))); redraw(); } }, 'Add nested filter') : null);
|
|
300
|
+
const body = all.length === 1 ? box(all[0], 0) : h('div', { class: 'fx-fgroups' }, all.map((g, gi) => h('div', { class: 'fx-fgroups__row' },
|
|
301
|
+
gi === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
|
|
302
|
+
: gi === 1 ? h('span', {}, ...joinControl('Match between groups', match, (value) => { change({ match: value }); redraw(); }))
|
|
303
|
+
: h('span', { class: 'fx-rows__lead fx-rows__word' }, match === 'any' ? 'OR' : 'AND'),
|
|
304
|
+
box(g, gi))));
|
|
176
305
|
return [
|
|
177
306
|
title('Filters'),
|
|
178
|
-
|
|
179
|
-
const known = kindOf(filter.field);
|
|
180
|
-
const lead = i === 0 ? h('span', { class: 'fx-rows__lead' }, 'Where')
|
|
181
|
-
: i === 1 ? choose('Match', match, [{ value: 'all', label: 'And' }, { value: 'any', label: 'Or' }], (value) => { change({ match: value }); redraw(); })
|
|
182
|
-
: h('span', { class: 'fx-rows__lead' }, match === 'any' ? 'Or' : 'And');
|
|
183
|
-
if (!known) {
|
|
184
|
-
return h('div', { class: 'fx-rows__row' }, lead, h('span', { class: 'fx-rows__gone' }, 'A custom field that was deleted'), bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
|
|
185
|
-
}
|
|
186
|
-
const ops = opsOf(filter.field);
|
|
187
|
-
return h('div', { class: 'fx-rows__row' },
|
|
188
|
-
lead,
|
|
189
|
-
choose('Field', filter.field, list, (field) => {
|
|
190
|
-
const op = Object.keys(opsOf(field))[0];
|
|
191
|
-
setRow(i, { field, op, value: valueFor(field, op, null) });
|
|
192
|
-
redraw();
|
|
193
|
-
}),
|
|
194
|
-
choose('Operator', filter.op, Object.entries(ops).map(([value, label]) => ({ value, label })), (op) => { setRow(i, { op, value: valueFor(filter.field, op, filter.value) }); redraw(); }),
|
|
195
|
-
valueControl(i, filter),
|
|
196
|
-
bin('Remove this filter', () => { setFilters(filters.filter((_, n) => n !== i)); redraw(); }));
|
|
197
|
-
})) : h('p', { class: 'fx-rows__empty' }, empty),
|
|
307
|
+
all.length ? body : h('p', { class: 'fx-rows__empty' }, empty),
|
|
198
308
|
rowsFoot(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
setFilters([...filters, { field: first, op, value: valueFor(first, op, null) }]);
|
|
202
|
-
redraw();
|
|
203
|
-
}) : h('span'),
|
|
204
|
-
filters.length ? clearAll(() => { setFilters([]); redraw(); }) : null),
|
|
309
|
+
count < 20 ? addButton('Add filter', () => { setGroups([...all, { match: 'all', filters: [newRow()] }]); redraw(); }) : h('span'),
|
|
310
|
+
all.length ? clearAll(() => { change({ groups: [], match: 'all' }); redraw(); }) : null),
|
|
205
311
|
];
|
|
206
312
|
}, { rows: true, clearLabel: 'Clear all filters' });
|
|
207
313
|
// The chip names how many filters are on.
|
|
208
314
|
const draw = () => {
|
|
209
|
-
const count = filterCount(view());
|
|
210
|
-
control.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0,
|
|
315
|
+
const count = filterCount(view(), kindOf);
|
|
316
|
+
control.set(count ? `${count} ${count === 1 ? 'Filter' : 'Filters'}` : 'Filter', count > 0, groups().length ? () => change({ groups: [], match: 'all' }) : null);
|
|
211
317
|
};
|
|
212
318
|
return { ...control, draw };
|
|
213
319
|
}
|
package/server/ui/xero-import.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
49
49
|
const history = params.get('show') === 'all';
|
|
50
50
|
const icon = (name, label) => lucide(h, name, label);
|
|
51
51
|
const host = h('div', { class: 'fx-xi' }, h('p', { class: 'fx-dim' }, 'Loading…'));
|
|
52
|
-
const
|
|
52
|
+
const tally = h('span', {});
|
|
53
53
|
const actions = [];
|
|
54
54
|
let view = params.get('view') === 'list' ? 'list' : 'card';
|
|
55
55
|
if (!history) {
|
|
@@ -66,11 +66,8 @@ export async function renderXeroImport(main, ui) {
|
|
|
66
66
|
renderReview();
|
|
67
67
|
}
|
|
68
68
|
main.append(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
h('a', { href: '/integrations/xero', onclick: (event) => ui.go(event) }, 'Xero'), h('span', { 'aria-hidden': 'true' }, '›'),
|
|
72
|
-
h('span', {}, history ? 'History' : 'Review')),
|
|
73
|
-
ui.pageHeader(history ? 'Xero history' : 'Review', { subtitle, actions }),
|
|
69
|
+
ui.crumbs(['/integrations', 'Integrations'], ['/integrations/xero', 'Xero'], history ? 'History' : 'Review'),
|
|
70
|
+
ui.pageHeader(history ? 'Xero history' : 'Review', { count: tally, actions }),
|
|
74
71
|
host);
|
|
75
72
|
|
|
76
73
|
let organisationId;
|
|
@@ -117,7 +114,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
117
114
|
return;
|
|
118
115
|
}
|
|
119
116
|
if (!rows.length) {
|
|
120
|
-
|
|
117
|
+
tally.replaceChildren('');
|
|
121
118
|
host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Nothing to check.'), go('/xero-import?show=all', 'Xero history')));
|
|
122
119
|
return;
|
|
123
120
|
}
|
|
@@ -134,7 +131,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
134
131
|
const fit = flowFit(row, pick);
|
|
135
132
|
const bar = h('span', { class: 'fx-xi__progress', role: 'progressbar', 'aria-valuemin': '1', 'aria-valuemax': String(rows.length), 'aria-valuenow': String(at + 1) },
|
|
136
133
|
h('span', { style: { width: `${((at + 1) / rows.length) * 100}%` } }));
|
|
137
|
-
|
|
134
|
+
tally.replaceChildren(h('span', { class: 'fx-xi__count' }, bar, `${at + 1} / ${rows.length}`));
|
|
138
135
|
const days = daysApart(row.issuedOn, fit.date);
|
|
139
136
|
const facts = [
|
|
140
137
|
['hash', 'Number', h('span', { class: 'fx-num' }, row.number ?? '—'), h('span', { class: 'fx-num' }, fit.name), null],
|
|
@@ -195,7 +192,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
195
192
|
|
|
196
193
|
// Every one to check, best fit beside it; ticked rows go from the bulk bar.
|
|
197
194
|
function reviewList(rows) {
|
|
198
|
-
|
|
195
|
+
tally.replaceChildren(`${rows.length} to check`);
|
|
199
196
|
const ticked = new Set();
|
|
200
197
|
const boxes = new Map();
|
|
201
198
|
const drawBar = () => {
|
|
@@ -315,7 +312,7 @@ export async function renderXeroImport(main, ui) {
|
|
|
315
312
|
return;
|
|
316
313
|
}
|
|
317
314
|
const total = Object.values(page.counts).reduce((a, b) => a + b, 0);
|
|
318
|
-
|
|
315
|
+
tally.replaceChildren(h('span', { class: 'fx-xi__count' }, icon('check', 'In Flow'), `${page.counts.linked} / ${total}`));
|
|
319
316
|
shown = offset === 0 ? page.rows : [...shown, ...page.rows];
|
|
320
317
|
const rows = page.rows.map(rowOf);
|
|
321
318
|
if (offset === 0) body.replaceChildren(...(rows.length ? rows : [h('tr', {}, h('td', { colspan: '8', class: 'fx-empty' }, q.trim() ? 'No match.' : 'No Xero invoices copied yet.'))]));
|