@arcforgelabs/flow 2026.10.27 → 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 +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -2
- package/server/api/invoicing.js +96 -40
- package/server/app.js +3 -1
- package/server/core/automations.js +14 -0
- package/server/core/invoice-rules.js +4 -2
- package/server/core/invoices.js +4 -2
- package/server/core/job-board.js +130 -0
- package/server/core/ledger.js +12 -5
- package/server/core/line-mapping.js +337 -0
- package/server/core/pipeline.js +9 -3
- package/server/core/sending.js +4 -3
- package/server/db/migrate-all.js +26 -0
- package/server/db/migrations/0010_invoicing_rules.sql +34 -13
- package/server/db/migrations/0011_invoice_rules.sql +11 -5
- package/server/db/migrations/0012_invoice_sending.sql +18 -9
- package/server/db/migrations/0013_line_mappings.sql +35 -0
- package/server/db/migrations/meta/0013_snapshot.json +3568 -0
- package/server/db/migrations/meta/_journal.json +7 -0
- package/server/db/schema.js +43 -0
- package/server/import/at-start.js +64 -2
- package/server/index.js +5 -1
- package/server/integrations/port.js +1 -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/migrations/0000_api_governor.sql +3 -3
- package/server/integrations/seazona/migrations/0001_mirror.sql +9 -9
- package/server/integrations/xero/drafts.js +4 -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 +12 -6
- package/server/ui/app-page.js +6 -4
- package/server/ui/app.js +111 -8
- package/server/ui/clerk-appearance.js +21 -0
- package/server/ui/flow.css +155 -30
- package/server/ui/invoicing-settings.js +2 -0
- package/server/ui/job-board.js +155 -0
- package/server/ui/jobs.js +220 -91
- package/server/ui/line-mappings.js +60 -0
- package/server/ui/monitoring.js +15 -7
- package/server/ui/routes.js +1 -1
- package/server/ui/search.js +145 -0
package/server/ui/app.js
CHANGED
|
@@ -14,6 +14,7 @@ import { automationSection } from '/assets/ui/automations.js';
|
|
|
14
14
|
import { renderInvoicingSettings, sendModeSection } from '/assets/ui/invoicing-settings.js';
|
|
15
15
|
import { renderJobs } from '/assets/ui/jobs.js';
|
|
16
16
|
import { startMonitoring } from '/assets/ui/monitoring.js';
|
|
17
|
+
import { openSearch } from '/assets/ui/search.js';
|
|
17
18
|
|
|
18
19
|
const $ = (id) => document.getElementById(id);
|
|
19
20
|
|
|
@@ -208,6 +209,8 @@ const ICON_PATHS = {
|
|
|
208
209
|
plug: '<path d="M9 3v5M15 3v5M6 8h12v3a6 6 0 01-12 0zM12 17v4"/>',
|
|
209
210
|
person: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',
|
|
210
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"/>',
|
|
211
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"/>',
|
|
212
215
|
};
|
|
213
216
|
|
|
@@ -217,6 +220,87 @@ function icon(name) {
|
|
|
217
220
|
return node;
|
|
218
221
|
}
|
|
219
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
|
+
|
|
220
304
|
// The view and, for one record, its id: /invoices/<id> is ['invoices', '<id>'].
|
|
221
305
|
function currentView() {
|
|
222
306
|
const [name, id = null] = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
|
|
@@ -230,7 +314,7 @@ async function route() {
|
|
|
230
314
|
const [view, id] = currentView();
|
|
231
315
|
const path = id ? `/${view}/${id}` : `/${view}`;
|
|
232
316
|
if (location.pathname !== path) history.replaceState(null, '', path);
|
|
233
|
-
for (const link of document.querySelectorAll('[data-nav]')) {
|
|
317
|
+
for (const link of document.querySelectorAll('a[data-nav]')) {
|
|
234
318
|
link.setAttribute('aria-current', link.dataset.nav === view ? 'page' : 'false');
|
|
235
319
|
}
|
|
236
320
|
main.replaceChildren();
|
|
@@ -242,7 +326,7 @@ async function route() {
|
|
|
242
326
|
|
|
243
327
|
// Opens a path in place; the address changes, the page stays.
|
|
244
328
|
function go(path, { replace = false } = {}) {
|
|
245
|
-
if (path !== location.pathname) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
329
|
+
if (path !== location.pathname + location.search) history[replace ? 'replaceState' : 'pushState'](null, '', path);
|
|
246
330
|
route();
|
|
247
331
|
}
|
|
248
332
|
|
|
@@ -286,14 +370,33 @@ function renderShell() {
|
|
|
286
370
|
if (view.ownerOnly && me?.person.role !== 'owner') continue;
|
|
287
371
|
if (view.group && view.group !== group) items.push(h('span', { class: 'fx-nav__group' }, view.group));
|
|
288
372
|
group = view.group;
|
|
289
|
-
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)));
|
|
290
374
|
}
|
|
291
|
-
|
|
292
|
-
|
|
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);
|
|
293
389
|
// Errors and problem reports go to the installation's own Sentry, when the
|
|
294
|
-
// Sentry integration is on (monitoring.js).
|
|
295
|
-
|
|
296
|
-
startMonitoring({ flow, person: me.person
|
|
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
|
+
})
|
|
297
400
|
.catch((error) => console.warn(`[monitoring] ${error.message}`));
|
|
298
401
|
window.addEventListener('popstate', route);
|
|
299
402
|
return route();
|
|
@@ -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,
|
package/server/ui/flow.css
CHANGED
|
@@ -114,23 +114,42 @@ body:is([data-state="off"], [data-state="error"], [data-state="no-access"]) .fx-
|
|
|
114
114
|
width: 232px; height: 100vh; padding: 16px 12px 12px;
|
|
115
115
|
background: var(--t-s1); border-right: 1px solid var(--fx-line);
|
|
116
116
|
}
|
|
117
|
-
.fx-
|
|
118
|
-
.fx-
|
|
117
|
+
.fx-side__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 0 14px 8px; }
|
|
118
|
+
.fx-side__head .fx-brand { min-width: 0; font-size: 19px; }
|
|
119
|
+
.fx-side__head .fx-mark { width: 26px; height: 26px; }
|
|
119
120
|
.fx-nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow-y: auto; }
|
|
120
121
|
.fx-nav__group { padding: 16px 10px 6px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
|
|
121
122
|
.fx-nav__group:first-child { padding-top: 4px; }
|
|
122
|
-
.fx-nav a {
|
|
123
|
+
.fx-nav a, .fx-nav__report {
|
|
123
124
|
display: flex; align-items: center; gap: 10px;
|
|
124
125
|
padding: 8px 10px; border-radius: var(--t-radius);
|
|
125
126
|
color: var(--t-text); font-weight: 500; font-size: 14px; text-decoration: none; white-space: nowrap;
|
|
126
127
|
}
|
|
127
|
-
.fx-nav a:hover { color: var(--t-strong); background: var(--fx-hover); }
|
|
128
|
+
.fx-nav a:hover, .fx-nav__report:hover { color: var(--t-strong); background: var(--fx-hover); }
|
|
129
|
+
.fx-nav__report { margin-top: auto; border: 0; background: none; font: inherit; font-weight: 500; font-size: 14px; text-align: left; cursor: pointer; }
|
|
130
|
+
.fx-nav__report[hidden] { display: none; }
|
|
128
131
|
.fx-nav a[aria-current="page"] { color: var(--t-accent); background: var(--t-accent-bg); font-weight: 650; box-shadow: inset 2px 0 0 var(--t-brand); }
|
|
129
132
|
.fx-nav__count { margin-left: auto; color: var(--t-dim); font-size: 12px; font-weight: 700; }
|
|
130
133
|
.fx-ico { display: inline-flex; flex: none; }
|
|
131
134
|
.fx-ico svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
132
135
|
.fx-side__end { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px 6px 0; border-top: 1px solid var(--fx-line); }
|
|
133
136
|
.fx-side__user { display: flex; min-width: 32px; min-height: 32px; align-items: center; }
|
|
137
|
+
/* Clerk replaces the mount's classes with its root box's, so this uses the id. */
|
|
138
|
+
#user-button { flex: 1 1 auto; min-width: 0; }
|
|
139
|
+
/* Folded (wide screens only): the sidebar keeps its icons, the menu button
|
|
140
|
+
and the account photo; each page's name is still read out. */
|
|
141
|
+
@media (min-width: 821px) {
|
|
142
|
+
.fx-shell[data-folded] .fx-side { width: 64px; padding-inline: 10px; }
|
|
143
|
+
.fx-shell[data-folded] .fx-side__head { flex-direction: column; gap: 10px; padding: 0 0 14px; }
|
|
144
|
+
.fx-shell[data-folded] .fx-brand span { display: none; }
|
|
145
|
+
.fx-shell[data-folded] .fx-nav__group { height: 1px; margin: 10px 6px; padding: 0; overflow: hidden; background: var(--fx-line); color: transparent; }
|
|
146
|
+
.fx-shell[data-folded] :is(.fx-nav a, .fx-nav__report) { justify-content: center; padding: 9px 0; }
|
|
147
|
+
.fx-shell[data-folded] .fx-nav__label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
148
|
+
.fx-shell[data-folded] .fx-side__end { flex-direction: column; padding: 12px 0 0; }
|
|
149
|
+
.fx-shell[data-folded] #user-button { flex: none; }
|
|
150
|
+
.fx-shell[data-folded] #user-button .cl-userButtonTrigger { width: auto; padding: 0; border-radius: 50%; background: transparent; box-shadow: none; }
|
|
151
|
+
.fx-shell[data-folded] #user-button .cl-userButtonOuterIdentifier { display: none; }
|
|
152
|
+
}
|
|
134
153
|
.fx-main { flex: 1; min-width: 0; max-width: 1180px; margin: 0 auto; padding: 32px clamp(16px, 4vw, 48px) 64px; display: grid; gap: 20px; align-content: start; }
|
|
135
154
|
.fx-main:focus { outline: none; box-shadow: none; }
|
|
136
155
|
|
|
@@ -384,25 +403,107 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
384
403
|
.fx-toolbar--plain .fx-search { margin-bottom: 0; }
|
|
385
404
|
.fx-empty .fx-btn { margin-top: 12px; }
|
|
386
405
|
.fx-panel.fx-empty { display: grid; justify-items: center; }
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
.fx-
|
|
391
|
-
.fx-
|
|
392
|
-
.fx-
|
|
393
|
-
.fx-
|
|
394
|
-
.fx-
|
|
395
|
-
.fx-
|
|
396
|
-
.fx-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
.fx-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
+
/* The board never scrolls sideways: its columns (one per work system status)
|
|
407
|
+
wrap onto more rows when the page is narrow, and stack on a phone. A long
|
|
408
|
+
column scrolls inside itself. */
|
|
409
|
+
.fx-board { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 1fr)); gap: 10px; align-items: start; }
|
|
410
|
+
.fx-board__col { display: grid; gap: 8px; align-content: start; min-width: 0; padding: 10px 8px 8px; border-radius: var(--t-radius-lg); background: var(--t-s1); }
|
|
411
|
+
.fx-board__head { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 2px 2px 4px; }
|
|
412
|
+
.fx-board__sum { flex: none; margin-left: auto; color: var(--t-dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
413
|
+
.fx-board__cards { display: grid; gap: 8px; max-height: 70vh; overflow-y: auto; }
|
|
414
|
+
.fx-board__none { padding: 8px 4px; color: var(--t-dim); font-size: 13px; }
|
|
415
|
+
.fx-table--jobs tbody tr { cursor: pointer; }
|
|
416
|
+
|
|
417
|
+
/* A work system status as a coloured label: board columns and the job page.
|
|
418
|
+
The colour follows where the status sits in the work (job-board.js,
|
|
419
|
+
statusRank): new, in work, checking and complete, out of the lab, cancelled. */
|
|
420
|
+
.fx-stage {
|
|
421
|
+
--stage: var(--t-accent);
|
|
422
|
+
display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-width: 0; padding: 3px 8px 3px 7px;
|
|
423
|
+
border: 1px solid color-mix(in srgb, var(--stage) 30%, transparent); border-radius: 4px;
|
|
424
|
+
background: color-mix(in srgb, var(--stage) 12%, transparent); color: var(--stage);
|
|
425
|
+
font-size: 11px; font-weight: 700; line-height: 1.3; letter-spacing: .04em; text-transform: uppercase;
|
|
426
|
+
}
|
|
427
|
+
.fx-stage::before { content: ""; flex: none; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
|
428
|
+
.fx-stage__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
429
|
+
.fx-stage__count { opacity: .75; font-variant-numeric: tabular-nums; }
|
|
430
|
+
.fx-stage[data-rank="0"] { --stage: var(--t-mute); }
|
|
431
|
+
.fx-stage[data-rank="3"], .fx-stage[data-rank="4"] { --stage: color-mix(in srgb, var(--t-accent) 45%, var(--t-success)); }
|
|
432
|
+
.fx-stage[data-rank="5"], .fx-stage[data-rank="6"] { --stage: var(--t-success); }
|
|
433
|
+
.fx-stage[data-rank="7"] { --stage: var(--t-dim); }
|
|
434
|
+
|
|
435
|
+
/* ── Job card ───────────────────────────────────────────────────────────────
|
|
436
|
+
One card on the board ("Task card"; jobs.js, jobCard): client,
|
|
437
|
+
practitioner, one date, total, and a small warning mark when the job has a
|
|
438
|
+
problem. The card design may be swapped: change this block and jobCard
|
|
439
|
+
together. */
|
|
440
|
+
.fx-job { position: relative; display: grid; min-width: 0; padding: 11px 12px 9px; border: 1px solid var(--fx-line); border-radius: var(--t-radius); background: var(--t-paper); box-shadow: var(--t-shadow); font-size: 13px; cursor: pointer; }
|
|
441
|
+
.fx-job:hover { border-color: var(--t-brd-hi, var(--fx-line)); }
|
|
442
|
+
.fx-job:focus-visible { outline: 2px solid var(--t-ring, var(--t-accent)); outline-offset: 2px; }
|
|
443
|
+
.fx-job__client { display: -webkit-box; overflow: hidden; color: var(--t-strong); font-size: 14px; font-weight: 600; line-height: 1.3; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
|
|
444
|
+
.fx-job__who { display: flex; align-items: center; gap: 6px; min-width: 0; margin-top: 5px; color: var(--t-dim); font-size: 12.5px; }
|
|
445
|
+
.fx-job__who span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
446
|
+
.fx-job__who svg, .fx-job__date svg { flex: none; color: var(--t-mute); }
|
|
447
|
+
.fx-job__foot { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--fx-line-soft); color: var(--t-dim); font-size: 12px; }
|
|
448
|
+
.fx-job__date { display: inline-flex; align-items: center; gap: 5px; min-width: 0; overflow: hidden; white-space: nowrap; }
|
|
449
|
+
.fx-job__date--late, .fx-job__date--late svg { color: var(--t-danger); }
|
|
450
|
+
.fx-job__space { flex: 1; }
|
|
451
|
+
.fx-job__amount { color: var(--t-text); font-weight: 600; font-family: var(--t-data, var(--t-body)); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
452
|
+
.fx-job__problem { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: var(--t-warn); cursor: pointer; }
|
|
453
|
+
.fx-job__problem:hover, .fx-job__problem:focus-visible { background: var(--t-warn-bg); }
|
|
454
|
+
.fx-job__cell { display: inline-flex; align-items: center; gap: 6px; }
|
|
455
|
+
|
|
456
|
+
/* ── Job page ───────────────────────────────────────────────────────────────
|
|
457
|
+
A job opened from the board: a large panel over it, like a task page, with
|
|
458
|
+
the activity at the side. It stacks on a phone. */
|
|
459
|
+
.fx-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--t-dim); overflow-wrap: anywhere; }
|
|
460
|
+
.fx-dialog.fx-task { width: min(1040px, calc(100vw - 32px)); max-height: calc(100vh - 48px); overflow-y: auto; }
|
|
461
|
+
.fx-task__grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; }
|
|
462
|
+
.fx-task__main { min-width: 0; padding: 20px 28px 28px; }
|
|
463
|
+
.fx-task__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
|
464
|
+
.fx-task__crumb { display: flex; flex-wrap: wrap; gap: 6px; color: var(--t-dim); font-size: 12.5px; }
|
|
465
|
+
.fx-task__slash { color: var(--t-mute); }
|
|
466
|
+
.fx-task__title { margin: 6px 0 12px; color: var(--t-strong); font: var(--t-display-weight) 30px/1.15 var(--t-display); overflow-wrap: anywhere; }
|
|
467
|
+
.fx-task__stage { margin-bottom: 18px; }
|
|
468
|
+
.fx-task__fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 32px; margin: 0; }
|
|
469
|
+
.fx-task__fields > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 12px; align-items: center; min-width: 0; padding: 8px 0; border-bottom: 1px solid var(--fx-line-soft); }
|
|
470
|
+
.fx-task__fields dt { color: var(--t-dim); font-size: 12.5px; }
|
|
471
|
+
.fx-task__fields dd { min-width: 0; margin: 0; color: var(--t-strong); font-size: 13.5px; overflow-wrap: anywhere; }
|
|
472
|
+
.fx-task__late { color: var(--t-danger) !important; }
|
|
473
|
+
.fx-task__link { color: var(--t-accent); text-decoration: none; border-bottom: 1px solid color-mix(in srgb, var(--t-accent) 40%, transparent); }
|
|
474
|
+
.fx-task__link:hover { border-bottom-color: currentColor; }
|
|
475
|
+
.fx-task__fix { scroll-margin-top: 16px; margin-top: 22px; border: 1px solid var(--t-warn-brd, var(--t-warn)); border-radius: var(--t-radius); background: var(--t-warn-bg); }
|
|
476
|
+
.fx-task__fix h3, .fx-task__lines h3, .fx-task__activity h3 { margin: 0; color: var(--t-dim); font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
477
|
+
.fx-task__fix h3 { padding: 12px 14px 4px; color: var(--t-warn); }
|
|
478
|
+
.fx-task__fixrow { padding: 9px 14px; border-top: 1px solid var(--fx-line-soft); color: var(--t-strong); font-size: 13.5px; }
|
|
479
|
+
.fx-task__fixrow:first-of-type { border-top: 0; }
|
|
480
|
+
.fx-task__lines { margin-top: 24px; }
|
|
481
|
+
.fx-task__lines h3 { margin-bottom: 8px; }
|
|
482
|
+
.fx-task__lines table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 13px; }
|
|
483
|
+
.fx-task__lines th { padding: 6px 8px 6px 0; border-bottom: 1px solid var(--fx-line); color: var(--t-mute); font-size: 12px; font-weight: 500; text-align: left; }
|
|
484
|
+
.fx-task__lines td { padding: 9px 8px 9px 0; border-bottom: 1px solid var(--fx-line-soft); vertical-align: top; overflow-wrap: anywhere; }
|
|
485
|
+
.fx-task__lines .fx-num { padding-right: 0; text-align: right; }
|
|
486
|
+
.fx-task__lines tfoot td { border-bottom: 0; color: var(--t-strong); font-weight: 600; }
|
|
487
|
+
.fx-task__col-code { width: 130px; }
|
|
488
|
+
.fx-task__col-amount { width: 96px; }
|
|
489
|
+
.fx-task__miss { color: var(--t-warn); }
|
|
490
|
+
.fx-task__actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }
|
|
491
|
+
.fx-task__actions:empty { display: none; }
|
|
492
|
+
.fx-task__activity { min-width: 0; padding: 24px 20px; border-left: 1px solid var(--fx-line); background: var(--t-s1); }
|
|
493
|
+
.fx-task__activity h3 { margin-bottom: 14px; }
|
|
494
|
+
.fx-task__activity ol { margin: 0; padding: 0; list-style: none; }
|
|
495
|
+
.fx-task__activity li { position: relative; padding: 0 0 16px 18px; color: var(--t-text); font-size: 13px; }
|
|
496
|
+
.fx-task__activity li::before { content: ""; position: absolute; left: 3px; top: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--t-mute); }
|
|
497
|
+
.fx-task__activity li::after { content: ""; position: absolute; left: 5.5px; top: 16px; bottom: 0; width: 1px; background: var(--fx-line); }
|
|
498
|
+
.fx-task__activity li:last-child::after { display: none; }
|
|
499
|
+
.fx-task__activity time { display: block; margin-top: 1px; color: var(--t-mute); font-size: 11.5px; }
|
|
500
|
+
@media (max-width: 760px) {
|
|
501
|
+
.fx-dialog.fx-task { width: 100vw; max-width: 100vw; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; }
|
|
502
|
+
.fx-task__grid { grid-template-columns: minmax(0, 1fr); }
|
|
503
|
+
.fx-task__main { padding: 16px; }
|
|
504
|
+
.fx-task__fields { grid-template-columns: minmax(0, 1fr); }
|
|
505
|
+
.fx-task__activity { padding: 16px; border-top: 1px solid var(--fx-line); border-left: 0; }
|
|
506
|
+
}
|
|
406
507
|
|
|
407
508
|
/* ── Dialog ─────────────────────────────────────────────────────────────── */
|
|
408
509
|
.fx-dialog {
|
|
@@ -416,6 +517,25 @@ a.fx-strong:hover { text-decoration: underline; }
|
|
|
416
517
|
.fx-dialog__title { color: var(--t-strong); font: var(--t-display-weight) 21px/1.2 var(--t-display); }
|
|
417
518
|
.fx-dialog__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
|
418
519
|
|
|
520
|
+
/* Search (search.js): a box near the top, results grouped under it. */
|
|
521
|
+
.fx-search-dialog { width: min(600px, calc(100vw - 32px)); margin-top: 12vh; }
|
|
522
|
+
.fx-search-dialog__bar { display: flex; align-items: center; gap: 10px; padding: 10px 12px 10px 16px; border-bottom: 1px solid var(--fx-line); color: var(--t-dim); }
|
|
523
|
+
.fx-search-dialog__input { flex: 1; min-width: 0; padding: 6px 0; border: 0; background: transparent; color: var(--t-strong); font: 500 16px/1.4 var(--t-body); }
|
|
524
|
+
.fx-search-dialog__input:focus { outline: none; box-shadow: none; }
|
|
525
|
+
.fx-search-dialog__input::-webkit-search-cancel-button { display: none; }
|
|
526
|
+
.fx-search-dialog__results { max-height: min(60vh, 460px); overflow-y: auto; padding: 6px; }
|
|
527
|
+
.fx-search-dialog__group + .fx-search-dialog__group { margin-top: 6px; }
|
|
528
|
+
.fx-search-dialog__label { padding: 8px 10px 4px; color: var(--t-dim); font: var(--t-label-weight) var(--t-label-size)/1.3 var(--t-body); letter-spacing: var(--t-label-spacing); text-transform: var(--t-label-case); }
|
|
529
|
+
.fx-search-dialog__option { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: var(--t-radius); color: var(--t-text); text-decoration: none; }
|
|
530
|
+
.fx-search-dialog__option:hover { background: var(--fx-hover); }
|
|
531
|
+
.fx-search-dialog__option[aria-selected="true"] { color: var(--t-accent); background: var(--t-accent-bg); }
|
|
532
|
+
.fx-search-dialog__text { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
|
533
|
+
.fx-search-dialog__title { flex: none; color: inherit; font-weight: 600; font-size: 14px; }
|
|
534
|
+
.fx-search-dialog__detail { overflow: hidden; color: var(--t-dim); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
|
535
|
+
/* --t-dim is only checked on plain surfaces; on the accent tint, --t-text passes. */
|
|
536
|
+
.fx-search-dialog__option[aria-selected="true"] .fx-search-dialog__detail { color: var(--t-text); }
|
|
537
|
+
.fx-search-dialog__empty { padding: 18px 10px; color: var(--t-dim); text-align: center; }
|
|
538
|
+
|
|
419
539
|
/* ── Sign-in ────────────────────────────────────────────────────────────── */
|
|
420
540
|
.fx-auth { min-height: 100vh; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); }
|
|
421
541
|
.fx-auth__brand {
|
|
@@ -517,13 +637,18 @@ body[data-state="loading"] .fx-loading { display: grid; justify-items: center; }
|
|
|
517
637
|
@media (max-width: 820px) {
|
|
518
638
|
.fx-shell { flex-direction: column; }
|
|
519
639
|
.fx-side { flex-direction: row; align-items: center; width: auto; height: 60px; padding: 0 12px; border-right: 0; border-bottom: 1px solid var(--fx-line); }
|
|
520
|
-
.fx-
|
|
521
|
-
.fx-
|
|
522
|
-
|
|
523
|
-
.fx-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
640
|
+
.fx-side__head { flex-direction: row-reverse; gap: 6px; padding: 0; }
|
|
641
|
+
.fx-side__head .fx-brand span { display: none; }
|
|
642
|
+
/* The menu opens as a panel below the bar. */
|
|
643
|
+
.fx-nav {
|
|
644
|
+
display: none; position: absolute; top: 60px; left: 0; right: 0;
|
|
645
|
+
max-height: calc(100vh - 60px); padding: 4px 12px 12px;
|
|
646
|
+
background: var(--t-s1); border-bottom: 1px solid var(--fx-line); box-shadow: var(--t-shadow);
|
|
647
|
+
}
|
|
648
|
+
.fx-shell[data-menu-open] .fx-nav { display: flex; }
|
|
649
|
+
.fx-nav__report { margin-top: 8px; }
|
|
650
|
+
.fx-side__end { margin-left: auto; padding: 0; border-top: 0; }
|
|
651
|
+
#user-button { flex: none; }
|
|
527
652
|
}
|
|
528
653
|
|
|
529
654
|
/* ── Phones ─────────────────────────────────────────────────────────────── */
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { automationSection } from '/assets/ui/automations.js';
|
|
13
13
|
import { rulesSection } from '/assets/ui/invoice-rules.js';
|
|
14
|
+
import { lineMappingSection } from '/assets/ui/line-mappings.js';
|
|
14
15
|
|
|
15
16
|
const DOCS = 'https://github.com/arcforgelabs/flow/blob/main/docs/INVOICING_RULES.md';
|
|
16
17
|
|
|
@@ -66,6 +67,7 @@ export async function renderInvoicingSettings(main, ui) {
|
|
|
66
67
|
disabled ? notice('Only an owner or admin can change these.', 'info') : null,
|
|
67
68
|
section('Drafts',
|
|
68
69
|
row('Payment terms', 'Days from invoice to due date.', days('paymentTermsDays', 'Payment terms in days'))),
|
|
70
|
+
lineMappingSection(ui),
|
|
69
71
|
automationSection(ui, 'invoicing', 'Automations'),
|
|
70
72
|
rulesSection(ui),
|
|
71
73
|
].filter(Boolean));
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The Jobs board's rules, with no page code, so tests can read them
|
|
2
|
+
// (test/job-board.test.js). server/ui/jobs.js draws the page.
|
|
3
|
+
//
|
|
4
|
+
// The board's columns are the work system's own job statuses, one to one.
|
|
5
|
+
// A job's invoice has a stage of its own (invoiceStage), shown in the job's
|
|
6
|
+
// detail panel; a job whose invoice needs a person has a problem (fixes).
|
|
7
|
+
|
|
8
|
+
// Where a status sits on the board when the work system gives no order:
|
|
9
|
+
// first the statuses of new work, then the ones Flow does not know (in the
|
|
10
|
+
// order Flow first saw them), then checking, complete, shipped, delivered,
|
|
11
|
+
// and cancelled last.
|
|
12
|
+
const STATUS_RANKS = [
|
|
13
|
+
[/\b(new|draft|received|booked|submitted|ordered|pending)\b/i, 0],
|
|
14
|
+
[/\b(scan|scanned|design|designed|model|wax|impression)/i, 1],
|
|
15
|
+
[/\b(qc|quality|check|checked|checking|finish|finished|finishing)\b/i, 3],
|
|
16
|
+
[/\bcomplete(d)?\b|\bready\b/i, 4],
|
|
17
|
+
[/\b(shipped|dispatched|sent)\b/i, 5],
|
|
18
|
+
[/\bdelivered\b/i, 6],
|
|
19
|
+
[/\b(cancell?ed|void|closed|archived|deleted)\b/i, 7],
|
|
20
|
+
];
|
|
21
|
+
const UNKNOWN_RANK = 2;
|
|
22
|
+
// From this rank on, the work is out of the lab: shipped, delivered, cancelled.
|
|
23
|
+
export const FINAL_RANK = 5;
|
|
24
|
+
export const NO_STATUS = 'No status';
|
|
25
|
+
|
|
26
|
+
export function statusRank(label) {
|
|
27
|
+
return STATUS_RANKS.find(([pattern]) => pattern.test(label ?? ''))?.[1] ?? UNKNOWN_RANK;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The board's column labels, left to right. `statuses` is the work system's
|
|
31
|
+
// list ({ label, firstSeenAt }); `jobs` adds any status only a job names.
|
|
32
|
+
export function orderStatuses(statuses = [], jobs = []) {
|
|
33
|
+
const seen = new Map();
|
|
34
|
+
for (const s of statuses) if (s?.label && !seen.has(s.label)) seen.set(s.label, s.firstSeenAt ?? null);
|
|
35
|
+
for (const job of jobs) {
|
|
36
|
+
const label = job.status ?? NO_STATUS;
|
|
37
|
+
if (!seen.has(label)) seen.set(label, null);
|
|
38
|
+
}
|
|
39
|
+
return [...seen].sort(([a, at], [b, bt]) => statusRank(a) - statusRank(b)
|
|
40
|
+
|| String(at ?? '9').localeCompare(String(bt ?? '9'))
|
|
41
|
+
|| a.localeCompare(b)).map(([label]) => label);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const INVOICE_STAGES = {
|
|
45
|
+
drafted: 'Drafted', problem: 'Problem', ready: 'Ready to invoice', invoiced: 'Invoiced', paid: 'Paid',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const PROBLEM_VIEWS = ['pending', 'review_required', 'held'];
|
|
49
|
+
// A rule's reason, as ruleBlockers writes it (server/core/invoicing-rules.js).
|
|
50
|
+
const isRule = (reason) => reason !== 'the job is not marked ready to invoice';
|
|
51
|
+
|
|
52
|
+
// The stage of a job's invoice, from its billing obligation (the API's job
|
|
53
|
+
// shape, server/core/job-board.js), or null while the job has none or is
|
|
54
|
+
// still in work.
|
|
55
|
+
// Drafted a draft in Flow that nobody marked ready, or a job
|
|
56
|
+
// that passes every check and waits for the automation
|
|
57
|
+
// Problem something a person must fix: the job's checks, a rule
|
|
58
|
+
// that stops the draft, or a problem with the draft
|
|
59
|
+
// Ready to invoice a person marked the draft ready
|
|
60
|
+
// Invoiced issued, sent, or in the accounting system
|
|
61
|
+
// Paid paid in full
|
|
62
|
+
export function invoiceStage(o) {
|
|
63
|
+
if (!o || o.view === 'in_work') return null;
|
|
64
|
+
if (o.view === 'paid') return 'paid';
|
|
65
|
+
if (PROBLEM_VIEWS.includes(o.view)) return 'problem';
|
|
66
|
+
if (o.view === 'invoiced') return 'invoiced';
|
|
67
|
+
if (o.invoiceView === 'problem' || o.invoiceStatus === 'void') return 'problem';
|
|
68
|
+
if (o.invoiceStatus === 'issued') return 'invoiced';
|
|
69
|
+
if (o.invoiceView === 'ready') return 'ready';
|
|
70
|
+
if (o.view === 'ready') return (o.waitingFor ?? []).some(isRule) ? 'problem' : 'drafted';
|
|
71
|
+
if (o.view === 'drafted') return 'drafted';
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const capital = (text) => (text ? text[0].toUpperCase() + text.slice(1) : text);
|
|
76
|
+
const sentence = (text) => capital(String(text).trim().replace(/\.?$/, '.'));
|
|
77
|
+
|
|
78
|
+
// One ledger reason (server/core/ledger.js) as a fix: a short label, what is
|
|
79
|
+
// wrong, and what to do.
|
|
80
|
+
function reasonFix(reason, acct) {
|
|
81
|
+
const fix = (label, todo) => ({ label, text: sentence(reason), todo });
|
|
82
|
+
if (reason === 'the accounting system was not checked') {
|
|
83
|
+
return { label: `Not checked in ${acct}`, text: `Flow did not check ${acct} for an invoice for this job yet.`, todo: `Wait for the next check, a few minutes. If it stays, check ${acct} under Settings, Integrations.` };
|
|
84
|
+
}
|
|
85
|
+
if (reason === 'a line does not map to a price item') {
|
|
86
|
+
return { label: 'No price code', text: 'A line has a code that is not in the price list.', todo: 'Add the code to the price list, or correct the code in the work system.' };
|
|
87
|
+
}
|
|
88
|
+
if (reason === 'the job has no customer contact') {
|
|
89
|
+
return { label: 'Client not linked', text: 'The client is not linked to a customer in Flow.', todo: 'Link the client to a customer in Flow.' };
|
|
90
|
+
}
|
|
91
|
+
if (reason === 'the work system report is out of date') {
|
|
92
|
+
return { label: 'Out of date', text: 'Flow has no news of this job from the work system for more than a day.', todo: 'Check the work system under Settings, Integrations.' };
|
|
93
|
+
}
|
|
94
|
+
if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Compare the invoice with the job, then correct the invoice.');
|
|
95
|
+
if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.');
|
|
96
|
+
if (/^the accounting evidence is ambiguous/.test(reason)) return fix(`${acct} match unclear`, `Find the invoice for this job in ${acct}, then link it.`);
|
|
97
|
+
if (/^the accounting evidence is conflict/.test(reason)) return fix(`Two ${acct} invoices`, `Find the right invoice for this job in ${acct}. Void the other.`);
|
|
98
|
+
if (/^invoice .* was (voided|deleted)$/.test(reason)) return fix(`Invoice ${/voided$/.test(reason) ? 'voided' : 'deleted'}`, 'Make a new invoice, or close the job.');
|
|
99
|
+
if (/^the accounting system reports /.test(reason)) return fix(`Already in ${acct}`, `Check the invoice in ${acct}. Link it to this job if it is the right one.`);
|
|
100
|
+
if (/^the job is held$/.test(reason)) return fix('On hold', 'Release the hold when the job can be invoiced.');
|
|
101
|
+
if (/^the job is review$/.test(reason)) return fix('Needs review', 'Check the job and its invoice.');
|
|
102
|
+
if (reason === 'held') return fix('On hold', 'Release the hold when the job can be invoiced.');
|
|
103
|
+
return fix('Needs review', 'Check the job and its invoice.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// One draft problem (server/core/sending.js, and the accounting system's) as a fix.
|
|
107
|
+
function draftFix(problem, acct) {
|
|
108
|
+
const code = problem.code ?? '';
|
|
109
|
+
const fix = (label, todo) => ({ label, text: sentence(problem.message ?? label), todo });
|
|
110
|
+
if (code === 'no_lines') return fix('No lines', 'Open the invoice and add the lines.');
|
|
111
|
+
if (code === 'no_amount') return fix('Total is $0', 'Open the invoice and correct the amounts.');
|
|
112
|
+
if (code === 'line_no_item') return fix('No price code', 'Open the invoice and choose a price item for each line.');
|
|
113
|
+
if (code.endsWith('_item_missing')) return fix(`Item not in ${acct}`, `Add the item in ${acct}, then sync.`);
|
|
114
|
+
if (code.endsWith('_contact_missing')) return fix(`Client not in ${acct}`, `Add the client in ${acct}, then sync.`);
|
|
115
|
+
if (code.endsWith('_contact_ambiguous')) return fix(`Two ${acct} clients`, `Merge or rename the clients in ${acct}, then sync.`);
|
|
116
|
+
if (code.includes('earlier_version')) return fix(`Old copy in ${acct}`, 'Open the invoice for the steps.');
|
|
117
|
+
if (code.startsWith('send_') || code.endsWith('_push_failed') || code.endsWith('_outcome_unknown')) return fix('Not sent', 'Open the invoice and send it again.');
|
|
118
|
+
return fix('Invoice problem', 'Open the invoice to correct it.');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// What a person must fix on a job, in order: [{ label, text, todo }]. Empty
|
|
122
|
+
// unless invoiceStage is Problem. `accountingName` names the accounting
|
|
123
|
+
// system (for example Xero).
|
|
124
|
+
export function fixes(o, { accountingName = null } = {}) {
|
|
125
|
+
if (invoiceStage(o) !== 'problem') return [];
|
|
126
|
+
const acct = accountingName ?? 'the accounting system';
|
|
127
|
+
const found = [];
|
|
128
|
+
if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r, acct)));
|
|
129
|
+
if (o.view === 'ready') {
|
|
130
|
+
found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
|
|
131
|
+
label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoicing, Rules.',
|
|
132
|
+
})));
|
|
133
|
+
}
|
|
134
|
+
if (o.invoiceStatus === 'void') {
|
|
135
|
+
found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.' });
|
|
136
|
+
}
|
|
137
|
+
found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p, acct)));
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The short words for a job's problems: the first label, and "+N" for more.
|
|
142
|
+
export function problemSummary(found) {
|
|
143
|
+
const labels = [...new Set(found.map((f) => f.label))];
|
|
144
|
+
if (!labels.length) return '';
|
|
145
|
+
return labels.length > 1 ? `${labels[0]} +${labels.length - 1}` : labels[0];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Does a job match the search text? Case, client, practitioner or invoice
|
|
149
|
+
// number; the case is not shown on the board, but a person can find it.
|
|
150
|
+
export function matches(job, q) {
|
|
151
|
+
if (!q) return true;
|
|
152
|
+
const needle = q.toLowerCase();
|
|
153
|
+
return [job.caseRef, job.sourceJobRef, job.clientName, job.workClientName, job.practitioner, job.obligation?.invoiceNumber]
|
|
154
|
+
.some((value) => value && String(value).toLowerCase().includes(needle));
|
|
155
|
+
}
|