diff --git a/.claude/agent-memory/frontend-developer/MEMORY.md b/.claude/agent-memory/frontend-developer/MEMORY.md index be509d754..82f98da1c 100644 --- a/.claude/agent-memory/frontend-developer/MEMORY.md +++ b/.claude/agent-memory/frontend-developer/MEMORY.md @@ -514,3 +514,32 @@ For agent-owned dirs, Python fallback: write compressed zlib blob directly. - Per-card state: `dataStates` Record with isLoading/error/isEmpty - Card mapping: budget-summary/alerts → budgetOverview; source-utilization → budgetSources; timeline-status/mini-gantt → timeline; invoice-pipeline → invoices; subsidy-pipeline → subsidyPrograms; quick-actions → no data (always shows) - Grid: 3-column desktop, 2-column tablet, 1-column mobile (all via CSS Grid) + +## Invoice & Subsidy Pipeline Cards (Story #476, feat/476-invoice-subsidy-pipeline) + +**InvoicePipelineCard** (`client/src/components/InvoicePipelineCard/`): + +- Receives `invoices: Invoice[]` + `summary: InvoiceStatusBreakdown` props +- Filters to pending invoices, sorts by date (oldest first), shows top 5 +- Overdue detection: parse date with `new Date(year, month-1, day)`, compare < today's midnight +- Displays vendor name, invoice number (or `#${id.slice(0,8)}`), amount (formatCurrency), date (formatDate) +- Overdue badge (`data-testid="overdue-badge"`) with warning color via `rgba(251, 146, 60, 0.15)` background +- Footer: pending total + "View all invoices" link to `/budget/invoices` +- Empty state: `"No pending invoices"` when no pending items + +**SubsidyPipelineCard** (`client/src/components/SubsidyPipelineCard/`): + +- Receives `subsidyPrograms: SubsidyProgram[]` prop +- Groups by lifecycle status: eligible/applied/approved/received/rejected (in that order, only show non-empty groups) +- Per-group: count, fixed-reduction total (sum `reductionValue` where `reductionType === 'fixed'`), deadline warning +- Deadline warning: if ANY program has `applicationDeadline` within 14 days (inclusive) and >= 0 days future +- Status badges: gray (eligible), blue (applied), green (approved+received), red (rejected) +- Footer: "View all subsidies" link to `/budget/subsidies` +- Empty state: `"No subsidy programs found"` + +**Integration into DashboardPage**: + +- Added state: `invoices: Invoice[]`, `invoiceSummary: InvoiceStatusBreakdown | null`, `subsidyPrograms: SubsidyProgram[]` +- Fetching: `Promise.allSettled` includes `fetchAllInvoices({ pageSize: 10 })` and `fetchSubsidyPrograms()` +- isEmpty logic: invoices card = `invoices.filter(inv => status === 'pending').length === 0` +- Conditional render: `invoiceSummary ? ` and `` diff --git a/.claude/agent-memory/qa-integration-tester/MEMORY.md b/.claude/agent-memory/qa-integration-tester/MEMORY.md index 75cb7d2dd..2914007fd 100644 --- a/.claude/agent-memory/qa-integration-tester/MEMORY.md +++ b/.claude/agent-memory/qa-integration-tester/MEMORY.md @@ -3,6 +3,21 @@ > Detailed notes live in topic files. This index links to them. > See: `budget-categories-story-142.md`, `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-358-document-linking.md`, `story-360-document-a11y.md`, `story-epic08-e2e.md`, `story-509-manage-page.md`, `story-471-dashboard.md` +## Story #476 Invoice & Subsidy Pipeline Cards (2026-03-10) + +**Test files**: `InvoicePipelineCard.test.tsx` (12 tests), `SubsidyPipelineCard.test.tsx` (13 tests). + +**Key patterns**: + +- **No `data-testid` on pending total**: `InvoicePipelineCard` renders the footer total in a plain `div` with `className={styles.footerTotal}` — no testid. Test it with `getByText(/pending total/i, { exact: false })` and check `textContent` contains the amount. +- **Early return on empty state**: `InvoicePipelineCard` returns `

