@arcforgelabs/flow 2026.10.25 → 2026.10.27
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 +6 -0
- package/npm-shrinkwrap.json +627 -2
- package/package.json +3 -1
- package/server/api/invoicing.js +586 -0
- package/server/app.js +38 -1
- package/server/auth/people.js +26 -0
- package/server/auth/routes.js +13 -0
- package/server/core/automations.js +140 -0
- package/server/core/invoice-rules.js +224 -0
- package/server/core/invoices.js +11 -1
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/ledger.js +50 -14
- package/server/core/links.js +3 -3
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +495 -0
- package/server/core/sending.js +282 -0
- package/server/db/migrations/0008_people_appearance.sql +1 -0
- package/server/db/migrations/0009_pipeline_matcher.sql +46 -0
- package/server/db/migrations/0010_invoicing_rules.sql +51 -0
- package/server/db/migrations/0011_invoice_rules.sql +21 -0
- package/server/db/migrations/0012_invoice_sending.sql +9 -0
- package/server/db/migrations/meta/0008_snapshot.json +2479 -0
- package/server/db/migrations/meta/0009_snapshot.json +2809 -0
- package/server/db/migrations/meta/0010_snapshot.json +3158 -0
- package/server/db/migrations/meta/0011_snapshot.json +3314 -0
- package/server/db/migrations/meta/0012_snapshot.json +3380 -0
- package/server/db/migrations/meta/_journal.json +35 -0
- package/server/db/schema.js +186 -2
- package/server/index.js +20 -3
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +8 -0
- package/server/integrations/seazona/feed.js +11 -0
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/ledger.js +4 -0
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +123 -16
- package/server/integrations/xero/import.js +22 -0
- package/server/integrations/xero/index.js +21 -3
- package/server/integrations/xero/lookup.js +94 -0
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +10 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/theme/color-contrast.js +39 -2
- package/server/theme/contrast-audit.js +63 -0
- package/server/theme/themes.js +10 -2
- package/server/ui/app-page.js +6 -10
- package/server/ui/app.js +364 -105
- package/server/ui/appearance.js +3 -2
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +18 -2
- package/server/ui/flow.css +334 -22
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +108 -0
- package/server/ui/invoicing.js +597 -0
- package/server/ui/jobs.js +172 -0
- package/server/ui/logos/seazona.svg +1 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/logos/xero.svg +1 -0
- package/server/ui/monitoring.js +53 -0
- package/server/ui/routes.js +5 -1
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// The "Rules" section of Settings, Invoicing (server/core/invoice-rules.js,
|
|
2
|
+
// /api/invoicing/rules). Each rule a lab builds is a row: its name, one short
|
|
3
|
+
// line that reads its conditions, and its switch. Edit opens a dialog where
|
|
4
|
+
// every part is picked from a menu: the field, the test, and a value from
|
|
5
|
+
// what Flow already holds (Seazona's statuses, the customers, the price list).
|
|
6
|
+
// Everyone can read the rules; an owner or admin changes them. The caller
|
|
7
|
+
// passes in app.js's shared pieces as `ui`.
|
|
8
|
+
|
|
9
|
+
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICE_RULES.md';
|
|
10
|
+
|
|
11
|
+
const TEST_LABELS = {
|
|
12
|
+
more_than: 'is more than', at_most: 'is at most', is: 'is', is_not: 'is not', is_one_of: 'is one of',
|
|
13
|
+
is_empty: 'is empty', is_not_empty: 'is not empty', has: 'has', has_not: 'does not have',
|
|
14
|
+
};
|
|
15
|
+
const ACTION_LABELS = { wait: 'Wait for a person', skip: 'Never draft' };
|
|
16
|
+
const NO_VALUE = ['is_empty', 'is_not_empty'];
|
|
17
|
+
|
|
18
|
+
// A rule as one short line: "Job age (days) is more than 90 · Wait for a person".
|
|
19
|
+
export function ruleSummary(rule, meta) {
|
|
20
|
+
const fieldOf = new Map(meta.fields.map((f) => [f.key, f]));
|
|
21
|
+
const labelOf = (field, value) => {
|
|
22
|
+
const option = (meta.options[field.options] ?? []).find((o) => String(o.value).toLowerCase() === String(value).toLowerCase());
|
|
23
|
+
return option?.label ?? String(value);
|
|
24
|
+
};
|
|
25
|
+
const part = (c) => {
|
|
26
|
+
const field = fieldOf.get(c.field);
|
|
27
|
+
if (!field) return c.field;
|
|
28
|
+
const test = TEST_LABELS[c.test] ?? c.test;
|
|
29
|
+
if (NO_VALUE.includes(c.test)) return `${field.label} ${test}`;
|
|
30
|
+
if (field.kind === 'boolean') return `${field.label} ${test} ${c.value ? 'yes' : 'no'}`;
|
|
31
|
+
if (field.kind === 'share') return `${labelOf(field, c.item).split(' · ')[0]} share ${test} ${c.value}%`;
|
|
32
|
+
if (field.kind === 'money') return `${field.label} ${test} $${Number(c.value).toFixed(2)}`;
|
|
33
|
+
const value = Array.isArray(c.value) ? c.value.map((v) => labelOf(field, v)).join(', ') : labelOf(field, c.value);
|
|
34
|
+
return `${field.label} ${test} ${value}`;
|
|
35
|
+
};
|
|
36
|
+
return `${rule.conditions.map(part).join(' and ')} · ${ACTION_LABELS[rule.action.kind] ?? rule.action.kind}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function rulesSection(ui, title = 'Rules') {
|
|
40
|
+
const { h, flow, toast, notice, dialog, me } = ui;
|
|
41
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
42
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
|
|
43
|
+
let meta;
|
|
44
|
+
let rules = [];
|
|
45
|
+
|
|
46
|
+
const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
|
|
47
|
+
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
48
|
+
h('div', { class: 'fx-set__control fx-rule__controls' }, control));
|
|
49
|
+
|
|
50
|
+
async function send(path, method, payload) {
|
|
51
|
+
try {
|
|
52
|
+
const body = await flow(path, { method, ...(payload ? { body: JSON.stringify(payload) } : {}) });
|
|
53
|
+
toast('Saved.', 'success');
|
|
54
|
+
return body;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
toast(error.message, 'danger');
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// One condition's controls: field, test and value, each a menu where Flow
|
|
62
|
+
// knows the values. `read()` answers the condition as the API takes it.
|
|
63
|
+
function conditionEditor(start, onRemove) {
|
|
64
|
+
const fieldSelect = h('select', { class: 'fx-select', 'aria-label': 'Field' },
|
|
65
|
+
meta.fields.map((f) => h('option', { value: f.key }, f.label)));
|
|
66
|
+
const testSelect = h('select', { class: 'fx-select', 'aria-label': 'Test' });
|
|
67
|
+
const valueHost = h('div', { class: 'fx-rule__value' });
|
|
68
|
+
let readValue = () => ({});
|
|
69
|
+
|
|
70
|
+
const options = (field) => meta.options[field.options] ?? [];
|
|
71
|
+
const choiceSelect = (field, value, label) => {
|
|
72
|
+
const list = options(field);
|
|
73
|
+
const select = h('select', { class: 'fx-select', 'aria-label': label },
|
|
74
|
+
list.map((o) => h('option', { value: o.value }, o.label)));
|
|
75
|
+
if (value !== undefined && !list.some((o) => String(o.value).toLowerCase() === String(value).toLowerCase())) {
|
|
76
|
+
select.append(h('option', { value }, String(value)));
|
|
77
|
+
}
|
|
78
|
+
if (value !== undefined) select.value = list.find((o) => String(o.value).toLowerCase() === String(value).toLowerCase())?.value ?? value;
|
|
79
|
+
return select;
|
|
80
|
+
};
|
|
81
|
+
const numberBox = (value, label) => h('input', { class: 'fx-input', type: 'number', min: '0', step: 'any', value: value ?? '', 'aria-label': label });
|
|
82
|
+
|
|
83
|
+
function drawValue(value, item) {
|
|
84
|
+
const field = meta.fields.find((f) => f.key === fieldSelect.value);
|
|
85
|
+
const test = testSelect.value;
|
|
86
|
+
valueHost.replaceChildren();
|
|
87
|
+
if (NO_VALUE.includes(test)) {
|
|
88
|
+
readValue = () => ({});
|
|
89
|
+
} else if (field.kind === 'boolean') {
|
|
90
|
+
const select = h('select', { class: 'fx-select', 'aria-label': 'Value' }, h('option', { value: 'true' }, 'Yes'), h('option', { value: 'false' }, 'No'));
|
|
91
|
+
select.value = String(value ?? false);
|
|
92
|
+
valueHost.append(select);
|
|
93
|
+
readValue = () => ({ value: select.value === 'true' });
|
|
94
|
+
} else if (field.kind === 'share') {
|
|
95
|
+
const items = choiceSelect(field, item, 'Line item');
|
|
96
|
+
const box = numberBox(value ?? 20, 'Percent of the other lines');
|
|
97
|
+
valueHost.append(items, box, h('span', { class: 'fx-dim' }, '%'));
|
|
98
|
+
readValue = () => ({ item: items.value, value: Number(box.value) });
|
|
99
|
+
} else if (['number', 'money'].includes(field.kind)) {
|
|
100
|
+
const box = numberBox(value, 'Value');
|
|
101
|
+
valueHost.append(box);
|
|
102
|
+
readValue = () => ({ value: Number(box.value) });
|
|
103
|
+
} else if (test === 'is_one_of') {
|
|
104
|
+
const chosen = new Set((Array.isArray(value) ? value : []).map((v) => String(v).toLowerCase()));
|
|
105
|
+
const boxes = options(field).map((o) => {
|
|
106
|
+
const box = h('input', { type: 'checkbox', value: o.value, checked: chosen.has(String(o.value).toLowerCase()) });
|
|
107
|
+
return h('label', { class: 'fx-multi__option' }, box, h('span', {}, o.label));
|
|
108
|
+
});
|
|
109
|
+
valueHost.append(h('div', { class: 'fx-rule__many' }, ...(boxes.length ? boxes : [h('span', { class: 'fx-dim' }, 'No values yet.')])));
|
|
110
|
+
readValue = () => ({ value: boxes.map((b) => b.querySelector('input')).filter((b) => b.checked).map((b) => b.value) });
|
|
111
|
+
} else if (field.options) {
|
|
112
|
+
const select = choiceSelect(field, Array.isArray(value) ? value[0] : value, 'Value');
|
|
113
|
+
valueHost.append(select);
|
|
114
|
+
readValue = () => ({ value: select.value });
|
|
115
|
+
} else {
|
|
116
|
+
const box = h('input', { class: 'fx-input', type: 'text', value: value ?? '', 'aria-label': 'Value' });
|
|
117
|
+
valueHost.append(box);
|
|
118
|
+
readValue = () => ({ value: box.value });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function drawTests(test) {
|
|
123
|
+
const field = meta.fields.find((f) => f.key === fieldSelect.value);
|
|
124
|
+
testSelect.replaceChildren(...field.tests.map((t) => h('option', { value: t }, TEST_LABELS[t] ?? t)));
|
|
125
|
+
testSelect.value = field.tests.includes(test) ? test : field.tests[0];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fieldSelect.value = start?.field ?? meta.fields[0].key;
|
|
129
|
+
drawTests(start?.test);
|
|
130
|
+
drawValue(start?.value, start?.item);
|
|
131
|
+
fieldSelect.addEventListener('change', () => { drawTests(); drawValue(); });
|
|
132
|
+
testSelect.addEventListener('change', () => drawValue());
|
|
133
|
+
|
|
134
|
+
const remove = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', 'aria-label': 'Remove condition' }, 'Remove');
|
|
135
|
+
const element = h('div', { class: 'fx-rule__condition' }, fieldSelect, testSelect, valueHost, remove);
|
|
136
|
+
remove.addEventListener('click', () => onRemove(element));
|
|
137
|
+
return { element, read: () => ({ field: fieldSelect.value, test: testSelect.value, ...readValue() }) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The dialog for a new rule (rule null) or an existing one. `draft` fills
|
|
141
|
+
// it again after a refused save, so no choice is lost.
|
|
142
|
+
async function edit(rule, draft = rule) {
|
|
143
|
+
const name = h('input', { class: 'fx-input', name: 'name', required: true, maxlength: '120', value: draft?.name ?? '', 'aria-label': 'Rule name' });
|
|
144
|
+
const action = h('select', { class: 'fx-select', 'aria-label': 'Then' },
|
|
145
|
+
Object.entries(ACTION_LABELS).map(([value, label]) => h('option', { value }, label)));
|
|
146
|
+
action.value = draft?.action.kind ?? 'wait';
|
|
147
|
+
let editors = [];
|
|
148
|
+
const list = h('div', { class: 'fx-rule__conditions' });
|
|
149
|
+
const addCondition = (start) => {
|
|
150
|
+
if (editors.length >= 10) return toast('A rule has 10 conditions at most.', 'danger');
|
|
151
|
+
const editor = conditionEditor(start, (element) => {
|
|
152
|
+
if (editors.length === 1) return toast('A rule needs at least one condition.', 'danger');
|
|
153
|
+
editors = editors.filter((e) => e.element !== element);
|
|
154
|
+
element.remove();
|
|
155
|
+
return null;
|
|
156
|
+
});
|
|
157
|
+
editors.push(editor);
|
|
158
|
+
list.append(editor.element);
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
for (const c of draft?.conditions ?? [null]) addCondition(c);
|
|
162
|
+
const more = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add condition');
|
|
163
|
+
more.addEventListener('click', () => addCondition());
|
|
164
|
+
|
|
165
|
+
let wantsDelete = false;
|
|
166
|
+
const remove = rule ? h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm fx-rule__delete', type: 'button' }, 'Delete rule') : null;
|
|
167
|
+
remove?.addEventListener('click', () => {
|
|
168
|
+
wantsDelete = true;
|
|
169
|
+
remove.closest('dialog').close();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const label = (text, ...children) => h('div', { class: 'fx-field' }, h('span', { class: 'fx-label' }, text), ...children);
|
|
173
|
+
const data = await dialog({
|
|
174
|
+
title: rule ? 'Edit rule' : 'New rule',
|
|
175
|
+
body: h('div', { class: 'fx-fields fx-rule__dialog' },
|
|
176
|
+
label('Name', name),
|
|
177
|
+
label('When', h('span', { class: 'fx-dim' }, 'A job is ready to invoice and has no invoice.')),
|
|
178
|
+
label('If all of these match', list, more),
|
|
179
|
+
label('Then', action),
|
|
180
|
+
remove),
|
|
181
|
+
confirm: rule ? 'Save' : 'Add rule',
|
|
182
|
+
});
|
|
183
|
+
if (wantsDelete) {
|
|
184
|
+
const sure = await dialog({ title: `Delete "${rule.name}"?`, body: h('p', { class: 'fx-dim' }, 'Jobs stop waiting on this rule.'), confirm: 'Delete', tone: 'danger' });
|
|
185
|
+
if (sure && await send(`/api/invoicing/rules/${rule.id}`, 'DELETE')) rules = rules.filter((r) => r.id !== rule.id);
|
|
186
|
+
return draw();
|
|
187
|
+
}
|
|
188
|
+
if (!data) return null;
|
|
189
|
+
const payload = { name: name.value, conditions: editors.map((e) => e.read()), action: { kind: action.value } };
|
|
190
|
+
const body = rule
|
|
191
|
+
? await send(`/api/invoicing/rules/${rule.id}`, 'PUT', payload)
|
|
192
|
+
: await send('/api/invoicing/rules', 'POST', payload);
|
|
193
|
+
if (!body) return edit(rule, payload);
|
|
194
|
+
rules = rule ? rules.map((r) => (r.id === rule.id ? body.rule : r)) : [...rules, body.rule];
|
|
195
|
+
return draw();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const rowOf = (rule) => {
|
|
199
|
+
const toggle = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': rule.name, 'aria-checked': String(rule.enabled), disabled });
|
|
200
|
+
toggle.addEventListener('click', async () => {
|
|
201
|
+
toggle.disabled = true;
|
|
202
|
+
const body = await send(`/api/invoicing/rules/${rule.id}`, 'PUT', { enabled: toggle.getAttribute('aria-checked') !== 'true' });
|
|
203
|
+
toggle.disabled = disabled;
|
|
204
|
+
if (!body) return;
|
|
205
|
+
rules = rules.map((r) => (r.id === rule.id ? body.rule : r));
|
|
206
|
+
toggle.setAttribute('aria-checked', String(body.rule.enabled));
|
|
207
|
+
});
|
|
208
|
+
const controls = [toggle];
|
|
209
|
+
if (!disabled) {
|
|
210
|
+
const open = h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button', 'aria-label': `Edit ${rule.name}` }, 'Edit');
|
|
211
|
+
open.addEventListener('click', () => edit(rule));
|
|
212
|
+
controls.unshift(open);
|
|
213
|
+
}
|
|
214
|
+
return row(rule.name, ruleSummary(rule, meta), controls);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
function draw() {
|
|
218
|
+
const add = disabled ? null : h('button', { class: 'fx-btn fx-btn--ghost fx-btn--sm', type: 'button' }, 'Add rule');
|
|
219
|
+
add?.addEventListener('click', () => edit(null));
|
|
220
|
+
section.replaceChildren(
|
|
221
|
+
h('h2', { class: 'fx-set__title' }, title),
|
|
222
|
+
h('p', { class: 'fx-set__hint', style: { margin: '0 0 4px' } }, 'A ready job waits, or is never drafted, when a rule matches.'),
|
|
223
|
+
...(rules.length ? rules.map(rowOf) : [h('p', { class: 'fx-dim' }, 'No rules. Every ready job is drafted.')]),
|
|
224
|
+
h('div', { class: 'fx-rule__footer' }, add,
|
|
225
|
+
h('a', { class: 'fx-dim', href: DOCS, target: '_blank', rel: 'noopener' }, 'How rules work')));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
(async () => {
|
|
229
|
+
try {
|
|
230
|
+
[meta, { rules }] = await Promise.all([flow('/api/invoicing/rule-fields'), flow('/api/invoicing/rules')]);
|
|
231
|
+
draw();
|
|
232
|
+
} catch (error) {
|
|
233
|
+
section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), notice(error.message, 'danger'));
|
|
234
|
+
}
|
|
235
|
+
})();
|
|
236
|
+
return section;
|
|
237
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Settings, Invoicing (/invoicing-settings): payment terms, the draft
|
|
2
|
+
// automation, and the lab's own rules a ready job passes before Flow drafts
|
|
3
|
+
// its invoice (invoice-rules.js, docs/INVOICE_RULES.md). Invoicing needs no integration: how an invoice
|
|
4
|
+
// goes to Xero is on the Xero integration page (sendModeSection).
|
|
5
|
+
// Draft when ready is an automation (server/core/automations.js); the
|
|
6
|
+
// Seazona integration page holds the rule that marks a job ready.
|
|
7
|
+
// One row for each setting: its name, one short line, and its control. A
|
|
8
|
+
// change saves at once. Everyone can read them; an owner or admin changes
|
|
9
|
+
// them. The full rules are in docs/INVOICING_RULES.md. app.js passes in its
|
|
10
|
+
// shared pieces as `ui`.
|
|
11
|
+
|
|
12
|
+
import { automationSection } from '/assets/ui/automations.js';
|
|
13
|
+
import { rulesSection } from '/assets/ui/invoice-rules.js';
|
|
14
|
+
|
|
15
|
+
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICING_RULES.md';
|
|
16
|
+
|
|
17
|
+
const SEND_MODES = [
|
|
18
|
+
{ value: 'accounting_draft', label: 'Xero draft' },
|
|
19
|
+
{ value: 'approve_and_email', label: 'Approved and emailed' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export async function renderInvoicingSettings(main, ui) {
|
|
23
|
+
const { h, flow, toast, notice, pageHeader, me } = ui;
|
|
24
|
+
const host = h('div', { class: 'fx-card', style: { padding: '8px 20px 16px' } }, h('div', { class: 'fx-skeleton' }));
|
|
25
|
+
main.append(pageHeader('Invoicing', { subtitle: 'How Flow drafts invoices. No integration needed.' }), host);
|
|
26
|
+
let body;
|
|
27
|
+
try {
|
|
28
|
+
body = await flow('/api/invoicing/settings');
|
|
29
|
+
} catch (error) {
|
|
30
|
+
host.replaceChildren(notice(error.message, 'danger'));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
34
|
+
let saved = body.settings;
|
|
35
|
+
|
|
36
|
+
async function save(changes, control) {
|
|
37
|
+
control.disabled = true;
|
|
38
|
+
try {
|
|
39
|
+
saved = (await flow('/api/invoicing/settings', { method: 'PUT', body: JSON.stringify(changes) })).settings;
|
|
40
|
+
toast('Saved.', 'success');
|
|
41
|
+
return true;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
toast(error.message, 'danger');
|
|
44
|
+
return false;
|
|
45
|
+
} finally {
|
|
46
|
+
control.disabled = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const row = (name, hint, control) => h('div', { class: 'fx-set__row' },
|
|
51
|
+
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, name), h('span', { class: 'fx-set__hint' }, hint)),
|
|
52
|
+
h('div', { class: 'fx-set__control' }, control));
|
|
53
|
+
|
|
54
|
+
// A text or number box saves when it loses focus with a new value.
|
|
55
|
+
const box = (key, label, { type = 'text', read = (v) => v, show = (v) => v ?? '', placeholder } = {}) => {
|
|
56
|
+
const input = h('input', { class: 'fx-input', type, value: show(saved[key]), 'aria-label': label, disabled, placeholder });
|
|
57
|
+
input.addEventListener('change', async () => {
|
|
58
|
+
if (!(await save({ [key]: read(input.value) }, input))) input.value = show(saved[key]);
|
|
59
|
+
});
|
|
60
|
+
return input;
|
|
61
|
+
};
|
|
62
|
+
const days = (key, label) => box(key, label, { type: 'number', read: Number });
|
|
63
|
+
|
|
64
|
+
const section = (title, ...rows) => h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), ...rows);
|
|
65
|
+
host.replaceChildren(...[
|
|
66
|
+
disabled ? notice('Only an owner or admin can change these.', 'info') : null,
|
|
67
|
+
section('Drafts',
|
|
68
|
+
row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days'))),
|
|
69
|
+
automationSection(ui, 'invoicing', 'Automations'),
|
|
70
|
+
rulesSection(ui),
|
|
71
|
+
].filter(Boolean));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The Xero integration page's "Sending" section: how a checked invoice goes to
|
|
75
|
+
// Xero. A customer can have its own mode (/api/invoicing/contacts/:id/billing).
|
|
76
|
+
export function sendModeSection(ui) {
|
|
77
|
+
const { h, flow, toast, notice, me } = ui;
|
|
78
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
79
|
+
const select = h('select', { class: 'fx-select', 'aria-label': 'Send to Xero as', disabled },
|
|
80
|
+
SEND_MODES.map((m) => h('option', { value: m.value }, m.label)));
|
|
81
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, 'Sending'),
|
|
82
|
+
h('div', { class: 'fx-set__row' },
|
|
83
|
+
h('div', { class: 'fx-set__text' }, h('span', { class: 'fx-set__name' }, 'Send to Xero as'), h('span', { class: 'fx-set__hint' }, 'Draft: approve in Xero. Approved: Xero emails it.')),
|
|
84
|
+
h('div', { class: 'fx-set__control' }, select)),
|
|
85
|
+
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How sending works')));
|
|
86
|
+
let saved = null;
|
|
87
|
+
(async () => {
|
|
88
|
+
try {
|
|
89
|
+
saved = (await flow('/api/invoicing/settings')).settings;
|
|
90
|
+
select.value = saved.sendMode;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
section.append(notice(error.message, 'danger'));
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
select.addEventListener('change', async () => {
|
|
96
|
+
select.disabled = true;
|
|
97
|
+
try {
|
|
98
|
+
saved = (await flow('/api/invoicing/settings', { method: 'PUT', body: JSON.stringify({ sendMode: select.value }) })).settings;
|
|
99
|
+
toast('Saved.', 'success');
|
|
100
|
+
} catch (error) {
|
|
101
|
+
toast(error.message, 'danger');
|
|
102
|
+
if (saved) select.value = saved.sendMode;
|
|
103
|
+
} finally {
|
|
104
|
+
select.disabled = disabled;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return section;
|
|
108
|
+
}
|