Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .claude/agent-memory/frontend-developer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,3 +514,32 @@ For agent-owned dirs, Python fallback: write compressed zlib blob directly.
- Per-card state: `dataStates` Record<DataSourceKey, DataSourceState> 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 ? <InvoicePipelineCard ... />` and `<SubsidyPipelineCard ... />`
15 changes: 15 additions & 0 deletions .claude/agent-memory/qa-integration-tester/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p data-testid="invoice-empty">` 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).
Expand Down
1 change: 1 addition & 0 deletions .claude/agent-memory/security-engineer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 10 additions & 0 deletions .claude/agent-memory/ux-designer/story-9-2-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<li>` items lack `aria-label` to announce overdue state in context for SR users.
4 changes: 4 additions & 0 deletions .claude/metrics/review-metrics.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
71 changes: 71 additions & 0 deletions client/src/components/QuickActionsCard/QuickActionsCard.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
102 changes: 102 additions & 0 deletions client/src/components/QuickActionsCard/QuickActionsCard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<QuickActionsCard />);

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(<QuickActionsCard />);

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(<QuickActionsCard />);

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(<QuickActionsCard />);

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(<QuickActionsCard />);

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(<QuickActionsCard />);

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(<QuickActionsCard />);

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(<QuickActionsCard />);

// No loading indicator
expect(screen.queryByText(/loading/i)).toBeNull();
// No error indicator
expect(screen.queryByText(/error/i)).toBeNull();
});
});
39 changes: 39 additions & 0 deletions client/src/components/QuickActionsCard/QuickActionsCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.container}>
{/* Primary action: New Work Item */}
<Link to="/project/work-items/new" className={styles.primaryAction}>
New Work Item
</Link>

{/* Quick navigation links grid */}
<div className={styles.linksGrid}>
<Link to="/project/work-items" className={styles.quickLink} aria-label="Work Items">
Work Items
</Link>
<Link to="/schedule" className={styles.quickLink} aria-label="Timeline">
Timeline
</Link>
<Link to="/budget/overview" className={styles.quickLink} aria-label="Budget">
Budget
</Link>
<Link to="/budget/invoices" className={styles.quickLink} aria-label="Invoices">
Invoices
</Link>
<Link to="/budget/vendors" className={styles.quickLink} aria-label="Vendors">
Vendors
</Link>
</div>
</div>
);
}

export default QuickActionsCard;
3 changes: 3 additions & 0 deletions client/src/pages/DashboardPage/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -367,6 +368,8 @@ export function DashboardPage() {
<SourceUtilizationCard sources={budgetSources} />
) : card.id === 'timeline-status' && timelineData ? (
<TimelineStatusCards timeline={timelineData} />
) : card.id === 'quick-actions' ? (
<QuickActionsCard />
) : (
<p className={styles.cardPlaceholder}>Content coming soon.</p>
)}
Expand Down