From a63193c8fe7c53ba63195a4b56a89bb9bf7dce77 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Tue, 21 Apr 2026 15:29:39 +0300 Subject: [PATCH 1/4] docs --- docs/dynamic-report-refactor/blueprint.md | 1535 +++++++++++++++++++++ docs/dynamic-report-refactor/spec.md | 239 ++++ 2 files changed, 1774 insertions(+) create mode 100644 docs/dynamic-report-refactor/blueprint.md create mode 100644 docs/dynamic-report-refactor/spec.md diff --git a/docs/dynamic-report-refactor/blueprint.md b/docs/dynamic-report-refactor/blueprint.md new file mode 100644 index 0000000000..20ce302702 --- /dev/null +++ b/docs/dynamic-report-refactor/blueprint.md @@ -0,0 +1,1535 @@ +# Dynamic Report Refactor — Implementation Blueprint & LLM Prompts + +## High-Level Blueprint + +### Phase 1 — Backend contract + +Update the server-side GraphQL type and Zod validation schema to match the new explicit-node format, +add the in-memory legacy-migration helper, then regenerate types. + +### Phase 2 — Client types & pure utilities + +Update the client-side `CustomData` type and extract the tree-building logic (bank tree, report +tree) into pure, testable functions before any UI work starts. + +### Phase 3 — Legacy migration (client) + +Write a client-side helper that detects the old template format and converts it into the new +explicit-leaf format using live entity data. + +### Phase 4 — Split state + two-panel layout + +Replace the single combined tree with two separate state slices (`bankTree` / `reportTree`) and +render them side-by-side, initially without cross-tree drag. + +### Phase 5 — Single-presence drop handler + +Implement the cross-tree drop logic that enforces "entity exists in exactly one place" including +whole-branch moves. + +### Phase 6 — Edit mode + dirty state + +Add the edit-mode toggle, dirty-state tracking, unsaved-changes indicator, and the confirmation +dialog that guards template switches. + +### Phase 7 — Node rendering (nodeType-aware) + +Update `CustomNode` to branch on `nodeType`, show edit controls only in edit mode, and pass the +edit-mode prop through the tree. + +### Phase 8 — Cross-tree `canDrop` rules + +Tighten the `canDrop` predicate: no dropping a branch inside a financial-entity leaf, etc. + +### Phase 9 — Delete branch → move to bank + +Swap the current "delete removes nodes" behaviour for "delete moves branch intact to bank root" with +a confirmation dialog. + +### Phase 10 — Template management + +Refactor the save/manage UX: save-as, resave, rename, duplicate, locked-template enforcement, and +the legacy-format prompt. + +### Phase 11 — Integration pass & CSV + +Wire all pieces in `index.tsx`, preserve filter/URL-param behaviour, confirm CSV export operates on +the report tree only. + +--- + +## Iterative Chunks (right-sized) + +``` +Chunk A Backend schema + Zod + migration helper + tests +Chunk B Client CustomData type + node-type predicate utilities +Chunk C buildInitialBankTree pure function + tests +Chunk D buildReportTree pure function + tests +Chunk E Client-side legacy-migration helper + tests +Chunk F Split bank/report state + two-panel layout (no drag yet) +Chunk G Cross-tree single-presence drop handler + tests +Chunk H Edit mode + dirty state + confirmation dialog +Chunk I CustomNode — nodeType-aware rendering + edit-mode prop +Chunk J TreeView — cross-tree canDrop rules +Chunk K Delete branch → confirmation + move-to-bank +Chunk L Template management (save-as, resave, rename, duplicate) +Chunk M Legacy template detection banner +Chunk N Integration wiring (index.tsx) + filter persistence + locked-template guard +Chunk O CSV export — report tree only +``` + +--- + +## LLM Prompts + +--- + +### Prompt 1 — Backend: GraphQL schema + Zod schema + legacy migration helper + +````text +We are working in the monorepo at `packages/server/src/modules/reports/`. + +## Context + +The existing `DynamicReportNodeData` GraphQL type (in +`packages/server/src/modules/reports/typeDefs/dynamic-report.graphql.ts`) is: + +```graphql +type DynamicReportNodeData { + descendantSortCodes: [Int!] + descendantFinancialEntities: [UUID!] + mergedSortCodes: [Int!] + isOpen: Boolean! + hebrewText: String +} +```` + +The existing Zod schema (in `packages/server/src/modules/reports/helpers/dynamic-report.helper.ts`) +mirrors these fields. + +## Task + +### 1. Update the GraphQL type + +In `packages/server/src/modules/reports/typeDefs/dynamic-report.graphql.ts`, replace the +`DynamicReportNodeData` type with: + +```graphql +type DynamicReportNodeData { + nodeType: String! + isOpen: Boolean! + hebrewText: String +} +``` + +Remove the `descendantSortCodes`, `descendantFinancialEntities`, and `mergedSortCodes` fields +entirely. + +### 2. Update the Zod schema + +In `packages/server/src/modules/reports/helpers/dynamic-report.helper.ts`: + +- Replace `dynamicReportNodeData` with a new Zod object that has: + - `nodeType`: `z.enum(['synthetic-branch', 'sort-code-branch', 'financial-entity'])` + - `isOpen`: `z.boolean()` + - `hebrewText`: `z.string().optional()` +- Keep the `.strict()` modifier. +- Keep `parseTemplate`, `validateTemplate`, and the exported type aliases `DynamicReportNodeType` + and `DynamicReportNodeDataType`. + +### 3. Add a legacy format detector and migrator + +Still in `dynamic-report.helper.ts`, export two new functions: + +```typescript +/** + * Returns true when the raw parsed node array contains at least one node + * whose `data` object has a `descendantSortCodes` key — the hallmark of the + * old implicit-membership format. + */ +export function isLegacyTemplate(nodes: unknown[]): boolean + +/** + * Converts a legacy template (implicit-membership format) to the new + * explicit-leaf format given the set of live financial entity IDs that belong + * to each sort code. + * + * @param nodes Parsed legacy node array (validated against the old schema) + * @param entityBySortCode Map — entity UUIDs that belong to that sort code + * @returns Node array in the new explicit format (no hint arrays, explicit leaf nodes) + */ +export function migrateLegacyTemplate( + nodes: LegacyDynamicReportNode[], + entityBySortCode: Map +): DynamicReportNodeType[] +``` + +For `migrateLegacyTemplate`: + +- Copy all branch nodes (droppable === true) as-is, mapping `data` to new format: + - Synthetic branches (no `sortCode` in old data): `nodeType: 'synthetic-branch'` + - Sort-code branches (`sortCode` present in old data): `nodeType: 'sort-code-branch'` + - Strip `descendantSortCodes`, `descendantFinancialEntities`, `mergedSortCodes` +- For every entity UUID in `descendantFinancialEntities` on each branch node, if the UUID is not + already present as an explicit leaf, insert a leaf node: + `{ id: uuid, parent: branchId, text: uuid, droppable: false, data: { nodeType: 'financial-entity', isOpen: false } }` + (the caller will patch `text` to the entity name after loading — the UUID placeholder is fine for + persistence purposes) +- Add a `LegacyDynamicReportNode` type (internal, not exported) that mirrors the old Zod shape so + TypeScript is happy inside the function. + +### 4. Write unit tests + +Create `packages/server/src/modules/reports/helpers/__tests__/dynamic-report.helper.test.ts`. + +Use `vitest`. Import from `../dynamic-report.helper.js`. + +Test cases required: + +- `parseTemplate` succeeds with a valid new-format JSON string +- `parseTemplate` throws on a JSON string missing `nodeType` +- `isLegacyTemplate` returns `true` for an array where at least one node's `data` has + `descendantSortCodes` +- `isLegacyTemplate` returns `false` for an array where no node has that key +- `migrateLegacyTemplate` with a two-level legacy tree (one sort-code branch + two entities in + `descendantFinancialEntities`) produces: + - the branch node with `nodeType: 'sort-code-branch'` + - two explicit leaf nodes with `nodeType: 'financial-entity'` and `parent` equal to the branch + node's `id` +- `migrateLegacyTemplate` does not duplicate a leaf that already exists explicitly + +After all changes run `yarn workspace @accounter/server generate` and confirm there are no +TypeScript errors in the helper file or its tests. + +```` + +--- + +### Prompt 2 — Client: `CustomData` type + node-type predicate utilities + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/types.ts + +## Context + +The current `CustomData` type is: +```typescript +export type CustomData = { + hebrewText?: string; + value?: number | null; + sortCode?: number | null; + isOpen: boolean; + descendantSortCodes?: number[] | null; + descendantFinancialEntities?: string[] | null; + mergedSortCodes?: number[] | null; +}; +```` + +The GraphQL schema now emits `nodeType: String!` on `DynamicReportNodeData` (updated in prompt 1). +The generated client types will include `nodeType`. + +## Task + +### 1. Replace `CustomData` + +Replace the type entirely with: + +```typescript +export type NodeType = 'synthetic-branch' | 'sort-code-branch' | 'financial-entity' + +export type CustomData = { + nodeType: NodeType + hebrewText?: string + /** Local-currency total; populated at runtime from ledger query, never stored */ + value?: number | null + /** Numeric sort-code key; present on sort-code-branch nodes */ + sortCode?: number | null + isOpen: boolean +} +``` + +### 2. Add predicate helpers (same file, exported) + +```typescript +import type { NodeModel } from '@minoru/react-dnd-treeview' + +export function isFinancialEntityNode(node: NodeModel): boolean { + return node.data?.nodeType === 'financial-entity' +} + +export function isSortCodeBranchNode(node: NodeModel): boolean { + return node.data?.nodeType === 'sort-code-branch' +} + +export function isSyntheticBranchNode(node: NodeModel): boolean { + return node.data?.nodeType === 'synthetic-branch' +} + +export function isBranchNode(node: NodeModel): boolean { + return node.droppable === true +} +``` + +### 3. Fix immediately visible type errors + +After changing `CustomData`, TypeScript will produce errors in: + +- `custom-node.tsx` — references to `data?.sortCode`, `data?.value` etc. These are still valid + fields, but `data?.descendantSortCodes` etc. must be removed. Remove any usage of the removed + fields; keep `sortCode` and `value` access. +- `index.tsx` — same removals for the dropped fields. +- `dynamic-report-save-template.tsx` — the template serialisation references the old fields; remove + `descendantSortCodes`, `descendantFinancialEntities`, `mergedSortCodes` from the serialised + object. +- `tree-view.tsx` — references to `data?.sortCode` in the existing `handleDrop` / `canDrop` logic; + update as needed to use the new predicates. + +Do NOT restructure any component behaviour yet — only fix TypeScript errors caused by the type +change. + +### 4. Verify + +Run `yarn workspace @accounter/client build` (or `tsc --noEmit`) and confirm zero TypeScript errors +in all files under `packages/client/src/components/reports/dynamic-report/`. + +```` + +--- + +### Prompt 3 — Bank tree builder (pure function + unit tests) + +```text +We are working in the dynamic-report client component folder: + packages/client/src/components/reports/dynamic-report/ + +## Context + +The bank panel displays financial entities grouped by sort-code folders, with +independent entities (no sort code) listed alphabetically after the folders. +The bank state is never saved — it is always rebuilt fresh from live data. + +The relevant GraphQL query types from `packages/client/src/gql/graphql.ts` are: +- `AllSortCodesQuery['allSortCodes']` — array of `{ id, key, name }` +- `BusinessTransactionsSumFromLedgerRecordsSuccessfulResult['businessTransactionsSum']` + — array of `{ business: { id, name, sortCode: { id, key, name } | null }, total: { raw } }` + +`NodeModel` is from `@minoru/react-dnd-treeview`. + +## Task + +### 1. Create `bank-tree.ts` + +Create `packages/client/src/components/reports/dynamic-report/bank-tree.ts`. + +Export one pure function: + +```typescript +import type { NodeModel } from '@minoru/react-dnd-treeview'; +import type { AllSortCodesQuery, DynamicReportQuery } from '../../../gql/graphql.js'; +import type { CustomData } from './types.js'; + +export const BANK_TREE_ROOT_ID = 'bank'; + +type BusinessSum = Extract< + NonNullable, + { __typename?: 'BusinessTransactionsSumFromLedgerRecordsSuccessfulResult' } +>['businessTransactionsSum'][number]; + +/** + * Builds the bank tree in its default / initial state. + * + * Rules: + * - One sort-code-branch node per sort code, ordered ascending by sortCode.key + * - Node id = sortCode.id, text = `${sortCode.key} — ${sortCode.name}` + * - Financial entity nodes whose business.sortCode is non-null become children + * of the matching sort-code-branch node + * - Financial entity nodes with no sort code are placed directly under BANK_TREE_ROOT_ID + * and sorted alphabetically by business.name + * - Entities with |total.raw| < 0.005 are excluded unless includeZeroed is true + * - value on each leaf = total.raw * -1 + * + * @param sortCodes All sort codes from AllSortCodesQuery + * @param businessSums Sum results from DynamicReport query + * @param excludedIds Set of entity IDs already placed in the report tree (excluded from bank) + * @param includeZeroed When true include entities whose |value| < 0.005 + */ +export function buildInitialBankTree( + sortCodes: AllSortCodesQuery['allSortCodes'], + businessSums: BusinessSum[], + excludedIds: ReadonlySet, + includeZeroed: boolean, +): NodeModel[] +```` + +Implementation notes: + +- Sort-code branches: + `{ id: sortCode.id, parent: BANK_TREE_ROOT_ID, droppable: true, text: \`${sortCode.key} — + ${sortCode.name}\`, data: { nodeType: 'sort-code-branch', sortCode: sortCode.key, isOpen: false } + }` +- Only include sort-code-branch nodes for sort codes that have at least one visible entity + (otherwise bank is cluttered with empty folders). +- Financial entity leaves: + `{ id: business.id, parent: sortCodeNodeId | BANK_TREE_ROOT_ID, droppable: false, text: business.name, data: { nodeType: 'financial-entity', value, isOpen: false } }` +- No sort-code branches are emitted for entities in `excludedIds`. +- Return value is a flat array (the tree library reconstructs the hierarchy). + +### 2. Unit tests + +Create `packages/client/src/components/reports/dynamic-report/__tests__/bank-tree.test.ts`. + +Use `vitest`. Import from `../bank-tree.js`. + +Required test cases: + +- Empty inputs → returns empty array +- Two sort codes, three entities: correct parent assignment, sort-code-branch ids match sortCode.id, + ascending sort-code order +- Entity with no sort code → parent is `BANK_TREE_ROOT_ID`, appears after all sort-code branches, + sorted alphabetically when multiple independent entities exist +- Entity in `excludedIds` → not present in output (and its sort-code branch is omitted if it would + be empty) +- `includeZeroed = false` → entity with `total.raw = 0` excluded +- `includeZeroed = true` → same entity included +- `value` on leaf equals `total.raw * -1` + +Run `yarn workspace @accounter/client test --run` and confirm all tests pass. + +```` + +--- + +### Prompt 4 — Report tree builder (pure function + unit tests) + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/ + +## Context + +When a template is loaded the report tree is reconstructed from: +1. The saved template nodes (branches + explicit entity-leaf nodes in new format) +2. Live `businessTransactionsSum` data (to hydrate `value` and `text` on entity leaves) + +Entities referenced in the template that are NOT present in the live data are +omitted silently (they may have been deleted or have been merged). + +Entities present in live data but NOT in the template remain in the bank (handled +by the bank tree builder via `excludedIds`). + +## Task + +### 1. Create `report-tree.ts` + +Create `packages/client/src/components/reports/dynamic-report/report-tree.ts`. + +```typescript +import type { NodeModel } from '@minoru/react-dnd-treeview'; +import type { DynamicReportQuery } from '../../../gql/graphql.js'; +import type { CustomData } from './types.js'; + +export const REPORT_TREE_ROOT_ID = 'report'; + +type TemplateNode = { + id: string | number; + parent: string | number; + text: string; + droppable: boolean; + data: { + nodeType: string; + isOpen: boolean; + hebrewText?: string | null; + }; +}; + +type BusinessSum = Extract< + NonNullable, + { __typename?: 'BusinessTransactionsSumFromLedgerRecordsSuccessfulResult' } +>['businessTransactionsSum'][number]; + +/** + * Builds the report tree from a saved template and live entity data. + * + * Rules: + * - Branch nodes (synthetic-branch, sort-code-branch) are copied from the template + * with their nesting and order preserved. + * - Entity-leaf nodes are copied from the template; their `value` and `text` are + * patched from the matching entry in `businessSums` (matched by id). + * - Entity leaves whose id is not found in `businessSums` are dropped (entity no + * longer exists). + * - The `isOpen` state per node is taken from the template. + * + * @returns { reportTree, placedEntityIds } + * reportTree — flat NodeModel array for the report panel + * placedEntityIds — Set of entity UUIDs placed in the report tree + * (so the bank builder can exclude them) + */ +export function buildReportTree( + templateNodes: TemplateNode[], + businessSums: BusinessSum[], +): { reportTree: NodeModel[]; placedEntityIds: Set } +```` + +Implementation notes: + +- Replace `parent: 0` (old root sentinel) with `REPORT_TREE_ROOT_ID` so the report-tree `rootId` + prop is consistent. +- `sortCode` on branch nodes: if `nodeType === 'sort-code-branch'` and the template node id is a + number, set `data.sortCode` to that id; otherwise leave undefined. + +### 2. Unit tests + +Create `packages/client/src/components/reports/dynamic-report/__tests__/report-tree.test.ts`. + +Required test cases: + +- Empty template → empty report tree, empty `placedEntityIds` +- Template with one synthetic branch + two entity leaves: + - Both entity leaves present in `businessSums` → both appear with hydrated `value` and `text`, + correct parent + - `placedEntityIds` contains both entity UUIDs +- One of the entity leaves is NOT in `businessSums` → that leaf is dropped; `placedEntityIds` + contains only the surviving entity +- Branch node with `nodeType: 'sort-code-branch'` → `data.sortCode` is set +- Template with nested branches → parent IDs are preserved exactly + +Run `yarn workspace @accounter/client test --run` and confirm all tests pass. + +```` + +--- + +### Prompt 5 — Client-side legacy template migration helper + tests + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/ + +## Context + +Old templates use `descendantFinancialEntities` arrays on branch nodes to +implicitly declare which entities belong there; entities are NOT stored as +explicit leaf nodes. The new format stores them as explicit leaf nodes. + +When the server returns a template whose nodes contain `descendantFinancialEntities`, +the client must: +1. Detect the legacy format. +2. Migrate it in-memory to the new format (no DB write yet). +3. Show a banner prompting the user to resave. + +The migration helper at the server level (`migrateLegacyTemplate`) is the +reference for the conversion algorithm. Here we need the client-side variant that +receives richer live data (business names + sort codes). + +## Task + +### 1. Create `legacy-migration.ts` + +Create +`packages/client/src/components/reports/dynamic-report/legacy-migration.ts`. + +```typescript +import type { NodeModel } from '@minoru/react-dnd-treeview'; +import type { DynamicReportQuery } from '../../../gql/graphql.js'; +import { REPORT_TREE_ROOT_ID } from './report-tree.js'; +import type { CustomData } from './types.js'; + +/** + * Raw node shape as returned by the GraphQL query for a legacy template. + * (Old DynamicReportNodeData included the hint arrays.) + */ +export type LegacyTemplateNode = { + id: string | number; + parent: string | number; + text: string; + droppable: boolean; + data: { + isOpen: boolean; + hebrewText?: string | null; + sortCode?: number | null; + descendantSortCodes?: number[] | null; + descendantFinancialEntities?: string[] | null; + mergedSortCodes?: number[] | null; + }; +}; + +type BusinessSum = Extract< + NonNullable, + { __typename?: 'BusinessTransactionsSumFromLedgerRecordsSuccessfulResult' } +>['businessTransactionsSum'][number]; + +/** + * Returns true when the node array is in the legacy implicit-membership format. + * Detection: any node whose `data` has a `descendantFinancialEntities` key. + */ +export function isLegacyTemplateNodes(nodes: LegacyTemplateNode[]): boolean + +/** + * Converts a legacy template node array to the new explicit-leaf format. + * + * Algorithm: + * 1. Copy all branch nodes, assigning nodeType: + * - `data.sortCode != null` → 'sort-code-branch' + * - otherwise → 'synthetic-branch' + * Strip all hint array fields. + * 2. For each branch node that had `descendantFinancialEntities`, create explicit + * leaf nodes for each UUID in that array if a matching entry exists in + * `businessSums`. Set `value` from businessSum.total.raw * -1 and `text` + * from businessSum.business.name. + * 3. Nodes whose parent is 0 or the old implicit root get parent = REPORT_TREE_ROOT_ID. + * + * @param nodes Legacy node array (from GraphQL response) + * @param businessSums Live entity data to hydrate names + values + */ +export function migrateLegacyTemplateNodes( + nodes: LegacyTemplateNode[], + businessSums: BusinessSum[], +): NodeModel[] +```` + +### 2. Unit tests + +Create `packages/client/src/components/reports/dynamic-report/__tests__/legacy-migration.test.ts`. + +Required test cases: + +- `isLegacyTemplateNodes` returns `true` for a node with `descendantFinancialEntities` +- `isLegacyTemplateNodes` returns `false` for new-format nodes (only `nodeType`) +- Migration of a legacy tree: one sort-code branch with two entity UUIDs in + `descendantFinancialEntities` → output has the branch (nodeType='sort-code-branch', no hint + arrays) + two explicit leaf nodes with correct parent, name, value +- Migration: entity UUID in `descendantFinancialEntities` but NOT in `businessSums` → that leaf is + silently dropped +- Migration: synthetic branch (no `sortCode`) → `nodeType: 'synthetic-branch'` +- Migration: node with `parent: 0` → parent becomes `REPORT_TREE_ROOT_ID` + +Run `yarn workspace @accounter/client test --run` and confirm all new tests pass. + +```` + +--- + +### Prompt 6 — Split bank/report state + two-panel layout + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/index.tsx + +## Context + +The current component stores a single `tree` state that holds both bank and report +nodes together, distinguished by their `parent` (BANK_TREE_ROOT_ID vs. +REPORT_TREE_ROOT_ID). We need to split this into two separate state slices and +render them side-by-side. + +The pure helpers we now have: +- `buildInitialBankTree()` from `./bank-tree.js` (BANK_TREE_ROOT_ID = 'bank') +- `buildReportTree()` from `./report-tree.js` (REPORT_TREE_ROOT_ID = 'report') + +## Task + +### 1. Replace the single `tree` state + +Replace: +```typescript +const [tree, setTree] = useState[]>([]); +```` + +with: + +```typescript +const [bankTree, setBankTree] = useState[]>([]) +const [reportTree, setReportTree] = useState[]>([]) +``` + +### 2. Rebuild on data change + +Add a `useMemo` / `useEffect` that recomputes `bankTree` whenever `sortCodes`, +`businessTransactionsSumData`, or `templateData` changes: + +- If a template is loaded: call `buildReportTree(templateData.template, businessSums)` to produce + `{ reportTree, placedEntityIds }`, then call + `buildInitialBankTree(sortCodes, businessSums, placedEntityIds, isShowZeroedAccounts)` for the + bank. +- If no template: call `buildInitialBankTree` with an empty `excludedIds` set, set `reportTree` to + `[]`. + +Keep the `isShowZeroedAccounts` state wired to the existing filter toggle. + +### 3. Two-panel layout + +Replace the existing single `` JSX with a two-column layout: + +```tsx +
+
+

