diff --git a/docs/dynamic-report-audit/spec.md b/docs/dynamic-report-audit/spec.md new file mode 100644 index 0000000000..fd49c92a63 --- /dev/null +++ b/docs/dynamic-report-audit/spec.md @@ -0,0 +1,204 @@ +# Dynamic Report — Audit Layer Spec + +## Overview + +Add a per-node metadata layer to the dynamic report: every node (branch and leaf, in the report +tree) carries a **status**, an optional **note**, and an optional list of **linked documents**. +Metadata is stored inside the template JSON, shared across all users of the same business, and +travels with its node when the node is moved. Changes to metadata mark the template dirty and +require an explicit resave. + +--- + +## Data shape — additions to `node.data` + +Three new optional fields on every node (both branches and leaves): + +| Field | Type | Default | +| ----------------- | ----------------------------------------- | -------------- | +| `status` | `"unapproved" \| "pending" \| "approved"` | `"unapproved"` | +| `note` | `string \| null` | `null` | +| `linkedDocuments` | `LinkedDocument[]` | `[]` | + +`LinkedDocument` shape: + +``` +{ type: "uuid" | "url", value: string } +``` + +### Status semantics + +| Value | Meaning | +| ------------ | -------------------------------------- | +| `unapproved` | Default — node has never been reviewed | +| `pending` | Reviewed but waiting on something | +| `approved` | Fully reviewed and signed off | + +- Status is **independent** per node — a branch's status is not derived from its children +- There is no automatic status downgrade on ledger data changes (out of scope for this feature) + +--- + +## Storage + +Metadata is stored **inline in the template JSON** as extra fields on each node's `data` object. No +new DB columns or tables are needed. + +Metadata **travels with its node** when the node is dragged or moved — it is part of the node object +and moves with it. + +Metadata is **shared** across all users of the same business owner (same as the template itself). + +--- + +## Inline node indicators + +Every node row (in both the bank and report panels) shows the following indicators on its right side +— **always visible, regardless of edit mode or locked state**: + +- **Status badge** — colored pill: + - Gray: `unapproved` + - Yellow: `pending` + - Green: `approved` +- **Note icon** (`MessageSquare`) — shown only when `note` is non-null and non-empty +- **Paperclip icon** (`Paperclip`) — shown only when `linkedDocuments.length > 0` + +--- + +## Node metadata modal + +### How to open + +Clicking any of the inline indicators (status badge, note icon, paperclip icon) opens the modal. A +dedicated info/edit button on the node row may also open it. + +The modal is **always accessible** — not gated on edit mode, and accessible even for locked +templates. + +### Modal contents + +1. **Node label** — displayed as the modal title (read-only) + +2. **Status selector** — segmented control or radio group with three options: `Unapproved` / + `Pending` / `Approved` + +3. **Note** — multiline textarea; placeholder "Add a note…" + +4. **Linked documents** — list of currently linked items + an add-document input: + + Each existing item shows: + - `type: "url"` → raw URL rendered as a clickable external link + - `type: "uuid"` → description + link **resolved from the server**: + - Show a loading spinner while the resolution query is in flight + - On success: render `description` as a clickable link to the document's `url` + - On failure (UUID not found or error): fall back to displaying the raw UUID as plain text + - A remove (×) button to delete the item from the list + + **Add document input** — a text input + "Add" button: + - Accepts either a valid UUID or a full URL (`http://` or `https://`) + - Type is auto-detected on submit: UUID format → `type: "uuid"`, otherwise → `type: "url"` + - Appends the new item to the list + +5. **Footer buttons**: + - **Cancel** — discard all unsaved modal edits, close the modal + - **Save** — apply changes to the node's data in the tree state, mark the template dirty, close + the modal + +Changes are **not applied** until "Save" is clicked. Closing the modal or clicking "Cancel" discards +any in-progress edits. + +### Document resolution behavior + +Resolution is **lazy** — the `linkedDocumentInfo` query is called when the modal opens (or when a +new UUID is added), not on template load. Each UUID is resolved independently. + +--- + +## Backend changes + +### New GraphQL query — `linkedDocumentInfo` + +```graphql +type LinkedDocumentInfo { + id: UUID! + description: String! # human-readable label (charge description, invoice number, etc.) + url: String! # deep-link to the document within the app +} + +extend type Query { + linkedDocumentInfo(id: UUID!): LinkedDocumentInfo # returns null if not found +} +``` + +The resolver should look up the UUID across the relevant document types (charges, invoices, etc.) +and return the best available description and a direct app URL. Returns `null` if not found. + +### `DynamicReportNodeData` GraphQL type additions + +```graphql +type LinkedDocument { + type: String! # "uuid" | "url" + value: String! +} + +# additions to DynamicReportNodeData +status: String! +note: String +linkedDocuments: [LinkedDocument!]! +``` + +### Zod schema additions (`dynamic-report.helper.ts`) + +Add to `dynamicReportNodeData`: + +```ts +status: z.enum(['unapproved', 'pending', 'approved']).default('unapproved'), +note: z.string().nullable().default(null), +linkedDocuments: z.array( + z.object({ type: z.enum(['uuid', 'url']), value: z.string() }) +).default([]), +``` + +--- + +## Frontend changes + +### `types.ts` + +Add to `CustomData`: + +```ts +status?: 'unapproved' | 'pending' | 'approved'; +note?: string | null; +linkedDocuments?: { type: 'uuid' | 'url'; value: string }[]; +``` + +### `custom-node.tsx` + +- Render inline status badge (colored by value; default `unapproved` when absent) +- Render note icon when `note` is non-empty +- Render paperclip icon when `linkedDocuments` has items +- Wire a click handler on any indicator (or a dedicated button) to open the metadata modal + +### `node-metadata-modal.tsx` _(new file)_ + +Full modal component implementing all of the above: + +- Status selector +- Note textarea +- Linked documents list with resolution + remove +- Add document input with type auto-detection +- Cancel / Save footer + +--- + +## Files affected + +| File | Change | +| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `packages/server/src/modules/reports/typeDefs/dynamic-report.graphql.ts` | Add `status`, `note`, `linkedDocuments` to `DynamicReportNodeData`; add `LinkedDocument` type; add `linkedDocumentInfo` query | +| `packages/server/src/modules/reports/helpers/dynamic-report.helper.ts` | Extend Zod schema with three new metadata fields | +| `packages/server/src/modules/reports/resolvers/dynamic-report.resolver.ts` | Implement `linkedDocumentInfo` resolver | +| `packages/client/src/components/reports/dynamic-report/types.ts` | Add `status`, `note`, `linkedDocuments` to `CustomData` | +| `packages/client/src/components/reports/dynamic-report/custom-node.tsx` | Add inline status badge + note/paperclip icons; wire open-modal handler | +| `packages/client/src/components/reports/dynamic-report/node-metadata-modal.tsx` | New component — metadata modal (status, note, linked documents) | diff --git a/docs/dynamic-report-refactor/blueprint.md b/docs/dynamic-report-refactor/blueprint.md new file mode 100644 index 0000000000..5761623e68 --- /dev/null +++ b/docs/dynamic-report-refactor/blueprint.md @@ -0,0 +1,1578 @@ +# 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. 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 + +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 (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 +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 `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 +/** + * 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 +} +``` + +### 3. Add predicate helpers (same file, exported) + +```typescript +export function isFinancialEntityNode(node: FlatNode): boolean { + return node.data.nodeType === 'financial-entity' +} + +export function isSortCodeBranchNode(node: FlatNode): boolean { + return node.data.nodeType === 'sort-code-branch' +} + +export function isSyntheticBranchNode(node: FlatNode): boolean { + return node.data.nodeType === 'synthetic-branch' +} + +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 +} +``` + +### 4. Fix immediately visible type errors + +After changing `CustomData` and removing `NodeModel`, TypeScript will produce errors in: + +- `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. + +### 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 } }` + +`FlatNode` is the local flat-node type defined in `./types.js` (replaces +`NodeModel` from the old `@minoru/react-dnd-treeview` dependency). + +## Task + +### 1. Create `bank-tree.ts` + +Create `packages/client/src/components/reports/dynamic-report/bank-tree.ts`. + +Export one pure function: + +```typescript +import type { AllSortCodesQuery, DynamicReportQuery } from '../../../gql/graphql.js'; +import type { CustomData, FlatNode } 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, +): 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 } }` +- 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 { DynamicReportQuery } from '../../../gql/graphql.js'; +import type { CustomData, FlatNode } 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 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: FlatNode[]; 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 { DynamicReportQuery } from '../../../gql/graphql.js'; +import { REPORT_TREE_ROOT_ID } from './report-tree.js'; +import type { CustomData, FlatNode } 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[], +): FlatNode[] +```` + +### 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 +
+ + +
+``` + +`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((nodes: FlatNode[]) => setBankTree(nodes), []) +const handleReportDrop = useCallback((nodes: FlatNode[]) => setReportTree(nodes), []) +``` + +### 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 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'; +}; + +/** + * 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 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 { nextBankTree, nextReportTree } + */ +export function handleCrossTreeDrop( + bankTree: FlatNode[], + reportTree: FlatNode[], + payload: DragPayload, + targetNodeId: string, + targetTreeId: 'bank' | 'report', + instruction: Instruction | null, +): { nextBankTree: FlatNode[]; nextReportTree: FlatNode[] } +``` + +Algorithm: + +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 a single `onDrop` callback attached to the +`monitorForElements` global monitor from Pragmatic DnD (set up once in a `useEffect`): + +```typescript +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]); +``` + +`extractInstruction` is from `@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item`. +`monitorForElements` is from `@atlaskit/pragmatic-drag-and-drop/element/adapter`. + +### 3. Unit tests + +Create `packages/client/src/components/reports/dynamic-report/__tests__/cross-tree-drop.test.ts`. + +Required test cases: + +- 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`. + +```` + +--- + +### 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: FlatNode[]) => FlatNode[]) => { + setBankTree(fn) + setIsDirty(true) + }, + [] +) +const updateReportTree = useCallback( + (fn: (prev: FlatNode[]) => FlatNode[]) => { + 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-panel.tsx` (Pragmatic DnD version): + +- Add `editMode: boolean` to `TreePanelProps` +- Pass it down to each `TreeNodeRow` + +In `index.tsx`: + +- Pass `editMode` to both `TreePanel` 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..d799dfca5a --- /dev/null +++ b/docs/dynamic-report-refactor/spec.md @@ -0,0 +1,278 @@ +# 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). + +--- + +### 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 | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `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 | +| `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