feat(budget-lines): full edit + linked-item move (#1553)#1554
Conversation
…es & WI/HI parity Implement issue #1553: Add full edit form (including itemized amount) and parent-move affordance for linked invoice budget lines. Extend BudgetLineForm with collapsed-current-parent row + expandable picker for cross-table moves (work item ↔ household item). Also extend BudgetSection to support move callbacks for inline-edited budget lines on work item and household item detail pages, enabling parity with invoice-side edits. Changes: - Add editAndMoveBudgetLine() API function in invoiceBudgetLinesApi.ts - Extend BudgetLineFormProps with currentParentType/Id/Label + onMove callback - Add state management for edit-move picker (isPickerExpanded, isMoving, movePickerError) - Render collapsed current-parent row + "Change" ghost button in edit mode - Expand picker on "Change" click with tabs, full parent picker, cross-table move hint banner - Add itemizedAmountField rendering in BudgetLineForm (between budget fields and parent picker) - Update BudgetLineForm.module.css with token-based classes for new UI elements - .currentParentRow, .entityTypePill, .ghostChangeButton, .ghostCancelButton - .moveHint, .itemizedAmountField, .requiredStar - Mobile touch target rules (44px min-height on tablet/mobile) - Extended prefers-reduced-motion block for new animated elements - Replace simple amount-input modal with unified BudgetLineForm for assigned lines - Remove specialized assigned-line branch in EditBudgetLineModal - Pass full form state + itemized amount + move callbacks - Wire handleBudgetLineFullEditSubmit (save without move) and handleMoveBudgetLine (save with move) - Add 8 new i18n keys in budget.json under budgetLineForm namespace: - linkedItemLegend, changeParentButton, cancelChangeParentButton, moveButton, movingButton - moveCrossTableHint, moveCrossTableHintReverse, itemizedAmountLabel - Extend BudgetSectionProps with parentEntityId, parentEntityLabel, onMoveBudgetLine - Pass new props through to inline-edit BudgetLineForm on WI/HI detail pages - Wire move callbacks to support both invoice-side and direct work-item/household-item moves Note: Backend API changes (EditAndMoveBudgetLineRequest export, move field support in UpdateBudgetLineRequest/UpdateWorkItemBudgetRequest/UpdateHouseholdItemBudgetRequest) implemented by backend-developer in parallel. Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
…get lines and WI/HI parity Add `editAndMoveBudgetLine()` to invoiceBudgetLineService supporting four scenarios: - In-place field edits (description, plannedAmount, confidence, category, source, vendor, quantity, unit, unitPrice, includesVat, itemizedAmount) - Same-table parent moves (WI → WI, HI → HI) - Cross-table parent moves (WIB ↔ HIB) with IBL repointing - Category fallback logic (form value → existing → 'bc-household-items' for WI → HI) Extend PATCH /api/invoices/:invoiceId/budget-lines/:id to accept all budget-line fields and move fields (newWorkItemId, newHouseholdItemId). Repoint IBLs on cross-table moves; drop subsidy links silently. Extend PATCH work-item-budgets and household-item-budgets with same-table move support (WI → WI, HI → HI). Cross-table moves are rejected as not applicable in entity-scoped endpoints. Update UpdateBudgetLineRequest shared type to include newWorkItemId and newHouseholdItemId optional fields. All moves use db.transaction() for atomicity. Validates mutual exclusion, target existence, BUDGET_LINE_ALREADY_LINKED guard, and itemized sum bounds. Co-Authored-By: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com>
Adds 8 new German translations for the edit-budget-line parent-move UI introduced in feat/1553: linkedItemLegend, changeParentButton, cancelChangeParentButton, moveButton, movingButton, moveCrossTableHint, moveCrossTableHintReverse, and itemizedAmountLabel. Uses glossary-approved terms (Arbeitspaket, Haushaltsartikel, Budgetposition) and "aufgeschlüsselt" pattern from existing invoiceDetail itemizedAmount keys. "Übertragen" chosen for the cross-table move hints to convey reassignment without implying a financial wire transfer. Co-Authored-By: Claude translator (Sonnet 4.6) <noreply@anthropic.com>
…ines (#1553) 6 scenarios covering the new BudgetLineForm modal on invoice budget lines: - Scenario 1 (@smoke @Responsive): edit description + itemized amount; assert row updates - Scenario 2 (@smoke @Responsive): same-table WI → WI move; assert Linked Item column changes - Scenario 3 (@Responsive): cross-table WI → HI move; assert move-hint banner + column update - Scenario 4: BUDGET_LINE_ALREADY_LINKED guard; assert error in modal, modal stays open - Scenario 5: WI detail page inline edit parity; assert form visible, parent picker absent (onMoveBudgetLine not wired in WorkItemDetailPage — documented as known limitation) - Scenario 6: mobile 375px viewport; assert 44px touch target on Change button Extend InvoiceDetailPage POM with Issue #1553 locators: budgetLineFormDescription, budgetLineItemizedAmount, linkedItemLegend, changeParentButton, moveButton, cancelChangeButton, moveHintBanner Fixes #1553 Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
…I parity (#1553) Adds 6 new test files covering all scenarios from the QA spec: - invoiceBudgetLineService.editAndMove.test.ts: 16 scenarios (in-place edit, same-table move WI→WI and HI→HI, cross-table WI→HI and HI→WI, category fallback, all 4 guards, transaction atomicity) - invoiceBudgetLines.editAndMove.test.ts: 7 route integration scenarios via app.inject() including auth, 404, 409, and schema validation (itemizedAmount:0) - workItemBudgetService.move.test.ts: same-table WI→WI happy path + field updates, error guards (NotFound, BudgetLineAlreadyLinked), cross-table rejection (ValidationError when newHouseholdItemId provided) - householdItemBudgetService.move.test.ts: mirror of WI move tests for HI→HI and cross-table rejection (ValidationError when newWorkItemId provided) - BudgetLineForm.move.test.tsx: 13 component scenarios (collapsed row, expand, tab switch, moveHint, button disable/enable, Cancel, onMove callback, error display, isUnassigned regression, onMove absent, itemizedAmount field) - invoiceBudgetLinesApi.editAndMove.test.ts: 11 API client tests for editAndMoveBudgetLine() — 100% coverage on invoiceBudgetLinesApi.ts Client tests pass locally. Server tests fail locally with pre-existing TS1343 worktree issue (import.meta); CI validates server tests correctly. Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
…ages (AC-14, AC-15, AC-16) - Add handleMoveBudgetLine to WorkItemDetailPage to support same-table and cross-table moves via editAndMoveBudgetLine for invoice-linked lines - Add handleMoveBudgetLine to HouseholdItemDetailPage with same logic - Pass parentEntityId, parentEntityLabel, and onMoveBudgetLine props to BudgetSection in both pages - Add i18n key for cross-table move without invoice error - Import editAndMoveBudgetLine from invoiceBudgetLinesApi in both pages - Add budget namespace i18n access for error message translation This enables the parent-picker inline edit UI (already added in previous PR) to function end-to-end on both detail pages. Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
HouseholdItemDetail uses 'name' property, not 'title'. This fixes the type error in the HouseholdItemDetailPage BudgetSection parentEntityLabel prop. Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
Adds German translation for the new constraint error shown when a user attempts a cross-table budget line move without an invoice link. Fixes #1553 Co-Authored-By: Claude translator (Sonnet 4.6) <noreply@anthropic.com>
…ed color fallbacks - Finding 4: Remove hardcoded rgba() fallbacks from BudgetLineForm.module.css (lines 275, 287, 288). Tokens --color-primary-bg, --color-danger-bg, and --color-danger-border already exist in tokens.css and are properly defined for both light and dark modes. - Finding 5: Preserve caller's translated error message in handleMove catch block. When onMove throws an Error with a pre-translated message (e.g., from handleMoveBudgetLine in WI/HI detail pages), pass that message through instead of discarding it and using the generic fallback. Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
Updated API-Contract.md to document PATCH /api/invoices/:invoiceId/budget-lines/:id with full schema, including: - EditAndMoveBudgetLineRequest fields (itemizedAmount, description, plannedAmount, confidence, category/source/vendor, quantity/unit/unitPrice, includesVat, newWorkItemId, newHouseholdItemId) - Error codes for NOT_FOUND and BUDGET_LINE_ALREADY_LINKED - Notes on transaction atomicity and response shape - Clarified cross-table moves in work item and household item budget endpoints Wiki commit: ce22e50 Co-Authored-By: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
|
[security-engineer] Security review of PR #1554 — full edit + linked-item move for invoice budget lines. Verdict: APPROVE (no blocking findings)All new code paths are properly authenticated, use Drizzle parameterized queries throughout, and the transactional atomicity is correctly implemented. No Critical or High findings. Three Low/Informational observations noted below. Checklist
Tenant/Scope Safety — PASSThe application is single-tenant (no multi-tenancy, one user base per instance). Scope isolation is enforced by:
All target entity existence checks are performed inside the transaction before the write operations. There is no cross-scope pivot path. Authorization — PASSAll three modified PATCH handlers check Mutual Exclusion of Move Fields — PASSMutual exclusion of
Transactional Atomicity — PASSAll cross-table and same-table moves are wrapped in SQL Injection — PASSAll raw All values are passed as Drizzle column references or bound parameters — no string interpolation. Findings[LOW] workItemBudgets / householdItemBudgets PATCH schemas:
|
steilerDev
left a comment
There was a problem hiding this comment.
[product-architect]
Architecture review: Approve with medium findings for refinement. (Cannot self-approve via GitHub; treat this comment as an approval verdict.)
What I verified
- Transaction patterns: Service layer matches the spec. Same-table = single
UPDATE; cross-table = atomic insert+repoint+delete insidedb.transaction(). Mirrors the canonicalassignToHouseholdItempattern frombudgetLineAssignService.ts. (server/src/services/invoiceBudgetLineService.ts:667-973) - Mutual exclusion of move fields: Enforced at service layer (line 608) ahead of the transaction. Good — keeps the route schema simple and validation centralized.
- WI/HI cross-table rejection: Correctly raises
ValidationErrorwith a clear message (workItemBudgetService.ts:149-153,householdItemBudgetService.ts:153-157). The architect call to make IBL the canonical cross-table owner is sound — keeps the cross-table category mapping rule in one place. - Category fallback rule (WI→HI): Implemented per spec — form value → existing WIB value →
bc-household-itemsfallback (invoiceBudgetLineService.ts:825-828). HI→WI uses form value → existing HIB value (line 915-916), no hardcoded fallback, which is correct since WIB allows nullbudget_category_id. - Subsidy linkage dropped on cross-table: Confirmed — neither
work_item_budget_subsidiesnorhousehold_item_budget_subsidiesis touched in the transaction. Matches the locked architect decision and theassignToHouseholdItemprecedent. - Shared types:
EditAndMoveBudgetLineRequestis well-shaped and matches the route JSON schema (invoiceBudgetLines.ts:43-78).UpdateBudgetLineRequestcorrectly extended with the two move fields. - API Contract wiki:
wiki/API-Contract.mdupdated with the full PATCH body shape, new error code (BUDGET_LINE_ALREADY_LINKED), and atomic-transaction note. Submodule pointer correctly bumped in commit0d51fe60.
Medium findings (refinement, non-blocking)
-
WI/HI PATCH wiki sections undocument the new fields.
wiki/API-Contract.mdlines 2406-2413 (WI) and 6846-6853 (HI) do not listnewWorkItemId/newHouseholdItemIdin the field tables, norquantity/unit/unitPrice/includesVat(which the route schema accepts atserver/src/routes/workItemBudgets.ts:75-82and the equivalent HI route). The "Notes" section correctly says cross-table is rejected, but the same-table move affordance is not documented. Worth a follow-up wiki commit during epic refinement. -
WI/HI same-table guard is stricter than the IBL service.
server/src/services/workItemBudgetService.ts:204-212and the equivalent HI guard (server/src/services/householdItemBudgetService.ts:210-220) check whether the target entity has any IBL linked (across all invoices). The IBL service's same-table guard atserver/src/services/invoiceBudgetLineService.ts:718-731scopes to the same invoice (invoiceId = X AND ... AND id != lineId). Per spec AC-10 ("a different IBL row on the same invoice"), the IBL service matches the spec; the WI/HI guard is broader. Two budget lines on the same WI but different invoices is a valid state today (e.g., one WIB linked to invoice A, another linked to invoice B). Recommendation: scope the WI/HI guard to the same invoice for consistency. If the intent is to deliberately keep WI/HI safer, document it in a comment. -
Dead-ish code:
updateInvoiceBudgetLine.server/src/services/invoiceBudgetLineService.ts:361-446is no longer called from the route (replaced byeditAndMoveBudgetLineat the PATCH handler). It's still exported and covered by tests, so coverage stays clean — but it's effectively dead production code. Either remove it (and prune its tests) or document why it's kept. Low priority, refinement item.
What I did not block on
- The transaction in
editAndMoveBudgetLineis correctly structured (validation outside, mutations inside) and theresultreturn value pattern works for fetching post-transaction state. - Test coverage on the new code paths looks solid: 4 new service/route test files totaling ~1100 LOC, plus FE form tests and 802-LOC E2E spec.
- Cross-table category fallback to
bc-household-itemsis the right call since HIBs always carry that category in the orphan-assignment path.
Verdict: approve — these findings can land in epic refinement.
steilerDev
left a comment
There was a problem hiding this comment.
[ux-designer]
PR #1554 — Full Edit + Parent Move for Budget Lines — Design Review
Token Adherence — PASS
All new CSS in BudgetLineForm.module.css uses design tokens exclusively. The latest fix commit (d4f12d4c) correctly removed the rgba() fallbacks on .parentPickerTabActive and .parentPickerError, leaving clean var(--token-name) references throughout. Verified token existence in tokens.css for every property used by the new rules:
| Class | Token(s) | Status |
|---|---|---|
.currentParentRow |
--color-bg-primary, --color-border, --radius-md, --spacing-2/3, --font-size-sm, --color-text-primary |
✓ |
.entityTypePill_work_item |
--color-status-in-progress-bg/text |
✓ |
.entityTypePill_household_item |
--color-hi-status-scheduled-bg/text |
✓ |
.ghostChangeButton |
--color-primary, --color-primary-hover, --shadow-focus, --font-size-sm |
✓ |
.ghostCancelButton |
--color-border-strong, --color-text-secondary, --color-bg-tertiary, --radius-md, --shadow-focus, --transition-normal |
✓ |
.moveHint |
--color-warning-bg, --color-warning, --color-warning-text-on-light, --radius-md, --spacing-2/3 |
✓ |
.itemizedAmountField |
--color-border, --spacing-4/2 |
✓ |
.requiredStar |
--color-danger, --spacing-1 |
✓ |
Dark mode is handled automatically — all tokens listed above have correct [data-theme="dark"] overrides in tokens.css.
Visual Consistency — PASS
- currentParentRow:
background: var(--color-bg-primary)withborder: 1px solid var(--color-border)matches the section-card pattern used throughout the app (1px border,--color-bg-primaryinterior). - entityTypePill: uses the correct WI/HI palette tokens and
--radius-fullfor a pill shape as specced. - ghostCancelButton: mirrors
cancelButtonstructure (same padding,--color-bg-tertiaryhover,--shadow-focusfocus-visible) — visually coherent. - moveHint:
--color-warning-bg/--color-warning/--color-warning-text-on-light— matches the warning banner pattern established in #1545. - itemizedAmountField: top-border visual separator is clean and consistent with existing form section separators.
- Shared component reuse:
Modal,FormError,WorkItemPicker,HouseholdItemPickerall used correctly. No one-off parallel implementations introduced.
Responsive Behavior — PASS
- Touch targets:
ghostChangeButtongetsmin-height: 44pxat ≤ 1024px — spec-compliant. parentPickerTabgetsmin-height: 44px; flex: 1at ≤ 1024px — spec-compliant.- Mobile (≤ 639px):
.parentPickerTabsalreadydisplay: flexby default; the breakpoint rule is harmless redundancy but causes no visual regression. prefers-reduced-motion: all new transition-bearing classes (ghostChangeButton,ghostCancelButton,moveHint) correctly added to the existingprefers-reduced-motion: reduceblock.
Findings
Non-blocking (fix before merge or in refinement)
1. aria-controls points to an element that does not exist in the DOM when the button is visible
BudgetLineForm.tsx lines 515–516:
aria-expanded={false}
aria-controls="parent-picker-body"div#parent-picker-body (line 524) is only mounted when isPickerExpanded === true. The "Change" button is only mounted when isPickerExpanded === false. The two never coexist in the DOM, so the aria-controls reference is always dangling — screen readers cannot follow the relationship. The aria-expanded value is also hardcoded to false rather than computed.
Correct pattern: keep both the button and the controlled region in the DOM simultaneously, toggling visibility with CSS (display: none / conditional class), and updating aria-expanded dynamically:
// Keep button always mounted, let expanded state drive aria-expanded
<button
aria-expanded={isPickerExpanded}
aria-controls="parent-picker-body"
onClick={() => setIsPickerExpanded(v => !v)}
>
{isPickerExpanded ? t('budgetLineForm.cancelChangeParentButton') : t('budgetLineForm.changeParentButton')}
</button>
// Always mounted; CSS hides when collapsed
<div id="parent-picker-body" hidden={!isPickerExpanded}>
{/* expanded content */}
</div>This also eliminates the need for the separate ghostCancelButton in the expanded view.
Severity: Medium — aria-controls with a missing referent is a WCAG 4.1.2 (Name, Role, Value) violation but does not create a keyboard trap and the picker itself is keyboard-operable; the assistive technology impact is that users cannot programmatically navigate from the button to the controlled region.
2. Missing :focus-visible on .parentPickerTab and .modeBtn
BudgetLineForm.module.css lines 255–274 (.parentPickerTab) and lines 130–159 (.modeBtn) have no :focus-visible rule. These are keyboard-focusable buttons with no focus indicator in the component's own stylesheet. The browser default :focus ring may provide something, but the design system pattern (all other buttons in this file) is outline: none; box-shadow: var(--shadow-focus).
Note: .parentPickerTab is pre-existing, but the PR introduces new tab button rendering in the expanded move picker — it is a good moment to fix while the file is open. .modeBtn is also pre-existing.
Correct fix for both:
.parentPickerTab:focus-visible,
.modeBtn:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}Severity: Medium — WCAG 2.4.7 (Focus Visible), affects keyboard-only users. The browser default ring may partially compensate depending on the OS/browser combination, so this is not a complete focus-invisible failure — but it is inconsistent with every other interactive element in the file.
Informational
- The "Change" ghost button uses
text-decoration: underlineto signal interactivity. This is distinct from other ghost/link buttons in the codebase; if a future pass standardizes ghost-button affordances, this may warrant revisiting. Not a blocking issue. - The unassigned-path
BudgetLineForminvocation insideEditBudgetLineModal(lines ~1647–1680) still passes a mostly-emptyformstate (hardcodedpricingMode: 'direct', empty pricing fields) — this is pre-existing behavior for the unassigned path and outside this story's scope.
Summary
All new CSS is fully tokenized (the d4f12d4c fix resolved the only token compliance gap). Dark mode, responsive behavior, and shared component reuse all pass. Two medium accessibility findings require attention before or shortly after merge:
aria-controlsreferent not in DOM when button is visible — structural ARIA fix needed inBudgetLineForm.tsx.- Missing
:focus-visibleon.parentPickerTaband.modeBtn— two CSS rule additions inBudgetLineForm.module.css.
Neither finding blocks functional correctness or creates a keyboard trap. Approving with the expectation these are fixed in a follow-up commit on this branch or a targeted refinement.
steilerDev
left a comment
There was a problem hiding this comment.
[ux-designer]
PR #1554 — Full Edit + Parent Move for Budget Lines — Design Review
Token Adherence — PASS
All new CSS in BudgetLineForm.module.css uses design tokens exclusively. The latest fix commit (d4f12d4c) correctly removed the rgba() fallbacks on .parentPickerTabActive and .parentPickerError, leaving clean var(--token-name) references throughout. Verified token existence in tokens.css for every property used by the new rules:
| Class | Token(s) | Status |
|---|---|---|
.currentParentRow |
--color-bg-primary, --color-border, --radius-md, --spacing-2/3, --font-size-sm, --color-text-primary |
✓ |
.entityTypePill_work_item |
--color-status-in-progress-bg/text |
✓ |
.entityTypePill_household_item |
--color-hi-status-scheduled-bg/text |
✓ |
.ghostChangeButton |
--color-primary, --color-primary-hover, --shadow-focus, --font-size-sm |
✓ |
.ghostCancelButton |
--color-border-strong, --color-text-secondary, --color-bg-tertiary, --radius-md, --shadow-focus, --transition-normal |
✓ |
.moveHint |
--color-warning-bg, --color-warning, --color-warning-text-on-light, --radius-md, --spacing-2/3 |
✓ |
.itemizedAmountField |
--color-border, --spacing-4/2 |
✓ |
.requiredStar |
--color-danger, --spacing-1 |
✓ |
Dark mode is handled automatically — all tokens listed above have correct [data-theme="dark"] overrides in tokens.css.
Visual Consistency — PASS
- currentParentRow:
background: var(--color-bg-primary)withborder: 1px solid var(--color-border)matches the section-card pattern used throughout the app (1px border,--color-bg-primaryinterior). - entityTypePill: uses the correct WI/HI palette tokens and
--radius-fullfor a pill shape as specced. - ghostCancelButton: mirrors
cancelButtonstructure (same padding,--color-bg-tertiaryhover,--shadow-focusfocus-visible) — visually coherent. - moveHint:
--color-warning-bg/--color-warning/--color-warning-text-on-light— matches the warning banner pattern established in #1545. - itemizedAmountField: top-border visual separator is clean and consistent with existing form section separators.
- Shared component reuse:
Modal,FormError,WorkItemPicker,HouseholdItemPickerall used correctly. No one-off parallel implementations introduced.
Responsive Behavior — PASS
- Touch targets:
ghostChangeButtongetsmin-height: 44pxat ≤ 1024px — spec-compliant. parentPickerTabgetsmin-height: 44px; flex: 1at ≤ 1024px — spec-compliant.- Mobile (≤ 639px):
.parentPickerTabsalreadydisplay: flexby default; the breakpoint rule is harmless redundancy but causes no visual regression. prefers-reduced-motion: all new transition-bearing classes (ghostChangeButton,ghostCancelButton,moveHint) correctly added to the existingprefers-reduced-motion: reduceblock.
Findings
Non-blocking (fix before merge or in refinement)
1. aria-controls points to an element that does not exist in the DOM when the button is visible
BudgetLineForm.tsx lines 515–516:
aria-expanded={false}
aria-controls="parent-picker-body"div#parent-picker-body (line 524) is only mounted when isPickerExpanded === true. The "Change" button is only mounted when isPickerExpanded === false. The two never coexist in the DOM, so the aria-controls reference is always dangling — screen readers cannot follow the relationship. The aria-expanded value is also hardcoded to false rather than computed.
Correct pattern: keep both the button and the controlled region in the DOM simultaneously, toggling visibility with CSS (display: none / conditional class), and updating aria-expanded dynamically:
// Keep button always mounted, let expanded state drive aria-expanded
<button
aria-expanded={isPickerExpanded}
aria-controls="parent-picker-body"
onClick={() => setIsPickerExpanded(v => !v)}
>
{isPickerExpanded ? t('budgetLineForm.cancelChangeParentButton') : t('budgetLineForm.changeParentButton')}
</button>
// Always mounted; CSS hides when collapsed
<div id="parent-picker-body" hidden={!isPickerExpanded}>
{/* expanded content */}
</div>This also eliminates the need for the separate ghostCancelButton in the expanded view.
Severity: Medium — aria-controls with a missing referent is a WCAG 4.1.2 (Name, Role, Value) violation but does not create a keyboard trap and the picker itself is keyboard-operable; the assistive technology impact is that users cannot programmatically navigate from the button to the controlled region.
2. Missing :focus-visible on .parentPickerTab and .modeBtn
BudgetLineForm.module.css lines 255–274 (.parentPickerTab) and lines 130–159 (.modeBtn) have no :focus-visible rule. These are keyboard-focusable buttons with no focus indicator in the component's own stylesheet. The browser default :focus ring may provide something, but the design system pattern (all other buttons in this file) is outline: none; box-shadow: var(--shadow-focus).
Note: .parentPickerTab is pre-existing, but the PR introduces new tab button rendering in the expanded move picker — it is a good moment to fix while the file is open. .modeBtn is also pre-existing.
Correct fix for both:
.parentPickerTab:focus-visible,
.modeBtn:focus-visible {
outline: none;
box-shadow: var(--shadow-focus);
}Severity: Medium — WCAG 2.4.7 (Focus Visible), affects keyboard-only users. The browser default ring may partially compensate depending on the OS/browser combination, so this is not a complete focus-invisible failure — but it is inconsistent with every other interactive element in the file.
Informational
- The "Change" ghost button uses
text-decoration: underlineto signal interactivity. This is distinct from other ghost/link buttons in the codebase; if a future pass standardizes ghost-button affordances, this may warrant revisiting. Not a blocking issue. - The unassigned-path
BudgetLineForminvocation insideEditBudgetLineModal(lines ~1647–1680) still passes a mostly-emptyformstate (hardcodedpricingMode: 'direct', empty pricing fields) — this is pre-existing behavior for the unassigned path and outside this story's scope.
Summary
All new CSS is fully tokenized (the d4f12d4c fix resolved the only token compliance gap). Dark mode, responsive behavior, and shared component reuse all pass. Two medium accessibility findings require attention before or shortly after merge:
aria-controlsreferent not in DOM when button is visible — structural ARIA fix needed inBudgetLineForm.tsx.- Missing
:focus-visibleon.parentPickerTaband.modeBtn— two CSS rule additions inBudgetLineForm.module.css.
Neither finding blocks functional correctness or creates a keyboard trap. Approving with the expectation these are fixed in a follow-up commit on this branch or a targeted refinement.
…inProperties Records two findings from the story #1553 invoice budget line edit+move review: - Low: move fields (newWorkItemId/newHouseholdItemId) accept empty string (no minLength:1) - Informational: WIB and HIB PATCH schemas missing minProperties:1 Co-Authored-By: Claude security-engineer (Sonnet 4.6) <noreply@anthropic.com>
steilerDev
left a comment
There was a problem hiding this comment.
[product-owner] Review of PR #1554 against issue #1553 acceptance criteria.
Verdict: REQUEST CHANGES
Solid work overall — backend service is well-structured, transactional, and all four scenarios (in-place, same-table, cross-table either direction) are covered. i18n keys translated in lock-step, test authorship correct (qa-integration-tester / e2e-test-engineer), :focus-visible indicators present. However, there is a critical silent-data-loss bug in the invoice-side edit flow that blocks approval.
Blocking — must fix
AC-2 — Critical data loss on save (FAIL)
The accepted deviation in the PR scope was that budgetSourceId and vendorId would not be pre-populated. But the implementation also fails to pre-populate pricingMode, quantity, unit, unitPrice, and includesVat, and crucially the save handler unconditionally sends those (empty) form values back to the server. The result is silent data loss for any non-trivial budget line.
Reproduction (no user changes required):
- Create a budget line with
pricingMode=unit, quantity=10, unit='m³', unitPrice=50, includesVat=false, link to invoice. - From the invoice detail page, click Actions → Edit on that line.
- Modal opens. Form shows
pricingMode=direct, empty qty/unit/unitPrice,includesVat=true(checked), no vendor/source selected. - Click Save without changing anything.
- Underlying WIB row is now
quantity=null, unit=null, unitPrice=null, includesVat=true, budgetSourceId=null, vendorId=null.
Root causes (two layers):
client/src/pages/InvoiceDetailPage/InvoiceBudgetLinesSection.tsxlines 309–322 (openEditBudgetLineModal) hard-codespricingMode: 'direct', emptyquantity/unit/unitPrice,includesVat: true, emptybudgetSourceId/vendorIdregardless of the line's existing values. AC-2 explicitly requires the "same rule used byuseBudgetSection.toFormState" for pricing-mode detection.handleBudgetLineFullEditSubmit(lines 715–780) andhandleMoveBudgetLine(lines 785–835) unconditionally includequantity,unit,unitPrice,includesVat,budgetSourceId,vendorIdin the PATCH payload — so the wiped form state is written back to the database.- Underlying structural gap:
InvoiceBudgetLineDetailResponse(shared/src/types/invoiceBudgetLine.ts:95) does not surfacequantity,unit,unitPrice,includesVat,budgetSourceId, orvendorIdfrom the WIB/HIB row. The detail resolver inserver/src/services/invoiceBudgetLineService.ts:73–179would need to include these fields before the client can pre-populate them. Once surfaced, applyinguseBudgetSection.toFormState(or equivalent) inopenEditBudgetLineModalresolves the form-init half, and the submit handler can either send only changed fields or send the full pre-populated state.
Test coverage gap: existing unit/integration/E2E tests all use fixture lines with quantity: null, unit: null, unitPrice: null, includesVat: true, budgetSourceId: null, vendorId: null — i.e., they cannot catch this regression. A regression test that creates a unit-priced line with a vendor + non-default source and verifies all fields are preserved after a no-op edit save is required.
This is not a formatting/polish issue — it's a data-integrity defect that affects real-world budget lines (any line created in qty × unit-price mode, any line with a vendor, any line not on the default discretionary source). Recommend backend extends the detail response, then client pre-populates the full form per AC-2, and submit handler either sends only changed fields or relies on a fully pre-populated form.
AC verdict summary
Invoice-side full edit (AC-1 to AC-4)
- AC-1 PASS —
EditBudgetLineModalfor assigned lines renders the fullBudgetLineFormwith itemized amount + linked-item parent picker. - AC-2 FAIL — description / plannedAmount / confidence / budgetCategoryId / itemizedAmount / current-parent pre-populated correctly.
budgetSourceIdandvendorIdnot pre-populated (accepted deviation).pricingMode,quantity,unit,unitPrice,includesVatalso not pre-populated — see "Blocking" above. - AC-3 PASS — toggling work-item / household-item tabs clears the picker selection and re-renders the appropriate picker.
- AC-4 PASS — single Save submission with client-side validation (negative qty/price disabled, blank planned amount disables submit, itemized amount ≤ 0 short-circuits before server call).
Move semantics (AC-5 to AC-9)
- AC-5 PASS — same-table move uses single
UPDATEwith new parent FK; IBL FK unchanged. - AC-6 PASS — cross-table move is wrapped in
db.transaction(() => { insert; repoint; delete; }); atomic. - AC-7 PASS — new row copies all editable fields. Category mapping follows architect decision (form value → existing →
bc-household-itemsfallback). Invoice link points to correct detail page after move. - AC-8 CONDITIONAL — non-parent field updates do a single
UPDATEper the in-place branch, anditemizedAmountis only updated if provided. But because of the AC-2 bug, the "non-parent field update" path is silently sending wiped pricing/vendor/source data along with the legitimate field changes — same fix unblocks this AC. - AC-9 PASS — all save operations wrapped in a single
db.transaction().remainingAmountrecalculated after save.
Server-side guards (AC-10 to AC-13)
- AC-10 PASS —
BUDGET_LINE_ALREADY_LINKEDthrown when target parent already has a different IBL for the same invoice (same-table and cross-table both checked). - AC-11 PASS —
ITEMIZED_SUM_EXCEEDS_INVOICEthrown when the new itemized total exceeds invoice amount. - AC-12 PASS —
NotFoundErrorthrown for missing target work item or household item. - AC-13 PASS — server
ValidationErroronitemizedAmount <= 0; client form hasrequired+min=0+ disables Save when itemized amount is empty.
WI/HI inline-edit parity (AC-14 to AC-16)
- AC-14 PASS —
WorkItemDetailPage.handleMoveBudgetLineis wired throughBudgetSection.onMoveBudgetLine; same-table moves go viaupdateWorkItemBudgetwithnewWorkItemId; cross-table with invoice goes viaeditAndMoveBudgetLine. - AC-15 PASS — same wiring on
HouseholdItemDetailPage. - AC-16 ACCEPTED MITIGATION — cross-table moves on a line with no invoice link throw the translated
budgetLineForm.moveCrossTableNoInvoiceError. This is the documented architect decision in the PR description and is the user-informed-error outcome the AC calls for. Acceptable.
Validation, errors, a11y, i18n (AC-17 to AC-20)
- AC-17 PASS —
ApiClientErrormapped viatranslateApiError; generic fallback uses translatededitError.saveFailed.FormErrorbanner used in form. - AC-18 PASS — 8 new keys +
moveCrossTableNoInvoiceErrorin bothenandde; no hardcoded German. - AC-19 PASS —
:focus-visibleshadows on all new interactive elements (ghostChangeButton,ghostCancelButton, picker tabs),aria-expanded/aria-controlson Change button, Modal handles Escape. - AC-20 PASS — currency labels use
{{currencySymbol}}interpolation, computed total usestoFixed(2)(matches existing form). No rawIntl.NumberFormatortoLocaleDateStringin new code.
Required fixes before re-review
- Extend
InvoiceBudgetLineDetailResponseandresolveDetailto surfacequantity,unit,unitPrice,includesVat,budgetSourceId,vendorId(andbudgetSource/vendorsummaries if helpful for the form's select pre-population). - Update
openEditBudgetLineModalto computepricingModeperuseBudgetSection.toFormStaterules and to pre-populatequantity/unit/unitPrice/includesVat/budgetSourceId/vendorIdfrom the line. - Either keep the submit-everything pattern (now safe because form is fully pre-populated) or switch to a dirty-field diff and only PATCH changed fields. Either is acceptable.
- Add a qa-integration-tester regression test: create a budget line with
pricingMode=unit, quantity=10, unit='m³', unitPrice=50, includesVat=false, budgetSourceId=<non-default>, vendorId=<set>; open invoice edit modal; click Save without changes; assert the underlying WIB row's fields are byte-for-byte unchanged. Mirror in an E2E scenario.
After those land, this is approvable.
…ovements Critical fix for the PO-flagged data-loss bug on PR #1554: - Extend InvoiceBudgetLineDetailResponse with quantity, unit, unitPrice, includesVat, vendorId, budgetSourceId (now returned by resolveDetail) - Pre-populate the edit form with all fields (pricingMode derived from quantity/unitPrice presence), preventing silent overwrite on Save - Update test fixtures across 7 client test files for the new response fields UX a11y fixes: - Render both collapsed/expanded parent-picker regions; toggle via the hidden attribute. aria-expanded now dynamically reflects state and aria-controls always resolves to a present element. - Add :focus-visible rules for .parentPickerTab and .modeBtn using var(--shadow-focus). Co-Authored-By: Claude backend-developer (Haiku 4.5) <noreply@anthropic.com> Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
d743c2b to
4e0a6ce
Compare
|
[product-owner] Re-review after commit Verdict: APPROVE (functional ACs)The critical data-loss bug flagged in my previous review at AC-2 / AC-8 is fully addressed. VerificationAC-2 (form pre-populated with all underlying fields) — PASS
AC-8 (no row insertion/deletion on in-place edit; non-parent fields preserved) — PASS Round-trip analysis: form loads with underlying values → user saves without changes → submit payload at CI statusQuality Gates currently running on the latest commit. Approval is contingent on green CI; recommend the orchestrator wait for Non-blocking observations (already tracked by security-engineer)
Neither blocks Story #1553 acceptance. Story acceptance: APPROVED pending CI. |
…fter PATCH schema expansion The PATCH schema now uses additionalProperties: false — Fastify silently strips unknown fields (including legacy workItemBudgetId) instead of returning 400. Move semantics are covered by invoiceBudgetLines.editAndMove.test.ts. Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
…it modal - Remove wrapping <form> element from assigned budget line edit modal (Bug 1) - BudgetLineForm has its own form element; nesting was invalid HTML - Pass onSubmit directly to BudgetLineForm instead of form wrapper - Remove duplicate modal footer with Save button (BudgetLineForm has own) - Add missing editAndMoveBudgetLine mock export in test files (Bug 2) - InvoiceBudgetLinesSection.test.tsx: add mockEditAndMoveBudgetLine declaration and export - InvoiceLinkModal.test.tsx: add editAndMoveBudgetLine to mock - InvoiceBudgetLinesSection.area.test.tsx: add editAndMoveBudgetLine to mock - Verify no unused state remains (Bug 3) - All props in EditBudgetLineModalProps are used in assigned/unassigned branches Fixes #1553 - E2E strict mode violations from nested forms and duplicate buttons Co-Authored-By: Claude frontend-developer (Haiku 4.5) <noreply@anthropic.com>
Scenario 1, 3, 5, and 6 in invoice-budget-line-edit-remove.spec.ts referenced the old #budget-line-amount input from the pre-unification modal. After the unified BudgetLineForm modal replaced it, the correct field is #budget-itemized-amount and the Save button text changed from "Save" to "Save Changes" (budgetLineForm.submitSave i18n key). Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) <noreply@anthropic.com>
…th parent picker - BudgetLineForm.move.test.tsx: fix 3 failures caused by the render-both parent picker pattern (collapsed + expanded regions always in DOM). Use getAllByText/span-scoped check for 'Work Item' pill, assert parent-picker-body has the 'hidden' attribute instead of checking picker testid absence, and update error-text assertion to match the production code path that uses err.message over the translation fallback. - InvoiceBudgetLinesSection.test.tsx: update the #1425 legacy edit-modal tests to match the unified EditBudgetLineModal (Story #1553). Extended the BudgetLineForm mock to render an itemizedAmount labeled input when the prop is provided, added role='alert' to the error div, reset mockEditAndMoveBudgetLine in beforeEach, and updated the three submit/error tests to assert against editAndMoveBudgetLine (the API called by the full-form edit path) instead of the removed updateInvoiceBudgetLine-only path. Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
Jest matches jest.mock by exact module specifier, not resolved path. The WorkItemPicker / HouseholdItemPicker mocks were keyed on a relative path that did not match the production import in BudgetLineForm.tsx, so the real picker components were rendering instead of the mocks — making data-testid lookups fail in CI. Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
…terception jest.mock (CJS) does not hoist or intercept ESM imports under --experimental-vm-modules. Replace jest.mock calls for WorkItemPicker and HouseholdItemPicker with jest.unstable_mockModule so the mocks actually intercept in CI. Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) <noreply@anthropic.com>
|
🎉 This PR is included in version 2.7.0-beta.4 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
Summary
Closes #1553 — Full edit (all fields + linked item move) for budget lines on invoice/quotation detail page.
BudgetLineFormwith all properties editable (description, category, confidence, funding source, vendor, pricing details, itemized amount, linked item), not just the itemized amount.onMoveBudgetLinewired through both detail pages). Same-table moves go through the entity-scoped PATCH; cross-table moves are only allowed via the invoice path (clear translated error otherwise).editAndMoveBudgetLine()service oninvoiceBudgetLineService; extendedPATCH /api/invoices/:invoiceId/budget-lines/:idschema/handler; WI/HI PATCH endpoints extended with same-tablenewWorkItemId/newHouseholdItemIdsupport.BUDGET_LINE_ALREADY_LINKED(409),ITEMIZED_SUM_EXCEEDS_INVOICE(400),VALIDATION_ERROR,NOT_FOUND— all wired and translated.budgetLineFormtranslated to German (Arbeitspaket,Haushaltsartikel,Budgetposition,Verknüpftes Element).API-Contract.mdupdated to document the new PATCH body shape and error codes.Locked architect decisions
budgetCategoryId→ existing WIB category →bc-household-itemsfallback.assignToHouseholdItemprecedent).moveCrossTableNoInvoiceError.Fixes #1553
Test plan
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com