@arcforgelabs/flow 2026.10.26 → 2026.10.29
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 -2
- package/server/api/invoicing.js +313 -46
- package/server/app.js +30 -2
- package/server/core/automations.js +154 -0
- package/server/core/invoice-rules.js +226 -0
- package/server/core/invoices.js +8 -3
- package/server/core/invoicing-rules.js +255 -0
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +57 -17
- package/server/core/line-mapping.js +337 -0
- package/server/core/monitoring.js +34 -0
- package/server/core/pipeline.js +16 -3
- package/server/core/sending.js +283 -0
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +72 -0
- package/server/db/migrations/0011_invoice_rules.sql +27 -0
- package/server/db/migrations/0012_invoice_sending.sql +18 -0
- package/server/db/migrations/0013_line_mappings.sql +35 -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/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +28 -0
- package/server/db/schema.js +158 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +17 -5
- package/server/integrations/adapters.js +2 -1
- package/server/integrations/port.js +6 -0
- package/server/integrations/seazona/feed.js +62 -4
- package/server/integrations/seazona/index.js +7 -1
- package/server/integrations/seazona/lanes.js +4 -1
- package/server/integrations/seazona/ledger.js +2 -0
- package/server/integrations/seazona/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/sentry/index.js +105 -0
- package/server/integrations/xero/client.js +5 -0
- package/server/integrations/xero/drafts.js +127 -17
- package/server/integrations/xero/index.js +11 -1
- package/server/integrations/xero/lookup.js +6 -2
- package/server/integrations/xero/migrations/0000_xero_connections.sql +1 -1
- package/server/integrations/xero/migrations/0001_xero_catalogue.sql +5 -5
- package/server/integrations/xero/migrations/0002_xero_invoice_pushes.sql +2 -2
- package/server/integrations/xero/migrations/0003_xero_payment_cursors.sql +1 -1
- package/server/integrations/xero/migrations/0004_xero_mirror.sql +7 -7
- package/server/integrations/xero/migrations/0005_xero_send_modes.sql +16 -0
- package/server/integrations/xero/migrations/meta/_journal.json +7 -0
- package/server/ui/app-page.js +7 -4
- package/server/ui/app.js +122 -6
- package/server/ui/automations.js +96 -0
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +243 -30
- package/server/ui/invoice-rules.js +237 -0
- package/server/ui/invoicing-settings.js +110 -0
- package/server/ui/invoicing.js +166 -20
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +232 -84
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/logos/sentry.svg +1 -0
- package/server/ui/monitoring.js +61 -0
- package/server/ui/routes.js +2 -2
- package/server/ui/search.js +145 -0
package/server/ui/app.js
CHANGED
|
@@ -10,7 +10,11 @@ import { getAppearance, onAppearanceChange, setAppearance, loadFonts } from '/as
|
|
|
10
10
|
import { flowClerkAppearance } from '/assets/ui/clerk-appearance.js';
|
|
11
11
|
import { ACCENT_PRESETS, normalizeAccent, THEME_FAMILIES, tokensFor } from '/assets/theme/themes.js';
|
|
12
12
|
import { renderInvoice, renderInvoiceList } from '/assets/ui/invoicing.js';
|
|
13
|
+
import { automationSection } from '/assets/ui/automations.js';
|
|
14
|
+
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
13
15
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
16
|
+
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
17
|
+
import { openSearch } from '/assets/ui/search.js';
|
|
14
18
|
|
|
15
19
|
const $ = (id) => document.getElementById(id);
|
|
16
20
|
|
|
@@ -190,6 +194,7 @@ let me = null;
|
|
|
190
194
|
const VIEWS = {
|
|
191
195
|
jobs: { label: 'Jobs', icon: 'jobs', group: 'Work', render: (main) => renderJobs(main, ui()) },
|
|
192
196
|
invoices: { label: 'Invoices', icon: 'invoices', group: 'Work', render: (main, id) => (id ? renderInvoice(main, ui(), id) : renderInvoiceList(main, ui())) },
|
|
197
|
+
'invoicing-settings': { label: 'Invoicing', icon: 'invoices', group: 'Settings', render: (main) => renderInvoicingSettings(main, ui()) },
|
|
193
198
|
people: { label: 'People', icon: 'people', group: 'Settings', ownerOnly: true, render: renderPeople },
|
|
194
199
|
integrations: { label: 'Integrations', icon: 'plug', group: 'Settings', ownerOnly: true, render: (main, id) => (id ? renderIntegration(main, id) : renderIntegrations(main)) },
|
|
195
200
|
account: { label: 'Account', icon: 'person', group: 'Settings', render: renderAccount },
|
|
@@ -203,6 +208,9 @@ const ICON_PATHS = {
|
|
|
203
208
|
people: '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.5 2.7-6 6-6s6 2.5 6 6"/><path d="M16 5a3 3 0 010 6M21 20c0-3-1.6-5-4-5.7"/>',
|
|
204
209
|
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
205
210
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
211
|
+
flag: '<path d="M5 21V4M5 4h11l-2 4 2 4H5"/>',
|
|
212
|
+
search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
|
|
213
|
+
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
|
206
214
|
palette: '<path d="M12 3a9 9 0 100 18c1.1 0 1.5-.8 1.5-1.6 0-1-.8-1.4-.8-2.4 0-.9.7-1.5 1.6-1.5H17a4 4 0 004-4c0-4.7-4-8.5-9-8.5z"/><circle cx="7.5" cy="11" r="1"/><circle cx="10" cy="7" r="1"/><circle cx="15" cy="7" r="1"/>',
|
|
207
215
|
};
|
|
208
216
|
|
|
@@ -212,6 +220,87 @@ function icon(name) {
|
|
|
212
220
|
return node;
|
|
213
221
|
}
|
|
214
222
|
|
|
223
|
+
// The menu button at the top of the sidebar. On a wide screen it folds the
|
|
224
|
+
// sidebar to its icons and back, and the choice stays in this browser. On a
|
|
225
|
+
// narrow screen the sidebar is a bar across the top, and the button opens
|
|
226
|
+
// the menu below it; choosing a page, Escape or a click outside closes it.
|
|
227
|
+
const NARROW = window.matchMedia('(max-width: 820px)');
|
|
228
|
+
const FOLDED_KEY = 'flow_sidebar_folded';
|
|
229
|
+
|
|
230
|
+
function readFolded() {
|
|
231
|
+
try { return localStorage.getItem(FOLDED_KEY) === '1'; } catch { return false; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function drawMenu() {
|
|
235
|
+
const shell = $('shell');
|
|
236
|
+
const folded = readFolded();
|
|
237
|
+
shell.toggleAttribute('data-folded', folded);
|
|
238
|
+
const open = NARROW.matches ? shell.hasAttribute('data-menu-open') : !folded;
|
|
239
|
+
const toggle = $('menu-toggle');
|
|
240
|
+
const label = NARROW.matches ? (open ? 'Close the menu' : 'Open the menu') : (folded ? 'Show the menu' : 'Fold the menu');
|
|
241
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
242
|
+
toggle.setAttribute('aria-label', label);
|
|
243
|
+
toggle.title = label;
|
|
244
|
+
// Folded, a page's name shows when the pointer rests on its icon.
|
|
245
|
+
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
246
|
+
if (folded && !NARROW.matches) link.title = link.dataset.label;
|
|
247
|
+
else link.removeAttribute('title');
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function closeMenu() {
|
|
252
|
+
if (!$('shell').hasAttribute('data-menu-open')) return;
|
|
253
|
+
$('shell').removeAttribute('data-menu-open');
|
|
254
|
+
drawMenu();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function toggleMenu() {
|
|
258
|
+
if (NARROW.matches) {
|
|
259
|
+
$('shell').toggleAttribute('data-menu-open');
|
|
260
|
+
} else {
|
|
261
|
+
try { localStorage.setItem(FOLDED_KEY, readFolded() ? '0' : '1'); } catch { /* the choice is only a convenience */ }
|
|
262
|
+
}
|
|
263
|
+
drawMenu();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
NARROW.addEventListener('change', () => { $('shell').removeAttribute('data-menu-open'); drawMenu(); });
|
|
267
|
+
document.addEventListener('keydown', (event) => {
|
|
268
|
+
if (event.key === 'Escape' && $('shell')?.hasAttribute('data-menu-open')) {
|
|
269
|
+
closeMenu();
|
|
270
|
+
$('menu-toggle').focus();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
document.addEventListener('click', (event) => {
|
|
274
|
+
if (!event.target.closest?.('.fx-side') || event.target.closest?.('[data-nav]')) closeMenu();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Search, from the sidebar foot or Ctrl+K (⌘K): the pages this person may
|
|
278
|
+
// open, invoices and jobs (search.js).
|
|
279
|
+
function search() {
|
|
280
|
+
const views = Object.entries(VIEWS)
|
|
281
|
+
.filter(([, view]) => !view.ownerOnly || me?.person.role === 'owner')
|
|
282
|
+
.map(([name, view]) => ({ name, label: view.label, icon: view.icon }));
|
|
283
|
+
openSearch({ h, flow, go, icon, views });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
document.addEventListener('keydown', (event) => {
|
|
287
|
+
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;
|
|
288
|
+
if (document.body.dataset.state !== 'signed-in') return;
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
search();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// "Report a problem" in Clerk's account menu, between "Manage account" and
|
|
294
|
+
// "Sign out". Clerk mounts and unmounts the icon itself.
|
|
295
|
+
function reportMenuItem(openReport) {
|
|
296
|
+
return {
|
|
297
|
+
label: 'Report a problem',
|
|
298
|
+
onClick: () => { openReport().catch((error) => toast(error.message, 'danger')); },
|
|
299
|
+
mountIcon: (element) => element.replaceChildren(icon('flag')),
|
|
300
|
+
unmountIcon: (element) => element.replaceChildren(),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
215
304
|
// The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
|
|
216
305
|
function currentView() {
|
|
217
306
|
const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
@@ -225,7 +314,7 @@ async function route() {
|
|
|
225
314
|
const [view, id] = currentView();
|
|
226
315
|
const path = id ? `/${view}/${id}` : `/${view}`;
|
|
227
316
|
if (location.pathname !== path) history.replaceState(null, '', path);
|
|
228
|
-
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
317
|
+
for (const link of document.querySelectorAll('a[data-nav]')) {
|
|
229
318
|
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
230
319
|
}
|
|
231
320
|
main.replaceChildren();
|
|
@@ -237,7 +326,7 @@ async function route() {
|
|
|
237
326
|
|
|
238
327
|
// Opens a path in place; the address changes, the page stays.
|
|
239
328
|
function go(path, { replace = false } = {}) {
|
|
240
|
-
if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
329
|
+
if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
241
330
|
route();
|
|
242
331
|
}
|
|
243
332
|
|
|
@@ -281,10 +370,34 @@ function renderShell() {
|
|
|
281
370
|
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
282
371
|
if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
|
|
283
372
|
group = view.group;
|
|
284
|
-
items.push(h('a', { href: `/${name}`, 'data-nav': name, onclick: navigate }, icon(view.icon), h('span', {}, view.label)));
|
|
373
|
+
items.push(h('a', { href: `/${name}`, 'data-nav': name, 'data-label': view.label, onclick: navigate }, icon(view.icon), h('span', { class: 'fx-nav__label' }, view.label)));
|
|
285
374
|
}
|
|
286
|
-
|
|
287
|
-
|
|
375
|
+
// "Report a problem" sits at the foot of the menu, above the account chip,
|
|
376
|
+
// while Sentry and its report form are on (monitoring.js).
|
|
377
|
+
const report = h('button', { type: 'button', class: 'fx-nav__report', 'data-nav': 'report', 'data-label': 'Report a problem', hidden: true },
|
|
378
|
+
icon('flag'), h('span', { class: 'fx-nav__label' }, 'Report a problem'));
|
|
379
|
+
nav.replaceChildren(...items, report);
|
|
380
|
+
$('menu-toggle').replaceChildren(icon('menu'));
|
|
381
|
+
$('menu-toggle').onclick = toggleMenu;
|
|
382
|
+
drawMenu();
|
|
383
|
+
// The sidebar foot shows who is signed in: photo or initial, then the name
|
|
384
|
+
// (the email when the account has no name). It opens the account menu.
|
|
385
|
+
const userButton = { afterSignOutUrl: '/sign-in', showName: true };
|
|
386
|
+
$('search').replaceChildren(icon('search'));
|
|
387
|
+
$('search').onclick = search;
|
|
388
|
+
mountClerk('userButton', $('user-button'), userButton);
|
|
389
|
+
// Errors and problem reports go to the installation's own Sentry, when the
|
|
390
|
+
// Sentry integration is on (monitoring.js). Then "Report a problem" shows
|
|
391
|
+
// in the menu and in the account menu. Flow works the same without it.
|
|
392
|
+
startMonitoring({ flow, person: me.person })
|
|
393
|
+
.then((openReport) => {
|
|
394
|
+
if (!openReport) return;
|
|
395
|
+
report.hidden = false;
|
|
396
|
+
report.onclick = () => { openReport().catch((error) => toast(error.message, 'danger')); };
|
|
397
|
+
unmountClerk($('user-button'));
|
|
398
|
+
mountClerk('userButton', $('user-button'), { ...userButton, customMenuItems: [reportMenuItem(openReport)] });
|
|
399
|
+
})
|
|
400
|
+
.catch((error) => console.warn(`[monitoring] ${error.message}`));
|
|
288
401
|
window.addEventListener('popstate', route);
|
|
289
402
|
return route();
|
|
290
403
|
}
|
|
@@ -405,8 +518,9 @@ const STATE_TONE = { ready: 'success', disabled: 'mute', failed: 'danger', resta
|
|
|
405
518
|
const CATALOGUE = {
|
|
406
519
|
xero: { maker: 'Xero', category: 'accounting', blurb: 'Send invoices to Xero as drafts and bring payments back.', logo: 'xero.svg' },
|
|
407
520
|
seazona: { maker: 'Seazona', category: 'lab', blurb: 'Bring lab cases and orders into Flow as jobs.', logo: 'seazona.svg' },
|
|
521
|
+
sentry: { maker: 'Sentry', category: 'monitoring', blurb: 'Send errors and problem reports to your own Sentry project.', logo: 'sentry.svg' },
|
|
408
522
|
};
|
|
409
|
-
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software']];
|
|
523
|
+
const CATEGORIES = [['all', 'All'], ['accounting', 'Accounting'], ['lab', 'Lab software'], ['monitoring', 'Monitoring']];
|
|
410
524
|
const CATEGORY_LABEL = Object.fromEntries(CATEGORIES);
|
|
411
525
|
|
|
412
526
|
function integrationLogo(integration, size = '') {
|
|
@@ -626,6 +740,8 @@ async function renderIntegration(main, id) {
|
|
|
626
740
|
!body.secretsKey ? notice('Flow has no secrets key, so it cannot store a client secret. Ask whoever hosts Flow to set FLOW_SECRETS_KEY (64 hex characters), then restart Flow.', 'warn', 'No secrets key.') : null,
|
|
627
741
|
current.state === 'failed' && current.error ? notice(current.error, 'danger', 'It could not start.') : null,
|
|
628
742
|
current.id === 'xero' && current.state === 'ready' ? h('section', { class: 'fx-int-section' }, xeroConnection(body.organisations, canChange)) : null,
|
|
743
|
+
current.id === 'seazona' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, automationSection(ui(), 'seazona', 'Ready to invoice')) : null,
|
|
744
|
+
current.id === 'xero' ? h('section', { class: 'fx-int-section fx-card', style: { padding: '8px 20px 16px' } }, sendModeSection(ui())) : null,
|
|
629
745
|
guide ? h('section', { class: 'fx-int-section' },
|
|
630
746
|
h('h2', { class: 'fx-int-section__title' }, 'Set up'),
|
|
631
747
|
h('ol', { class: 'fx-steps' }, guide.steps.map((step) => h('li', {}, step))),
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Automation rows (server/core/automations.js, /api/invoicing/automations).
|
|
2
|
+
// Each automation shows where its trigger lives: the work system's ready rule
|
|
3
|
+
// on that integration's page, the draft rule on Settings, Invoicing. One row
|
|
4
|
+
// for each: its name, one short line, and its switch, plus a row for each of
|
|
5
|
+
// its settings. A change saves at once. Everyone can read them; an owner or
|
|
6
|
+
// admin changes them. The caller passes in app.js's shared pieces as `ui`.
|
|
7
|
+
|
|
8
|
+
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/AUTOMATIONS.md';
|
|
9
|
+
|
|
10
|
+
// A section of the automations kept at `place`, filled when they load.
|
|
11
|
+
export function automationSection(ui, place, title) {
|
|
12
|
+
const { h, flow, toast, notice, me } = ui;
|
|
13
|
+
const section = h('section', { class: 'fx-set' }, h('h2', { class: 'fx-set__title' }, title), h('div', { class: 'fx-skeleton' }));
|
|
14
|
+
const disabled = !['owner', 'admin'].includes(me?.person.role);
|
|
15
|
+
|
|
16
|
+
async function save(key, changes, control) {
|
|
17
|
+
control.disabled = true;
|
|
18
|
+
try {
|
|
19
|
+
const { automation } = await flow(`/api/invoicing/automations/${key}`, { method: 'PUT', body: JSON.stringify(changes) });
|
|
20
|
+
toast('Saved.', 'success');
|
|
21
|
+
return automation;
|
|
22
|
+
} catch (error) {
|
|
23
|
+
toast(error.message, 'danger');
|
|
24
|
+
return null;
|
|
25
|
+
} finally {
|
|
26
|
+
control.disabled = disabled;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const row = (name, hint, control) => h('div', { class: 'fx-set__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));
|
|
33
|
+
|
|
34
|
+
// A menu of the statuses the work system reported, each with its job count.
|
|
35
|
+
// A status saved earlier stays in the menu even when no job has it now.
|
|
36
|
+
const statusPicker = (automation) => {
|
|
37
|
+
const summary = h('summary', { class: 'fx-select fx-multi__summary' });
|
|
38
|
+
const list = h('div', { class: 'fx-multi__list', role: 'group', 'aria-label': 'Ready statuses' }, h('div', { class: 'fx-skeleton' }));
|
|
39
|
+
const picker = h('details', { class: 'fx-multi' }, summary, list);
|
|
40
|
+
let options = [];
|
|
41
|
+
const show = () => {
|
|
42
|
+
const chosen = new Set(automation.config.statuses);
|
|
43
|
+
const names = options.filter((o) => chosen.has(o.value)).map((o) => o.label);
|
|
44
|
+
summary.textContent = names.length ? names.join(', ') : 'None';
|
|
45
|
+
list.replaceChildren(...(options.length ? options.map((o) => {
|
|
46
|
+
const box = h('input', { type: 'checkbox', checked: chosen.has(o.value), disabled });
|
|
47
|
+
box.addEventListener('change', async () => {
|
|
48
|
+
const next = options.map((x) => x.value).filter((v) => (v === o.value ? box.checked : chosen.has(v)));
|
|
49
|
+
if (!next.length) {
|
|
50
|
+
box.checked = true;
|
|
51
|
+
toast('Pick at least one status. To stop it, turn the automation off.', 'danger');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const saved = await save(automation.key, { config: { statuses: next } }, box);
|
|
55
|
+
if (saved) automation.config = saved.config;
|
|
56
|
+
show();
|
|
57
|
+
});
|
|
58
|
+
return h('label', { class: 'fx-multi__option' }, box, h('span', {}, o.label), h('span', { class: 'fx-dim' }, String(o.jobs)));
|
|
59
|
+
}) : [h('p', { class: 'fx-dim', style: { margin: 0 } }, 'Seazona has reported no statuses yet.')]));
|
|
60
|
+
};
|
|
61
|
+
(async () => {
|
|
62
|
+
try {
|
|
63
|
+
({ statuses: options } = await flow('/api/invoicing/work-statuses'));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
options = [];
|
|
66
|
+
toast(error.message, 'danger');
|
|
67
|
+
}
|
|
68
|
+
const known = new Set(options.map((o) => o.value));
|
|
69
|
+
for (const value of automation.config.statuses) if (!known.has(value)) options.push({ value, label: value, jobs: 0 });
|
|
70
|
+
show();
|
|
71
|
+
})();
|
|
72
|
+
return picker;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const rowsOf = (automation) => {
|
|
76
|
+
const button = h('button', { class: 'fx-switch', type: 'button', role: 'switch', 'aria-label': automation.name, 'aria-checked': String(automation.enabled), disabled });
|
|
77
|
+
button.addEventListener('click', async () => {
|
|
78
|
+
const saved = await save(automation.key, { enabled: button.getAttribute('aria-checked') !== 'true' }, button);
|
|
79
|
+
if (saved) button.setAttribute('aria-checked', String(saved.enabled));
|
|
80
|
+
});
|
|
81
|
+
const out = [row(automation.name, automation.hint, button)];
|
|
82
|
+
if (automation.config.statuses) out.push(row('Ready statuses', 'Picked from the statuses Seazona reports.', statusPicker(automation)));
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
(async () => {
|
|
87
|
+
try {
|
|
88
|
+
const { automations } = await flow(`/api/invoicing/automations?place=${encodeURIComponent(place)}`);
|
|
89
|
+
section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), ...automations.flatMap(rowsOf),
|
|
90
|
+
h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How automations work')));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
section.replaceChildren(h('h2', { class: 'fx-set__title' }, title), notice(error.message, 'danger'));
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
return section;
|
|
96
|
+
}
|
|
@@ -11,6 +11,9 @@ const gearIcon = {
|
|
|
11
11
|
'&& > *': { visibility: 'hidden' },
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
// Matches the narrow-screen rule in flow.css, where the sidebar becomes a bar.
|
|
15
|
+
const NARROW = '@media (max-width: 820px)';
|
|
16
|
+
|
|
14
17
|
function token(name, fallback) {
|
|
15
18
|
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
16
19
|
return value || fallback;
|
|
@@ -42,6 +45,7 @@ export function flowClerkAppearance({ embedded = false, publicSignUp = false } =
|
|
|
42
45
|
const inputEdge = { ...controlEdge, '&&[data-feedback="error"]': { boxShadow: edge(danger) } };
|
|
43
46
|
const display = token('--t-display', 'ui-serif, Georgia, serif');
|
|
44
47
|
const dim = token('--t-dim', '#5b6773');
|
|
48
|
+
const paper = token('--t-paper', '#ffffff');
|
|
45
49
|
return {
|
|
46
50
|
variables: {
|
|
47
51
|
colorPrimary: token('--t-primary', '#1f6f8f'),
|
|
@@ -84,6 +88,23 @@ export function flowClerkAppearance({ embedded = false, publicSignUp = false } =
|
|
|
84
88
|
badge: { '&&': { color: dim, background: `color-mix(in srgb, ${text} 5%, transparent)`, borderColor: line } },
|
|
85
89
|
formButtonPrimary: { boxShadow: 'none', textTransform: 'none', fontWeight: 650 },
|
|
86
90
|
socialButtonsBlockButton: controlEdge,
|
|
91
|
+
// The account button in the sidebar foot is a chip: photo or initial,
|
|
92
|
+
// then the name (Clerk shows the email when there is no name), clipped
|
|
93
|
+
// with an ellipsis to fit. The top bar on narrow screens has no room for
|
|
94
|
+
// the name, so there it is the photo or initial alone.
|
|
95
|
+
userButtonTrigger: {
|
|
96
|
+
'&&': {
|
|
97
|
+
width: '100%', minWidth: 0, justifyContent: 'flex-start', padding: '4px 10px 4px 4px', borderRadius: token('--t-radius-lg', '10px'), background: paper, boxShadow: `inset 0 0 0 1px ${line}`,
|
|
98
|
+
'&:hover': { background: `color-mix(in srgb, ${text} 6%, ${paper})` },
|
|
99
|
+
[NARROW]: { width: 'auto', padding: 0, borderRadius: '50%', background: 'transparent', boxShadow: 'none', '&:hover': { background: 'transparent' } },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
userButtonBox: { flexDirection: 'row-reverse', justifyContent: 'flex-end', minWidth: 0, gap: '8px' },
|
|
103
|
+
userButtonOuterIdentifier: {
|
|
104
|
+
minWidth: 0, paddingInlineStart: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
|
105
|
+
color: token('--t-strong', text), fontSize: '14px', fontWeight: 600,
|
|
106
|
+
[NARROW]: { display: 'none' },
|
|
107
|
+
},
|
|
87
108
|
userButtonPopoverCard: { border: `1px solid ${line}`, boxShadow: shadow },
|
|
88
109
|
userButtonPopoverActionButtonIcon__manageAccount: gearIcon,
|
|
89
110
|
footer: embedded ? { display: 'none' } : undefined,
|