Bank

+ +
+
+

Report

+ +
+
+``` + +For now use stub handlers that just call `setBankTree` / `setReportTree` with the new tree passed in +by the library (no cross-tree logic yet): + +```typescript +const handleBankDrop = useCallback((newTree: NodeModel[]) => setBankTree(newTree), []) +const handleReportDrop = useCallback( + (newTree: NodeModel[]) => setReportTree(newTree), + [] +) +``` + +### 4. Remove dead code + +Remove `buildSortCodeFinancialEntitiesMaps`, `updateSortCodesTreeNodes`, +`updateFinancialEntitiesTreeNodes`, and the old `handleDrop` that operated on the unified `tree` +state. + +### 5. Remove import of `REPORT_TREE_ROOT_ID` defined inline + +`REPORT_TREE_ROOT_ID` and `BANK_TREE_ROOT_ID` are now exported from the utility modules — import +them from there. + +### 6. Smoke test + +Add a render test at +`packages/client/src/components/reports/dynamic-report/__tests__/index.test.tsx` using vitest + +@testing-library/react. Mock the urql `useQuery` hook to return empty results. Assert that two +headings "Bank" and "Report" are present in the rendered output. + +Run `yarn workspace @accounter/client test --run` and confirm all tests pass. + +```` + +--- + +### Prompt 7 — Cross-tree single-presence drop handler + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/index.tsx + packages/client/src/components/reports/dynamic-report/__tests__/index.test.tsx + +## Context + +After prompt 6 we have two separate tree states and stub drop handlers. Now we +need the real drop handlers that enforce the single-presence rule. + +The rule: every entity exists in exactly one location at all times — the bank OR +the report tree, never both. The same applies to branches: dragging a branch moves +the branch AND all its descendants atomically. + +`getDescendants(tree, nodeId)` from `@minoru/react-dnd-treeview` returns all +descendant `NodeModel` entries. + +## Task + +### 1. Extract `handleCrossTreeDrop` as a pure function + +Create +`packages/client/src/components/reports/dynamic-report/cross-tree-drop.ts`. + +```typescript +import { getDescendants, type NodeModel, type DropOptions } from '@minoru/react-dnd-treeview'; +import type { CustomData } from './types.js'; + +/** + * Handles a drop that may cross tree boundaries (bank ↔ report). + * + * @param sourceTree The tree the drag originated from + * @param targetTree The tree the drop target belongs to + * @param dropOptions DropOptions from @minoru/react-dnd-treeview + * @param newTargetTree The new target tree array emitted by the library's onDrop + * + * @returns { nextSourceTree, nextTargetTree } + */ +export function handleCrossTreeDrop( + sourceTree: NodeModel[], + targetTree: NodeModel[], + dropOptions: DropOptions, + newTargetTree: NodeModel[], +): { nextSourceTree: NodeModel[]; nextTargetTree: NodeModel[] } +```` + +Algorithm: + +1. Collect the moved node IDs = + `[dragSourceId, ...getDescendants(sourceTree, dragSourceId).map(n => n.id)]` +2. Remove those IDs from `sourceTree` → `nextSourceTree` +3. Use `newTargetTree` as `nextTargetTree` (the library already placed the node correctly) +4. If the drop was within the SAME tree (source and target are the same array reference), return + `{ nextSourceTree: newTargetTree, nextTargetTree: newTargetTree }` (no cross-tree move needed). + +### 2. Wire into index.tsx + +Replace the stub handlers with: + +```typescript +const handleBankDrop = useCallback( + (newTree: NodeModel[], dropOptions: DropOptions) => { + const { nextSourceTree, nextTargetTree } = handleCrossTreeDrop( + reportTree, + bankTree, + dropOptions, + newTree + ) + setReportTree(nextSourceTree) + setBankTree(nextTargetTree) + }, + [bankTree, reportTree] +) + +const handleReportDrop = useCallback( + (newTree: NodeModel[], dropOptions: DropOptions) => { + const { nextSourceTree, nextTargetTree } = handleCrossTreeDrop( + bankTree, + reportTree, + dropOptions, + newTree + ) + setBankTree(nextSourceTree) + setReportTree(nextTargetTree) + }, + [bankTree, reportTree] +) +``` + +Note: `onDrop` in `@minoru/react-dnd-treeview` receives `(newTree, dropOptions)` — pass both. Update +the `TreeView` prop type for `onDrop` to match. + +### 3. Unit tests + +Create `packages/client/src/components/reports/dynamic-report/__tests__/cross-tree-drop.test.ts`. + +Required test cases: + +- Entity dragged from source tree to target tree → entity absent from source, present in target at + drop position +- Branch with two children dragged across → branch + both children absent from source, present in + target +- Same-tree reorder (sourceTree === targetTree, i.e. same array reference used for both arguments) → + returns `newTargetTree` for both (no cross-tree deletion) + +Run `yarn workspace @accounter/client test --run`. + +```` + +--- + +### Prompt 8 — Edit mode, dirty state, and confirmation dialog + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/index.tsx + +## Context + +We need: +1. An explicit edit-mode toggle that gates all structural changes. +2. A dirty-state flag set on every tree mutation. +3. A visual "Unsaved changes" indicator. +4. A confirmation dialog when the user tries to load a different template while dirty. + +## Task + +### 1. Add state + +In `index.tsx`, add: +```typescript +const [editMode, setEditMode] = useState(false); +const [isDirty, setIsDirty] = useState(false); +```` + +### 2. Mark dirty on every mutation + +Wrap `setBankTree` and `setReportTree` in helper functions that also set `isDirty`: + +```typescript +const updateBankTree = useCallback( + (fn: (prev: NodeModel[]) => NodeModel[]) => { + setBankTree(fn) + setIsDirty(true) + }, + [] +) +const updateReportTree = useCallback( + (fn: (prev: NodeModel[]) => NodeModel[]) => { + setReportTree(fn) + setIsDirty(true) + }, + [] +) +``` + +Use `updateBankTree` / `updateReportTree` everywhere a structural change occurs (drop handlers, +handleAddBankNode, handleTextChange, handleDeleteCategory). + +Reset `isDirty` to `false` after a successful template save or when a template is freshly loaded. + +### 3. Gate edit actions behind `editMode` + +Pass `editMode` as a prop to both `TreeView` instances (replacing `enableDnd`). `canDrag` in +`TreeView` returns `false` when `editMode` is false (keep existing `canDrag={() => enableDnd}` but +rename `enableDnd` to `editMode` in props). The "Add new category" button in the footer should be +disabled/hidden when `!editMode`. Rename state/prop from `enableDnd` → `editMode` consistently. + +### 4. Visual dirty indicator + +In the footer context (the `setFiltersContext` call), add a `Badge` with text "Unsaved changes" when +`isDirty && !fetching`. Import `Badge` from `../../ui/badge.js`. + +### 5. Confirmation dialog on template load + +When the user selects a new template via `ManageTemplates`, check `isDirty`. If dirty, show a shadcn +`AlertDialog`: + +- Title: "Unsaved changes" +- Description: "Loading a template will discard your unsaved changes. Continue?" +- Cancel button: dismiss without loading +- Continue button: load the template and reset `isDirty` + +Implement this by adding an `onSelectTemplate` callback prop to `ManageTemplates` that `index.tsx` +provides. The component calls this prop when the user clicks a template row; `index.tsx` intercepts +it, checks dirty state, shows the dialog if needed, and proceeds to navigate when confirmed. + +### 6. Smoke test additions + +Extend the render test in `__tests__/index.test.tsx`: + +- When `editMode` is false, the "Add new category" button is disabled. +- When `isDirty` is true, a "Unsaved changes" badge is present. (Simulate by triggering a drop via + the drop handler and then checking the DOM.) + +Run `yarn workspace @accounter/client test --run`. + +```` + +--- + +### Prompt 9 — `CustomNode` nodeType-aware rendering + edit-mode prop + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/custom-node.tsx + +## Context + +`CustomNode` currently renders identically for all nodes except a rough check +(`droppable && !sortCode`) to distinguish "synthetic categories". Now that every +node carries `data.nodeType`, we can branch precisely. + +## Task + +### 1. Add `editMode` prop + +```typescript +type Props = { + node: NodeModel; + depth: number; + isOpen: boolean; + onToggle: (id: NodeModel['id']) => void; + onTextChange: (id: NodeModel['id'], value: string) => void; + onDeleteCategory: (id: NodeModel['id']) => void; + descendants: NodeModel[]; + filter: DynamicReportFiltersType; + editMode: boolean; // ← new +}; +```` + +### 2. Branch on `nodeType` + +**`sort-code-branch` nodes**: + +- Show toggle icon (open/close) and descendant count (unchanged) +- Show `Sort Code {data.sortCode}` (unchanged) +- Show sum badge (unchanged) +- No rename or delete controls (sort-code branches are managed by data, not user) + +**`synthetic-branch` nodes**: + +- Show toggle icon, descendant count, sum badge (unchanged) +- Show rename inline input and delete dropdown item **only when `editMode` is true** +- When `editMode` is false, the rename input is hidden and the dropdown is not rendered + +**`financial-entity` nodes** (`droppable === false`): + +- Show leaf icon +- Show entity name and value badge +- Show expand/collapse for the ledger view (`BusinessExtendedInfo`) on click — this is independent + of edit mode +- No rename or delete controls + +### 3. Remove `isCategory` derivation + +Remove `const isCategory = droppable && !props.node.data?.sortCode` and replace all usages with +`isSyntheticBranchNode(node)` from `./types.js`. + +### 4. Pass `editMode` through `TreeView` → `CustomNode` + +In `tree-view.tsx`: + +- Add `editMode: boolean` to `Props` +- Pass it to `CustomNode` in the `render` callback + +In `index.tsx`: + +- Pass `editMode` to both `TreeView` instances + +### 5. Render tests + +Create `packages/client/src/components/reports/dynamic-report/__tests__/custom-node.test.tsx`. + +Use vitest + @testing-library/react. + +Required test cases (use `renderWithProviders` or a minimal wrapper with DndProvider): + +- `sort-code-branch` node: rename input is NOT present regardless of `editMode` +- `synthetic-branch` node, `editMode=false`: rename and delete controls not present +- `synthetic-branch` node, `editMode=true`: rename input toggle visible, delete menu item visible +- `financial-entity` node: value badge present, no rename or delete + +Run `yarn workspace @accounter/client test --run`. + +```` + +--- + +### Prompt 10 — `TreeView` cross-tree `canDrop` rules + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/tree-view.tsx + +## Context + +Currently `canDrop` only allows/prevents based on `editMode` and whether the +source is already a child of the target. We need richer rules that apply to both +the bank and report panels. + +## Task + +### 1. Add `treeId` prop + +```typescript +type Props = ... & { + treeId: 'bank' | 'report'; + editMode: boolean; +}; +```` + +### 2. Update `canDrop` + +```typescript +canDrop={(_tree, { dragSource, dropTargetId, dropTarget }) => { + if (!editMode) return false; + + // Cannot drop anything onto a financial-entity leaf + if (dropTarget && isFinancialEntityNode(dropTarget)) return false; + + // Allow dropping onto the root of either tree + if (dropTargetId === rootId) return true; + + // Within the same tree: allow reordering among siblings + if (dragSource?.parent === dropTargetId) return true; + + return undefined; // let the library decide for other cases +}} +``` + +Import `isFinancialEntityNode` from `./types.js`. + +### 3. Unit tests + +Create `packages/client/src/components/reports/dynamic-report/__tests__/tree-view.test.tsx`. + +Required test cases: + +- `editMode=false` → `canDrop` returns false regardless of source/target +- Dropping onto a `financial-entity` node → returns false +- Dropping onto a branch node or root → returns true (when `editMode=true`) + +Because `canDrop` is an internal prop passed to `Tree`, test the logic via the exported component: +simulate that the drop is blocked by asserting the resulting tree state does not change when +dropping on a financial-entity target. + +Run `yarn workspace @accounter/client test --run`. + +```` + +--- + +### Prompt 11 — Delete branch → confirmation dialog + move to bank + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/index.tsx + packages/client/src/components/reports/dynamic-report/custom-node.tsx + +## Context + +Currently `handleDeleteCategory` in `index.tsx` removes the branch and re-parents +its direct children to `BANK_TREE_ROOT_ID`. The spec requires: +- When a branch is deleted from the **report** tree, the entire branch + (structure intact) moves to the bank tree root. +- A confirmation dialog is shown before the delete is executed. +- Deleting a branch from the **bank** tree removes it (with confirmation). + +## Task + +### 1. Create `move-branch-to-bank.ts` + +Create +`packages/client/src/components/reports/dynamic-report/move-branch-to-bank.ts`. + +```typescript +import { getDescendants, type NodeModel } from '@minoru/react-dnd-treeview'; +import { BANK_TREE_ROOT_ID } from './bank-tree.js'; +import type { CustomData } from './types.js'; + +/** + * Removes `branchId` and all its descendants from `reportTree`, + * and appends them to `bankTree` with the branch's parent set to + * BANK_TREE_ROOT_ID (descendants retain their relative parents). + */ +export function moveBranchToBank( + branchId: NodeModel['id'], + reportTree: NodeModel[], + bankTree: NodeModel[], +): { nextReportTree: NodeModel[]; nextBankTree: NodeModel[] } +```` + +### 2. Update `handleDeleteCategory` in index.tsx + +Replace the existing single-tree implementation: + +```typescript +const handleDeleteCategory = useCallback((id: NodeModel['id'], treeSource: 'bank' | 'report') => { + setPendingDelete({ id, treeSource }) + setDeleteConfirmOpen(true) +}, []) + +const confirmDelete = useCallback(() => { + if (!pendingDelete) return + if (pendingDelete.treeSource === 'report') { + const { nextReportTree, nextBankTree } = moveBranchToBank( + pendingDelete.id, + reportTree, + bankTree + ) + updateReportTree(() => nextReportTree) + updateBankTree(() => nextBankTree) + } else { + // Delete from bank: remove branch + descendants entirely + const ids = new Set([ + pendingDelete.id, + ...getDescendants(bankTree, pendingDelete.id).map(n => n.id) + ]) + updateBankTree(prev => prev.filter(n => !ids.has(n.id))) + } + setDeleteConfirmOpen(false) + setPendingDelete(null) +}, [pendingDelete, reportTree, bankTree, updateReportTree, updateBankTree]) +``` + +Add state: `const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);` Add state: +`const [pendingDelete, setPendingDelete] = useState<{ id: NodeModel['id']; treeSource: 'bank' | 'report' } | null>(null);` + +Render a shadcn `AlertDialog` that is open when `deleteConfirmOpen`: + +- Title: "Delete branch" +- Description: "This branch will be moved to the bank. Continue?" (for report) / "Delete this branch + permanently?" (for bank) +- Cancel + Confirm buttons. + +### 3. Thread `treeSource` through `TreeView` → `CustomNode` + +Add a `treeSource: 'bank' | 'report'` prop to both `TreeView` and `CustomNode`. Pass it to the +`onDeleteCategory` callback. + +### 4. Unit tests + +Create +`packages/client/src/components/reports/dynamic-report/__tests__/move-branch-to-bank.test.ts`. + +Required test cases: + +- Branch with two entity children: after `moveBranchToBank`, branch and both children are absent + from report tree, present in bank tree +- Branch root's parent in bank is `BANK_TREE_ROOT_ID` +- Children's parents are unchanged (still point to the branch id) +- Bank tree and report tree together still contain the same total count of nodes + +Run `yarn workspace @accounter/client test --run`. + +```` + +--- + +### Prompt 12 — Template management: save-as, resave, rename, duplicate + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/dynamic-report-save-template.tsx + packages/client/src/components/reports/dynamic-report/dynamic-report-manage-templates.tsx + packages/client/src/components/reports/dynamic-report/index.tsx + +## Context + +### Current state +- `SaveTemplate` inserts a new template (save-as only). +- `ManageTemplates` lists templates with delete and unlock actions. + +### Required additions +- **Resave**: overwrite the currently loaded template with the current report tree. +- **Rename**: rename the currently loaded template. +- **Duplicate**: save the current report tree as a new template (like save-as, but + pre-fills a suggested name). + +### Template serialisation rule +The saved template must contain **only report tree nodes** — bank tree nodes are +never saved. + +Entity leaf nodes must include `nodeType: 'financial-entity'` and omit `value` +(a runtime-only field). + +## Task + +### 1. Extract `serializeReportTree` helper + +Create `packages/client/src/components/reports/dynamic-report/template-serialization.ts`: + +```typescript +import type { NodeModel } from '@minoru/react-dnd-treeview'; +import type { CustomData } from './types.js'; + +/** + * Converts the report tree state to the JSON string persisted in the DB. + * - Strips `value` (runtime only) + * - Includes `nodeType`, `isOpen`, `hebrewText`, `sortCode` in data + * - Returns a JSON string + */ +export function serializeReportTree(reportTree: NodeModel[]): string +```` + +### 2. Update `SaveTemplate` + +- Rename the internal `template` memo to use `serializeReportTree(reportTree)`. +- Accept `reportTree` as prop (instead of `tree`). +- Remove references to old serialization code that included bank-tree nodes. + +### 3. Add Resave button + +In `index.tsx`, add a "Resave" button to the footer that is only enabled when a `templateName` is +set and `isDirty` is true. On click it calls +`updateDynamicReportTemplate({ name: templateName, template: serializeReportTree(reportTree) })` via +the existing `useUpdateDynamicReportTemplate` hook, then sets `isDirty = false`. + +### 4. Update `ManageTemplates` + +In the per-row action dropdown in `dynamic-report-manage-templates.tsx`, add: + +- **Rename**: opens an inline input in the row (or a modal) with the current name, calls + `updateDynamicReportTemplateName({ name, newName })` on submit. +- **Duplicate**: triggers the save-as modal pre-filled with `"Copy of "`. + +For locked templates the only available action remains **duplicate** (not rename, delete, or edit). +Ensure locked rows disable the other actions. + +### 5. Unit tests for serialization + +Create +`packages/client/src/components/reports/dynamic-report/__tests__/template-serialization.test.ts`. + +Required test cases: + +- `serializeReportTree` output parses back to valid JSON +- Output does not contain any node whose `data.nodeType` is not one of the three valid values +- `value` field is absent from all serialized nodes +- `sortCode` field is present on nodes with `nodeType: 'sort-code-branch'` + +Run `yarn workspace @accounter/client test --run`. + +```` + +--- + +### Prompt 13 — Legacy template detection banner + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/index.tsx + +## Context + +After prompt 5 we have `isLegacyTemplateNodes` and `migrateLegacyTemplateNodes` +from `./legacy-migration.js`. After prompt 6/7, when a template is loaded the +component calls `buildReportTree`. We need to intercept legacy templates before +`buildReportTree` and show a dismissible banner. + +## Task + +### 1. Detect and migrate on template load + +In the `useEffect` / `useMemo` that processes `templateData`: +1. Check `isLegacyTemplateNodes(templateData.dynamicReport.template)`. +2. If legacy: + a. Call `migrateLegacyTemplateNodes(templateData.dynamicReport.template, businessSums)` + to get migrated nodes. + b. Pass migrated nodes to `buildReportTree`. + c. Set state `isLegacyTemplate = true`. +3. If not legacy: + a. Pass nodes directly to `buildReportTree`. + b. Set `isLegacyTemplate = false`. + +Add state: `const [isLegacyTemplate, setIsLegacyTemplate] = useState(false);` + +### 2. Banner + +Render an `Alert` (variant `default`, using the `Info` icon from lucide-react) +below the toolbar and above the two-panel layout when `isLegacyTemplate` is true: + +```tsx +{isLegacyTemplate && ( + + + Legacy template format + + This template was saved in an older format and has been automatically + migrated. Please resave to update it permanently. + + +)} +```` + +The alert is dismissible by clicking the Resave button (which sets `isLegacyTemplate = false` after +a successful save). + +### 3. Render test additions + +Extend `__tests__/index.test.tsx`: + +- When the template query returns a legacy node array (containing `descendantFinancialEntities`), + the "Legacy template format" alert is present. +- When the template query returns a new-format node array, the alert is absent. + +Run `yarn workspace @accounter/client test --run`. + +```` + +--- + +### Prompt 14 — Integration wiring: filters, URL params, locked template, CSV + +```text +We are working in: + packages/client/src/components/reports/dynamic-report/index.tsx + packages/client/src/components/reports/dynamic-report/download-csv.tsx + +This is the integration pass — wiring all pieces together and ensuring no +regressions in the existing cross-cutting concerns. + +## Task + +### 1. Filters and URL params + +Verify (and fix if broken) that: +- `fromDate`, `toDate`, `ownerIds` from the filter state are passed to + `DynamicReportDocument` query variables (unchanged). +- `isShowZeroedAccounts` is passed to `buildInitialBankTree` as `includeZeroed`. +- On filter change the bank tree is rebuilt (via the dependency on + `businessTransactionsSumData` in the `useEffect`). +- The filter state is read from and written to URL query params via `useUrlQuery` + (unchanged from current implementation). + +### 2. Locked template enforcement + +When the loaded template has `isLocked === true` (from +`templateData.dynamicReport.isLocked`): +- Disable the edit-mode toggle (`` or hidden). +- Hide the Resave button. +- Disable Delete and Rename in `ManageTemplates` for that row. +- The only available template action is Duplicate. + +Add a `isTemplateLocked` derived value: +```typescript +const isTemplateLocked = templateData?.dynamicReport?.isLocked ?? false; +```` + +Pass it to the edit-mode `` as `disabled={isTemplateLocked}`. Pass it to `ManageTemplates` +as a `currentTemplateLocked` prop so it can disable actions for the currently-active row. + +### 3. CSV export — report tree only + +In `download-csv.tsx`, verify (or update) that the `tree` prop it receives is the `reportTree` +state, not the combined tree. In `index.tsx`, change the `DownloadCSV` usage to +``. + +### 4. Reset dirty / isLegacyTemplate after save + +After any successful mutation that saves the template (`insertDynamicReportTemplate`, +`updateDynamicReportTemplate`): + +- Set `isDirty = false` +- Set `isLegacyTemplate = false` + +### 5. AddBankNode restricted to bank + +`handleAddBankNode` should only add a node to `bankTree`, not `reportTree`. Verify that the newly +created synthetic branch has `parent: BANK_TREE_ROOT_ID` and `data.nodeType: 'synthetic-branch'`. + +### 6. Integration smoke test + +Extend `__tests__/index.test.tsx`: + +- Mock a locked template; assert the edit-mode switch is disabled. +- Assert the "Resave" button is absent when the template is locked. +- Assert `` receives `reportTree` (check prop via a mock or by inspecting the rendered + DOM for report-only node names). + +Run `yarn workspace @accounter/client test --run` and `yarn lint` and confirm zero errors. + +```` + +--- + +### Prompt 15 — Final: `yarn generate` + end-to-end type check + lint + +```text +This is the final cleanup prompt. No new features — only verification and fixes. + +## Task + +### 1. Run codegen + +```bash +yarn generate +```` + +This regenerates `packages/client/src/gql/graphql.ts` and server `__generated__/types` from the +updated GraphQL schema (prompt 1). Some generated types will change because `DynamicReportNodeData` +no longer has `descendantSortCodes` etc. + +### 2. Fix generated-type usages + +After codegen, the `TemplateForDynamicReport` query document in `index.tsx` will request fields that +no longer exist in the schema. Update the inline GraphQL query: + +```graphql +query TemplateForDynamicReport($name: String!) { + dynamicReport(name: $name) { + id + name + isLocked + template { + id + parent + text + droppable + data { + nodeType + isOpen + hebrewText + } + } + } +} +``` + +Remove `descendantSortCodes`, `descendantFinancialEntities`, `mergedSortCodes` from this query +fragment. + +For the legacy-migration path, the query that loads legacy templates must still request those fields +from the old GraphQL endpoint during the transition window. Since the server no longer exposes them, +the client-side legacy detection will simply never trigger for templates loaded after the schema +change; the only legacy templates will be those whose raw JSON string (in the DB) was saved in the +old format. The `isLegacyTemplateNodes` check operates on the raw template JSON returned as +`template` nodes — if the server parses with the new Zod schema it will strip old fields. To handle +this cleanly: + +- Keep the server-side `parseTemplate` behind a try/catch that falls back to parsing with the old + lenient schema (the legacy Zod shape without `.strict()` and with optional old fields). Return the + parsed nodes verbatim so the client can detect and migrate. +- OR (simpler): store and return the raw template JSON as a plain string field `rawTemplate: String` + alongside the structured `template: [DynamicReportNode]` field, and let the client parse + detect + the legacy format from `rawTemplate`. + +Choose the simpler option: add `rawTemplate: String` to `DynamicReportInfo` in the GraphQL typeDef, +resolve it by returning the raw DB string from the provider. The client uses `rawTemplate` only in +the legacy-detection path and the structured `template` field for the normal rendering path. + +Update `TemplateForDynamicReport` query to also request `rawTemplate`. Update `DynamicReportInfo` +resolver to return `rawTemplate`. + +### 3. Full type-check + +```bash +yarn workspace @accounter/client tsc --noEmit +yarn workspace @accounter/server tsc --noEmit +``` + +Fix any remaining type errors. + +### 4. Lint + +```bash +yarn lint +``` + +Fix any ESLint warnings or errors. + +### 5. Full test run + +```bash +yarn test +``` + +All tests must pass. + +``` + +--- + +## Summary of Prompt Ordering + +| # | File(s) touched | What it tests | +|---|---|---| +| 1 | server typeDef, Zod helper | Server unit tests for parse/migrate | +| 2 | client `types.ts` | TypeScript compile check | +| 3 | `bank-tree.ts` | Pure-function unit tests | +| 4 | `report-tree.ts` | Pure-function unit tests | +| 5 | `legacy-migration.ts` | Pure-function unit tests | +| 6 | `index.tsx` split state | Render smoke test | +| 7 | `cross-tree-drop.ts` | Drop-handler unit tests | +| 8 | `index.tsx` edit/dirty | Render + interaction tests | +| 9 | `custom-node.tsx` | Node render tests | +| 10 | `tree-view.tsx` | `canDrop` logic tests | +| 11 | `move-branch-to-bank.ts`, `index.tsx` | Move-to-bank unit tests | +| 12 | save/manage templates | Serialization unit tests | +| 13 | `index.tsx` legacy banner | Render tests | +| 14 | `index.tsx` (integration) | Integration smoke tests | +| 15 | All (codegen + types) | Compile + lint + full test run | +``` diff --git a/docs/dynamic-report-refactor/spec.md b/docs/dynamic-report-refactor/spec.md new file mode 100644 index 0000000000..3dc6d85a34 --- /dev/null +++ b/docs/dynamic-report-refactor/spec.md @@ -0,0 +1,239 @@ +# Dynamic Report Refactor — Specification + +## Overview + +Refactor the dynamic report screen from its current implicit-membership model (sort codes as parent +hints, entities inferred by membership) to an explicit-node model where every entity appears exactly +once, both the bank and the report are full freeform trees, and templates save the report tree with +explicit entity leaf nodes. + +--- + +## Existing implementation (context) + +- **DB table**: `accounter_schema.dynamic_report_templates` — columns: `name`, `owner_id`, + `template` (JSON text), `created_at`, `updated_at`, `is_locked` +- **Template JSON node shape**: `{ id, parent, text, droppable, data }` where + `data = { descendantSortCodes, descendantFinancialEntities, mergedSortCodes, isOpen, hebrewText }` +- `descendantSortCodes` / `descendantFinancialEntities` are "hint" arrays on branch nodes — entities + are **not** stored as explicit leaf nodes in the current format +- **GraphQL mutations**: `updateDynamicReportTemplate`, `updateDynamicReportTemplateName`, + `insertDynamicReportTemplate`, `deleteDynamicReportTemplate`, `lockDynamicReportTemplate`, + `unlockDynamicReportTemplate` +- **Lock**: DB boolean `is_locked`; lock/unlock mutations exist but are not exposed from the dynamic + report screen (locked by external flows, e.g. annual audit) +- **Provider**: `DynamicReportProvider` — + `packages/server/src/modules/reports/providers/dynamic-report.provider.ts` +- **Zod validation**: `dynamicReportTemplate` — + `packages/server/src/modules/reports/helpers/dynamic-report.helper.ts` +- **Leaf expanded view** currently reuses `BusinessExtendedInfo`, which calls + `businessTransactionsFromLedgerRecords(businessIDs: [id])` + +--- + +## Required backend changes + +### New template node format + +| Field | Old | New | +| ---------------------------------- | ---------- | ---------------------------------------------------------------- | +| `data.nodeType` | _(absent)_ | `"synthetic-branch" \| "sort-code-branch" \| "financial-entity"` | +| `data.descendantSortCodes` | present | **removed** | +| `data.mergedSortCodes` | present | **removed** | +| `data.descendantFinancialEntities` | present | **removed** | +| `data.isOpen` | present | kept | +| `data.hebrewText` | present | kept | + +Financial entities are stored as **explicit leaf nodes** in the template JSON array (not inferred +from sort code membership). A financial entity leaf has `droppable: false` and +`data.nodeType: "financial-entity"`. + +**Node IDs**: + +- Financial entity leaf: entity UUID +- Sort-code branch: sort code DB id +- Synthetic branch: generated string id (e.g. `synthetic-`) + +**Files to change**: + +- `packages/server/src/modules/reports/typeDefs/dynamic-report.graphql.ts` — `DynamicReportNodeData` + type: remove `descendantSortCodes`, `mergedSortCodes`, `descendantFinancialEntities`; add + `nodeType: String!` +- `packages/server/src/modules/reports/helpers/dynamic-report.helper.ts` — Update Zod schema to + match new shape +- No DB column changes needed (template is stored as an opaque JSON text column) + +### Backward compatibility + +On template load, detect old format by checking for presence of `descendantSortCodes` on any node. +If detected: + +1. Migrate the template in-memory to the new explicit-leaf format using the live entity data +2. Display a prompt informing the user the template is in a legacy format and asking them to resave + +--- + +## Functional specification + +### Core data + +- Data source: ledger records +- Each side of a ledger record is a **financial entity** (business or tax category) +- The server computes the total local-currency amount per entity for a given date range (existing + `businessTransactionsSumFromLedgerRecords` query) +- The report displays all entities with their local currency sum + +--- + +### Layout + +Two-panel side-by-side layout: + +- **Left panel** — Bank +- **Right panel** — Report tree + +--- + +### Bank (left panel) + +**Initial / default state** (applied on page load and on template load): + +- Entities grouped by sort-code folders +- Sort-code folders ordered ascending by sort code number +- Each sort-code folder label: `` +- Independent entities (no sort code) listed alphabetically **after** all sort-code folders + +**User-controlled state**: + +- User can reorder branches and leaves freely (drag handles or drag-and-drop) +- User can create custom synthetic branches in the bank +- Exact user-controlled order is preserved in the session + +**Bank state is never saved** to a template. Loading a template always resets the bank to its +initial state. + +--- + +### Report tree (right panel) + +- Fully user-structured tree of **branches** (synthetic or sort-code) and **leaves** (financial + entities) +- Branches show: name, total sum of all descendant leaves (local currency), leaf count +- Branches can contain sub-branches, leaves, or both +- Branches are collapsible/expandable +- Leaves are expandable (shows the ledger records table — see + [Leaf expanded view](#leaf-expanded-view)) + +--- + +### Entity placement — single-presence rule + +Every entity exists in **exactly one** location at all times: the bank OR the report tree (never +both). + +| Action | Result | +| -------------------------------------------------- | --------------------------------------------------------------------------------------- | +| Drag sort-code folder or branch from bank → report | Entire unit (all descendants) moves to report; removed from bank | +| Drag single entity from bank → report | Entity moves to drop position in report | +| Drag single entity from report → bank | Entity moves to exact drop position in bank | +| Drag branch from report → bank | Branch structure preserved; lands at drop position in bank | +| Delete branch (via delete button) | Confirmation dialog → if confirmed, entire branch (structure intact) moves to bank root | +| Drag branch within report | Entire branch + all descendants move as a unit | + +--- + +### Edit mode + +- An explicit **"Edit mode" toggle** is required to enable: drag-and-drop, rename, add branch, + delete branch +- When a **locked** template is loaded, the edit mode toggle is **disabled or hidden** +- Any structural change (drag, rename, add, delete) marks the session as **dirty** +- Dirty state is visually indicated (e.g. "Unsaved changes" badge in the toolbar) + +--- + +### Templates + +**What is saved**: report tree only — branch nodes (synthetic + sort-code), leaf nodes (explicit +entity UUIDs), nesting, order, and `isOpen` state per node. + +**What is NOT saved**: bank state. + +**On template load**: + +1. Entities listed in the template → placed in the report tree as specified +2. All other entities → bank, reset to initial state (sort-code groups, default order) +3. Entities created after the template was saved → always start in the bank + +**Dirty-state guard**: if the session has unsaved changes when the user picks a template, show a +confirmation dialog: _"Unsaved changes will be lost. Continue?"_ + +**Template management** (all from the dynamic report screen): + +| Operation | Description | +| --------- | ------------------------------------------ | +| Select | Load a saved template | +| Save as | Save current state as a new named template | +| Resave | Overwrite the currently loaded template | +| Rename | Rename the currently loaded template | +| Duplicate | Save a copy under a new name | +| Delete | Delete a template (with confirmation) | + +**Locked templates**: + +- Locked by external flows (e.g. annual audit step) — not from this screen +- When loaded: duplicate only; edit, delete, rename disabled; edit mode toggle hidden +- The `is_locked` flag is a DB boolean; `lockDynamicReportTemplate` / `unlockDynamicReportTemplate` + mutations exist but are not surfaced here + +**Sharing**: templates are shared across all users of the same business owner. + +--- + +### Filters + +Same as current implementation: + +- Date range (from + to) +- Owner filter +- "Show zeroed accounts" toggle +- All filters persisted in URL query params + +--- + +### Leaf expanded view + +**Current implementation** (to reuse for now): `BusinessExtendedInfo` component +(`packages/client/src/components/business-ledger/business-extended-info.tsx`), which calls +`businessTransactionsFromLedgerRecords` with `businessIDs: [entityId]`. + +**Future step** (separate work item): replace `BusinessExtendedInfo` with a purpose-built component +for this screen: + +- Fixed columns: Business, Date, Local Amount, Local Amount Balance, Reference, Details, Counter + Account +- Optional foreign currency column pairs (Amount + Balance) — only currencies actually present in + that entity's ledger records are offered +- Currency toggle state is ephemeral (not saved), defaults to local currency only +- Each entity independently manages its shown foreign currencies + +--- + +### Export + +CSV export button — exports the **report tree only** (same as current behavior). + +--- + +## Files affected + +| File | Change | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `packages/server/src/modules/reports/typeDefs/dynamic-report.graphql.ts` | Update `DynamicReportNodeData` GraphQL type | +| `packages/server/src/modules/reports/helpers/dynamic-report.helper.ts` | Update Zod schema + add backward-compat migration helper | +| `packages/client/src/components/reports/dynamic-report/index.tsx` | Main component rewrite | +| `packages/client/src/components/reports/dynamic-report/custom-node.tsx` | Node rendering — `nodeType`-aware, edit mode prop | +| `packages/client/src/components/reports/dynamic-report/tree-view.tsx` | Drag rules — cross-tree, single-presence enforcement | +| `packages/client/src/components/reports/dynamic-report/types.ts` | `CustomData` type — add `nodeType`, remove hint arrays | +| `packages/client/src/components/reports/dynamic-report/dynamic-report-manage-templates.tsx` | Template management UI (select, resave, rename, duplicate, delete) | +| `packages/client/src/components/reports/dynamic-report/dynamic-report-save-template.tsx` | Save-as flow | From b962e183fe9f67312f8b4ab3015e224de93ceb24 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Wed, 22 Apr 2026 11:04:57 +0300 Subject: [PATCH 2/4] initial UI + doc update Co-authored-by: Copilot --- docs/dynamic-report-refactor/blueprint.md | 311 ++++++++------ docs/dynamic-report-refactor/spec.md | 39 ++ packages/client/package.json | 4 + .../dynamic-report-2/cross-tree-drop.ts | 140 ++++++ .../reports/dynamic-report-2/drag-overlay.tsx | 26 ++ .../reports/dynamic-report-2/index.tsx | 384 +++++++++++++++++ .../dynamic-report-2/legacy-banner.tsx | 28 ++ .../reports/dynamic-report-2/mock-data.ts | 251 +++++++++++ .../dynamic-report-2/template-manager.tsx | 232 ++++++++++ .../reports/dynamic-report-2/toolbar.tsx | 185 ++++++++ .../reports/dynamic-report-2/tree-node.tsx | 401 ++++++++++++++++++ .../reports/dynamic-report-2/tree-panel.tsx | 111 +++++ .../reports/dynamic-report-2/types.ts | 111 +++++ yarn.lock | 90 +++- 14 files changed, 2178 insertions(+), 135 deletions(-) create mode 100644 packages/client/src/components/reports/dynamic-report-2/cross-tree-drop.ts create mode 100644 packages/client/src/components/reports/dynamic-report-2/drag-overlay.tsx create mode 100644 packages/client/src/components/reports/dynamic-report-2/index.tsx create mode 100644 packages/client/src/components/reports/dynamic-report-2/legacy-banner.tsx create mode 100644 packages/client/src/components/reports/dynamic-report-2/mock-data.ts create mode 100644 packages/client/src/components/reports/dynamic-report-2/template-manager.tsx create mode 100644 packages/client/src/components/reports/dynamic-report-2/toolbar.tsx create mode 100644 packages/client/src/components/reports/dynamic-report-2/tree-node.tsx create mode 100644 packages/client/src/components/reports/dynamic-report-2/tree-panel.tsx create mode 100644 packages/client/src/components/reports/dynamic-report-2/types.ts diff --git a/docs/dynamic-report-refactor/blueprint.md b/docs/dynamic-report-refactor/blueprint.md index 20ce302702..5761623e68 100644 --- a/docs/dynamic-report-refactor/blueprint.md +++ b/docs/dynamic-report-refactor/blueprint.md @@ -25,7 +25,8 @@ render them side-by-side, initially without cross-tree drag. ### Phase 5 — Single-presence drop handler Implement the cross-tree drop logic that enforces "entity exists in exactly one place" including -whole-branch moves. +whole-branch moves. Uses Pragmatic DnD `onDrop` callbacks and the `applyInstruction` utility to +translate tree-item `Instruction` objects into flat-array mutations. ### Phase 6 — Edit mode + dirty state @@ -67,7 +68,7 @@ Chunk C buildInitialBankTree pure function + tests Chunk D buildReportTree pure function + tests Chunk E Client-side legacy-migration helper + tests Chunk F Split bank/report state + two-panel layout (no drag yet) -Chunk G Cross-tree single-presence drop handler + tests +Chunk G Cross-tree single-presence drop handler (Pragmatic DnD) + tests Chunk H Edit mode + dirty state + confirmation dialog Chunk I CustomNode — nodeType-aware rendering + edit-mode prop Chunk J TreeView — cross-tree canDrop rules @@ -249,41 +250,72 @@ export type CustomData = { } ``` -### 2. Add predicate helpers (same file, exported) +### 2. Add `FlatNode` type + +Replace the dependency on `NodeModel` from `@minoru/react-dnd-treeview` with a local type that +retains the same flat-array shape used for DB serialisation: ```typescript -import type { NodeModel } from '@minoru/react-dnd-treeview' +/** + * Flat node shape — matches the DB template JSON format exactly. + * Replaces @minoru/react-dnd-treeview's NodeModel. + */ +export type FlatNode = { + id: string + parent: string // parent id, or BANK_TREE_ROOT_ID / REPORT_TREE_ROOT_ID for root nodes + text: string + droppable: boolean + data: T +} +``` -export function isFinancialEntityNode(node: NodeModel): boolean { - return node.data?.nodeType === 'financial-entity' +### 3. Add predicate helpers (same file, exported) + +```typescript +export function isFinancialEntityNode(node: FlatNode): boolean { + return node.data.nodeType === 'financial-entity' } -export function isSortCodeBranchNode(node: NodeModel): boolean { - return node.data?.nodeType === 'sort-code-branch' +export function isSortCodeBranchNode(node: FlatNode): boolean { + return node.data.nodeType === 'sort-code-branch' } -export function isSyntheticBranchNode(node: NodeModel): boolean { - return node.data?.nodeType === 'synthetic-branch' +export function isSyntheticBranchNode(node: FlatNode): boolean { + return node.data.nodeType === 'synthetic-branch' } -export function isBranchNode(node: NodeModel): boolean { +export function isBranchNode(node: FlatNode): boolean { return node.droppable === true } + +/** Returns ids of all descendants in a flat node array. */ +export function getDescendantIds(nodes: FlatNode[], rootId: string): string[] { + const result: string[] = [] + const queue = [rootId] + while (queue.length) { + const id = queue.shift()! + for (const n of nodes) { + if (n.parent === id) { + result.push(n.id) + queue.push(n.id) + } + } + } + return result +} ``` -### 3. Fix immediately visible type errors +### 4. Fix immediately visible type errors -After changing `CustomData`, TypeScript will produce errors in: +After changing `CustomData` and removing `NodeModel`, TypeScript will produce errors in: -- `custom-node.tsx` — references to `data?.sortCode`, `data?.value` etc. These are still valid - fields, but `data?.descendantSortCodes` etc. must be removed. Remove any usage of the removed - fields; keep `sortCode` and `value` access. -- `index.tsx` — same removals for the dropped fields. -- `dynamic-report-save-template.tsx` — the template serialisation references the old fields; remove - `descendantSortCodes`, `descendantFinancialEntities`, `mergedSortCodes` from the serialised - object. -- `tree-view.tsx` — references to `data?.sortCode` in the existing `handleDrop` / `canDrop` logic; - update as needed to use the new predicates. +- `custom-node.tsx` — switch `NodeModel` to `FlatNode`; remove + `data?.descendantSortCodes` etc. +- `index.tsx` — same removals for the dropped fields; replace `NodeModel` with `FlatNode`. +- `dynamic-report-save-template.tsx` — remove `descendantSortCodes`, `descendantFinancialEntities`, + `mergedSortCodes` from the serialised object. +- `tree-view.tsx` — remove `NodeModel` imports; the `handleDrop` / `canDrop` logic will be replaced + in Chunk G with Pragmatic DnD callbacks. Do NOT restructure any component behaviour yet — only fix TypeScript errors caused by the type change. @@ -314,7 +346,8 @@ The relevant GraphQL query types from `packages/client/src/gql/graphql.ts` are: - `BusinessTransactionsSumFromLedgerRecordsSuccessfulResult['businessTransactionsSum']` — array of `{ business: { id, name, sortCode: { id, key, name } | null }, total: { raw } }` -`NodeModel` is from `@minoru/react-dnd-treeview`. +`FlatNode` is the local flat-node type defined in `./types.js` (replaces +`NodeModel` from the old `@minoru/react-dnd-treeview` dependency). ## Task @@ -325,9 +358,8 @@ Create `packages/client/src/components/reports/dynamic-report/bank-tree.ts`. Export one pure function: ```typescript -import type { NodeModel } from '@minoru/react-dnd-treeview'; import type { AllSortCodesQuery, DynamicReportQuery } from '../../../gql/graphql.js'; -import type { CustomData } from './types.js'; +import type { CustomData, FlatNode } from './types.js'; export const BANK_TREE_ROOT_ID = 'bank'; @@ -359,15 +391,15 @@ export function buildInitialBankTree( businessSums: BusinessSum[], excludedIds: ReadonlySet, includeZeroed: boolean, -): NodeModel[] +): FlatNode[] ```` Implementation notes: - Sort-code branches: `{ id: sortCode.id, parent: BANK_TREE_ROOT_ID, droppable: true, text: \`${sortCode.key} — - ${sortCode.name}\`, data: { nodeType: 'sort-code-branch', sortCode: sortCode.key, isOpen: false } - }` + ${sortCode.name}\`, + data: { nodeType: 'sort-code-branch', sortCode: sortCode.key, isOpen: false } }` - Only include sort-code-branch nodes for sort codes that have at least one visible entity (otherwise bank is cluttered with empty folders). - Financial entity leaves: @@ -425,9 +457,8 @@ by the bank tree builder via `excludedIds`). Create `packages/client/src/components/reports/dynamic-report/report-tree.ts`. ```typescript -import type { NodeModel } from '@minoru/react-dnd-treeview'; import type { DynamicReportQuery } from '../../../gql/graphql.js'; -import type { CustomData } from './types.js'; +import type { CustomData, FlatNode } from './types.js'; export const REPORT_TREE_ROOT_ID = 'report'; @@ -461,14 +492,14 @@ type BusinessSum = Extract< * - The `isOpen` state per node is taken from the template. * * @returns { reportTree, placedEntityIds } - * reportTree — flat NodeModel array for the report panel + * reportTree — flat FlatNode array for the report panel * placedEntityIds — Set of entity UUIDs placed in the report tree * (so the bank builder can exclude them) */ export function buildReportTree( templateNodes: TemplateNode[], businessSums: BusinessSum[], -): { reportTree: NodeModel[]; placedEntityIds: Set } +): { reportTree: FlatNode[]; placedEntityIds: Set } ```` Implementation notes: @@ -530,10 +561,9 @@ Create `packages/client/src/components/reports/dynamic-report/legacy-migration.ts`. ```typescript -import type { NodeModel } from '@minoru/react-dnd-treeview'; import type { DynamicReportQuery } from '../../../gql/graphql.js'; import { REPORT_TREE_ROOT_ID } from './report-tree.js'; -import type { CustomData } from './types.js'; +import type { CustomData, FlatNode } from './types.js'; /** * Raw node shape as returned by the GraphQL query for a legacy template. @@ -585,7 +615,7 @@ export function isLegacyTemplateNodes(nodes: LegacyTemplateNode[]): boolean export function migrateLegacyTemplateNodes( nodes: LegacyTemplateNode[], businessSums: BusinessSum[], -): NodeModel[] +): FlatNode[] ```` ### 2. Unit tests @@ -633,14 +663,14 @@ The pure helpers we now have: Replace: ```typescript -const [tree, setTree] = useState[]>([]); +const [tree, setTree] = useState[]>([]); ```` with: ```typescript -const [bankTree, setBankTree] = useState[]>([]) -const [reportTree, setReportTree] = useState[]>([]) +const [bankTree, setBankTree] = useState[]>([]) +const [reportTree, setReportTree] = useState[]>([]) ``` ### 2. Rebuild on data change @@ -663,44 +693,39 @@ Replace the existing single `` JSX with a two-column layout: ```tsx
-
-

