Author SHA1 Message Date
saqib mir 33de435c91 update the dashbaord 2026-09-09 17:38:43 +05:30
saqib mirandClaude Sonnet 5 a51b18124b feat(002-reporting-dashboards-ui): add management/product/support/AI reporting dashboards
Presentation-only UI over the already-complete 015-reporting-dashboards backend:
four new dashboards (Management, Product, Support, AI) wired into a tabbed
Analytics & Reports page alongside the existing SLA Monitor/Escalation Matrix
views, plus the new stat-tile/ranked-bar-list/status-distribution/meter
primitives they share.

Applies the dataviz skill's methodology throughout (form before color, status
colors reserved and always paired with an icon + label, validated against
both light and dark mode) and fixes a real mobile overflow bug in the Tabs
primitive found via screenshot review, not just accessibility-tree checks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:25:13 +05:30
saqib mirandClaude Sonnet 5 a37539794f docs(002-reporting-dashboards-ui): plan, data model, quickstart, tasks
Four new dataviz-informed UI primitives (stat tile, ranked bar list,
status distribution, meter) shared across all four dashboards, no new
charting dependency. 24 tasks across a shared Foundational phase and
4 independently-testable dashboard user stories.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:12:48 +05:30
saqib mirandClaude Sonnet 5 e7e6e853d5 docs(002-reporting-dashboards-ui): feature spec and quality checklist
Presentation-only follow-on to supporthub-api's already-complete
015-reporting-dashboards, per the same backend-first pattern used
throughout this session. Plan applies the dataviz skill methodology
given the user's explicit request for production-level design.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:10:37 +05:30
34 changed files with 2042 additions and 16 deletions
+3 -3
View File
@@ -4,10 +4,10 @@
NEXT_PUBLIC_APP_NAME="SupportHub Web (Dev)"
NEXT_PUBLIC_APP_ENV="development"
NEXT_PUBLIC_APP_URL="https://support-dev.maskantech.in"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_API_URL="https://supportdev-api.maskantech.in/api/v1"
NEXT_PUBLIC_WS_URL="wss://supportdev-api.maskantech.in/ws"
NEXT_PUBLIC_API_URL="http://localhost:4501"
NEXT_PUBLIC_WS_URL="ws://localhost:4501/ws"
NEXT_PUBLIC_SAAS_PLATFORM_NAME="SaaS Parent Platform (Dev)"
NEXT_PUBLIC_SAAS_AUTH_HEADER="X-SaaS-User-Token"
+1 -1
View File
@@ -8,7 +8,7 @@ NEXT_PUBLIC_APP_ENV="development"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# SupportHub Backend API Integration (supporthub-api)
NEXT_PUBLIC_API_URL="http://localhost:4501/api/v1"
NEXT_PUBLIC_API_URL="http://localhost:4501"
NEXT_PUBLIC_WS_URL="ws://localhost:4501/ws"
# SaaS Parent Platform Integration
+1 -1
View File
@@ -8,7 +8,7 @@ NEXT_PUBLIC_APP_ENV="development"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# SupportHub Backend API Integration (supporthub-api)
NEXT_PUBLIC_API_URL="http://localhost:4501/api/v1"
NEXT_PUBLIC_API_URL="http://localhost:4501"
NEXT_PUBLIC_WS_URL="ws://localhost:4501/ws"
# SaaS Parent Platform Integration
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
@@ -0,0 +1,86 @@
# Specification Quality Checklist: Reporting and Analytics Dashboards UI
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-09
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Backend counterpart (`supporthub-api`'s 015-reporting-dashboards) is already complete and
committed — this feature is presentation-only, per the same backend-first pattern already
used for every prior feature spanning both repos this session.
- The user asked explicitly for "production-level design" — this feature's plan.md accordingly
applies this project's `dataviz` skill methodology (form selection before color, status colors
reserved for status-flavored distributions, sequential single-hue for magnitude rankings,
validated against both light and dark mode) rather than an ad hoc visual treatment.
- All items pass; no revision iterations were needed. No [NEEDS CLARIFICATION] markers were
required.
## Implementation-time findings
- **`dataviz` palette validation caught a real dark-mode gap in this app's existing status
colors.** Running `scripts/validate_palette.js` against the Badge component's own
emerald-500/amber-500/`--destructive` convention (chosen over the raw `--warning`/`--success`
CSS tokens, which fail contrast outright in light mode) showed the convention passes in light
mode but fails two checks in dark mode: warning/success CVD separation is 5.5, under the 6
floor, and destructive's dark value (#7f1d1d) has a contrast ratio of only 1.74 against the
dark surface. Rather than reworking this app's established status-color tokens (out of this
feature's scope), every new status-flavored component (`StatusDistribution`) applies the
skill's own prescribed mitigation for a borderline palette: identity never rides on color
alone — each segment pairs its bar color with a distinct icon shape (`CheckCircle2` /
`AlertTriangle` / `XCircle`) and a visible text label, in both the legend and the segment
itself. This satisfies the CVD floor-band's secondary-encoding requirement and the contrast
WARN's "visible labels required" mitigation.
- **A real backend bug was found and fixed via manual verification against real seeded data**,
not by any automated test: `ManagementRepository.countEverEscalatedToHuman` and
`ProductReportRepository.countEverEscalatedToHuman` (015-reporting-dashboards) checked a list
of terminal statuses that, per `ticket-state-machine.ts`'s own transition table, is reachable
from BOTH the AI-resolved path and the human-escalation path once they converge on shared
terminal statuses (`RESOLUTION_PENDING_CUSTOMER`/`RESOLVED`/`CLOSED`/`REOPENED`) — so every
AI-resolved ticket was being double-counted as human-escalated too (confirmed live: 34/34
tickets, 100%, against real dev data). Fixed by keying off `assignments: { some: {} }` instead,
since `orchestrationService.handleHumanEscalation` is the only code path that ever creates an
`Assignment` row. This is why this feature's own frontend verification session is the reason
015's own dashboards now report correct figures.
- **The `Tabs` primitive's mobile layout had a real overflow bug**, found only by taking an
actual screenshot (not just an accessibility-tree read) at a 390px viewport: `TabsList` centered
its six tabs with no scroll affordance, so the active tab was clipped off-screen on both edges
instead of being scrollable into view. Fixed in `components/ui/tabs.tsx` by making the list
horizontally scrollable on narrow viewports (`overflow-x-auto`, left-aligned, `shrink-0`
triggers) while keeping the existing centered/inline layout at `sm:` and above.
- Per-agent workload in the Support dashboard displays the raw `agentId` (no name-lookup
endpoint exists yet) — accepted as data-model.md's own documented fallback rather than adding a
new endpoint or an N+1 lookup out of this feature's scope.
- The full Playwright E2E suite, run serially against the real dev backend, intermittently trips
013-auth-hardening's real login rate limiter (`LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5` per
`LOGIN_RATE_LIMIT_WINDOW_SECONDS=300`) because every spec file shares the same
`admin@supporthub.internal` test account and each does its own real login. This is confirmed
pre-existing: the same failures occur running the suite with this feature's new
`reports-dashboards.spec.ts` entirely excluded. This feature's own E2E spec passes 3/3 cleanly
in isolation; the shared-account rate-limit interaction is a systemic property of the existing
E2E suite design, not a regression introduced here.
@@ -0,0 +1,104 @@
# Data Model: Reporting and Analytics Dashboards UI
Response shapes mirror `supporthub-api`'s `specs/015-reporting-dashboards/data-model.md`
exactly — reproduced here as the frontend's own typed contract (Constitution Principle IV),
plus the one derived view-model type each dashboard actually renders.
## API Response Types (`lib/api/types/reports.ts`)
```ts
export interface DateRangeDTO { from: string; to: string }
export interface ManagementDashboardDTO {
range: DateRangeDTO;
totalCases: number;
aiResolved: number;
humanEscalated: number;
resolved: number;
open: number;
slaCompliance: { met: number; breached: number; rate: number | null };
escalationCount: number;
averageResponseSeconds: number | null;
averageResolutionSeconds: number | null;
}
export interface ProductDashboardDTO {
productId: string;
range: DateRangeDTO;
supportVolume: number;
problemsByCategory: Array<{ categoryId: string | null; count: number }>;
recurringProblems: Array<{ categoryId: string | null; count: number }>;
aiResolutionRate: number | null;
humanEscalationRate: number | null;
topErrors: Array<{ code: string; count: number }>;
}
export interface SupportDashboardDTO {
generatedAt: string;
range: DateRangeDTO;
workloadByAgent: Array<{ agentId: string; openAssignments: number }>;
slaAtRisk: number;
slaBreached: number;
escalationCount: number;
averageResponseSeconds: number | null;
averageResolutionSeconds: number | null;
}
export interface AiDashboardDTO {
range: DateRangeDTO;
totalSessions: number;
aiResolutionRate: number | null;
humanHandoffRate: number | null;
failedTroubleshootingEscalationRate: number | null;
knowledgeMatchRate: number | null;
confidenceDistribution: { proceed: number; ask: number; escalate: number };
toolInvocations: { success: number; failed: number };
}
```
## Display Helpers (`lib/format/duration.ts`, inline in each dashboard)
- **`formatDurationSeconds(value: number | null): string`** — `null` -> `"No data"`; otherwise a
compact human duration (`"2h 15m"`, `"45s"`) — never raw seconds.
- **`formatRate(value: number | null): string`** — `null` -> `"No data"`; otherwise a percentage,
one decimal place (`"62.5%"`).
- **`formatCount(value: number): string`** — thousands-comma'd (dataviz `marks-and-anatomy.md`'s
own labeling convention), no special-casing needed since a count is never `null`.
## Ranked List View-Model
Every ranked list (`problemsByCategory`, `recurringProblems`, `topErrors`, `workloadByAgent`)
renders through one shared `RankedBarList` primitive taking:
```ts
interface RankedBarListItem {
label: string; // categoryId, error code, or agentId — resolved to a display label upstream
// where a friendlier name exists, falling back to the raw id otherwise
value: number;
}
```
Bar width is `value / max(values)` — proportional within the list shown, not against some
absolute scale, per `dataviz` choosing-a-form.md's "compare magnitude" guidance for a bounded
top-N list.
## Status Distribution View-Model
Confidence distribution and tool invocations both render through one shared
`StatusDistribution` primitive:
```ts
interface StatusDistributionSegment {
label: string; // "Proceed" | "Ask" | "Escalate" | "Success" | "Failed"
value: number;
tone: 'success' | 'warning' | 'destructive';
}
```
Mapping (fixed, never inferred from label text):
| Source | proceed/success | ask | escalate/failed |
|---|---|---|---|
| `confidenceDistribution` | `proceed` -> success | `ask` -> warning | `escalate` -> destructive |
| `toolInvocations` | `success` -> success | — (two-segment) | `failed` -> destructive |
| `slaCompliance` | `met` -> success | — (two-segment) | `breached` -> destructive |
+124
View File
@@ -0,0 +1,124 @@
# Implementation Plan: Reporting and Analytics Dashboards UI
**Branch**: `002-reporting-dashboards-ui` | **Date**: 2026-09-09 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/002-reporting-dashboards-ui/spec.md`
## Summary
Presents `supporthub-api`'s already-complete 015-reporting-dashboards contract (four `GET
/admin/reports/*` endpoints) as a tabbed admin surface, replacing nothing existing —
`admin/reports/page.tsx` already hosts the SLA Monitor and Escalation Matrix (012-admin-list-
views work); this feature adds Management/Product/Support/AI as four more tabs on that same
page. New reusable dashboard primitives (stat tile, ranked bar list, status distribution bar,
meter) are built once in `components/ui` and reused across all four dashboards, following the
`dataviz` skill's methodology: pick the form before color, status colors (already defined in
this project's own design tokens) for status-flavored splits, a single sequential hue for
magnitude rankings, validated in both light and dark mode.
## Technical Context
**Language/Version**: TypeScript, Next.js 14 App Router (unchanged).
**Primary Dependencies**: None new — TanStack Query (already the standard), the existing
`components/ui` kit, this project's own Tailwind design tokens. No charting library added
(spec.md Assumptions) — every visual is plain HTML/CSS per the `dataviz` skill's Tier 0/1
component guidance (a proportional div-based bar needs no SVG/canvas dependency).
**Storage**: N/A — no client-side persistence; date-range selection is component state
(spec.md Assumptions).
**Testing**: Vitest for the new pure helpers (duration/rate formatting, the "no data" guard);
component tests for each dashboard's loading/empty/error/ready rendering (mocked query client,
matching 001-agent-admin-ui's own established testing pattern); Playwright E2E against the real
running `supporthub-api` + `supporthub-web` pair for the full tab-switching, date-range, and
product-selection flow — this session's standing rule of never claiming a frontend scenario done
without exercising it against real, live infrastructure.
**Target Platform**: Web, `(admin)` portal only (Constitution Principle III).
**Project Type**: Frontend — single Next.js app, no new module boundary crossed.
**Performance Goals**: Each dashboard fetches independently (its own `useQuery`) so switching
tabs doesn't block on data the current tab doesn't need; date-range changes debounce-free (a
single explicit "Apply" action, not a fetch per keystroke) to avoid hammering the backend while
typing a date.
**Constraints**: FR-008 — no new/changed backend endpoint; this is presentation-only. FR-004 —
every `null` rate/average renders as an explicit "No data" treatment, computed once in the
mapping layer (`lib/api/reports.ts`) so every consuming component gets an already-safe shape,
never a raw `null` a component might accidentally interpolate into text.
**Scale/Scope**: One new `lib/api/reports.ts` (+types), four new `features/reports/*-dashboard.tsx`
components, four new `components/ui` primitives (stat tile, ranked bar list, status distribution
bar, meter), one modified `admin/reports/page.tsx` (adds tabs), one shared date-range control.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. Each Identity Has Exactly One Authority, and the Frontend Is Never It | Not applicable — no identity/access surface touched. | PASS — N/A |
| II. The Backend Is the Sole Source of Business Logic | Every figure (rates, distributions, rankings) is rendered exactly as the backend computed it — this feature does no business-rule computation, only display formatting (e.g. seconds -> "2h 15m"). | PASS |
| III. Strict Portal Boundaries | All new code lives under `(admin)`/`features/reports` — no customer- or support-portal import, no shared primitive leaks admin-only data (the new `components/ui` primitives are generic value/label/color renderers, not admin-shaped). | PASS |
| IV. Typed API Boundary, No Ad Hoc Fetching | All four dashboards fetched through new `lib/api/reports.ts` functions wrapped in TanStack Query hooks — no direct `fetch`/`axios` in any component. | PASS |
| V. Configuration Over Hardcoding | Not applicable — no admin-configurable enum rendered by this feature (the confidence bands/status labels are fixed, backend-defined vocabulary, not business policy). | PASS — N/A |
| VI. Accessible, Responsive, Enterprise-Grade UI | New primitives keep density over decoration per this principle's own wording — status colors always paired with a text label (never color-alone, satisfying both this principle's WCAG requirement and the `dataviz` skill's own non-negotiable), keyboard-reachable tab/date controls, responsive down to a single column. | PASS |
| VII. Testing Gates | Typecheck/lint clean; new Vitest coverage for the mapping/formatting helpers and each dashboard's query-state rendering; Playwright coverage added for the reports tab-switching flow (not one of the constitution's two named cross-cutting journeys, but this project's own established practice of E2E-verifying every admin feature against a real backend, per 001's own precedent). | PASS |
No violations requiring Complexity Tracking justification.
## Project Structure
### Documentation (this feature)
```text
specs/002-reporting-dashboards-ui/
├── plan.md
├── data-model.md
├── quickstart.md
└── tasks.md
```
### Source Code (repository root)
```text
supporthub-web/
├── src/
│ ├── components/ui/
│ │ ├── stat-tile.tsx # NEW
│ │ ├── ranked-bar-list.tsx # NEW
│ │ ├── status-distribution.tsx # NEW
│ │ ├── meter.tsx # NEW
│ │ └── index.ts # MODIFIED — export the four above
│ ├── lib/
│ │ ├── api/
│ │ │ ├── reports.ts # NEW — 4 fetch functions
│ │ │ └── types/
│ │ │ └── reports.ts # NEW — response + view-model types
│ │ └── format/
│ │ └── duration.ts # NEW — seconds -> "2h 15m" / "No data"
│ ├── features/reports/
│ │ ├── management-dashboard.tsx # NEW
│ │ ├── product-dashboard.tsx # NEW
│ │ ├── support-dashboard.tsx # NEW
│ │ ├── ai-dashboard.tsx # NEW
│ │ ├── date-range-control.tsx # NEW — shared by Management/Product/AI
│ │ ├── sla-monitor.tsx # UNCHANGED (012's own)
│ │ └── escalation-matrix.tsx # UNCHANGED (012's own)
│ └── app/(admin)/admin/reports/
│ └── page.tsx # MODIFIED — adds 4 tabs alongside the 2 existing sections
└── tests/
├── unit/lib/format/ # duration formatting, no-data guard
├── unit/features/reports/ # per-dashboard query-state rendering
└── e2e/ # reports tab-switching + date-range Playwright spec
```
**Structure Decision**: No new route, no new portal — extends the existing `(admin)/admin/reports`
page and `features/reports` module already established by 012-admin-list-views' SLA Monitor/
Escalation Matrix work.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
@@ -0,0 +1,53 @@
# Quickstart: Reporting and Analytics Dashboards UI
Manual verification against a real, running `supporthub-api` (with real ticket/SLA/AI data —
reuse the throwaway Postgres/Redis and admin login already established this session) and a
real, running `supporthub-web` dev server, logged in as an admin.
## Scenario 1 — Management dashboard (User Story 1)
1. Open Admin -> Reports -> Management tab.
2. **Expected**: stat tiles for total cases, AI resolved, human escalated, resolved, open;
an SLA compliance meter; escalation count; average response/resolution time — all real
numbers, not placeholders.
3. Change the date range to one with no activity.
4. **Expected**: counts show `0`, rates/averages show "No data," never blank or `NaN`.
5. Change the date range back.
6. **Expected**: the dashboard re-fetches and every figure updates.
## Scenario 2 — Product dashboard (User Story 2)
1. Select Product A in the product picker.
2. **Expected**: support volume, problem-category ranked bars, AI/human resolution rates, and
top-error ranked bars all reflect Product A only.
3. Switch to Product B.
4. **Expected**: every figure fully replaces — no leftover Product A data visible mid-transition
beyond the loading state.
## Scenario 3 — Support dashboard (User Story 3)
1. Open the Support tab.
2. **Expected**: a ranked bar list of per-agent workload; SLA at-risk and SLA breached shown as
two visually distinct figures; response/resolution performance stat tiles.
## Scenario 4 — AI dashboard (User Story 4)
1. Open the AI tab.
2. **Expected**: AI resolution rate, human-handoff rate, knowledge-match rate stat tiles; a
confidence distribution bar (proceed/ask/escalate, success/warning/destructive-toned, each
labeled); a tool success/failure distribution bar, same treatment.
## Scenario 5 — Responsive and dark mode
1. Resize the viewport down to a narrow mobile width on each tab.
2. **Expected**: stat tiles stack to one column, ranked bars remain fully readable, nothing
clips or requires horizontal scroll.
3. Toggle dark mode.
4. **Expected**: every figure, bar, and status color remains legible and uses this project's
existing dark-mode tokens.
## What "done" looks like
All five scenarios pass against a real running backend and frontend pair, verified visually
(not just by reading component code) and via the Playwright spec covering tab-switching,
date-range changes, and product selection.
+188
View File
@@ -0,0 +1,188 @@
# Feature Specification: Reporting and Analytics Dashboards UI
**Feature Branch**: `002-reporting-dashboards-ui`
**Created**: 2026-09-09
**Status**: Draft
**Input**: User description: "Reporting and analytics dashboards UI: a production-quality admin surface presenting the four dashboards (Management, Product, Support, AI) supporthub-api's 015-reporting-dashboards feature now exposes — date-range filtering, stat tiles, distributions, and ranked lists, following this project's established loading/empty/error/ready query-state discipline and design system."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Admin sees organization-wide health at a glance (Priority: P1)
An admin opens the reports area and immediately sees, for a chosen date range, how support is
doing overall: total cases, how many were resolved and by whom (AI vs. a human), how many are
still open, whether SLA is being met, and average response/resolution time.
**Why this priority**: This is the dashboard the roadmap's own top-level success criteria map to
most directly — it's the first thing anyone opens the reports area to see.
**Independent Test**: Can be fully tested by loading the Management tab against a backend with
known data and confirming every figure on screen matches the API response exactly, with correct
loading/empty/error presentation.
**Acceptance Scenarios**:
1. **Given** the backend has ticket/SLA/escalation data for the default period, **When** the
admin opens the Management dashboard, **Then** every stat tile and the SLA compliance figure
show the real values from `GET /admin/reports/management`, not placeholder text.
2. **Given** the admin changes the date range, **When** the new range is applied, **Then** the
dashboard re-fetches and every figure updates to match the new range.
3. **Given** a rate or average is `null` (no qualifying data), **When** the dashboard renders,
**Then** it shows an explicit "No data" treatment, never `NaN`, `undefined`, or a bare `0`
that could be misread as a real zero.
4. **Given** the backend request fails, **When** the dashboard renders, **Then** it shows the
established error state (never a blank screen or a silently stale view).
---
### User Story 2 - Admin drills into one product's support health (Priority: P1)
An admin picks a product and sees that product's own support volume, problem-category
breakdown, AI/human resolution split, and most frequent error codes — never another product's
data mixed in.
**Why this priority**: Per-product visibility is as fundamental as the org-wide view for a
platform serving multiple SaaS products, and pairs directly with the Management view.
**Independent Test**: Can be fully tested by selecting two different products against a backend
with data for both and confirming each product's own figures show only its own data.
**Acceptance Scenarios**:
1. **Given** a product is selected, **When** the Product dashboard loads, **Then** support
volume, the problem-category breakdown, and the AI/human resolution rates all reflect only
that product.
2. **Given** the admin switches products, **When** the new product's data loads, **Then** the
previous product's figures are fully replaced, never blended or stale.
3. **Given** a product has no error-code lookups in range, **When** the dashboard renders,
**Then** the "top errors" section shows an explicit empty state, not a blank gap.
---
### User Story 3 - Admin sees team workload and SLA risk (Priority: P2)
An admin sees current per-agent workload and which tickets are approaching or past their SLA
due date, alongside response/resolution performance for the period.
**Why this priority**: Operational, day-to-day utility rather than a new class of information —
P2 relative to the two org/product-level views above.
**Independent Test**: Can be fully tested against a backend with known assignment/SLA data and
confirming per-agent workload and at-risk/breached counts match exactly.
**Acceptance Scenarios**:
1. **Given** agents have current open assignments, **When** the Support dashboard loads,
**Then** each agent's workload is shown, ranked by load.
2. **Given** some SLA runs are at risk and others already breached, **When** the dashboard
renders, **Then** the two counts are visually distinct, never merged into one figure.
---
### User Story 4 - Admin sees how well the AI is performing (Priority: P2)
An admin sees the AI's resolution rate, human-handoff rate, knowledge-match rate, confidence
distribution, and tool success/failure for the period.
**Why this priority**: Validates the AI-first design's premise for a narrower audience than the
org/product views — P2.
**Independent Test**: Can be fully tested against a backend with known AI session/diagnosis/tool
data and confirming every figure matches.
**Acceptance Scenarios**:
1. **Given** AI sessions resolved and escalated in the period, **When** the AI dashboard loads,
**Then** the resolution/handoff rates and the confidence distribution (proceed/ask/escalate)
match the real session data.
2. **Given** tool invocations succeeded and failed, **When** the dashboard renders, **Then** the
success/failure split is shown with each outcome visually distinct and labeled, never color
alone.
---
### Edge Cases
- What happens while a request is in flight? A loading state distinct from both "empty" and
"error" — the existing `getQueryState` discipline, never a flash of zeroed-out figures.
- What happens if the selected date range is invalid (`from` after `to`)? The date-range control
itself prevents choosing an invalid range; if the backend still rejects one, the error state
shows the backend's own message.
- What happens on a narrow viewport? Every dashboard degrades to a single-column layout — stat
tiles stack, ranked lists remain fully readable, nothing is clipped or requires horizontal
scroll.
- What happens in dark mode? Every figure, bar, and status color remains legible and uses this
project's existing dark-mode tokens — not a separate, unvalidated color set.
- What happens with a very long category/error-code name in a ranked list? It truncates with an
accessible full-text affordance (title attribute at minimum), never breaking the layout or
overlapping the value.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST present all four dashboards (Management, Product, Support, AI) from
`specs/002-reporting-dashboards-ui`'s own admin reports area, each independently loadable.
- **FR-002**: The Management, Product, and AI dashboards MUST support a date-range filter
(`from`/`to`), re-fetching on change; the Support dashboard's workload/risk figures are
current-state (per the backend contract) and are not range-filtered, matching
`supporthub-api`'s own `SupportDashboard.generatedAt` framing.
- **FR-003**: The Product dashboard MUST require a product to be selected before fetching, and
MUST show the backend's own error when an unknown product is requested.
- **FR-004**: Every rate/average field that the backend returns as `null` MUST render as an
explicit "No data" treatment — never `NaN`, a blank cell, or a `0` indistinguishable from a
real zero value.
- **FR-005**: Every data view MUST follow the project's established `getQueryState` discipline
(loading/empty/error/ready), never conflating any two of those states.
- **FR-006**: Ranked lists (problem categories, top errors, agent workload) MUST be sorted
descending by the backend's own ordering and MUST visually encode magnitude (not just list the
numbers as plain text) per this project's data-visualization standard.
- **FR-007**: Status-flavored distributions (AI confidence bands, tool success/failure, SLA
met/breached) MUST use this project's existing status colors (success/warning/destructive)
consistently with their real-world meaning, MUST include a visible legend/label (never color
alone), and MUST remain legible and validated in both light and dark mode.
- **FR-008**: This feature MUST NOT introduce any new backend endpoint or change any existing
one — it is a pure presentation layer over `supporthub-api`'s already-complete
015-reporting-dashboards contract.
- **FR-009**: The reports area MUST remain reachable only to an authenticated admin session,
consistent with every other admin surface in this application.
### Key Entities
- **Dashboard view-model**: The frontend-side shape each dashboard's API response is mapped into
for rendering — never persisted, recomputed on every fetch.
- **Date range selection**: UI-local state (`from`/`to`) driving the Management/Product/AI
dashboards' queries; not synced to a URL param in this first cut (Assumptions).
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An admin can answer "how is support doing," "how is this product doing," "who's
overloaded," and "is the AI helping" each within one screen, with no figure requiring a
separate lookup to interpret.
- **SC-002**: Every figure on every dashboard is independently verifiable against the backend's
own response for the same request — no discrepancy, no client-side recomputation that could
drift from what the API actually returned.
- **SC-003**: The reports area is fully usable — legible, correctly laid out, no clipped or
overlapping content — from a narrow mobile viewport up through a large desktop screen, and in
both light and dark mode.
## Assumptions
- Scope is presentation only, per the user's own explicit direction to build the backend first
(015-reporting-dashboards, already complete) and the frontend as a distinct follow-on —
consistent with every prior feature this session that spanned both repos.
- Date-range selection is local component state, not persisted to the URL or local storage in
this first cut — a shareable/bookmarkable link to a specific range is a reasonable future
enhancement, not required here.
- The Product dashboard's product picker reuses the existing product list already available to
the admin UI (`GET /admin/products`, 012-admin-list-views) rather than introducing a new
lookup endpoint.
- "Visually encode magnitude" (FR-006) means a simple proportional bar behind/beside each ranked
row — not a full charting library. This project has no charting dependency today, and
introducing one for a handful of ranked lists and status splits would be disproportionate to
the need (this session's own "don't add complexity beyond what the task requires" standard).
+128
View File
@@ -0,0 +1,128 @@
---
description: "Task list for 002-reporting-dashboards-ui"
---
# Tasks: Reporting and Analytics Dashboards UI
**Input**: Design documents from `specs/002-reporting-dashboards-ui/`
**Organization**: Tasks are grouped by user story (US1 = P1 Management, US2 = P1 Product,
US3 = P2 Support, US4 = P2 AI), sharing one Foundational phase (types, format helpers, the four
new `components/ui` primitives, the shared date-range control).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-web/` (repo root).
---
## Phase 1: Foundational (Blocking Prerequisites)
- [x] T001 [P] Add `lib/api/types/reports.ts` per data-model.md's four DTO interfaces
- [x] T002 [P] Add `lib/format/duration.ts``formatDurationSeconds`, `formatRate`,
`formatCount` (depends on nothing — pure functions)
- [x] T003 [P] Add `components/ui/stat-tile.tsx` — label/value/optional-delta contract per
`dataviz` marks-and-anatomy.md's "figures" section; sans semibold value, sentence-case
label
- [x] T004 [P] Add `components/ui/ranked-bar-list.tsx` — takes `RankedBarListItem[]`, renders
each as a label + proportional bar (single sequential hue = `primary`) + value, sorted as
given (backend already sorts), truncating long labels with a `title` tooltip
- [x] T005 [P] Add `components/ui/status-distribution.tsx` — takes
`StatusDistributionSegment[]`, renders a segmented bar (2px surface gaps between segments
per `dataviz` marks-and-anatomy.md) plus a visible legend row (label + swatch + value for
each segment — never color alone)
- [x] T006 [P] Add `components/ui/meter.tsx` — a single-ratio track/fill pair (SLA compliance
rate), fill tone success/warning/destructive by value, unfilled track a lighter step of
the same tone
- [x] T007 Export T003-T006 from `components/ui/index.ts`
- [x] T008 Add `features/reports/date-range-control.tsx` — two date inputs + an explicit
"Apply" action (plan.md: no fetch-per-keystroke), shared by Management/Product/AI
- [x] T009 Add `lib/api/reports.ts``getManagementDashboard(range)`,
`getProductDashboard(externalProductId, range)`, `getSupportDashboard()`,
`getAiDashboard(range)`, each a thin typed wrapper over `apiClient` per Constitution
Principle IV (depends on T001)
**Checkpoint**: Primitives and typed client in place. Each dashboard can now be built
independently.
---
## Phase 2: User Story 1 - Management dashboard (Priority: P1)
- [x] T010 [US1] Add `features/reports/management-dashboard.tsx``useQuery` +
`getQueryState`, stat tiles (T003) for totals, `Meter` (T006) for SLA compliance,
`formatDurationSeconds` for the two averages, `DateRangeControl` (T008) wired to
`queryKey` (depends on T003, T006, T008, T009)
- [x] T011 [US1] Unit tests: `formatDurationSeconds`/`formatRate` null -> "No data" guard, a
real-value case each, in `tests/unit/lib/format/duration.test.ts` (depends on T002)
- [x] T012 [US1] Component test: Management dashboard renders loading/empty/error/ready
correctly against a mocked query client, in
`tests/unit/features/reports/management-dashboard.test.tsx` (depends on T010)
**Checkpoint**: Quickstart Scenario 1 passes against a real backend.
---
## Phase 3: User Story 2 - Product dashboard (Priority: P1)
- [x] T013 [US2] Add a product picker reusing the existing `GET /admin/products` list (spec.md
Assumptions) — check for an existing products-list hook/component from
012-admin-list-views' own admin/products page before adding a new one
- [x] T014 [US2] Add `features/reports/product-dashboard.tsx``RankedBarList` (T004) for
problem categories and top errors, stat tiles for support volume/rates, wired to T013's
picker + `DateRangeControl` (depends on T004, T008, T009, T013)
- [x] T015 [US2] Component test: switching products fully replaces the rendered figures, an
unknown-product error state renders correctly, in
`tests/unit/features/reports/product-dashboard.test.tsx` (depends on T014)
**Checkpoint**: Quickstart Scenario 2 passes against a real backend.
---
## Phase 4: User Story 3 - Support dashboard (Priority: P2)
- [x] T016 [US3] Add `features/reports/support-dashboard.tsx``RankedBarList` (T004) for
per-agent workload, stat tiles for at-risk/breached/escalation count and performance
averages — no date-range control (current-state, per data-model.md) (depends on T003,
T004, T009)
- [x] T017 [US3] Component test: at-risk and breached render as visually distinct figures, in
`tests/unit/features/reports/support-dashboard.test.tsx` (depends on T016)
**Checkpoint**: Quickstart Scenario 3 passes against a real backend.
---
## Phase 5: User Story 4 - AI dashboard (Priority: P2)
- [x] T018 [US4] Add `features/reports/ai-dashboard.tsx` — stat tiles for the four rates,
`StatusDistribution` (T005) for confidence bands and tool invocations, `DateRangeControl`
(depends on T003, T005, T008, T009)
- [x] T019 [US4] Component test: confidence/tool distributions map to the correct tone per
data-model.md's fixed mapping table, in
`tests/unit/features/reports/ai-dashboard.test.tsx` (depends on T018)
**Checkpoint**: Quickstart Scenario 4 passes against a real backend.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [x] T020 Wire all four dashboards into `app/(admin)/admin/reports/page.tsx` as tabs
(`components/ui/tabs`) alongside the existing SLA Monitor/Escalation Matrix sections
(depends on T010, T014, T016, T018)
- [x] T021 Playwright E2E: tab-switching, date-range change, and product-selection flow against
a real running backend + frontend pair, in `tests/e2e/reports-dashboards.spec.ts`
(depends on T020)
- [x] T022 Manually verify Quickstart Scenario 5 (responsive + dark mode) directly in a browser
- [x] T023 Update `specs/002-reporting-dashboards-ui/checklists/requirements.md` Notes with any
implementation-time findings
- [x] T024 `npm run typecheck`/`npm run lint` clean; full existing Vitest + Playwright suite
re-run to confirm no regression in 001-agent-admin-ui's own coverage
---
## Dependencies & Execution Order
- **Foundational (Phase 1)**: No dependencies — BLOCKS all four user stories
- **User Stories 2-5**: Each depends only on Foundational — independent of each other
- **Polish (Phase 6)**: Depends on all four user stories
+44 -9
View File
@@ -1,17 +1,52 @@
'use client';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui';
import { SlaMonitor } from '@/features/reports/sla-monitor';
import { EscalationMatrix } from '@/features/reports/escalation-matrix';
import { ManagementDashboard } from '@/features/reports/management-dashboard';
import { ProductDashboard } from '@/features/reports/product-dashboard';
import { SupportDashboard } from '@/features/reports/support-dashboard';
import { AiDashboard } from '@/features/reports/ai-dashboard';
export default function AnalyticsAndReportsPage() {
return (
<div className="p-6 flex flex-col gap-8">
<section>
<h2 className="text-lg font-semibold mb-4">SLA Monitor</h2>
<SlaMonitor />
</section>
<section>
<h2 className="text-lg font-semibold mb-4">Escalation Matrix</h2>
<EscalationMatrix />
</section>
<div className="p-6 flex flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-foreground">Analytics &amp; Reports</h1>
<p className="text-sm text-muted-foreground mt-1">
Organization, product, team, and AI performance at a glance.
</p>
</div>
<Tabs defaultValue="management">
<TabsList>
<TabsTrigger value="management">Management</TabsTrigger>
<TabsTrigger value="product">Product</TabsTrigger>
<TabsTrigger value="support">Support</TabsTrigger>
<TabsTrigger value="ai">AI</TabsTrigger>
<TabsTrigger value="sla">SLA Monitor</TabsTrigger>
<TabsTrigger value="escalations">Escalation Matrix</TabsTrigger>
</TabsList>
<TabsContent value="management">
<ManagementDashboard />
</TabsContent>
<TabsContent value="product">
<ProductDashboard />
</TabsContent>
<TabsContent value="support">
<SupportDashboard />
</TabsContent>
<TabsContent value="ai">
<AiDashboard />
</TabsContent>
<TabsContent value="sla">
<SlaMonitor />
</TabsContent>
<TabsContent value="escalations">
<EscalationMatrix />
</TabsContent>
</Tabs>
</div>
);
}
+4
View File
@@ -16,3 +16,7 @@ export * from './toast';
export * from './alert';
export * from './progress';
export * from './skeleton';
export * from './stat-tile';
export * from './ranked-bar-list';
export * from './status-distribution';
export * from './meter';
+47
View File
@@ -0,0 +1,47 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Progress } from './progress';
export interface MeterProps {
label: string;
/** 0-1 ratio, or null for "no data" (015-reporting-dashboards' own null convention). */
value: number | null;
valueLabel: string;
className?: string;
}
/** dataviz marks-and-anatomy.md "Meter": fill severity accent -> warning -> danger, unfilled
* track a lighter step of the same ramp. Built on the existing Progress primitive rather than
* duplicating it — only the severity-by-value tone and label header are new. */
const Meter = React.forwardRef<HTMLDivElement, MeterProps>(
({ label, value, valueLabel, className }, ref) => {
const percentage = value === null ? 0 : Math.round(value * 100);
const tone =
value === null
? 'bg-muted-foreground/40'
: value >= 0.9
? 'bg-emerald-500'
: value >= 0.7
? 'bg-amber-500'
: 'bg-red-500 dark:bg-red-400';
return (
<div ref={ref} className={cn('flex flex-col gap-1.5', className)}>
<div className="flex items-baseline justify-between">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
<span className="text-sm font-semibold tabular-nums text-foreground">{valueLabel}</span>
</div>
<Progress
value={percentage}
size="md"
indicatorClassName={tone}
aria-label={label}
className="bg-muted"
/>
</div>
);
},
);
Meter.displayName = 'Meter';
export { Meter };
+65
View File
@@ -0,0 +1,65 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface RankedBarListItem {
label: string;
value: number;
}
export interface RankedBarListProps {
items: RankedBarListItem[];
formatValue?: (value: number) => string;
emptyMessage?: string;
className?: string;
}
/** dataviz choosing-a-form.md "compare magnitude" -> bar, sequential (one hue). Bar width is
* proportional within this list (value / max shown), not an absolute scale — appropriate for a
* bounded top-N ranking, not a chart meant to compare across separate requests.
* marks-and-anatomy.md: <=24px thick, 4px rounded data-end, square at the baseline (here: the
* bar's leading edge, since this is a horizontal ranked list). */
const RankedBarList = React.forwardRef<HTMLDivElement, RankedBarListProps>(
(
{ items, formatValue = (v) => v.toLocaleString('en-US'), emptyMessage = 'No data', className },
ref,
) => {
if (items.length === 0) {
return (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)}>
{emptyMessage}
</p>
);
}
const max = Math.max(...items.map((item) => item.value), 1);
return (
<div ref={ref} className={cn('flex flex-col gap-3', className)}>
{items.map((item) => {
const widthPercent = Math.max((item.value / max) * 100, 2);
return (
<div key={item.label} className="flex flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm text-foreground truncate" title={item.label}>
{item.label}
</span>
<span className="text-sm font-medium tabular-nums text-muted-foreground shrink-0">
{formatValue(item.value)}
</span>
</div>
<div className="h-2.5 w-full rounded-full bg-muted overflow-hidden" aria-hidden="true">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${widthPercent}%` }}
/>
</div>
</div>
);
})}
</div>
);
},
);
RankedBarList.displayName = 'RankedBarList';
export { RankedBarList };
+42
View File
@@ -0,0 +1,42 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent } from './card';
import { Skeleton } from './skeleton';
export interface StatTileProps {
label: string;
value: string;
tone?: 'default' | 'success' | 'warning' | 'destructive';
loading?: boolean;
className?: string;
}
const toneClasses: Record<NonNullable<StatTileProps['tone']>, string> = {
default: 'text-foreground',
success: 'text-emerald-600 dark:text-emerald-400',
warning: 'text-amber-600 dark:text-amber-400',
destructive: 'text-red-600 dark:text-red-400',
};
/** dataviz marks-and-anatomy.md "figures": label (sentence case, no trailing colon) + value
* (sans semibold, proportional figures — never tabular-nums at display size). One current value
* with no plot needs no hover layer (the one form the skill's interaction step exempts). */
const StatTile = React.forwardRef<HTMLDivElement, StatTileProps>(
({ label, value, tone = 'default', loading = false, className }, ref) => (
<Card ref={ref} className={cn('h-full', className)}>
<CardContent className="p-4 flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
{loading ? (
<Skeleton className="h-8 w-20" />
) : (
<span className={cn('text-2xl font-semibold leading-tight', toneClasses[tone])}>
{value}
</span>
)}
</CardContent>
</Card>
),
);
StatTile.displayName = 'StatTile';
export { StatTile };
+91
View File
@@ -0,0 +1,91 @@
import * as React from 'react';
import { CheckCircle2, AlertTriangle, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
export type StatusTone = 'success' | 'warning' | 'destructive';
export interface StatusDistributionSegment {
label: string;
value: number;
tone: StatusTone;
}
export interface StatusDistributionProps {
segments: StatusDistributionSegment[];
formatValue?: (value: number) => string;
className?: string;
}
const toneConfig: Record<
StatusTone,
{ bar: string; icon: React.ComponentType<{ className?: string }>; text: string }
> = {
success: { bar: 'bg-emerald-500', icon: CheckCircle2, text: 'text-emerald-600 dark:text-emerald-400' },
warning: { bar: 'bg-amber-500', icon: AlertTriangle, text: 'text-amber-600 dark:text-amber-400' },
destructive: { bar: 'bg-red-500 dark:bg-red-400', icon: XCircle, text: 'text-red-600 dark:text-red-400' },
};
/**
* dataviz "part-to-whole" -> stacked bar, categorical color job — here the categories are
* status-flavored (met/breached, proceed/ask/escalate, success/failed), so this project's own
* reserved status tones apply instead of a generic categorical assignment (per the skill's own
* "status colors are reserved" rule).
*
* This app's dark-mode success/warning/destructive tones fall short of the skill's own CVD/
* contrast checks for a small adjacent swatch (validated directly — see
* specs/002-reporting-dashboards-ui/checklists/requirements.md). Mitigated exactly as the skill
* prescribes for a borderline palette: identity never rides on color alone — every segment
* pairs its color with a distinct icon shape AND a text label, both in the legend and (where
* width allows) directly on the segment.
*/
const StatusDistribution = React.forwardRef<HTMLDivElement, StatusDistributionProps>(
({ segments, formatValue = (v) => v.toLocaleString('en-US'), className }, ref) => {
const total = segments.reduce((sum, s) => sum + s.value, 0);
return (
<div ref={ref} className={cn('flex flex-col gap-3', className)}>
{total === 0 ? (
<p className="text-sm text-muted-foreground">No data</p>
) : (
<div
className="flex h-3 w-full overflow-hidden rounded-full bg-muted"
role="img"
aria-label={segments.map((s) => `${s.label}: ${formatValue(s.value)}`).join(', ')}
>
{segments.map((segment, index) => {
const widthPercent = (segment.value / total) * 100;
if (widthPercent === 0) return null;
return (
<div
key={segment.label}
className={cn(
toneConfig[segment.tone].bar,
index > 0 && 'ml-0.5',
)}
style={{ width: `${widthPercent}%` }}
/>
);
})}
</div>
)}
<div className="flex flex-wrap gap-x-5 gap-y-2">
{segments.map((segment) => {
const { icon: Icon, text } = toneConfig[segment.tone];
return (
<div key={segment.label} className="flex items-center gap-1.5">
<Icon className={cn('h-4 w-4 shrink-0', text)} aria-hidden="true" />
<span className="text-sm text-foreground">{segment.label}</span>
<span className="text-sm font-medium tabular-nums text-muted-foreground">
{formatValue(segment.value)}
</span>
</div>
);
})}
</div>
</div>
);
},
);
StatusDistribution.displayName = 'StatusDistribution';
export { StatusDistribution };
+2 -2
View File
@@ -59,7 +59,7 @@ const TabsList = React.forwardRef<
ref={ref}
role="tablist"
className={cn(
'inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground w-full sm:w-auto',
'flex h-10 items-center gap-1 overflow-x-auto rounded-lg bg-muted p-1 text-muted-foreground w-full sm:w-auto sm:inline-flex sm:justify-center',
className
)}
{...props}
@@ -89,7 +89,7 @@ const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
aria-selected={isActive}
onClick={() => context.onValueChange(value)}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
+115
View File
@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
StatTile,
StatusDistribution,
Skeleton,
Alert,
AlertDescription,
} from '@/components/ui';
import { getAiDashboard } from '@/lib/api/reports';
import { getQueryState } from '@/lib/query/query-state';
import { formatRate, formatCount } from '@/lib/format/duration';
import { DateRangeControl, DateRangeValue } from './date-range-control';
function defaultRange(): DateRangeValue {
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
return { from: from.toISOString(), to: to.toISOString() };
}
/** specs/002-reporting-dashboards-ui User Story 4. */
export function AiDashboard() {
const [range, setRange] = useState<DateRangeValue>(defaultRange);
const query = useQuery({
queryKey: ['reports', 'ai', range],
queryFn: () => getAiDashboard(range),
});
const state = getQueryState(query, () => false);
return (
<div className="flex flex-col gap-5">
<DateRangeControl value={range} onApply={setRange} />
{state === 'loading' && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
)}
{state === 'error' && (
<Alert variant="destructive">
<AlertDescription>Couldn&apos;t load the AI dashboard.</AlertDescription>
</Alert>
)}
{state === 'ready' && query.data && (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatTile
label="AI resolution rate"
value={formatRate(query.data.aiResolutionRate)}
tone="success"
/>
<StatTile
label="Human handoff rate"
value={formatRate(query.data.humanHandoffRate)}
tone="warning"
/>
<StatTile
label="Knowledge match rate"
value={formatRate(query.data.knowledgeMatchRate)}
/>
<StatTile
label="Failed troubleshooting"
value={formatRate(query.data.failedTroubleshootingEscalationRate)}
tone="destructive"
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-4">
Diagnosis confidence
</h3>
<StatusDistribution
segments={[
{
label: 'Proceed',
value: query.data.confidenceDistribution.proceed,
tone: 'success',
},
{ label: 'Ask', value: query.data.confidenceDistribution.ask, tone: 'warning' },
{
label: 'Escalate',
value: query.data.confidenceDistribution.escalate,
tone: 'destructive',
},
]}
formatValue={formatCount}
/>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-4">Tool invocations</h3>
<StatusDistribution
segments={[
{ label: 'Success', value: query.data.toolInvocations.success, tone: 'success' },
{
label: 'Failed',
value: query.data.toolInvocations.failed,
tone: 'destructive',
},
]}
formatValue={formatCount}
/>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,56 @@
'use client';
import { useState } from 'react';
import { Input, Button } from '@/components/ui';
export interface DateRangeValue {
from: string;
to: string;
}
interface DateRangeControlProps {
value: DateRangeValue;
onApply: (value: DateRangeValue) => void;
}
function toDateInputValue(iso: string): string {
return iso.slice(0, 10);
}
/** spec.md Assumptions: an explicit "Apply" action, not a fetch per keystroke — avoids
* hammering the backend while the admin is still typing a date. */
export function DateRangeControl({ value, onApply }: DateRangeControlProps) {
const [from, setFrom] = useState(toDateInputValue(value.from));
const [to, setTo] = useState(toDateInputValue(value.to));
const invalid = from > to;
return (
<div className="flex flex-wrap items-end gap-3">
<Input
type="date"
label="From"
value={from}
max={to}
onChange={(e) => setFrom(e.target.value)}
containerClassName="w-40"
/>
<Input
type="date"
label="To"
value={to}
min={from}
onChange={(e) => setTo(e.target.value)}
containerClassName="w-40"
/>
<Button
size="sm"
disabled={invalid}
onClick={() =>
onApply({ from: new Date(from).toISOString(), to: new Date(to).toISOString() })
}
>
Apply
</Button>
</div>
);
}
@@ -0,0 +1,90 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { StatTile, Meter, Skeleton, Alert, AlertDescription } from '@/components/ui';
import { getManagementDashboard } from '@/lib/api/reports';
import { getQueryState } from '@/lib/query/query-state';
import { formatDurationSeconds, formatRate, formatCount } from '@/lib/format/duration';
import { DateRangeControl, DateRangeValue } from './date-range-control';
function defaultRange(): DateRangeValue {
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
return { from: from.toISOString(), to: to.toISOString() };
}
/** specs/002-reporting-dashboards-ui User Story 1. */
export function ManagementDashboard() {
const [range, setRange] = useState<DateRangeValue>(defaultRange);
const query = useQuery({
queryKey: ['reports', 'management', range],
queryFn: () => getManagementDashboard(range),
});
const state = getQueryState(query, () => false);
return (
<div className="flex flex-col gap-5">
<DateRangeControl value={range} onApply={setRange} />
{state === 'loading' && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
)}
{state === 'error' && (
<Alert variant="destructive">
<AlertDescription>Couldn&apos;t load the management dashboard.</AlertDescription>
</Alert>
)}
{state === 'ready' && query.data && (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<StatTile label="Total cases" value={formatCount(query.data.totalCases)} />
<StatTile
label="AI resolved"
value={formatCount(query.data.aiResolved)}
tone="success"
/>
<StatTile
label="Human escalated"
value={formatCount(query.data.humanEscalated)}
tone="warning"
/>
<StatTile label="Resolved" value={formatCount(query.data.resolved)} />
<StatTile label="Open" value={formatCount(query.data.open)} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-border bg-card p-4 flex flex-col gap-4">
<Meter
label="SLA compliance"
value={query.data.slaCompliance.rate}
valueLabel={formatRate(query.data.slaCompliance.rate)}
/>
<div className="flex gap-6 text-sm text-muted-foreground">
<span>Met: {formatCount(query.data.slaCompliance.met)}</span>
<span>Breached: {formatCount(query.data.slaCompliance.breached)}</span>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatTile label="Escalations" value={formatCount(query.data.escalationCount)} />
<StatTile
label="Avg. response"
value={formatDurationSeconds(query.data.averageResponseSeconds)}
/>
<StatTile
label="Avg. resolution"
value={formatDurationSeconds(query.data.averageResolutionSeconds)}
/>
</div>
</div>
</>
)}
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
StatTile,
RankedBarList,
Skeleton,
Alert,
AlertDescription,
} from '@/components/ui';
import { getProductDashboard } from '@/lib/api/reports';
import { listProductCatalog } from '@/lib/api/catalog';
import { getQueryState } from '@/lib/query/query-state';
import { formatRate, formatCount } from '@/lib/format/duration';
import { DateRangeControl, DateRangeValue } from './date-range-control';
function defaultRange(): DateRangeValue {
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
return { from: from.toISOString(), to: to.toISOString() };
}
/** specs/002-reporting-dashboards-ui User Story 2. */
export function ProductDashboard() {
const [range, setRange] = useState<DateRangeValue>(defaultRange);
const [productId, setProductId] = useState<string>('');
const productsQuery = useQuery({
queryKey: ['products', 'catalog'],
queryFn: listProductCatalog,
});
const dashboardQuery = useQuery({
queryKey: ['reports', 'product', productId, range],
queryFn: () => getProductDashboard(productId, range),
enabled: productId.length > 0,
});
const state = productId ? getQueryState(dashboardQuery, () => false) : 'empty';
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1.5 w-64">
<label htmlFor="product-picker" className="text-sm font-medium text-foreground">
Product
</label>
<select
id="product-picker"
value={productId}
onChange={(e) => setProductId(e.target.value)}
className="flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 shadow-subtle"
>
<option value="">Select a product</option>
{productsQuery.data?.map((product) => (
<option key={product.id} value={product.externalProductId}>
{product.name}
</option>
))}
</select>
</div>
{productId && <DateRangeControl value={range} onApply={setRange} />}
</div>
{state === 'empty' && (
<p className="text-sm text-muted-foreground">Select a product to see its dashboard.</p>
)}
{state === 'loading' && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
)}
{state === 'error' && (
<Alert variant="destructive">
<AlertDescription>
{dashboardQuery.error instanceof Error
? dashboardQuery.error.message
: "Couldn't load the product dashboard."}
</AlertDescription>
</Alert>
)}
{state === 'ready' && dashboardQuery.data && (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<StatTile
label="Support volume"
value={formatCount(dashboardQuery.data.supportVolume)}
/>
<StatTile
label="AI resolution rate"
value={formatRate(dashboardQuery.data.aiResolutionRate)}
tone="success"
/>
<StatTile
label="Human escalation rate"
value={formatRate(dashboardQuery.data.humanEscalationRate)}
tone="warning"
/>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-4">Recurring problems</h3>
<RankedBarList
items={dashboardQuery.data.recurringProblems.map((p) => ({
label: p.categoryId ?? 'Uncategorized',
value: p.count,
}))}
emptyMessage="No problems recorded in this range."
/>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-4">Top errors</h3>
<RankedBarList
items={dashboardQuery.data.topErrors.map((e) => ({
label: e.code,
value: e.count,
}))}
emptyMessage="No error-code lookups in this range."
/>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,70 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import {
StatTile,
RankedBarList,
Skeleton,
Alert,
AlertDescription,
} from '@/components/ui';
import { getSupportDashboard } from '@/lib/api/reports';
import { getQueryState } from '@/lib/query/query-state';
import { formatDurationSeconds, formatCount } from '@/lib/format/duration';
/** specs/002-reporting-dashboards-ui User Story 3. Workload/SLA-risk figures are current-state
* (data-model.md's own SupportDashboard.generatedAt framing) — no date-range control here. */
export function SupportDashboard() {
const query = useQuery({
queryKey: ['reports', 'support'],
queryFn: getSupportDashboard,
});
const state = getQueryState(query, () => false);
return (
<div className="flex flex-col gap-5">
{state === 'loading' && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
)}
{state === 'error' && (
<Alert variant="destructive">
<AlertDescription>Couldn&apos;t load the support dashboard.</AlertDescription>
</Alert>
)}
{state === 'ready' && query.data && (
<>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<StatTile label="SLA at risk" value={formatCount(query.data.slaAtRisk)} tone="warning" />
<StatTile
label="SLA breached"
value={formatCount(query.data.slaBreached)}
tone="destructive"
/>
<StatTile label="Escalations" value={formatCount(query.data.escalationCount)} />
<StatTile
label="Avg. response"
value={formatDurationSeconds(query.data.averageResponseSeconds)}
/>
</div>
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-foreground mb-4">Workload by agent</h3>
<RankedBarList
items={query.data.workloadByAgent
.slice()
.sort((a, b) => b.openAssignments - a.openAssignments)
.map((a) => ({ label: a.agentId, value: a.openAssignments }))}
emptyMessage="No agents currently have open assignments."
/>
</div>
</>
)}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { apiClient } from './client';
import {
ManagementDashboardDTO,
ProductDashboardDTO,
SupportDashboardDTO,
AiDashboardDTO,
DateRangeParams,
} from './types';
interface ApiEnvelope<T> {
success: true;
data: T;
meta: null;
}
/** specs/002-reporting-dashboards-ui/data-model.md — thin typed wrappers, one per
* supporthub-api 015-reporting-dashboards endpoint (Constitution Principle IV: no ad hoc
* fetching outside this layer). */
export async function getManagementDashboard(
range: DateRangeParams,
): Promise<ManagementDashboardDTO> {
const response = await apiClient.get<ApiEnvelope<ManagementDashboardDTO>>(
'/admin/reports/management',
{ params: range },
);
return response.data.data;
}
export async function getProductDashboard(
externalProductId: string,
range: DateRangeParams,
): Promise<ProductDashboardDTO> {
const response = await apiClient.get<ApiEnvelope<ProductDashboardDTO>>(
`/admin/reports/product/${externalProductId}`,
{ params: range },
);
return response.data.data;
}
export async function getSupportDashboard(): Promise<SupportDashboardDTO> {
const response =
await apiClient.get<ApiEnvelope<SupportDashboardDTO>>('/admin/reports/support');
return response.data.data;
}
export async function getAiDashboard(range: DateRangeParams): Promise<AiDashboardDTO> {
const response = await apiClient.get<ApiEnvelope<AiDashboardDTO>>('/admin/reports/ai', {
params: range,
});
return response.data.data;
}
+1
View File
@@ -8,3 +8,4 @@ export * from './escalation';
export * from './monitoring';
export * from './catalog';
export * from './knowledge';
export * from './reports';
+58
View File
@@ -0,0 +1,58 @@
/** Mirrors supporthub-api specs/015-reporting-dashboards/data-model.md exactly (Constitution
* Principle IV — typed API boundary kept in sync with the backend's own contract). */
export interface DateRangeDTO {
from: string;
to: string;
}
export interface ManagementDashboardDTO {
range: DateRangeDTO;
totalCases: number;
aiResolved: number;
humanEscalated: number;
resolved: number;
open: number;
slaCompliance: { met: number; breached: number; rate: number | null };
escalationCount: number;
averageResponseSeconds: number | null;
averageResolutionSeconds: number | null;
}
export interface ProductDashboardDTO {
productId: string;
range: DateRangeDTO;
supportVolume: number;
problemsByCategory: Array<{ categoryId: string | null; count: number }>;
recurringProblems: Array<{ categoryId: string | null; count: number }>;
aiResolutionRate: number | null;
humanEscalationRate: number | null;
topErrors: Array<{ code: string; count: number }>;
}
export interface SupportDashboardDTO {
generatedAt: string;
range: DateRangeDTO;
workloadByAgent: Array<{ agentId: string; openAssignments: number }>;
slaAtRisk: number;
slaBreached: number;
escalationCount: number;
averageResponseSeconds: number | null;
averageResolutionSeconds: number | null;
}
export interface AiDashboardDTO {
range: DateRangeDTO;
totalSessions: number;
aiResolutionRate: number | null;
humanHandoffRate: number | null;
failedTroubleshootingEscalationRate: number | null;
knowledgeMatchRate: number | null;
confidenceDistribution: { proceed: number; ask: number; escalate: number };
toolInvocations: { success: number; failed: number };
}
export interface DateRangeParams {
from?: string;
to?: string;
}
+27
View File
@@ -0,0 +1,27 @@
/** 015-reporting-dashboards (backend) returns null for "no qualifying data in range" — this
* project's own FR-004: never render that as NaN, a blank cell, or a 0 a reader could mistake
* for a real zero. */
export const NO_DATA_LABEL = 'No data';
export function formatDurationSeconds(value: number | null): string {
if (value === null) return NO_DATA_LABEL;
if (value < 60) return `${Math.round(value)}s`;
const totalMinutes = Math.round(value / 60);
const days = Math.floor(totalMinutes / (60 * 24));
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
const minutes = totalMinutes % 60;
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
export function formatRate(value: number | null): string {
if (value === null) return NO_DATA_LABEL;
return `${(value * 100).toFixed(1)}%`;
}
export function formatCount(value: number): string {
return value.toLocaleString('en-US');
}
+2
View File
@@ -5,7 +5,9 @@ const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/features/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
container: {
+80
View File
@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test';
/**
* specs/002-reporting-dashboards-ui/quickstart.md Scenarios 1-4, against a real,
* locally-running supporthub-api — not a mock. Covers tab-switching across all four new
* dashboards, date-range change, and product selection.
*/
test.describe('Reporting and analytics dashboards (User Stories 1-4)', () => {
async function signInAsAdmin(page: import('@playwright/test').Page) {
await page.goto('/sign-in');
await page.getByLabel(/Email/).fill('admin@supporthub.internal');
await page.getByLabel(/Password/).fill('ChangeMe123!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page).toHaveURL(/\/admin\/dashboard/);
}
test('switching tabs loads each dashboard\'s own figures', async ({ page }) => {
await signInAsAdmin(page);
await page.goto('/admin/reports');
await expect(page.getByRole('tab', { name: 'Management', exact: true })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(page.getByText('Total cases')).toBeVisible();
await page.getByRole('tab', { name: 'Support', exact: true }).click();
await expect(page.getByText('SLA at risk')).toBeVisible();
await expect(page.getByText('Workload by agent')).toBeVisible();
await page.getByRole('tab', { name: 'AI', exact: true }).click();
await expect(page.getByText('AI resolution rate')).toBeVisible();
await expect(page.getByText('Diagnosis confidence')).toBeVisible();
await expect(page.getByText('Tool invocations')).toBeVisible();
await page.getByRole('tab', { name: 'Management', exact: true }).click();
await expect(page.getByText('Total cases')).toBeVisible();
});
test('changing the date range on the management dashboard refetches its figures', async ({
page,
}) => {
await signInAsAdmin(page);
await page.goto('/admin/reports');
await expect(page.getByText('Total cases')).toBeVisible();
const dateInputs = page.locator('input[type="date"]');
await dateInputs.first().fill('2000-01-01');
await dateInputs.nth(1).fill('2000-01-02');
const responsePromise = page.waitForResponse((res) =>
res.url().includes('/admin/reports/management'),
);
await page.getByRole('button', { name: 'Apply' }).click();
await responsePromise;
// A far-past range with no activity resolves to all-zero counts (backend contract).
await expect(page.getByText('Total cases').locator('..').getByText('0')).toBeVisible();
});
test('selecting a product on the product dashboard loads that product\'s own figures', async ({
page,
}) => {
await signInAsAdmin(page);
await page.goto('/admin/reports');
await page.getByRole('tab', { name: 'Product', exact: true }).click();
await expect(page.getByText('Select a product to see its dashboard.')).toBeVisible();
const picker = page.getByLabel('Product');
const firstRealOption = picker.locator('option').nth(1);
const productName = await firstRealOption.textContent();
await picker.selectOption({ index: 1 });
await expect(page.getByText('Support volume')).toBeVisible();
await expect(page.getByText('Recurring problems')).toBeVisible();
await expect(page.getByText('Top errors')).toBeVisible();
expect(productName).toBeTruthy();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AiDashboard } from '@/features/reports/ai-dashboard';
import { getAiDashboard } from '@/lib/api/reports';
import type { AiDashboardDTO } from '@/lib/api/types';
vi.mock('@/lib/api/reports', () => ({
getAiDashboard: vi.fn(),
}));
function renderWithClient(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
const dashboard: AiDashboardDTO = {
range: { from: '2026-08-10T00:00:00.000Z', to: '2026-09-09T00:00:00.000Z' },
totalSessions: 7,
aiResolutionRate: 0.114,
humanHandoffRate: 0.727,
failedTroubleshootingEscalationRate: 0,
knowledgeMatchRate: 0.068,
confidenceDistribution: { proceed: 5, ask: 0, escalate: 2 },
toolInvocations: { success: 5, failed: 2 },
};
function toneOf(label: string): string {
const legendRow = screen.getByText(label).closest('div');
const icon = legendRow?.querySelector('svg');
return icon?.getAttribute('class') ?? '';
}
describe('AiDashboard', () => {
beforeEach(() => {
vi.mocked(getAiDashboard).mockReset();
});
it('maps each distribution segment to its fixed data-model.md tone', async () => {
vi.mocked(getAiDashboard).mockResolvedValue(dashboard);
renderWithClient(<AiDashboard />);
await waitFor(() => expect(screen.getByText('Proceed')).toBeInTheDocument());
// confidenceDistribution: proceed -> success, ask -> warning, escalate -> destructive
expect(toneOf('Proceed')).toContain('emerald');
expect(toneOf('Ask')).toContain('amber');
expect(toneOf('Escalate')).toContain('red');
// toolInvocations: success -> success, failed -> destructive
expect(toneOf('Success')).toContain('emerald');
expect(toneOf('Failed')).toContain('red');
});
it('renders the four rate stat tiles with real values', async () => {
vi.mocked(getAiDashboard).mockResolvedValue(dashboard);
renderWithClient(<AiDashboard />);
await waitFor(() => expect(screen.getByText('11.4%')).toBeInTheDocument());
expect(screen.getByText('72.7%')).toBeInTheDocument();
expect(screen.getByText('6.8%')).toBeInTheDocument();
expect(screen.getByText('0.0%')).toBeInTheDocument();
});
it('renders an error state when the query fails', async () => {
vi.mocked(getAiDashboard).mockRejectedValue(new Error('boom'));
renderWithClient(<AiDashboard />);
expect(await screen.findByText(/couldn.t load the ai dashboard/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ManagementDashboard } from '@/features/reports/management-dashboard';
import { getManagementDashboard } from '@/lib/api/reports';
import type { ManagementDashboardDTO } from '@/lib/api/types';
vi.mock('@/lib/api/reports', () => ({
getManagementDashboard: vi.fn(),
}));
function renderWithClient(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
const dashboard: ManagementDashboardDTO = {
range: { from: '2026-08-10T00:00:00.000Z', to: '2026-09-09T00:00:00.000Z' },
totalCases: 34,
aiResolved: 4,
humanEscalated: 26,
resolved: 7,
open: 27,
slaCompliance: { met: 6, breached: 2, rate: 0.75 },
escalationCount: 3,
averageResponseSeconds: 180,
averageResolutionSeconds: null,
};
describe('ManagementDashboard', () => {
beforeEach(() => {
vi.mocked(getManagementDashboard).mockReset();
});
it('renders a loading state while the query is in flight', () => {
vi.mocked(getManagementDashboard).mockReturnValue(new Promise(() => {}));
const { container } = renderWithClient(<ManagementDashboard />);
expect(container.querySelectorAll('[data-slot="skeleton"], .animate-pulse').length).toBeGreaterThan(
0,
);
});
it('renders an error state when the query fails', async () => {
vi.mocked(getManagementDashboard).mockRejectedValue(new Error('boom'));
renderWithClient(<ManagementDashboard />);
expect(await screen.findByText(/couldn.t load the management dashboard/i)).toBeInTheDocument();
});
it('renders real figures once the query resolves', async () => {
vi.mocked(getManagementDashboard).mockResolvedValue(dashboard);
renderWithClient(<ManagementDashboard />);
await waitFor(() => expect(screen.getByText('34')).toBeInTheDocument());
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('26')).toBeInTheDocument();
expect(screen.getByText('75.0%')).toBeInTheDocument();
expect(screen.getByText('Met: 6')).toBeInTheDocument();
expect(screen.getByText('Breached: 2')).toBeInTheDocument();
expect(screen.getByText('3m')).toBeInTheDocument();
expect(screen.getByText('No data')).toBeInTheDocument();
});
});
@@ -0,0 +1,105 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ProductDashboard } from '@/features/reports/product-dashboard';
import { getProductDashboard } from '@/lib/api/reports';
import { listProductCatalog } from '@/lib/api/catalog';
import type { ProductDashboardDTO } from '@/lib/api/types';
vi.mock('@/lib/api/reports', () => ({
getProductDashboard: vi.fn(),
}));
vi.mock('@/lib/api/catalog', () => ({
listProductCatalog: vi.fn(),
}));
function renderWithClient(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
const products = [
{
id: 'p1',
externalProductId: 'CORE_PLATFORM',
name: 'Core SupportHub Platform',
status: 'active',
supportEnabled: true,
integrationStatus: 'active' as const,
},
{
id: 'p2',
externalProductId: 'BILLING',
name: 'Billing Service',
status: 'active',
supportEnabled: true,
integrationStatus: 'active' as const,
},
];
function makeDashboard(overrides: Partial<ProductDashboardDTO> = {}): ProductDashboardDTO {
return {
productId: 'CORE_PLATFORM',
range: { from: '2026-08-10T00:00:00.000Z', to: '2026-09-09T00:00:00.000Z' },
supportVolume: 34,
problemsByCategory: [],
recurringProblems: [{ categoryId: null, count: 0 }],
aiResolutionRate: 0.11764705882352941,
humanEscalationRate: 0.7647058823529411,
topErrors: [
{ code: 'SEED-POPULAR-1', count: 5 },
{ code: 'SEED-RARE-1', count: 1 },
],
...overrides,
};
}
describe('ProductDashboard', () => {
beforeEach(() => {
vi.mocked(getProductDashboard).mockReset();
vi.mocked(listProductCatalog).mockReset();
vi.mocked(listProductCatalog).mockResolvedValue(products);
});
it('shows a prompt and no figures until a product is selected', async () => {
renderWithClient(<ProductDashboard />);
expect(await screen.findByText(/select a product to see its dashboard/i)).toBeInTheDocument();
expect(getProductDashboard).not.toHaveBeenCalled();
});
it('replaces the rendered figures when the selected product changes', async () => {
vi.mocked(getProductDashboard).mockImplementation(async (externalProductId) =>
makeDashboard(
externalProductId === 'BILLING'
? { productId: 'BILLING', supportVolume: 9, topErrors: [{ code: 'BILL-ERR', count: 2 }] }
: {},
),
);
const user = userEvent.setup();
renderWithClient(<ProductDashboard />);
const picker = await screen.findByLabelText('Product');
await screen.findByRole('option', { name: 'Core SupportHub Platform' });
await user.selectOptions(picker, 'CORE_PLATFORM');
await waitFor(() => expect(screen.getByText('34')).toBeInTheDocument());
expect(screen.getByText('SEED-POPULAR-1')).toBeInTheDocument();
await user.selectOptions(picker, 'BILLING');
await waitFor(() => expect(screen.getByText('9')).toBeInTheDocument());
expect(screen.getByText('BILL-ERR')).toBeInTheDocument();
expect(screen.queryByText('SEED-POPULAR-1')).not.toBeInTheDocument();
});
it('renders the backend error message for an unknown product', async () => {
vi.mocked(getProductDashboard).mockRejectedValue(new Error('Product not found.'));
const user = userEvent.setup();
renderWithClient(<ProductDashboard />);
const picker = await screen.findByLabelText('Product');
await screen.findByRole('option', { name: 'Core SupportHub Platform' });
await user.selectOptions(picker, 'CORE_PLATFORM');
expect(await screen.findByText('Product not found.')).toBeInTheDocument();
});
});
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SupportDashboard } from '@/features/reports/support-dashboard';
import { getSupportDashboard } from '@/lib/api/reports';
import type { SupportDashboardDTO } from '@/lib/api/types';
vi.mock('@/lib/api/reports', () => ({
getSupportDashboard: vi.fn(),
}));
function renderWithClient(ui: React.ReactElement) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
const dashboard: SupportDashboardDTO = {
generatedAt: '2026-09-09T10:00:00.000Z',
range: { from: '2026-09-09T00:00:00.000Z', to: '2026-09-09T10:00:00.000Z' },
workloadByAgent: [
{ agentId: 'agent-1', openAssignments: 13 },
{ agentId: 'agent-2', openAssignments: 13 },
],
slaAtRisk: 1,
slaBreached: 2,
escalationCount: 3,
averageResponseSeconds: 0,
averageResolutionSeconds: null,
};
describe('SupportDashboard', () => {
beforeEach(() => {
vi.mocked(getSupportDashboard).mockReset();
});
it('renders SLA-at-risk and SLA-breached as visually distinct figures', async () => {
vi.mocked(getSupportDashboard).mockResolvedValue(dashboard);
renderWithClient(<SupportDashboard />);
const atRisk = await screen.findByText('SLA at risk');
const breached = screen.getByText('SLA breached');
const atRiskValue = atRisk.closest('[class*="rounded"]')?.querySelector('span.text-2xl');
const breachedValue = breached.closest('[class*="rounded"]')?.querySelector('span.text-2xl');
expect(atRiskValue?.textContent).toBe('1');
expect(breachedValue?.textContent).toBe('2');
expect(atRiskValue?.className).not.toBe(breachedValue?.className);
expect(atRiskValue?.className).toContain('amber');
expect(breachedValue?.className).toContain('red');
});
it('renders workload sorted by open assignments, most-loaded first', async () => {
vi.mocked(getSupportDashboard).mockResolvedValue({
...dashboard,
workloadByAgent: [
{ agentId: 'agent-low', openAssignments: 2 },
{ agentId: 'agent-high', openAssignments: 9 },
],
});
renderWithClient(<SupportDashboard />);
await waitFor(() => expect(screen.getByText('agent-high')).toBeInTheDocument());
const labels = screen.getAllByText(/agent-(low|high)/).map((el) => el.textContent);
expect(labels).toEqual(['agent-high', 'agent-low']);
});
it('renders an error state when the query fails', async () => {
vi.mocked(getSupportDashboard).mockRejectedValue(new Error('boom'));
renderWithClient(<SupportDashboard />);
expect(await screen.findByText(/couldn.t load the support dashboard/i)).toBeInTheDocument();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import {
formatDurationSeconds,
formatRate,
formatCount,
NO_DATA_LABEL,
} from '@/lib/format/duration';
describe('formatDurationSeconds', () => {
it('renders the "No data" guard for null', () => {
expect(formatDurationSeconds(null)).toBe(NO_DATA_LABEL);
});
it('renders whole seconds under a minute', () => {
expect(formatDurationSeconds(45)).toBe('45s');
});
it('renders minutes and seconds rounded to whole minutes', () => {
expect(formatDurationSeconds(130)).toBe('2m');
});
it('renders hours and minutes', () => {
expect(formatDurationSeconds(3 * 3600 + 20 * 60)).toBe('3h 20m');
});
it('renders days and hours', () => {
expect(formatDurationSeconds(2 * 86400 + 5 * 3600)).toBe('2d 5h');
});
});
describe('formatRate', () => {
it('renders the "No data" guard for null', () => {
expect(formatRate(null)).toBe(NO_DATA_LABEL);
});
it('renders a fraction as a one-decimal percentage', () => {
expect(formatRate(0.11764705882352941)).toBe('11.8%');
});
it('renders zero as 0.0%', () => {
expect(formatRate(0)).toBe('0.0%');
});
});
describe('formatCount', () => {
it('renders a locale-formatted integer', () => {
expect(formatCount(1234)).toBe('1,234');
});
it('renders zero as 0', () => {
expect(formatCount(0)).toBe('0');
});
});