` when `pendingInvoices.length === 0` (before the footer renders), so the footer/total is not present in the empty state. +- **Dynamic date tests**: Compute "yesterday", "+N days" using `new Date()` + `setDate()` + `formatDateStr()` helper. Never hardcode dates that become stale. +- **`SubsidyPipelineCard` deadline logic**: inclusive boundary at 14 days (`daysUntilDeadline <= 14`). 14 days = warning, 15 days = no warning. +- **Percentage reduction excluded from group-reduction**: `group-reduction` testid only shows fixed reductions. When only percentage programs exist, `totalFixedReduction = 0` so the `group-reduction` span is not rendered at all. +- **Rejected group always last**: Component appends rejected group after the `lifecycleStatuses` loop — it always renders after eligible/applied/approved/received. +- **`SubsidyProgram.applicableCategories`**: type is `BudgetCategory[]` (not `string[]`). Always set to `[]` in fixtures. +- **`Invoice.budgetLines`**: type is `InvoiceBudgetLineSummary[]`. Always set to `[]` in fixtures. Also requires `remainingAmount: number`. + ## Story #606 Invoice Budget Lines UI Tests (2026-03-08) **Test files**: `invoiceBudgetLinesApi.test.ts` (26), `InvoiceBudgetLinesSection.test.tsx` (36), updated `InvoiceDetailPage.test.tsx` (18). diff --git a/.claude/agent-memory/security-engineer/MEMORY.md b/.claude/agent-memory/security-engineer/MEMORY.md index c3cc0a919..0cf3772bb 100644 --- a/.claude/agent-memory/security-engineer/MEMORY.md +++ b/.claude/agent-memory/security-engineer/MEMORY.md @@ -86,6 +86,7 @@ See `review-history.md` for detailed findings per PR. | #615 | EPIC-15 Story #606 — Invoice Budget Lines Frontend (InvoiceBudgetLinesSection) | COMMENTED (no findings) | 2026-03-08 | | #708 | EPIC-09 Story #470 — User Preferences Infrastructure | COMMENTED (2 informational: value no maxLength, DELETE key param no bounds) | 2026-03-09 | | #709 | EPIC-09 Story #471 — Dashboard Layout & Data Shell | COMMENTED (1 informational: emptyAction.href no protocol allowlist) | 2026-03-09 | +| #713 | EPIC-09 Story #476 — Invoice & Subsidy Pipeline Cards | COMMENTED (no findings) | 2026-03-10 | ## Known Open Recommendations (Low Priority) diff --git a/.claude/agent-memory/ux-designer/story-9-2-dashboard.md b/.claude/agent-memory/ux-designer/story-9-2-dashboard.md index f0d4b9c36..4079a042b 100644 --- a/.claude/agent-memory/ux-designer/story-9-2-dashboard.md +++ b/.claude/agent-memory/ux-designer/story-9-2-dashboard.md @@ -106,3 +106,13 @@ All values covered by existing Layer 2 semantic tokens. - `retryButton` duplicates `btnPrimary`; spec says compose `btnSecondaryCompact` - `customizeButton` uses `--color-bg-tertiary` (code block token); use `--color-bg-secondary` or compose `btnSecondary` - `reEnableButton:focus-visible` uses non-standard inset shadow; must use `var(--shadow-focus)` (outset) + +## PR #713 Review Findings — Invoice & Subsidy Pipeline Cards (APPROVED) + +Model implementation — comprehensive token use, correct focus-visible pattern, consistent with BudgetAlertsCard/TimelineStatusCards patterns. + +Key informational notes: + +- `rgba(251, 146, 60, ...)` (orange-400 value) used for warning tint backgrounds — accepted exception per review criteria, but hue drifts slightly from `--color-warning` in dark mode (shifts to orange-300). A future `--color-warning-bg` token would eliminate this drift. +- `TimelineStatusCards.module.css` line 91 has pre-existing reference to `--color-warning-bg` which does NOT exist in tokens.css — flag as a separate bug (not part of PR #713). +- Low: overdue `

  • ` items lack `aria-label` to announce overdue state in context for SR users. diff --git a/.claude/metrics/review-metrics.jsonl b/.claude/metrics/review-metrics.jsonl index 0113ac2a7..bf19cfc98 100644 --- a/.claude/metrics/review-metrics.jsonl +++ b/.claude/metrics/review-metrics.jsonl @@ -123,3 +123,7 @@ {"pr":711,"story":473,"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} {"pr":711,"story":473,"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} {"pr":712,"issues":[474],"epic":9,"type":"feat","mergedAt":null,"filesChanged":11,"linesChanged":1100,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":2,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"request-changes","findings":{"critical":0,"high":2,"medium":3,"low":3,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":2,"medium":3,"low":5,"informational":2}} +{"pr":714,"story":"#477","agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} +{"pr":714,"story":"#477","agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} +{"pr":714,"story":"#477","agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} +{"pr":714,"story":"#477","agent":"ux-designer","verdict":"request-changes","findings":{"critical":0,"high":0,"medium":2,"low":2,"informational":1}} diff --git a/client/src/components/QuickActionsCard/QuickActionsCard.module.css b/client/src/components/QuickActionsCard/QuickActionsCard.module.css new file mode 100644 index 000000000..8366a2225 --- /dev/null +++ b/client/src/components/QuickActionsCard/QuickActionsCard.module.css @@ -0,0 +1,71 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.primaryAction { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-2); + padding: var(--spacing-3) var(--spacing-4); + background-color: var(--color-primary); + color: var(--color-primary-text); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + text-decoration: none; + transition: var(--transition-button); + cursor: pointer; + min-height: 44px; +} + +.primaryAction:hover { + background-color: var(--color-primary-hover); +} + +.primaryAction:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.linksGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--spacing-2); +} + +.quickLink { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-3); + background-color: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + text-decoration: none; + transition: var(--transition-button-border); + cursor: pointer; + min-height: 44px; +} + +.quickLink:hover { + border-color: var(--color-primary); + background-color: var(--color-bg-hover); +} + +.quickLink:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +@media (prefers-reduced-motion: reduce) { + .primaryAction, + .quickLink { + transition: none; + } +} diff --git a/client/src/components/QuickActionsCard/QuickActionsCard.test.tsx b/client/src/components/QuickActionsCard/QuickActionsCard.test.tsx new file mode 100644 index 000000000..f8798101f --- /dev/null +++ b/client/src/components/QuickActionsCard/QuickActionsCard.test.tsx @@ -0,0 +1,102 @@ +/** + * @jest-environment jsdom + */ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import { renderWithRouter } from '../../test/testUtils.js'; +import type * as CardTypes from './QuickActionsCard.js'; + +// CSS modules mocked via identity-obj-proxy + +// Dynamic import — must happen after any jest.unstable_mockModule calls. +// QuickActionsCard has no context deps so no mocks are needed before the import. +let QuickActionsCard: typeof CardTypes.QuickActionsCard; + +describe('QuickActionsCard', () => { + beforeEach(async () => { + if (!QuickActionsCard) { + const mod = await import('./QuickActionsCard.js'); + QuickActionsCard = mod.QuickActionsCard; + } + }); + + // ── Test 1: Renders New Work Item button ───────────────────────────────── + + it('renders New Work Item primary action link with correct href', () => { + renderWithRouter(); + + const link = screen.getByRole('link', { name: /new work item/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/project/work-items/new'); + }); + + // ── Test 2: Renders Work Items link ────────────────────────────────────── + + it('renders View Work Items link with correct href', () => { + renderWithRouter(); + + const link = screen.getByRole('link', { name: /^work items$/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/project/work-items'); + }); + + // ── Test 3: Renders Timeline link ──────────────────────────────────────── + + it('renders View Timeline link with correct href', () => { + renderWithRouter(); + + const link = screen.getByRole('link', { name: /^timeline$/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/schedule'); + }); + + // ── Test 4: Renders Budget link ────────────────────────────────────────── + + it('renders View Budget link with correct href', () => { + renderWithRouter(); + + const link = screen.getByRole('link', { name: /^budget$/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/budget/overview'); + }); + + // ── Test 5: Renders Invoices link ──────────────────────────────────────── + + it('renders View Invoices link with correct href', () => { + renderWithRouter(); + + const link = screen.getByRole('link', { name: /^invoices$/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/budget/invoices'); + }); + + // ── Test 6: Renders Vendors link ───────────────────────────────────────── + + it('renders View Vendors link with correct href', () => { + renderWithRouter(); + + const link = screen.getByRole('link', { name: /^vendors$/i }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', '/budget/vendors'); + }); + + // ── Test 7: All links are keyboard accessible ───────────────────────────── + + it('renders at least 6 links for keyboard accessibility (1 primary + 5 quick links)', () => { + renderWithRouter(); + + const links = screen.getAllByRole('link'); + expect(links.length).toBeGreaterThanOrEqual(6); + }); + + // ── Test 8: No loading or error states ─────────────────────────────────── + + it('renders immediately without any loading or error indicators', () => { + renderWithRouter(); + + // No loading indicator + expect(screen.queryByText(/loading/i)).toBeNull(); + // No error indicator + expect(screen.queryByText(/error/i)).toBeNull(); + }); +}); diff --git a/client/src/components/QuickActionsCard/QuickActionsCard.tsx b/client/src/components/QuickActionsCard/QuickActionsCard.tsx new file mode 100644 index 000000000..3466e0add --- /dev/null +++ b/client/src/components/QuickActionsCard/QuickActionsCard.tsx @@ -0,0 +1,39 @@ +import { Link } from 'react-router-dom'; +import styles from './QuickActionsCard.module.css'; + +/** + * Quick actions card for the dashboard. + * Provides prominent "New Work Item" button and quick navigation links + * to common sections of the application. + */ +export function QuickActionsCard() { + return ( +
    + {/* Primary action: New Work Item */} + + New Work Item + + + {/* Quick navigation links grid */} +
    + + Work Items + + + Timeline + + + Budget + + + Invoices + + + Vendors + +
    +
    + ); +} + +export default QuickActionsCard; diff --git a/client/src/pages/DashboardPage/DashboardPage.tsx b/client/src/pages/DashboardPage/DashboardPage.tsx index 510ac2b50..2538a61c1 100644 --- a/client/src/pages/DashboardPage/DashboardPage.tsx +++ b/client/src/pages/DashboardPage/DashboardPage.tsx @@ -18,6 +18,7 @@ import { BudgetSummaryCard } from '../../components/BudgetSummaryCard/BudgetSumm import { BudgetAlertsCard } from '../../components/BudgetAlertsCard/BudgetAlertsCard.js'; import { SourceUtilizationCard } from '../../components/SourceUtilizationCard/SourceUtilizationCard.js'; import { TimelineStatusCards } from '../../components/TimelineStatusCards/TimelineStatusCards.js'; +import { QuickActionsCard } from '../../components/QuickActionsCard/QuickActionsCard.js'; import styles from './DashboardPage.module.css'; type DataSourceKey = @@ -367,6 +368,8 @@ export function DashboardPage() { ) : card.id === 'timeline-status' && timelineData ? ( + ) : card.id === 'quick-actions' ? ( + ) : (

    Content coming soon.

    )}