Bank

- -
-
-

Report

- -
+ +
``` -For now use stub handlers that just call `setBankTree` / `setReportTree` with the new tree passed in -by the library (no cross-tree logic yet): +`TreePanel` wraps a Pragmatic DnD `dropTargetForElements` drop zone and renders `TreeNodeRow` +children (each a Pragmatic DnD `draggable`). For now use stub handlers (no cross-tree logic yet): ```typescript -const handleBankDrop = useCallback((newTree: NodeModel[]) => setBankTree(newTree), []) -const handleReportDrop = useCallback( - (newTree: NodeModel[]) => setReportTree(newTree), - [] -) +const handleBankDrop = useCallback((nodes: FlatNode[]) => setBankTree(nodes), []) +const handleReportDrop = useCallback((nodes: FlatNode[]) => setReportTree(nodes), []) ``` ### 4. Remove dead code @@ -754,72 +779,89 @@ Create `packages/client/src/components/reports/dynamic-report/cross-tree-drop.ts`. ```typescript -import { getDescendants, type NodeModel, type DropOptions } from '@minoru/react-dnd-treeview'; -import type { CustomData } from './types.js'; +import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import type { CustomData, FlatNode } from './types.js'; +import { getDescendantIds } from './types.js'; + +export type DragPayload = { + nodeId: string; + sourceTreeId: 'bank' | 'report'; +}; /** - * Handles a drop that may cross tree boundaries (bank ↔ report). + * Applies a Pragmatic DnD tree-item Instruction to the flat node arrays. * - * @param sourceTree The tree the drag originated from - * @param targetTree The tree the drop target belongs to - * @param dropOptions DropOptions from @minoru/react-dnd-treeview - * @param newTargetTree The new target tree array emitted by the library's onDrop + * @param bankTree Current bank flat nodes + * @param reportTree Current report flat nodes + * @param payload Drag payload attached to the draggable element + * @param targetNodeId Id of the node the item was dropped on (or panel root id) + * @param targetTreeId Which panel received the drop ('bank' | 'report') + * @param instruction Tree-item hitbox instruction (reorder-above, reorder-below, + * make-child, reparent) — or null for panel-root drops * - * @returns { nextSourceTree, nextTargetTree } + * @returns { nextBankTree, nextReportTree } */ export function handleCrossTreeDrop( - sourceTree: NodeModel[], - targetTree: NodeModel[], - dropOptions: DropOptions, - newTargetTree: NodeModel[], -): { nextSourceTree: NodeModel[]; nextTargetTree: NodeModel[] } -```` + bankTree: FlatNode[], + reportTree: FlatNode[], + payload: DragPayload, + targetNodeId: string, + targetTreeId: 'bank' | 'report', + instruction: Instruction | null, +): { nextBankTree: FlatNode[]; nextReportTree: FlatNode[] } +``` Algorithm: -1. Collect the moved node IDs = - `[dragSourceId, ...getDescendants(sourceTree, dragSourceId).map(n => n.id)]` -2. Remove those IDs from `sourceTree` → `nextSourceTree` -3. Use `newTargetTree` as `nextTargetTree` (the library already placed the node correctly) -4. If the drop was within the SAME tree (source and target are the same array reference), return - `{ nextSourceTree: newTargetTree, nextTargetTree: newTargetTree }` (no cross-tree move needed). +1. Determine `sourceTree` = bank or report based on `payload.sourceTreeId`. +2. Collect moved node IDs = `[payload.nodeId, ...getDescendantIds(sourceTree, payload.nodeId)]`. +3. Remove those IDs from the source tree → `nextSourceTree`. +4. If `instruction` is `null` or the drop is on the panel root: append moved nodes to the end of + the target tree root (parent = target root id). +5. If `instruction.type === 'make-child'`: set `parent = targetNodeId` on the dragged node. +6. If `instruction.type === 'reorder-above'` / `'reorder-below'`: insert moved nodes before/after + `targetNodeId` in the flat array, preserving their original parent (or setting parent to + `targetNode.parent` if crossing trees). +7. If `instruction.type === 'reparent'`: set `parent` of the dragged node according to + `instruction.desiredLevel` (walk up ancestors of `targetNodeId` by level). +8. `canDrop` guard — return unchanged trees if: + - Target node is a `financial-entity` leaf and instruction is `make-child`. + - Source and target are the same node. ### 2. Wire into index.tsx -Replace the stub handlers with: +Replace the stub handlers with a single `onDrop` callback attached to the +`monitorForElements` global monitor from Pragmatic DnD (set up once in a `useEffect`): ```typescript -const handleBankDrop = useCallback( - (newTree: NodeModel[], dropOptions: DropOptions) => { - const { nextSourceTree, nextTargetTree } = handleCrossTreeDrop( - reportTree, - bankTree, - dropOptions, - newTree - ) - setReportTree(nextSourceTree) - setBankTree(nextTargetTree) - }, - [bankTree, reportTree] -) - -const handleReportDrop = useCallback( - (newTree: NodeModel[], dropOptions: DropOptions) => { - const { nextSourceTree, nextTargetTree } = handleCrossTreeDrop( - bankTree, - reportTree, - dropOptions, - newTree - ) - setBankTree(nextSourceTree) - setReportTree(nextTargetTree) - }, - [bankTree, reportTree] -) +useEffect(() => { + return monitorForElements({ + onDrop({ source, location }) { + const payload = source.data as DragPayload; + const target = location.current.dropTargets[0]; + if (!target) return; + const targetNodeId = target.data.nodeId as string; + const targetTreeId = target.data.treeId as 'bank' | 'report'; + const instruction = extractInstruction(target.data) ?? null; + + const { nextBankTree, nextReportTree } = handleCrossTreeDrop( + bankTree, + reportTree, + payload, + targetNodeId, + targetTreeId, + instruction, + ); + setBankTree(nextBankTree); + setReportTree(nextReportTree); + setIsDirty(true); + }, + }); +}, [bankTree, reportTree]); ``` -Note: `onDrop` in `@minoru/react-dnd-treeview` receives `(newTree, dropOptions)` — pass both. Update -the `TreeView` prop type for `onDrop` to match. +`extractInstruction` is from `@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item`. +`monitorForElements` is from `@atlaskit/pragmatic-drag-and-drop/element/adapter`. ### 3. Unit tests @@ -827,12 +869,13 @@ Create `packages/client/src/components/reports/dynamic-report/__tests__/cross-tr Required test cases: -- Entity dragged from source tree to target tree → entity absent from source, present in target at - drop position -- Branch with two children dragged across → branch + both children absent from source, present in - target -- Same-tree reorder (sourceTree === targetTree, i.e. same array reference used for both arguments) → - returns `newTargetTree` for both (no cross-tree deletion) +- Entity dragged from bank to report (instruction: `null` / panel-root) → entity absent from bank, + present in report as root node +- Branch with two children dragged from report to bank → branch + both children absent from report, + present in bank +- `make-child` instruction → dragged node parent set to `targetNodeId` +- `reorder-above` / `reorder-below` → dragged node inserted at correct position in flat array +- `canDrop` guard: `make-child` on a `financial-entity` leaf → trees unchanged Run `yarn workspace @accounter/client test --run`. @@ -842,7 +885,7 @@ Run `yarn workspace @accounter/client test --run`. ### Prompt 8 — Edit mode, dirty state, and confirmation dialog -```text +````text We are working in: packages/client/src/components/reports/dynamic-report/index.tsx @@ -870,14 +913,14 @@ Wrap `setBankTree` and `setReportTree` in helper functions that also set `isDirt ```typescript const updateBankTree = useCallback( - (fn: (prev: NodeModel[]) => NodeModel[]) => { + (fn: (prev: FlatNode[]) => FlatNode[]) => { setBankTree(fn) setIsDirty(true) }, [] ) const updateReportTree = useCallback( - (fn: (prev: NodeModel[]) => NodeModel[]) => { + (fn: (prev: FlatNode[]) => FlatNode[]) => { setReportTree(fn) setIsDirty(true) }, @@ -990,14 +1033,14 @@ Remove `const isCategory = droppable && !props.node.data?.sortCode` and replace ### 4. Pass `editMode` through `TreeView` → `CustomNode` -In `tree-view.tsx`: +In `tree-panel.tsx` (Pragmatic DnD version): -- Add `editMode: boolean` to `Props` -- Pass it to `CustomNode` in the `render` callback +- Add `editMode: boolean` to `TreePanelProps` +- Pass it down to each `TreeNodeRow` In `index.tsx`: -- Pass `editMode` to both `TreeView` instances +- Pass `editMode` to both `TreePanel` instances ### 5. Render tests diff --git a/docs/dynamic-report-refactor/spec.md b/docs/dynamic-report-refactor/spec.md index 3dc6d85a34..d799dfca5a 100644 --- a/docs/dynamic-report-refactor/spec.md +++ b/docs/dynamic-report-refactor/spec.md @@ -225,6 +225,42 @@ CSV export button — exports the **report tree only** (same as current behavior --- +### Drag-and-drop library + +The refactored component uses **Pragmatic drag and drop** (`@atlaskit/pragmatic-drag-and-drop`) as +the DnD engine, replacing the existing `@minoru/react-dnd-treeview` / `react-dnd` stack. + +**Rationale**: + +- `react-dnd` (used internally by `@minoru/react-dnd-treeview`) is abandoned and has known React 19 + incompatibilities. The project is on React 19. +- Pragmatic DnD is actively maintained by Atlassian, powers Trello/Jira/Confluence, is + framework-agnostic (no peer dependency on React internals), and ships a dedicated tree-item hitbox + package that directly solves the positional drop requirement. + +**Packages used**: + +| Package | Purpose | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `@atlaskit/pragmatic-drag-and-drop` | Core draggable / drop-target primitives | +| `@atlaskit/pragmatic-drag-and-drop-hitbox` | Tree-item hitbox: `instruction` objects (`reorder-above`, `reorder-below`, `make-child`, `reparent`) | +| `@atlaskit/pragmatic-drag-and-drop-react-beautiful-dnd-migration` | _(not used)_ | + +**Data model**: the flat `{ id, parent, droppable, text, data }` node shape (previously from +`NodeModel`) is retained as an internal type (`FlatNode`) so the DB +serialisation format is unchanged. The tree UI builds a nested view from this flat array for +rendering; mutations update the flat array. + +**Drop position**: the `@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item` `Instruction` type +encodes where a node should land (`reorder-above`, `reorder-below`, `make-child`, `reparent` with +`desiredLevel`). A shared `applyInstruction` utility translates an `Instruction` into flat-array +mutations. + +**`canDrop` equivalent**: the `canDrop` prop is replaced by a guard inside the `onDrop` callback. +The same rules apply — no dropping a branch into a financial-entity leaf; single-presence enforced. + +--- + ## Files affected | File | Change | @@ -237,3 +273,6 @@ CSV export button — exports the **report tree only** (same as current behavior | `packages/client/src/components/reports/dynamic-report/types.ts` | `CustomData` type — add `nodeType`, remove hint arrays | | `packages/client/src/components/reports/dynamic-report/dynamic-report-manage-templates.tsx` | Template management UI (select, resave, rename, duplicate, delete) | | `packages/client/src/components/reports/dynamic-report/dynamic-report-save-template.tsx` | Save-as flow | +| `packages/client/src/components/reports/dynamic-report-2/index.tsx` | Prototype main component — Pragmatic DnD wiring | +| `packages/client/src/components/reports/dynamic-report-2/tree-panel.tsx` | Prototype panel — Pragmatic DnD drop target | +| `packages/client/src/components/reports/dynamic-report-2/tree-node.tsx` | Prototype node row — Pragmatic DnD draggable + tree-item hitbox | diff --git a/packages/client/package.json b/packages/client/package.json index 521767eca3..88a26d4f2c 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -24,7 +24,11 @@ "preview": "vite preview" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "1.8.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "1.1.0", "@auth0/auth0-react": "2.16.2", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@hookform/resolvers": "5.2.2", diff --git a/packages/client/src/components/reports/dynamic-report-2/cross-tree-drop.ts b/packages/client/src/components/reports/dynamic-report-2/cross-tree-drop.ts new file mode 100644 index 0000000000..c7dd25f524 --- /dev/null +++ b/packages/client/src/components/reports/dynamic-report-2/cross-tree-drop.ts @@ -0,0 +1,140 @@ +import type { Instruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { + getDescendantIds, + isFinancialEntityNode, + type CustomData, + type FlatNode, +} from './types.js'; + +export type DragPayload = { + nodeId: string; + sourceTreeId: 'bank' | 'report'; +}; + +/** + * Applies a Pragmatic DnD tree-item Instruction to the flat node arrays. + * + * @param bankTree Current bank flat nodes + * @param reportTree Current report flat nodes + * @param payload Drag payload attached to the draggable element + * @param targetNodeId Id of the node the item was dropped on (or tree root id) + * @param targetTreeId Which panel received the drop ('bank' | 'report') + * @param instruction Tree-item hitbox instruction — or null for root-level drops + * + * @returns { nextBankTree, nextReportTree } + */ +export function handleCrossTreeDrop( + bankTree: FlatNode[], + reportTree: FlatNode[], + payload: DragPayload, + targetNodeId: string, + targetTreeId: 'bank' | 'report', + instruction: Instruction | null, +): { nextBankTree: FlatNode[]; nextReportTree: FlatNode[] } { + const sourceTree = payload.sourceTreeId === 'bank' ? bankTree : reportTree; + const targetTree = targetTreeId === 'bank' ? bankTree : reportTree; + + const draggedNode = sourceTree.find(n => n.id === payload.nodeId); + if (!draggedNode) return { nextBankTree: bankTree, nextReportTree: reportTree }; + + // canDrop guard: cannot make a financial-entity a child + if (instruction?.type === 'make-child') { + const targetNode = targetTree.find(n => n.id === targetNodeId); + if (targetNode && isFinancialEntityNode(targetNode)) { + return { nextBankTree: bankTree, nextReportTree: reportTree }; + } + } + + // Collect all ids to move (dragged node + all descendants) + const movedIds = new Set([payload.nodeId, ...getDescendantIds(sourceTree, payload.nodeId)]); + const movedNodes = sourceTree.filter(n => movedIds.has(n.id)); + + const isSameTree = payload.sourceTreeId === targetTreeId; + // After removing moved nodes from the source, what remains + const prunedSourceTree = sourceTree.filter(n => !movedIds.has(n.id)); + // The base for building the target (deduped: if same tree, use pruned) + const baseTarget = isSameTree ? prunedSourceTree : targetTree; + + let nextTargetTree: FlatNode[]; + + if (!instruction || instruction.type === 'instruction-blocked') { + // Append at root level of the target tree + const updatedMoved = movedNodes.map(n => + n.id === payload.nodeId ? { ...n, parent: targetTreeId } : n, + ); + nextTargetTree = [...baseTarget, ...updatedMoved]; + } else if (instruction.type === 'make-child') { + const updatedMoved = movedNodes.map(n => + n.id === payload.nodeId ? { ...n, parent: targetNodeId } : n, + ); + nextTargetTree = [...baseTarget, ...updatedMoved]; + } else if (instruction.type === 'reorder-above') { + const targetNode = baseTarget.find(n => n.id === targetNodeId); + const newParent = isSameTree + ? (targetNode?.parent ?? targetTreeId) + : (targetNode?.parent ?? targetTreeId); + const updatedMoved = movedNodes.map(n => + n.id === payload.nodeId ? { ...n, parent: newParent } : n, + ); + const targetIndex = baseTarget.findIndex(n => n.id === targetNodeId); + if (targetIndex === -1) { + nextTargetTree = [...baseTarget, ...updatedMoved]; + } else { + nextTargetTree = [ + ...baseTarget.slice(0, targetIndex), + ...updatedMoved, + ...baseTarget.slice(targetIndex), + ]; + } + } else if (instruction.type === 'reorder-below') { + const targetNode = baseTarget.find(n => n.id === targetNodeId); + const newParent = targetNode?.parent ?? targetTreeId; + const updatedMoved = movedNodes.map(n => + n.id === payload.nodeId ? { ...n, parent: newParent } : n, + ); + const targetIndex = baseTarget.findIndex(n => n.id === targetNodeId); + if (targetIndex === -1) { + nextTargetTree = [...baseTarget, ...updatedMoved]; + } else { + nextTargetTree = [ + ...baseTarget.slice(0, targetIndex + 1), + ...updatedMoved, + ...baseTarget.slice(targetIndex + 1), + ]; + } + } else if (instruction.type === 'reparent') { + // Walk up from targetNodeId by (currentLevel - desiredLevel) steps to find the new parent + const levelsUp = instruction.currentLevel - instruction.desiredLevel; + let ancestorId: string = targetNodeId; + for (let i = 0; i < levelsUp; i++) { + const ancestor = baseTarget.find(n => n.id === ancestorId); + if (!ancestor) break; + ancestorId = ancestor.parent; + } + const updatedMoved = movedNodes.map(n => + n.id === payload.nodeId ? { ...n, parent: ancestorId } : n, + ); + const targetIndex = baseTarget.findIndex(n => n.id === targetNodeId); + nextTargetTree = [ + ...baseTarget.slice(0, targetIndex + 1), + ...updatedMoved, + ...baseTarget.slice(targetIndex + 1), + ]; + } else { + nextTargetTree = [...baseTarget, ...movedNodes]; + } + + if (isSameTree) { + if (targetTreeId === 'bank') { + return { nextBankTree: nextTargetTree, nextReportTree: reportTree }; + } else { + return { nextBankTree: bankTree, nextReportTree: nextTargetTree }; + } + } else { + if (payload.sourceTreeId === 'bank') { + return { nextBankTree: prunedSourceTree, nextReportTree: nextTargetTree }; + } else { + return { nextBankTree: nextTargetTree, nextReportTree: prunedSourceTree }; + } + } +} diff --git a/packages/client/src/components/reports/dynamic-report-2/drag-overlay.tsx b/packages/client/src/components/reports/dynamic-report-2/drag-overlay.tsx new file mode 100644 index 0000000000..e63fc82d70 --- /dev/null +++ b/packages/client/src/components/reports/dynamic-report-2/drag-overlay.tsx @@ -0,0 +1,26 @@ +import { type ReactElement } from 'react'; +import { Building2, Folder, User } from 'lucide-react'; +import { isBranchNode, type CustomData, type FlatNode } from './types.js'; + +interface DragOverlayContentProps { + node: FlatNode; +} + +export function DragOverlayContent({ node }: DragOverlayContentProps): ReactElement { + const entityType = node.data.entityType ?? 'business'; + return ( +
+ {isBranchNode(node) ? ( + + ) : entityType === 'business' ? ( + + ) : ( + + )} + {node.text} + + ({isBranchNode(node) ? 'branch' : 'entity'}) + +
+ ); +} diff --git a/packages/client/src/components/reports/dynamic-report-2/index.tsx b/packages/client/src/components/reports/dynamic-report-2/index.tsx new file mode 100644 index 0000000000..8b90dd67fe --- /dev/null +++ b/packages/client/src/components/reports/dynamic-report-2/index.tsx @@ -0,0 +1,384 @@ +import { useCallback, useEffect, useState } from 'react'; +import { extractInstruction } from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog.js'; +import { Button } from '@/components/ui/button.js'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { handleCrossTreeDrop, type DragPayload } from './cross-tree-drop.js'; +import { LegacyBanner } from './legacy-banner.js'; +import { mockBankTree, mockOwners, mockReportTree, mockTemplates } from './mock-data.js'; +import { TemplateManager } from './template-manager.js'; +import { Toolbar } from './toolbar.js'; +import { TreePanel } from './tree-panel.js'; +import { type CustomData, type FlatNode, type Template } from './types.js'; + +export function DynamicReport() { + // State + const [fromDate, setFromDate] = useState('2024-01-01'); + const [toDate, setToDate] = useState('2024-12-31'); + const [selectedOwner, setSelectedOwner] = useState(mockOwners[0].id); + const [showZeroed, setShowZeroed] = useState(false); + const [editMode, setEditMode] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const [showLegacyBanner, setShowLegacyBanner] = useState(true); + + const [currentTemplate, setCurrentTemplate] = useState