@arcforgelabs/flow 2026.10.51 → 2026.10.52
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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server/api/captures.js +26 -4
- package/server/api/invoicing.js +1 -1
- package/server/app.js +2 -0
- package/server/auth/people.js +3 -3
- package/server/core/ai.js +2 -1
- package/server/core/capture-match.js +158 -0
- package/server/core/captures.js +111 -7
- package/server/core/invoice-rules.js +16 -5
- package/server/core/reader.js +1 -10
- package/server/core/sending.js +7 -3
- package/server/db/migrations/0024_rule_icon.sql +5 -0
- package/server/db/migrations/meta/0024_snapshot.json +4703 -0
- package/server/db/migrations/meta/_journal.json +8 -1
- package/server/db/schema.js +3 -0
- package/server/integrations/seazona/history/backfill.js +39 -9
- package/server/integrations/seazona/index.js +1 -12
- package/server/integrations/seazona/stage-moves.js +3 -13
- package/server/integrations/sentry/index.js +6 -6
- package/server/integrations/setup.js +1 -0
- package/server/integrations/xero/drafts.js +27 -13
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +61 -20
- package/server/ui/capture-fields.js +17 -0
- package/server/ui/captures.js +283 -0
- package/server/ui/columns.js +68 -3
- package/server/ui/flow.css +92 -17
- package/server/ui/invoice-rules.js +10 -2
- package/server/ui/invoicing-settings.js +2 -8
- package/server/ui/invoicing.js +12 -8
- package/server/ui/job-view.js +6 -3
- package/server/ui/jobs.js +40 -117
- package/server/ui/kit.js +86 -1
- package/server/ui/list-views.js +47 -3
- package/server/ui/mobile.js +45 -5
- package/server/ui/nav-pins.js +4 -2
- package/server/ui/price-list.js +138 -69
- package/server/ui/routes.js +4 -1
- package/server/ui/saved-views.js +2 -0
- package/server/ui/view-bar.js +61 -2
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// or admin changes them. The full rules are in docs/INVOICING_RULES.md.
|
|
11
11
|
// app.js passes in its shared pieces as `ui`.
|
|
12
12
|
|
|
13
|
-
import { rulesSection } from '/assets/ui/invoice-rules.js';
|
|
13
|
+
import { ruleMark, rulesSection } from '/assets/ui/invoice-rules.js';
|
|
14
14
|
import { lineMappingSection } from '/assets/ui/line-mappings.js';
|
|
15
15
|
|
|
16
16
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICING_RULES.md';
|
|
@@ -27,12 +27,6 @@ const PARTS = [
|
|
|
27
27
|
{ key: 'price-codes', title: 'Price codes', mount: (ui) => lineMappingSection(ui) },
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
-
// What a rule does, as its card's mark: Pending, Waits, Never drafted.
|
|
31
|
-
const KIND = {
|
|
32
|
-
pending: { mark: 'flag' },
|
|
33
|
-
wait: { mark: 'clock' },
|
|
34
|
-
skip: { mark: 'ban' },
|
|
35
|
-
};
|
|
36
30
|
const SHOWN = 6;
|
|
37
31
|
|
|
38
32
|
export function renderInvoicingSettings(main, ui, id) {
|
|
@@ -60,7 +54,7 @@ export function renderInvoicingSettings(main, ui, id) {
|
|
|
60
54
|
return;
|
|
61
55
|
}
|
|
62
56
|
const ruleCard = (rule) => tileCard({
|
|
63
|
-
row: true, href: page('invoices'), mark: (
|
|
57
|
+
row: true, href: page('invoices'), mark: ruleMark(rule), title: rule.name,
|
|
64
58
|
meta: rule.enabled ? 'On' : 'Off',
|
|
65
59
|
});
|
|
66
60
|
const waiting = labels.filter((l) => l.unmapped > 0).length;
|
package/server/ui/invoicing.js
CHANGED
|
@@ -641,16 +641,20 @@ export async function renderInvoiceList(main, ui) {
|
|
|
641
641
|
};
|
|
642
642
|
const href = (invoice) => `/invoices/${invoice.id}`;
|
|
643
643
|
const ALL = {
|
|
644
|
-
|
|
645
|
-
|
|
644
|
+
// The client is the row's link: on a phone it is the title, with a 44px hit area.
|
|
645
|
+
number: numbered ? { kind: 'number', phone: 'meta', className: 'fx-strong', cell: (i) => i.number ?? 'Draft' } : null,
|
|
646
|
+
client: { kind: 'client', phone: 'title', cell: (i) => link(href(i), i.contactName ?? 'No client') },
|
|
647
|
+
// A phone row: the client (and number) and total, then the problem,
|
|
648
|
+
// the reference or the status, and the date. A tab without numbers
|
|
649
|
+
// holds one status: its tab says it.
|
|
646
650
|
reference: showProblem
|
|
647
|
-
? { label: 'Problem', kind: 'text', cell: problemCell }
|
|
648
|
-
: { kind: 'text', className: 'fx-dim', cell: (i) => i.reference ?? '—' },
|
|
649
|
-
date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
|
|
651
|
+
? { label: 'Problem', kind: 'text', phone: 'meta', cell: problemCell }
|
|
652
|
+
: { kind: 'text', className: 'fx-dim', phone: numbered ? 'hide' : 'meta', cell: (i) => i.reference ?? '—' },
|
|
653
|
+
date: { label: created ? 'Created' : 'Date', kind: 'date', className: 'fx-dim', phone: 'meta', cell: (i) => dateCell(h, i.issueDate ?? i.createdAt?.slice(0, 10)) },
|
|
650
654
|
due: { kind: 'date', className: 'fx-dim', cell: (i) => dateCell(h, i.dueDate) },
|
|
651
655
|
total: { kind: 'total', num: true, className: 'fx-strong', cell: (i) => formatCents(i.totalCents) },
|
|
652
|
-
status: { kind: 'status', className: 'fx-status-cell', cell: statusCell },
|
|
653
|
-
linked: linked ? { kind: 'link', cell: linkCell } : null,
|
|
656
|
+
status: { kind: 'status', className: 'fx-status-cell', ...(numbered ? {} : { phone: 'hide' }), cell: statusCell },
|
|
657
|
+
linked: linked ? { kind: 'link', phone: 'hide', cell: linkCell } : null,
|
|
654
658
|
// The invoice's job (from the work system): its case, practitioner and
|
|
655
659
|
// the day it shipped.
|
|
656
660
|
case: { kind: 'case', className: 'fx-dim', cell: (i) => (i.job ? caseNumber(i.job) ?? '—' : '—') },
|
|
@@ -1449,7 +1453,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
1449
1453
|
h('div', { class: 'fx-invoice__main' },
|
|
1450
1454
|
h('section', { class: 'fx-card fx-doc' },
|
|
1451
1455
|
h('dl', { class: 'fx-doc__meta' },
|
|
1452
|
-
h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim' }, invoice.contact.email) : null)),
|
|
1456
|
+
h('div', {}, h('dt', { class: 'fx-label' }, 'Bill to'), h('dd', {}, invoice.contact?.name ?? '—', invoice.contact?.email ? h('span', { class: 'fx-dim', title: invoice.contact.email }, invoice.contact.email) : null)),
|
|
1453
1457
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Reference'), h('dd', {}, invoice.reference ?? '—')),
|
|
1454
1458
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Date'), h('dd', {}, showDate(invoice.issueDate))),
|
|
1455
1459
|
h('div', {}, h('dt', { class: 'fx-label' }, 'Due'), h('dd', {}, showDate(invoice.dueDate)))),
|
package/server/ui/job-view.js
CHANGED
|
@@ -273,7 +273,10 @@ function inDateValue(day, want, today, late) {
|
|
|
273
273
|
// person looking (for "Me" in a person filter); `today` is yyyy-mm-dd.
|
|
274
274
|
export function matchesFilter(filter, value, { today, me = null, late = null, kind = null } = {}) {
|
|
275
275
|
const want = filter.value;
|
|
276
|
-
|
|
276
|
+
// A field can hold many choices (the Price list's Linked): Is keeps a row
|
|
277
|
+
// with any of the wanted ones, Is not a row with none.
|
|
278
|
+
const empty = value == null || value === '' || value === false || (Array.isArray(value) && !value.length);
|
|
279
|
+
const holds = (w) => (Array.isArray(value) ? value.includes(w) : value === w);
|
|
277
280
|
const day = typeof value === 'string' && ISO_DAY.test(value.slice(0, 10)) ? value.slice(0, 10) : null;
|
|
278
281
|
if (kind === 'date' && ['is', 'not'].includes(filter.op)) {
|
|
279
282
|
const inside = inDateValue(day, want, today, late);
|
|
@@ -284,8 +287,8 @@ export function matchesFilter(filter, value, { today, me = null, late = null, ki
|
|
|
284
287
|
case 'unset': return empty;
|
|
285
288
|
case 'yes': return value === true;
|
|
286
289
|
case 'no': return value !== true;
|
|
287
|
-
case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).
|
|
288
|
-
case 'not': return !want.map((w) => (w === 'me' ? me : w)).
|
|
290
|
+
case 'is': return Array.isArray(want) ? want.map((w) => (w === 'me' ? me : w)).some(holds) : !empty && text(value) === text(want).trim();
|
|
291
|
+
case 'not': return !want.map((w) => (w === 'me' ? me : w)).some(holds);
|
|
289
292
|
case 'has': return text(value).includes(text(want).trim());
|
|
290
293
|
case 'hasnt': return !text(value).includes(text(want).trim());
|
|
291
294
|
case 'eq': return typeof value === 'number' && value === want;
|
package/server/ui/jobs.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// long press opens Move to. With a keyboard, the job's panel has the same moves.
|
|
14
14
|
|
|
15
15
|
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
16
|
-
import {
|
|
16
|
+
import { captureButton } from '/assets/ui/captures.js';
|
|
17
|
+
import { columnTable, dateCell, fieldColumn, groupHead, personCell, rowCells, viewSaver } from '/assets/ui/columns.js';
|
|
17
18
|
import { lucide } from '/assets/ui/custom-fields.js';
|
|
18
19
|
import { formatCents } from '/assets/ui/invoicing.js';
|
|
19
20
|
import { jobChangeReview } from '/assets/ui/job-change.js';
|
|
@@ -92,7 +93,6 @@ function saveSide(tab) {
|
|
|
92
93
|
|
|
93
94
|
const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
|
|
94
95
|
const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
95
|
-
const CHEVRON_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="m6 3.5 4.5 4.5L6 12.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
96
96
|
const CALENDAR_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="11" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
|
|
97
97
|
|
|
98
98
|
export async function renderJobs(main, ui) {
|
|
@@ -126,7 +126,9 @@ export async function renderJobs(main, ui) {
|
|
|
126
126
|
// In the list, ticked rows get the same floating bar as Invoices.
|
|
127
127
|
const bulk = bulkBar(ui, { label: 'Ticked jobs' });
|
|
128
128
|
const frame = listFrame(main, host);
|
|
129
|
-
|
|
129
|
+
// A form photo from the Jobs page; Capture lists it with its result.
|
|
130
|
+
const shoot = captureButton(ui, { icon: true, added: () => ui.toast('Sent to Capture.', 'success') });
|
|
131
|
+
main.append(...listHead('Jobs', { views: views.element, controls, end: [find.element, toggle, ...shoot.nodes] }).nodes, frame.element, bulk.element);
|
|
130
132
|
|
|
131
133
|
const drawToggle = () => toggle.replaceChildren(...[['board', 'Board'], ['list', 'List']].map(([id, label]) => h('button', {
|
|
132
134
|
type: 'button', role: 'radio', class: 'fx-seg__item', 'aria-checked': String(state.view.layout === id),
|
|
@@ -170,7 +172,7 @@ export async function renderJobs(main, ui) {
|
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
// The chips (view-bar.js), shared with every list page.
|
|
173
|
-
const { menu, option,
|
|
175
|
+
const { menu, option, groupMenu: buildGroupMenu, sortMenu: buildSortMenu, filterMenu: buildFilterMenu } = viewBar(h);
|
|
174
176
|
const DATE_WORDS = ['Oldest first', 'Newest first'];
|
|
175
177
|
const NUMBER_WORDS = ['Low to high', 'High to low'];
|
|
176
178
|
const DIR_WORDS = { total: NUMBER_WORDS, due: DATE_WORDS, ordered: DATE_WORDS, shipped: DATE_WORDS };
|
|
@@ -178,54 +180,31 @@ export async function renderJobs(main, ui) {
|
|
|
178
180
|
const dirWords = (by) => DIR_WORDS[by] ?? TYPE_WORDS[fieldById(fieldIdOf(by))?.type] ?? ['A to Z', 'Z to A'];
|
|
179
181
|
const fieldById = (id) => state.fields.find((f) => f.id === id) ?? null;
|
|
180
182
|
|
|
181
|
-
// ── Group by ──
|
|
182
|
-
//
|
|
183
|
-
//
|
|
183
|
+
// ── Group by, Sort ── (view-bar.js). The board always has lanes, so only
|
|
184
|
+
// the list can take the groups away. In the list, a column's head sorts
|
|
185
|
+
// too.
|
|
184
186
|
const groupOptions = () => [
|
|
185
187
|
...Object.entries(GROUPS).filter(([id]) => id !== 'none').map(([value, label]) => ({ value, label })),
|
|
186
188
|
{ group: 'Custom fields', options: groupable().map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
187
189
|
];
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
{ group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
207
|
-
];
|
|
208
|
-
const setSorts = (sorts) => change({ sorts });
|
|
209
|
-
const sortMenu = menu(ICONS.sort, (redraw) => {
|
|
210
|
-
const { sorts } = state.view;
|
|
211
|
-
const unused = sortOptions().flatMap((o) => o.options ?? [o]).filter((o) => !sorts.some((s) => s.by === o.value));
|
|
212
|
-
return [
|
|
213
|
-
title('Sort'),
|
|
214
|
-
sorts.length ? h('div', { class: 'fx-rows' }, sorts.map((s, i) => {
|
|
215
|
-
const [up, down] = dirWords(s.by);
|
|
216
|
-
const others = sorts.filter((_, n) => n !== i).map((x) => x.by);
|
|
217
|
-
const options = sortOptions().map((o) => (o.group ? { ...o, options: o.options.filter((x) => !others.includes(x.value)) } : o)).filter((o) => o.group || !others.includes(o.value));
|
|
218
|
-
return h('div', { class: 'fx-rows__row fx-rows__row--sort' },
|
|
219
|
-
h('span', { class: 'fx-rows__lead' }, i ? 'then' : 'Sort by'),
|
|
220
|
-
choose('Sort by', s.by, options, (by) => { setSorts(sorts.map((x, n) => (n === i ? { by, dir: x.dir } : x))); redraw(); }),
|
|
221
|
-
choose('Order', s.dir, [{ value: 'asc', label: up }, { value: 'desc', label: down }], (dir) => { setSorts(sorts.map((x, n) => (n === i ? { ...x, dir } : x))); redraw(); }),
|
|
222
|
-
bin('Remove this sort', () => { setSorts(sorts.filter((_, n) => n !== i)); redraw(); }));
|
|
223
|
-
})) : h('p', { class: 'fx-rows__empty' }, 'No sort. Jobs show in the lab system’s order.'),
|
|
224
|
-
rowsFoot(
|
|
225
|
-
sorts.length < 5 && unused.length ? addButton('Add sort', () => { setSorts([...sorts, { by: unused[0].value, dir: 'asc' }]); redraw(); }) : h('span'),
|
|
226
|
-
sorts.length ? clearAll(() => { setSorts([]); redraw(); }) : null),
|
|
227
|
-
];
|
|
228
|
-
}, { rows: true, clearLabel: 'Clear the sort' });
|
|
190
|
+
const isList = () => state.view.layout === 'list';
|
|
191
|
+
const groupMenu = buildGroupMenu({
|
|
192
|
+
options: groupOptions,
|
|
193
|
+
view: () => ({ group: state.view.group === 'none' && !isList() ? 'status' : state.view.group, groupDir: state.view.groupDir }),
|
|
194
|
+
change,
|
|
195
|
+
clearable: isList,
|
|
196
|
+
});
|
|
197
|
+
const sortMenu = buildSortMenu({
|
|
198
|
+
options: () => [
|
|
199
|
+
...Object.entries(SORTS).map(([value, label]) => ({ value, label })),
|
|
200
|
+
{ group: 'Custom fields', options: state.fields.map((f) => ({ value: fieldGroup(f.id), label: f.name })) },
|
|
201
|
+
],
|
|
202
|
+
view: () => state.view,
|
|
203
|
+
change,
|
|
204
|
+
dirWords,
|
|
205
|
+
nameOf: (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field',
|
|
206
|
+
empty: 'No sort. Jobs show in the lab system’s order.',
|
|
207
|
+
});
|
|
229
208
|
|
|
230
209
|
// ── Filters ──
|
|
231
210
|
// The shared filter panel (view-bar.js), as ClickUp's: groups of rows of
|
|
@@ -301,8 +280,6 @@ export async function renderJobs(main, ui) {
|
|
|
301
280
|
});
|
|
302
281
|
controls.append(...groupMenu.node, ...sortMenu.node, ...filterMenu.node, ...columnsMenu.node);
|
|
303
282
|
|
|
304
|
-
// A sort's name: a built-in key or a custom field.
|
|
305
|
-
const sortName = (by) => SORTS[by] ?? fieldById(fieldIdOf(by))?.name ?? 'Custom field';
|
|
306
283
|
function drawControls() {
|
|
307
284
|
const { group, sorts, layout } = state.view;
|
|
308
285
|
const list = layout === 'list';
|
|
@@ -314,9 +291,7 @@ export async function renderJobs(main, ui) {
|
|
|
314
291
|
groupMenu.set(label, shown !== DEFAULT_VIEW.group, grouped ? () => change({ group: list ? 'none' : 'status' }) : null);
|
|
315
292
|
// In the list, the column heads sort; the Sort chip shows there only
|
|
316
293
|
// while a sort is on, so it can be seen and cleared.
|
|
317
|
-
|
|
318
|
-
sortMenu.set(sorts.length > 1 ? `Sort · ${sorts.length}` : first ? `Sort: ${sortName(first.by)} ${first.dir === 'desc' ? '↓' : '↑'}` : 'Sort', sorts.length > 0,
|
|
319
|
-
sorts.length ? () => change({ sorts: [] }) : null);
|
|
294
|
+
sortMenu.draw();
|
|
320
295
|
sortMenu.wrap.hidden = list && !sorts.length;
|
|
321
296
|
filterMenu.draw();
|
|
322
297
|
columnsMenu.set('Columns', false);
|
|
@@ -809,44 +784,6 @@ export async function renderJobs(main, ui) {
|
|
|
809
784
|
if (field.type === 'checkbox') return value === true ? 1 : 0;
|
|
810
785
|
return typeof value === 'string' ? value.toLowerCase() : value;
|
|
811
786
|
}
|
|
812
|
-
// A column head's sort button, as ClickUp's: it shows on hover, or
|
|
813
|
-
// always while it sorts. A click sorts one way, again the other way, and
|
|
814
|
-
// a third time takes the sort off. A right-click on a sorting head opens
|
|
815
|
-
// a small menu: change the direction, or clear the sort.
|
|
816
|
-
function sortButton(columnId, label) {
|
|
817
|
-
const by = sortOf(columnId);
|
|
818
|
-
if (!by) return null;
|
|
819
|
-
const { sorts } = state.view;
|
|
820
|
-
const at = sorts.findIndex((s) => s.by === by);
|
|
821
|
-
const on = at >= 0 ? sorts[at] : null;
|
|
822
|
-
const [up, down] = dirWords(by);
|
|
823
|
-
const next = () => {
|
|
824
|
-
if (!on) change({ sorts: [{ by, dir: 'asc' }] });
|
|
825
|
-
else if (on.dir === 'asc') change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: 'desc' } : s)) });
|
|
826
|
-
else change({ sorts: sorts.filter((_, n) => n !== at) });
|
|
827
|
-
};
|
|
828
|
-
const words = on ? `Sorted ${on.dir === 'asc' ? up : down}. Click to ${on.dir === 'asc' ? `sort ${down}` : 'clear the sort'}` : `Sort by ${label}`;
|
|
829
|
-
return h('button', {
|
|
830
|
-
type: 'button', class: `fx-th-sort${on ? ' fx-th-sort--on' : ''}`, 'aria-label': words, title: words,
|
|
831
|
-
onclick: (event) => { event.stopPropagation(); next(); },
|
|
832
|
-
oncontextmenu: (event) => {
|
|
833
|
-
if (!on) return;
|
|
834
|
-
event.preventDefault();
|
|
835
|
-
event.stopPropagation();
|
|
836
|
-
onRightButton(event, () => sortMenuAt(event, by, label, on, at));
|
|
837
|
-
},
|
|
838
|
-
}, lucide(h, on ? (on.dir === 'asc' ? 'arrow-up' : 'arrow-down') : 'chevrons-up-down'),
|
|
839
|
-
on && sorts.length > 1 ? h('span', { class: 'fx-th-sort__n' }, String(at + 1)) : null);
|
|
840
|
-
}
|
|
841
|
-
function sortMenuAt(event, by, label, on, at) {
|
|
842
|
-
const { sorts } = state.view;
|
|
843
|
-
const flip = () => change({ sorts: sorts.map((s, n) => (n === at ? { ...s, dir: s.dir === 'asc' ? 'desc' : 'asc' } : s)) });
|
|
844
|
-
heads.menu.show([
|
|
845
|
-
h('button', { type: 'button', class: 'fx-pop__item', role: 'menuitemcheckbox', 'aria-checked': 'true', title: 'Change the direction', onclick: () => { heads.menu.close(); flip(); } },
|
|
846
|
-
h('span', { class: 'fx-pop__tick' }, lucide(h, on.dir === 'asc' ? 'arrow-up' : 'arrow-down')), h('span', {}, `Sort by ${label}`), h('span', { class: 'fx-pop__end' }, svg(ICONS.tick))),
|
|
847
|
-
heads.menu.item('ban', 'Clear sort', () => change({ sorts: sorts.filter((_, n) => n !== at) })),
|
|
848
|
-
], event);
|
|
849
|
-
}
|
|
850
787
|
// The list's heads (columns.js): drag an edge to resize, drag a head to
|
|
851
788
|
// move it; a click or right-click opens its menu: sort by it, move it,
|
|
852
789
|
// edit its custom field, hide it. Client always shows, first. + lists the
|
|
@@ -862,19 +799,9 @@ export async function renderJobs(main, ui) {
|
|
|
862
799
|
...customColumns().filter((f) => !shown.has(fieldGroup(f.id))).map((f) => ({ id: fieldGroup(f.id), label: f.name })),
|
|
863
800
|
];
|
|
864
801
|
},
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
if (sort) {
|
|
869
|
-
const [up, down] = dirWords(sort);
|
|
870
|
-
parts.push(item('arrow-up-narrow-wide', `Sort: ${up}`, () => change({ sorts: [{ by: sort, dir: 'asc' }] })),
|
|
871
|
-
item('arrow-down-wide-narrow', `Sort: ${down}`, () => change({ sorts: [{ by: sort, dir: 'desc' }] })), h('div', { class: 'fx-pop__sep' }));
|
|
872
|
-
}
|
|
873
|
-
if (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)) {
|
|
874
|
-
parts.push(item('settings', 'Edit custom field', () => ui.go('/custom-fields')));
|
|
875
|
-
}
|
|
876
|
-
return parts;
|
|
877
|
-
},
|
|
802
|
+
sorting: { sortOf, sorts: () => state.view.sorts, change, dirWords },
|
|
803
|
+
extra: (column, item) => (column.id.startsWith('field:') && ['owner', 'admin'].includes(ui.me?.person?.role)
|
|
804
|
+
? [item('settings', 'Edit custom field', () => ui.go('/custom-fields'))] : []),
|
|
878
805
|
});
|
|
879
806
|
|
|
880
807
|
// The list's columns, in the order the person chose them. One column holds
|
|
@@ -896,14 +823,14 @@ export async function renderJobs(main, ui) {
|
|
|
896
823
|
const allowed = new Set(builtInColumns());
|
|
897
824
|
// Client is always the first column: it has no drag, Move or Hide.
|
|
898
825
|
const client = {
|
|
899
|
-
id: 'client', label: 'Client', kind: 'client', fixed: true,
|
|
826
|
+
id: 'client', label: 'Client', kind: 'client', fixed: true,
|
|
900
827
|
cell: (job) => h('span', { class: 'fx-job__cell' }, h('span', { class: 'fx-strong' }, clientOf(job)), problemMark(job)),
|
|
901
828
|
};
|
|
902
829
|
return [client, ...state.view.columns.map((id) => {
|
|
903
830
|
if (BUILT_IN[id]) return allowed.has(id) ? { id, label: id === 'assignee' ? assigneeField().name : COLUMNS[id], ...BUILT_IN[id] } : null;
|
|
904
831
|
const field = customColumns().find((f) => fieldGroup(f.id) === id);
|
|
905
832
|
return field ? fieldColumn(h, field, { id, valueOf: (job) => valueOf(job, field.id)?.value ?? null, people: state.people }) : null;
|
|
906
|
-
}).filter(Boolean)
|
|
833
|
+
}).filter(Boolean)];
|
|
907
834
|
}
|
|
908
835
|
|
|
909
836
|
// A person custom field's value as a badge (initials), the name on hover.
|
|
@@ -953,17 +880,13 @@ export async function renderJobs(main, ui) {
|
|
|
953
880
|
saveFolded(state.folded);
|
|
954
881
|
draw();
|
|
955
882
|
};
|
|
956
|
-
const head = ({ label, jobs, rank, color }) => {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
},
|
|
962
|
-
|
|
963
|
-
return h('tr', { class: `fx-table__group${open ? '' : ' fx-table__group--folded'}`, onclick: () => fold(label) },
|
|
964
|
-
tickCell(setBox(jobs, `Tick every job in ${label}`)),
|
|
965
|
-
h('td', { colspan: String(columns.length + 1) }, h('span', { class: 'fx-table__group-row' }, toggle, h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))))));
|
|
966
|
-
};
|
|
883
|
+
const head = ({ label, jobs, rank, color }) => groupHead(h, {
|
|
884
|
+
label: `${label}, ${jobs.length} ${jobs.length === 1 ? 'job' : 'jobs'}`,
|
|
885
|
+
badge: color ? h('span', { class: 'fx-cf__opt fx-board__lane', 'data-color': color }, label, h('span', { class: 'fx-stage__count' }, String(jobs.length))) : stagePill(label, jobs.length, rank),
|
|
886
|
+
open: !folded.has(label), onFold: () => fold(label),
|
|
887
|
+
lead: tickCell(setBox(jobs, `Tick every job in ${label}`)), span: columns.length + 1,
|
|
888
|
+
extra: h('span', { class: 'fx-board__sum' }, formatCents(totalOf(jobs))),
|
|
889
|
+
});
|
|
967
890
|
return h('div', { class: 'fx-panel' }, heads.table({
|
|
968
891
|
className: 'fx-table--jobs',
|
|
969
892
|
columns,
|
package/server/ui/kit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The shared pieces every Flow page builds with: elements (h), the page
|
|
2
2
|
// header, notes, toasts, dialogs, setting rows, the ⋯ row menu, the button
|
|
3
|
-
// that waits between presses, cards that open a page, and the crumbs trail. app.js hands them to each view module in ui(),
|
|
3
|
+
// that waits between presses, cards that open a page, the icon picker, and the crumbs trail. app.js hands them to each view module in ui(),
|
|
4
4
|
// and the design system's cards (scripts/design-system/) render these same
|
|
5
5
|
// functions, so a card is the real thing. Reuse one or give it an option;
|
|
6
6
|
// never copy it into a page (AGENTS.md, "UI: reuse first").
|
|
@@ -499,6 +499,91 @@ export function tileSection(title, cards, { href, more = 'Show all' } = {}) {
|
|
|
499
499
|
h('div', { class: 'fx-tiles__grid' }, cards)), 'tileSection');
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
+
// The icons an icon picker shows before a search. A search finds any Lucide
|
|
503
|
+
// icon by its name or its words (lucide-static's tags.json).
|
|
504
|
+
export const ICON_CHOICES = Object.freeze([
|
|
505
|
+
'flag', 'clock', 'ban', 'list-checks', 'circle-alert', 'triangle-alert', 'octagon-alert', 'shield-alert', 'bell', 'bell-ring',
|
|
506
|
+
'info', 'circle-help', 'circle-check', 'circle-x', 'check', 'x', 'eye', 'eye-off', 'lock', 'unlock',
|
|
507
|
+
'star', 'heart', 'bookmark', 'tag', 'tags', 'hash', 'percent', 'dollar-sign', 'banknote', 'receipt',
|
|
508
|
+
'receipt-text', 'wallet', 'credit-card', 'coins', 'calculator', 'piggy-bank', 'hand-coins', 'file-text', 'file-check', 'file-x',
|
|
509
|
+
'file-warning', 'files', 'folder', 'clipboard-list', 'clipboard-check', 'notebook-pen', 'pencil', 'pen-line', 'stamp', 'send',
|
|
510
|
+
'mail', 'inbox', 'archive', 'calendar', 'calendar-clock', 'calendar-x', 'calendar-check', 'hourglass', 'timer', 'alarm-clock',
|
|
511
|
+
'history', 'repeat', 'refresh-cw', 'user', 'user-round', 'users', 'user-check', 'user-x', 'contact', 'building',
|
|
512
|
+
'building-2', 'hospital', 'house', 'map-pin', 'truck', 'package', 'box', 'boxes', 'shopping-cart', 'store',
|
|
513
|
+
'briefcase', 'wrench', 'hammer', 'cog', 'sliders-horizontal', 'filter', 'search', 'scan-search', 'scale', 'gavel',
|
|
514
|
+
'shield', 'shield-check', 'badge-check', 'award', 'thumbs-up', 'thumbs-down', 'smile', 'frown', 'zap', 'sparkles',
|
|
515
|
+
'flame', 'snowflake', 'sun', 'moon', 'droplet', 'leaf', 'stethoscope', 'syringe', 'pill', 'microscope',
|
|
516
|
+
'test-tube', 'flask-conical', 'activity', 'heart-pulse', 'bone', 'hand', 'camera', 'image', 'printer', 'phone',
|
|
517
|
+
'message-square', 'link', 'unlink', 'globe', 'pause', 'play', 'circle-stop', 'octagon-x', 'rocket', 'target',
|
|
518
|
+
'lightbulb', 'puzzle', 'layers', 'list', 'split', 'merge', 'git-branch', 'text-cursor-input', 'type', 'skull',
|
|
519
|
+
]);
|
|
520
|
+
const ICON_FOUND = 96;
|
|
521
|
+
let iconWords = null;
|
|
522
|
+
let pickerNext = 0;
|
|
523
|
+
|
|
524
|
+
// An icon picker (IconPicker card): a button that shows the icon now chosen,
|
|
525
|
+
// and a panel with a search box and a grid of icons, each named on hover. On
|
|
526
|
+
// a desk the panel opens under the button; on a phone it is a sheet at the
|
|
527
|
+
// foot. `value` null shows `fallback`, and "Default" goes back to it.
|
|
528
|
+
// `onpick(name)` gets each choice (null for Default).
|
|
529
|
+
export function iconPicker({ value = null, fallback = 'circle', label = 'Icon', onpick = () => {} }) {
|
|
530
|
+
let current = value;
|
|
531
|
+
let shown = fallback;
|
|
532
|
+
const id = `fx-icons-${++pickerNext}`;
|
|
533
|
+
const button = h('button', {
|
|
534
|
+
class: 'fx-icons__button', type: 'button', 'aria-haspopup': 'dialog', popovertarget: id,
|
|
535
|
+
});
|
|
536
|
+
const find = h('input', { class: 'fx-input', type: 'search', placeholder: 'Find', 'aria-label': 'Find an icon' });
|
|
537
|
+
const grid = h('div', { class: 'fx-icons__grid', role: 'group', 'aria-label': label });
|
|
538
|
+
const reset = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Default');
|
|
539
|
+
const panel = applyPhoneRule(h('div', { class: 'fx-pop fx-icons__panel', id, popover: 'auto', role: 'dialog', 'aria-label': label },
|
|
540
|
+
find, grid, h('div', { class: 'fx-icons__foot' }, reset)), 'iconPicker');
|
|
541
|
+
const draw = () => {
|
|
542
|
+
named(button, `${label}: ${current ?? shown}`);
|
|
543
|
+
button.replaceChildren(lucide(h, current ?? shown), lucide(h, 'chevron-down'));
|
|
544
|
+
};
|
|
545
|
+
const choose = (name) => {
|
|
546
|
+
panel.hidePopover();
|
|
547
|
+
current = name;
|
|
548
|
+
draw();
|
|
549
|
+
onpick(name);
|
|
550
|
+
};
|
|
551
|
+
const cell = (name) => named(h('button', {
|
|
552
|
+
class: 'fx-icons__cell', type: 'button', 'aria-pressed': String(name === (current ?? shown)), onclick: () => choose(name),
|
|
553
|
+
}, lucide(h, name)), name);
|
|
554
|
+
const fill = () => {
|
|
555
|
+
const q = find.value.trim().toLowerCase();
|
|
556
|
+
const words = iconWords ?? Object.fromEntries(ICON_CHOICES.map((n) => [n, []]));
|
|
557
|
+
const names = q ? Object.keys(words).filter((n) => n.includes(q) || words[n].some((w) => w.includes(q))).slice(0, ICON_FOUND) : ICON_CHOICES;
|
|
558
|
+
grid.replaceChildren(...(names.length ? names.map(cell) : [h('p', { class: 'fx-dim' }, 'No icon.')]));
|
|
559
|
+
};
|
|
560
|
+
find.addEventListener('input', fill);
|
|
561
|
+
reset.addEventListener('click', () => choose(null));
|
|
562
|
+
panel.addEventListener('toggle', async (event) => {
|
|
563
|
+
button.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
564
|
+
if (event.newState !== 'open') return;
|
|
565
|
+
find.value = '';
|
|
566
|
+
reset.hidden = current === null;
|
|
567
|
+
fill();
|
|
568
|
+
if (panel.dataset.phoneLayout !== 'sheet') {
|
|
569
|
+
const at = button.getBoundingClientRect();
|
|
570
|
+
panel.style.top = `${Math.round(Math.max(8, Math.min(at.bottom + 6, innerHeight - panel.offsetHeight - 8)))}px`;
|
|
571
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(at.left, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
572
|
+
find.focus();
|
|
573
|
+
}
|
|
574
|
+
// The words of every Lucide icon, once. Without them (the design system's
|
|
575
|
+
// page), a search reads the names here.
|
|
576
|
+
iconWords ??= await fetch('/assets/icons/tags.json').then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
|
577
|
+
if (iconWords && find.value) fill();
|
|
578
|
+
});
|
|
579
|
+
draw();
|
|
580
|
+
return {
|
|
581
|
+
element: h('span', { class: 'fx-icons' }, button, panel),
|
|
582
|
+
value: () => current,
|
|
583
|
+
fallback: (name) => { shown = name; draw(); },
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
502
587
|
// A dialog that fills the screen has no pixel outside it, so an accessibility
|
|
503
588
|
// check cannot tell it is modal and reads the page underneath. While one is
|
|
504
589
|
// open, every other top-level node is inert. The attribute is what the check
|
package/server/ui/list-views.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// A person's saved views of a list page (Jobs, Invoices, Reconcile
|
|
1
|
+
// A person's saved views of a list page (Jobs, Invoices, Reconcile, Price
|
|
2
|
+
// list), as
|
|
2
3
|
// ClickUp's view tabs: each has a name and the page's view (Group, Sort,
|
|
3
4
|
// Filter, Columns), and one can be the person's default, which opens when
|
|
4
5
|
// they go to the page. No DOM: the server checks a saved list with the same
|
|
5
6
|
// rules the page uses (server/auth/people.js).
|
|
6
7
|
import { caseNumber } from './job-board.js';
|
|
7
|
-
import { cleanFilterView, fieldIdOf, filterJobs, KIND_OF_TYPE, normaliseJobsView } from './job-view.js';
|
|
8
|
+
import { cleanFilterView, fieldIdOf, filterJobs, KIND_OF_TYPE, normaliseJobsView, normaliseListView, PRICE_COLUMNS } from './job-view.js';
|
|
8
9
|
|
|
9
10
|
// The Invoices filter's fields (invoicing.js): the list's own columns, then
|
|
10
11
|
// the job columns the Columns menu offers (Case, Practitioner, Shipped).
|
|
@@ -111,6 +112,48 @@ export const RECONCILE_FILTER_FIELDS = {
|
|
|
111
112
|
match: { label: 'Match', kind: 'check', icon: 'link' },
|
|
112
113
|
};
|
|
113
114
|
|
|
115
|
+
// The Price list's fields (price-list.js): Filter, Group and Sort read
|
|
116
|
+
// them. `group` marks the fields a person can group by.
|
|
117
|
+
export const PRICE_FIELDS = {
|
|
118
|
+
code: { label: 'Code', kind: 'text', icon: 'hash' },
|
|
119
|
+
name: { label: 'Name', kind: 'text', icon: 'text' },
|
|
120
|
+
price: { label: 'Price', kind: 'number', icon: 'dollar-sign' },
|
|
121
|
+
tax: { label: 'Tax', kind: 'choice', icon: 'percent', group: true },
|
|
122
|
+
linked: { label: 'Linked', kind: 'choice', icon: 'link', group: true },
|
|
123
|
+
differs: { label: 'Differs', kind: 'check', icon: 'triangle-alert', group: true },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// A code's value for a Price list field, as Filter, Group and Sort compare
|
|
127
|
+
// it. Linked is the systems the code links to (a list: a code can link to
|
|
128
|
+
// Xero and Seazona both).
|
|
129
|
+
export function priceValue(item, field) {
|
|
130
|
+
switch (field) {
|
|
131
|
+
case 'code': return item.code ?? null;
|
|
132
|
+
case 'name': return item.name ?? null;
|
|
133
|
+
case 'price': return item.unitAmountCents == null ? null : item.unitAmountCents / 100;
|
|
134
|
+
case 'tax': return item.taxCode ?? null;
|
|
135
|
+
case 'linked': return [...new Set((item.links ?? []).map((l) => l.system))];
|
|
136
|
+
case 'differs': return (item.findings?.length ?? 0) > 0;
|
|
137
|
+
default: return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The Price list view: its columns (as before), Group, Sort and Filter.
|
|
142
|
+
const PRICE_DIRS = ['asc', 'desc'];
|
|
143
|
+
export function normalisePriceView(input) {
|
|
144
|
+
const value = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
return {
|
|
147
|
+
...normaliseListView(value, PRICE_COLUMNS),
|
|
148
|
+
group: PRICE_FIELDS[value.group]?.group ? value.group : 'none',
|
|
149
|
+
groupDir: PRICE_DIRS.includes(value.groupDir) ? value.groupDir : 'asc',
|
|
150
|
+
sorts: (Array.isArray(value.sorts) ? value.sorts : [])
|
|
151
|
+
.filter((s) => s && Object.hasOwn(PRICE_FIELDS, s.by) && !seen.has(s.by) && seen.add(s.by))
|
|
152
|
+
.map((s) => ({ by: s.by, dir: PRICE_DIRS.includes(s.dir) ? s.dir : 'asc' })).slice(0, 5),
|
|
153
|
+
...cleanFilterView(value, kindIn(PRICE_FIELDS, false)),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
114
157
|
const CUSTOM_FIELD = /^field:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
115
158
|
const kindIn = (fields, custom) => (field) => (Object.hasOwn(fields, field) ? fields[field].kind : custom && CUSTOM_FIELD.test(field) ? '*' : null);
|
|
116
159
|
|
|
@@ -120,12 +163,13 @@ export const normaliseReconcileView = (value) => cleanFilterView(value, kindIn(R
|
|
|
120
163
|
|
|
121
164
|
// The pages with saved views. Each is named in a switch, never looked up
|
|
122
165
|
// by the name a request sends, so a name such as "constructor" is refused.
|
|
123
|
-
export const SAVED_VIEW_PAGES = Object.freeze(['jobs', 'invoices', 'reconcile']);
|
|
166
|
+
export const SAVED_VIEW_PAGES = Object.freeze(['jobs', 'invoices', 'reconcile', 'price-list']);
|
|
124
167
|
export function pageViewCheck(page) {
|
|
125
168
|
switch (page) {
|
|
126
169
|
case 'jobs': return normaliseJobsView;
|
|
127
170
|
case 'invoices': return normaliseInvoicesView;
|
|
128
171
|
case 'reconcile': return normaliseReconcileView;
|
|
172
|
+
case 'price-list': return normalisePriceView;
|
|
129
173
|
default: return null;
|
|
130
174
|
}
|
|
131
175
|
}
|
package/server/ui/mobile.js
CHANGED
|
@@ -68,6 +68,24 @@ export const RULES = {
|
|
|
68
68
|
"mark": false,
|
|
69
69
|
"card": "Button"
|
|
70
70
|
},
|
|
71
|
+
"captureButton": {
|
|
72
|
+
"component": "captureButton",
|
|
73
|
+
"direction": "button",
|
|
74
|
+
"phone": {
|
|
75
|
+
"layout": "camera"
|
|
76
|
+
},
|
|
77
|
+
"tablet": {
|
|
78
|
+
"layout": "camera"
|
|
79
|
+
},
|
|
80
|
+
"desk": {
|
|
81
|
+
"layout": "files"
|
|
82
|
+
},
|
|
83
|
+
"touch": 44,
|
|
84
|
+
"pageOverflow": "none",
|
|
85
|
+
"screens": [],
|
|
86
|
+
"mark": false,
|
|
87
|
+
"card": "CaptureButton"
|
|
88
|
+
},
|
|
71
89
|
"cardRows": {
|
|
72
90
|
"component": "cardRows",
|
|
73
91
|
"direction": "card per row",
|
|
@@ -203,7 +221,8 @@ export const RULES = {
|
|
|
203
221
|
"invoices-approved",
|
|
204
222
|
"invoices-ticked",
|
|
205
223
|
"invoices-filter",
|
|
206
|
-
"reconcile"
|
|
224
|
+
"reconcile",
|
|
225
|
+
"price-list"
|
|
207
226
|
],
|
|
208
227
|
"card": "Filter"
|
|
209
228
|
},
|
|
@@ -230,7 +249,8 @@ export const RULES = {
|
|
|
230
249
|
"invoices-approved",
|
|
231
250
|
"invoices-ticked",
|
|
232
251
|
"invoices-filter",
|
|
233
|
-
"reconcile"
|
|
252
|
+
"reconcile",
|
|
253
|
+
"price-list"
|
|
234
254
|
],
|
|
235
255
|
"card": "FindBox"
|
|
236
256
|
},
|
|
@@ -270,6 +290,23 @@ export const RULES = {
|
|
|
270
290
|
"mark": false,
|
|
271
291
|
"card": "Icon"
|
|
272
292
|
},
|
|
293
|
+
"iconPicker": {
|
|
294
|
+
"component": "iconPicker",
|
|
295
|
+
"direction": "sheet",
|
|
296
|
+
"phone": {
|
|
297
|
+
"layout": "sheet"
|
|
298
|
+
},
|
|
299
|
+
"tablet": {
|
|
300
|
+
"layout": "popover"
|
|
301
|
+
},
|
|
302
|
+
"desk": {
|
|
303
|
+
"layout": "popover"
|
|
304
|
+
},
|
|
305
|
+
"touch": 44,
|
|
306
|
+
"pageOverflow": "none",
|
|
307
|
+
"screens": [],
|
|
308
|
+
"card": "IconPicker"
|
|
309
|
+
},
|
|
273
310
|
"jobChangeBanner": {
|
|
274
311
|
"component": "jobChangeBanner",
|
|
275
312
|
"direction": "banner",
|
|
@@ -311,7 +348,8 @@ export const RULES = {
|
|
|
311
348
|
"invoices-approved",
|
|
312
349
|
"invoices-ticked",
|
|
313
350
|
"invoices-filter",
|
|
314
|
-
"reconcile"
|
|
351
|
+
"reconcile",
|
|
352
|
+
"price-list"
|
|
315
353
|
],
|
|
316
354
|
"card": "ListFrame"
|
|
317
355
|
},
|
|
@@ -339,7 +377,8 @@ export const RULES = {
|
|
|
339
377
|
"invoices-approved",
|
|
340
378
|
"invoices-ticked",
|
|
341
379
|
"invoices-filter",
|
|
342
|
-
"reconcile"
|
|
380
|
+
"reconcile",
|
|
381
|
+
"price-list"
|
|
343
382
|
],
|
|
344
383
|
"card": "ListHead"
|
|
345
384
|
},
|
|
@@ -534,7 +573,8 @@ export const RULES = {
|
|
|
534
573
|
"invoices-approved",
|
|
535
574
|
"invoices-ticked",
|
|
536
575
|
"invoices-filter",
|
|
537
|
-
"reconcile"
|
|
576
|
+
"reconcile",
|
|
577
|
+
"price-list"
|
|
538
578
|
],
|
|
539
579
|
"card": "SavedViews"
|
|
540
580
|
},
|