@arcforgelabs/flow 2026.10.36 → 2026.10.37
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 +11 -2
- package/server/auth/clerk.js +35 -0
- package/server/auth/routes.js +45 -6
- package/server/core/host.js +24 -0
- package/server/index.js +9 -3
- package/server/integrations/seazona/index.js +1 -1
- package/server/integrations/seazona/settings.js +5 -2
- package/server/integrations/sentry/index.js +5 -2
- package/server/integrations/xero/index.js +3 -3
- package/server/integrations/xero/settings.js +6 -0
- package/server/ui/app.js +88 -23
- package/server/ui/bulk-bar.js +99 -0
- package/server/ui/flow.css +84 -11
- package/server/ui/invoicing.js +234 -30
- package/server/ui/job-board.js +58 -34
- package/server/ui/jobs.js +27 -13
- package/server/ui/routes.js +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The bulk action bar: while a person has ticked rows in a list, it floats
|
|
2
|
+
// at the foot of the page, like ClickUp's. It shows "N selected ×" (the ×
|
|
3
|
+
// clears the ticks), then the actions in groups split by a thin line, then
|
|
4
|
+
// More. An action has a Lucide icon and one word; an icon-only action gives
|
|
5
|
+
// its name on hover and to a screen reader. More opens every action, with a
|
|
6
|
+
// search box, grouped under small heads.
|
|
7
|
+
//
|
|
8
|
+
// bulkBar(ui) answers { element, draw(state) }. state = { count, onClear,
|
|
9
|
+
// groups: [[action]], more: [{ head, actions: [action] }] }; an action is
|
|
10
|
+
// { icon, label, title?, run, primary?, iconOnly?, tone? }. title is the
|
|
11
|
+
// long name, for the hover tip and the More menu. A count of 0 hides the
|
|
12
|
+
// bar; Esc clears the ticks while it shows.
|
|
13
|
+
import { lucide } from '/assets/ui/custom-fields.js';
|
|
14
|
+
|
|
15
|
+
let barCount = 0;
|
|
16
|
+
|
|
17
|
+
export function bulkBar(ui) {
|
|
18
|
+
const { h } = ui;
|
|
19
|
+
const id = `fx-bulk-more-${++barCount}`;
|
|
20
|
+
const element = h('div', { class: 'fx-bulk', role: 'toolbar', 'aria-label': 'Ticked invoices', hidden: true });
|
|
21
|
+
const panel = h('div', { class: 'fx-pop fx-bulk__more', id, popover: 'auto', role: 'menu' });
|
|
22
|
+
let current = null;
|
|
23
|
+
let busy = false;
|
|
24
|
+
|
|
25
|
+
const run = async (action) => {
|
|
26
|
+
if (busy) return;
|
|
27
|
+
busy = true;
|
|
28
|
+
element.setAttribute('aria-busy', 'true');
|
|
29
|
+
try { await action.run(); } finally {
|
|
30
|
+
busy = false;
|
|
31
|
+
element.removeAttribute('aria-busy');
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const button = (action) => h('button', {
|
|
36
|
+
type: 'button',
|
|
37
|
+
class: `fx-bulk__act${action.primary ? ' fx-bulk__act--primary' : ''}${action.iconOnly ? ' fx-bulk__act--icon' : ''}`,
|
|
38
|
+
...(action.tone ? { 'data-tone': action.tone } : {}),
|
|
39
|
+
...(action.iconOnly ? { 'aria-label': action.title ?? action.label, 'data-tip': action.title ?? action.label } : action.title ? { title: action.title } : {}),
|
|
40
|
+
onclick: () => run(action),
|
|
41
|
+
}, lucide(h, action.icon), action.iconOnly ? null : h('span', { class: 'fx-bulk__word' }, action.label));
|
|
42
|
+
|
|
43
|
+
// More: a search box over every action, under small heads.
|
|
44
|
+
const search = h('input', { class: 'fx-input fx-bulk__search', type: 'search', placeholder: 'Search…', 'aria-label': 'Search actions' });
|
|
45
|
+
const list = h('div', { class: 'fx-bulk__list' });
|
|
46
|
+
const drawMore = () => {
|
|
47
|
+
const q = search.value.trim().toLowerCase();
|
|
48
|
+
const parts = [];
|
|
49
|
+
for (const { head, actions } of current?.more ?? []) {
|
|
50
|
+
const found = actions.filter((a) => !q || `${a.label} ${a.title ?? ''}`.toLowerCase().includes(q));
|
|
51
|
+
if (!found.length) continue;
|
|
52
|
+
parts.push(h('p', { class: 'fx-pop__head' }, head), h('div', { class: 'fx-bulk__grid' }, found.map((action) => h('button', {
|
|
53
|
+
type: 'button', class: 'fx-pop__item', role: 'menuitem', ...(action.tone ? { 'data-tone': action.tone } : {}),
|
|
54
|
+
onclick: () => { panel.hidePopover?.(); run(action); },
|
|
55
|
+
}, h('span', { class: 'fx-pop__tick' }, lucide(h, action.icon)), h('span', {}, action.title ?? action.label)))));
|
|
56
|
+
}
|
|
57
|
+
list.replaceChildren(...(parts.length ? parts : [h('p', { class: 'fx-bulk__none' }, 'No action matches.')]));
|
|
58
|
+
};
|
|
59
|
+
search.addEventListener('input', drawMore);
|
|
60
|
+
const onKey = (event) => {
|
|
61
|
+
if (!element.isConnected) { document.removeEventListener('keydown', onKey); return; }
|
|
62
|
+
if (event.key === 'Escape' && !element.hidden && !panel.matches?.(':popover-open') && !document.querySelector('dialog[open]')) current?.onClear();
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener?.('keydown', onKey);
|
|
65
|
+
panel.append(search, list);
|
|
66
|
+
const more = h('button', {
|
|
67
|
+
type: 'button', class: 'fx-bulk__act', popovertarget: id, 'aria-haspopup': 'menu', 'aria-expanded': 'false',
|
|
68
|
+
}, lucide(h, 'ellipsis'), h('span', { class: 'fx-bulk__word' }, 'More'));
|
|
69
|
+
// The menu opens above the bar, at its right edge.
|
|
70
|
+
panel.addEventListener('toggle', (event) => {
|
|
71
|
+
more.setAttribute('aria-expanded', String(event.newState === 'open'));
|
|
72
|
+
if (event.newState !== 'open') return;
|
|
73
|
+
search.value = '';
|
|
74
|
+
drawMore();
|
|
75
|
+
const at = more.getBoundingClientRect();
|
|
76
|
+
panel.style.top = `${Math.round(Math.max(8, at.top - panel.offsetHeight - 8))}px`;
|
|
77
|
+
panel.style.left = `${Math.round(Math.max(8, Math.min(at.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 8)))}px`;
|
|
78
|
+
search.focus();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function draw(state) {
|
|
82
|
+
current = state;
|
|
83
|
+
element.hidden = !state?.count;
|
|
84
|
+
if (element.hidden) { panel.hidePopover?.(); return; }
|
|
85
|
+
const count = h('span', { class: 'fx-bulk__count' },
|
|
86
|
+
h('span', {}, `${state.count} selected`),
|
|
87
|
+
h('button', { type: 'button', class: 'fx-bulk__clear', 'aria-label': 'Clear the ticks', 'data-tip': 'Clear', onclick: state.onClear }, lucide(h, 'x')));
|
|
88
|
+
const groups = state.groups.filter((g) => g.length);
|
|
89
|
+
const parts = [];
|
|
90
|
+
groups.forEach((group, i) => {
|
|
91
|
+
if (i) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }));
|
|
92
|
+
parts.push(...group.map(button));
|
|
93
|
+
});
|
|
94
|
+
if ((state.more ?? []).some((g) => g.actions.length)) parts.push(h('span', { class: 'fx-bulk__sep', 'aria-hidden': 'true' }), more, panel);
|
|
95
|
+
element.replaceChildren(count, h('span', { class: 'fx-bulk__acts' }, ...parts));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { element, draw };
|
|
99
|
+
}
|
package/server/ui/flow.css
CHANGED
|
@@ -155,7 +155,8 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
155
155
|
/* Folded (wide screens only): the sidebar keeps its icons, the menu button
|
|
156
156
|
and the account photo; each page's name is still read out. */
|
|
157
157
|
@media (min-width: 821px) {
|
|
158
|
-
.fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; }
|
|
158
|
+
.fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; cursor: e-resize; }
|
|
159
|
+
.fx-shell[data-folded] .fx-side :is(a, button, #user-button) { cursor: pointer; }
|
|
159
160
|
.fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
|
|
160
161
|
.fx-shell[data-folded] .fx-side__head .fx-brand { display: none; }
|
|
161
162
|
.fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
|
|
@@ -234,6 +235,12 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
234
235
|
.fx-link__dot[data-tone="success"] { background: var(--t-success); }
|
|
235
236
|
.fx-link__dot[data-tone="warn"] { background: var(--t-warn); }
|
|
236
237
|
.fx-link__dot[data-tone="danger"] { background: var(--t-danger); }
|
|
238
|
+
.fx-linked { display: grid; }
|
|
239
|
+
.fx-linked__row { display: flex; align-items: center; gap: 8px; white-space: nowrap; min-height: 44px; padding: 6px 0; border-top: 1px solid var(--fx-line-soft); }
|
|
240
|
+
.fx-linked__row:first-child { border-top: 0; }
|
|
241
|
+
.fx-linked__ref { font-family: var(--t-data); font-variant-numeric: tabular-nums; color: var(--t-strong); }
|
|
242
|
+
.fx-linked__open { margin-left: auto; font-size: 12.5px; color: var(--t-dim); white-space: nowrap; }
|
|
243
|
+
.fx-linked__open:hover { color: var(--t-strong); }
|
|
237
244
|
.fx-tag {
|
|
238
245
|
margin-left: 8px; padding: 1px 7px; border: 1px solid var(--fx-line); border-radius: 999px;
|
|
239
246
|
color: var(--t-dim); font-size: 11.5px; font-weight: 600;
|
|
@@ -269,9 +276,6 @@ a.fx-link:hover, a.fx-link:focus-visible { border-color: var(--t-strong); }
|
|
|
269
276
|
font-size: 12.5px; font-weight: 700; letter-spacing: .02em;
|
|
270
277
|
}
|
|
271
278
|
.fx-empty { padding: 28px 16px; color: var(--t-dim); text-align: center; }
|
|
272
|
-
.fx-roles { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0; font-size: 13.5px; color: var(--t-dim); }
|
|
273
|
-
.fx-roles dt { color: var(--t-strong); font-weight: 600; }
|
|
274
|
-
.fx-roles dd { margin: 0; }
|
|
275
279
|
|
|
276
280
|
/* ── Integrations ───────────────────────────────────────────────────────── */
|
|
277
281
|
.fx-chip { border: 1px solid var(--fx-line); border-radius: 999px; background: var(--t-paper); color: var(--t-text); }
|
|
@@ -509,12 +513,13 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
509
513
|
.fx-table__group-row { display: flex; align-items: center; gap: 12px; }
|
|
510
514
|
.fx-table__group-row .fx-board__sum { margin-left: 0; }
|
|
511
515
|
.fx-stage[data-rank="plain"] { --stage: var(--t-dim); }
|
|
512
|
-
/* The board
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
its bottom edge,
|
|
516
|
-
fx-main--board while the
|
|
517
|
-
|
|
516
|
+
/* The board is one row of lanes (one per group), each about a phone wide.
|
|
517
|
+
Lanes share the width when they fit; more lanes scroll sideways inside
|
|
518
|
+
the board, never as a second row. On a wide, tall screen the board fills
|
|
519
|
+
the page down to its bottom edge, the page itself does not scroll, and
|
|
520
|
+
each lane scrolls inside itself (jobs.js sets fx-main--board while the
|
|
521
|
+
board shows). */
|
|
522
|
+
.fx-board { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(min(85vw, 272px), 1fr); gap: 12px; align-items: start; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; padding-bottom: 4px; }
|
|
518
523
|
.fx-board__col { display: flex; flex-direction: column; gap: 8px; min-width: 0; min-height: 0; padding: 10px 6px 6px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
|
|
519
524
|
.fx-board__head { display: flex; flex: none; align-items: center; gap: 8px; min-width: 0; padding: 2px 4px 4px 2px; }
|
|
520
525
|
.fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
@@ -522,7 +527,7 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
522
527
|
@media (min-width: 821px) and (min-height: 560px) {
|
|
523
528
|
.fx-main--board { height: 100vh; height: 100dvh; padding: 24px 24px 16px; grid-template-rows: auto auto minmax(0, 1fr); align-content: stretch; }
|
|
524
529
|
.fx-main--board > .fx-jobs { min-height: 0; }
|
|
525
|
-
.fx-main--board .fx-board { height: 100%; overflow-y:
|
|
530
|
+
.fx-main--board .fx-board { height: 100%; overflow-y: hidden; align-items: stretch; }
|
|
526
531
|
.fx-main--board .fx-board__cards { flex: 1; max-height: none; }
|
|
527
532
|
}
|
|
528
533
|
.fx-board__none { padding: 8px 4px; color: var(--t-dim); font-size: 13px; }
|
|
@@ -823,6 +828,16 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
823
828
|
.fx-table__actions { text-align: left; }
|
|
824
829
|
.fx-table__actions .fx-row--end { justify-content: flex-start; }
|
|
825
830
|
.fx-ph__actions, .fx-ph__actions .fx-btn { width: 100%; }
|
|
831
|
+
/* People: the name with its actions (⋯, Approve) on one line, then the
|
|
832
|
+
role select full width, then the status pill when there is one. */
|
|
833
|
+
.fx-people tr { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px 10px; }
|
|
834
|
+
.fx-people td, .fx-people td:nth-child(2), .fx-people td:nth-child(3) { display: block; width: auto; margin: 0; padding: 0; }
|
|
835
|
+
.fx-people td:nth-child(1) { grid-area: 1 / 1; }
|
|
836
|
+
.fx-people td:nth-child(4) { grid-area: 1 / 2; }
|
|
837
|
+
.fx-people td:nth-child(2) { grid-area: 2 / 1 / 3 / -1; }
|
|
838
|
+
.fx-people td:nth-child(2) .fx-select { width: 100%; }
|
|
839
|
+
.fx-people td:nth-child(3) { grid-area: 3 / 1 / 4 / -1; }
|
|
840
|
+
.fx-people td:nth-child(3):empty { display: none; }
|
|
826
841
|
}
|
|
827
842
|
|
|
828
843
|
@keyframes fx-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
@@ -921,6 +936,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
921
936
|
.fx-cfm > .fx-notice { margin: 12px 16px 0; }
|
|
922
937
|
.fx-cfm__foot { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 13px; }
|
|
923
938
|
.fx-pop__item:disabled { opacity: .5; cursor: default; }
|
|
939
|
+
.fx-pop__item[data-tone="danger"], .fx-pop__item[data-tone="danger"] .fx-pop__tick { color: var(--t-danger); }
|
|
924
940
|
.fx-pop__item .fx-lucide { width: 15px; height: 15px; }
|
|
925
941
|
@media (max-width: 720px) {
|
|
926
942
|
.fx-cfm { grid-template-columns: minmax(0, 1fr) 40px 40px; }
|
|
@@ -1019,3 +1035,60 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
1019
1035
|
.fx-pick__row[hidden] { display: none; }
|
|
1020
1036
|
.fx-pick__main { display: grid; gap: 2px; min-width: 0; }
|
|
1021
1037
|
.fx-pick > .fx-dim { padding: 14px; }
|
|
1038
|
+
|
|
1039
|
+
/* The Invoices list head (invoicing.js): tabs, search and the send bar stay
|
|
1040
|
+
at the top while rows scroll up under them; a shadow shows once they do.
|
|
1041
|
+
Narrow, the app bar (60px) sits above it. */
|
|
1042
|
+
.fx-listhead { position: sticky; top: 0; z-index: 5; margin: -12px calc(-1 * clamp(16px, 4vw, 48px)) 0; padding: 12px clamp(16px, 4vw, 48px) 0; background: var(--t-bg); transition: box-shadow .15s; }
|
|
1043
|
+
.fx-listhead[data-stuck] { box-shadow: 0 10px 12px -12px rgba(0, 0, 0, .45); }
|
|
1044
|
+
.fx-listhead .fx-sendbar { margin-bottom: 12px; }
|
|
1045
|
+
.fx-listhead__edge { height: 0; margin-bottom: -20px; }
|
|
1046
|
+
@media (max-width: 820px) { .fx-listhead { top: 60px; } }
|
|
1047
|
+
/* On a phone the head would hide too many rows: it scrolls away. */
|
|
1048
|
+
@media (max-width: 560px) { .fx-listhead { position: static; } }
|
|
1049
|
+
|
|
1050
|
+
/* The bulk action bar (bulk-bar.js): floats at the foot while rows are
|
|
1051
|
+
ticked. "N selected ×", then icon actions in groups, then More. It takes
|
|
1052
|
+
the theme's own surface, line, corners and primary button, like a menu. */
|
|
1053
|
+
.fx-bulk {
|
|
1054
|
+
position: sticky; bottom: 16px; z-index: 6; justify-self: center; display: flex; align-items: center; gap: 2px; max-width: 100%;
|
|
1055
|
+
padding: 5px; border: 1px solid var(--fx-line); border-radius: var(--t-radius-lg); background: var(--t-paper); color: var(--t-text);
|
|
1056
|
+
box-shadow: var(--t-shadow), 0 12px 32px rgba(0, 0, 0, .16);
|
|
1057
|
+
font: 500 13.5px/1 var(--t-body); white-space: nowrap;
|
|
1058
|
+
}
|
|
1059
|
+
.fx-bulk[hidden] { display: none; }
|
|
1060
|
+
.fx-bulk[aria-busy="true"] { opacity: .7; pointer-events: none; }
|
|
1061
|
+
.fx-bulk__acts { display: flex; align-items: center; gap: 2px; min-width: 0; }
|
|
1062
|
+
.fx-bulk__count { display: inline-flex; align-items: center; gap: 6px; flex: none; margin-right: 6px; padding: 3px 4px 3px 10px; border: 1px solid var(--t-accent-brd, var(--fx-line)); border-radius: var(--t-radius); background: var(--t-accent-bg); color: var(--t-accent); font-weight: 600; }
|
|
1063
|
+
.fx-bulk__clear, .fx-bulk__act { position: relative; display: inline-flex; align-items: center; gap: 6px; padding: 7px 9px; border: 1px solid transparent; border-radius: var(--t-radius); background: none; color: inherit; font: inherit; cursor: pointer; }
|
|
1064
|
+
.fx-bulk__clear { padding: 3px; color: inherit; }
|
|
1065
|
+
.fx-bulk__act:hover, .fx-bulk__act:focus-visible, .fx-bulk__act[aria-expanded="true"] { background: var(--fx-hover); color: var(--t-strong); outline: none; }
|
|
1066
|
+
.fx-bulk__clear:hover, .fx-bulk__clear:focus-visible { background: color-mix(in srgb, var(--t-accent) 14%, transparent); outline: none; }
|
|
1067
|
+
.fx-bulk__act--primary, .fx-bulk__act--primary:hover, .fx-bulk__act--primary:focus-visible { background: var(--t-primary); border-color: var(--t-primary); color: var(--t-on-primary); font-weight: 600; }
|
|
1068
|
+
.fx-bulk__act--primary:hover { background: color-mix(in srgb, var(--t-primary) 86%, var(--t-text)); }
|
|
1069
|
+
.fx-bulk__act--icon { padding: 7px; }
|
|
1070
|
+
.fx-bulk__act[data-tone="danger"] { color: var(--t-danger); }
|
|
1071
|
+
.fx-bulk__act[data-tone="danger"]:hover { background: var(--t-danger-bg); }
|
|
1072
|
+
.fx-bulk__sep { flex: none; width: 1px; height: 18px; margin: 0 4px; background: var(--fx-line); }
|
|
1073
|
+
/* An icon-only action says its name on hover, in a tip above it. */
|
|
1074
|
+
.fx-bulk [data-tip]:hover::after, .fx-bulk [data-tip]:focus-visible::after {
|
|
1075
|
+
content: attr(data-tip); position: absolute; bottom: calc(100% + 10px); left: 50%; transform: translateX(-50%);
|
|
1076
|
+
padding: 4px 8px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); color: var(--t-strong);
|
|
1077
|
+
font: 500 12px/1.2 var(--t-body); pointer-events: none; box-shadow: var(--t-shadow), 0 4px 12px rgba(0, 0, 0, .12);
|
|
1078
|
+
}
|
|
1079
|
+
/* More: a compact list, one row per action, like a dropdown. */
|
|
1080
|
+
.fx-pop.fx-bulk__more { width: 248px; min-width: 0; padding: 4px; }
|
|
1081
|
+
.fx-bulk__search { width: 100%; height: 30px; margin-bottom: 2px; padding: 4px 8px; font-size: 13px; }
|
|
1082
|
+
.fx-bulk__more .fx-pop__head { padding: 6px 8px 2px; font-size: 10.5px; }
|
|
1083
|
+
.fx-bulk__more .fx-pop__item { gap: 8px; padding: 4px 8px; font-size: 13px; line-height: 1.25; }
|
|
1084
|
+
.fx-bulk__more .fx-pop__item .fx-lucide { width: 14px; height: 14px; }
|
|
1085
|
+
.fx-bulk__grid { display: grid; }
|
|
1086
|
+
.fx-bulk__grid .fx-pop__item[data-tone="danger"] { color: var(--t-danger); }
|
|
1087
|
+
.fx-bulk__none { margin: 8px 10px; color: var(--t-dim); font-size: 13.5px; }
|
|
1088
|
+
@media (max-width: 560px) {
|
|
1089
|
+
.fx-bulk { bottom: 10px; padding: 4px; }
|
|
1090
|
+
.fx-bulk__count { margin-right: 2px; padding-left: 8px; }
|
|
1091
|
+
.fx-bulk__act { padding: 7px 6px; }
|
|
1092
|
+
.fx-bulk__sep { margin: 0 2px; }
|
|
1093
|
+
.fx-bulk__act:not(.fx-bulk__act--primary) .fx-bulk__word { display: none; }
|
|
1094
|
+
}
|
package/server/ui/invoicing.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// zero, server/core/money.js). Data comes from /api/invoicing
|
|
7
7
|
// (server/api/invoicing.js). app.js passes in its shared pieces as `ui`.
|
|
8
8
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
9
|
+
import { bulkBar } from '/assets/ui/bulk-bar.js';
|
|
9
10
|
import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
|
|
10
11
|
import { jobChangeBanner } from '/assets/ui/job-change.js';
|
|
11
12
|
|
|
@@ -22,10 +23,10 @@ const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_che
|
|
|
22
23
|
const EMPTY = {
|
|
23
24
|
to_check: 'No drafts to check. Press New invoice to start one.',
|
|
24
25
|
problem: 'No problems.',
|
|
25
|
-
ready: 'Nothing is ready.
|
|
26
|
+
ready: 'Nothing is ready. In Drafts, tick drafts and press Ready.',
|
|
26
27
|
};
|
|
27
|
-
//
|
|
28
|
-
|
|
28
|
+
// Every tab's rows can be ticked; the bulk bar (bulkActions) says what the
|
|
29
|
+
// ticked rows can do there.
|
|
29
30
|
|
|
30
31
|
const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
|
|
31
32
|
export const formatCents = (cents) => money.format((cents ?? 0) / 100);
|
|
@@ -120,18 +121,28 @@ export async function renderInvoiceList(main, ui) {
|
|
|
120
121
|
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice', value: state.q });
|
|
121
122
|
const bar = h('div', { class: 'fx-sendbar', hidden: true });
|
|
122
123
|
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
124
|
+
const bulk = bulkBar(ui);
|
|
123
125
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
124
126
|
let body = null;
|
|
125
127
|
const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
126
128
|
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
129
|
+
// The tabs, search and send bar stay at the top while the rows scroll up
|
|
130
|
+
// under them; the bulk bar floats at the foot (ClickUp's layout).
|
|
131
|
+
const head = h('div', { class: 'fx-listhead' }, h('div', { class: 'fx-toolbar' }, tabs, search), bar);
|
|
132
|
+
const edge = h('div', { class: 'fx-listhead__edge', 'aria-hidden': 'true' });
|
|
127
133
|
main.append(
|
|
128
134
|
pageHeader('Invoices', {
|
|
129
135
|
actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
|
|
130
136
|
}),
|
|
131
137
|
alerts.element,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
host
|
|
138
|
+
edge,
|
|
139
|
+
head,
|
|
140
|
+
host,
|
|
141
|
+
bulk.element);
|
|
142
|
+
// A shadow under the head shows once rows scroll under it.
|
|
143
|
+
if (typeof IntersectionObserver === 'function') {
|
|
144
|
+
new IntersectionObserver(([entry]) => head.toggleAttribute('data-stuck', !entry.isIntersecting), { rootMargin: `-${head.offsetTop > 0 && window.matchMedia?.('(max-width: 820px)').matches ? 61 : 1}px 0px 0px 0px` }).observe(edge);
|
|
145
|
+
}
|
|
135
146
|
|
|
136
147
|
let timer;
|
|
137
148
|
search.addEventListener('input', () => {
|
|
@@ -167,19 +178,10 @@ export async function renderInvoiceList(main, ui) {
|
|
|
167
178
|
const ticked = [...state.ticked];
|
|
168
179
|
const parts = [];
|
|
169
180
|
if (state.status === 'to_check') {
|
|
170
|
-
parts.push(h('p', { class: 'fx-dim' },
|
|
171
|
-
if (ticked.length) {
|
|
172
|
-
parts.push(action(`Move ${ticked.length} to Ready`, 'mark-ready', () => ({ invoiceIds: ticked }), (res) => (res.refused.length
|
|
173
|
-
? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
|
|
174
|
-
: [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
|
|
175
|
-
}
|
|
181
|
+
parts.push(h('p', { class: 'fx-dim' }, 'Check each draft, tick it, then press Ready.'));
|
|
176
182
|
} else if (state.status === 'ready') {
|
|
177
183
|
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
178
184
|
const buttons = [];
|
|
179
|
-
if (ticked.length) {
|
|
180
|
-
buttons.push(action(`Move ${ticked.length} to Drafts`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
|
|
181
|
-
(res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success'], 'ghost'));
|
|
182
|
-
}
|
|
183
185
|
if (context.canIssue && body.counts.ready > 0) {
|
|
184
186
|
buttons.push(action(to ? `Send all ${body.counts.ready} ready to ${to}` : `Finalise all ${body.counts.ready} ready`, 'send-ready', () => ({ issueDate: localDate() }),
|
|
185
187
|
(res) => [`${res.issued} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more are ready: press again.` : ''}`, 'success']));
|
|
@@ -194,6 +196,162 @@ export async function renderInvoiceList(main, ui) {
|
|
|
194
196
|
}
|
|
195
197
|
bar.replaceChildren(...parts);
|
|
196
198
|
bar.hidden = !parts.length;
|
|
199
|
+
bulk.draw({ count: ticked.length, onClear: () => setTicks([]), ...bulkActions(ticked) });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Ticks changed from the bar: the rows draw again with the new ticks.
|
|
203
|
+
function setTicks(ids) {
|
|
204
|
+
state.ticked = new Set(ids);
|
|
205
|
+
if (body) draw(body);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A bulk step on the server: on success the ticks clear and the list loads
|
|
209
|
+
// again; on a failure the ticks stay, so a person can try again.
|
|
210
|
+
async function post(path, payload, done) {
|
|
211
|
+
try {
|
|
212
|
+
const res = await flow(`/api/invoicing/invoices/${path}`, { method: 'POST', body: JSON.stringify(payload) });
|
|
213
|
+
toast(...done(res));
|
|
214
|
+
state.ticked.clear();
|
|
215
|
+
seen.clear();
|
|
216
|
+
await load();
|
|
217
|
+
alerts.refresh();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
toast(error.message, 'danger');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// What the ticked rows can do on this tab. The bar shows the main ones;
|
|
224
|
+
// More shows them all, with Selection steps under them.
|
|
225
|
+
function bulkActions(ticked) {
|
|
226
|
+
const to = body.sendsTo;
|
|
227
|
+
const rows = body.invoices.filter((i) => state.ticked.has(i.id));
|
|
228
|
+
const numbered = ['issued', 'void'].includes(state.status);
|
|
229
|
+
const status = [];
|
|
230
|
+
const apply = [];
|
|
231
|
+
if (state.status === 'ready' && context.canIssue) {
|
|
232
|
+
status.push({
|
|
233
|
+
icon: 'circle-check-big', label: 'Finalise', primary: true,
|
|
234
|
+
title: to ? `Finalise and send to ${to}` : 'Finalise',
|
|
235
|
+
run: async () => {
|
|
236
|
+
const ok = await ui.dialog({
|
|
237
|
+
title: `Finalise ${plural(ticked.length, 'invoice', 'invoices')}?`,
|
|
238
|
+
body: h('p', {}, `Each one gets the next number and is locked.${to ? ` ${sendModeNote(to, body.sendMode, 'each one')}` : ''}`),
|
|
239
|
+
confirm: to ? `Finalise and send ${ticked.length}` : `Finalise ${ticked.length}`,
|
|
240
|
+
});
|
|
241
|
+
if (!ok) return;
|
|
242
|
+
await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
|
|
243
|
+
(res) => [`${plural(res.issued, 'invoice', 'invoices')} finalised.${to ? ` They are on the way to ${to}.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (state.status === 'ready') {
|
|
248
|
+
status.push({
|
|
249
|
+
icon: 'undo-2', label: 'Drafts', title: 'Move to Drafts',
|
|
250
|
+
run: () => post('mark-not-ready', { invoiceIds: ticked }, (res) => [`${plural(res.moved, 'invoice', 'invoices')} back in Drafts.`, 'success']),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (state.status === 'to_check') {
|
|
254
|
+
status.push({
|
|
255
|
+
icon: 'check-check', label: 'Ready', primary: true, title: 'Move to Ready',
|
|
256
|
+
run: () => post('mark-ready', { invoiceIds: ticked }, (res) => (res.refused.length
|
|
257
|
+
? [`${res.marked} ready. ${plural(res.refused.length, 'draft has', 'drafts have')} a problem and moved to Problem.`, 'danger']
|
|
258
|
+
: [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
const failed = rows.filter((i) => i.status === 'issued').map((i) => i.id);
|
|
262
|
+
if (state.status === 'problem' && context.canIssue && to && failed.length) {
|
|
263
|
+
status.push({
|
|
264
|
+
icon: 'refresh-cw', label: 'Retry', primary: true, title: `Send again to ${to}`,
|
|
265
|
+
run: () => post('send-again', { invoiceIds: failed }, (res) => [`${res.queued} on the way to ${to} again.`, 'success']),
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
apply.push({
|
|
269
|
+
icon: 'copy', label: 'Copy', iconOnly: true, title: numbered ? 'Copy numbers' : 'Copy references',
|
|
270
|
+
run: () => copyLines(rows.map((i) => (numbered ? i.number : i.reference) ?? i.contactName ?? '')),
|
|
271
|
+
});
|
|
272
|
+
apply.push({ icon: 'download', label: 'Export', iconOnly: true, title: 'Export CSV', run: () => exportCsv(rows) });
|
|
273
|
+
if (numbered) apply.push({ icon: 'file-down', label: 'PDFs', iconOnly: true, title: 'Download PDFs', run: () => downloadPdfs(rows) });
|
|
274
|
+
const danger = [];
|
|
275
|
+
const voidable = rows.filter((i) => i.status === 'issued');
|
|
276
|
+
if (state.status === 'issued' && context.canIssue && voidable.length) {
|
|
277
|
+
danger.push({
|
|
278
|
+
icon: 'ban', label: 'Void', iconOnly: true, tone: 'danger', title: 'Void',
|
|
279
|
+
run: () => voidAll(voidable),
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const all = body.invoices.map((i) => i.id);
|
|
283
|
+
const selection = [
|
|
284
|
+
{ icon: 'list-checks', label: 'Tick all shown', run: () => setTicks(all) },
|
|
285
|
+
{ icon: 'square-check', label: 'Invert ticks', run: () => setTicks(all.filter((id) => !state.ticked.has(id))) },
|
|
286
|
+
{ icon: 'square-x', label: 'Clear ticks', run: () => setTicks([]) },
|
|
287
|
+
];
|
|
288
|
+
return {
|
|
289
|
+
groups: [status, apply, danger],
|
|
290
|
+
more: [
|
|
291
|
+
{ head: 'Change status', actions: [...status, ...danger] },
|
|
292
|
+
{ head: 'Apply an action', actions: apply },
|
|
293
|
+
{ head: 'Selection', actions: selection },
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function copyLines(lines) {
|
|
299
|
+
try {
|
|
300
|
+
await navigator.clipboard.writeText(lines.join('\n'));
|
|
301
|
+
toast(`${plural(lines.length, 'line', 'lines')} copied.`, 'success');
|
|
302
|
+
} catch {
|
|
303
|
+
toast('The browser did not allow a copy.', 'danger');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The ticked rows as a CSV file, with the columns on the screen.
|
|
308
|
+
function exportCsv(rows) {
|
|
309
|
+
const cell = (v) => (/[",\n]/.test(String(v ?? '')) ? `"${String(v).replace(/"/g, '""')}"` : String(v ?? ''));
|
|
310
|
+
const lines = [['Number', 'Client', 'Reference', 'Date', 'Total', 'Status']]
|
|
311
|
+
.concat(rows.map((i) => [i.number ?? '', i.contactName ?? '', i.reference ?? '', i.issueDate ?? i.createdAt?.slice(0, 10) ?? '',
|
|
312
|
+
((i.totalCents ?? 0) / 100).toFixed(2), i.status === 'void' ? 'Void' : VIEW_LABEL[i.view] ?? STATUS_LABEL[i.status]]));
|
|
313
|
+
save(new Blob([lines.map((l) => l.map(cell).join(',')).join('\r\n')], { type: 'text/csv' }), `invoices-${state.status}-${localDate()}.csv`);
|
|
314
|
+
toast(`${plural(rows.length, 'invoice', 'invoices')} exported.`, 'success');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function downloadPdfs(rows) {
|
|
318
|
+
const token = await window.Clerk?.session?.getToken();
|
|
319
|
+
let saved = 0;
|
|
320
|
+
for (const invoice of rows) {
|
|
321
|
+
try {
|
|
322
|
+
const res = await fetch(`/api/invoicing/invoices/${invoice.id}/pdf`, { headers: { Authorization: `Bearer ${token}` } });
|
|
323
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
324
|
+
save(await res.blob(), `${invoice.number ?? invoice.id}.pdf`);
|
|
325
|
+
saved += 1;
|
|
326
|
+
} catch {
|
|
327
|
+
// The toast below counts the ones that did not save.
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
toast(saved === rows.length ? `${plural(saved, 'PDF', 'PDFs')} saved.` : `${saved} of ${rows.length} PDFs saved.`, saved === rows.length ? 'success' : 'danger');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Void, one invoice after the other, as on one invoice's page.
|
|
334
|
+
async function voidAll(rows) {
|
|
335
|
+
const ok = await ui.dialog({
|
|
336
|
+
title: `Void ${plural(rows.length, 'invoice', 'invoices')}?`,
|
|
337
|
+
body: h('div', {}, h('p', {}, 'Their numbers stay used. A void invoice cannot be finalised again.'),
|
|
338
|
+
body.sendsTo ? h('p', { class: 'fx-problem' }, `Flow does not change ${body.sendsTo}: void them in ${body.sendsTo} too.`) : null),
|
|
339
|
+
confirm: `Void ${rows.length}`, tone: 'danger',
|
|
340
|
+
});
|
|
341
|
+
if (!ok) return;
|
|
342
|
+
let done = 0;
|
|
343
|
+
let error = null;
|
|
344
|
+
for (const invoice of rows) {
|
|
345
|
+
try {
|
|
346
|
+
await flow(`/api/invoicing/invoices/${invoice.id}/void`, { method: 'POST', body: '{}' });
|
|
347
|
+
done += 1;
|
|
348
|
+
} catch (e) { error = e; }
|
|
349
|
+
}
|
|
350
|
+
toast(error ? `${done} of ${rows.length} void. ${error.message}` : `${plural(done, 'invoice', 'invoices')} void.`, error ? 'danger' : 'success');
|
|
351
|
+
state.ticked.clear();
|
|
352
|
+
seen.clear();
|
|
353
|
+
await load();
|
|
354
|
+
alerts.refresh();
|
|
197
355
|
}
|
|
198
356
|
|
|
199
357
|
// A tab switch shows at once: the tab is selected, the last list seen for
|
|
@@ -257,7 +415,6 @@ export async function renderInvoiceList(main, ui) {
|
|
|
257
415
|
saveNav({ tab: state.status, q: state.q, ids: body.invoices.map((i) => i.id) });
|
|
258
416
|
const open = (href) => { saveNav({ ...readNav(), scrollY: window.scrollY }); go(href); };
|
|
259
417
|
const link = (href, label) => h('a', { class: 'fx-strong', href, onclick: (event) => { event.preventDefault(); event.stopPropagation(); open(href); } }, label);
|
|
260
|
-
const ticks = TICKS[state.status];
|
|
261
418
|
const showProblem = state.status === 'problem';
|
|
262
419
|
const created = ['to_check', 'problem', 'ready'].includes(state.status);
|
|
263
420
|
// An invoice gets its number when it is sent or issued, so only the
|
|
@@ -280,6 +437,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
280
437
|
};
|
|
281
438
|
const all = h('input', {
|
|
282
439
|
type: 'checkbox', class: 'fx-tick', 'aria-label': 'Tick every invoice shown',
|
|
440
|
+
...(state.ticked.size && body.invoices.every((i) => state.ticked.has(i.id)) ? { checked: true } : {}),
|
|
283
441
|
onchange: () => {
|
|
284
442
|
for (const invoice of body.invoices) { if (all.checked) state.ticked.add(invoice.id); else state.ticked.delete(invoice.id); }
|
|
285
443
|
for (const box of boxes) box.checked = all.checked;
|
|
@@ -289,7 +447,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
289
447
|
const statusCells = new Map();
|
|
290
448
|
host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
|
|
291
449
|
h('thead', {}, h('tr', {},
|
|
292
|
-
|
|
450
|
+
h('th', { class: 'fx-tick-cell' }, all),
|
|
293
451
|
numbered ? h('th', {}, 'Number') : null, h('th', {}, 'Client'), h('th', {}, showProblem ? 'Problem' : 'Reference'),
|
|
294
452
|
h('th', {}, created ? 'Created' : 'Finalised'), h('th', { class: 'fx-num' }, 'Total'), h('th', {}, 'Status'),
|
|
295
453
|
linked ? h('th', {}, 'Linked') : null)),
|
|
@@ -298,7 +456,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
298
456
|
const first = invoice.problems?.[0];
|
|
299
457
|
const view = invoice.status === 'void' ? null : invoice.view;
|
|
300
458
|
return h('tr', { tabindex: '0', onclick: () => open(href), onkeydown: (event) => { if (event.key === 'Enter') open(href); } },
|
|
301
|
-
|
|
459
|
+
h('td', { class: 'fx-tick-cell', onclick: (event) => event.stopPropagation() }, tickBox(invoice)),
|
|
302
460
|
numbered ? h('td', {}, link(href, invoice.number ?? 'Draft')) : null,
|
|
303
461
|
h('td', {}, numbered ? (invoice.contactName ?? '—') : link(href, invoice.contactName ?? 'No client')),
|
|
304
462
|
showProblem
|
|
@@ -336,6 +494,18 @@ export async function renderInvoiceList(main, ui) {
|
|
|
336
494
|
if (returning && back?.scrollY) window.scrollTo(0, back.scrollY);
|
|
337
495
|
}
|
|
338
496
|
|
|
497
|
+
// A file the browser saves, from a blob made on this page.
|
|
498
|
+
function save(blob, name) {
|
|
499
|
+
const url = URL.createObjectURL(blob);
|
|
500
|
+
const a = document.createElement('a');
|
|
501
|
+
a.href = url;
|
|
502
|
+
a.download = name;
|
|
503
|
+
document.body.append(a);
|
|
504
|
+
a.click();
|
|
505
|
+
a.remove();
|
|
506
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
507
|
+
}
|
|
508
|
+
|
|
339
509
|
// The list an invoice was opened from: its tab, search, invoice ids in order,
|
|
340
510
|
// and scroll. The invoice page's bar steps through it and goes back to it.
|
|
341
511
|
const NAV = 'flow_invoice_nav';
|
|
@@ -435,16 +605,46 @@ function totalsBox(ui, { subtotalCents, taxCents, totalCents }) {
|
|
|
435
605
|
h('dt', { class: 'fx-totals__grand' }, 'Total'), h('dd', { class: 'fx-totals__grand' }, formatCents(totalCents)));
|
|
436
606
|
}
|
|
437
607
|
|
|
438
|
-
// The
|
|
439
|
-
|
|
608
|
+
// The Linked card: this invoice's link in each connected system (linksTo).
|
|
609
|
+
// A work system row names the job's order and opens it there; the
|
|
610
|
+
// accounting row gives the number and state there (from Flow's copy). A
|
|
611
|
+
// system with no link shows a dashed row. With no system on, the card holds
|
|
612
|
+
// the job's facts only.
|
|
613
|
+
function linkedCard(ui, invoice, accountingStatus = null) {
|
|
440
614
|
const { h } = ui;
|
|
441
|
-
|
|
615
|
+
const job = invoice?.job ?? null;
|
|
616
|
+
const systems = invoice?.linksTo ?? [];
|
|
617
|
+
if (!job && !systems.length) return null;
|
|
618
|
+
const facts = job && (job.practitioner || job.shippedOn) ? h('dl', { class: 'fx-facts' },
|
|
619
|
+
job.practitioner ? [h('dt', {}, 'Practitioner'), h('dd', {}, job.practitioner)] : null,
|
|
620
|
+
job.shippedOn ? [h('dt', {}, 'Shipped'), h('dd', {}, showDate(job.shippedOn))] : null) : null;
|
|
621
|
+
const row = (system, { tone = null, ref = null, state = null, url = null, none = null }) => h('div', { class: 'fx-linked__row' },
|
|
622
|
+
linkIcon(ui, system, { tone, title: none ?? `${system.name}${ref ? ` ${ref}` : ''}` }),
|
|
623
|
+
h('span', { class: 'fx-strong' }, system.name),
|
|
624
|
+
ref ? h('span', { class: 'fx-linked__ref' }, ref) : null,
|
|
625
|
+
state ? h('span', { class: 'fx-status', 'data-tone': tone ?? 'mute' }, state) : null,
|
|
626
|
+
none ? h('span', { class: 'fx-dim' }, none) : null,
|
|
627
|
+
url ? h('a', { class: 'fx-linked__open', href: url, target: '_blank', rel: 'noopener', title: `Open in ${system.name}` }, 'Open ↗') : null);
|
|
628
|
+
const rows = systems.map((system) => {
|
|
629
|
+
if (system.kind === 'work') {
|
|
630
|
+
return job?.sourceSystem === system.id
|
|
631
|
+
? row(system, { tone: 'success', ref: job.caseRef ?? job.sourceJobRef, url: job.link?.url ?? null })
|
|
632
|
+
: row(system, { none: 'No job linked' });
|
|
633
|
+
}
|
|
634
|
+
const status = accountingStatus;
|
|
635
|
+
if (!status || (status.state === 'not_sent' && !status.finding)) {
|
|
636
|
+
return row(system, { none: invoice?.status === 'draft' ? 'Goes there when sent' : `Not in ${system.name} yet` });
|
|
637
|
+
}
|
|
638
|
+
const tone = status.finding ? 'danger' : LINK_TONE[status.state] ?? 'mute';
|
|
639
|
+
const state = status.finding ? 'Out of sync' : status.label.replace(` in ${system.name}`, '');
|
|
640
|
+
return row(system, { tone, ref: status.xero?.number ?? null, state, url: status.xero?.url ?? null });
|
|
641
|
+
});
|
|
642
|
+
const workRows = systems.some((s) => s.kind === 'work');
|
|
442
643
|
return h('section', { class: 'fx-card fx-side-card' },
|
|
443
|
-
h('h3', { class: 'fx-label' }, '
|
|
444
|
-
h('
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
h('dt', {}, 'From'), h('dd', {}, job.sourceSystem === 'seazona' ? 'Seazona' : job.sourceSystem)));
|
|
644
|
+
h('h3', { class: 'fx-label' }, 'Linked'),
|
|
645
|
+
!workRows && job ? h('p', { class: 'fx-dim' }, `Job from ${job.systemName ?? job.sourceSystem}`) : null,
|
|
646
|
+
h('div', { class: 'fx-linked' }, rows),
|
|
647
|
+
facts);
|
|
448
648
|
}
|
|
449
649
|
|
|
450
650
|
// A draft, or a new invoice before its first save.
|
|
@@ -796,7 +996,7 @@ function draftView(invoice, ui, context, redraw) {
|
|
|
796
996
|
h('div', { class: 'fx-lines__foot' },
|
|
797
997
|
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', onclick: () => { lines.push({ description: '', quantity: 1, unitAmountCents: null, taxCode: 'GST', priceItemId: null }); drawLines(); } }, '+ Add line'),
|
|
798
998
|
totalsHost))),
|
|
799
|
-
h('aside', { class: 'fx-invoice__aside' }, statusCard,
|
|
999
|
+
h('aside', { class: 'fx-invoice__aside' }, statusCard, linkedCard(ui, invoice))),
|
|
800
1000
|
].map((part) => {
|
|
801
1001
|
// Any edit on the page shows Save changes; undoing it hides it again.
|
|
802
1002
|
for (const kind of ['input', 'change', 'click']) part?.addEventListener?.(kind, () => setTimeout(watchChanges));
|
|
@@ -855,12 +1055,14 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
855
1055
|
};
|
|
856
1056
|
const organisationId = context.organisations?.[0]?.id;
|
|
857
1057
|
const xeroHost = h('div');
|
|
1058
|
+
const linkedHost = h('div', {}, linkedCard(ui, invoice) ?? '');
|
|
858
1059
|
let xeroStatus = null;
|
|
859
1060
|
const send = invoice.status === 'issued' && context.canIssue ? sendToXero(invoice, ui, context) : null;
|
|
860
1061
|
const loadXero = async () => {
|
|
861
1062
|
if (!context.xero) return;
|
|
862
1063
|
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
863
1064
|
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
1065
|
+
linkedHost.replaceChildren(linkedCard(ui, invoice, xeroStatus) ?? '');
|
|
864
1066
|
if (xeroStatus && xeroStatus.state !== 'not_sent') showSent(send);
|
|
865
1067
|
};
|
|
866
1068
|
loadXero();
|
|
@@ -905,8 +1107,8 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
905
1107
|
h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
|
|
906
1108
|
h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
|
|
907
1109
|
h('aside', { class: 'fx-invoice__aside' },
|
|
1110
|
+
linkedHost,
|
|
908
1111
|
xeroHost,
|
|
909
|
-
jobCard(ui, invoice.job),
|
|
910
1112
|
invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
911
1113
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
912
1114
|
h('ol', { class: 'fx-timeline' }, invoice.versions.map((v) => h('li', {},
|
|
@@ -920,7 +1122,9 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
920
1122
|
// never writes to Xero: a person fixes the difference where they choose.
|
|
921
1123
|
function xeroCard(invoice, ui, context, status, reload) {
|
|
922
1124
|
const { h, flow, toast } = ui;
|
|
923
|
-
|
|
1125
|
+
// The Linked card shows Xero's number and state; this card shows only a
|
|
1126
|
+
// difference to resolve.
|
|
1127
|
+
if (!status?.finding) return null;
|
|
924
1128
|
const rows = status.differences.map((d) => h('tr', {},
|
|
925
1129
|
h('td', {}, FIELD_LABEL[d.field] ?? d.field),
|
|
926
1130
|
h('td', { class: 'fx-num' }, showField(d.field, d.flow)),
|