Skip to content

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.

  • No backend, OpenAPI or migration change. Shares are sent as the generated types already allow: { analytic_account_id: string; percentage: string } (check Schemas['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_id is 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, reusing common: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.
  • 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.tsx and each feature’s existing dialog test; locales common.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 in projectIds), null removes 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): with accounting.ledger.read it renders a labelled combobox listing active projects, shows the current project’s code, shows the “Multiple” label for mixed lines, and choosing an option calls onChange(id); “No project” calls onChange(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 / useProjectOptions and the Field + Combobox pattern from piece 1’s list filters (web/src/features/receivables/InvoicesPage.tsx). The picker needs ALL project accounts (to recognise old shares) for projectIds but only active ones as options; if useProjectAxis exposes 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. Without accounting.ledger.read the 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 (use documentProject; 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.shares at DocumentDialog.tsx:78).
  • Step 4: run pnpm --dir web vitest run for the whole web suite once, typecheck, lint.
  • Step 5: Browser check. Start the dev server per .claude/launch.json if 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.
  • 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.*.