@protoboardai/views 0.0.0-stage → 0.2.1

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.
Files changed (56) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +21 -2
  4. package/package.json +57 -4
  5. package/src/applies.ts +54 -0
  6. package/src/builtins.tsx +89 -0
  7. package/src/drawing/BoardDrawingView.tsx +515 -0
  8. package/src/drawing/CircuitPcbView.tsx +494 -0
  9. package/src/drawing/DrawingControls.tsx +39 -0
  10. package/src/drawing/boards.ts +82 -0
  11. package/src/drawing/circuit.ts +413 -0
  12. package/src/drawing/kicadSchematic.ts +277 -0
  13. package/src/drawing/palettes.ts +150 -0
  14. package/src/drawing/svg.ts +445 -0
  15. package/src/drawing/usePanZoom.ts +400 -0
  16. package/src/icons.tsx +69 -0
  17. package/src/index.ts +33 -0
  18. package/src/inspector/DrcPanel.tsx +302 -0
  19. package/src/inspector/SelectionInspector.tsx +392 -0
  20. package/src/inspector/index.ts +9 -0
  21. package/src/inspector/model.ts +224 -0
  22. package/src/pair/PairView.tsx +314 -0
  23. package/src/pair/model.ts +206 -0
  24. package/src/parts/BomEditor.tsx +564 -0
  25. package/src/parts/CardPages.tsx +317 -0
  26. package/src/parts/CategoryList.tsx +72 -0
  27. package/src/parts/CloudPanel.tsx +160 -0
  28. package/src/parts/GlbPreview.tsx +116 -0
  29. package/src/parts/LibrariesManager.tsx +251 -0
  30. package/src/parts/LibraryBrowser.tsx +191 -0
  31. package/src/parts/LibraryPage.tsx +168 -0
  32. package/src/parts/PartCard.tsx +274 -0
  33. package/src/parts/PartDetail.tsx +613 -0
  34. package/src/parts/PartStatus.tsx +54 -0
  35. package/src/parts/Prices.tsx +241 -0
  36. package/src/parts/ProjectParts.tsx +174 -0
  37. package/src/parts/ProjectPartsPage.tsx +264 -0
  38. package/src/parts/SettingsPage.tsx +268 -0
  39. package/src/parts/Surface.tsx +51 -0
  40. package/src/parts/Windowed.tsx +70 -0
  41. package/src/parts/carousel.tsx +344 -0
  42. package/src/parts/index.ts +32 -0
  43. package/src/parts/library-data.ts +726 -0
  44. package/src/parts/matte.ts +69 -0
  45. package/src/parts/model.ts +590 -0
  46. package/src/parts/part-pages.ts +379 -0
  47. package/src/parts/part-status.ts +88 -0
  48. package/src/parts/revision.ts +11 -0
  49. package/src/parts/three.d.ts +5 -0
  50. package/src/parts/ui.tsx +335 -0
  51. package/src/parts/views.tsx +433 -0
  52. package/src/registry.ts +107 -0
  53. package/src/tabs.ts +118 -0
  54. package/src/theme.ts +225 -0
  55. package/src/types.ts +93 -0
  56. package/src/ui/Select.tsx +99 -0
