@arcforgelabs/flow 2026.10.41 → 2026.10.43
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/invoicing.js +15 -5
- package/server/core/invoice-rules.js +49 -8
- package/server/core/sending.js +33 -5
- package/server/integrations/sentry/index.js +9 -1
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +74 -29
- package/server/ui/automations.js +9 -3
- package/server/ui/custom-fields.js +17 -16
- package/server/ui/flow.css +77 -37
- package/server/ui/invoice-notes.js +7 -5
- package/server/ui/invoice-rules.js +38 -13
- package/server/ui/invoicing-settings.js +69 -35
- package/server/ui/invoicing.js +17 -51
- package/server/ui/job-board.js +20 -8
- package/server/ui/job-change.js +36 -0
- package/server/ui/jobs.js +88 -32
- package/server/ui/monitoring.js +1 -0
- package/server/ui/price-list.js +2 -3
- package/server/ui/price-reconcile.js +7 -8
- package/server/ui/routes.js +1 -1
- package/server/ui/sidecar.js +72 -0
- package/server/ui/tip.js +99 -0
- package/server/ui/xero-import.js +7 -10
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// passes in app.js's shared pieces as `ui`.
|
|
15
15
|
|
|
16
16
|
import { orderStatuses, statusRank } from '/assets/ui/job-board.js';
|
|
17
|
+
import { sidecar } from '/assets/ui/sidecar.js';
|
|
17
18
|
|
|
18
19
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CUSTOM_FIELDS.md';
|
|
19
20
|
const TYPE_LABELS = {
|
|
@@ -96,7 +97,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
96
97
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
97
98
|
const host = h('div', { class: 'fx-card fx-cfm__card' }, h('div', { class: 'fx-skeleton' }));
|
|
98
99
|
const addButton = h('button', { class: 'fx-btn fx-btn--primary fx-btn--sm', type: 'button', disabled }, 'Create custom field');
|
|
99
|
-
main.append(pageHeader('Custom fields', {
|
|
100
|
+
main.append(pageHeader('Custom fields', { help: { href: DOCS, label: 'How custom fields work' } }), host);
|
|
100
101
|
|
|
101
102
|
let data = null;
|
|
102
103
|
const groupColor = (type) => data?.groupColors?.[type] ?? 'gray';
|
|
@@ -157,17 +158,19 @@ export async function renderCustomFields(main, ui) {
|
|
|
157
158
|
// the manager. Edit on a row (or Create) opens it; another row's Edit
|
|
158
159
|
// switches it at once, so a person moves between fields quickly. The
|
|
159
160
|
// table stays usable behind it. Save closes it; so do Cancel, × and Esc.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
document.addEventListener('keydown', (event) => {
|
|
169
|
-
if (event.key === 'Escape' && side.isConnected && !side.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) closeSide();
|
|
161
|
+
// The shared sidecar (sidecar.js), over the table.
|
|
162
|
+
const panel = sidecar(h, {
|
|
163
|
+
label: 'Custom field', overlay: true, escape: true,
|
|
164
|
+
onChange: (tab) => {
|
|
165
|
+
if (tab !== null) return;
|
|
166
|
+
sideFor = null;
|
|
167
|
+
table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
|
|
168
|
+
},
|
|
170
169
|
});
|
|
170
|
+
const side = panel.element;
|
|
171
|
+
side.classList.add('fx-cfs');
|
|
172
|
+
let sideFor = null;
|
|
173
|
+
const closeSide = () => panel.close();
|
|
171
174
|
|
|
172
175
|
// Open the sidecar for a field (null: a new one; `draft` fills it after a
|
|
173
176
|
// failed save). A work system's field opens read only.
|
|
@@ -188,7 +191,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
188
191
|
// lies over the label, so the label shows and the browser's list opens.
|
|
189
192
|
const typeText = h('span', {}, TYPE_LABELS[type.value]);
|
|
190
193
|
const headType = field ? typeLabel() : h('label', { class: 'fx-cfs__typepick' }, lucide(h, TYPE_ICONS[type.value]), typeText, lucide(h, 'chevron-down'), type);
|
|
191
|
-
const close =
|
|
194
|
+
const close = panel.closeButton();
|
|
192
195
|
|
|
193
196
|
const name = h('input', { class: 'fx-cfs__name', required: true, maxlength: '60', value: draft?.name ?? '', 'aria-label': 'Custom field name', placeholder: 'For example Priority', readonly: readOnly });
|
|
194
197
|
name.addEventListener('input', changed);
|
|
@@ -245,10 +248,9 @@ export async function renderCustomFields(main, ui) {
|
|
|
245
248
|
await load();
|
|
246
249
|
});
|
|
247
250
|
fit();
|
|
248
|
-
|
|
251
|
+
panel.show(form);
|
|
249
252
|
// Below the toolbar, so search and Create stay in reach.
|
|
250
253
|
side.style.top = `${toolbar.offsetHeight}px`;
|
|
251
|
-
side.hidden = false;
|
|
252
254
|
table.querySelector('.fx-cfm__row[data-open]')?.removeAttribute('data-open');
|
|
253
255
|
if (field) table.querySelector(`.fx-cfm__row[data-id="${CSS.escape(field.id)}"]`)?.setAttribute('data-open', 'true');
|
|
254
256
|
(readOnly ? close : name).focus({ preventScroll: true });
|
|
@@ -493,8 +495,7 @@ export async function renderCustomFields(main, ui) {
|
|
|
493
495
|
}
|
|
494
496
|
table.replaceChildren(...parts);
|
|
495
497
|
// The count is what the table shows, the work systems' fields too.
|
|
496
|
-
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`)
|
|
497
|
-
h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How custom fields work'));
|
|
498
|
+
foot.replaceChildren(h('span', {}, `${count} ${count === 1 ? 'custom field' : 'custom fields'}`));
|
|
498
499
|
}
|
|
499
500
|
const foot = h('div', { class: 'fx-cfm__foot' });
|
|
500
501
|
host.append(foot);
|
package/server/ui/flow.css
CHANGED
|
@@ -170,15 +170,30 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
170
170
|
.fx-main { flex: 1; min-width: 0; max-width: 1180px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 48px) 64px; display: grid; gap: 20px; align-content: start; }
|
|
171
171
|
.fx-main:focus { outline: none; box-shadow: none; }
|
|
172
172
|
|
|
173
|
-
/* Page header: title
|
|
174
|
-
.
|
|
175
|
-
.fx-
|
|
173
|
+
/* Page header: the title only (no line under it), then the actions,
|
|
174
|
+
primary last. One height on every page, with or without a button. */
|
|
175
|
+
.fx-ph { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; min-height: 36px; }
|
|
176
|
+
.fx-ph__text { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
176
177
|
.fx-title {
|
|
177
178
|
color: var(--t-strong);
|
|
178
|
-
font: var(--t-display-weight)
|
|
179
|
+
font: var(--t-display-weight) 24px/1.15 var(--t-display); letter-spacing: -0.015em;
|
|
179
180
|
}
|
|
181
|
+
.fx-ph__count { color: var(--t-dim); font-size: 14px; font-variant-numeric: tabular-nums; }
|
|
182
|
+
/* A record's facts beside its title (Reconcile: client · date · total). */
|
|
180
183
|
.fx-ph__sub { color: var(--t-dim); }
|
|
181
|
-
|
|
184
|
+
/* A docs link as a "?" icon beside a title (helpLink in app.js). */
|
|
185
|
+
.fx-help { display: inline-flex; vertical-align: middle; margin-left: 2px; padding: 4px; border-radius: var(--t-radius); color: var(--t-dim); }
|
|
186
|
+
.fx-help:hover, .fx-help:focus-visible { background: var(--fx-hover); color: var(--t-strong); }
|
|
187
|
+
.fx-help .fx-lucide { width: 16px; height: 16px; }
|
|
188
|
+
.fx-ph__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
189
|
+
|
|
190
|
+
/* The tip an icon-only control shows on hover and keyboard focus (tip.js). */
|
|
191
|
+
.fx-tip {
|
|
192
|
+
position: fixed; top: 0; left: 0; z-index: 1000; max-width: 260px; padding: 4px 8px;
|
|
193
|
+
border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
|
|
194
|
+
font: 500 12px/1.3 var(--t-body); pointer-events: none; white-space: nowrap;
|
|
195
|
+
box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
|
|
196
|
+
}
|
|
182
197
|
|
|
183
198
|
/* ── Notices, pills, tags, toasts ──────────────────────────────────────── */
|
|
184
199
|
.fx-notice {
|
|
@@ -287,10 +302,12 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
287
302
|
.fx-chip { border: 1px solid var(--fx-line); border-radius: 999px; background: var(--t-paper); color: var(--t-text); }
|
|
288
303
|
.fx-int-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; }
|
|
289
304
|
.fx-int-bar .fx-search { margin: 0; }
|
|
305
|
+
.fx-int-bar .fx-row { flex: 1 1 auto; flex-wrap: nowrap; justify-content: flex-end; min-width: 0; }
|
|
306
|
+
.fx-int-bar .fx-row .fx-search { flex: 0 1 320px; width: auto; min-width: 0; }
|
|
307
|
+
.fx-int-bar .fx-row .fx-select { flex: none; }
|
|
290
308
|
.fx-int-sections { display: grid; gap: 32px; }
|
|
291
309
|
.fx-int-section-list { display: grid; gap: 16px; }
|
|
292
310
|
.fx-int-heading { color: var(--t-strong); font: 600 19px/1.3 var(--t-body); }
|
|
293
|
-
.fx-int-heading-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
294
311
|
.fx-int-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
|
295
312
|
.fx-int-card {
|
|
296
313
|
display: grid; grid-template-rows: auto 1fr auto; gap: 14px; padding: 18px;
|
|
@@ -298,6 +315,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
298
315
|
color: var(--t-text); text-decoration: none;
|
|
299
316
|
transition: transform 140ms var(--fx-ease), box-shadow 140ms var(--fx-ease), border-color 140ms var(--fx-ease);
|
|
300
317
|
}
|
|
318
|
+
.fx-int-card--bare { grid-template-rows: auto auto; }
|
|
301
319
|
.fx-int-card:hover, .fx-int-card:focus-visible { transform: translateY(-2px); border-color: var(--t-brd-hi, var(--t-control)); box-shadow: 0 8px 24px rgba(0, 0, 0, .1); }
|
|
302
320
|
.fx-int-card__head { display: flex; align-items: center; gap: 12px; }
|
|
303
321
|
.fx-int-card__head h3 { flex: 1; color: var(--t-strong); font: 650 16px/1.25 var(--t-body); }
|
|
@@ -312,6 +330,7 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
312
330
|
display: grid; place-items: center; flex: none; width: 44px; height: 44px; border-radius: 10px;
|
|
313
331
|
background: #fff; box-shadow: inset 0 0 0 1px var(--fx-line);
|
|
314
332
|
}
|
|
333
|
+
.fx-logo--icon { background: var(--t-accent-bg); color: var(--t-accent); box-shadow: none; }
|
|
315
334
|
.fx-logo img { width: 28px; height: 28px; object-fit: contain; }
|
|
316
335
|
.fx-logo--lg { width: 52px; height: 52px; }
|
|
317
336
|
.fx-logo--lg img { width: 34px; height: 34px; }
|
|
@@ -473,7 +492,8 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
473
492
|
}
|
|
474
493
|
|
|
475
494
|
/* ── Jobs board ─────────────────────────────────────────────────────────── */
|
|
476
|
-
.fx-main
|
|
495
|
+
/* Every page shares .fx-main's width; only the board's lanes use the whole window. */
|
|
496
|
+
.fx-main--board { max-width: none; }
|
|
477
497
|
.fx-main > * { min-width: 0; }
|
|
478
498
|
.fx-toolbar--plain { border-bottom: 0; align-items: center; }
|
|
479
499
|
.fx-toolbar--plain .fx-search { margin-bottom: 0; }
|
|
@@ -674,7 +694,14 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
674
694
|
the activity at the side. It stacks on a phone. */
|
|
675
695
|
.fx-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--t-dim); overflow-wrap: anywhere; }
|
|
676
696
|
.fx-dialog.fx-task { width: min(1040px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow-y: auto; }
|
|
677
|
-
.fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr)
|
|
697
|
+
.fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr) 300px; }
|
|
698
|
+
/* No sidecar: the main area takes the room. Review is wider for its table. */
|
|
699
|
+
.fx-task__grid:has(> .fx-sidecar[hidden]) { grid-template-columns: minmax(0, 1fr); }
|
|
700
|
+
.fx-task__grid:has(> .fx-sidecar [data-tab="review"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr) 400px; }
|
|
701
|
+
.fx-task__side { background: var(--t-s1); }
|
|
702
|
+
.fx-task__tools { display: flex; align-items: center; gap: 4px; }
|
|
703
|
+
.fx-task__tools .fx-lucide { width: 16px; height: 16px; }
|
|
704
|
+
.fx-task__tools [aria-pressed="true"] { background: var(--fx-hover); color: var(--t-strong); }
|
|
678
705
|
.fx-task__main { min-width: 0; padding: 20px 28px 28px; }
|
|
679
706
|
.fx-task__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
680
707
|
.fx-task__crumb { display: flex; flex-wrap: wrap; gap: 6px; color: var(--t-dim); font-size: 12.5px; }
|
|
@@ -689,9 +716,14 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
689
716
|
.fx-task__link { color: var(--t-accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
|
|
690
717
|
.fx-task__link:hover { border-bottom-color: currentColor; }
|
|
691
718
|
.fx-task__fix { scroll-margin-top: 16px; margin-top: 22px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); }
|
|
692
|
-
.fx-task__fix h3, .fx-task__lines h3
|
|
719
|
+
.fx-task__fix h3, .fx-task__lines h3 { margin: 0; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
693
720
|
.fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
|
|
694
|
-
.fx-task__fixrow { padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
|
|
721
|
+
.fx-task__fixrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
|
|
722
|
+
.fx-task__fixrow > strong { font-weight: 600; }
|
|
723
|
+
.fx-task__fixrow > span { flex: 1 1 160px; color: var(--t-text); }
|
|
724
|
+
.fx-task__linkbtn { all: unset; cursor: pointer; color: var(--t-accent); border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
|
|
725
|
+
.fx-task__linkbtn:hover { border-bottom-color: currentColor; }
|
|
726
|
+
.fx-task__linkbtn:focus-visible { box-shadow: var(--fx-focus); }
|
|
695
727
|
.fx-task__fixrow:first-of-type { border-top: 0; }
|
|
696
728
|
.fx-task__choose { scroll-margin-top: 16px; margin-top: 22px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); color: var(--t-strong); font-size: 13.5px; }
|
|
697
729
|
.fx-task__choose > svg { flex: none; width: 20px; height: 20px; color: var(--t-warn); }
|
|
@@ -758,8 +790,9 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
758
790
|
.fx-task__miss { color: var(--t-warn); }
|
|
759
791
|
.fx-task__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
|
|
760
792
|
.fx-task__actions:empty { display: none; }
|
|
761
|
-
.fx-task__activity { min-width: 0;
|
|
762
|
-
|
|
793
|
+
.fx-task__activity { min-width: 0; }
|
|
794
|
+
/* Review in the sidecar: the change banner, without its own box. */
|
|
795
|
+
.fx-task__review .fx-change { margin: 0; padding: 0; border: 0; background: none; }
|
|
763
796
|
.fx-task__activity ol { margin: 0; padding: 0; list-style: none; }
|
|
764
797
|
.fx-task__activity li { position: relative; padding: 0 0 16px 18px; color: var(--t-text); font-size: 13px; }
|
|
765
798
|
.fx-task__activity li::before { content: ""; position: absolute; left: 3px; top: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--t-mute); }
|
|
@@ -771,7 +804,8 @@ th:hover .fx-th-sort, .fx-th-sort:focus-visible, .fx-th-sort--on { opacity: 1; }
|
|
|
771
804
|
.fx-task__grid { grid-template-columns: minmax(0, 1fr); }
|
|
772
805
|
.fx-task__main { padding: 16px; }
|
|
773
806
|
.fx-task__fields { grid-template-columns: minmax(0, 1fr); }
|
|
774
|
-
.fx-
|
|
807
|
+
.fx-task__grid:has(> .fx-sidecar [data-tab="review"][aria-selected="true"]) { grid-template-columns: minmax(0, 1fr); }
|
|
808
|
+
.fx-task__side { border-top: 1px solid var(--fx-line); border-left: 0; }
|
|
775
809
|
}
|
|
776
810
|
|
|
777
811
|
/* ── Dialog ─────────────────────────────────────────────────────────────── */
|
|
@@ -898,8 +932,13 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
898
932
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .15);
|
|
899
933
|
}
|
|
900
934
|
.fx-swatch[aria-checked="true"] { box-shadow: 0 0 0 2px var(--t-paper), 0 0 0 4px var(--t-text); }
|
|
901
|
-
|
|
902
|
-
.fx-swatch--default
|
|
935
|
+
/* The default accent: a pill that says Default, before the colours. */
|
|
936
|
+
.fx-swatch--default {
|
|
937
|
+
display: inline-flex; align-items: center; gap: 6px; width: auto; padding: 0 10px 0 4px; border-radius: 999px;
|
|
938
|
+
background: var(--t-paper); color: var(--t-text); font: 600 12.5px/1 var(--t-body); box-shadow: inset 0 0 0 1px var(--t-control);
|
|
939
|
+
}
|
|
940
|
+
.fx-swatch--default[aria-checked="true"] { box-shadow: inset 0 0 0 1px var(--t-control), 0 0 0 2px var(--t-paper), 0 0 0 4px var(--t-text); }
|
|
941
|
+
.fx-swatch__dot { flex: none; width: 22px; height: 22px; border-radius: 50%; }
|
|
903
942
|
.fx-swatch-custom { display: inline-flex; align-items: center; gap: 8px; color: var(--t-dim); font-size: 13px; font-weight: 600; }
|
|
904
943
|
.fx-swatch-custom input { width: 34px; height: 30px; padding: 0; border: 1px solid var(--t-control); border-radius: var(--t-radius); background: var(--t-paper); cursor: pointer; }
|
|
905
944
|
|
|
@@ -946,7 +985,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
946
985
|
/* ── Phones ─────────────────────────────────────────────────────────────── */
|
|
947
986
|
@media (max-width: 640px) {
|
|
948
987
|
.fx-main { padding-top: 20px; }
|
|
949
|
-
.fx-title { font-size:
|
|
988
|
+
.fx-title { font-size: 22px; }
|
|
950
989
|
.fx-table thead { display: none; }
|
|
951
990
|
.fx-table, .fx-table tbody, .fx-table tr, .fx-table td { display: block; width: 100%; }
|
|
952
991
|
.fx-table tr { padding: 12px 14px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
@@ -955,7 +994,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
955
994
|
.fx-table tbody tr:hover td { background: none; }
|
|
956
995
|
.fx-table__actions { text-align: left; }
|
|
957
996
|
.fx-table__actions .fx-row--end { justify-content: flex-start; }
|
|
958
|
-
.fx-ph__actions, .fx-ph__actions .fx-btn { width: 100%; }
|
|
959
997
|
/* People: the name with its actions (⋯, Approve) on one line, then the
|
|
960
998
|
role select full width, then the status pill when there is one. */
|
|
961
999
|
.fx-people tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 10px; }
|
|
@@ -1118,10 +1156,22 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1118
1156
|
in from the right of the manager and leaves the table usable. */
|
|
1119
1157
|
.fx-cfm__card { position: relative; }
|
|
1120
1158
|
.fx-cfm__card:has(> .fx-cfs:not([hidden])) { min-height: min(640px, 100vh); }
|
|
1121
|
-
|
|
1122
|
-
.
|
|
1123
|
-
|
|
1124
|
-
|
|
1159
|
+
/* The shared sidecar (sidecar.js): a right column, or over the page with
|
|
1160
|
+
--over. Tabs are an icon and one word; × closes it. */
|
|
1161
|
+
.fx-sidecar { display: flex; flex-direction: column; min-width: 0; border-left: 1px solid var(--fx-line); background: var(--t-paper); color: var(--t-text); }
|
|
1162
|
+
.fx-sidecar[hidden] { display: none; }
|
|
1163
|
+
.fx-sidecar--over { position: absolute; inset: 0 0 0 auto; z-index: 5; width: min(420px, 100%); box-shadow: -16px 0 40px rgba(0, 0, 0, .22); animation: fx-sidecar-in .18s var(--fx-ease); }
|
|
1164
|
+
@keyframes fx-sidecar-in { from { transform: translateX(24px); opacity: 0; } }
|
|
1165
|
+
@media (prefers-reduced-motion: reduce) { .fx-sidecar--over { animation: none; } }
|
|
1166
|
+
.fx-sidecar__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 52px; padding: 0 12px 0 8px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
1167
|
+
.fx-sidecar__tabs { display: flex; gap: 2px; min-width: 0; }
|
|
1168
|
+
.fx-sidecar__tab { all: unset; cursor: pointer; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 10px; border-radius: var(--t-radius); color: var(--t-dim); font-size: 13px; font-weight: 500; }
|
|
1169
|
+
.fx-sidecar__tab .fx-lucide { width: 15px; height: 15px; }
|
|
1170
|
+
.fx-sidecar__tab:hover { background: var(--fx-hover); color: var(--t-strong); }
|
|
1171
|
+
.fx-sidecar__tab[aria-selected="true"] { background: var(--fx-hover); color: var(--t-strong); }
|
|
1172
|
+
.fx-sidecar__tab:focus-visible { box-shadow: var(--fx-focus); }
|
|
1173
|
+
.fx-sidecar__mark { width: 6px; height: 6px; border-radius: 50%; background: var(--t-warn); }
|
|
1174
|
+
.fx-sidecar__body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px; }
|
|
1125
1175
|
.fx-cfs__form { position: sticky; top: 0; display: flex; flex-direction: column; width: 100%; height: min(100%, 100vh); max-height: 100vh; min-height: 0; }
|
|
1126
1176
|
.fx-cfs__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 52px; padding: 0 12px 0 16px; border-bottom: 1px solid var(--fx-line-soft); }
|
|
1127
1177
|
.fx-cfs__typename, .fx-cfs__typepick { display: inline-flex; align-items: center; gap: 8px; color: var(--t-text); font-weight: 500; font-size: 14px; }
|
|
@@ -1129,9 +1179,9 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1129
1179
|
.fx-cfs__typepick:hover, .fx-cfs__typepick:focus-within { background: var(--fx-hover); }
|
|
1130
1180
|
.fx-cfs__typepick .fx-lucide { width: 15px; height: 15px; }
|
|
1131
1181
|
.fx-cfs__type { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
|
|
1132
|
-
.fx-
|
|
1133
|
-
.fx-
|
|
1134
|
-
.fx-
|
|
1182
|
+
.fx-sidecar__close, .fx-cfs__delete, .fx-cfs__tool { all: unset; cursor: pointer; display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 6px; color: var(--t-dim); }
|
|
1183
|
+
.fx-sidecar__close:hover, .fx-cfs__tool:hover:not(:disabled) { background: var(--fx-hover); color: var(--t-strong); }
|
|
1184
|
+
.fx-sidecar__close:focus-visible, .fx-cfs__delete:focus-visible, .fx-cfs__tool:focus-visible { box-shadow: var(--fx-focus); }
|
|
1135
1185
|
.fx-cfs__body { flex: 1; min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 0; }
|
|
1136
1186
|
.fx-cfs__section { display: grid; gap: 8px; padding: 14px 16px 4px; }
|
|
1137
1187
|
.fx-cfs__section:last-child { margin-top: 14px; padding-top: 16px; border-top: 1px solid var(--fx-line-soft); }
|
|
@@ -1250,13 +1300,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1250
1300
|
.fx-pager__step:hover:not(:disabled) { background: var(--fx-hover); border-color: var(--fx-line); }
|
|
1251
1301
|
.fx-pager__step:disabled { opacity: 0.35; cursor: default; }
|
|
1252
1302
|
|
|
1253
|
-
/* New invoice from a job: a
|
|
1254
|
-
list of jobs with no invoice. */
|
|
1255
|
-
.fx-fromjob {
|
|
1256
|
-
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px;
|
|
1257
|
-
padding: 12px 16px; border: 1px dashed var(--fx-line); border-radius: var(--t-radius);
|
|
1258
|
-
}
|
|
1259
|
-
.fx-fromjob__text { display: grid; gap: 2px; font-size: 14px; }
|
|
1303
|
+
/* New invoice from a job: a searchable list of jobs with no invoice. */
|
|
1260
1304
|
.fx-dialog:has(.fx-pick__body) { width: min(620px, calc(100vw - 32px)); }
|
|
1261
1305
|
.fx-pick__body { display: grid; gap: 12px; }
|
|
1262
1306
|
.fx-pick { display: grid; max-height: min(420px, 55vh); overflow-y: auto; border: 1px solid var(--fx-line); border-radius: var(--t-radius); }
|
|
@@ -1300,12 +1344,6 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1300
1344
|
.fx-bulk__act[data-tone="danger"] { color: var(--t-danger); }
|
|
1301
1345
|
.fx-bulk__act[data-tone="danger"]:hover { background: var(--t-danger-bg); }
|
|
1302
1346
|
.fx-bulk__sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: var(--fx-line); }
|
|
1303
|
-
/* An icon-only action says its name on hover, in a tip above it. */
|
|
1304
|
-
.fx-bulk [data-tip]:hover::after, .fx-bulk [data-tip]:focus-visible::after {
|
|
1305
|
-
content: attr(data-tip); position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
|
|
1306
|
-
padding: 4px 8px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
|
|
1307
|
-
font: 500 12px/1.2 var(--t-body); pointer-events: none; box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
|
|
1308
|
-
}
|
|
1309
1347
|
/* More: a compact list, one row per action, like a dropdown. */
|
|
1310
1348
|
.fx-pop.fx-bulk__more { width: 248px; min-width: 0; padding: 4px; }
|
|
1311
1349
|
.fx-bulk__search { width: 100%; height: 30px; margin-bottom: 2px; padding: 4px 8px; font-size: 13px; }
|
|
@@ -1370,6 +1408,8 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1370
1408
|
|
|
1371
1409
|
/* A list's own search (view-bar.js findBox): an icon that opens into a box. */
|
|
1372
1410
|
.fx-find { display: inline-flex; align-items: center; }
|
|
1411
|
+
/* On a tab bar the icon sits level with the tab names, not on the line. */
|
|
1412
|
+
.fx-toolbar > .fx-find { align-self: center; }
|
|
1373
1413
|
.fx-find__button { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; }
|
|
1374
1414
|
.fx-find__button .fx-lucide { width: 15px; height: 15px; }
|
|
1375
1415
|
.fx-find__input { display: none; width: min(240px, 60vw); height: 32px; padding: 0 10px; }
|
|
@@ -18,9 +18,11 @@ export function sendModeNote(to, sendMode, what = 'each invoice') {
|
|
|
18
18
|
return text.join(' ');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
// A warning above a draft, only when its Send cannot work: the accounting
|
|
22
|
+
// system is off or takes no invoices. Otherwise nothing: the problems show
|
|
23
|
+
// on their own, and the Send dialog says what Send does.
|
|
24
|
+
export function draftNote(to, invoice) {
|
|
25
|
+
const sendMode = invoice?.sendMode;
|
|
26
|
+
if (!to || !sendMode || (sendMode.connected && sendMode.writesAllowed)) return null;
|
|
27
|
+
return sendModeNote(to, sendMode);
|
|
26
28
|
}
|
|
@@ -11,9 +11,13 @@ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICE_RULES.
|
|
|
11
11
|
const TEST_LABELS = {
|
|
12
12
|
more_than: 'is more than', at_most: 'is at most', is: 'is', is_not: 'is not', is_one_of: 'is one of',
|
|
13
13
|
is_empty: 'is empty', is_not_empty: 'is not empty', has: 'has', has_not: 'does not have',
|
|
14
|
+
is_not_one_of: 'is not one of', contains: 'contains', does_not_contain: 'does not contain',
|
|
15
|
+
starts_with: 'starts with', does_not_start_with: 'does not start with', has_number: 'has a number', one_word: 'is one word',
|
|
14
16
|
};
|
|
15
|
-
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft' };
|
|
16
|
-
const NO_VALUE = ['is_empty', 'is_not_empty'];
|
|
17
|
+
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft', pending: 'Pending, with a note' };
|
|
18
|
+
const NO_VALUE = ['is_empty', 'is_not_empty', 'has_number', 'one_word'];
|
|
19
|
+
const MANY = ['is_one_of', 'is_not_one_of'];
|
|
20
|
+
const TYPED = ['contains', 'does_not_contain', 'starts_with', 'does_not_start_with'];
|
|
17
21
|
|
|
18
22
|
// A rule as one short line: "Job age (days) is more than 90 · Wait for a person".
|
|
19
23
|
export function ruleSummary(rule, meta) {
|
|
@@ -33,7 +37,8 @@ export function ruleSummary(rule, meta) {
|
|
|
33
37
|
const value = Array.isArray(c.value) ? c.value.map((v) => labelOf(field, v)).join(', ') : labelOf(field, c.value);
|
|
34
38
|
return `${field.label} ${test} ${value}`;
|
|
35
39
|
};
|
|
36
|
-
|
|
40
|
+
const then = rule.action.kind === 'pending' ? `Pending: ${rule.action.note}` : ACTION_LABELS[rule.action.kind] ?? rule.action.kind;
|
|
41
|
+
return `${rule.conditions.map(part).join(' and ')} · ${then}`;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export function rulesSection(ui, title = 'Rules') {
|
|
@@ -100,9 +105,19 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
100
105
|
const box = numberBox(value, 'Value');
|
|
101
106
|
valueHost.append(box);
|
|
102
107
|
readValue = () => ({ value: Number(box.value) });
|
|
103
|
-
} else if (test === '
|
|
104
|
-
const
|
|
105
|
-
|
|
108
|
+
} else if (TYPED.includes(test) || (field.kind === 'text' && !MANY.includes(test) && !options(field).length)) {
|
|
109
|
+
const box = h('input', { class: 'fx-input', type: 'text', maxlength: '200', value: Array.isArray(value) ? value[0] ?? '' : value ?? '', 'aria-label': 'Value' });
|
|
110
|
+
valueHost.append(box);
|
|
111
|
+
readValue = () => ({ value: box.value });
|
|
112
|
+
} else if (MANY.includes(test)) {
|
|
113
|
+
const picked = Array.isArray(value) ? value : [];
|
|
114
|
+
const chosen = new Set(picked.map((v) => String(v).toLowerCase()));
|
|
115
|
+
// A value saved earlier that is no longer on the menu stays ticked.
|
|
116
|
+
const known = new Set(options(field).map((o) => String(o.value).toLowerCase()));
|
|
117
|
+
// Ticked values come first, so a long menu shows what the rule holds.
|
|
118
|
+
const list = [...picked.filter((v) => !known.has(String(v).toLowerCase())).map((v) => ({ value: v, label: v })), ...options(field)]
|
|
119
|
+
.sort((x, y) => Number(chosen.has(String(y.value).toLowerCase())) - Number(chosen.has(String(x.value).toLowerCase())));
|
|
120
|
+
const boxes = list.map((o) => {
|
|
106
121
|
const box = h('input', { type: 'checkbox', value: o.value, checked: chosen.has(String(o.value).toLowerCase()) });
|
|
107
122
|
return h('label', { class: 'fx-multi__option' }, box, h('span', {}, o.label));
|
|
108
123
|
});
|
|
@@ -112,10 +127,6 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
112
127
|
const select = choiceSelect(field, Array.isArray(value) ? value[0] : value, 'Value');
|
|
113
128
|
valueHost.append(select);
|
|
114
129
|
readValue = () => ({ value: select.value });
|
|
115
|
-
} else {
|
|
116
|
-
const box = h('input', { class: 'fx-input', type: 'text', value: value ?? '', 'aria-label': 'Value' });
|
|
117
|
-
valueHost.append(box);
|
|
118
|
-
readValue = () => ({ value: box.value });
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
132
|
|
|
@@ -144,6 +155,15 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
144
155
|
const action = h('select', { class: 'fx-select', 'aria-label': 'Then' },
|
|
145
156
|
Object.entries(ACTION_LABELS).map(([value, label]) => h('option', { value }, label)));
|
|
146
157
|
action.value = draft?.action.kind ?? 'wait';
|
|
158
|
+
// A pending rule's note: what a person must fix. It shows on the draft.
|
|
159
|
+
const note = h('input', {
|
|
160
|
+
class: 'fx-input', maxlength: '200', value: draft?.action.note ?? '', 'aria-label': 'Note',
|
|
161
|
+
placeholder: 'For example: Put the practitioner in the job, then check the Reference.',
|
|
162
|
+
});
|
|
163
|
+
const noteField = h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, 'Note on the draft'), note);
|
|
164
|
+
const showNote = () => { noteField.hidden = action.value !== 'pending'; };
|
|
165
|
+
action.addEventListener('change', showNote);
|
|
166
|
+
showNote();
|
|
147
167
|
let editors = [];
|
|
148
168
|
const list = h('div', { class: 'fx-rule__conditions' });
|
|
149
169
|
const addCondition = (start) => {
|
|
@@ -174,9 +194,10 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
174
194
|
title: rule ? 'Edit rule' : 'New rule',
|
|
175
195
|
body: h('div', { class: 'fx-fields fx-rule__dialog' },
|
|
176
196
|
label('Name', name),
|
|
177
|
-
label('When', h('span', { class: 'fx-dim' }, 'A job is ready to invoice and
|
|
197
|
+
label('When', h('span', { class: 'fx-dim' }, 'A job is ready to invoice. A pending rule also checks its draft and the Reference.')),
|
|
178
198
|
label('If all of these match', list, more),
|
|
179
199
|
label('Then', action),
|
|
200
|
+
noteField,
|
|
180
201
|
remove),
|
|
181
202
|
confirm: rule ? 'Save' : 'Add rule',
|
|
182
203
|
});
|
|
@@ -186,7 +207,11 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
186
207
|
return draw();
|
|
187
208
|
}
|
|
188
209
|
if (!data) return null;
|
|
189
|
-
const payload = {
|
|
210
|
+
const payload = {
|
|
211
|
+
name: name.value,
|
|
212
|
+
conditions: editors.map((e) => e.read()),
|
|
213
|
+
action: action.value === 'pending' ? { kind: 'pending', note: note.value } : { kind: action.value },
|
|
214
|
+
};
|
|
190
215
|
const body = rule
|
|
191
216
|
? await send(`/api/invoicing/rules/${rule.id}`, 'PUT', payload)
|
|
192
217
|
: await send('/api/invoicing/rules', 'POST', payload);
|
|
@@ -219,7 +244,7 @@ export function rulesSection(ui, title = 'Rules') {
|
|
|
219
244
|
add?.addEventListener('click', () => edit(null));
|
|
220
245
|
section.replaceChildren(
|
|
221
246
|
h('h2', { class: 'fx-set__title' }, title),
|
|
222
|
-
h('p', { class: 'fx-set__hint', style: { margin: '0 0 4px' } }, '
|
|
247
|
+
h('p', { class: 'fx-set__hint', style: { margin: '0 0 4px' } }, 'When a rule matches, a ready job waits, is never drafted, or its draft is Pending with the rule\'s note.'),
|
|
223
248
|
...(rules.length ? rules.map(rowOf) : [h('p', { class: 'fx-dim' }, 'No rules. Every ready job is drafted.')]),
|
|
224
249
|
h('div', { class: 'fx-rule__footer' }, add,
|
|
225
250
|
h('a', { class: 'fx-dim', href: DOCS, target: '_blank', rel: 'noopener' }, 'How rules work')));
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
// Settings,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// change saves at once. Everyone can read them; an owner
|
|
9
|
-
// them. The full rules are in docs/INVOICING_RULES.md.
|
|
10
|
-
// shared pieces as `ui`.
|
|
1
|
+
// Settings, Rules (/invoicing-settings): a card for each part, like the
|
|
2
|
+
// Integrations page, and one page for each (/invoicing-settings/<key>).
|
|
3
|
+
// Drafts holds payment terms and the Reference; Price codes the lines with no
|
|
4
|
+
// price item (line-mappings.js); Invoices the checks a ready job passes
|
|
5
|
+
// before Flow drafts its invoice (invoice-rules.js, docs/INVOICE_RULES.md).
|
|
6
|
+
// Automations have their own page (automations.js).
|
|
7
|
+
// How an invoice goes to Xero is on the Xero integration page
|
|
8
|
+
// (sendModeSection). A change saves at once. Everyone can read them; an owner
|
|
9
|
+
// or admin changes them. The full rules are in docs/INVOICING_RULES.md.
|
|
10
|
+
// app.js passes in its shared pieces as `ui`.
|
|
11
11
|
|
|
12
|
-
import { automationSection } from '/assets/ui/automations.js';
|
|
13
12
|
import { rulesSection } from '/assets/ui/invoice-rules.js';
|
|
14
13
|
import { lineMappingSection } from '/assets/ui/line-mappings.js';
|
|
15
14
|
|
|
@@ -20,19 +19,51 @@ const SEND_MODES = [
|
|
|
20
19
|
{ value: 'approve_and_email', label: 'Approved and emailed' },
|
|
21
20
|
];
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
const onCount = (list) => ({ label: `${list.filter((x) => x.enabled).length} on`, tone: list.some((x) => x.enabled) ? 'success' : 'mute' });
|
|
23
|
+
|
|
24
|
+
// Each card: its page, its mark, and its state once the data is in. No
|
|
25
|
+
// blurb: the name and the state say enough.
|
|
26
|
+
const PARTS = [
|
|
27
|
+
{ key: 'drafts', title: 'Drafts', mark: 'draft', mount: (ui) => draftsSection(ui) },
|
|
28
|
+
{ key: 'price-codes', title: 'Price codes', mark: 'tag', mount: (ui) => lineMappingSection(ui),
|
|
29
|
+
summary: async (flow) => {
|
|
30
|
+
const { labels } = await flow('/api/invoicing/line-labels');
|
|
31
|
+
const waiting = labels.filter((l) => l.unmapped > 0).length;
|
|
32
|
+
return waiting ? { status: { label: `${waiting} wait`, tone: 'warn' }, action: 'Fix' } : { status: { label: 'All mapped', tone: 'success' } };
|
|
33
|
+
} },
|
|
34
|
+
{ key: 'invoices', title: 'Invoices', mark: 'rules', mount: (ui) => rulesSection(ui, 'Invoices'),
|
|
35
|
+
summary: async (flow) => ({ status: onCount((await flow('/api/invoicing/rules')).rules) }) },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function renderInvoicingSettings(main, ui, id) {
|
|
39
|
+
const { h, flow, notice, pageHeader, tileCard, crumbs } = ui;
|
|
40
|
+
if (id) {
|
|
41
|
+
const part = PARTS.find((p) => p.key === id);
|
|
42
|
+
main.append(crumbs(['/invoicing-settings', 'Rules'], part?.title ?? 'Not found'),
|
|
43
|
+
part ? h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, part.mount(ui)) : notice('No such part of Rules.', 'danger'));
|
|
32
44
|
return;
|
|
33
45
|
}
|
|
46
|
+
const grid = h('div', { class: 'fx-int-grid' });
|
|
47
|
+
main.append(pageHeader('Rules'), grid);
|
|
48
|
+
// Each card shows at once and fills in its state when its data comes back.
|
|
49
|
+
for (const part of PARTS) {
|
|
50
|
+
const href = `/invoicing-settings/${part.key}`;
|
|
51
|
+
const slot = tileCard({ href, mark: part.mark, title: part.title });
|
|
52
|
+
grid.append(slot);
|
|
53
|
+
if (part.summary) {
|
|
54
|
+
part.summary(flow)
|
|
55
|
+
.then((more) => slot.replaceWith(tileCard({ href, mark: part.mark, title: part.title, ...more })))
|
|
56
|
+
.catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Payment terms and the Reference template.
|
|
62
|
+
function draftsSection(ui) {
|
|
63
|
+
const { h, flow, toast, notice, me } = ui;
|
|
34
64
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
35
|
-
|
|
65
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Drafts'), h('div', { class: 'fx-skeleton' }));
|
|
66
|
+
let saved;
|
|
36
67
|
|
|
37
68
|
async function save(changes, control) {
|
|
38
69
|
control.disabled = true;
|
|
@@ -60,19 +91,23 @@ export async function renderInvoicingSettings(main, ui) {
|
|
|
60
91
|
});
|
|
61
92
|
return input;
|
|
62
93
|
};
|
|
63
|
-
const days = (key, label) => box(key, label, { type: 'number', read: Number });
|
|
64
94
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
(async () => {
|
|
96
|
+
try {
|
|
97
|
+
saved = (await flow('/api/invoicing/settings')).settings;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
section.replaceChildren(h('h2', { class: 'fx-set__title' }, 'Drafts'), notice(error.message, 'danger'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
section.replaceChildren(...[
|
|
103
|
+
h('h2', { class: 'fx-set__title' }, 'Drafts'),
|
|
104
|
+
disabled ? notice('Only an owner or admin can change these.', 'info') : null,
|
|
105
|
+
row('Payment terms', 'Days from invoice to due date.', box('paymentTermsDays', 'Payment terms in days', { type: 'number', read: Number })),
|
|
70
106
|
row('Reference', 'The Reference on each invoice. Use {practitioner} and {patient}.',
|
|
71
|
-
box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' }))
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
].filter(Boolean));
|
|
107
|
+
box('referenceTemplate', 'Reference rule', { placeholder: '{practitioner} - {patient}' })),
|
|
108
|
+
].filter(Boolean));
|
|
109
|
+
})();
|
|
110
|
+
return section;
|
|
76
111
|
}
|
|
77
112
|
|
|
78
113
|
// The Xero integration page's "Sending" section: how a checked invoice goes to
|
|
@@ -82,11 +117,10 @@ export function sendModeSection(ui) {
|
|
|
82
117
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
83
118
|
const select = h('select', { class: 'fx-select', 'aria-label': 'Send to Xero as', disabled },
|
|
84
119
|
SEND_MODES.map((m) => h('option', { value: m.value }, m.label)));
|
|
85
|
-
const section = h('section', { class: 'fx-set' },
|
|
120
|
+
const section = h('section', { class: 'fx-set' }, ui.sectionTitle('Sending', { href: DOCS, label: 'How sending works' }),
|
|
86
121
|
h('div', { class: 'fx-set__row' },
|
|
87
122
|
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, 'Send to Xero as'), h('span', { class: 'fx-set__hint' }, 'Draft: approve in Xero. Approved: Xero emails it.')),
|
|
88
|
-
h('div', { class: 'fx-set__control' }, select))
|
|
89
|
-
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How sending works')));
|
|
123
|
+
h('div', { class: 'fx-set__control' }, select)));
|
|
90
124
|
let saved = null;
|
|
91
125
|
(async () => {
|
|
92
126
|
try {
|