@arcforgelabs/flow 2026.10.41 → 2026.10.43

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.
@@ -9,7 +9,7 @@ import { alertStrip } from '/assets/ui/alerts.js';
9
9
  import { bulkBar } from '/assets/ui/bulk-bar.js';
10
10
  import { draftNote, sendModeNote } from '/assets/ui/invoice-notes.js';
11
11
  import { caseNumber } from '/assets/ui/job-board.js';
12
- import { jobChangeBanner } from '/assets/ui/job-change.js';
12
+ import { jobChangeReview } from '/assets/ui/job-change.js';
13
13
  import { findBox } from '/assets/ui/view-bar.js';
14
14
 
15
15
  // The tabs, left to right (server/api/invoicing.js, TABS): Pending (a
@@ -201,7 +201,7 @@ export async function renderInvoiceList(main, ui) {
201
201
  });
202
202
  if (!ok) return;
203
203
  await post('send-ready', { issueDate: localDate(), invoiceIds: ticked },
204
- (res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, 'success']);
204
+ (res) => [`${to ? `${res.issued} on the way to ${to}.` : `${res.issued} approved.`}${res.held ? ` ${res.held} failed a check and went to Pending.` : ''}${res.remaining ? ` ${res.remaining} more stay in Ready: tick them and press again.` : ''}`, res.held ? 'info' : 'success']);
205
205
  },
206
206
  });
207
207
  }
