Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33de435c91 | ||
|
|
a51b18124b | ||
|
|
a37539794f | ||
|
|
e7e6e853d5 |
+3
-3
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export * from './escalation';
|
||||
export * from './monitoring';
|
||||
export * from './catalog';
|
||||
export * from './knowledge';
|
||||
export * from './reports';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user