diff --git a/CLAUDE.md b/CLAUDE.md index a93cbc9..4582207 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,6 +283,13 @@ Matching fixes (2026-10-28, owner reports from the HORS panel: a Control Hub tha - **Sizes and bores.** `spellSizes` turns "8x14x5mm" (or "8 × 14 × 5 mm") into "8mm 14mm 5mm" for library names and the tagged search; a configuration's own size field is ignored when the name gives sizes. `boreConflict` drops products with the other bore: "Round Bore" excludes goBILDA's REX/hex bore, and "REX" or "hex" excludes round ones. - **Tagged exact match.** A vendor-tagged part ("(GB)") whose search leaves one product that carries every number in the name is an exact match ("8x14x5mm Bearing - Round Bore (GB)" is 1611-0514-0008; "6000rpm Motor - Bare REX (GB)" is 5203-2402-0001). Sizes written as one token must appear in the product name in the same order (bore, outside diameter, thickness), so "5x14x8mm" is never matched to the 8×14×5 bearing. Several products, or a name without numbers, stay a choice. A part carrying two discontinued numbers (a part number and a name for different products) gets every replacement as a choice. +Review flow (2026-10-28, owner feedback from the live HORS panel): +- **Unmatched Buy parts are in Review.** `needsProduct` (`app/model.ts`): Buy with no exact or confirmed product. `reviewQueue` lists them last as kind `product` (filter "No product"), each row offering what fixes it: Confirm/Not this for a possible product, "Use {vendor} {SKU}" for up to three of `productOptions` (possible match, `choices`, `nearMatches`), the existing vendor search link when the name or part number holds a SKU, and 3D print / Not needed / Other. `Summary` gains `unmatched` and `toReview` (= `review` + `unmatched`); the Review tab's count and dot, the Overview primary action ("Review N parts"), and its first next move all use `toReview`. The separate "unmatched" primary action and next move are gone; the type bar's orange share is now labelled "Undecided" (it still counts only `disposition === 'review'`, so the bar adds up to the unique count). The Buy list keeps its "No vendor match yet" group, so the order view still shows everything to buy, with a "Resolve in Review" button that opens Review on "No product"; Overview's "No vendor yet" row does the same. `applyMatchDecision` now also accepts a pick on a rejected line, since Review offers a rejected part's near matches. +- **Drawer moves on.** `open(key, list)` records the list a part was opened from (Review's filtered order, a Buy list group, a Make group, the Parts filter) and which parts still belong to it. After a type or product choice saves, the drawer shows the next part still in that list (`nextInList`, wrapping to skipped ones) with "{name} moved to 3D print" (`movedText`), or closes and shows that note as a page toast when none is left or the part was opened on its own (Overview search). A failed save stays on the part with its error. The drawer also lists an unmatched part's closest products ("Use this"). Overview search's Enter now blurs the field first; with focus left in the field, the drawer closed itself at once. +- **Bulk types.** Review rows have checkboxes (shift-click for a range, Space on the highlighted row, "Select all shown") and "Select all N in {subassembly}" (`subassemblyOf`: the part's direct parent by its first placement, as its path of names from the top without instance numbers, so every copy of one subassembly is one group and two unrelated subassemblies of the same name are not; `inSubassembly` matches any depth below it; shift-click ranges start from the last clicked row looked up by key). The bar sets one type through `setDispositions` in `useControlKit`: the existing per-part override save, four at a time, each with its own `saving` state and no list-wide lock; rows of one part save once, and a part whose type was changed after the batch began (drawer, row button) is skipped rather than overwritten. Failures stay selected with a "{n} of {m} could not be saved: names" alert. +- **Fastener counts.** Onshape's OpenAPI (`BTRootAssemblyInfo`, `BTSubAssemblyInfo`) documents `instances` as "including those created by patterns and replicates"; `patterns` only maps seeds to copies and `features` holds the Replicate feature. The walk already counts every instance, so pattern and Replicate copies were always counted (the HORS scan's BOM quantities matched the definition on all 102 matched rows). `fixtures/assembly-patterns.json` pins this, including a suppressed pattern copy. No extra Onshape request is needed; the "can be low with Replicate or patterns" notes in the Buy list, workbook, and classifier now say copies are counted and only fasteners never modelled are missing. Not verified against a live assembly with a Replicate feature. +- Screenshots of the flow at 380 and 1440 px, from fixture mode with the saved HORS scan, are in `docs/screenshots/review-flow/`. + ## Tuning, tests, and release Match normalization, thresholds, and alternative ranking are in `packages/shared/src/matching.ts`; an exact SKU still forces Buy absent a manual override. Disposition rules (`decideDisposition`), word lists, and print folders are in `packages/shared/src/classifier.ts`; order quantities are in `packages/shared/src/purchasing.ts`. Vendor stale hours and cron schedule are in `wrangler.toml`; catalog crawl batch limits are in `apps/worker/src/vendor-ingest.ts`. Scan request flow, source cache, and backoff are in `apps/worker/src/scan.ts`; BOM column mapping is in `packages/shared/src/bom.ts`; STEP export job bounds (per-call request budget, 5-minute translation timeout, 20 MB file cap) are in `apps/worker/src/step-export.ts`. Change these with fixture tests and record any changed data shape here. diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index a334050..6d02e4e 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,10 +1,11 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Boxes, Check, LayoutDashboard, ListChecks, LogOut, RefreshCw, Settings, ShoppingCart, Wrench } from 'lucide-react'; import { Alert } from '../components/arc/alert/alert'; import { Button } from './bits'; import { Progress } from '../components/arc/progress/progress'; import { contextKeys, useControlKit, type ControlKit, type ScanCounts, type ScanStep } from './useControlKit'; -import { ago, summarize, type View } from './model'; +import { ago, nextInList, summarize, type View } from './model'; +import type { BomLine } from '@control-kit/shared'; import { Overview } from './Overview'; import { Review } from './Review'; import { Parts } from './Parts'; @@ -17,6 +18,11 @@ import { useControlPoint } from './useControlPoint'; import { WorkspaceMark } from './ControlPoint'; import { CONTROL_POINT_CAD_URL } from '@control-kit/shared'; +/** The list a part was opened from, in its order, and whether a part still belongs to it (a decided part leaves Review). */ +export type PartList = { keys: string[]; listed?: (line: BomLine) => boolean }; +export type Open = (key: string, list?: PartList) => void; +export type ReviewFilter = 'all' | 'match' | 'suggested' | 'undecided' | 'product' | 'ai'; + const views: View[] = ['overview', 'review', 'parts', 'buy', 'make']; const viewLabel: Record = { overview: 'Overview', review: 'Review', parts: 'Parts', buy: 'Buy list', make: 'Make' }; const viewIcon: Record = { overview: , review: , parts: , buy: , make: }; @@ -109,7 +115,14 @@ function Workspace({ kit }: { kit: ControlKit }) { const result = kit.result!; const cp = useControlPoint(kit); const [view, setView] = useState(() => (views as string[]).includes(location.hash.slice(1)) ? location.hash.slice(1) as View : 'overview'); - const [selected, setSelected] = useState(null); + // The open part, and the list it was opened from: after a choice the drawer moves to the next part still in that list. + const [selected, setSelected] = useState<{ key: string; list: PartList | null } | null>(null); + /** "Bracket moved to 3D print": shown in the drawer when it moves on, or on the page when it closes. */ + const [notice, setNotice] = useState<{ text: string; at: number } | null>(null); + useEffect(() => { if (!notice) return; const timer = setTimeout(() => setNotice(null), 5000); return () => clearTimeout(timer); }, [notice]); + const [reviewFilter, setReviewFilter] = useState('all'); + const latest = useRef(result); + latest.current = result; const [settingsOpen, setSettingsOpen] = useState(false); // Relative times need a clock, which the first render does not have. const [now, setNow] = useState(null); @@ -121,12 +134,24 @@ function Workspace({ kit }: { kit: ControlKit }) { document.querySelector('.tab[aria-current="page"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }, [view]); const summary = summarize(result); - const counts: Record = { overview: null, review: summary.review, parts: summary.uniqueLines, buy: summary.buy, make: summary.make.total }; + const counts: Record = { overview: null, review: summary.toReview, parts: summary.uniqueLines, buy: summary.buy, make: summary.make.total }; // Each section's colour job: review is orange while parts wait and green once none do; buy is sky; make is gold. - const tone: Record = { overview: null, review: summary.review ? 'attention' : 'done', parts: null, buy: 'buy', make: 'make' }; + const tone: Record = { overview: null, review: summary.toReview ? 'attention' : 'done', parts: null, buy: 'buy', make: 'make' }; const dot = (item: View) => tone[item] && ; - const line = selected ? result.lines.find(item => item.bomLineKey === selected) ?? result.suppressed.find(item => item.bomLineKey === selected) ?? null : null; - const open = (key: string) => setSelected(key); + const line = selected ? result.lines.find(item => item.bomLineKey === selected.key) ?? result.suppressed.find(item => item.bomLineKey === selected.key) ?? null : null; + const open: Open = (key, list) => { setNotice(null); setSelected({ key, list: list ?? null }); }; + /** After a saved choice: the next part still in the list the drawer came from, or close. The scan read is the latest. */ + const moved = (from: BomLine, text: string) => { + setNotice({ text, at: Date.now() }); + setSelected(current => { + if (!current || current.key !== from.bomLineKey) return current; + const list = current.list; + const lines = new Map(latest.current.lines.map(item => [item.bomLineKey, item])); + const next = list && nextInList(list.keys, current.key, key => { const item = lines.get(key); return Boolean(item) && (!list.listed || list.listed(item!)); }); + return next ? { key: next, list } : null; + }); + }; + const goReview = (filter: ReviewFilter) => { setReviewFilter(filter); setView('review'); }; const subtitle = [result.documentName, now ? `scanned ${ago(result.scannedAt, now)}` : null].filter(Boolean).join(' · '); return
@@ -163,14 +188,15 @@ function Workspace({ kit }: { kit: ControlKit }) {
{kit.error && } {kit.progress &&
} - {view === 'overview' && } - {view === 'review' && } + {view === 'overview' && } + {view === 'review' && } {view === 'parts' && } - {view === 'buy' && } + {view === 'buy' && } {view === 'make' && }
- setSelected(null)} /> + setSelected(null)} onMoved={moved} notice={notice && selected ? notice.text : null} hasNext={Boolean(selected?.list)} /> + {notice && !selected &&
{notice.text}
} ; } diff --git a/apps/web/src/app/BuyList.tsx b/apps/web/src/app/BuyList.tsx index c049688..8f63434 100644 --- a/apps/web/src/app/BuyList.tsx +++ b/apps/web/src/app/BuyList.tsx @@ -2,20 +2,24 @@ import { ExternalLink, FileSpreadsheet, ShoppingCart } from 'lucide-react'; import { Alert } from '../components/arc/alert/alert'; import { EmptyState } from '../components/arc/empty-state/empty-state'; import type { ControlKit } from './useControlKit'; -import { buyList, catalogsComplete, fasteners, feedDates, money, priceDate, summarize, type View } from './model'; +import type { BomLine } from '@control-kit/shared'; +import { buyList, catalogsComplete, needsProduct, fasteners, feedDates, money, priceDate, summarize, type View } from './model'; import { Button, LineCost, SectionHead, StockNote } from './bits'; +import type { Open, ReviewFilter } from './App'; /** * What to order, by vendor. Only exact or confirmed matches sit under a vendor, and only priced lines count toward a * total; parts to buy without a match are listed below with what they still need. */ -export function BuyList({ kit, open, go }: { kit: ControlKit; open: (key: string) => void; go: (view: View) => void }) { +export function BuyList({ kit, open, go, goReview }: { kit: ControlKit; open: Open; go: (view: View) => void; goReview: (filter: ReviewFilter) => void }) { const result = kit.result!; const { vendors, needsSku } = buyList(result); const stock = fasteners(result); const summary = summarize(result); const complete = summary.buyLines > 0 && summary.pricedLines === summary.buyLines; const outOfStock = vendors.flatMap(group => group.lines).filter(line => line.vendor?.stock === 'out').length; + // The drawer steps through the group it was opened from while its parts are still in that group. + const inGroup = (lines: BomLine[], listed: (line: BomLine) => boolean) => (key: string) => open(key, { keys: lines.map(line => line.bomLineKey), listed }); return <> } tone="buy" title="Buy list" actions={<> @@ -45,7 +49,7 @@ export function BuyList({ kit, open, go }: { kit: ControlKit; open: (key: string const vendor = line.vendor!; return
- + {vendor.sku} {line.qty} needed @@ -64,8 +68,9 @@ export function BuyList({ kit, open, go }: { kit: ControlKit; open: (key: string )} {needsSku.length > 0 &&
-

No vendor match yet

{catalogsComplete(result.catalogHealth) ? 'These parts are marked Buy but no catalog product matched. Add the vendor SKU to the part number in Onshape and rescan.' : 'Some vendor catalogs are still loading. Rescan later, or add the vendor SKU to the part number in Onshape.'}

-
{needsSku.map(line =>
+
{needsSku.map(line =>
} {stock.length > 0 &&
-

Fasteners

Screws, nuts, washers, and clips usually come from your hardware stock, so they need no decision. Counts come from the CAD and can be low when fasteners were placed with Replicate or patterns.

-
{stock.map(line => )}
} diff --git a/apps/web/src/app/Make.tsx b/apps/web/src/app/Make.tsx index 71d70df..12b0876 100644 --- a/apps/web/src/app/Make.tsx +++ b/apps/web/src/app/Make.tsx @@ -5,9 +5,10 @@ import { EmptyState } from '../components/arc/empty-state/empty-state'; import { Progress } from '../components/arc/progress/progress'; import type { ControlKit } from './useControlKit'; import { makeGroups, type View } from './model'; +import type { Open } from './App'; /** Parts the team makes, by process, with the STEP export and its per-part results. */ -export function Make({ kit, open, go }: { kit: ControlKit; open: (key: string) => void; go: (view: View) => void }) { +export function Make({ kit, open, go }: { kit: ControlKit; open: Open; go: (view: View) => void }) { const groups = makeGroups(kit.result!); const total = groups.reduce((sum, group) => sum + group.lines.length, 0); const step = kit.step; @@ -42,7 +43,7 @@ export function Make({ kit, open, go }: { kit: ControlKit; open: (key: string) =

{group.label}

{group.lines.length} part{group.lines.length === 1 ? '' : 's'}, {group.lines.reduce((sum, line) => sum + line.qty, 0)} pieces

{group.lines.map(line =>
- + {line.qty} needed{line.material && {line.material}} {line.dispositionSource === 'manual' ? 'chosen by your team' : line.dispositionEvidence[line.dispositionEvidence.length - 1] ?? ''}
diff --git a/apps/web/src/app/Overview.tsx b/apps/web/src/app/Overview.tsx index c8a211a..68b24fc 100644 --- a/apps/web/src/app/Overview.tsx +++ b/apps/web/src/app/Overview.tsx @@ -9,6 +9,7 @@ import type { ControlPoint } from './useControlPoint'; import { ControlPointCard } from './ControlPoint'; import { ago, buyList, catalogsComplete, catalogState, catalogStateText, composition, feedDates, findParts, hasChanges, money, percent, primaryAction, readiness, scanChanges, staleCatalogs, topCosts, unmatchedParts, vendorLabel, type PrimaryAction, type Readiness, type Summary, type View } from './model'; +import type { Open, ReviewFilter } from './App'; /** "1 part", "3 parts". */ const parts = (count: number) => `${count} ${count === 1 ? 'part' : 'parts'}`; @@ -19,7 +20,7 @@ const day = (iso: string) => new Date(iso).toLocaleDateString(undefined, { month /** A problem still standing between this scan and a complete order and build list. */ type Move = { key: string; icon: ReactNode; tone: 'attention' | 'blocked'; title: string; detail: string; action?: ReactNode; lines?: BomLine[] }; -export function Overview({ kit, cp, summary, go, open, now }: { kit: ControlKit; cp: ControlPoint; summary: Summary; go: (view: View) => void; open: (key: string) => void; now: number | null }) { +export function Overview({ kit, cp, summary, go, goReview, open, now }: { kit: ControlKit; cp: ControlPoint; summary: Summary; go: (view: View) => void; goReview: (filter: ReviewFilter) => void; open: Open; now: number | null }) { const result = kit.result!; const ready = readiness(result, summary); const primary = primaryAction(summary, ready); @@ -32,18 +33,19 @@ export function Overview({ kit, cp, summary, go, open, now }: { kit: ControlKit; const moves: Move[] = []; const done: string[] = []; - if (summary.review) moves.push({ key: 'review', icon: , tone: 'attention', title: `${parts(summary.review)} ${summary.review === 1 ? 'needs' : 'need'} a decision`, - detail: summary.possibleMatches ? `${summary.possibleMatches} have a possible vendor match to confirm. The rest need you to choose how they're made.` : 'Choose whether each part is bought, printed, or fabricated.', - action: }); + // Review holds parts whose type is undecided and parts to buy with no vendor product, so one move counts both. + if (summary.toReview) moves.push({ key: 'review', icon: , tone: 'attention', title: `${parts(summary.toReview)} ${summary.toReview === 1 ? 'needs' : 'need'} a decision`, + detail: [ + summary.review && (summary.possibleMatches ? `${summary.possibleMatches} of ${summary.review} undecided ${summary.review === 1 ? 'has' : 'have'} a possible vendor match to confirm; choose how the rest are made.` : `Choose how ${summary.review === 1 ? '1 part is' : `${summary.review} parts are`} made.`), + summary.unmatched && `${parts(summary.unmatched)} to buy ${summary.unmatched === 1 ? 'has' : 'have'} no vendor product yet: pick one, search the vendor, or add the SKU to the part number in Onshape${notReady.length ? '; some catalogs are still loading' : ''}.`, + ].filter(Boolean).join(' '), + action: , lines: summary.unmatched ? unmatchedParts(result) : undefined }); else done.push('Every part has a decision'); // Loading catalogs explain unmatched parts, so they come before them. if (notReady.length) moves.push({ key: 'catalog', icon: , tone: 'attention', title: notReady.length === health!.vendors.length ? 'Vendor catalogs are still loading' : 'Some vendor catalogs are still loading', detail: `${notReady.map(vendor => `${vendorLabel[vendor.vendorId]} (${catalogStateText(vendor).toLowerCase()})`).join(', ')}. Parts from these vendors can't be matched or priced yet; rescan later to pick them up.` }); else if (health) done.push('All vendor catalogs loaded'); - if (ready.order.unmatched) moves.push({ key: 'sku', icon: , tone: 'attention', title: `${parts(ready.order.unmatched)} to buy ${ready.order.unmatched === 1 ? 'has' : 'have'} no vendor match`, - detail: notReady.length ? 'Add the vendor SKU to the part number in Onshape, or confirm a match once catalogs finish loading.' : 'Add the vendor SKU to the part number in Onshape, then rescan.', - action: , lines: unmatchedParts(result) }); - else if (summary.buyLines) done.push('Every part to buy is matched'); + if (!ready.order.unmatched && summary.buyLines) done.push('Every part to buy is matched'); if (ready.order.unpriced) moves.push({ key: 'unpriced', icon: , tone: 'attention', title: `${parts(ready.order.unpriced)} matched but not priced`, detail: 'Their pack size or price is unknown, so they stay out of the total. Check them on the vendor\'s site.', action: }); else if (summary.buyLines && !ready.order.unmatched) done.push('Every matched part is priced'); @@ -59,14 +61,14 @@ export function Overview({ kit, cp, summary, go, open, now }: { kit: ControlKit; {kit.previous && } - + {stale.length > 0 &&
{stale.map(vendor => `${vendorLabel[vendor.vendorId]} (${catalogStateText(vendor).toLowerCase()})`).join(', ')}. Prices are as of {result.vendorDataUpdatedAt ? day(result.vendorDataUpdatedAt) : 'an earlier date'}; check prices and stock on the vendor's site before ordering.
}
- + {summary.make.total > 0 && } @@ -82,7 +84,12 @@ function FindPart({ kit, open }: { kit: ControlKit; open: (key: string) => void return
+ onKeyDown={event => { + // Enter opens the first match. The field lets go of focus first: the drawer otherwise sees focus still outside it + // as the person leaving, and closes again at once. + if (event.key === 'Enter' && found[0]) { event.preventDefault(); event.currentTarget.blur(); open(found[0].bomLineKey); setQuery(''); } + if (event.key === 'Escape') setQuery(''); + }} /> {query.trim() &&
    {found.length ? found.map(line =>
  • ) :
  • No part matches “{query.trim()}”.
  • }
} @@ -147,7 +154,7 @@ function Meter({ tone, label, ready, total, detail }: { tone: 'buy' | 'make'; la * order and build list are complete and a Control Point workspace is chosen, sending there becomes the action; after * this scan is sent, viewing it there is. */ -function PrimaryBar({ kit, cp, primary, summary, go }: { kit: ControlKit; cp: ControlPoint; primary: PrimaryAction; summary: Summary; go: (view: View) => void }) { +function PrimaryBar({ kit, cp, primary, summary, go, goReview }: { kit: ControlKit; cp: ControlPoint; primary: PrimaryAction; summary: Summary; go: (view: View) => void; goReview: (filter: ReviewFilter) => void }) { const workspace = primary.kind === 'build-pack' && cp.workspace?.canEditCad ? cp.workspace : null; // "View" only when this exact BOM, with the files a send would attach now, already went to this workspace. const sentNow = workspace && cp.sentCurrent ? cp.sent : null; @@ -161,7 +168,7 @@ function PrimaryBar({ kit, cp, primary, summary, go }: { kit: ControlKit; cp: Co
; const run = () => { - if (primary.kind === 'review') return go('review'); + if (primary.kind === 'review') return goReview('all'); if (primary.kind !== 'build-pack') return go('buy'); buildPack(); }; @@ -313,7 +320,7 @@ function MoneyCard({ kit, summary, ready, go }: { kit: ControlKit; summary: Summ } /** Each vendor's share of the order, with stock problems called out in red. */ -function BuyByVendor({ kit, go }: { kit: ControlKit; go: (view: View) => void }) { +function BuyByVendor({ kit, go, goReview }: { kit: ControlKit; go: (view: View) => void; goReview: (filter: ReviewFilter) => void }) { const { vendors, needsSku } = buyList(kit.result!); if (!vendors.length && !needsSku.length) return null; return
@@ -327,9 +334,9 @@ function BuyByVendor({ kit, go }: { kit: ControlKit; go: (view: View) => void }) {group.subtotal === null ? not priced : money(group.subtotal)} ; })} - {needsSku.length > 0 && }
; diff --git a/apps/web/src/app/PartDrawer.tsx b/apps/web/src/app/PartDrawer.tsx index 4f00d6d..028f2c1 100644 --- a/apps/web/src/app/PartDrawer.tsx +++ b/apps/web/src/app/PartDrawer.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; -import { ExternalLink } from 'lucide-react'; -import { dispositions, dispositionLabel, type BomLine } from '@control-kit/shared'; +import { Check, ExternalLink } from 'lucide-react'; +import { dispositions, dispositionLabel, type BomLine, type ClassificationOverride, type VendorRecord } from '@control-kit/shared'; import { Alert } from '../components/arc/alert/alert'; import { Button, DispositionChip, LineCost, StockNote, Thumbnail } from './bits'; import { Drawer, DrawerContent, useDrawerItem } from '../components/arc/drawer/drawer'; import type { ControlKit } from './useControlKit'; -import { money, priceDate, vendorLabel, vendorLookup } from './model'; +import { matchedText, money, movedText, priceDate, productOptions, vendorLabel, vendorLookup } from './model'; /** A `suggestion` that still waits in Review reads "Suggested"; one that decided the part reads as automatic. */ const sourceLabel: Record = { manual: 'Chosen by your team', 'exact-sku': 'Exact vendor SKU', 'explicit-metadata': 'From Onshape data', provenance: 'From where the part comes from', suggestion: 'Decided automatically; change it if wrong', none: 'Not decided' }; @@ -47,22 +47,31 @@ function PartPicture({ kit, line }: { kit: ControlKit; line: BomLine }) { } /** Everything about one part: how it is obtained and why, its vendor product, and its Onshape source. */ -export function PartDrawer({ kit, line, onClose }: { kit: ControlKit; line: BomLine | null; onClose: () => void }) { +/** + * After a choice saves, `onMoved` moves the drawer to the next part in the list it was opened from (or closes it) and + * `notice` says where the last part went. + */ +export function PartDrawer({ kit, line, onClose, onMoved, notice, hasNext }: { kit: ControlKit; line: BomLine | null; onClose: () => void; onMoved: (line: BomLine, text: string) => void; notice: string | null; hasNext: boolean }) { // Keeps the last part on screen while the drawer closes. const shown = useDrawerItem(line); return { if (!open) onClose(); }}> {shown && {/* The page's own error banner is behind the drawer, so a failed save is shown here too. */} {kit.error && } - + {notice &&

{notice}. Showing the next part.

} +
}
; } -function PartDetails({ kit, line }: { kit: ControlKit; line: BomLine }) { +function PartDetails({ kit, line, onMoved, hasNext }: { kit: ControlKit; line: BomLine; onMoved: (line: BomLine, text: string) => void; hasNext: boolean }) { const saving = kit.saving.has(line.bomLineKey); const locked = Boolean(kit.pending) || saving || !kit.result?.scanId || line.suppressed; const vendor = line.vendor; + // A saved choice moves on to the next part; a failed one stays here with its error. + const choose = async (choice: ClassificationOverride | null) => { if (await kit.setDisposition(line, choice)) onMoved(line, movedText(line.name, choice)); }; + const pick = async (record?: VendorRecord) => { if (await kit.decide(line, 'confirmed', record)) onMoved(line, matchedText(line.name, record ?? line.vendor!)); }; + const options = vendor ? [] : productOptions(line, 4); return
{/* What this part is at a glance: how it is obtained, its match, its cost, its stock. */} @@ -83,10 +92,10 @@ function PartDetails({ kit, line }: { kit: ControlKit; line: BomLine }) { {line.suppressed ?

Suppressed parts are not bought or made. Unsuppress it in Onshape and rescan to include it.

: <>
{dispositions.map(value => )} + aria-pressed={line.manualClassification === value} disabled={locked} onClick={() => void choose(value)}>{dispositionLabel[value].replace(' (laser/waterjet/router)', '')})}
- {line.manualClassification && } -

Choices are shared with everyone who scans this document and kept across rescans.

+ {line.manualClassification && } +

Choices are shared with everyone who scans this document and kept across rescans. {hasNext ? 'After a choice, the next part in the list opens.' : 'After a choice, this panel closes.'}

} @@ -107,18 +116,26 @@ function PartDetails({ kit, line }: { kit: ControlKit; line: BomLine }) { {line.choices!.map(choice => { const chosen = line.matchStatus === 'confirmed' && choice.vendorId === vendor.vendorId && choice.normalizedSku === vendor.normalizedSku; return
- + {vendorLabel[choice.vendorId]} {choice.sku} {choice.name}{choice.priceUsd !== null ? `, ${money(choice.priceUsd)} (${priceDate(choice)})` : ''}
; })}
} {line.matchStatus === 'possible' &&
- +
} : <>

{line.matchStatus === 'rejected' ? 'You rejected the suggested product.' : 'No vendor product matched. A part number holding the vendor SKU gives an exact match after the next rescan.'}

+ {options.length > 0 && !line.suppressed &&
+

Closest products in the catalogs. Pick the one this part is:

+ {options.map(option =>
+ + {vendorLabel[option.vendorId]} {option.sku} + {option.name}{option.priceUsd !== null ? `, ${money(option.priceUsd)} (${priceDate(option)})` : ''} +
)} +
} {vendorLookup(line) && Look up {vendorLookup(line)!.sku} on {vendorLookup(line)!.vendor} } } diff --git a/apps/web/src/app/Parts.tsx b/apps/web/src/app/Parts.tsx index abbd232..bf6314f 100644 --- a/apps/web/src/app/Parts.tsx +++ b/apps/web/src/app/Parts.tsx @@ -5,13 +5,14 @@ import SegmentedControl from '../components/arc/segmented-control/segmented-cont import { Select } from '../components/arc/select/select'; import type { ControlKit } from './useControlKit'; import { filterParts, isMake, type PartFilter, type PartSort } from './model'; +import type { Open } from './App'; import { Boxes } from 'lucide-react'; import { Button, DispositionChip, LineCost, PartMeta, SectionHead, Thumbnail } from './bits'; const page = 50; /** Every part in the assembly, filterable by what happens to it. A row opens the part's details. */ -export function Parts({ kit, open }: { kit: ControlKit; open: (key: string) => void }) { +export function Parts({ kit, open }: { kit: ControlKit; open: Open }) { const result = kit.result!; const [filter, setFilter] = useState('all'); const [query, setQuery] = useState(''); @@ -33,7 +34,7 @@ export function Parts({ kit, open }: { kit: ControlKit; open: (key: string) => v
{filter === 'suppressed' &&

Suppressed parts are not counted in quantities or purchasing.

}
-
{rows.slice(0, shown).map(line => ; + const uncertain = result.lines.filter(needsAiReview).length; + const aiButton = kit.ai.enabled && uncertain > 0 && ; if (!queue.length) return <> - } tone="done" title="Review complete" actions={aiButton}>Every part has a decision. New or changed parts show up here after a rescan. + } tone="done" title="Review complete" actions={aiButton}>Every part has a decision and every part to buy has its product. New or changed parts show up here after a rescan. {kit.ai.note &&

{kit.ai.note}

} + {bulkNote && setBulkNote(null)} />} ; + const allShown = visible.length > 0 && visible.every(item => selected.has(item.line.bomLineKey)); return <> - } tone="attention" title={`${queue.length} ${queue.length === 1 ? 'part needs' : 'parts need'} a decision`} actions={aiButton}>Your choices apply to everyone who scans this document and are kept across rescans. Choose a part to see the evidence behind it. + } tone="attention" title={`${queue.length} ${queue.length === 1 ? 'part needs' : 'parts need'} a decision`} actions={aiButton}>Choose how each part is made, and the product for each part to buy. Choices apply to everyone who scans this document and are kept across rescans. {kit.ai.note &&

{kit.ai.note}

} - { setFilter(value as Filter); setShown(page); }} options={[ + { setFilter(value as ReviewFilter); setShown(page); }} options={[ { value: 'all', label: `All ${queue.length}` }, { value: 'match', label: `Vendor matches ${count('match')}` }, { value: 'suggested', label: `Suggested ${count('suggested')}` }, { value: 'undecided', label: `Undecided ${count('undecided')}` }, + ...(count('product') ? [{ value: 'product', label: `No product ${count('product')}` }] : []), ...(count('ai') ? [{ value: 'ai', label: `AI on other parts ${count('ai')}` }] : []), ]} /> + {bulkNote && setBulkNote(null)} />}
- {suggested.length > 1 && (filter === 'all' || filter === 'suggested') &&
+ {suggested.length > 1 && (filter === 'all' || filter === 'suggested') && !selected.size &&

{kit.bulk ? `Saving ${kit.bulk.done + 1} of ${kit.bulk.total}…` : `${suggested.length} parts have a suggestion`}

{Object.entries(suggestedBy).map(([choice, n]) => `${n} ${shortLabel[choice as keyof typeof shortLabel]}`).join(', ')}. Accepting saves each one as if you chose it; you can change any of them later.

} -

Keys: A accept suggestion · B buy · P 3D print · N not needed · {kit.ai.enabled && <>I accept AI · X dismiss AI · }↑↓ move · Enter details

+
+ + {selected.size > 0 && <> + Set to + {bulkChoices.map(choice => )} + + {bulkRunning && Saving {chosen.filter(line => kit.saving.has(line.bomLineKey)).length || chosen.length}…} + } + {!selected.size && Tick parts (shift-click for a range) to give them one type.} +
+

Keys: A accept suggestion · B buy · P 3D print · N not needed · Space select · {kit.ai.enabled && <>I accept AI · X dismiss AI · }↑↓ move · Enter details

{visible.map(({ line, kind }, index) => { const busy = kit.saving.has(line.bomLineKey); // Only a whole-list operation (Accept all, loading shared choices) blocks every row; one save blocks only its own. - const locked = Boolean(kit.pending) || !kit.result!.scanId || busy; + const locked = Boolean(kit.pending) || !result.scanId || busy; // Only the highlighted row's likely answer is primary, so the list never shows a column of volt buttons. const lead = index === active ? 'primary' : 'secondary'; - return
setActive(index)}> + const sub = subassemblyOf(line); + const siblings = sub ? items.filter(item => inSubassembly(item.line, sub)).length : 0; + const options = kind === 'product' && !(line.matchStatus === 'possible' && line.vendor) ? productOptions(line) : []; + const lookup = kind === 'product' ? vendorLookup(line) : null; + return
setActive(index)}> + toggle(index, event)} onChange={() => undefined} />
- + - {kind === 'match' ? `Possible match: ${vendorLabel[line.vendor!.vendorId]} ${line.vendor!.sku}, ${line.vendor!.name}` : line.reviewReason ?? (line.vendor && !line.vendor.packSizeVerified ? 'Pack size unknown, so no total yet' : '')} + {kind === 'match' ? `Possible match: ${vendorLabel[line.vendor!.vendorId]} ${line.vendor!.sku}, ${line.vendor!.name}` : kind === 'product' ? productReason(line, complete, options.length, Boolean(lookup)) : line.reviewReason ?? (line.vendor && !line.vendor.packSizeVerified ? 'Pack size unknown, so no total yet' : '')} {line.aiSuggestion && {aiText(line)}} + {busy && Saving…} + {siblings > 1 && }
{line.aiSuggestion && <> } - {kind === 'match' && <> + {(kind === 'match' || (kind === 'product' && line.matchStatus === 'possible' && line.vendor)) && <> } + {options.map((option, at) => )} + {lookup && Search {lookup.vendor} } {kind === 'suggested' && } - {(kind === 'match' || line.suggestedDisposition !== 'buy') && } + {kind !== 'product' && (kind === 'match' || line.suggestedDisposition !== 'buy') && } {line.suggestedDisposition !== 'print' && } - +
; })}
diff --git a/apps/web/src/app/model.test.ts b/apps/web/src/app/model.test.ts index f3cc8a8..8a57d22 100644 --- a/apps/web/src/app/model.test.ts +++ b/apps/web/src/app/model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { aggregateAssembly, classifyScan, recalculatePurchasing, type AssemblyInstance, type BomLine, type ScanResult, type VendorRecord } from '@control-kit/shared'; -import { buyList, catalogState, catalogStateText, catalogsComplete, composition, feedDates, filterParts, findParts, hasChanges, money, priceDate, primaryAction, readiness, reviewQueue, makeListKey, scanChanges, staleCatalogs, summarize, topCosts, workspaceAccent } from './model'; +import { buyList, catalogState, catalogStateText, catalogsComplete, composition, feedDates, filterParts, findParts, hasChanges, inReview, inSubassembly, matchedText, money, movedText, needsProduct, nextInList, priceDate, primaryAction, productOptions, queueKind, readiness, reviewQueue, makeListKey, scanChanges, staleCatalogs, subassemblyOf, summarize, topCosts, workspaceAccent } from './model'; const vendor = (sku: string, packSize: number | null, priceUsd: number | null): VendorRecord => ({ vendorId: 'gobilda', sku, normalizedSku: sku.replace(/\D/g, ''), name: 'Screw', normalizedName: 'screw', url: 'https://www.gobilda.com/x/', priceUsd, packSize, packSizeVerified: packSize !== null, stock: 'in', stockQty: null, category: 'hardware', updatedAt: '2026-10-24T00:00:00Z', source: 'sitemap' }); @@ -55,9 +55,10 @@ describe('Control Kit view model', () => { const ready = readiness(result, summary); expect(ready.order).toMatchObject({ ready: 2, total: 2, unmatched: 0, unpriced: 0, outOfStock: 1 }); expect(primaryAction(summary, ready)).toMatchObject({ kind: 'review', label: 'Review 1 part' }); - expect(primaryAction({ ...summary, review: 0 }, ready)).toMatchObject({ kind: 'stock', label: 'Replace 1 out-of-stock part' }); - expect(primaryAction({ ...summary, review: 0 }, { ...ready, order: { ...ready.order, unmatched: 3 } }).label).toBe('Fix 3 unmatched parts'); - expect(primaryAction({ ...summary, review: 0 }, { ...ready, order: { ...ready.order, outOfStock: 0 } }).kind).toBe('build-pack'); + expect(primaryAction({ ...summary, toReview: 0 }, ready)).toMatchObject({ kind: 'stock', label: 'Replace 1 out-of-stock part' }); + // Parts to buy without a product are counted in Review's number, so "Review N parts" covers them. + expect(primaryAction({ ...summary, review: 0, unmatched: 3, toReview: 3 }, ready).label).toBe('Review 3 parts'); + expect(primaryAction({ ...summary, toReview: 0 }, { ...ready, order: { ...ready.order, outOfStock: 0 } }).kind).toBe('build-pack'); expect(topCosts(result).map(line => line.partId)).toEqual(['screw', 'bearing']); expect(money(1768.05)).toBe('$1,768.05'); }); @@ -120,8 +121,74 @@ describe('Control Kit view model', () => { const result = scan([...parts('Mystery part', 1, 'a'), ...parts('8mm ball bearing', 5, 'b'), ...parts('Wheel', 1, 'c')], lines => { const wheel = lines.find(line => line.partId === 'c')!; wheel.vendor = vendor('3606-0014-0096', 1, 12); wheel.matchStatus = 'possible'; }); - // The bearing is decided automatically (standard hardware), so only the match question and the open one remain. - expect(reviewQueue(result).map(item => [item.line.name, item.kind])).toEqual([['Wheel', 'match'], ['Mystery part', 'undecided']]); + // The bearing is decided Buy automatically (standard hardware), but no product matched, so it waits for one last. + expect(reviewQueue(result).map(item => [item.line.name, item.kind])).toEqual([['Wheel', 'match'], ['Mystery part', 'undecided'], ['8mm ball bearing', 'product']]); + }); + it('puts parts to buy without a vendor product in Review, counted the same everywhere', () => { + const result = scan([...parts('M4 screw', 12, 'screw'), ...parts('8mm ball bearing', 4, 'bearing'), ...parts('Motor 5203-2402-0019', 1, 'motor'), ...parts('Mystery part', 1, 'mystery')], lines => { + Object.assign(lines.find(line => line.partId === 'screw')!, { vendor: vendor('2802-0004-0010', 10, 8.99), matchStatus: 'exact' }); + // A possible product on a part already decided Buy still needs a person to confirm it. + Object.assign(lines.find(line => line.partId === 'motor')!, { vendor: vendor('5203-2402-0019', 1, 41.99), matchStatus: 'possible' }); + }); + const bearing = result.lines.find(line => line.partId === 'bearing')!; + const motor = result.lines.find(line => line.partId === 'motor')!; + expect([bearing.disposition, motor.disposition]).toEqual(['buy', 'buy']); + // Their group is No product, so a drawer working through another group never returns to them. + expect([queueKind(bearing), queueKind(motor)]).toEqual(['product', 'product']); + expect(result.lines.filter(needsProduct).map(line => line.partId).sort()).toEqual(['bearing', 'motor']); + const summary = summarize(result); + expect(summary).toMatchObject({ review: 1, unmatched: 2, toReview: 3 }); + // The same parts the Buy list holds under "No vendor match yet". + expect(buyList(result).needsSku.map(line => line.partId).sort()).toEqual(['bearing', 'motor']); + expect(readiness(result, summary).order.unmatched).toBe(2); + expect(reviewQueue(result).map(item => [item.line.partId, item.kind])).toEqual([['mystery', 'undecided'], ['bearing', 'product'], ['motor', 'product']]); + expect(primaryAction(summary, readiness(result, summary)).label).toBe('Review 3 parts'); + // The type bar still adds up to the unique count: unmatched Buy parts are counted under Buy, not twice. + expect(composition(summary).reduce((sum, share) => sum + share.count, 0)).toBe(summary.uniqueLines); + expect(composition(summary).find(share => share.key === 'review')).toMatchObject({ label: 'Undecided', count: 1 }); + // Matched and priced, a part leaves Review. + expect(inReview(result.lines.find(line => line.partId === 'screw')!)).toBe(false); + }); + it('offers the possible product, then choices, then near matches, each once', () => { + const a = vendor('1611-0019-0006', 1, 2.5); const b = vendor('1611-0019-0008', 1, 2.5); const c = vendor('1611-0019-0010', 1, 2.5); + const result = scan(parts('8mm ball bearing', 4, 'bearing'), lines => Object.assign(lines[0], { vendor: a, matchStatus: 'possible', choices: [a, b], nearMatches: [b, c] })); + expect(productOptions(result.lines[0]).map(record => record.sku)).toEqual(['1611-0019-0006', '1611-0019-0008', '1611-0019-0010']); + expect(productOptions(result.lines[0], 2)).toHaveLength(2); + // Unmatched, only its own records are offered. + const unmatched = scan(parts('8mm ball bearing', 4, 'bearing'), lines => Object.assign(lines[0], { nearMatches: [c] })); + expect(productOptions(unmatched.lines[0]).map(record => record.sku)).toEqual(['1611-0019-0010']); + expect(matchedText('Bearing', c)).toBe('Bearing matched to goBILDA 1611-0019-0010'); + }); + it('groups parts by the subassembly they sit in, at any depth', () => { + const base = scan(parts('Mystery part', 1, 'top')).lines[0]; + // Only the paths matter here. + const line = (name: string, ...paths: string[]) => ({ ...base, name, instancePaths: paths }); + const roller = line('Roller', 'Robot > Right Pod <1> > Intake <1> > Roller <1>', 'Robot > Left Pod <1> > Intake <2> > Roller <1>'); + const plate = line('Plate', 'Robot > Right Pod <2> > Plate <1>'); + const top = line('Frame', 'Robot > Frame <1>'); + const leftIntake = line('Intake roller', 'Robot > Left Pod <1> > Intake <1> > Intake roller <1>'); + const rightIntake = subassemblyOf(roller)!; + expect(rightIntake).toEqual({ name: 'Intake', path: ['Right Pod', 'Intake'] }); + expect(subassemblyOf(plate)).toEqual({ name: 'Right Pod', path: ['Right Pod'] }); + expect(subassemblyOf(top)).toBeNull(); + // Every copy of the right pod is the same subassembly; the left pod's Intake is a different one of the same name. + expect(inSubassembly(plate, { name: 'Right Pod', path: ['Right Pod'] })).toBe(true); + expect(inSubassembly(roller, { name: 'Right Pod', path: ['Right Pod'] })).toBe(true); + expect(inSubassembly(leftIntake, rightIntake)).toBe(false); + expect(inSubassembly(plate, rightIntake)).toBe(false); + // The part itself is not its own subassembly. + expect(inSubassembly(top, { name: 'Frame', path: ['Frame'] })).toBe(false); + }); + it('moves the drawer to the next part still in its list, wrapping to skipped ones, and closes when none is left', () => { + const keys = ['a', 'b', 'c', 'd']; + expect(nextInList(keys, 'b', () => true)).toBe('c'); + expect(nextInList(keys, 'b', key => key !== 'c')).toBe('d'); + expect(nextInList(keys, 'd', key => key === 'a')).toBe('a'); + expect(nextInList(keys, 'b', key => key === 'b')).toBeNull(); + expect(nextInList(keys, 'gone', key => key === 'c')).toBe('c'); + expect(movedText('Bracket', 'print')).toBe('Bracket moved to 3D print'); + expect(movedText('Bracket', 'ignore')).toBe('Bracket moved to Not needed'); + expect(movedText('Bracket', null)).toBe('Bracket is back on the automatic choice'); }); it('filters and searches parts by disposition, part number, and SKU', () => { const result = scan([...parts('Sensor mount [3DP]', 1, 'm'), ...parts('M3 screw', 2, 's'), { id: 'x', name: 'Spare', type: 'Part', documentId: 'doc', elementId: 'studio', partId: 'x', suppressed: true }], lines => { diff --git a/apps/web/src/app/model.ts b/apps/web/src/app/model.ts index 67f21ab..c9b072c 100644 --- a/apps/web/src/app/model.ts +++ b/apps/web/src/app/model.ts @@ -37,10 +37,14 @@ export function feedDates(lines: BomLine[]) { export const priced = (line: BomLine) => line.lineTotalUsd !== null; /** Priced, or ordered under another line that carries the price for the same SKU. */ export const covered = (line: BomLine) => priced(line) || Boolean(line.orderedWith); +/** A part to buy that no order can include yet: it has no exact or confirmed vendor product. It waits in Review. */ +export const needsProduct = (line: BomLine) => line.disposition === 'buy' && !(line.vendor && (line.matchStatus === 'exact' || line.matchStatus === 'confirmed')); export type Summary = { uniqueLines: number; instances: number; suppressedInstances: number; - buy: number; make: Record<'print' | 'cnc' | 'cut-2d' | 'fabricate', number> & { total: number }; review: number; + buy: number; make: Record<'print' | 'cnc' | 'cut-2d' | 'fabricate', number> & { total: number }; + /** Parts whose type is undecided; `unmatched` Buy parts have no vendor product. Review holds both, `toReview` in all. */ + review: number; unmatched: number; toReview: number; /** Stock fasteners and parts a person set aside; with buy, make, and review they add up to `uniqueLines`. */ hardware: number; ignored: number; /** Known subtotal over priced Buy lines only; `pricedLines` of `buyLines` carry a price. */ @@ -57,7 +61,7 @@ export function summarize(result: ScanResult): Summary { make.total = make.print + make.cnc + make['cut-2d'] + make.fabricate; return { uniqueLines: lines.length, instances: result.stats.totalInstances, suppressedInstances: result.suppressed.reduce((sum, line) => sum + line.qty, 0), - buy: buy.length, make, review: count('review'), hardware: count('hardware'), ignored: count('ignore'), + buy: buy.length, make, review: count('review'), unmatched: buy.filter(needsProduct).length, toReview: count('review') + buy.filter(needsProduct).length, hardware: count('hardware'), ignored: count('ignore'), subtotal: pricedBuy.length ? Math.round(pricedBuy.reduce((sum, line) => sum + line.lineTotalUsd!, 0) * 100) / 100 : null, pricedLines: coveredBuy.length, buyLines: buy.length, dataIssues: result.missingMetadata.filter(line => !line.suppressed).length, @@ -66,29 +70,89 @@ export function summarize(result: ScanResult): Summary { }; } -/** Why a line is in the review queue, which decides the actions it offers. */ -export type QueueKind = 'match' | 'suggested' | 'undecided'; +/** + * Why a line is in the review queue, which decides the actions it offers: a possible match, a suggested or open type, + * or (`product`) a part to buy with no vendor product yet. + */ +export type QueueKind = 'match' | 'suggested' | 'undecided' | 'product'; export type QueueItem = { line: BomLine; kind: QueueKind }; -/** Possible vendor matches first (one click settles both identity and Buy), then suggestions, then open questions. */ +/** Whether a line waits in Review: its type is undecided, or it is to be bought but no product matched. */ +export const inReview = (line: BomLine) => line.disposition === 'review' || needsProduct(line); +/** The Review group a line waiting in Review belongs to. */ +export const queueKind = (line: BomLine): QueueKind => line.disposition === 'buy' ? 'product' : line.matchStatus === 'possible' && line.vendor ? 'match' : line.suggestedDisposition ? 'suggested' : 'undecided'; +/** + * Possible vendor matches first (one click settles both identity and Buy), then suggestions, then open questions, then + * parts to buy that still need their product. + */ export function reviewQueue(result: ScanResult): QueueItem[] { - const items = result.lines.filter(line => line.disposition === 'review').map((line): QueueItem => ({ - line, kind: line.matchStatus === 'possible' && line.vendor ? 'match' : line.suggestedDisposition ? 'suggested' : 'undecided', - })); - const order: Record = { match: 0, suggested: 1, undecided: 2 }; + const items = result.lines.filter(inReview).map((line): QueueItem => ({ line, kind: queueKind(line) })); + const order: Record = { match: 0, suggested: 1, undecided: 2, product: 3 }; return items.sort((a, b) => order[a.kind] - order[b.kind] || b.line.qty - a.line.qty || a.line.name.localeCompare(b.line.name)); } -export type VendorGroup = { vendorId: VendorId; label: string; lines: BomLine[]; subtotal: number | null; unpriced: number }; /** - * The Buy list: lines a team can order, grouped by vendor. Only exact or confirmed matches appear under a vendor; - * Buy lines without one are listed separately, since they need a SKU before they can be ordered. + * Products a person can pick for a part to buy: its possible match, then its choices, then its closest unchosen + * records, each once. Picking one saves a confirmed match, the same as "Use this" in the part drawer. */ +export function productOptions(line: BomLine, count = 3): VendorRecord[] { + const seen = new Set(); + const options: VendorRecord[] = []; + for (const record of [line.matchStatus === 'possible' ? line.vendor : null, ...(line.choices ?? []), ...(line.nearMatches ?? [])]) { + if (!record || seen.has(`${record.vendorId}:${record.normalizedSku}`)) continue; + seen.add(`${record.vendorId}:${record.normalizedSku}`); + options.push(record); + } + return options.slice(0, count); +} + +/** Where a choice sent a part, for the confirmation after it saves: "Bracket moved to 3D print". */ +const destination: Record = { buy: 'Buy', print: '3D print', cnc: 'CNC', 'cut-2d': '2D cut', fabricate: 'Other fabrication', hardware: 'Fasteners', ignore: 'Not needed', review: 'Review' }; +export function movedText(name: string, choice: Disposition | null) { + return choice === null ? `${name} is back on the automatic choice` : `${name} moved to ${destination[choice]}`; +} +export const matchedText = (name: string, record: VendorRecord) => `${name} matched to ${vendorLabel[record.vendorId]} ${record.sku}`; + +/** A path segment without Onshape's instance number: "Right Pod <2>" is "Right Pod". */ +const segment = (name: string) => name.replace(/\s*<\d+>$/, '').trim(); +/** + * The subassembly a part sits directly in, by its first placement: its name and its path from the top without instance + * numbers ("Robot > Right Pod <1> > Intake <1> > Roller" is Intake at ["Right Pod", "Intake"]). The path keeps two + * unrelated subassemblies of one name (an Intake in each pod) apart, while every copy of one subassembly ("Pod <1>" and + * "Pod <2>") is the same. Null for a part placed at the top level of the assembly. + */ +export type Subassembly = { name: string; path: string[] }; +const ancestors = (path: string) => path.split(' > ').slice(1, -1).map(segment); +export function subassemblyOf(line: BomLine): Subassembly | null { + const path = line.instancePaths[0] ? ancestors(line.instancePaths[0]) : []; + return path.length ? { name: path[path.length - 1], path } : null; +} +/** Whether any placement of the part sits inside that subassembly, at any depth below it. */ +export const inSubassembly = (line: BomLine, sub: Subassembly) => line.instancePaths.some(path => { + const above = ancestors(path); + return above.length >= sub.path.length && sub.path.every((part, index) => above[index] === part); +}); + +/** + * The part a drawer moves to after a choice: the next one after `current` in the list it was opened from that is still + * listed there, wrapping round to earlier ones the person skipped; null when none is left. + */ +export function nextInList(keys: string[], current: string, listed: (key: string) => boolean): string | null { + const at = keys.indexOf(current); + const order = at < 0 ? keys : [...keys.slice(at + 1), ...keys.slice(0, at)]; + return order.find(key => key !== current && listed(key)) ?? null; +} + +export type VendorGroup = { vendorId: VendorId; label: string; lines: BomLine[]; subtotal: number | null; unpriced: number }; /** Fasteners taken from stock: listed with their CAD counts, never priced unless an exact SKU makes them Buy. */ export const fasteners = (result: ScanResult) => result.lines.filter(line => line.disposition === 'hardware').sort((a, b) => a.name.localeCompare(b.name)); +/** + * The Buy list: lines a team can order, grouped by vendor. Only exact or confirmed matches appear under a vendor; + * Buy lines without one (`needsSku`, the same parts Review lists as needing a product) are listed separately. + */ export function buyList(result: ScanResult) { const buy = result.lines.filter(line => line.disposition === 'buy'); - const matched = buy.filter(line => line.vendor && (line.matchStatus === 'exact' || line.matchStatus === 'confirmed')); + const matched = buy.filter(line => !needsProduct(line)); const groups = new Map(); for (const line of matched) groups.set(line.vendor!.vendorId, [...(groups.get(line.vendor!.vendorId) ?? []), line]); const vendors: VendorGroup[] = [...groups.entries()].map(([vendorId, lines]) => { @@ -168,11 +232,11 @@ export function readiness(result: ScanResult, summary: Summary = summarize(resul export const percent = (part: number, whole: number) => whole ? Math.round((part / whole) * 100) : 0; /** The one thing to do next. Only one action is ever primary; it follows the first open problem in the order a team works. */ -export type PrimaryAction = { kind: 'review' | 'unmatched' | 'unpriced' | 'stock' | 'build-pack'; label: string; count: number }; +// Parts to buy without a product are in Review, so "Review N parts" counts them and no separate unmatched step exists. +export type PrimaryAction = { kind: 'review' | 'unpriced' | 'stock' | 'build-pack'; label: string; count: number }; export function primaryAction(summary: Summary, ready: Readiness): PrimaryAction { const n = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`; - if (summary.review) return { kind: 'review', label: `Review ${n(summary.review, 'part', 'parts')}`, count: summary.review }; - if (ready.order.unmatched) return { kind: 'unmatched', label: `Fix ${n(ready.order.unmatched, 'unmatched part', 'unmatched parts')}`, count: ready.order.unmatched }; + if (summary.toReview) return { kind: 'review', label: `Review ${n(summary.toReview, 'part', 'parts')}`, count: summary.toReview }; if (ready.order.unpriced) return { kind: 'unpriced', label: `Check ${n(ready.order.unpriced, 'unpriced part', 'unpriced parts')}`, count: ready.order.unpriced }; if (ready.order.outOfStock) return { kind: 'stock', label: `Replace ${n(ready.order.outOfStock, 'out-of-stock part', 'out-of-stock parts')}`, count: ready.order.outOfStock }; return { kind: 'build-pack', label: 'Download build pack', count: 0 }; @@ -190,7 +254,8 @@ export type Share = { key: 'buy' | 'make' | 'hardware' | 'review' | 'ignored'; l export function composition(summary: Summary): Share[] { const shares: Share[] = [ { key: 'buy', label: 'Buy', count: summary.buy }, { key: 'make', label: 'Make', count: summary.make.total }, - { key: 'hardware', label: 'Fasteners', count: summary.hardware }, { key: 'review', label: 'Needs review', count: summary.review }, + // Type still undecided. Review also lists parts to buy without a product, which this bar counts under Buy. + { key: 'hardware', label: 'Fasteners', count: summary.hardware }, { key: 'review', label: 'Undecided', count: summary.review }, { key: 'ignored', label: 'Not needed', count: summary.ignored }, ]; return shares.filter(share => share.count); diff --git a/apps/web/src/app/useControlKit.ts b/apps/web/src/app/useControlKit.ts index e192325..320c0bc 100644 --- a/apps/web/src/app/useControlKit.ts +++ b/apps/web/src/app/useControlKit.ts @@ -448,6 +448,42 @@ export function useControlKit(context: URLSearchParams) { } finally { setBulk(null); setPending(null); } } + /** + * One type for several parts (Review's selection). Each part saves on its own, four at a time, with its own saving + * state, so the rest of the list stays usable; rows of one part (two revisions) save once. A rescan or sign-out stops + * starting new saves. Returns the parts that saved and those that did not, so the caller can keep the failures. + */ + async function setDispositions(lines: BomLine[], choice: ClassificationOverride | null): Promise<{ saved: BomLine[]; failed: BomLine[] }> { + const saved: BomLine[] = []; const failed: BomLine[] = []; + if (!result || pending) return { saved, failed: lines }; + const generation = scanGeneration.current; + const identities = new Set(); + const todo = lines.filter(line => { + const identity = classificationIdentity(line); + if (identities.has(identity)) return false; + identities.add(identity); + if (saving.has(line.bomLineKey)) { failed.push(line); return false; } + return true; + }); + // A part the person changed after choosing the batch (in the drawer, or a row button while it waited in the queue) + // keeps that newer choice: it is skipped, not overwritten, and counts as neither saved nor failed. + const edited = editedSince({ clock: editClock.current, saving: new Set(savingNow.current) }); + const rowsOf = (line: BomLine) => (resultRef.current?.lines ?? []).filter(item => classificationIdentity(item) === classificationIdentity(line)).map(item => item.bomLineKey); + let next = 0; + const worker = async () => { + while (next < todo.length) { + const line = todo[next++]; + if (generation !== scanGeneration.current) { failed.push(line); continue; } + if ([line.bomLineKey, ...rowsOf(line)].some(key => edited('type', key))) continue; + markSaving(line.bomLineKey, true, 'type'); + try { (await saveChoice(line, choice) ? saved : failed).push(line); } + finally { markSaving(line.bomLineKey, false, 'type'); } + } + }; + await Promise.all(Array.from({ length: Math.min(4, todo.length) }, worker)); + return { saved, failed }; + } + async function saveSettings(teamNumber: string) { if (savingSettings) return false; if (teamNumber && !/^\d{1,6}$/.test(teamNumber)) { setError('Team number must be 1 to 6 digits.'); return false; } @@ -616,6 +652,6 @@ export function useControlKit(context: URLSearchParams) { } return { assemblyPicture, redrawAssemblyPicture, choiceTimes, refreshChoices, context, buildWorkbook, lastStepZip, previous, ai, runAiReview, acceptAi, dismissAi, saving, showInOnshape, partPicture, session, checkingSession, refreshSession, connectUrl, error, clearError: () => setError(''), result, progress, scan, cancelScan, decide, setDisposition, - acceptSuggestions, bulk, pending, settings, savingSettings, saveSettings, signOut, excel, downloadExcel, downloadCsv, step, downloadStep }; + acceptSuggestions, setDispositions, bulk, pending, settings, savingSettings, saveSettings, signOut, excel, downloadExcel, downloadCsv, step, downloadStep }; } export type ControlKit = ReturnType; diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css index 4c03a88..8c1589b 100644 --- a/apps/web/src/styles/theme.css +++ b/apps/web/src/styles/theme.css @@ -523,3 +523,29 @@ a.brand { color: inherit; text-decoration: none; } } a.nav-item { text-decoration: none; } .control-point .panel-head > .link { white-space: nowrap; flex: none; } + +/* Review selection: a checkbox column, a bar that sets one type for every selected part, and a selected tint. */ +.rows .row.selectable { grid-template-columns: auto minmax(0, 1fr) auto; } +.row-check, .bulk-bar input[type="checkbox"] { width: 16px; height: 16px; margin: 0; accent-color: var(--ck-volt); cursor: pointer; align-self: start; margin-top: 3px; } +.row.selected { background: color-mix(in srgb, var(--ck-volt) 5%, transparent); } +.row.selected.focused { background: color-mix(in srgb, var(--ck-volt) 11%, transparent); } +.row[aria-busy="true"] { opacity: 0.7; } +@container (max-width: 1000px) { + .rows .row.review-row.selectable { grid-template-columns: auto minmax(0, 1fr); } + .rows .review-row.selectable .row-aside { grid-column: 2; } +} +@media (max-width: 520px) { + .rows .row.selectable { grid-template-columns: auto minmax(0, 1fr); } + .rows .row.selectable .row-aside { grid-column: 2; } +} +.sub-select { justify-self: start; color: var(--ck-text-muted); } +.bulk-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--ck-line); } +.bulk-bar.on { position: sticky; top: 57px; z-index: 2; background: color-mix(in srgb, var(--ck-volt) 6%, var(--ck-card)); box-shadow: inset 3px 0 0 var(--ck-volt); } +.bulk-bar .check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 500; cursor: pointer; } +.bulk-bar .check input { margin-top: 0; } +@media (max-width: 899px) { .bulk-bar.on { top: 104px; } } + +/* Where a choice sent a part: green, in the drawer when it moves on and at the page's foot when it closes. */ +.drawer-notice { margin-bottom: 12px; display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--ck-done); padding: 8px 10px; border-radius: 8px; background: color-mix(in srgb, var(--ck-done) 10%, transparent); } +.toast { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60; max-width: calc(100vw - 32px); padding: 10px 14px; border-radius: 10px; font-size: 13px; + color: var(--ck-done); background: var(--ck-raised); border: 1px solid color-mix(in srgb, var(--ck-done) 40%, transparent); box-shadow: 0 8px 24px rgb(0 0 0 / 0.4); } diff --git a/apps/worker/src/catalog.test.ts b/apps/worker/src/catalog.test.ts index 9d424bf..78e766e 100644 --- a/apps/worker/src/catalog.test.ts +++ b/apps/worker/src/catalog.test.ts @@ -300,6 +300,14 @@ describe('known products and SKU choices', () => { // Picking another choice later replaces the first pick. const changed = applyMatchDecision(result, 'line-0', 'confirmed', { vendorId: 'gobilda', normalizedSku: normalizeSku('3110-0002-0001', 'gobilda') }); expect(changed.lines[0].vendor?.sku).toBe('3110-0002-0001'); + // After rejecting the shown product, Review offers the near matches; picking one confirms it. + const near = { ...changed.lines[0].vendor!, sku: '3110-0002-0009', normalizedSku: normalizeSku('3110-0002-0009', 'gobilda') }; + const rejected = applyMatchDecision({ ...changed, lines: changed.lines.map(line => ({ ...line, matchStatus: 'possible' as const, nearMatches: [near] })) }, 'line-0', 'rejected'); + expect(rejected.lines[0]).toMatchObject({ matchStatus: 'rejected', vendor: null }); + const picked = applyMatchDecision(rejected, 'line-0', 'confirmed', near); + expect(picked.lines[0]).toMatchObject({ matchStatus: 'confirmed', vendor: { sku: '3110-0002-0009' } }); + // A record that is none of the line's own still confirms nothing. + expect(applyMatchDecision(rejected, 'line-0', 'confirmed', { vendorId: 'gobilda', normalizedSku: '99999' }).lines[0].matchStatus).toBe('rejected'); }); it('asks which product a kit is when its own bodies name a different SKU of the same family', async () => { clearCatalogMemoryCache(); diff --git a/docs/screenshots/review-flow/buy-no-match-1440.png b/docs/screenshots/review-flow/buy-no-match-1440.png new file mode 100644 index 0000000..da05943 Binary files /dev/null and b/docs/screenshots/review-flow/buy-no-match-1440.png differ diff --git a/docs/screenshots/review-flow/buy-no-match-380.png b/docs/screenshots/review-flow/buy-no-match-380.png new file mode 100644 index 0000000..80b7d91 Binary files /dev/null and b/docs/screenshots/review-flow/buy-no-match-380.png differ diff --git a/docs/screenshots/review-flow/drawer-moved-1440.png b/docs/screenshots/review-flow/drawer-moved-1440.png new file mode 100644 index 0000000..fd9cb24 Binary files /dev/null and b/docs/screenshots/review-flow/drawer-moved-1440.png differ diff --git a/docs/screenshots/review-flow/drawer-moved-380.png b/docs/screenshots/review-flow/drawer-moved-380.png new file mode 100644 index 0000000..3b8daa1 Binary files /dev/null and b/docs/screenshots/review-flow/drawer-moved-380.png differ diff --git a/docs/screenshots/review-flow/overview-1440.png b/docs/screenshots/review-flow/overview-1440.png new file mode 100644 index 0000000..28d152d Binary files /dev/null and b/docs/screenshots/review-flow/overview-1440.png differ diff --git a/docs/screenshots/review-flow/overview-380.png b/docs/screenshots/review-flow/overview-380.png new file mode 100644 index 0000000..81117c6 Binary files /dev/null and b/docs/screenshots/review-flow/overview-380.png differ diff --git a/docs/screenshots/review-flow/review-1440.png b/docs/screenshots/review-flow/review-1440.png new file mode 100644 index 0000000..58dd7f2 Binary files /dev/null and b/docs/screenshots/review-flow/review-1440.png differ diff --git a/docs/screenshots/review-flow/review-380.png b/docs/screenshots/review-flow/review-380.png new file mode 100644 index 0000000..9a8ea8c Binary files /dev/null and b/docs/screenshots/review-flow/review-380.png differ diff --git a/docs/screenshots/review-flow/review-bulk-done-1440.png b/docs/screenshots/review-flow/review-bulk-done-1440.png new file mode 100644 index 0000000..9e1c0c0 Binary files /dev/null and b/docs/screenshots/review-flow/review-bulk-done-1440.png differ diff --git a/docs/screenshots/review-flow/review-bulk-done-380.png b/docs/screenshots/review-flow/review-bulk-done-380.png new file mode 100644 index 0000000..ece9cbd Binary files /dev/null and b/docs/screenshots/review-flow/review-bulk-done-380.png differ diff --git a/docs/screenshots/review-flow/review-no-product-1440.png b/docs/screenshots/review-flow/review-no-product-1440.png new file mode 100644 index 0000000..012262f Binary files /dev/null and b/docs/screenshots/review-flow/review-no-product-1440.png differ diff --git a/docs/screenshots/review-flow/review-no-product-380.png b/docs/screenshots/review-flow/review-no-product-380.png new file mode 100644 index 0000000..311544b Binary files /dev/null and b/docs/screenshots/review-flow/review-no-product-380.png differ diff --git a/docs/screenshots/review-flow/review-selection-1440.png b/docs/screenshots/review-flow/review-selection-1440.png new file mode 100644 index 0000000..3e55dfd Binary files /dev/null and b/docs/screenshots/review-flow/review-selection-1440.png differ diff --git a/docs/screenshots/review-flow/review-selection-380.png b/docs/screenshots/review-flow/review-selection-380.png new file mode 100644 index 0000000..8da1bbb Binary files /dev/null and b/docs/screenshots/review-flow/review-selection-380.png differ diff --git a/fixtures/assembly-patterns.json b/fixtures/assembly-patterns.json new file mode 100644 index 0000000..8967c80 --- /dev/null +++ b/fixtures/assembly-patterns.json @@ -0,0 +1,225 @@ +{ + "rootAssembly": { + "name": "Pattern Robot", + "instances": [ + { + "id": "plate", + "name": "Side Plate <1>", + "type": "Part", + "documentId": "pat", + "elementId": "plates", + "partId": "plate" + }, + { + "id": "s0", + "name": "M4 x 12mm Socket Head Screw <1>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "s1", + "name": "M4 x 12mm Socket Head Screw <2>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "s2", + "name": "M4 x 12mm Socket Head Screw <3>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "s3", + "name": "M4 x 12mm Socket Head Screw <4>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "r4", + "name": "M4 x 12mm Socket Head Screw <5>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "r5", + "name": "M4 x 12mm Socket Head Screw <6>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "r6", + "name": "M4 x 12mm Socket Head Screw <7>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "r7", + "name": "M4 x 12mm Socket Head Screw <8>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "r8", + "name": "M4 x 12mm Socket Head Screw <9>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "r9", + "name": "M4 x 12mm Socket Head Screw <10>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "screw" + }, + { + "id": "n0", + "name": "M4 Nylock Nut <1>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "nut" + }, + { + "id": "n1", + "name": "M4 Nylock Nut <2>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "nut" + }, + { + "id": "n2", + "name": "M4 Nylock Nut <3>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "nut", + "suppressed": true + }, + { + "id": "pod1", + "name": "Pod <1>", + "type": "Assembly", + "documentId": "pat", + "elementId": "pod" + }, + { + "id": "pod2", + "name": "Pod <2>", + "type": "Assembly", + "documentId": "pat", + "elementId": "pod" + } + ], + "patterns": [ + { + "id": "circ", + "name": "Circular pattern 1", + "type": "CIRCULAR", + "suppressed": false, + "seedToPatternInstances": { + "s0": [ + "s1", + "s2", + "s3" + ] + } + }, + { + "id": "lin", + "name": "Linear pattern 1", + "type": "LINEAR", + "suppressed": true, + "seedToPatternInstances": { + "n1": [ + "n2" + ] + } + } + ], + "features": [ + { + "id": "rep1", + "featureType": "replicate", + "suppressed": false, + "featureData": { + "name": "Replicate 1", + "instanceIds": [ + "r4", + "r5", + "r6", + "r7", + "r8", + "r9" + ] + } + } + ] + }, + "subAssemblies": [ + { + "documentId": "pat", + "elementId": "pod", + "instances": [ + { + "id": "sp0", + "name": "Standoff <1>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "standoff" + }, + { + "id": "sp1", + "name": "Standoff <2>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "standoff" + }, + { + "id": "sp2", + "name": "Standoff <3>", + "type": "Part", + "documentId": "pat", + "elementId": "hardware", + "partId": "standoff" + } + ], + "patterns": [ + { + "id": "lin2", + "name": "Linear pattern 2", + "type": "LINEAR", + "suppressed": false, + "seedToPatternInstances": { + "sp0": [ + "sp1", + "sp2" + ] + } + } + ], + "features": [] + } + ] +} diff --git a/packages/excel/src/workbook.ts b/packages/excel/src/workbook.ts index 38994d4..f575b12 100644 --- a/packages/excel/src/workbook.ts +++ b/packages/excel/src/workbook.ts @@ -88,7 +88,7 @@ function purchaseNotes(line: BomLine, result: ScanResult) { if (line.priceCheck) values.push(`${line.priceCheck}; not priced`); if (ordered(line) && line.vendor!.priceUsd === null) values.push('No price, so no total'); if (result.stats.staleVendorData && ordered(line)) values.push('Vendor data may be out of date'); - if (line.disposition === 'hardware') values.push('Fastener from stock: CAD count can be low if Replicate or patterns placed some'); + if (line.disposition === 'hardware') values.push('Fastener from stock: CAD count includes pattern and Replicate copies, not fasteners that were never modelled'); if (line.purchasedAssembly) values.push(`One product (${line.purchasedAssembly.parts} internal parts not listed${line.purchasedAssembly.includes?.length ? `; includes ${line.purchasedAssembly.includes.join(', ')}` : ''})`); if (line.order && line.order.lines > 1) values.push(`Packs cover ${line.order.qty} pieces across ${line.order.lines} CAD lines with this SKU`); if (line.orderedWith) values.push(`Ordered with ${result.lines.find(other => other.bomLineKey === line.orderedWith)?.name ?? 'another row'} (same SKU)`); diff --git a/packages/shared/src/classifier.ts b/packages/shared/src/classifier.ts index 5c27b75..dabb17a 100644 --- a/packages/shared/src/classifier.ts +++ b/packages/shared/src/classifier.ts @@ -5,7 +5,8 @@ import { recalculatePurchasing } from './purchasing'; * How the team will get the physical part. `review` means nothing reliable decided it yet. * Suppression is an assembly state, not a disposition, and lives on `BomLine.suppressed`. * `hardware` is a fastener (screw, nut, washer, clip) that teams take from their hardware stock: it needs no decision, - * and its CAD count is a guide only, since Replicate and patterns can place fasteners the count may not see. + * and its CAD count is every placement, including pattern and Replicate copies (the assembly definition lists them as + * instances); fasteners that were never modelled are not counted. * `ignore` ("Not needed") is set only by a person, for a body that is not a real part to buy or make (a shaft that is * part of an imported motor, a reference body); it leaves Review, Buy, and Make but stays listed. */ diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index abda9ee..0f6daf7 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -16,6 +16,19 @@ describe('assembly traversal and aggregation', () => { expect(hub).toMatchObject({ qty: 1, metadataIssue: { code: 'unresolved-reference' }, partStudioUrl: '' }); expect(result.lines).toHaveLength(2); }); + it('counts every copy placed by a pattern or Replicate, which the definition lists as instances', () => { + // Shape from Onshape's OpenAPI (BTRootAssemblyInfo/BTSubAssemblyInfo): `instances` includes "those created by patterns + // and replicates"; `patterns` only maps each seed to its copies and `features` holds the Replicate feature itself. + const result = aggregateAssembly(fixture('patterns'), { ...context, documentId: 'pat' }, 'Pattern Robot'); + const qty = (name: string) => result.lines.find(line => line.name === name)?.qty; + // A seed plus three circular copies, plus six Replicate copies: ten screws. + expect(qty('M4 x 12mm Socket Head Screw')).toBe(10); + // A linear pattern of three standoffs in a pod placed twice: six. + expect(qty('Standoff')).toBe(6); + // A suppressed pattern's copy is suppressed on the instance, so it leaves the active count. + expect(qty('M4 Nylock Nut')).toBe(2); + expect(result.suppressed.find(line => line.name === 'M4 Nylock Nut')?.qty).toBe(1); + }); it('recurses through two module occurrences and excludes suppressed parts from active totals', () => { const result = aggregateAssembly(fixture('simple'), context, 'Simple FTC Assembly'); expect(result.stats.totalInstances).toBe(11); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 23ac00c..bf59942 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -117,7 +117,8 @@ export function applyMatchDecision(result: ScanResult, key: string, decision: 'c const picked = line && pick ? decisionRecord(line, pick) : null; // A pick that is none of the line's records must not confirm the record that happens to be shown. if (pick && !picked) return next; - if (!line || (line.matchStatus !== 'possible' && !(picked && ['confirmed', 'unmatched'].includes(line.matchStatus)))) return next; + // A pick can follow an earlier pick, no match at all, or a rejection (Review offers a rejected part's near matches). + if (!line || (line.matchStatus !== 'possible' && !(picked && ['confirmed', 'unmatched', 'rejected'].includes(line.matchStatus)))) return next; if (picked) line.vendor = picked; line.matchStatus = decision; if (decision === 'rejected') { line.vendor = null; line.alternatives = []; } diff --git a/samples/FTC_Competition_Robot_BOM_2026-10-23.xlsx b/samples/FTC_Competition_Robot_BOM_2026-10-23.xlsx index cdfb167..d6b99f1 100644 Binary files a/samples/FTC_Competition_Robot_BOM_2026-10-23.xlsx and b/samples/FTC_Competition_Robot_BOM_2026-10-23.xlsx differ diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index a331f53..eef7d86 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -47,9 +47,12 @@ test('embedded connection opens outside the panel and can be rechecked', async ( test('overview totals, next steps, and Excel and CSV downloads', async ({ page }) => { await connected(page); const review = result.lines.filter(line => line.disposition === 'review').length; + // Review also holds parts to buy without a vendor product (none match here: no catalog is served). + const toReview = review + result.lines.filter(line => line.disposition === 'buy').length; // One primary action, led by the parts still waiting for a decision; the money card never shows a $0 total. - await expect(page.locator('.primary-bar button')).toHaveText(review ? new RegExp(`^Review ${review} parts?$`) : /./); - await expect(page.locator('.share-legend')).toContainText(`Needs review ${review}`); + await expect(page.locator('.primary-bar button')).toHaveText(new RegExp(`^Review ${toReview} parts?$`)); + await expect(page.locator('.sidebar').getByRole('button', { name: /^Review/ })).toContainText(String(toReview)); + await expect(page.locator('.share-legend')).toContainText(`Undecided ${review}`); await expect(page.locator('.money-value')).toHaveText('None priced'); const download = page.waitForEvent('download'); await page.getByRole('button', { name: 'Excel BOM' }).click(); @@ -121,12 +124,15 @@ test('review explains each part, saves a one-click choice, and accepts all sugge await section(page, /^Review/).click(); // Fasteners never wait for a decision. await expect(page.locator('.row').filter({ hasText: 'M3 Screw' })).toHaveCount(0); - // Standard hardware is decided automatically now; only how team-made parts are produced waits for a person. - await expect(page.locator('.row').filter({ hasText: 'Bearing' })).toHaveCount(0); + // Standard hardware is decided Buy automatically, but with no vendor product it waits here for one. + await expect(page.locator('.row').filter({ hasText: 'Bearing' })).toContainText('Buy, but no vendor product matched'); + await expect(page.getByRole('group', { name: 'Show' }).getByRole('button', { name: 'No product 2' })).toBeVisible(); const first = page.locator('.row').filter({ has: page.getByRole('button', { name: 'Buy' }) }).first(); const firstName = (await first.locator('.row-title').innerText()).trim(); await first.getByRole('button', { name: 'Buy', exact: true }).click(); - await expect(page.locator('.row').filter({ hasText: firstName })).toHaveCount(0); + // Marked Buy, it still needs its product, so it stays in Review as one. + await expect(page.locator('.row').filter({ hasText: firstName })).toContainText('Buy, but no vendor product matched'); + await expect(page.getByRole('group', { name: 'Show' }).getByRole('button', { name: 'No product 3' })).toBeVisible(); expect(saved.at(-1)).toMatchObject({ classification: 'buy' }); const remaining = withPrints.lines.filter(line => line.disposition === 'review' && line.suggestedDisposition && line.name !== firstName).length; expect(remaining).toBeGreaterThan(1); @@ -192,16 +198,78 @@ test('reopening the panel shows the last scan, and Not needed dismisses a part', await row.getByRole('button', { name: 'Not needed' }).click(); await expect(page.locator('.row').filter({ hasText: name })).toHaveCount(0); expect(saved.at(-1)).toMatchObject({ classification: 'ignore' }); - // Keyboard: B decides the highlighted part, which leaves the list; the highlight stays on the next one. + // Keyboard: P decides the highlighted part, which leaves the list; the highlight stays on the next one. const next = (await page.locator('.row.focused .row-title').innerText()).trim(); - await page.keyboard.press('b'); + await page.keyboard.press('p'); await expect(page.locator('.row').filter({ hasText: next })).toHaveCount(0); - expect(saved.at(-1)).toMatchObject({ classification: 'buy' }); + expect(saved.at(-1)).toMatchObject({ classification: 'print' }); // Parts remain, and the highlight stays on the next one. await expect(page.locator('.rows [role="listitem"]')).not.toHaveCount(0); await expect(page.locator('.row.focused')).toHaveCount(1); }); +test('the part drawer moves to the next part after a choice, and closes with a note when none is left', async ({ page }) => { + const saved: Array<{ classification: string | null }> = []; + await page.route('**/api/classification/override', async route => { saved.push(route.request().postDataJSON()); await route.fulfill({ status: 200, contentType: 'application/json', body: '{"saved":true}' }); }); + await connected(page, withPrints); + await section(page, /^Review/).click(); + await page.locator('.review-row').filter({ hasText: 'Printed guide A' }).locator('.row-title').click(); + const drawer = page.getByRole('dialog'); + await expect(drawer.getByRole('heading', { name: 'Printed guide A' })).toBeVisible(); + await drawer.getByRole('button', { name: 'CNC machine' }).click(); + // The drawer says where the part went and shows the next part in Review's order. + await expect(drawer.getByRole('status').filter({ hasText: 'Printed guide A moved to CNC' })).toBeVisible(); + await expect(drawer.getByRole('heading', { name: 'Printed guide B' })).toBeVisible(); + expect(saved.at(-1)).toMatchObject({ classification: 'cnc' }); + await page.keyboard.press('Escape'); + await expect(page.locator('.review-row').filter({ hasText: 'Printed guide A' })).toHaveCount(0); + // A part opened on its own (Overview search) has no list to move through, so the drawer closes and the page says where it went. + await section(page, /^Overview/).click(); + await page.getByPlaceholder(/Find a part/i).fill('Wheel'); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog').getByRole('heading', { name: 'Wheel' })).toBeVisible(); + await page.getByRole('dialog').getByRole('button', { name: 'Not needed' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.locator('.toast')).toHaveText('Wheel moved to Not needed'); + // A failed save keeps the drawer on the part with its error. + await page.unroute('**/api/classification/override'); + await page.route('**/api/classification/override', route => route.fulfill({ status: 500 })); + await section(page, /^Review/).click(); + await page.locator('.review-row').filter({ hasText: 'Printed guide B' }).locator('.row-title').click(); + await page.getByRole('dialog').getByRole('button', { name: 'CNC machine' }).click(); + await expect(page.getByRole('dialog').getByText('Could not save the choice for Printed guide B')).toBeVisible(); + await expect(page.getByRole('dialog').getByRole('heading', { name: 'Printed guide B' })).toBeVisible(); +}); + +test('Review sets one type for several parts: a shift-click range or a whole subassembly, with failures kept selected', async ({ page }) => { + const saved: string[] = []; + await page.route('**/api/classification/override', async route => { + const body = route.request().postDataJSON() as { identity: string; classification: string }; + // One part fails, so the others still save and it stays selected to retry. + if (body.identity.includes(':pb:')) return route.fulfill({ status: 500 }); + saved.push(`${body.identity}=${body.classification}`); + await route.fulfill({ status: 200, contentType: 'application/json', body: '{"saved":true}' }); + }); + await connected(page, withPrints); + await section(page, /^Review/).click(); + const checks = page.locator('.review-row .row-check'); + await checks.nth(0).click(); + await checks.nth(2).click({ modifiers: ['Shift'] }); + await expect(page.locator('.bulk-bar')).toContainText('3 selected'); + await page.locator('.bulk-bar').getByRole('button', { name: 'Fastener', exact: true }).click(); + await expect(page.getByRole('alert').filter({ hasText: '1 of 3 could not be saved: Printed guide B' })).toBeVisible(); + expect(saved).toHaveLength(2); + await expect(page.locator('.bulk-bar')).toContainText('1 selected'); + await expect(page.locator('.review-row.selected')).toContainText('Printed guide B'); + await page.locator('.bulk-bar').getByRole('button', { name: 'Clear' }).click(); + // Every part in the Left Module subassembly at once: the wheel, and the motor and bearing that still need products. + await page.locator('.review-row').filter({ hasText: 'Wheel' }).getByRole('button', { name: /Select all 3 in Left Module/ }).click(); + await expect(page.locator('.bulk-bar')).toContainText('3 selected'); + await page.locator('.bulk-bar').getByRole('button', { name: 'Not needed', exact: true }).click(); + await expect(page.getByRole('status').filter({ hasText: '3 parts moved to Not needed' })).toBeVisible(); + await expect(page.locator('.review-row').filter({ hasText: /Wheel|Motor|Bearing/ })).toHaveCount(0); +}); + test('privacy, terms, and support pages open from the site footer', async ({ page }) => { await page.route('**/api/session', route => route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ connected: false }) })); await page.goto('/');