Project link — piece 2: one project per document (picker in the document dialogs)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A user creating or editing a sales invoice, purchase invoice or quote/order can choose the project the whole document is for, once, in the dialog header.
Architecture: Web only. The three dialogs already round-trip each line’s shares untouched (shares: l.shares) but offer no way to set them. A pure helper rewrites the project-axis share on every line to 100% on the chosen account (other axes’ shares are kept); a small ProjectPicker component shows the project all lines share, or “Multiple” when they differ. The server contract, analytic.Plan and CheckChargeable are unchanged and remain the authority.
Tech Stack: React/TypeScript, react-query, vitest, MSW.
Spec: docs/superpowers/specs/2026-10-10-project-link-design.md (PL3). Builds on piece 1 (2026-10-10-project-link-lists.md): useProjectOptions(enabled) in web/src/features/projects/api.ts, common:projectLink.* strings.
Global Constraints
Section titled “Global Constraints”- No backend, OpenAPI or migration change. Shares are sent as the generated types already allow:
{ analytic_account_id: string; percentage: string }(checkSchemas['SalesLineInput']['shares']); percentage is a string"100", never a number (no floats near money-adjacent data). - Rewrite only the project axis: shares whose
analytic_account_idis one of the org’s project accounts (from the project axis’ account list, including inactive ones so an old project is replaced). Shares on other axes are preserved. - Choosing “no project” removes the project-axis share from every line.
- The picker is shown only with
accounting.ledger.read(the options endpoint’s permission — same gate and same limitation as piece 1’s filter); the lines’ existing shares still round-trip when hidden. - The server refuses a project the flow may not charge (
CheckChargeable) or that is inactive/members-only; surface the server’s message with the dialog’s existing error display, do not duplicate the rule client-side. - Strings: en/ms/zh plus rows in
docs/review/{ms,zh}-review.csv, reusingcommon:projectLink.*where possible. - Every absence assertion in a test is preceded by a presence assertion on the same fixture.
- Commit trailer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>; no push/merge.
File Structure
Section titled “File Structure”- Create
web/src/features/projects/documentProject.ts(pure helpers) +__tests__/documentProject.test.ts. - Create
web/src/features/projects/ProjectPicker.tsx+__tests__/ProjectPicker.test.tsx. - Modify
web/src/features/{receivables,payables}/InvoiceDialog.tsx,web/src/features/trade/DocumentDialog.tsxand each feature’s existing dialog test; localescommon.json(en/ms/zh) if new keys are needed;docs/review/*.csv;docs/ROADMAP.md.
Task 1: Pure helpers and the picker component
Section titled “Task 1: Pure helpers and the picker component”Files: create the four files above.
Interfaces:
-
Produces:
type Share = { analytic_account_id: string; percentage: string };/** The one project all lines share: its id, 'multiple', or null when no line has one. */export function documentProject(lines: { shares?: Share[] }[], projectIds: Set<string>): string | 'multiple' | null;/** Every line's project-axis share replaced by 100% on `projectId` (or removed when null); other shares kept. Returns new objects, never mutates. */export function withProject<L extends { shares?: Share[] }>(lines: L[], projectIds: Set<string>, projectId: string | null): L[];export function ProjectPicker(p: { lines: { shares?: Share[] }[]; onChange(projectId: string | null): void; id: string }): JSX.Element | null; // null without accounting.ledger.read -
Step 1: Failing tests.
documentProject: all lines on A →A; A and B →'multiple'; none →null; a line with A plus a non-project share →A(other-axis share ignored); lines[A, none]→'multiple'.withProject: sets 100% on every line, keeps an other-axis share, replaces an old project share (including one on an inactive project that is inprojectIds),nullremoves only the project share, input arrays are not mutated, percentage is the string"100".ProjectPicker(MSW for the analytic-axes endpoint like piece 1’s page tests): withaccounting.ledger.readit renders a labelled combobox listing active projects, shows the current project’s code, shows the “Multiple” label for mixed lines, and choosing an option callsonChange(id); “No project” callsonChange(null); without the permission it renders nothing (assert the label present with the permission first). -
Step 2:
pnpm --dir web vitest run src/features/projects→ FAIL (missing modules). -
Step 3: Implement. Reuse
useProjectAxis/useProjectOptionsand theField+Comboboxpattern from piece 1’s list filters (web/src/features/receivables/InvoicesPage.tsx). The picker needs ALL project accounts (to recognise old shares) forprojectIdsbut only active ones as options; ifuseProjectAxisexposes the accounts, build the id set from it. Keep the helpers free of React. -
Step 4: run the tests,
pnpm --dir web typecheck, lint on the touched files → PASS. Watch the “other-axis share kept” test fail once by deleting the filter. -
Step 5: commit —
feat(web): project picker and helpers for document dialogs.
Task 2: Sales and purchase invoice dialogs
Section titled “Task 2: Sales and purchase invoice dialogs”Files: modify web/src/features/receivables/InvoiceDialog.tsx, web/src/features/payables/InvoiceDialog.tsx; tests in each feature’s existing dialog test file (find with grep -l InvoiceDialog web/src/features/{receivables,payables}/__tests__/*).
Interfaces: Consumes ProjectPicker, withProject, documentProject from Task 1 (the dialogs need the project-id set: expose it from ProjectPicker’s module or a useProjectIds() hook added in Task 1 — pick one and use it in both dialogs and Task 3).
- Step 1: Failing tests (per dialog): open a new document, add two lines, choose project A in the header picker → the submitted body (capture the POST/PUT via MSW) has both lines with
shares: [{analytic_account_id: A, percentage: '100'}]; then choose B → both switch to B; “No project” →shares: [](assert A-present body first). Opening an existing draft whose lines are on A shows A selected; a draft with an other-axis share keeps it after changing the project. Withoutaccounting.ledger.readthe picker is absent and an existing line’s shares are still submitted unchanged. - Step 2: run → FAIL.
- Step 3: Implement. In the header fields area (near customer/supplier) add
<ProjectPicker lines={lines} onChange={(id) => setLines((ls) => withProject(ls, projectIds, id))} id="..."/>. New lines added after a project was chosen must inherit it: where the dialog appends a blank line, copy the current document project’s share (usedocumentProject; if'multiple', add no share). - Step 4: run the two features’ test folders, typecheck, lint → PASS.
- Step 5: commit —
feat(web): choose the project once per sales or purchase invoice.
Task 3: Quote/order dialog, browser check, roadmap
Section titled “Task 3: Quote/order dialog, browser check, roadmap”Files: web/src/features/trade/DocumentDialog.tsx and its test; docs/ROADMAP.md (tick piece 2 under “Project link on documents”).
- Step 1: Failing test as Task 2 for the trade dialog (quotation kind; also confirm a text line without an item still carries the share).
- Step 2: run → FAIL.
- Step 3: Implement as Task 2 (the trade lines state is
l.sharesatDocumentDialog.tsx:78). - Step 4: run
pnpm --dir web vitest runfor the whole web suite once, typecheck, lint. - Step 5: Browser check. Start the dev server per
.claude/launch.jsonif the API and a login are feasible; create a quote with a project, reopen it, confirm the list’s Project column shows it. If infeasible, say so and do not claim a visual check. - Step 6: tick piece 2 in ROADMAP (state the browser-check outcome honestly); commit —
feat(web): choose the project once per quote or order; roadmap.
Self-review notes
Section titled “Self-review notes”- Spec PL3 → Tasks 1–3: header picker, rewrite of every line’s project share to 100%, “Multiple” display, server remains authority, chargeable list unchanged.
- Added beyond PL3 wording, because piece 1 revealed it: there was no UI at all to set a project on these documents, so this piece is also what makes piece 1’s column non-empty. Per-line editing stays out of scope (YAGNI); mixed documents arise only from the API or imports.
- Names consistent:
documentProject,withProject,ProjectPicker,useProjectIds(or the exported id set),common:projectLink.*.