@@ -0,0 +1,564 @@
1
+ /**
2
+ * The bill of materials, in its own tab (PB-904, PB-933; docs/specs/bom.md).
3
+ * Styled after the Alpha's: summary cards over a table with labelled
4
+ * columns, a supplier chip per line that opens the offers held, a stock
5
+ * badge, the pack arithmetic in the open (what is bought against what is
6
+ * needed, with a warning where pieces are left over), the order cost in the
7
+ * accent colour, and a totals row.
8
+ *
9
+ * The model is core's structured bill (`@protoboardai/core/bom`); the view
10
+ * re-prices it itself when the reader changes the units to build or a
11
+ * line's offer, and tells the host so the project keeps the choice. One
12
+ * currency, dollars: a converted price carries its supplier's currency and
13
+ * the rate in a tooltip. How the library came by a price is not shown; the
14
+ * view shows the library's results.
15
+ *
16
+ * Narrow (a side panel, a small window): the table becomes a list of lines,
17
+ * each on three short rows, so nothing is cut off and nothing scrolls
18
+ * sideways.
19
+ */
20
+ import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
21
+ import { bomChoices, formatUnitUsd, formatUsd, repriceBom, type Bom, type BomGroup, type BomLine, type BomOffer, type BomQuote } from "@protoboardai/core/bom";
22
+ import { EmptyState, Icon, IconButton, ScrollArea, cx } from "@deltarobotics/uhd-viewer/components";
23
+ import { Select } from "../ui/Select.js";
24
+ import type { BomActions } from "./model.js";
25
+ import { PartsSurface } from "./Surface.js";
26
+ import { BODY, Card, RowAction, StatusText, type Tone } from "./ui.js";
27
+
28
+ /** "$12.40": every amount of a bill is in dollars. */
29
+ export const formatMoney = (amount: number, _currency = "USD") => formatUsd(amount);
30
+
31
+ /** "Oct 3": the day a price was observed. */
32
+ export function shortDay(iso: string | undefined): string | undefined {
33
+ if (!iso || Number.isNaN(Date.parse(iso))) return undefined;
34
+ return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
35
+ }
36
+
37
+ /** "read Oct 3 · fresh", or how old the newest price is. */
38
+ export function offersAge(asOf: string | undefined, now = new Date()): { date: string; fresh: string } | undefined {
39
+ const date = shortDay(asOf);
40
+ if (!date) return undefined;
41
+ const days = Math.floor((now.getTime() - new Date(asOf!).getTime()) / 86400000);
42
+ return { date, fresh: days <= 14 ? "fresh" : `${days} d old` };
43
+ }
44
+
45
+ type GroupBy = "module" | "supplier" | "none";
46
+ const GROUP_ICON: Record<GroupBy, string> = { module: "boxes", supplier: "store", none: "list" };
47
+ const GROUP_NOW: Record<GroupBy, string> = { module: "Grouped by module", supplier: "Grouped by supplier", none: "Not grouped" };
48
+ const GROUP_NEXT: Record<GroupBy, string> = { module: "by supplier", supplier: "not at all", none: "by module" };
49
+
50
+ const supplierOf = (l: BomLine) => l.offers.find((o) => o.id === l.chosen)?.supplier;
51
+
52
+ function regroup(bom: Bom, by: GroupBy): BomGroup[] {
53
+ if (by === "module") return bom.groups;
54
+ const lines = bom.groups.flatMap((g) => g.lines);
55
+ const sum = (list: BomLine[]) => Math.round(list.reduce((s, l) => s + (l.orderCost ?? 0), 0) * 100) / 100;
56
+ if (by === "none") return [{ path: "", name: "All lines", quantity: 1, lines, orderCost: sum(lines) }];
57
+ const map = new Map<string, BomLine[]>();
58
+ for (const l of lines) {
59
+ const k = supplierOf(l) ?? (l.status === "make" ? "Made for this product" : l.status === "included" ? "Included with other parts" : "No price yet");
60
+ map.set(k, [...(map.get(k) ?? []), l]);
61
+ }
62
+ return [...map].map(([name, list]) => ({ path: name, name, quantity: 1, lines: list, orderCost: sum(list) }));
63
+ }
64
+
65
+ const STATUS: Record<Exclude<BomLine["status"], "priced">, { tone: Tone; label: string }> = {
66
+ unpriced: { tone: "warning", label: "Unpriced" },
67
+ make: { tone: "info", label: "Needs quote" },
68
+ included: { tone: "neutral", label: "Included" },
69
+ };
70
+
71
+ /** The Alpha's stock badge: in stock, limited (some, not enough for the order), out, or not stated. */
72
+ function stockOf(o: BomOffer | undefined, q: BomQuote | undefined): { tone: Tone; label: string; tip: string } | undefined {
73
+ if (!o || !q) return undefined;
74
+ if (typeof o.stock !== "number") return { tone: "neutral", label: "Not stated", tip: `${o.supplier} did not state its stock` };
75
+ const tip = `${o.supplier} showed ${o.stock.toLocaleString("en-US")} in stock${shortDay(o.observedAt) ? ` on ${shortDay(o.observedAt)}` : ""}`;
76
+ if (o.stock <= 0) return { tone: "error", label: "Out of stock", tip };
77
+ if (o.stock < q.buy) return { tone: "warning", label: "Limited", tip: `${tip}; the order is ${q.buy}` };
78
+ return { tone: "ok", label: "In stock", tip };
79
+ }
80
+
81
+ function StockBadge({ line }: { line: BomLine }) {
82
+ const s = stockOf(line.offers.find((o) => o.id === line.chosen), line.quote);
83
+ if (!s) return <span className="text-fg-muted">—</span>;
84
+ return (
85
+ <span data-tooltip={s.tip} className="inline-flex h-[20px] max-w-full items-center gap-1.5 rounded-[5px] bg-muted px-1.5 text-[11.5px] whitespace-nowrap text-fg-muted outline-1 -outline-offset-1 outline-contrast">
86
+ <span aria-hidden className={cx("size-1.5 shrink-0 rounded-full", s.tone === "ok" ? "bg-ok" : s.tone === "warning" ? "bg-warning" : s.tone === "error" ? "bg-error" : "bg-fg-muted")} />
87
+ <span className="truncate">{s.label}</span>
88
+ </span>
89
+ );
90
+ }
91
+
92
+ /** One offer in the supplier list: who, what the order of this line costs there, and what to know. */
93
+ function offerLabel(o: BomOffer, q: BomQuote): ReactNode {
94
+ const marks = [q.native ? `from ${q.native.currency}` : "", q.packSize > 1 ? `pack of ${q.packSize}` : q.minOrder > 1 ? `min ${q.minOrder}` : "", q.stale ? "stale" : "", q.fit === "reel" || q.fit === "reel-over" ? "full reel" : "", o.stock === 0 ? "out of stock" : ""].filter(Boolean);
95
+ return (
96
+ <span className="flex min-w-0 items-baseline gap-3">
97
+ <span className="min-w-0 truncate">{o.supplier}</span>
98
+ <span className="ml-auto shrink-0 text-fg-muted">{q.unitPrice !== undefined ? `${formatUnitUsd(q.unitPrice)} × ${q.buy} = ${formatUsd(q.orderCost!)}` : q.problem === "no-rate" ? `no rate for ${o.currency}` : "no price"}</span>
99
+ {marks.length > 0 && <span className="shrink-0 text-[11px] text-fg-muted">{marks.join(" · ")}</span>}
100
+ </span>
101
+ );
102
+ }
103
+
104
+ const AUTO = "\u0000auto";
105
+ const CHIP = "inline-flex h-[22px] max-w-full min-w-0 items-center gap-1 rounded-[5px] border border-[var(--pb-control-border)] px-1.5 text-[11.5px] text-fg";
106
+
107
+ /** The supplier chip: the supplier used, and the offers held to choose from. */
108
+ function SupplierChip({ line, onChoose }: { line: BomLine; onChoose?: (offer: string | null) => void }) {
109
+ const o = line.offers.find((x) => x.id === line.chosen);
110
+ if (line.kind !== "buy") return <span className="text-fg-muted">—</span>;
111
+ if (!line.offers.length)
112
+ return line.listing ? (
113
+ <span className={cx(CHIP, "border-dashed text-fg-muted")} data-tooltip={`No price held. The part's model cites a listing at ${line.listing.supplier}.`}>
114
+ <span className="truncate">{line.listing.supplier}</span>
115
+ </span>
116
+ ) : (
117
+ <span className="text-[11.5px] text-fg-muted" data-tooltip="No offer yet. Use Refresh offers.">
118
+ No offers
119
+ </span>
120
+ );
121
+ const name = o?.supplier ?? (line.quotes.some((q) => q.unitPrice !== undefined) ? "Choose an offer" : "No price held");
122
+ const pinned = line.choice === "pinned";
123
+ const body = (
124
+ <>
125
+ {pinned && <Icon name="pin" size={11} strokeWidth={2} className="shrink-0 text-accent" aria-hidden />}
126
+ <span className="truncate">{name}</span>
127
+ </>
128
+ );
129
+ if (!onChoose || (line.offers.length === 1 && !pinned && o)) return <span className={CHIP} data-tooltip={`${name}: the only offer held`}>{body}</span>;
130
+ const options = [
131
+ ...(pinned ? [{ value: AUTO, label: <span className="text-fg-muted">Best offer by the rules</span>, text: name }] : []),
132
+ ...line.quotes.map((q) => ({ value: q.offer, label: offerLabel(line.offers.find((x) => x.id === q.offer)!, q), text: line.offers.find((x) => x.id === q.offer)!.supplier })),
133
+ ];
134
+ return (
135
+ <Select
136
+ value={line.chosen ?? ""}
137
+ options={options}
138
+ onChange={(v) => onChoose(v === AUTO ? null : v)}
139
+ label={`Supplier for ${line.item}`}
140
+ tooltip={pinned ? `${name}: chosen for this line. ${line.offers.length} offer${line.offers.length === 1 ? "" : "s"} held` : `${name}: the best of ${line.offers.length} offers held. Choose another`}
141
+ className={cx(CHIP, "hover:bg-hover")}
142
+ trigger={
143
+ <>
144
+ {body}
145
+ <Icon name="chevron-down" size={12} className="shrink-0 text-fg-muted" aria-hidden />
146
+ </>
147
+ }
148
+ />
149
+ );
150
+ }
151
+
152
+ /** The unit price, with what a reader should know beside it: converted, stale. */
153
+ function UnitPrice({ line, bom }: { line: BomLine; bom: Bom }) {
154
+ const q = line.quote;
155
+ if (!q || q.unitPrice === undefined) return <span className="text-fg-muted">—</span>;
156
+ return (
157
+ <span className="inline-flex items-center gap-1 whitespace-nowrap">
158
+ <span>{formatUnitUsd(q.unitPrice)}</span>
159
+ {q.native && (
160
+ <span
161
+ data-tooltip={`Converted from ${q.native.unitPrice} ${q.native.currency} at ${q.native.rate}: ${bom.fx.source}, ${bom.fx.date}`}
162
+ aria-label={`converted from ${q.native.currency}`}
163
+ className="inline-flex h-[15px] items-center rounded-[3px] bg-muted px-1 font-ui text-[9.5px] leading-none text-fg-muted outline-1 -outline-offset-1 outline-contrast"
164
+ >
165
+ {q.native.currency}
166
+ </span>
167
+ )}
168
+ {q.stale && (
169
+ <span data-tooltip={q.ageDays !== undefined ? `${q.ageDays} days old. Use Refresh offers.` : "The library marks this price as stale."} aria-label="stale price" className="inline-flex text-warning">
170
+ <Icon name="clock-alert" size={13} aria-hidden />
171
+ </span>
172
+ )}
173
+ </span>
174
+ );
175
+ }
176
+
177
+ /** What is bought against what is needed; a warning where pieces are left over. */
178
+ function Buy({ line }: { line: BomLine }) {
179
+ const q = line.quote;
180
+ if (!q) return <span className="text-fg-muted">{line.kind === "buy" ? line.needed : "—"}</span>;
181
+ const why = q.raised
182
+ ? `at the price break ${q.buy} cost ${formatUsd(q.orderCost!)}, ${q.raised.from} would cost ${formatUsd(q.raised.fromCost)}`
183
+ : q.packSize > 1
184
+ ? `${q.packs} pack${q.packs === 1 ? "" : "s"} of ${q.packSize}`
185
+ : `minimum order ${q.minOrder}`;
186
+ return (
187
+ <span className="inline-flex items-center gap-1 whitespace-nowrap">
188
+ <span>{q.buy}</span>
189
+ {q.surplus > 0 && (
190
+ <span data-tooltip={`Buys ${q.buy} for ${line.needed} needed (${why}): ${q.surplus} left over, ${formatUsd(q.surplus * q.unitPrice!)} of this line`} aria-label={`${q.surplus} left over`} className="inline-flex text-warning">
191
+ <Icon name="triangle-alert" size={13} aria-hidden />
192
+ </span>
193
+ )}
194
+ </span>
195
+ );
196
+ }
197
+
198
+ const COLS = "grid grid-cols-[minmax(190px,2.6fr)_minmax(120px,1.25fr)_100px_64px_64px_92px_84px_92px_76px] items-center gap-x-3";
199
+ const HEAD = "font-ui text-[10.5px] tracking-[0.1em] text-fg-muted uppercase";
200
+ const NUM_BASE = "justify-self-end text-right font-ui text-[12px]";
201
+ const NUM = `${NUM_BASE} text-fg`;
202
+
203
+ function PartName({ line, actions }: { line: BomLine; actions: BomActions }) {
204
+ const sub = [line.mpn, line.value].filter(Boolean).join(" · ") || line.definition;
205
+ const open = actions.openPart ? () => actions.openPart!(line) : undefined;
206
+ return (
207
+ <span className="flex min-w-0 items-start gap-2">
208
+ <Icon name={line.kind === "make" ? "hammer" : line.kind === "included" ? "package-check" : "package"} size={14} strokeWidth={1.75} className="mt-[3px] shrink-0 text-fg-muted" aria-hidden />
209
+ <span className="flex min-w-0 flex-col">
210
+ {open ? (
211
+ <button type="button" onClick={open} data-tooltip={`Open ${line.item}`} className="min-w-0 cursor-pointer truncate text-left text-[13px] text-fg hover:underline focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus">
212
+ {line.item}
213
+ </button>
214
+ ) : (
215
+ <span className="truncate text-[13px] text-fg">{line.item}</span>
216
+ )}
217
+ <span className="truncate font-ui text-[11px] text-fg-muted">{sub}</span>
218
+ </span>
219
+ </span>
220
+ );
221
+ }
222
+
223
+ function Actions({ line, actions }: { line: BomLine; actions: BomActions }) {
224
+ const o = line.offers.find((x) => x.id === line.chosen);
225
+ const url = o?.url ?? (line.status === "unpriced" ? line.listing?.url : undefined);
226
+ const where = o?.supplier ?? line.listing?.supplier;
227
+ return (
228
+ <span className="flex shrink-0 items-center justify-end">
229
+ {url && actions.openLink && <RowAction icon="external-link" label={`Open at ${where}`} onClick={() => actions.openLink!(url)} />}
230
+ {actions.focus && line.instances.length > 0 && <RowAction icon="crosshair" label={`Show in the viewer (${line.instances.length} place${line.instances.length === 1 ? "" : "s"})`} onClick={() => actions.focus!(line)} />}
231
+ {actions.openRow && line.source && <RowAction icon="file-text" label={`Open ${line.source.file}`} onClick={() => actions.openRow!(line)} />}
232
+ </span>
233
+ );
234
+ }
235
+
236
+ function Quantity({ line, units }: { line: BomLine; units: number }) {
237
+ return (
238
+ <span className="whitespace-nowrap" data-tooltip={units > 1 ? `${line.quantity} per unit × ${units} units = ${line.needed} needed` : `${line.quantity} per unit`}>
239
+ {line.needed}
240
+ </span>
241
+ );
242
+ }
243
+
244
+ function StatusNote({ line }: { line: BomLine }) {
245
+ if (line.status === "priced") return null;
246
+ const s = STATUS[line.status];
247
+ return (
248
+ <span data-tooltip={line.notes.join(". ") || undefined} className="inline-flex min-w-0">
249
+ <StatusText tone={s.tone}>{s.label}</StatusText>
250
+ </span>
251
+ );
252
+ }
253
+
254
+ function TableRow({ line, bom, actions, onChoose }: { line: BomLine; bom: Bom; actions: BomActions; onChoose?: (offer: string | null) => void }) {
255
+ return (
256
+ <div role="row" data-line={line.key} className={cx(COLS, "min-h-[44px] border-t border-line-subtle px-3 py-1.5 hover:bg-hover")}>
257
+ <span role="cell" className="min-w-0">
258
+ <PartName line={line} actions={actions} />
259
+ </span>
260
+ <span role="cell" className="min-w-0">
261
+ {line.status === "priced" || line.kind === "buy" ? <SupplierChip line={line} {...(onChoose ? { onChoose } : {})} /> : <StatusNote line={line} />}
262
+ </span>
263
+ <span role="cell" className="min-w-0">
264
+ {line.status === "priced" ? <StockBadge line={line} /> : line.kind === "buy" ? <StatusNote line={line} /> : <span className="text-fg-muted">—</span>}
265
+ </span>
266
+ <span role="cell" className={NUM}>
267
+ <Quantity line={line} units={bom.units} />
268
+ </span>
269
+ <span role="cell" className={NUM}>
270
+ <Buy line={line} />
271
+ </span>
272
+ <span role="cell" className={NUM}>
273
+ <UnitPrice line={line} bom={bom} />
274
+ </span>
275
+ <span role="cell" className={NUM}>
276
+ {line.perUnitCost !== undefined ? formatUsd(line.perUnitCost) : <span className="text-fg-muted">—</span>}
277
+ </span>
278
+ <span role="cell" className={cx(NUM_BASE, "font-medium text-accent")}>
279
+ {line.orderCost !== undefined ? formatUsd(line.orderCost) : <span className="font-normal text-fg-muted">—</span>}
280
+ </span>
281
+ <span role="cell">
282
+ <Actions line={line} actions={actions} />
283
+ </span>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ function ListRow({ line, bom, actions, onChoose }: { line: BomLine; bom: Bom; actions: BomActions; onChoose?: (offer: string | null) => void }) {
289
+ return (
290
+ <div data-line={line.key} className="flex flex-col gap-1.5 border-t border-line-subtle px-3 py-2">
291
+ <div className="flex items-start gap-2">
292
+ <span className="min-w-0 flex-1">
293
+ <PartName line={line} actions={actions} />
294
+ </span>
295
+ <span className="flex shrink-0 flex-col items-end">
296
+ <span className="font-ui text-[12.5px] font-medium text-accent">{line.orderCost !== undefined ? formatUsd(line.orderCost) : <StatusNote line={line} />}</span>
297
+ {line.perUnitCost !== undefined && <span className="font-ui text-[11px] text-fg-muted">{formatUsd(line.perUnitCost)} / unit</span>}
298
+ </span>
299
+ </div>
300
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 pl-[22px]">
301
+ {line.kind === "buy" && (
302
+ <span className="min-w-0 max-w-full">
303
+ <SupplierChip line={line} {...(onChoose ? { onChoose } : {})} />
304
+ </span>
305
+ )}
306
+ {line.status === "priced" && <StockBadge line={line} />}
307
+ <span className="inline-flex items-center gap-1 font-ui text-[11.5px] text-fg-muted">
308
+ need <Quantity line={line} units={bom.units} />
309
+ {line.quote && (
310
+ <>
311
+ · buy <Buy line={line} /> · <UnitPrice line={line} bom={bom} />
312
+ </>
313
+ )}
314
+ </span>
315
+ <span className="ml-auto">
316
+ <Actions line={line} actions={actions} />
317
+ </span>
318
+ </div>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ function GroupHead({ g, open, onToggle, icon }: { g: BomGroup; open: boolean; onToggle: () => void; icon: string }) {
324
+ return (
325
+ <button type="button" aria-expanded={open} onClick={onToggle} className="flex min-h-8 w-full cursor-pointer items-center gap-2 border-t border-line-subtle bg-[var(--pb-row-head)] px-3 text-left hover:bg-hover focus-visible:outline focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-focus">
326
+ <Icon name={open ? "chevron-down" : "chevron-right"} size={14} className="shrink-0 text-fg-muted" aria-hidden />
327
+ <Icon name={icon} size={14} strokeWidth={1.75} className="shrink-0 text-fg-muted" aria-hidden />
328
+ <span className="min-w-0 truncate text-[13px] font-semibold text-fg">
329
+ {g.name}
330
+ {g.quantity > 1 && ` ×${g.quantity}`}
331
+ </span>
332
+ <span className="shrink-0 text-[12px] text-fg-muted">
333
+ {g.lines.length} line{g.lines.length === 1 ? "" : "s"}
334
+ </span>
335
+ {g.orderCost > 0 && <span className="ml-auto shrink-0 font-ui text-[12px] text-fg">{formatUsd(g.orderCost)}</span>}
336
+ </button>
337
+ );
338
+ }
339
+
340
+ /** An Alpha summary card: a label with its icon, the figure, what it is made of. */
341
+ function Summary({ label, icon, value, accent, children }: { label: string; icon: string; value: ReactNode; accent?: boolean; children?: ReactNode }) {
342
+ return (
343
+ <Card className={cx(BODY, "flex min-w-0 flex-col gap-1.5 px-3.5 py-3")}>
344
+ <span className="flex items-center justify-between gap-2 text-fg-muted">
345
+ <span className={cx(HEAD, "truncate")}>{label}</span>
346
+ <Icon name={icon} size={14} strokeWidth={1.75} className="shrink-0" aria-hidden />
347
+ </span>
348
+ <span className={cx("truncate font-ui text-[20px] leading-tight font-semibold", accent ? "text-accent" : "text-fg")}>{value}</span>
349
+ {children && <div className="flex flex-col gap-0.5 text-[11.5px] leading-snug text-fg-muted">{children}</div>}
350
+ </Card>
351
+ );
352
+ }
353
+
354
+ function Facts({ items }: { items: [string, ReactNode, Tone?][] }) {
355
+ return (
356
+ <span className="flex flex-wrap gap-x-4 gap-y-1">
357
+ {items.map(([k, v, tone]) => (
358
+ <span key={k} className="flex flex-col">
359
+ <span>{k}</span>
360
+ <span className={cx("font-ui text-[12px] font-semibold", tone === "warning" ? "text-warning" : tone === "info" ? "text-info" : tone === "ok" ? "text-ok" : "text-fg")}>{v}</span>
361
+ </span>
362
+ ))}
363
+ </span>
364
+ );
365
+ }
366
+
367
+ /** Units to build: a number with its steppers. */
368
+ function Units({ value, onChange }: { value: number; onChange: (n: number) => void }) {
369
+ const [text, setText] = useState(String(value));
370
+ useEffect(() => setText(String(value)), [value]);
371
+ const commit = (raw: string) => {
372
+ const n = Math.round(Number(raw));
373
+ if (Number.isFinite(n) && n >= 1 && n <= 1_000_000) {
374
+ if (n !== value) onChange(n);
375
+ setText(String(n));
376
+ } else setText(String(value));
377
+ };
378
+ return (
379
+ <span className="inline-flex h-7 items-center gap-0.5 rounded-[5px] border border-line bg-sunken pl-2 outline-1 -outline-offset-1 outline-contrast focus-within:border-focus" data-tooltip="Units to build: quantities and the order are worked out for this many">
380
+ <span className={cx(HEAD, "pr-1")}>Units</span>
381
+ <input
382
+ aria-label="Units to build"
383
+ inputMode="numeric"
384
+ value={text}
385
+ onChange={(e) => setText(e.target.value.replace(/[^\d]/g, "").slice(0, 7))}
386
+ onBlur={(e) => commit(e.target.value)}
387
+ onKeyDown={(e) => {
388
+ if (e.key === "Enter") commit((e.target as HTMLInputElement).value);
389
+ if (e.key === "ArrowUp") (e.preventDefault(), onChange(value + 1));
390
+ if (e.key === "ArrowDown" && value > 1) (e.preventDefault(), onChange(value - 1));
391
+ }}
392
+ className="h-full w-[5ch] min-w-0 border-0 bg-transparent p-0 text-right font-ui text-[12.5px] text-fg outline-none"
393
+ />
394
+ <IconButton icon="minus" label="One unit fewer" tooltipSide="bottom" disabled={value <= 1} onClick={() => onChange(Math.max(1, value - 1))} />
395
+ <IconButton icon="plus" label="One unit more" tooltipSide="bottom" onClick={() => onChange(value + 1)} />
396
+ </span>
397
+ );
398
+ }
399
+
400
+ /** How long after the reader's last change a model from the host is taken to be an echo of it. */
401
+ const SETTLE_MS = 2500;
402
+ const sameChoices = (a: Record<string, string>, b: Record<string, string>) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([k, v]) => b[k] === v);
403
+
404
+ export function BomEditor({ model, actions, title = "Bill of materials" }: { model: Bom; actions: BomActions; title?: string }) {
405
+ const [by, setBy] = useState<GroupBy>("module");
406
+ const [closed, setClosed] = useState<Set<string>>(new Set());
407
+ // the reader's own units and offers, over the model the host last gave
408
+ const [local, setLocal] = useState<{ units?: number; choices?: Record<string, string> }>({});
409
+ // The host keeps each change and sends the model again a moment later. While the reader is still clicking, a
410
+ // model that arrives is one of an earlier click: what was changed here stays until the model says the same (or
411
+ // nothing was changed for a while, so the model is news from elsewhere: the CLI, another window).
412
+ const edited = useRef(0);
413
+ useEffect(() => {
414
+ setLocal((s) => {
415
+ if (Date.now() - edited.current > SETTLE_MS) return {};
416
+ const next = { ...s };
417
+ if (next.units === model.units) delete next.units;
418
+ if (next.choices && sameChoices(next.choices, bomChoices(model))) delete next.choices;
419
+ return next;
420
+ });
421
+ }, [model]);
422
+ const bom = useMemo(() => (local.units === undefined && !local.choices ? model : repriceBom(model, local)), [model, local]);
423
+ const groups = useMemo(() => regroup(bom, by), [bom, by]);
424
+ const lines = useMemo(() => bom.groups.flatMap((g) => g.lines), [bom]);
425
+
426
+ const setUnits = (n: number) => {
427
+ edited.current = Date.now();
428
+ setLocal((s) => ({ ...s, units: n }));
429
+ actions.setUnits?.(n);
430
+ };
431
+ const choose = (line: BomLine) => (offer: string | null) => {
432
+ const choices = { ...(local.choices ?? bomChoices(bom)) };
433
+ if (offer === null) delete choices[line.key];
434
+ else choices[line.key] = offer;
435
+ edited.current = Date.now();
436
+ setLocal((s) => ({ ...s, choices }));
437
+ actions.chooseOffer?.(line.key, offer);
438
+ };
439
+ const toggle = (k: string) => setClosed((s) => (s.has(k) ? new Set([...s].filter((x) => x !== k)) : new Set([...s, k])));
440
+ const cycle = <T,>(list: T[], v: T) => list[(list.indexOf(v) + 1) % list.length];
441
+
442
+ const src = bom.source.kind === "design" ? "from the design" : bom.source.kind === "release" ? `release ${bom.source.version}` : bom.source.file.split("/").pop()!;
443
+ const pieces = lines.filter((l) => l.kind !== "included").reduce((s, l) => s + l.quantity, 0);
444
+ const inStock = lines.filter((l) => l.status === "priced" && stockOf(l.offers.find((o) => o.id === l.chosen), l.quote)?.tone === "ok").length;
445
+ const observed = shortDay(bom.prices.newest);
446
+ const groupIcon = by === "supplier" ? "store" : "boxes";
447
+ const empty = !lines.length;
448
+
449
+ return (
450
+ <PartsSurface place="editor">
451
+ <div className="@container size-full">
452
+ <ScrollArea className="size-full" axis="vertical" contentClassName="flex min-w-[260px] flex-col gap-4 px-6 pt-5 pb-8 @max-[640px]:px-3">
453
+ <header className={cx(BODY, "flex flex-wrap items-center gap-x-3 gap-y-2")}>
454
+ <h1 className="text-[19px] font-semibold text-fg">{title}</h1>
455
+ <span className="min-w-0 truncate font-ui text-[12px] text-fg-muted">{src}</span>
456
+ {/* a toolbar: icon buttons that name themselves and what they do in a tooltip */}
457
+ <span className="ml-auto flex flex-wrap items-center gap-1" role="toolbar" aria-label="Bill of materials">
458
+ <Units value={bom.units} onChange={setUnits} />
459
+ <IconButton icon={GROUP_ICON[by]} label={`${GROUP_NOW[by]}: click to group ${GROUP_NEXT[by]}`} tooltipSide="bottom" onClick={() => setBy(cycle<GroupBy>(["module", "supplier", "none"], by))} />
460
+ {actions.refreshOffers && <IconButton icon="refresh-cw" label="Refresh offers: ask the library for its current prices" tooltipSide="bottom" onClick={actions.refreshOffers} />}
461
+ {actions.exportCsv && <IconButton icon="download" label="Export CSV" tooltipSide="bottom" disabled={empty} onClick={() => actions.exportCsv!(bom)} />}
462
+ </span>
463
+ </header>
464
+
465
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,210px),1fr))] gap-3">
466
+ <Summary label="Parts" icon="box" value={`${pieces} per unit`}>
467
+ <Facts
468
+ items={[
469
+ ["Lines", bom.counts.lines],
470
+ ["Suppliers", bom.counts.suppliers],
471
+ ["In stock", `${inStock} / ${bom.counts.priced}`],
472
+ ["Made", bom.counts.make ? `${bom.counts.make} · quote` : 0, bom.counts.make ? "info" : undefined],
473
+ ]}
474
+ />
475
+ </Summary>
476
+ <Summary label="Prices" icon="tags" value={`${bom.counts.priced} / ${bom.counts.buy} priced`}>
477
+ <Facts
478
+ items={[
479
+ ["Unpriced", bom.counts.unpriced, bom.counts.unpriced ? "warning" : undefined],
480
+ ["Stale", bom.counts.stale, bom.counts.stale ? "warning" : undefined],
481
+ ["Converted", bom.counts.converted],
482
+ ]}
483
+ />
484
+ <span>
485
+ {observed ? `Observed ${shortDay(bom.prices.oldest) !== observed ? `${shortDay(bom.prices.oldest)} to ` : ""}${observed}.` : "No prices held yet: Refresh offers asks the library."}
486
+ {bom.fx.used.length > 0 && (
487
+ <span data-tooltip={`${bom.fx.source}${bom.fx.project ? " (parts/fx-rates.json)" : ""}, ${bom.fx.date}: ${bom.fx.used.map((u) => `1 ${u.currency} = ${u.rate} USD`).join(", ")}`}> Rates of {bom.fx.date}.</span>
488
+ )}
489
+ </span>
490
+ </Summary>
491
+ <Summary label="Per unit" icon="cuboid" value={bom.counts.priced ? formatUsd(bom.totals.perUnit) : "—"}>
492
+ <span>The parts one unit uses, at the prices paid{bom.units > 1 ? ` when ordering for ${bom.units}` : ""}.</span>
493
+ </Summary>
494
+ <Summary label={`Order · ${bom.units} unit${bom.units === 1 ? "" : "s"}`} icon="shopping-cart" accent value={bom.counts.priced ? formatUsd(bom.totals.order) : "—"}>
495
+ {bom.counts.priced > 0 && (
496
+ <span>
497
+ {bom.totals.surplus > 0 ? `${formatUsd(bom.totals.surplus)} of it buys pieces left over (packs and minimum orders).` : "Nothing is left over."}
498
+ {bom.units > 1 && ` ${formatUsd(bom.totals.orderPerUnit)} per unit.`}
499
+ </span>
500
+ )}
501
+ <span>Tax and shipping are not included.{bom.counts.unpriced + bom.counts.make > 0 ? ` ${bom.counts.unpriced + bom.counts.make} line${bom.counts.unpriced + bom.counts.make === 1 ? " is" : "s are"} not in it.` : ""}</span>
502
+ </Summary>
503
+ </div>
504
+
505
+ {empty ? (
506
+ <EmptyState icon={<Icon name="sheet" size={26} strokeWidth={1.25} />} title="No lines">
507
+ The system has no bought or made parts yet.
508
+ </EmptyState>
509
+ ) : (
510
+ <Card className={cx(BODY, "overflow-hidden")}>
511
+ {/* wide: a table */}
512
+ <div role="table" aria-label={title} className="@max-[1080px]:hidden">
513
+ <div role="row" className={cx(COLS, HEAD, "h-9 px-3")}>
514
+ <span role="columnheader">Part</span>
515
+ <span role="columnheader">Supplier</span>
516
+ <span role="columnheader">Stock</span>
517
+ <span role="columnheader" className="justify-self-end" data-tooltip={`Pieces needed for ${bom.units} unit${bom.units === 1 ? "" : "s"}`}>
518
+ Need
519
+ </span>
520
+ <span role="columnheader" className="justify-self-end" data-tooltip="Pieces to buy: the need, in whole packs and at least the minimum order">
521
+ Buy
522
+ </span>
523
+ <span role="columnheader" className="justify-self-end" data-tooltip="Price per piece, in dollars, at the quantity bought">
524
+ Unit
525
+ </span>
526
+ <span role="columnheader" className="justify-self-end" data-tooltip="What the pieces one unit uses cost">
527
+ Per unit
528
+ </span>
529
+ <span role="columnheader" className="justify-self-end" data-tooltip="What ordering this line costs: pieces bought × unit price">
530
+ Order
531
+ </span>
532
+ <span />
533
+ </div>
534
+ {groups.map((g) => (
535
+ <Fragment key={g.path || "system"}>
536
+ {by !== "none" && <GroupHead g={g} icon={groupIcon} open={!closed.has(g.path)} onToggle={() => toggle(g.path)} />}
537
+ {!closed.has(g.path) && g.lines.map((l) => <TableRow key={l.key} line={l} bom={bom} actions={actions} onChoose={choose(l)} />)}
538
+ </Fragment>
539
+ ))}
540
+ </div>
541
+ {/* narrow: a list */}
542
+ <div className="hidden @max-[1080px]:block">
543
+ {groups.map((g) => (
544
+ <Fragment key={g.path || "system"}>
545
+ {by !== "none" && <GroupHead g={g} icon={groupIcon} open={!closed.has(g.path)} onToggle={() => toggle(g.path)} />}
546
+ {!closed.has(g.path) && g.lines.map((l) => <ListRow key={l.key} line={l} bom={bom} actions={actions} onChoose={choose(l)} />)}
547
+ </Fragment>
548
+ ))}
549
+ </div>
550
+ <div className="flex flex-wrap items-baseline justify-end gap-x-6 gap-y-1 border-t border-line bg-[var(--pb-row-head)] px-3 py-2 font-ui text-[12px]">
551
+ <span className="text-fg-muted">
552
+ Per unit <span className="text-fg">{bom.counts.priced ? formatUsd(bom.totals.perUnit) : "—"}</span>
553
+ </span>
554
+ <span className="text-fg-muted">
555
+ Order for {bom.units} <span className="font-semibold text-accent">{bom.counts.priced ? formatUsd(bom.totals.order) : "—"}</span>
556
+ </span>
557
+ </div>
558
+ </Card>
559
+ )}
560
+ </ScrollArea>
561
+ </div>
562
+ </PartsSurface>
563
+ );
564
+ }