@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/kit.js
CHANGED
|
@@ -149,10 +149,31 @@ export function sectionTitle(text, help = null) {
|
|
|
149
149
|
return h('h2', { class: 'fx-set__title' }, text, help ? helpLink(help) : null);
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
// With `short`, a phone shows only that one line ("Client not in Xero") in
|
|
153
|
+
// place of the title and text.
|
|
154
|
+
export function notice(text, tone = 'info', title = null, { short = null } = {}) {
|
|
153
155
|
return applyPhoneRule(h('div', { class: 'fx-notice', 'data-tone': tone, role: tone === 'danger' ? 'alert' : 'status' },
|
|
154
|
-
title ? h('strong', {}, title) : null,
|
|
155
|
-
h('span', {}, text)
|
|
156
|
+
title ? h('strong', { class: short ? 'fx-notice__long' : null }, title) : null,
|
|
157
|
+
h('span', { class: short ? 'fx-notice__long' : null }, text),
|
|
158
|
+
short ? h('span', { class: 'fx-notice__short' }, short) : null), 'notice');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A table that is a list of small cards on a phone (CardRows card). On a
|
|
162
|
+
// tablet and desk it stays a table. `areas` places each cell on the card
|
|
163
|
+
// (CSS grid-template-areas, one string per line) and `columns` sizes the
|
|
164
|
+
// card's columns. A cell names its place with `data-area` and its column
|
|
165
|
+
// name with `data-label` (shown above it on a phone). Rows drawn later are
|
|
166
|
+
// placed too.
|
|
167
|
+
export function cardRows(table, { areas, columns }) {
|
|
168
|
+
table.classList.add('fx-cardrows');
|
|
169
|
+
table.style.setProperty('--fx-cardrows-areas', areas.map((line) => `"${line}"`).join(' '));
|
|
170
|
+
table.style.setProperty('--fx-cardrows-columns', columns);
|
|
171
|
+
const place = () => {
|
|
172
|
+
for (const cell of table.querySelectorAll('td[data-area]')) cell.style.gridArea = cell.dataset.area;
|
|
173
|
+
};
|
|
174
|
+
place();
|
|
175
|
+
if (typeof MutationObserver === 'function') new MutationObserver(place).observe(table, { childList: true, subtree: true });
|
|
176
|
+
return applyPhoneRule(table, 'cardRows');
|
|
156
177
|
}
|
|
157
178
|
|
|
158
179
|
// A field that fails a check glows red, like a required field, until the
|
|
@@ -220,17 +241,87 @@ export function dialog({ title, body, confirm, tone = 'primary', choices = null
|
|
|
220
241
|
});
|
|
221
242
|
}
|
|
222
243
|
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
244
|
+
// A dialog that picks one row of a long list (Pick card). A search box over
|
|
245
|
+
// the list; `load(q)` answers the rows for its text (once with '' on open, and
|
|
246
|
+
// again as the person types when `remote`; else the rows filter here).
|
|
247
|
+
// `item(row)` draws a row as { value, title, meta, end }. Answers the picked
|
|
248
|
+
// row, or null on Cancel.
|
|
249
|
+
export async function pickOne({ title, intro = null, label, find = 'Find', load, item, empty, confirm, remote = false }) {
|
|
250
|
+
const search = h('input', { class: 'fx-input', type: 'search', placeholder: find, 'aria-label': find });
|
|
251
|
+
const list = h('div', { class: 'fx-pick', role: 'radiogroup', 'aria-label': label }, h('div', { class: 'fx-skeleton' }));
|
|
252
|
+
let rows = [];
|
|
253
|
+
const draw = (found) => {
|
|
254
|
+
rows = found.map((row) => {
|
|
255
|
+
const { value, title: name, meta = null, end = null } = item(row);
|
|
256
|
+
const element = h('label', { class: 'fx-pick__row' },
|
|
257
|
+
h('input', { type: 'radio', name: 'pick', value: String(value), required: true }),
|
|
258
|
+
h('span', { class: 'fx-pick__main' },
|
|
259
|
+
h('span', { class: 'fx-strong' }, name),
|
|
260
|
+
meta ? h('span', { class: 'fx-dim' }, meta) : null),
|
|
261
|
+
end == null ? h('span') : h('span', { class: 'fx-num fx-strong' }, end));
|
|
262
|
+
element.dataset.find = `${name} ${meta ?? ''}`.toLowerCase();
|
|
263
|
+
return { row, value: String(value), element };
|
|
264
|
+
});
|
|
265
|
+
list.replaceChildren(...(rows.length ? rows.map((r) => r.element) : [h('p', { class: 'fx-dim' }, empty)]));
|
|
266
|
+
};
|
|
267
|
+
let asked = 0;
|
|
268
|
+
const fill = async (q) => {
|
|
269
|
+
const turn = ++asked;
|
|
270
|
+
let found;
|
|
271
|
+
try {
|
|
272
|
+
found = await load(q);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
found = [];
|
|
275
|
+
toast(error.message, 'danger');
|
|
276
|
+
}
|
|
277
|
+
if (turn === asked) draw(found);
|
|
278
|
+
};
|
|
279
|
+
let wait = null;
|
|
280
|
+
search.addEventListener('input', () => {
|
|
281
|
+
const q = search.value.trim().toLowerCase();
|
|
282
|
+
if (remote) {
|
|
283
|
+
clearTimeout(wait);
|
|
284
|
+
wait = setTimeout(() => fill(q), 250);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
for (const r of rows) r.element.hidden = Boolean(q) && !r.element.dataset.find.includes(q);
|
|
288
|
+
});
|
|
289
|
+
fill('');
|
|
290
|
+
const picked = await dialog({
|
|
291
|
+
title,
|
|
292
|
+
body: applyPhoneRule(h('div', { class: 'fx-pick__body' }, intro ? h('p', { class: 'fx-dim' }, intro) : null, search, list), 'pickOne'),
|
|
293
|
+
confirm,
|
|
294
|
+
});
|
|
295
|
+
clearTimeout(wait);
|
|
296
|
+
const value = picked?.get('pick');
|
|
297
|
+
return value ? rows.find((r) => r.value === value)?.row ?? null : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// One setting, laid out the same on every settings screen: a short name and
|
|
301
|
+
// the control on the right. `hint` is a short status line under the name
|
|
302
|
+
// ("3 to check"), never an explanation. `help` is the explanation: a ? beside
|
|
303
|
+
// the name says it on hover, and a press shows it under the name (a phone has
|
|
304
|
+
// no hover). A wide control (a grid of choices) sits on its own line under the
|
|
305
|
+
// name. `tone: 'danger'` marks a row that needs a person.
|
|
306
|
+
export function settingRow({ label, hint, help = null, control, wide = false, tone = null }) {
|
|
228
307
|
const target = control?.matches?.('input, select, textarea') ? control : control?.querySelector?.('input:not([type=color]), select, textarea');
|
|
229
308
|
const id = target ? (target.id ||= `fx-set-${++settingRow.next}`) : null;
|
|
309
|
+
const helpLine = help ? h('p', { class: 'fx-setting__hint fx-setting__help', hidden: true }, help) : null;
|
|
310
|
+
const helpButton = help ? named(h('button', {
|
|
311
|
+
class: 'fx-help', type: 'button', 'aria-expanded': 'false',
|
|
312
|
+
onclick: (event) => {
|
|
313
|
+
const open = helpLine.hidden;
|
|
314
|
+
helpLine.hidden = !open;
|
|
315
|
+
event.currentTarget.setAttribute('aria-expanded', String(open));
|
|
316
|
+
},
|
|
317
|
+
}, lucide(h, 'circle-help')), `About ${label}`, help) : null;
|
|
230
318
|
return applyPhoneRule(h('div', { class: `fx-setting${wide ? ' fx-setting--wide' : ''}`, 'data-tone': tone },
|
|
231
319
|
h('div', { class: 'fx-setting__text' },
|
|
232
|
-
|
|
233
|
-
|
|
320
|
+
h('span', { class: 'fx-setting__head' },
|
|
321
|
+
id ? h('label', { class: 'fx-setting__name', for: id }, label) : h('span', { class: 'fx-setting__name' }, label),
|
|
322
|
+
helpButton),
|
|
323
|
+
hint ? h('p', { class: 'fx-setting__hint' }, hint) : null,
|
|
324
|
+
helpLine),
|
|
234
325
|
h('div', { class: 'fx-setting__control' }, control)), 'settingRow');
|
|
235
326
|
}
|
|
236
327
|
settingRow.next = 0;
|
|
@@ -9,14 +9,12 @@
|
|
|
9
9
|
const money = (cents) => (cents === null || cents === undefined ? '' : `$${(cents / 100).toFixed(2)}`);
|
|
10
10
|
|
|
11
11
|
export function lineMappingSection(ui) {
|
|
12
|
-
const { h, flow, toast, notice, me } = ui;
|
|
12
|
+
const { h, flow, toast, notice, me, settingRow } = ui;
|
|
13
13
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
14
14
|
const body = h('div', {}, h('div', { class: 'fx-skeleton' }));
|
|
15
15
|
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Price codes'), body);
|
|
16
16
|
|
|
17
|
-
const row = (
|
|
18
|
-
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
19
|
-
h('div', { class: 'fx-set__control' }, control));
|
|
17
|
+
const row = (label, hint, control) => settingRow({ label, hint, control });
|
|
20
18
|
|
|
21
19
|
async function load() {
|
|
22
20
|
let labels;
|
package/server/ui/mobile.js
CHANGED
|
@@ -68,6 +68,26 @@ export const RULES = {
|
|
|
68
68
|
"mark": false,
|
|
69
69
|
"card": "Button"
|
|
70
70
|
},
|
|
71
|
+
"cardRows": {
|
|
72
|
+
"component": "cardRows",
|
|
73
|
+
"direction": "card per row",
|
|
74
|
+
"phone": {
|
|
75
|
+
"layout": "cards"
|
|
76
|
+
},
|
|
77
|
+
"tablet": {
|
|
78
|
+
"layout": "table"
|
|
79
|
+
},
|
|
80
|
+
"desk": {
|
|
81
|
+
"layout": "table"
|
|
82
|
+
},
|
|
83
|
+
"touch": 44,
|
|
84
|
+
"pageOverflow": "none",
|
|
85
|
+
"screens": [
|
|
86
|
+
"invoice",
|
|
87
|
+
"invoice-issued"
|
|
88
|
+
],
|
|
89
|
+
"card": "CardRows"
|
|
90
|
+
},
|
|
71
91
|
"columnTable": {
|
|
72
92
|
"component": "columnTable",
|
|
73
93
|
"direction": "two-line row",
|
|
@@ -281,6 +301,7 @@ export const RULES = {
|
|
|
281
301
|
},
|
|
282
302
|
"touch": 44,
|
|
283
303
|
"pageOverflow": "none",
|
|
304
|
+
"scrollX": ".fx-frame, .fx-board, .fx-board-tabs",
|
|
284
305
|
"screens": [
|
|
285
306
|
"jobs-board",
|
|
286
307
|
"jobs-list",
|
|
@@ -308,6 +329,7 @@ export const RULES = {
|
|
|
308
329
|
},
|
|
309
330
|
"touch": 44,
|
|
310
331
|
"pageOverflow": "none",
|
|
332
|
+
"scrollX": ".fx-toolbar__tools",
|
|
311
333
|
"screens": [
|
|
312
334
|
"jobs-board",
|
|
313
335
|
"jobs-list",
|
|
@@ -366,7 +388,8 @@ export const RULES = {
|
|
|
366
388
|
"component": "notice",
|
|
367
389
|
"direction": "note",
|
|
368
390
|
"phone": {
|
|
369
|
-
"layout": "note"
|
|
391
|
+
"layout": "note",
|
|
392
|
+
"short": "one line in place of the title and text"
|
|
370
393
|
},
|
|
371
394
|
"tablet": {
|
|
372
395
|
"layout": "note"
|
|
@@ -376,7 +399,9 @@ export const RULES = {
|
|
|
376
399
|
},
|
|
377
400
|
"touch": 44,
|
|
378
401
|
"pageOverflow": "none",
|
|
379
|
-
"screens": [
|
|
402
|
+
"screens": [
|
|
403
|
+
"invoice-offer"
|
|
404
|
+
],
|
|
380
405
|
"card": "Notice"
|
|
381
406
|
},
|
|
382
407
|
"optionPill": {
|
|
@@ -452,6 +477,23 @@ export const RULES = {
|
|
|
452
477
|
"selector": ".fx-pager",
|
|
453
478
|
"card": "Pager"
|
|
454
479
|
},
|
|
480
|
+
"pickOne": {
|
|
481
|
+
"component": "pickOne",
|
|
482
|
+
"direction": "sheet list",
|
|
483
|
+
"phone": {
|
|
484
|
+
"layout": "sheet"
|
|
485
|
+
},
|
|
486
|
+
"tablet": {
|
|
487
|
+
"layout": "dialog"
|
|
488
|
+
},
|
|
489
|
+
"desk": {
|
|
490
|
+
"layout": "dialog"
|
|
491
|
+
},
|
|
492
|
+
"touch": 44,
|
|
493
|
+
"pageOverflow": "none",
|
|
494
|
+
"screens": [],
|
|
495
|
+
"card": "Pick"
|
|
496
|
+
},
|
|
455
497
|
"rowMenu": {
|
|
456
498
|
"component": "rowMenu",
|
|
457
499
|
"direction": "menu",
|
|
@@ -511,7 +553,9 @@ export const RULES = {
|
|
|
511
553
|
"touch": 44,
|
|
512
554
|
"pageOverflow": "none",
|
|
513
555
|
"screens": [
|
|
514
|
-
"appearance"
|
|
556
|
+
"appearance",
|
|
557
|
+
"integration-xero",
|
|
558
|
+
"integration-seazona"
|
|
515
559
|
],
|
|
516
560
|
"card": "SettingRow"
|
|
517
561
|
},
|
|
@@ -548,6 +592,7 @@ export const RULES = {
|
|
|
548
592
|
},
|
|
549
593
|
"touch": 44,
|
|
550
594
|
"pageOverflow": "none",
|
|
595
|
+
"scrollX": ".fx-tabs",
|
|
551
596
|
"screens": [
|
|
552
597
|
"invoices-ready",
|
|
553
598
|
"invoices-pending",
|
package/server/ui/price-list.js
CHANGED
|
@@ -34,7 +34,7 @@ const toCents = (text) => {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
export async function renderPriceList(main, ui) {
|
|
37
|
-
const { h, flow, toast, notice, dialog, pageHeader, me } = ui;
|
|
37
|
+
const { h, flow, toast, notice, dialog, pageHeader, me, settingRow } = ui;
|
|
38
38
|
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
39
39
|
const host = h('div', { class: 'fx-price' }, h('div', { class: 'fx-skeleton' }));
|
|
40
40
|
let view = normaliseListView(me?.views?.['price-list'], PRICE_COLUMNS);
|
|
@@ -71,9 +71,7 @@ export async function renderPriceList(main, ui) {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
const row = (
|
|
75
|
-
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), hint),
|
|
76
|
-
h('div', { class: 'fx-set__control fx-rule__controls' }, control));
|
|
74
|
+
const row = (label, hint, control) => settingRow({ label, hint, control });
|
|
77
75
|
|
|
78
76
|
const findingText = (f) => {
|
|
79
77
|
const name = systemName(f.system);
|
|
@@ -165,7 +163,7 @@ export async function renderPriceList(main, ui) {
|
|
|
165
163
|
else select.value = saved;
|
|
166
164
|
select.disabled = disabled;
|
|
167
165
|
});
|
|
168
|
-
return row('Line price from',
|
|
166
|
+
return row('Line price from', 'Where an invoice line takes its price.', select);
|
|
169
167
|
}
|
|
170
168
|
|
|
171
169
|
function draw() {
|
package/server/ui/routes.js
CHANGED
|
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
|
|
|
21
21
|
const logoDir = join(here, 'logos');
|
|
22
22
|
const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
|
|
23
23
|
const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
|
|
24
|
-
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
24
|
+
export const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'contacts.js', 'xero-contact.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'xero-usage.js', 'xero-usage-model.js', 'job-view.js', 'list-views.js', 'saved-views.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'kit.js', 'columns.js', 'mobile.js', 'person.js', 'pager.js', 'nav-pins.js', 'flow-mark.svg']);
|
|
25
25
|
const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
|
|
26
26
|
const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
|
|
27
27
|
const MAX_AGE = '1h';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Link and Create for a Flow contact that is not a Xero contact
|
|
2
|
+
// (docs/CONTACTS.md). The Pending invoice and the contact page both show
|
|
3
|
+
// them. Link picks the Xero client when the names differ or Xero has the
|
|
4
|
+
// name twice; Flow writes only its own link. Create adds the client to Xero.
|
|
5
|
+
// `done(outcome)` runs after either one works.
|
|
6
|
+
|
|
7
|
+
const SMALL = new Set(['the', 'and', 'of', 'pty', 'ltd', 'limited', 'co', 'inc']);
|
|
8
|
+
|
|
9
|
+
function words(name) {
|
|
10
|
+
return new Set(String(name ?? '').toLowerCase().replace(/&/g, ' and ').replace(/[^a-z0-9]+/g, ' ').split(' ')
|
|
11
|
+
.filter((word) => word && !SMALL.has(word)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// How many words two names share.
|
|
15
|
+
export function closeness(a, b) {
|
|
16
|
+
const mine = words(a);
|
|
17
|
+
let shared = 0;
|
|
18
|
+
for (const word of words(b)) if (mine.has(word)) shared += 1;
|
|
19
|
+
return shared;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The Xero contacts nearest the name first, then A to Z.
|
|
23
|
+
export function nearestFirst(name, contacts) {
|
|
24
|
+
return contacts
|
|
25
|
+
.map((contact) => ({ contact, score: closeness(name, contact.name) }))
|
|
26
|
+
.sort((a, b) => b.score - a.score || a.contact.name.localeCompare(b.contact.name))
|
|
27
|
+
.map(({ contact }) => contact);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function xeroContacts(ui, organisationId, q) {
|
|
31
|
+
const query = new URLSearchParams({ organisationId, q, limit: '1000' });
|
|
32
|
+
const { contacts } = await ui.flow(`/api/xero/contacts?${query}`);
|
|
33
|
+
return contacts.filter((contact) => contact.status === 'ACTIVE');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function xeroContactActions(ui, { organisationId, contact, offerLink = false, offerCreate = false, done }) {
|
|
37
|
+
const { h, flow, toast } = ui;
|
|
38
|
+
const failed = (button, error) => {
|
|
39
|
+
toast(error.message, error.status === 429 ? 'info' : 'danger');
|
|
40
|
+
if (error.retryAfterMs) ui.holdButton(button, error.retryAfterMs);
|
|
41
|
+
else button.disabled = false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const link = offerLink ? h('button', {
|
|
45
|
+
class: `fx-btn ${offerCreate ? 'fx-btn--ghost' : 'fx-btn--primary'}`, type: 'button', title: 'Link this client to its Xero client',
|
|
46
|
+
onclick: async (event) => {
|
|
47
|
+
const button = event.currentTarget;
|
|
48
|
+
const picked = await ui.pickOne({
|
|
49
|
+
title: `Link ${contact.name} to Xero`,
|
|
50
|
+
intro: 'Pick the Xero client. Flow links the two. Xero does not change.',
|
|
51
|
+
label: 'Xero clients',
|
|
52
|
+
find: 'Find a Xero client',
|
|
53
|
+
remote: true,
|
|
54
|
+
load: async (q) => nearestFirst(contact.name, await xeroContacts(ui, organisationId, q)),
|
|
55
|
+
item: (row) => ({ value: row.xeroContactId, title: row.name, meta: [row.email, row.accountNumber].filter(Boolean).join(' · ') }),
|
|
56
|
+
empty: 'No Xero client has that name. Sync Xero, or use Create.',
|
|
57
|
+
confirm: 'Link',
|
|
58
|
+
});
|
|
59
|
+
if (!picked) return;
|
|
60
|
+
button.disabled = true;
|
|
61
|
+
try {
|
|
62
|
+
await flow('/api/xero/contacts/link', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id, xeroContactId: picked.xeroContactId }) });
|
|
63
|
+
toast(`${contact.name} is linked to ${picked.name} in Xero.`, 'success');
|
|
64
|
+
await done('linked');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
failed(button, error);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}, 'Link') : null;
|
|
70
|
+
|
|
71
|
+
const create = offerCreate ? h('button', {
|
|
72
|
+
class: 'fx-btn fx-btn--primary', type: 'button', title: 'Create this client in Xero',
|
|
73
|
+
onclick: async (event) => {
|
|
74
|
+
const button = event.currentTarget;
|
|
75
|
+
// A close name in Xero is often this client: say so before a second one.
|
|
76
|
+
let close = [];
|
|
77
|
+
try {
|
|
78
|
+
close = (await xeroContacts(ui, organisationId, '')).filter((row) => closeness(contact.name, row.name) > 0);
|
|
79
|
+
} catch { /* Create still works without the list. */ }
|
|
80
|
+
const names = nearestFirst(contact.name, close).slice(0, 3).map((row) => row.name);
|
|
81
|
+
const confirmed = await ui.dialog({
|
|
82
|
+
title: `Create ${contact.name} in Xero?`,
|
|
83
|
+
body: h('div', { class: 'fx-pick__body' },
|
|
84
|
+
h('p', {}, 'Flow adds this client to Xero from the contact.'),
|
|
85
|
+
names.length ? ui.notice(`Xero has ${names.join(', ')}. If one is this client, use Link.`, 'info', 'Close names.') : null),
|
|
86
|
+
confirm: 'Create',
|
|
87
|
+
});
|
|
88
|
+
if (!confirmed) return;
|
|
89
|
+
button.disabled = true;
|
|
90
|
+
try {
|
|
91
|
+
await flow('/api/xero/contacts', { method: 'POST', body: JSON.stringify({ organisationId, contactId: contact.id }) });
|
|
92
|
+
toast(`${contact.name} is in Xero.`, 'success');
|
|
93
|
+
await done('created');
|
|
94
|
+
} catch (error) {
|
|
95
|
+
failed(button, error);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}, 'Create') : null;
|
|
99
|
+
|
|
100
|
+
return [link, create].filter(Boolean);
|
|
101
|
+
}
|