@arcforgelabs/flow 2026.10.48 → 2026.10.49
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 +11 -11
- package/package.json +1 -1
- package/server/core/automations.js +34 -0
- package/server/core/pipeline.js +46 -6
- package/server/integrations/port.js +1 -1
- package/server/integrations/seazona/feed.js +41 -4
- package/server/integrations/seazona/sweep.js +11 -8
- package/server/integrations/sentry/index.js +2 -2
- package/server/integrations/xero/client.js +23 -0
- package/server/integrations/xero/contact-sync.js +289 -0
- package/server/integrations/xero/create-contact.js +42 -2
- package/server/integrations/xero/drafts.js +14 -6
- package/server/integrations/xero/import.js +5 -1
- package/server/integrations/xero/index.js +15 -2
- package/server/integrations/xero/lookup.js +15 -2
- package/server/integrations/xero/migrations/0013_xero_contact_sync.sql +15 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/integrations/xero/routes.js +16 -2
- package/server/ui/app.js +44 -39
- package/server/ui/automations.js +2 -4
- package/server/ui/contacts.js +20 -27
- package/server/ui/flow.css +120 -40
- package/server/ui/invoice-rules.js +2 -4
- package/server/ui/invoicing-settings.js +3 -7
- package/server/ui/invoicing.js +49 -75
- package/server/ui/kit.js +101 -10
- package/server/ui/line-mappings.js +2 -4
- package/server/ui/mobile.js +48 -3
- package/server/ui/price-list.js +3 -5
- package/server/ui/routes.js +1 -1
- package/server/ui/xero-contact.js +101 -0
package/server/ui/app.js
CHANGED
|
@@ -25,7 +25,7 @@ import { isSearchKey, openSearch } from '/assets/ui/search.js';
|
|
|
25
25
|
import { alertStrip } from '/assets/ui/alerts.js';
|
|
26
26
|
import { initialsOf, personLabel } from '/assets/ui/person.js';
|
|
27
27
|
import { startTips } from '/assets/ui/tip.js';
|
|
28
|
-
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
28
|
+
import { crumbs, dialog, edgeFade, fieldError, h, holdButton, linksOpenWith, listFrame, listHead, notice, pageHeader, pickOne, rowMenu, sectionTitle, settingRow, tileCard, tileSection, toast } from '/assets/ui/kit.js';
|
|
29
29
|
import { NAV_DEFAULT, NAV_LOCKED } from '/assets/ui/nav-pins.js';
|
|
30
30
|
|
|
31
31
|
const $ = (id) => document.getElementById(id);
|
|
@@ -332,7 +332,7 @@ function navigate(event) {
|
|
|
332
332
|
// The shared pieces a view module (invoicing.js) builds with. Its `go`
|
|
333
333
|
// takes a path, or a link's click event.
|
|
334
334
|
const ui = () => ({
|
|
335
|
-
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, dialog, me, holdButton, tileCard, tileSection, crumbs,
|
|
335
|
+
h, flow, toast, notice, fieldError, pageHeader, listHead, listFrame, edgeFade, sectionTitle, settingRow, dialog, pickOne, me, holdButton, tileCard, tileSection, crumbs,
|
|
336
336
|
go: (target, options) => (typeof target === 'string' ? go(target, options) : navigate(target)),
|
|
337
337
|
});
|
|
338
338
|
|
|
@@ -622,6 +622,11 @@ const CATALOGUE = {
|
|
|
622
622
|
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
|
|
623
623
|
const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
|
|
624
624
|
|
|
625
|
+
// The two notices every integration page can show, each one short line. The
|
|
626
|
+
// detail is on the ? (docs/INTEGRATIONS.md has the rest).
|
|
627
|
+
const readOnlyNotice = () => notice('Verified owner sign-in needed.', 'warn', 'Read only.');
|
|
628
|
+
const secretsKeyNotice = () => notice('Set FLOW_SECRETS_KEY, then restart Flow.', 'warn', 'No secrets key.');
|
|
629
|
+
|
|
625
630
|
function integrationLogo(integration, size = '') {
|
|
626
631
|
const file = CATALOGUE[integration.id]?.logo;
|
|
627
632
|
return h('span', { class: `fx-logo${size ? ` fx-logo--${size}` : ''}`, 'aria-hidden': 'true' },
|
|
@@ -631,7 +636,7 @@ function integrationLogo(integration, size = '') {
|
|
|
631
636
|
async function renderIntegrations(main) {
|
|
632
637
|
const state = { tab: 'all', category: 'all', q: '' };
|
|
633
638
|
const tabs = h('div', { class: 'fx-seg', role: 'tablist' });
|
|
634
|
-
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search
|
|
639
|
+
const search = h('input', { class: 'fx-input fx-search', type: 'search', placeholder: 'Search', 'aria-label': 'Search integrations' });
|
|
635
640
|
const category = h('select', { class: 'fx-select', 'aria-label': 'Category' },
|
|
636
641
|
CATEGORIES.map(([id, label]) => h('option', { value: id }, id === 'all' ? 'All categories' : label)));
|
|
637
642
|
const notices = h('div', { class: 'fx-fields' });
|
|
@@ -650,8 +655,8 @@ async function renderIntegrations(main) {
|
|
|
650
655
|
}
|
|
651
656
|
const canChange = Boolean(me.ownerActions?.allowed);
|
|
652
657
|
notices.replaceChildren(...[
|
|
653
|
-
canChange ? null :
|
|
654
|
-
body.secretsKey ? null :
|
|
658
|
+
canChange ? null : readOnlyNotice(),
|
|
659
|
+
body.secretsKey ? null : secretsKeyNotice(),
|
|
655
660
|
].filter(Boolean));
|
|
656
661
|
const list = [...body.integrations].sort((a, b) => a.name.localeCompare(b.name));
|
|
657
662
|
const connected = (i) => i.enabled;
|
|
@@ -706,8 +711,8 @@ function integrationTile(integration) {
|
|
|
706
711
|
}
|
|
707
712
|
|
|
708
713
|
|
|
709
|
-
// One integration's page (/integrations/<id>): its logo and
|
|
710
|
-
//
|
|
714
|
+
// One integration's page (/integrations/<id>): its logo, name and Connect
|
|
715
|
+
// (each step shown as it runs), the setup steps (closed), its settings, and the
|
|
711
716
|
// facts beside them.
|
|
712
717
|
async function renderIntegration(main, id) {
|
|
713
718
|
const host = h('div', { class: 'fx-int-page' }, h('div', { class: 'fx-skeleton' }));
|
|
@@ -733,6 +738,9 @@ async function renderIntegration(main, id) {
|
|
|
733
738
|
? h('span', { class: 'fx-status', 'data-tone': 'danger', 'data-int-status': '' }, 'Calls stopped')
|
|
734
739
|
: h('span', { class: 'fx-status', 'data-tone': STATE_TONE[current.state] ?? 'mute', 'data-int-status': '' }, STATE_LABEL[current.state] ?? current.state));
|
|
735
740
|
let shown = found;
|
|
741
|
+
// Settings: closed on a phone, open on a wider screen, then as the person
|
|
742
|
+
// leaves it while the page redraws (Connect).
|
|
743
|
+
let settingsOpen = !window.matchMedia?.('(max-width: 639px)').matches;
|
|
736
744
|
const alerts = alertStrip(ui(), 'integrations', {
|
|
737
745
|
scope: id,
|
|
738
746
|
onLoad: ({ danger }) => {
|
|
@@ -750,7 +758,7 @@ async function renderIntegration(main, id) {
|
|
|
750
758
|
// The host sets it: a value to read, not a box to type in.
|
|
751
759
|
rows.push(settingRow({
|
|
752
760
|
label: field.label,
|
|
753
|
-
|
|
761
|
+
help: 'Set by whoever hosts Flow.',
|
|
754
762
|
control: field.secret ? h('span', { class: 'fx-dim' }, 'Set') : h('code', { class: 'fx-setting__value' }, field.value),
|
|
755
763
|
}));
|
|
756
764
|
continue;
|
|
@@ -772,10 +780,11 @@ async function renderIntegration(main, id) {
|
|
|
772
780
|
const suggestion = field.suggest === 'callback' ? `${location.origin}/api/${current.id}/oauth/callback` : null;
|
|
773
781
|
rows.push(settingRow({
|
|
774
782
|
label: field.label,
|
|
775
|
-
hint: field.unreadable ? 'Stored with another key. Enter it again.' :
|
|
783
|
+
hint: field.unreadable ? 'Stored with another key. Enter it again.' : null,
|
|
784
|
+
help: field.help,
|
|
776
785
|
control: suggestion && !disabled
|
|
777
786
|
? h('div', { class: 'fx-setting__stack' }, input,
|
|
778
|
-
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this
|
|
787
|
+
h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', title: suggestion, onclick: () => { input.value = suggestion; navigator.clipboard?.writeText(suggestion); toast('Address filled in and copied.', 'success'); } }, 'Use this address'))
|
|
779
788
|
: input,
|
|
780
789
|
}));
|
|
781
790
|
}
|
|
@@ -831,7 +840,6 @@ async function renderIntegration(main, id) {
|
|
|
831
840
|
const on = current.enabled;
|
|
832
841
|
const guide = current.guide;
|
|
833
842
|
const hostOwned = current.enabledBy === 'host';
|
|
834
|
-
const entry = CATALOGUE[current.id];
|
|
835
843
|
const mode = current.fields.find((f) => /_MODE$/.test(f.name));
|
|
836
844
|
const modeLabel = mode?.choices?.find((c) => c.value === mode.value)?.label ?? mode?.value;
|
|
837
845
|
const busy = progress && progress.steps.some((step) => step.state === 'running');
|
|
@@ -843,44 +851,41 @@ async function renderIntegration(main, id) {
|
|
|
843
851
|
|
|
844
852
|
host.replaceChildren(
|
|
845
853
|
crumbs(['/integrations', 'Integrations'], current.name),
|
|
846
|
-
h('header', { class: 'fx-int-hero' },
|
|
847
|
-
integrationLogo(current, 'xl'),
|
|
848
|
-
h('div', { class: 'fx-int-hero__text' },
|
|
849
|
-
h('h1', { class: 'fx-title' }, current.name),
|
|
850
|
-
h('p', { class: 'fx-int-hero__maker' }, entry?.maker ?? current.name, h('span', { class: 'fx-dim' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')),
|
|
851
|
-
h('p', { class: 'fx-int-hero__blurb' }, entry?.blurb ?? ''),
|
|
852
|
-
h('div', { class: 'fx-int-hero__actions' },
|
|
853
|
-
primary,
|
|
854
|
-
on ? statusPill(current) : null,
|
|
855
|
-
progress ? connectProgress(progress) : null))),
|
|
856
854
|
h('div', { class: 'fx-int-page__grid' },
|
|
857
855
|
h('div', { class: 'fx-int-page__main' }, ...[
|
|
856
|
+
h('header', { class: 'fx-int-hero' },
|
|
857
|
+
integrationLogo(current, 'lg'),
|
|
858
|
+
h('h1', { class: 'fx-title' }, current.name),
|
|
859
|
+
h('div', { class: 'fx-int-hero__actions' },
|
|
860
|
+
on ? statusPill(current) : null,
|
|
861
|
+
primary,
|
|
862
|
+
progress ? connectProgress(progress) : null)),
|
|
858
863
|
alerts.element,
|
|
859
|
-
!canChange ?
|
|
860
|
-
!body.secretsKey ?
|
|
864
|
+
!canChange ? readOnlyNotice() : null,
|
|
865
|
+
!body.secretsKey ? secretsKeyNotice() : null,
|
|
861
866
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
862
867
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
863
868
|
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
864
869
|
on && current.state === 'ready' ? personSettingsSection(current) : null,
|
|
865
870
|
current.id === 'xero' && current.state === 'ready' ? xeroImportSection(body.organisations) : null,
|
|
871
|
+
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'xero', 'Contacts')) : null,
|
|
866
872
|
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
867
|
-
guide ? h('
|
|
868
|
-
h('
|
|
873
|
+
guide ? h('details', { class: 'fx-int-section fx-int-fold' },
|
|
874
|
+
h('summary', { class: 'fx-int-section__title' }, 'Set up'),
|
|
869
875
|
h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
|
|
870
|
-
guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' },
|
|
871
|
-
rows.length ? h('
|
|
872
|
-
h('
|
|
876
|
+
guide.link ? h('a', { class: 'fx-btn fx-btn--sm', href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} site ↗`) : null) : null,
|
|
877
|
+
rows.length ? h('details', { class: 'fx-int-section fx-int-fold', open: settingsOpen, ontoggle: (event) => { settingsOpen = event.currentTarget.open; } },
|
|
878
|
+
h('summary', { class: 'fx-int-section__title' }, 'Settings'),
|
|
873
879
|
h('div', { class: 'fx-settings' }, rows),
|
|
874
880
|
canChange && on && inputs.size ? h('div', { class: 'fx-row fx-row--end' }, h('button', { class: 'fx-btn', type: 'button', onclick: () => save(undefined) }, 'Save changes')) : null) : null,
|
|
875
881
|
].filter(Boolean)),
|
|
876
882
|
h('aside', { class: 'fx-int-page__meta' },
|
|
877
883
|
h('dl', { class: 'fx-meta' }, ...[
|
|
878
884
|
['Status', statusPill(current)],
|
|
879
|
-
['Category', h('span', { class: 'fx-chip fx-chip--static' }, CATEGORY_LABEL[entry?.category] ?? 'Integration')],
|
|
880
885
|
modeLabel ? ['Mode', modeLabel] : null,
|
|
881
|
-
|
|
882
|
-
current.updatedAt ? ['
|
|
883
|
-
guide?.link ? ['
|
|
886
|
+
current.enabledBy === 'host' ? ['On by', 'Host'] : current.enabledBy === 'flow' ? ['On by', 'Owner'] : null,
|
|
887
|
+
current.updatedAt ? ['Changed', new Date(current.updatedAt).toLocaleString('en-AU', { dateStyle: 'medium', timeStyle: 'short' })] : null,
|
|
888
|
+
guide?.link ? ['Docs', h('a', { href: guide.link, target: '_blank', rel: 'noopener' }, `${current.name} ↗`)] : null,
|
|
884
889
|
].filter(Boolean).map(([label, value]) => h('div', {}, h('dt', {}, label), h('dd', {}, value)))))));
|
|
885
890
|
};
|
|
886
891
|
document.title = `${found.name} · Flow`;
|
|
@@ -910,7 +915,7 @@ function personSettingsSection(integration) {
|
|
|
910
915
|
}
|
|
911
916
|
input.disabled = false;
|
|
912
917
|
});
|
|
913
|
-
return settingRow({ label: setting.label,
|
|
918
|
+
return settingRow({ label: setting.label, help: setting.hint, control: input });
|
|
914
919
|
});
|
|
915
920
|
section.replaceChildren(section.firstChild, h('div', { class: 'fx-settings' }, rows));
|
|
916
921
|
}).catch(() => section.remove());
|
|
@@ -933,7 +938,7 @@ function xeroImportSection(organisations) {
|
|
|
933
938
|
const organisationId = organisations?.[0]?.id;
|
|
934
939
|
const canAct = ['owner', 'admin'].includes(me?.person?.role);
|
|
935
940
|
const count = h('span', { class: 'fx-num fx-xi-count' }, '…');
|
|
936
|
-
const copied = h('span', {}, '
|
|
941
|
+
const copied = h('span', {}, '');
|
|
937
942
|
const switchFor = (name, label) => {
|
|
938
943
|
const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': label, 'aria-checked': 'false', disabled: true });
|
|
939
944
|
button.addEventListener('click', async () => {
|
|
@@ -954,16 +959,16 @@ function xeroImportSection(organisations) {
|
|
|
954
959
|
const makeUnmatched = switchFor('makeUnmatched', 'Make Flow invoices for the rest');
|
|
955
960
|
const section = h('section', { class: 'fx-set fx-int-section fx-card', style: { padding: '8px 20px 16px' } },
|
|
956
961
|
h('h2', { class: 'fx-set__title' }, 'Import'),
|
|
957
|
-
settingRow({ label: 'To check',
|
|
962
|
+
settingRow({ label: 'To check', help: 'Xero invoices that fit a Flow invoice or a Flow job.',
|
|
958
963
|
control: h('span', { class: 'fx-row' }, count, h('a', { class: 'fx-btn fx-btn--sm fx-btn--primary', href: '/xero-import', onclick: navigate }, 'Review')) }),
|
|
959
|
-
settingRow({ label: 'Link sure matches',
|
|
960
|
-
settingRow({ label: 'Make Flow invoices for the rest',
|
|
961
|
-
settingRow({ label: 'Xero history', hint: copied, control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
|
|
964
|
+
settingRow({ label: 'Link sure matches', help: 'Same client, total and week, and only one fit. Flow links them.', control: autoLink }),
|
|
965
|
+
settingRow({ label: 'Make Flow invoices for the rest', help: 'Off: they stay in the Xero history.', control: makeUnmatched }),
|
|
966
|
+
settingRow({ label: 'Xero history', hint: copied, help: 'Every Xero invoice Flow copied. A search makes no Xero call.', control: h('a', { class: 'fx-btn fx-btn--sm', href: '/xero-import?show=all', onclick: navigate }, 'Search') }));
|
|
962
967
|
if (organisationId) {
|
|
963
968
|
flow(`/api/xero/import?organisationId=${organisationId}&show=check&limit=1`)
|
|
964
969
|
.then(({ counts: c, settings }) => {
|
|
965
970
|
count.replaceChildren(String(c.job_fits + c.invoice_fits));
|
|
966
|
-
copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)}
|
|
971
|
+
copied.replaceChildren(`${Object.values(c).reduce((a, b) => a + b, 0)} copied, ${c.linked} in Flow`);
|
|
967
972
|
autoLink.setAttribute('aria-checked', String(settings.autoLink));
|
|
968
973
|
makeUnmatched.setAttribute('aria-checked', String(settings.makeUnmatched));
|
|
969
974
|
autoLink.disabled = makeUnmatched.disabled = !canAct;
|
package/server/ui/automations.js
CHANGED
|
@@ -9,7 +9,7 @@ const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/AUTOMATIONS.md
|
|
|
9
9
|
|
|
10
10
|
// A section of the automations kept at `place`, filled when they load.
|
|
11
11
|
export function automationSection(ui, place, title) {
|
|
12
|
-
const { h, flow, toast, notice, me } = ui;
|
|
12
|
+
const { h, flow, toast, notice, me, settingRow } = ui;
|
|
13
13
|
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
|
|
14
14
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
15
15
|
|
|
@@ -27,9 +27,7 @@ export function automationSection(ui, place, title) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
const row = (
|
|
31
|
-
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
32
|
-
h('div', { class: 'fx-set__control' }, control));
|
|
30
|
+
const row = (label, help, control) => settingRow({ label, help, control });
|
|
33
31
|
|
|
34
32
|
// A menu of the statuses the work system reported, each with its job count.
|
|
35
33
|
// A status saved earlier stays in the menu even when no job has it now.
|
package/server/ui/contacts.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Contacts (/contacts, /contacts/<id>). Flow's own customers and suppliers
|
|
2
2
|
// (server/core/contacts.js, docs/CONTACTS.md). The list and the record work
|
|
3
3
|
// with no integration on. When Xero is connected, the record says whether
|
|
4
|
-
// that contact is in Xero and, for an owner or admin, offers Create.
|
|
4
|
+
// that contact is in Xero and, for an owner or admin, offers Link and Create.
|
|
5
5
|
import { initialsOf } from '/assets/ui/person.js';
|
|
6
|
+
import { xeroContactActions } from '/assets/ui/xero-contact.js';
|
|
6
7
|
|
|
7
8
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/CONTACTS.md';
|
|
8
9
|
const KINDS = [['customer', 'Customer'], ['supplier', 'Supplier']];
|
|
@@ -225,36 +226,28 @@ async function loadXero(ui, contact, canEdit, host) {
|
|
|
225
226
|
return;
|
|
226
227
|
}
|
|
227
228
|
if (match.state === 'off') return;
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const confirmed = await ui.dialog({
|
|
234
|
-
title: `Create ${contact.name} in Xero?`,
|
|
235
|
-
body: h('p', {}, 'Flow adds this client to Xero from the contact.'),
|
|
236
|
-
confirm: 'Create',
|
|
237
|
-
});
|
|
238
|
-
if (!confirmed) return;
|
|
239
|
-
button.disabled = true;
|
|
240
|
-
try {
|
|
241
|
-
await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
|
|
242
|
-
toast(`${contact.name} is in Xero.`, 'success');
|
|
243
|
-
host.replaceChildren(h('section', { class: 'fx-card' }, notice(`${contact.name} is in Xero.`, 'success')));
|
|
244
|
-
} catch (error) {
|
|
245
|
-
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
246
|
-
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
247
|
-
else button.disabled = false;
|
|
248
|
-
}
|
|
249
|
-
},
|
|
250
|
-
}, 'Create')
|
|
251
|
-
: null;
|
|
229
|
+
const actions = canEdit ? xeroContactActions(ui, {
|
|
230
|
+
organisationId, contact, offerLink: match.offerLink, offerCreate: match.offerCreate,
|
|
231
|
+
done: (outcome) => host.replaceChildren(h('section', { class: 'fx-card' },
|
|
232
|
+
notice(outcome === 'linked' ? `${contact.name} is linked to Xero.` : `${contact.name} is in Xero.`, 'success'))),
|
|
233
|
+
}) : [];
|
|
252
234
|
const text = match.state === 'matched'
|
|
253
235
|
? `In Xero${match.name ? ` as ${match.name}` : ''}.`
|
|
254
236
|
: match.state === 'ambiguous'
|
|
255
237
|
? `More than one Xero contact is named "${contact.name}".`
|
|
256
238
|
: `No Xero contact is named "${contact.name}".`;
|
|
239
|
+
// Fields both sides changed (Two-way edits): alert only. A person makes the
|
|
240
|
+
// two the same, here or in Xero, and the next check clears it.
|
|
241
|
+
const differs = match.differs ?? [];
|
|
242
|
+
const clash = differs.length
|
|
243
|
+
? notice(`Changed in both: ${differs.map((d) => FIELD_LABELS[d.field] ?? d.field).join(', ')}. Make Flow and Xero the same.`, 'warn', 'Xero differs.')
|
|
244
|
+
: null;
|
|
257
245
|
host.replaceChildren(h('section', { class: 'fx-card fx-offer' },
|
|
258
|
-
notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
|
|
259
|
-
|
|
246
|
+
clash ?? notice(text, match.state === 'matched' ? 'success' : 'danger', match.state === 'matched' ? 'Xero.' : 'Xero.'),
|
|
247
|
+
...actions));
|
|
260
248
|
}
|
|
249
|
+
|
|
250
|
+
const FIELD_LABELS = {
|
|
251
|
+
name: 'Name', firstName: 'First name', lastName: 'Last name', email: 'Email', phone: 'Phone',
|
|
252
|
+
mobile: 'Mobile', accountNumber: 'Account', taxNumber: 'Tax number', address: 'Address',
|
|
253
|
+
};
|