@@ -836,15 +836,12 @@ function draftView(invoice, ui, context, redraw) {
836
836
  dueDate.addEventListener('change', syncIssue);
837
837
  syncIssue();
838
838
 
839
- // A new invoice can start from a job that has no invoice yet: a quiet
840
- // strip above the form says so. Flow drafts it the way the invoicing rules
841
- // do (client, Reference, lines) and links it to the job. An invoice with
842
- // no job is fine too.
843
- const fromJob = isNew && context.canIssue ? h('section', { class: 'fx-fromjob' },
844
- h('div', { class: 'fx-fromjob__text' },
845
- h('span', { class: 'fx-strong' }, 'Billing a job?'),
846
- h('span', { class: 'fx-dim' }, 'Flow fills in the details.')),
847
- h('button', { class: 'fx-btn', type: 'button', onclick: () => pickJob(ui) }, 'Choose a job')) : null;
839
+ // A new invoice can start from a job that has no invoice yet: a button in
840
+ // the header. Flow drafts it the way the invoicing rules do (client,
841
+ // Reference, lines) and links it to the job. An invoice with no job is
842
+ // fine too.
843
+ const fromJob = isNew && context.canIssue
844
+ ? h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', onclick: () => pickJob(ui) }, 'From a job') : null;
848
845
 
849
846
  // A draft with no problem is in Ready by itself; one with a problem is
850
847
  // Pending. A changed job is fixed at its source or accepted here.
@@ -853,47 +850,18 @@ function draftView(invoice, ui, context, redraw) {
853
850
  // changed. Other problems stay one line.
854
851
  const otherProblems = (invoice?.problems ?? []).filter((p) => !(jobChange && p.code === 'job_review'));
855
852
  const problems = [
856
- jobChange ? jobChangeBanner(ui, jobChange, {
857
- money: formatCents,
858
- // Accepting changes the draft for the business: owners and admins only.
859
- onAccept: !context.canIssue ? null : async () => {
860
- const system = jobChange.systemName;
861
- const lost = [jobChange.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, changed() ? 'Unsaved changes are lost.' : null].filter(Boolean);
862
- if (lost.length) {
863
- const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
864
- if (!ok) return;
865
- }
866
- try {
867
- const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
868
- toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
869
- redraw(res.invoice);
870
- } catch (error) {
871
- toast(error.message, 'danger');
872
- }
873
- },
874
- // Reads this one job from the work system again, then every check runs.
875
- onRecheck: async () => {
876
- try {
877
- const res = await flow(`/api/invoicing/jobs/${jobChange.jobId}/recheck`, { method: 'POST', body: '{}' });
878
- toast(res.invoice?.jobChange ? `Still changed in ${jobChange.systemName}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
879
- if (res.invoice) redraw(res.invoice);
880
- } catch (error) {
881
- toast(error.message, 'danger');
882
- }
883
- },
884
- }) : null,
853
+ // Accepting changes the draft for the business: owners and admins only.
854
+ jobChange ? jobChangeReview(ui, jobChange, { money: formatCents, canAccept: context.canIssue, unsaved: changed, done: redraw }) : null,
885
855
  otherProblems.length ? notice(otherProblems.map((p) => p.message).join(' '), 'danger', 'Problem.') : null,
886
856
  ];
887
857
 
888
858
  return [
889
859
  navBar(ui, invoice),
890
- h('header', { class: 'fx-ph' },
891
- h('div', { class: 'fx-ph__text' },
892
- h('h1', { class: 'fx-title' }, isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice'),
893
- h('p', { class: 'fx-ph__sub' }, draftNote(to, invoice, ready))),
894
- h('div', { class: 'fx-ph__actions' }, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton)),
860
+ ui.pageHeader(isNew ? 'New invoice' : ready ? 'Ready invoice' : 'Pending invoice', {
861
+ actions: [fromJob, isNew ? null : previewButton(ui, invoice.id, 'Shows the saved invoice.'), saveButton, sendButton ?? issueButton].filter(Boolean),
862
+ }),
863
+ draftNote(to, invoice) ? ui.notice(draftNote(to, invoice), 'warn') : null,
895
864
  ...problems,
896
- fromJob,
897
865
  h('div', { class: 'fx-invoice__grid' },
898
866
  h('div', { class: 'fx-invoice__main' },
899
867
  h('section', { class: 'fx-card fx-invoice__head' },
@@ -999,10 +967,8 @@ function issuedView(invoice, ui, context, redraw) {
999
967
  navBar(ui, invoice),
1000
968
  h('header', { class: 'fx-ph' },
1001
969
  h('div', { class: 'fx-ph__text' },
1002
- h('div', { class: 'fx-row' },
1003
- h('h1', { class: 'fx-title' }, invoice.number),
1004
- h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1005
- h('p', { class: 'fx-ph__sub' }, `${invoice.contact?.name ?? ''} · ${showDate(invoice.issueDate)}`)),
970
+ h('h1', { class: 'fx-title' }, invoice.number),
971
+ h('span', { class: 'fx-status', 'data-tone': TAB_TONE[invoice.tab] }, TAB_LABEL[invoice.tab] ?? 'Approved')),
1006
972
  h('div', { class: 'fx-ph__actions' }, actions, previewButton(ui, invoice.id), send)),
1007
973
  h('div', { class: 'fx-invoice__grid' },
1008
974
  h('div', { class: 'fx-invoice__main' },
@@ -1072,7 +1038,7 @@ function xeroCard(invoice, ui, context, status, reload) {
1072
1038
  }
1073
1039
 
1074
1040
  // Send to Xero (POST /api/xero/invoices/push). The customer's send mode
1075
- // (Settings, Invoice rules) decides what Xero gets: a draft that a person approves
1041
+ // (Settings, Rules) decides what Xero gets: a draft that a person approves
1076
1042
  // there, or an approved invoice that Xero emails to the customer.
1077
1043
  function sendToXero(invoice, ui, context) {
1078
1044
  const { h, flow, toast } = ui;
@@ -93,7 +93,10 @@ function reasonFix(reason) {
93
93
  if (reason === 'the work system report is out of date') {
94
94
  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.', go: 'integrations' };
95
95
  }
96
- if (reason === 'the job changed after its invoice was made') return fix('Job changed', 'Open the invoice to see what changed. Fix the job in the work system, or adopt its changes on the draft.', 'invoice');
96
+ // Review opens the job's own sidecar: what changed, and Accept change.
97
+ if (reason === 'the job changed after its invoice was made') {
98
+ return { label: 'Job changed', text: 'Changed after the draft was made.', todo: 'Fix it in the work system, or accept the change.', go: 'review' };
99
+ }
97
100
  if (reason === 'the job status moved back after its invoice was made') return fix('Status moved back', 'Check the job in the work system. Void or hold its invoice if the work is not done.', 'work');
98
101
  if (/deleted in /i.test(reason)) return fix('Deleted', 'Check the job in the work system. Void its invoice if it has one.', 'work');
99
102
  // The matcher found invoices that may be this job's, but no exact link.
@@ -134,27 +137,36 @@ function draftFix(problem) {
134
137
  }
135
138
 
136
139
  // Where a fix's link goes: Flow's invoice, the job in the work system, a
137
- // Flow settings page, or `choose`: Make invoice and Link invoice buttons
138
- // (jobs.js draws them).
139
- export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'choose'];
140
+ // Flow settings page, `review`: the job's Review sidecar, or `choose`: Make
141
+ // invoice and Link invoice buttons (jobs.js draws them).
142
+ export const GO = ['invoice', 'work', 'price-list', 'invoicing-settings', 'integrations', 'review', 'choose'];
140
143
 
141
144
  // What a person must fix on a job, in order: [{ label, text, todo, go }];
142
145
  // `todo` is null when the buttons are the instruction.
143
- // Empty unless invoiceStage is Problem.
146
+ // Empty unless invoiceStage is Problem. One fact is one fix: a job in review
147
+ // already says why, so its draft's copy of that hold (sending.js,
148
+ // `job_review`) is left out, and two fixes with the same words show once.
144
149
  export function fixes(o) {
145
150
  if (invoiceStage(o) !== 'problem') return [];
146
151
  const found = [];
147
152
  if (PROBLEM_VIEWS.includes(o.view)) found.push(...(o.reasons ?? []).map((r) => reasonFix(r)));
148
153
  if (o.view === 'ready') {
149
154
  found.push(...(o.waitingFor ?? []).filter(isRule).map((r) => ({
150
- label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Invoice rules.', go: 'invoicing-settings',
155
+ label: 'Stopped by a rule', text: sentence(r), todo: 'Draft the invoice here, or change the rule under Settings, Rules.', go: 'invoicing-settings',
151
156
  })));
152
157
  }
153
158
  if (o.invoiceStatus === 'void') {
154
159
  found.push({ label: 'Invoice void', text: 'The invoice for this job is void.', todo: 'Make a new invoice, or close the job.', go: 'invoice' });
155
160
  }
156
- found.push(...(o.invoiceProblems ?? []).map((p) => draftFix(p)));
157
- return found;
161
+ const held = o.view === 'review_required' && o.holdState === 'review';
162
+ found.push(...(o.invoiceProblems ?? []).filter((p) => !(held && p.code === 'job_review')).map((p) => draftFix(p)));
163
+ const seen = new Set();
164
+ return found.filter((f) => {
165
+ const key = `${f.label}\u0000${f.text}`;
166
+ if (seen.has(key)) return false;
167
+ seen.add(key);
168
+ return true;
169
+ });
158
170
  }
159
171
 
160
172
  // The short words for a job's problems: the first label, and "+N" for more.
@@ -87,3 +87,39 @@ export function jobChangeBanner(ui, change, { money, onAccept = null, onRecheck
87
87
  body)) : null,
88
88
  more);
89
89
  }
90
+
91
+ // The banner with Accept change and the recheck wired, for the invoice page
92
+ // and a job's Review sidecar (jobs.js). `canAccept`: the person may change
93
+ // drafts (owners and admins). `unsaved()`: edits on the page that Accept
94
+ // would lose. `done(invoice)` hears the invoice after Accept or a recheck.
95
+ export function jobChangeReview(ui, change, { money, canAccept = false, unsaved = () => false, done = () => {} }) {
96
+ const { h, flow, toast } = ui;
97
+ const system = change.systemName;
98
+ return jobChangeBanner(ui, change, {
99
+ money,
100
+ onAccept: !canAccept ? null : async () => {
101
+ const lost = [change.outcome === 'conflict' ? 'Your own line changes here are replaced.' : null, unsaved() ? 'Unsaved changes are lost.' : null].filter(Boolean);
102
+ if (lost.length) {
103
+ const ok = await ui.dialog({ title: 'Accept change?', body: h('p', {}, lost.join(' ')), confirm: 'Accept change' });
104
+ if (!ok) return;
105
+ }
106
+ try {
107
+ const res = await flow(`/api/invoicing/jobs/${change.jobId}/use-new-lines`, { method: 'POST', body: JSON.stringify({ accept: true }) });
108
+ toast(`Matches ${system}.${res.invoice.view === 'ready' ? ' In Ready.' : ''}`, 'success');
109
+ await done(res.invoice);
110
+ } catch (error) {
111
+ toast(error.message, 'danger');
112
+ }
113
+ },
114
+ // Reads this one job from the work system again, then every check runs.
115
+ onRecheck: async () => {
116
+ try {
117
+ const res = await flow(`/api/invoicing/jobs/${change.jobId}/recheck`, { method: 'POST', body: '{}' });
118
+ toast(res.invoice?.jobChange ? `Still changed in ${system}.` : 'Fixed. It matches now.', res.invoice?.jobChange ? 'danger' : 'success');
119
+ if (res.invoice) await done(res.invoice);
120
+ } catch (error) {
121
+ toast(error.message, 'danger');
122
+ }
123
+ },
124
+ });
125
+ }
package/server/ui/jobs.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { bulkBar } from '/assets/ui/bulk-bar.js';
16
16
  import { lucide } from '/assets/ui/custom-fields.js';
17
17
  import { formatCents } from '/assets/ui/invoicing.js';
18
+ import { jobChangeReview } from '/assets/ui/job-change.js';
18
19
  import {
19
20
  caseNumber, FINAL_RANK, fixes, INVOICE_STAGES, invoiceStage, matches, NO_STATUS, orderStatuses, problemSummary, statusRank,
20
21
  } from '/assets/ui/job-board.js';
@@ -22,10 +23,14 @@ import {
22
23
  COLUMNS, DEFAULT_VIEW, fieldGroup, fieldIdOf, fieldLanes, FILTER_FIELDS, filterJobs, groupJobs, GROUPS, KIND_OF_TYPE, keptStatuses, laneOf,
23
24
  normaliseJobsView, NOT_SET, OPERATORS, sortJobs, SORTS,
24
25
  } from '/assets/ui/job-view.js';
26
+ import { sidecar } from '/assets/ui/sidecar.js';
25
27
  import { findBox, ICONS, svg, today, viewBar } from '/assets/ui/view-bar.js';
26
28
 
27
29
  const LAYOUT_KEY = 'flow_jobs_layout';
28
30
  const FOLDED_KEY = 'flow_jobs_folded';
31
+ // The job panel's sidecar: the tab last open, or closed.
32
+ const SIDE_KEY = 'flow_job_side';
33
+ const SIDE_TABS = ['activity', 'review', 'closed'];
29
34
  const SYSTEM_NAME = { seazona: 'Seazona', xero: 'Xero' };
30
35
  // A job's events (server/core/ledger.js), in plain words.
31
36
  const ACTIVITY = {
@@ -85,6 +90,17 @@ function saveFolded(folded) {
85
90
  try { localStorage.setItem(FOLDED_KEY, JSON.stringify(folded)); } catch { /* a convenience only */ }
86
91
  }
87
92
 
93
+ function readSide() {
94
+ try {
95
+ const value = localStorage.getItem(SIDE_KEY);
96
+ return SIDE_TABS.includes(value) ? value : 'activity';
97
+ } catch { return 'activity'; }
98
+ }
99
+
100
+ function saveSide(tab) {
101
+ try { localStorage.setItem(SIDE_KEY, tab ?? 'closed'); } catch { /* a convenience only */ }
102
+ }
103
+
88
104
 
89
105
  const WARN_ICON = '<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 1.5 15 14H1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 6v3.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="8" cy="11.6" r=".9" fill="currentColor"/></svg>';
90
106
  const PERSON_ICON = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="5.5" r="3" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2.5 14c.6-2.8 2.8-4.3 5.5-4.3s4.9 1.5 5.5 4.3" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>';
@@ -116,9 +132,8 @@ export async function renderJobs(main, ui) {
116
132
  const controls = h('div', { class: 'fx-viewbar', role: 'group', 'aria-label': 'Group, sort and filter' });
117
133
  // In the list, ticked rows get the same floating bar as Invoices.
118
134
  const bulk = bulkBar(ui, { label: 'Ticked jobs' });
119
- main.classList.add('fx-main--wide');
120
135
  main.append(
121
- pageHeader('Jobs', { subtitle: 'Every job in the lab system, by its status there.' }),
136
+ pageHeader('Jobs'),
122
137
  h('div', { class: 'fx-toolbar fx-toolbar--plain' }, controls, h('div', { class: 'fx-toolbar__end' }, find.element, toggle)),
123
138
  host,
124
139
  bulk.element);
@@ -1086,25 +1101,8 @@ export async function renderJobs(main, ui) {
1086
1101
  if (!o) return [];
1087
1102
  if (o.invoiceId) {
1088
1103
  const label = o.invoiceStatus === 'draft' ? 'Open draft invoice' : `Open invoice ${o.invoiceNumber ?? ''}`.trim();
1089
- const open = h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label);
1090
- // Only the job changed and the draft is not sent (ledger.js): one tap
1091
- // puts the job's new lines on Flow's own draft. Nothing goes elsewhere.
1092
- if (!o.canUseNewLines) return [open];
1093
- const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
1094
- return [h('button', {
1095
- class: 'fx-btn fx-btn--primary', type: 'button',
1096
- onclick: async (event) => {
1097
- event.currentTarget.disabled = true;
1098
- try {
1099
- await flow(`/api/invoicing/jobs/${o.id}/use-new-lines`, { method: 'POST', body: '{}' });
1100
- ui.toast(`The draft invoice now matches ${system}.`, 'success');
1101
- } catch (error) {
1102
- ui.toast(error.message, 'danger');
1103
- }
1104
- close();
1105
- await load();
1106
- },
1107
- }, `Adopt ${system} changes`), open];
1104
+ // A changed job is accepted in the panel's Review sidecar.
1105
+ return [h('a', { class: 'fx-btn', href: `/invoices/${o.invoiceId}`, onclick: (event) => { close(); ui.go(event); } }, label)];
1108
1106
  }
1109
1107
  // Flow's own stage: a person marks a job ready to invoice, or an
1110
1108
  // automation does (Settings, Automations).
@@ -1147,15 +1145,20 @@ export async function renderJobs(main, ui) {
1147
1145
 
1148
1146
  // The job's page: a large panel over the board, like a task page. A
1149
1147
  // breadcrumb, the client's name, the stage, the job's fields, what to fix
1150
- // and its lines; its activity at the side (below on a phone). Esc, the X
1151
- // or a click outside closes it. `atFix` scrolls to what to fix. The job's
1152
- // custom fields can be changed here (in Flow only), and its stage moved
1153
- // (a hand-off to the work system).
1148
+ // and its lines. At the side (below on a phone), the sidecar: Activity,
1149
+ // and Review when the job changed after its draft (what changed, and
1150
+ // Accept change). The sidecar closes, and the main area takes its room.
1151
+ // Esc, the X or a click outside closes the panel. `atFix` scrolls to what
1152
+ // to fix, and opens Review when there is one. The job's custom fields can
1153
+ // be changed here (in Flow only), and its stage moved (a hand-off to the
1154
+ // work system).
1154
1155
  function openJob(job, { atFix = false } = {}) {
1155
1156
  const o = job.obligation;
1156
1157
  const stage = invoiceStage(o);
1157
1158
  const found = problemsOf(job);
1158
1159
  const choose = found.find((f) => f.go === 'choose') ?? null;
1160
+ // The job changed after its draft: the sidecar has a Review tab.
1161
+ const reviewable = Boolean(o?.invoiceId) && found.some((f) => f.go === 'review');
1159
1162
  const system = SYSTEM_NAME[job.sourceSystem] ?? job.sourceSystem;
1160
1163
  const status = job.status ?? NO_STATUS;
1161
1164
  const late = isLate(job);
@@ -1170,9 +1173,14 @@ export async function renderJobs(main, ui) {
1170
1173
  : choose ? h('span', { class: 'fx-task__none' }, svg(WARN_ICON), 'Needs invoice')
1171
1174
  : stage ? INVOICE_STAGES[stage] : o ? 'Not ready to invoice' : 'No invoice yet';
1172
1175
  const rest = found.filter((f) => f.go !== 'choose');
1176
+ // One short line a fix: its label, what is wrong, and its link. What to
1177
+ // do is the link's tip.
1173
1178
  const fixBox = rest.length ? h('section', { class: 'fx-task__fix', 'aria-label': 'Fix' },
1174
1179
  h('h3', {}, 'Fix'),
1175
- rest.map((f) => h('div', { class: 'fx-task__fixrow' }, h('span', {}, [f.text, f.todo].filter(Boolean).join(' ')), fixLink(f.go)))) : null;
1180
+ rest.map((f) => {
1181
+ const go = fixLink(f.go, f.todo);
1182
+ return h('div', { class: 'fx-task__fixrow', title: go ? null : f.todo }, h('strong', {}, f.label), h('span', {}, f.text), go);
1183
+ })) : null;
1176
1184
  // No invoice, and the match is unclear: one line and two buttons. The
1177
1185
  // possible matches are compared on the Reconcile page, not here.
1178
1186
  const chooseBox = choose ? h('section', { class: 'fx-task__choose', 'aria-label': 'Invoice' },
@@ -1200,8 +1208,12 @@ export async function renderJobs(main, ui) {
1200
1208
  }
1201
1209
  // Each fix's link: Flow's own invoice, the job in the work system, or
1202
1210
  // the Flow page where the fix is made (job-board.js GO).
1203
- function fixLink(go) {
1204
- const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, onclick: (event) => { close(); ui.go(event); } }, text);
1211
+ function fixLink(go, tip = null) {
1212
+ const inFlow = (href, text) => h('a', { class: 'fx-task__link', href, title: tip, onclick: (event) => { close(); ui.go(event); } }, text);
1213
+ if (go === 'review' && reviewable) {
1214
+ return h('button', { class: 'fx-task__link fx-task__linkbtn', type: 'button', title: tip, onclick: () => side.open('review') }, 'Review');
1215
+ }
1216
+ if (go === 'review' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
1205
1217
  if (go === 'invoice' && o?.invoiceId) return inFlow(`/invoices/${o.invoiceId}`, 'Open invoice');
1206
1218
  if (go === 'work' && job.link?.url) {
1207
1219
  return h('a', { class: 'fx-task__link', href: job.link.url, target: '_blank', rel: 'noopener' }, job.link.exact ? `Open case in ${system} ↗` : `Open ${system} ↗`);
@@ -1213,12 +1225,55 @@ export async function renderJobs(main, ui) {
1213
1225
  }
1214
1226
  const lines = job.lines ?? [];
1215
1227
  const activity = h('ol', {}, h('li', { class: 'fx-dim' }, 'Loading…'));
1228
+ const activityBody = h('div', { class: 'fx-task__activity' },
1229
+ o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`));
1230
+ // Review: the job's change as the invoice page shows it (job-change.js),
1231
+ // read from the invoice when the sidecar first needs it.
1232
+ const reviewBody = h('div', { class: 'fx-task__review' }, h('p', { class: 'fx-dim' }, 'Loading…'));
1233
+ let reviewRead = false;
1234
+ const readReview = () => {
1235
+ if (reviewRead) return;
1236
+ reviewRead = true;
1237
+ flow(`/api/invoicing/invoices/${o.invoiceId}`).then(({ invoice }) => drawReview(invoice))
1238
+ .catch(() => reviewBody.replaceChildren(h('p', {}, 'Flow could not read the change.')));
1239
+ };
1240
+ function drawReview(invoice) {
1241
+ const change = invoice?.jobChange ?? null;
1242
+ reviewBody.replaceChildren(change
1243
+ ? jobChangeReview(ui, change, { money: formatCents, canAccept: state.canDraft, done: reopen })
1244
+ : h('p', { class: 'fx-dim' }, `The draft matches ${system}.`));
1245
+ }
1246
+ // After Accept change or a recheck: the board reads again, and this
1247
+ // job's panel opens again with its new facts.
1248
+ async function reopen() {
1249
+ await load();
1250
+ const fresh = state.jobs.find((j) => jobKey(j) === jobKey(job));
1251
+ close();
1252
+ if (fresh) openJob(fresh);
1253
+ }
1254
+ const sideButton = h('button', {
1255
+ class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Side panel', title: 'Side panel', 'aria-pressed': 'false',
1256
+ onclick: () => (side.isOpen() ? side.close() : side.open(readSide() === 'review' && reviewable ? 'review' : 'activity')),
1257
+ }, lucide(h, 'panel-right'));
1258
+ const side = sidecar(h, {
1259
+ label: 'Job side panel',
1260
+ tabs: [
1261
+ { id: 'activity', label: 'Activity', icon: 'history', render: () => activityBody },
1262
+ reviewable ? { id: 'review', label: 'Review', icon: 'git-compare', mark: true, render: () => { readReview(); return reviewBody; } } : null,
1263
+ ].filter(Boolean),
1264
+ onChange: (tab) => {
1265
+ saveSide(tab);
1266
+ sideButton.setAttribute('aria-pressed', String(Boolean(tab)));
1267
+ },
1268
+ });
1269
+ side.element.classList.add('fx-task__side');
1216
1270
  element.append(h('div', { class: 'fx-task__grid' },
1217
1271
  h('div', { class: 'fx-task__main' },
1218
1272
  h('header', { class: 'fx-task__head' },
1219
1273
  h('nav', { class: 'fx-task__crumb', 'aria-label': 'Breadcrumb' },
1220
1274
  h('span', {}, 'Jobs'), h('span', { class: 'fx-task__slash', 'aria-hidden': 'true' }, '/'), h('span', {}, status)),
1221
- h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', onclick: close }, '✕')),
1275
+ h('div', { class: 'fx-task__tools' }, sideButton,
1276
+ h('button', { class: 'fx-btn fx-btn--ghost fx-btn--icon', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: close }, '✕'))),
1222
1277
  h('h2', { class: 'fx-task__title' }, clientOf(job)),
1223
1278
  h('div', { class: 'fx-task__stage' }, stagePill(status)),
1224
1279
  h('dl', { class: 'fx-task__fields' },
@@ -1255,13 +1310,14 @@ export async function renderJobs(main, ui) {
1255
1310
  h('td', { class: 'fx-num' }, job.totalCents == null ? '—' : formatCents(job.totalCents)))))
1256
1311
  : h('p', { class: 'fx-dim' }, 'No lines.')),
1257
1312
  h('div', { class: 'fx-task__actions' }, actions(job, close))),
1258
- h('aside', { class: 'fx-task__activity', 'aria-label': 'Activity' },
1259
- h('h3', {}, 'Activity'),
1260
- o ? activity : h('p', { class: 'fx-dim' }, `Flow has no invoice record for this job yet. It makes one when ${system} reports the job complete.`))));
1313
+ side.element));
1261
1314
  element.addEventListener('click', (event) => { if (event.target === element) close(); });
1262
1315
  element.addEventListener('close', () => element.remove());
1263
1316
  document.body.append(element);
1264
1317
  element.showModal();
1318
+ const last = readSide();
1319
+ if (atFix && reviewable) side.open('review');
1320
+ else if (last !== 'closed') side.open(last === 'review' && reviewable ? 'review' : 'activity');
1265
1321
  if (atFix && (chooseBox ?? fixBox)) (chooseBox ?? fixBox).scrollIntoView({ block: 'start' });
1266
1322
  if (!o) return;
1267
1323
  const when = (iso) => new Date(iso).toLocaleString('en-AU', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit' });
@@ -99,6 +99,7 @@ export async function startMonitoring({ flow, person }) {
99
99
  integrations,
100
100
  tracesSampleRate,
101
101
  beforeSendSpan: scrubSpan,
102
+ initialScope: { tags: config.tags ?? {} },
102
103
  });
103
104
  Sentry.getClient()?.on('beforeSendFeedback', tagNotify);
104
105
  // Who reported or met the error, so the lab can follow up.
@@ -29,7 +29,7 @@ export async function renderPriceList(main, ui) {
29
29
  const addButton = h('button', { class: 'fx-btn fx-btn--primary', type: 'button', disabled }, 'Add code');
30
30
  const reconcileButton = h('button', { class: 'fx-btn fx-btn--ghost', type: 'button', hidden: true, onclick: () => ui.go('/price-list/reconcile') }, 'Reconcile');
31
31
  const alerts = alertStrip(ui, 'price-list');
32
- main.append(pageHeader('Price list', { subtitle: 'The codes and prices that Flow puts on invoice lines.', actions: [reconcileButton, addButton] }), alerts.element, host);
32
+ main.append(pageHeader('Price list', { help: { href: DOCS, label: 'How the price list works' }, actions: [reconcileButton, addButton] }), alerts.element, host);
33
33
 
34
34
  let data = null;
35
35
  let query = '';
@@ -176,8 +176,7 @@ export async function renderPriceList(main, ui) {
176
176
  table,
177
177
  ];
178
178
  const settings = h('section', { class: 'fx-set fx-price__settings' }, h('h2', { class: 'fx-set__title' }, 'Invoice lines'), h('div', { class: 'fx-skeleton' }));
179
- linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r,
180
- h('p', { class: 'fx-dim', style: { margin: '8px 0 0' } }, h('a', { href: DOCS, target: '_blank', rel: 'noopener' }, 'How the price list works'))));
179
+ linePriceRow().then((r) => settings.replaceChildren(settings.firstChild, r));
181
180
  sections.push(settings);
182
181
  host.replaceChildren(...sections.filter(Boolean));
183
182
  // Opened from Reconcile's "Edit code": that code's dialog, once.
@@ -26,17 +26,16 @@ export async function renderPriceReconcile(main, ui) {
26
26
  const icon = (name, label) => lucide(h, name, label);
27
27
  let view = new URLSearchParams(location.search).get('view') === 'list' ? 'list' : 'card';
28
28
  const host = h('div', { class: 'fx-xi fx-prc' }, h('p', { class: 'fx-dim' }, 'Loading…'));
29
- const subtitle = h('span', {});
29
+ const tally = h('span', {});
30
30
  const toggle = (name, label, glyph) => h('button', {
31
31
  class: 'fx-xi__view', type: 'button', role: 'tab', 'aria-selected': String(view === name), 'aria-label': label, title: label, 'data-view': name,
32
32
  onclick: () => setView(name),
33
33
  }, icon(glyph));
34
34
  const bulk = bulkBar(ui, { label: 'Ticked records' });
35
35
  main.append(
36
- h('nav', { class: 'fx-crumbs', 'aria-label': 'Breadcrumb' },
37
- h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list'), h('span', { 'aria-hidden': 'true' }, '›'), h('span', {}, 'Reconcile')),
36
+ ui.crumbs(['/price-list', 'Price list'], 'Reconcile'),
38
37
  ui.pageHeader('Reconcile prices', {
39
- subtitle,
38
+ count: tally,
40
39
  actions: [h('div', { class: 'fx-xi__views', role: 'tablist', 'aria-label': 'View' }, toggle('card', 'One at a time', 'square'), toggle('list', 'List, tick many', 'list-checks'))],
41
40
  }),
42
41
  host, bulk.element);
@@ -129,13 +128,13 @@ export async function renderPriceReconcile(main, ui) {
129
128
  if (!data) return;
130
129
  bulk.draw({ count: 0 });
131
130
  if (!data.systems.length) {
132
- subtitle.replaceChildren('');
131
+ tally.replaceChildren('');
133
132
  host.replaceChildren(h('p', { class: 'fx-empty' }, 'No integration with a price list is on. Flow uses its own list alone.'),
134
133
  h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, '← Price list'));
135
134
  return;
136
135
  }
137
136
  if (!rows.length) {
138
- subtitle.replaceChildren('');
137
+ tally.replaceChildren('');
139
138
  host.replaceChildren(h('div', { class: 'fx-xi__done' }, icon('check-check'), h('p', {}, 'Every record has a code, and every price agrees.'),
140
139
  h('a', { href: '/price-list', onclick: (event) => ui.go(event) }, 'Price list')));
141
140
  return;
@@ -184,7 +183,7 @@ export async function renderPriceReconcile(main, ui) {
184
183
  // One record (or one differing code) at a time.
185
184
  function drawCard() {
186
185
  const row = rows[at];
187
- subtitle.replaceChildren(`${rows.length} to check`);
186
+ tally.replaceChildren(`${rows.length} to check`);
188
187
  const next = () => { at = (at + 1) % rows.length; pick = 0; chosen = null; drawCard(); };
189
188
  const record = row.record;
190
189
  const item = row.kind === 'differs' ? row.item : chosen ?? row.candidates[pick]?.item ?? null;
@@ -267,7 +266,7 @@ export async function renderPriceReconcile(main, ui) {
267
266
 
268
267
  // Every record and code to check; ticked rows go from the bulk bar.
269
268
  function drawList() {
270
- subtitle.replaceChildren(`${rows.length} to check`);
269
+ tally.replaceChildren(`${rows.length} to check`);
271
270
  const ticked = new Set();
272
271
  const boxes = new Map();
273
272
  const picked = () => rows.filter((r) => ticked.has(r.key));
@@ -21,7 +21,7 @@ const themeDir = join(here, '..', 'theme');
21
21
  const logoDir = join(here, 'logos');
22
22
  const LOGO_FILES = new Set(['xero.svg', 'seazona.svg', 'sentry.svg']);
23
23
  const THEME_FILES = new Set(['themes.js', 'color-contrast.js', 'openclaw-palettes.js', 'font-families.js']);
24
- const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'flow-mark.svg']);
24
+ const UI_FILES = new Set(['flow.css', 'appearance.js', 'clerk-appearance.js', 'app.js', 'invoicing.js', 'invoice-notes.js', 'job-change.js', 'sidecar.js', 'invoicing-settings.js', 'automations.js', 'invoice-rules.js', 'line-mappings.js', 'price-list.js', 'price-match.js', 'price-reconcile.js', 'custom-fields.js', 'jobs.js', 'job-board.js', 'reconcile.js', 'xero-import.js', 'job-view.js', 'monitoring.js', 'search.js', 'alerts.js', 'bulk-bar.js', 'view-bar.js', 'tip.js', 'flow-mark.svg']);
25
25
  const iconDir = join(dirname(require.resolve('lucide-static/package.json')), 'icons');
26
26
  const ICON_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.svg$/;
27
27
  const MAX_AGE = '1h';
@@ -0,0 +1,72 @@
1
+ // The sidecar, as in ClickUp: a panel at the right of a page or a dialog.
2
+ // The Custom fields manager opens it over its table (`overlay`); a job's
3
+ // panel keeps it as its right column, with tabs (Activity, Review). Closed,
4
+ // it takes no room, so the main area gets the full width.
5
+ //
6
+ // sidecar(h, { label, overlay, escape, tabs, onChange }) answers
7
+ // { element, isOpen, open, show, close, tab, setTabs }:
8
+ // open(id) selects tab `id` (or the first) and draws its `render()`
9
+ // show(node) draws one node with no tabs (the overlay's own form)
10
+ // close() hides it and empties it
11
+ // `tabs` is [{ id, label, icon, render }]; each tab is an icon and one word.
12
+ // `escape` closes it on Esc when no dialog or menu is open over the page.
13
+ // `onChange(id)` hears each open (the tab id) and close (null).
14
+
15
+ import { lucide } from '/assets/ui/custom-fields.js';
16
+
17
+ export function sidecar(h, { label, overlay = false, escape = false, tabs = [], onChange = null } = {}) {
18
+ const element = h('aside', { class: `fx-sidecar${overlay ? ' fx-sidecar--over' : ''}`, 'aria-label': label, hidden: true });
19
+ let list = tabs;
20
+ let current = null;
21
+
22
+ const closeButton = () => h('button', { class: 'fx-sidecar__close', type: 'button', 'aria-label': 'Close', title: 'Close', onclick: () => api.close() }, lucide(h, 'x'));
23
+
24
+ function drawTabs() {
25
+ const head = h('header', { class: 'fx-sidecar__head' },
26
+ h('div', { class: 'fx-sidecar__tabs', role: 'tablist', 'aria-label': label },
27
+ list.map((t) => h('button', {
28
+ class: 'fx-sidecar__tab', type: 'button', role: 'tab', 'data-tab': t.id,
29
+ 'aria-selected': String(t.id === current), onclick: () => api.open(t.id),
30
+ }, lucide(h, t.icon), h('span', {}, t.label), t.mark ? h('span', { class: 'fx-sidecar__mark', 'aria-hidden': 'true' }) : null))),
31
+ closeButton());
32
+ const tab = list.find((t) => t.id === current);
33
+ element.replaceChildren(head, h('div', { class: 'fx-sidecar__body', role: 'tabpanel' }, tab?.render?.() ?? null));
34
+ }
35
+
36
+ const api = {
37
+ element,
38
+ isOpen: () => !element.hidden,
39
+ tab: () => (element.hidden ? null : current),
40
+ open(id = null) {
41
+ current = list.find((t) => t.id === id)?.id ?? list[0]?.id ?? null;
42
+ drawTabs();
43
+ element.hidden = false;
44
+ onChange?.(current);
45
+ },
46
+ show(node) {
47
+ current = null;
48
+ element.replaceChildren(node);
49
+ element.hidden = false;
50
+ },
51
+ close() {
52
+ if (element.hidden) return;
53
+ element.hidden = true;
54
+ element.replaceChildren();
55
+ current = null;
56
+ onChange?.(null);
57
+ },
58
+ // New tabs (a tab's data came in): an open tab draws again.
59
+ setTabs(next) {
60
+ list = next;
61
+ if (!element.hidden && current) drawTabs();
62
+ },
63
+ closeButton,
64
+ };
65
+
66
+ if (escape) {
67
+ document.addEventListener('keydown', (event) => {
68
+ if (event.key === 'Escape' && element.isConnected && !element.hidden && !document.querySelector('dialog[open], .fx-pop:popover-open')) api.close();
69
+ });
70
+ }
71
+ return api;
72
+ }