@arcforgelabs/flow 2026.10.29 → 2026.10.32-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/npm-shrinkwrap.json +188 -2
- package/package.json +2 -1
- package/server/api/alerts.js +49 -0
- package/server/api/invoicing.js +110 -3
- package/server/app.js +29 -2
- package/server/core/alerts.js +157 -0
- package/server/core/automations.js +32 -1
- package/server/core/history.js +65 -0
- package/server/core/invoice-pdf.js +266 -0
- package/server/core/invoicing-rules.js +12 -0
- package/server/core/job-board.js +25 -3
- package/server/core/line-mapping.js +36 -10
- package/server/core/pipeline.js +84 -6
- package/server/core/price-list.js +348 -0
- package/server/core/queue.js +22 -0
- package/server/core/sending.js +16 -4
- package/server/db/commands.js +42 -0
- package/server/db/doctor.js +168 -0
- package/server/db/migrations/0014_price_list.sql +34 -0
- package/server/db/migrations/0015_alert_dismissals.sql +17 -0
- package/server/db/migrations/meta/0014_snapshot.json +3773 -0
- package/server/db/migrations/meta/0015_snapshot.json +3854 -0
- package/server/db/migrations/meta/_journal.json +14 -0
- package/server/db/schema.js +44 -0
- package/server/flow-server.js +20 -1
- package/server/index.js +2 -1
- package/server/integrations/call-guard.js +362 -0
- package/server/integrations/port.js +23 -2
- package/server/integrations/seazona/client.js +6 -0
- package/server/integrations/seazona/feed.js +23 -1
- package/server/integrations/seazona/governor.js +74 -21
- package/server/integrations/seazona/history/backfill.js +208 -0
- package/server/integrations/seazona/history/csv.js +65 -0
- package/server/integrations/seazona/history/index.js +584 -0
- package/server/integrations/seazona/history/read.js +196 -0
- package/server/integrations/seazona/index.js +51 -2
- package/server/integrations/seazona/lanes.js +251 -62
- package/server/integrations/seazona/migrations/0002_call_safety.sql +15 -0
- package/server/integrations/seazona/migrations/0003_call_guard.sql +2 -0
- package/server/integrations/seazona/migrations/0004_history_import.sql +68 -0
- package/server/integrations/seazona/migrations/meta/0002_snapshot.json +882 -0
- package/server/integrations/seazona/migrations/meta/0003_snapshot.json +895 -0
- package/server/integrations/seazona/migrations/meta/0004_snapshot.json +1323 -0
- package/server/integrations/seazona/migrations/meta/_journal.json +21 -0
- package/server/integrations/seazona/routes.js +145 -0
- package/server/integrations/seazona/scheduler.js +37 -1
- package/server/integrations/seazona/schema.js +116 -0
- package/server/integrations/seazona/settings.js +24 -3
- package/server/integrations/seazona/store.js +74 -0
- package/server/integrations/seazona/sync-once.js +1 -1
- package/server/integrations/seazona/tables.js +5 -0
- package/server/integrations/xero/client.js +27 -7
- package/server/integrations/xero/drafts.js +46 -8
- package/server/integrations/xero/import.js +125 -16
- package/server/integrations/xero/index.js +36 -7
- package/server/integrations/xero/lookup.js +26 -0
- package/server/integrations/xero/migrations/0006_xero_call_guard.sql +26 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/mirror.js +11 -2
- package/server/integrations/xero/oauth.js +4 -1
- package/server/integrations/xero/reconcile.js +173 -0
- package/server/integrations/xero/routes.js +35 -0
- package/server/integrations/xero/runtime.js +45 -4
- package/server/release-channel.js +3 -3
- package/server/ui/alerts.js +77 -0
- package/server/ui/app-page.js +1 -1
- package/server/ui/app.js +48 -14
- package/server/ui/clerk-appearance.js +42 -9
- package/server/ui/flow.css +31 -2
- package/server/ui/invoicing-settings.js +1 -1
- package/server/ui/invoicing.js +121 -8
- package/server/ui/line-mappings.js +3 -2
- package/server/ui/monitoring.js +14 -0
- package/server/ui/price-list.js +247 -0
- package/server/ui/routes.js +1 -1
|
@@ -11,6 +11,9 @@ const gearIcon = {
|
|
|
11
11
|
'&& > *': { visibility: 'hidden' },
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
// A small down chevron after the name, as in other apps' account buttons.
|
|
15
|
+
const CHEVRON = `url("data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5L6 7.5L9 4.5"/></svg>')}")`;
|
|
16
|
+
|
|
14
17
|
// Matches the narrow-screen rule in flow.css, where the sidebar becomes a bar.
|
|
15
18
|
const NARROW = '@media (max-width: 820px)';
|
|
16
19
|
|
|
@@ -88,24 +91,54 @@ export function flowClerkAppearance({ embedded = false, publicSignUp = false } =
|
|
|
88
91
|
badge: { '&&': { color: dim, background: `color-mix(in srgb, ${text} 5%, transparent)`, borderColor: line } },
|
|
89
92
|
formButtonPrimary: { boxShadow: 'none', textTransform: 'none', fontWeight: 650 },
|
|
90
93
|
socialButtonsBlockButton: controlEdge,
|
|
91
|
-
// The account button in the sidebar foot
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
94
|
+
// The account button in the sidebar foot: photo or initial, then the
|
|
95
|
+
// name (Clerk shows the email when there is no name), clipped with an
|
|
96
|
+
// ellipsis to fit, then a small chevron. At rest it is plain; on hover,
|
|
97
|
+
// keyboard focus or while its menu is open it is an outlined pill. The
|
|
98
|
+
// top bar on narrow screens has no room for the name, so there it is the
|
|
99
|
+
// photo or initial alone.
|
|
95
100
|
userButtonTrigger: {
|
|
96
101
|
'&&': {
|
|
97
|
-
width: '100%', minWidth: 0, justifyContent: 'flex-start', padding: '4px
|
|
98
|
-
'
|
|
99
|
-
[
|
|
102
|
+
width: 'auto', maxWidth: '100%', minWidth: 0, justifyContent: 'flex-start', padding: '4px 8px 4px 4px',
|
|
103
|
+
borderRadius: token('--t-radius-lg', '10px'), background: 'transparent', boxShadow: 'none',
|
|
104
|
+
'&:hover, &:focus-visible, &[aria-expanded="true"]': { background: `color-mix(in srgb, ${text} 5%, transparent)`, boxShadow: `inset 0 0 0 1px ${line}` },
|
|
105
|
+
'&:focus-visible': { boxShadow: `inset 0 0 0 1px ${line}, ${focus}` },
|
|
106
|
+
[NARROW]: { padding: 0, borderRadius: '50%', '&:hover, &[aria-expanded="true"]': { background: 'transparent', boxShadow: 'none' } },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
userButtonBox: {
|
|
110
|
+
flexDirection: 'row-reverse', justifyContent: 'flex-end', minWidth: 0, gap: '6px',
|
|
111
|
+
// The box lays out right to left, so ::before lands after the name.
|
|
112
|
+
'&::before': {
|
|
113
|
+
content: '""', flex: 'none', width: '10px', height: '10px', background: dim,
|
|
114
|
+
mask: `${CHEVRON} center / contain no-repeat`, WebkitMask: `${CHEVRON} center / contain no-repeat`,
|
|
100
115
|
},
|
|
116
|
+
[NARROW]: { '&::before': { display: 'none' } },
|
|
101
117
|
},
|
|
102
|
-
userButtonBox: { flexDirection: 'row-reverse', justifyContent: 'flex-end', minWidth: 0, gap: '8px' },
|
|
103
118
|
userButtonOuterIdentifier: {
|
|
104
119
|
minWidth: 0, paddingInlineStart: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
105
120
|
color: token('--t-strong', text), fontSize: '14px', fontWeight: 600,
|
|
106
121
|
[NARROW]: { display: 'none' },
|
|
107
122
|
},
|
|
108
|
-
|
|
123
|
+
// The account menu is as wide as its rows, between 220px and 280px, with
|
|
124
|
+
// compact rows. A long name or email ends with an ellipsis and does not
|
|
125
|
+
// make the menu wider.
|
|
126
|
+
userButtonPopoverCard: {
|
|
127
|
+
'&&': { width: 'max-content', minWidth: '220px', maxWidth: 'min(280px, calc(100vw - 16px))', border: `1px solid ${line}`, boxShadow: shadow },
|
|
128
|
+
},
|
|
129
|
+
userPreview__userButton: { '&&': { padding: '10px 12px', gap: '10px' } },
|
|
130
|
+
userPreviewAvatarContainer__userButton: { '&&, && .cl-userPreviewAvatarBox': { width: '28px', height: '28px' } },
|
|
131
|
+
userPreviewTextContainer__userButton: { '&&': { minWidth: 0, overflow: 'hidden' } },
|
|
132
|
+
userPreviewMainIdentifier__userButton: { '&&': { minWidth: 0, fontSize: '13px' } },
|
|
133
|
+
userPreviewMainIdentifierText__userButton: { '&&': { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
134
|
+
userPreviewSecondaryIdentifier__userButton: {
|
|
135
|
+
'&&': { display: 'block', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '12px', color: dim },
|
|
136
|
+
},
|
|
137
|
+
userButtonPopoverActionButton: { '&&': { padding: '8px 12px', gap: '10px', fontSize: '13px', color: text } },
|
|
138
|
+
userButtonPopoverActionButtonIconBox: { '&&': { flex: '0 0 28px', width: '28px' } },
|
|
139
|
+
userButtonPopoverCustomItemButton: { '&&': { padding: '8px 12px', gap: '10px', fontSize: '13px', color: text } },
|
|
140
|
+
userButtonPopoverCustomItemButtonIconBox: { '&&': { flex: '0 0 28px', width: '28px' } },
|
|
141
|
+
userButtonPopoverFooter: { '&&': { minWidth: 0 } },
|
|
109
142
|
userButtonPopoverActionButtonIcon__manageAccount: gearIcon,
|
|
110
143
|
footer: embedded ? { display: 'none' } : undefined,
|
|
111
144
|
},
|
package/server/ui/flow.css
CHANGED
|
@@ -132,7 +132,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
132
132
|
.fx-nav__count { margin-left: auto; color: var(--t-dim); font-size: 12px; font-weight: 700; }
|
|
133
133
|
.fx-ico { display: inline-flex; flex: none; }
|
|
134
134
|
.fx-ico svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
135
|
-
.fx-side__end { display: flex; align-items: center; justify-content: space-between; gap:
|
|
135
|
+
.fx-side__end { display: flex; align-items: center; justify-content: space-between; gap: 4px; padding: 12px 0 0; border-top: 1px solid var(--fx-line); }
|
|
136
136
|
.fx-side__user { display: flex; min-width: 32px; min-height: 32px; align-items: center; }
|
|
137
137
|
/* Clerk replaces the mount's classes with its root box's, so this uses the id. */
|
|
138
138
|
#user-button { flex: 1 1 auto; min-width: 0; }
|
|
@@ -148,7 +148,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
148
148
|
.fx-shell[data-folded] .fx-side__end { flex-direction: column; padding: 12px 0 0; }
|
|
149
149
|
.fx-shell[data-folded] #user-button { flex: none; }
|
|
150
150
|
.fx-shell[data-folded] #user-button .cl-userButtonTrigger { width: auto; padding: 0; border-radius: 50%; background: transparent; box-shadow: none; }
|
|
151
|
-
.fx-shell[data-folded] #user-button .cl-userButtonOuterIdentifier { display: none; }
|
|
151
|
+
.fx-shell[data-folded] #user-button .cl-userButtonOuterIdentifier, .fx-shell[data-folded] #user-button .cl-userButtonBox::before { display: none; }
|
|
152
152
|
}
|
|
153
153
|
.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; }
|
|
154
154
|
.fx-main:focus { outline: none; box-shadow: none; }
|
|
@@ -269,6 +269,7 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
269
269
|
.fx-int-hero__actions { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 6px; }
|
|
270
270
|
.fx-int-page__grid { display: grid; grid-template-columns: minmax(0, 1fr) 240px; gap: 32px; align-items: start; }
|
|
271
271
|
.fx-int-page__main { display: grid; gap: 28px; }
|
|
272
|
+
.fx-int-page__main > .fx-alerts { margin: 0; }
|
|
272
273
|
.fx-int-section { display: grid; gap: 14px; padding-top: 20px; border-top: 1px solid var(--fx-line); }
|
|
273
274
|
.fx-int-section__title { color: var(--t-strong); font: 650 17px/1.3 var(--t-body); }
|
|
274
275
|
.fx-int-section .fx-btn { justify-self: start; }
|
|
@@ -328,6 +329,25 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
328
329
|
/* Invoices: the Send all ready bar above the Ready list. */
|
|
329
330
|
.fx-sendbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin: 0 0 12px; padding: 12px 16px; border: 1px solid var(--fx-line); border-radius: 8px; background: var(--t-paper); margin-top: 12px; }
|
|
330
331
|
.fx-sendbar[hidden] { display: none; }
|
|
332
|
+
|
|
333
|
+
/* Quiet alerts (server/ui/alerts.js): one slim line under the page header,
|
|
334
|
+
only while something on the page needs this person. */
|
|
335
|
+
.fx-alerts { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; padding: 6px 6px 6px 14px; border: 1px solid var(--fx-line); border-left: 3px solid var(--t-warn); border-radius: var(--t-radius); background: var(--t-paper); font-size: 14px; }
|
|
336
|
+
.fx-alerts[hidden] { display: none; }
|
|
337
|
+
.fx-alerts[data-tone="danger"] { border-left-color: var(--t-danger); }
|
|
338
|
+
.fx-alerts__text { flex: 1; min-width: 0; margin: 0; color: var(--t-strong); }
|
|
339
|
+
.fx-alerts__money { color: var(--t-dim); }
|
|
340
|
+
.fx-alerts__action { flex: none; padding: 0; border: 0; background: none; font: inherit; font-weight: 600; color: var(--t-strong); text-decoration: underline; text-underline-offset: 2px; cursor: pointer; }
|
|
341
|
+
.fx-alerts__more { flex: none; font-size: 12.5px; color: var(--t-dim); white-space: nowrap; }
|
|
342
|
+
.fx-alerts__close { flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--t-radius); background: none; color: var(--t-dim); cursor: pointer; }
|
|
343
|
+
.fx-alerts__close:hover { background: var(--fx-hover); color: var(--t-strong); }
|
|
344
|
+
.fx-alerts__close svg { width: 14px; height: 14px; }
|
|
345
|
+
.fx-alerts__action:focus-visible, .fx-alerts__close:focus-visible { outline: 2px solid var(--t-ring); outline-offset: 2px; }
|
|
346
|
+
@media (max-width: 560px) {
|
|
347
|
+
.fx-alerts { flex-wrap: wrap; }
|
|
348
|
+
.fx-alerts__text { flex-basis: calc(100% - 52px); }
|
|
349
|
+
.fx-alerts__action, .fx-alerts__more { order: 2; }
|
|
350
|
+
}
|
|
331
351
|
.fx-sendbar p { margin: 0; }
|
|
332
352
|
.fx-problem { color: var(--t-danger); }
|
|
333
353
|
.fx-sendbar__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
@@ -587,6 +607,7 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
587
607
|
.fx-setting__text { display: grid; gap: 3px; min-width: 0; }
|
|
588
608
|
.fx-setting__name { color: var(--t-strong); font: 500 14.5px/1.35 var(--t-body); }
|
|
589
609
|
.fx-setting__hint { margin: 0; color: var(--t-dim); font-size: 13px; line-height: 1.45; }
|
|
610
|
+
.fx-setting[data-tone="danger"] .fx-setting__hint { color: var(--t-danger); }
|
|
590
611
|
.fx-setting__control { display: flex; justify-content: flex-end; min-width: 0; }
|
|
591
612
|
.fx-setting__control > .fx-input, .fx-setting__control > .fx-select, .fx-setting__stack { width: 320px; max-width: 100%; }
|
|
592
613
|
.fx-setting__control .fx-swatches { flex-wrap: nowrap; }
|
|
@@ -688,6 +709,14 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
688
709
|
.fx-set__control .fx-input[type="number"] { width: 96px; }
|
|
689
710
|
.fx-set__control .fx-select { min-width: 220px; }
|
|
690
711
|
.fx-set + .fx-set { margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--fx-line); }
|
|
712
|
+
/* Price list (price-list.js): a code's price and its systems' chips. */
|
|
713
|
+
.fx-price__hint { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; }
|
|
714
|
+
.fx-price__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; }
|
|
715
|
+
.fx-price__head .fx-search { margin: 0; }
|
|
716
|
+
@media (max-width: 560px) { .fx-price__head .fx-search { width: 100%; } }
|
|
717
|
+
.fx-price__systems { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
|
|
718
|
+
.fx-set__control .fx-price__link { width: 260px; min-width: 0; }
|
|
719
|
+
@media (max-width: 560px) { .fx-set__control .fx-price__link { width: 150px; } }
|
|
691
720
|
/* Invoice rules (invoice-rules.js): a row's Edit button and switch, and the
|
|
692
721
|
rule dialog's condition lines. */
|
|
693
722
|
.fx-rule__controls { align-items: center; gap: 10px; }
|
|
@@ -23,7 +23,7 @@ const SEND_MODES = [
|
|
|
23
23
|
export async function renderInvoicingSettings(main, ui) {
|
|
24
24
|
const { h, flow, toast, notice, pageHeader, me } = ui;
|
|
25
25
|
const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
|
|
26
|
-
main.append(pageHeader('
|
|
26
|
+
main.append(pageHeader('Invoice rules', { subtitle: 'The rules Flow uses to draft each invoice.' }), host);
|
|
27
27
|
let body;
|
|
28
28
|
try {
|
|
29
29
|
body = await flow('/api/invoicing/settings');
|
package/server/ui/invoicing.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// are a preview with the same rule (GST 10% per line, rounded half away from
|
|
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
|
+
import { alertStrip } from '/assets/ui/alerts.js';
|
|
8
9
|
|
|
9
10
|
const STATUS_LABEL = { draft: 'Draft', issued: 'Issued', void: 'Void' };
|
|
10
11
|
const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
|
|
@@ -12,7 +13,8 @@ const STATUS_TONE = { draft: 'info', issued: 'success', void: 'mute' };
|
|
|
12
13
|
// every draft is in one of them. A person marks a checked draft ready; any
|
|
13
14
|
// later change sends it back. An issued invoice that failed to send is a
|
|
14
15
|
// problem.
|
|
15
|
-
|
|
16
|
+
// Ready first: it opens by default, as the drafts most often wait there to send.
|
|
17
|
+
const TABS = [['ready', 'Ready'], ['to_check', 'Drafts'], ['problem', 'Problem'], ['issued', 'Issued'], ['void', 'Void']];
|
|
16
18
|
const VIEW_LABEL = { problem: 'Problem', ready: 'Ready', sending: 'Sending', to_check: 'Draft' };
|
|
17
19
|
const VIEW_TONE = { problem: 'danger', ready: 'success', sending: 'info', to_check: 'info' };
|
|
18
20
|
const EMPTY = {
|
|
@@ -26,6 +28,31 @@ const TICKS = { to_check: true, ready: true };
|
|
|
26
28
|
const money = new Intl.NumberFormat('en-AU', { style: 'currency', currency: 'AUD' });
|
|
27
29
|
export const formatCents = (cents) => money.format((cents ?? 0) / 100);
|
|
28
30
|
|
|
31
|
+
// Xero's status of an issued invoice, from Flow's copy of Xero (issue #132).
|
|
32
|
+
// A finding (a difference, or a void on one side only) shows as Out of sync.
|
|
33
|
+
const XERO_TONE = {
|
|
34
|
+
not_sent: 'mute', sending: 'info', not_copied: 'info', draft: 'info',
|
|
35
|
+
approved: 'success', part_paid: 'success', paid: 'success', voided: 'mute', deleted: 'mute',
|
|
36
|
+
};
|
|
37
|
+
const FIELD_LABEL = { contact: 'Client', subtotal: 'Subtotal', tax: 'GST', total: 'Total', 'line count': 'Lines', items: 'Items', date: 'Date' };
|
|
38
|
+
|
|
39
|
+
export function xeroTag(ui, status) {
|
|
40
|
+
const { h } = ui;
|
|
41
|
+
if (!status) return null;
|
|
42
|
+
return status.finding
|
|
43
|
+
? h('span', { class: 'fx-status', 'data-tone': 'danger', title: status.message }, 'Out of sync with Xero')
|
|
44
|
+
: h('span', { class: 'fx-status', 'data-tone': XERO_TONE[status.state] ?? 'mute' }, status.label);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const showField = (field, value) => (['subtotal', 'tax', 'total'].includes(field) && Number.isFinite(value) ? formatCents(value) : String(value ?? '—'));
|
|
48
|
+
|
|
49
|
+
// Xero's status for some invoices, or an empty map while Xero is off.
|
|
50
|
+
async function xeroStatuses(ui, organisationId, invoiceIds) {
|
|
51
|
+
if (!organisationId || !invoiceIds.length) return new Map();
|
|
52
|
+
const res = await ui.flow('/api/xero/invoices/status', { method: 'POST', body: JSON.stringify({ organisationId, invoiceIds }) }).catch(() => null);
|
|
53
|
+
return new Map((res?.invoices ?? []).map((s) => [s.invoiceId, s]));
|
|
54
|
+
}
|
|
55
|
+
|
|
29
56
|
function gstCents(lineAmountCents) {
|
|
30
57
|
const sign = Math.sign(lineAmountCents);
|
|
31
58
|
const abs = Math.abs(lineAmountCents) * 1000;
|
|
@@ -48,22 +75,40 @@ function localDate(date = new Date()) {
|
|
|
48
75
|
|
|
49
76
|
const showDate = (iso) => (iso ? new Date(`${iso}T00:00:00`).toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) : '—');
|
|
50
77
|
|
|
78
|
+
// What happens to a ready invoice in the accounting system, one fact per
|
|
79
|
+
// sentence. It follows the integration's settings: its connection, whether it
|
|
80
|
+
// takes writes, and the send mode on its page.
|
|
81
|
+
export function sendModeNote(to, sendMode) {
|
|
82
|
+
if (!to) return 'No accounting system is on. Flow keeps each invoice here only.';
|
|
83
|
+
if (sendMode && !sendMode.connected) return `${to} is not connected. Connect it in Integrations before you send.`;
|
|
84
|
+
if (sendMode && !sendMode.writesAllowed) return `${to} does not take invoices from Flow yet. Turn this on in Integrations, ${to}.`;
|
|
85
|
+
const text = sendMode?.mode === 'approve_and_email'
|
|
86
|
+
? [`Flow sends each invoice to ${to} as approved.`, `${to} emails it to the customer at once.`]
|
|
87
|
+
: [`Flow sends each invoice to ${to} as a draft.`, `You approve and send it in ${to}.`];
|
|
88
|
+
const others = sendMode?.otherCustomers ?? 0;
|
|
89
|
+
if (others) text.push(`${others} ${others === 1 ? 'customer has' : 'customers have'} a different setting.`);
|
|
90
|
+
return text.join(' ');
|
|
91
|
+
}
|
|
92
|
+
|
|
51
93
|
// ── The list ───────────────────────────────────────────────────────────────
|
|
52
94
|
|
|
53
95
|
export async function renderInvoiceList(main, ui) {
|
|
54
96
|
const { h, flow, pageHeader, notice, go, toast } = ui;
|
|
55
97
|
const saved = sessionStorage.getItem('flow_invoice_tab');
|
|
56
|
-
const state = { status: TABS.some(([id]) => id === saved) ? saved : '
|
|
98
|
+
const state = { status: TABS.some(([id]) => id === saved) ? saved : 'ready', q: '', ticked: new Set() };
|
|
57
99
|
const tabs = h('div', { class: 'fx-tabs', role: 'tablist' });
|
|
58
100
|
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Find a number, client or reference', 'aria-label': 'Find an invoice' });
|
|
59
101
|
const bar = h('div', { class: 'fx-sendbar', hidden: true });
|
|
60
102
|
const host = h('div', { class: 'fx-panel' }, h('div', { class: 'fx-skeleton' }));
|
|
61
103
|
const context = await flow('/api/invoicing/context').catch(() => ({ canIssue: false }));
|
|
62
104
|
let body = null;
|
|
105
|
+
const openTab = (id) => { state.status = id; state.ticked.clear(); sessionStorage.setItem('flow_invoice_tab', id); load(); };
|
|
106
|
+
const alerts = alertStrip(ui, 'invoices', { onTab: openTab });
|
|
63
107
|
main.append(
|
|
64
108
|
pageHeader('Invoices', {
|
|
65
109
|
actions: [h('a', { class: 'fx-btn fx-btn--primary', href: '/invoices/new', onclick: go }, 'New invoice')],
|
|
66
110
|
}),
|
|
111
|
+
alerts.element,
|
|
67
112
|
h('div', { class: 'fx-toolbar' }, tabs, search),
|
|
68
113
|
bar,
|
|
69
114
|
host);
|
|
@@ -85,6 +130,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
85
130
|
toast(...done(res));
|
|
86
131
|
state.ticked.clear();
|
|
87
132
|
await load();
|
|
133
|
+
alerts.refresh();
|
|
88
134
|
} catch (error) {
|
|
89
135
|
toast(error.message, 'danger');
|
|
90
136
|
el.disabled = false;
|
|
@@ -107,7 +153,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
107
153
|
: [`${plural(res.marked, 'draft', 'drafts')} ready.`, 'success'])));
|
|
108
154
|
}
|
|
109
155
|
} else if (state.status === 'ready') {
|
|
110
|
-
parts.push(h('p', { class: 'fx-dim' }, to
|
|
156
|
+
parts.push(h('p', { class: 'fx-dim' }, sendModeNote(to, body.sendMode)));
|
|
111
157
|
const buttons = [];
|
|
112
158
|
if (ticked.length) {
|
|
113
159
|
buttons.push(action(`Back to drafts (${ticked.length})`, 'mark-not-ready', () => ({ invoiceIds: ticked }),
|
|
@@ -144,7 +190,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
144
190
|
role: 'tab',
|
|
145
191
|
'aria-selected': String(state.status === id),
|
|
146
192
|
...(id === 'problem' && body.counts?.problem ? { 'data-tone': 'danger' } : {}),
|
|
147
|
-
onclick: () =>
|
|
193
|
+
onclick: () => openTab(id),
|
|
148
194
|
}, label, h('span', { class: 'fx-tabs__count' }, String(body.counts?.[id] ?? 0)))));
|
|
149
195
|
drawBar();
|
|
150
196
|
if (!body.invoices.length) {
|
|
@@ -175,6 +221,7 @@ export async function renderInvoiceList(main, ui) {
|
|
|
175
221
|
drawBar();
|
|
176
222
|
},
|
|
177
223
|
});
|
|
224
|
+
const statusCells = new Map();
|
|
178
225
|
host.replaceChildren(h('table', { class: 'fx-table fx-table--rows' },
|
|
179
226
|
h('thead', {}, h('tr', {},
|
|
180
227
|
ticks ? h('th', { class: 'fx-tick-cell' }, all) : null,
|
|
@@ -193,10 +240,23 @@ export async function renderInvoiceList(main, ui) {
|
|
|
193
240
|
: h('td', { class: 'fx-dim' }, invoice.job?.caseRef ?? invoice.reference ?? '—'),
|
|
194
241
|
h('td', { class: 'fx-dim' }, showDate(invoice.issueDate ?? invoice.createdAt?.slice(0, 10))),
|
|
195
242
|
h('td', { class: 'fx-num fx-strong' }, formatCents(invoice.totalCents)),
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
243
|
+
(() => {
|
|
244
|
+
const cell = h('td', { class: 'fx-status-cell' }, view
|
|
245
|
+
? h('span', { class: 'fx-status', 'data-tone': VIEW_TONE[view] }, VIEW_LABEL[view])
|
|
246
|
+
: h('span', { class: 'fx-status', 'data-tone': STATUS_TONE[invoice.status] }, STATUS_LABEL[invoice.status]));
|
|
247
|
+
statusCells.set(invoice.id, cell);
|
|
248
|
+
return cell;
|
|
249
|
+
})());
|
|
199
250
|
}))));
|
|
251
|
+
// Issued and void invoices also show Xero's status, from Flow's copy.
|
|
252
|
+
if (['issued', 'void'].includes(state.status)) {
|
|
253
|
+
const shownBody = body;
|
|
254
|
+
const statuses = await xeroStatuses(ui, context.organisations?.[0]?.id, body.invoices.map((i) => i.id));
|
|
255
|
+
if (shownBody !== body) return;
|
|
256
|
+
for (const [invoiceId, status] of statuses) {
|
|
257
|
+
if (status.state !== 'not_sent' || status.finding) statusCells.get(invoiceId)?.append(' ', xeroTag(ui, status));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
200
260
|
}
|
|
201
261
|
await load();
|
|
202
262
|
}
|
|
@@ -516,10 +576,26 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
516
576
|
toast(error.message, 'danger');
|
|
517
577
|
}
|
|
518
578
|
};
|
|
579
|
+
const organisationId = context.organisations?.[0]?.id;
|
|
580
|
+
const xeroHost = h('div');
|
|
581
|
+
let xeroStatus = null;
|
|
582
|
+
const loadXero = async () => {
|
|
583
|
+
if (!context.xero) return;
|
|
584
|
+
xeroStatus = (await xeroStatuses(ui, organisationId, [invoice.id])).get(invoice.id) ?? null;
|
|
585
|
+
xeroHost.replaceChildren(xeroCard(invoice, ui, context, xeroStatus, loadXero) ?? '');
|
|
586
|
+
};
|
|
587
|
+
loadXero();
|
|
588
|
+
// Voiding in Flow never changes Xero: say so while Xero still holds it live.
|
|
589
|
+
const voidBody = () => {
|
|
590
|
+
const live = xeroStatus?.xero && !['VOIDED', 'DELETED'].includes(xeroStatus.xero.status);
|
|
591
|
+
return h('div', {},
|
|
592
|
+
h('p', {}, 'The number stays used. A void invoice cannot be issued again.'),
|
|
593
|
+
live ? h('p', { class: 'fx-problem' }, `Xero still holds it as ${xeroStatus.label.replace(/ in Xero$/, '').toLowerCase()}${xeroStatus.xero.number ? ` (${xeroStatus.xero.number})` : ''}. Flow does not change Xero: void it in Xero too.`) : null);
|
|
594
|
+
};
|
|
519
595
|
const actions = invoice.status === 'issued' && context.canIssue ? [
|
|
520
596
|
h('button', {
|
|
521
597
|
class: 'fx-btn fx-btn--ghost fx-btn--danger', type: 'button',
|
|
522
|
-
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body:
|
|
598
|
+
onclick: () => act('void', `${invoice.number} is void.`, { title: `Void ${invoice.number}?`, body: voidBody(), confirm: 'Void', tone: 'danger' }),
|
|
523
599
|
}, 'Void'),
|
|
524
600
|
h('button', { class: 'fx-btn', type: 'button', onclick: () => act('revise', `${invoice.number} is a draft again.`) }, 'Revise'),
|
|
525
601
|
sendToXero(invoice, ui, context),
|
|
@@ -551,6 +627,7 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
551
627
|
h('td', { class: 'fx-num fx-strong' }, formatCents(line.lineAmountCents)))))),
|
|
552
628
|
h('div', { class: 'fx-lines__foot' }, h('span'), totalsBox(ui, invoice)))),
|
|
553
629
|
h('aside', { class: 'fx-invoice__aside' },
|
|
630
|
+
xeroHost,
|
|
554
631
|
jobCard(ui, invoice.job),
|
|
555
632
|
invoice.versions?.length ? h('section', { class: 'fx-card fx-side-card' },
|
|
556
633
|
h('h3', { class: 'fx-label' }, 'History'),
|
|
@@ -560,6 +637,42 @@ function issuedView(invoice, ui, context, redraw) {
|
|
|
560
637
|
];
|
|
561
638
|
}
|
|
562
639
|
|
|
640
|
+
// Xero's copy of this invoice: its status, any difference from what Flow
|
|
641
|
+
// sent, and Mark resolved, which reads Xero's changes and checks again. It
|
|
642
|
+
// never writes to Xero: a person fixes the difference where they choose.
|
|
643
|
+
function xeroCard(invoice, ui, context, status, reload) {
|
|
644
|
+
const { h, flow, toast } = ui;
|
|
645
|
+
if (!status || (status.state === 'not_sent' && !status.finding)) return null;
|
|
646
|
+
const rows = status.differences.map((d) => h('tr', {},
|
|
647
|
+
h('td', {}, FIELD_LABEL[d.field] ?? d.field),
|
|
648
|
+
h('td', { class: 'fx-num' }, showField(d.field, d.flow)),
|
|
649
|
+
h('td', { class: 'fx-num' }, showField(d.field, d.xero))));
|
|
650
|
+
const resolve = status.finding && context.canIssue ? h('button', {
|
|
651
|
+
class: 'fx-btn fx-btn--sm', type: 'button',
|
|
652
|
+
onclick: async () => {
|
|
653
|
+
resolve.disabled = true;
|
|
654
|
+
try {
|
|
655
|
+
const res = await flow(`/api/xero/invoices/${invoice.id}/recheck`, { method: 'POST', body: JSON.stringify({ organisationId: context.organisations[0].id }) });
|
|
656
|
+
toast(res.resolved ? 'Flow and Xero agree now.' : 'Still different. Fix it in Xero or in Flow, then try again.', res.resolved ? 'success' : 'danger');
|
|
657
|
+
await reload();
|
|
658
|
+
} catch (error) {
|
|
659
|
+
toast(error.message, 'danger');
|
|
660
|
+
resolve.disabled = false;
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
}, 'Mark resolved') : null;
|
|
664
|
+
return h('section', { class: 'fx-card fx-side-card' },
|
|
665
|
+
h('h3', { class: 'fx-label' }, 'Xero'),
|
|
666
|
+
h('div', { class: 'fx-row' }, xeroTag(ui, status), status.xero?.number ? h('span', { class: 'fx-dim' }, status.xero.number) : null),
|
|
667
|
+
status.finding ? h('p', { class: 'fx-problem' }, status.message) : null,
|
|
668
|
+
rows.length ? h('table', { class: 'fx-table' },
|
|
669
|
+
h('thead', {}, h('tr', {}, h('th', {}, ''), h('th', { class: 'fx-num' }, 'Flow sent'), h('th', { class: 'fx-num' }, 'Xero now'))),
|
|
670
|
+
h('tbody', {}, rows)) : null,
|
|
671
|
+
h('div', { class: 'fx-row' },
|
|
672
|
+
status.xero?.url ? h('a', { class: 'fx-btn fx-btn--sm fx-btn--ghost', href: status.xero.url, target: '_blank', rel: 'noopener' }, 'Open in Xero') : null,
|
|
673
|
+
resolve));
|
|
674
|
+
}
|
|
675
|
+
|
|
563
676
|
// Send to Xero (POST /api/xero/invoices/push). The customer's send mode
|
|
564
677
|
// (Settings, Invoicing) decides what Xero gets: a draft that a person approves
|
|
565
678
|
// there, or an approved invoice that Xero emails to the customer.
|
|
@@ -28,8 +28,9 @@ export function lineMappingSection(ui) {
|
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
30
|
const waiting = labels.filter((l) => l.unmapped > 0);
|
|
31
|
+
const listLink = row('Price list', 'Your codes and prices, and their links.', h('a', { class: 'fx-btn fx-btn--ghost fx-btn--sm', href: '/price-list', onclick: (event) => ui.go(event) }, 'Open'));
|
|
31
32
|
if (!waiting.length) {
|
|
32
|
-
body.replaceChildren(row('Every line has a price code', 'Lines from Seazona find their price item by themselves.', h('span', { class: 'fx-dim' }, '')));
|
|
33
|
+
body.replaceChildren(row('Every line has a price code', 'Lines from Seazona find their price item by themselves.', h('span', { class: 'fx-dim' }, '')), listLink);
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
const options = [h('option', { value: '' }, 'Choose a price item…'),
|
|
@@ -53,7 +54,7 @@ export function lineMappingSection(ui) {
|
|
|
53
54
|
});
|
|
54
55
|
const lines = label.unmapped === 1 ? '1 line waits' : `${label.unmapped} lines wait`;
|
|
55
56
|
return row(label.sample, `${money(label.unitAmountCents)} · ${lines}`, select);
|
|
56
|
-
}));
|
|
57
|
+
}), listLink);
|
|
57
58
|
}
|
|
58
59
|
load();
|
|
59
60
|
return section;
|
package/server/ui/monitoring.js
CHANGED
|
@@ -16,6 +16,16 @@ function loadScript({ src, integrity }) {
|
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// "Notify me": the form's email field (Sentry's own words) starts empty and
|
|
20
|
+
// is optional. A person who types an address there asks to hear back; Flow
|
|
21
|
+
// tags the report notify=yes, so the support desk sees it on the task.
|
|
22
|
+
// Sentry's form has no tick box of its own; this keeps to its stock options.
|
|
23
|
+
export function tagNotify(event) {
|
|
24
|
+
if (event?.type !== 'feedback') return;
|
|
25
|
+
const email = String(event.contexts?.feedback?.contact_email ?? '').trim();
|
|
26
|
+
event.tags = { ...event.tags, notify: email ? 'yes' : 'no' };
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
/** Resolves to a function that opens the "Report a problem" form, or null. */
|
|
20
30
|
export async function startMonitoring({ flow, person }) {
|
|
21
31
|
const config = await flow('/api/monitoring');
|
|
@@ -30,6 +40,9 @@ export async function startMonitoring({ flow, person }) {
|
|
|
30
40
|
showBranding: false,
|
|
31
41
|
// Flow already knows who is signed in (setUser below).
|
|
32
42
|
showName: false,
|
|
43
|
+
// The email field is the opt-in (tagNotify), so it is not filled from
|
|
44
|
+
// the signed-in person: a key setUser never sets.
|
|
45
|
+
useSentryUser: { name: 'username', email: 'notify_email' },
|
|
33
46
|
enableScreenshot: true,
|
|
34
47
|
triggerLabel: 'Report a problem',
|
|
35
48
|
formTitle: 'Report a problem',
|
|
@@ -45,6 +58,7 @@ export async function startMonitoring({ flow, person }) {
|
|
|
45
58
|
sendDefaultPii: false,
|
|
46
59
|
integrations,
|
|
47
60
|
});
|
|
61
|
+
Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
|
|
48
62
|
// Who reported or met the error, so the lab can follow up.
|
|
49
63
|
Sentry.setUser({ id: person.id, email: person.email });
|
|
50
64
|
const feedback = config.feedback ? Sentry.getFeedback() : null;
|