Files
roboco/panel/src/components/business/company-scorecard-card.tsx
T
6007f47fc9 [ef7b7cb9] Add Company Scorecard to Business Goals tab (#212)
* [0c7a4732] feat(cockpit): add completed_30d and median_lead_time_hours to delivery summary (#207) (#210)

- Extend DeliverySummary schema with completed_30d: int = 0 and
  median_lead_time_hours: float | None = None fields
- Add TaskService.get_delivery_stats_30d() that queries tasks completed
  in the last 30 days and computes statistics.median of lead times
- Update CockpitService.summary() to source both new keys from
  get_delivery_stats_30d() and include them in the delivery dict
- Update tests: mock new method in _patch(), assert new fields in
  test_summary_aggregates, fix test_route_ok_for_ceo dict, add three
  new unit tests for get_delivery_stats_30d (empty, multi, single)

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [d2647edf] Frontend: Build CompanyScorecard card on Goals tab (#211)

* [12569f37] Extend CockpitSummary type and build CompanyScorecardCard component (#208)

* [12569f37] feat(cockpit): extend CockpitSummary type with completed_30d and median_lead_time_hours

Add optional delivery.completed_30d (number) and top-level
median_lead_time_hours (number | null, optional) to CockpitSummary
interface in panel/src/lib/api/cockpit.ts so the API shape captures
the new backend fields without breaking existing consumers.

* [12569f37] feat(business): add CompanyScorecardCard component

Create panel/src/components/business/company-scorecard-card.tsx
exporting CompanyScorecardCard. The card fetches /cockpit/summary
via useQuery and renders five always-visible sections:

- Delivery: in_flight, blocked, awaiting_ceo, completed_30d tiles
  (all from API response; no hardcoded numbers)
- Spend: 30d spend + projected monthly; muted 'No budget cap set'
  when cap is null; red/destructive styling only when cap is a
  non-null number AND over_budget is true
- Speed: 'X.Xh median — target: < 24h' when value present;
  'No data yet' when null/undefined; '0h' never rendered
- Two stub Objectives with 'Not tracked yet' label, muted text,
  and dashed-border styling — no fabricated numeric values
- Loading: three grouped Skeleton blocks
- Error: OfflineState with title 'Could not load scorecard data'

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [f1f5cded] Integrate CompanyScorecardCard into GoalsTab and pass quality gate (#209)

* [f1f5cded] feat(cockpit): extend CockpitSummary with completed_30d and median_lead_time_hours

Add optional delivery.completed_30d (number) and top-level
median_lead_time_hours (number | null, optional) to CockpitSummary
interface in panel/src/lib/api/cockpit.ts. Backward compatible.

* [f1f5cded] feat(business): add CompanyScorecardCard component

Create panel/src/components/business/company-scorecard-card.tsx
exporting CompanyScorecardCard. Fetches /cockpit/summary via
useQuery and renders five always-visible sections: Delivery (no
hardcoded numbers), Spend (muted 'No budget cap set' when null;
red only when cap set AND over_budget true), Speed (X.Xh median
or 'No data yet'), two stub Objectives with dashed border and
'Not tracked yet' label. Loading: three skeleton groups. Error:
OfflineState 'Could not load scorecard data'.

* [f1f5cded] feat(goals-tab): integrate CompanyScorecardCard into GoalsTab

Import and render CompanyScorecardCard below the charter form in
goals-tab.tsx. The scorecard fetches its own data independently
so all loading/error states are handled per-card. Both cards are
always rendered in the Goals tab.

* [f1f5cded] fix(scorecard-tests): add vitest framework and CompanyScorecardCard test suite

Install vitest + @testing-library/react + @testing-library/jest-dom +
jsdom + @vitest/coverage-v8 as devDependencies in panel/.

Add panel/vitest.config.ts (jsdom env, @/* alias, coverage on
company-scorecard-card.tsx with 80% threshold).

Add panel/src/test/setup.ts (jest-dom matchers).

Update panel/package.json: add test, test:watch, typecheck scripts.

Update panel/eslint.config.mjs: ignore coverage/ directory to keep
lint clean of generated files.

Write panel/src/components/business/__tests__/company-scorecard-card.test.tsx
with 8 tests covering all 7 AC2 scenarios:
- loading skeleton rendered
- OfflineState on error
- OfflineState when data undefined
- delivery counts from mock data
- spend 'No budget cap set' when cap null
- spend destructive styling when cap non-null and over_budget true
- speed 'No data yet' when lead time null
- speed formatted value when lead time present

pnpm lint: 0 errors  pnpm typecheck: 0 errors
pnpm test: 8/8 pass  coverage: stmts 95% branches 90% fns 91% lines 95%

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
2026-06-18 03:33:47 +02:00

273 lines
8.7 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { cockpitApi, type CockpitSummary } from "@/lib/api/cockpit";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
// ---------------------------------------------------------------------------
// Loading skeleton — three grouped skeleton blocks
// ---------------------------------------------------------------------------
function ScorecardSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-40 mb-1" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-6">
{/* Group 1: Delivery + Spend */}
<div className="space-y-3">
<Skeleton className="h-4 w-20" />
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Skeleton className="h-16 rounded-lg" />
<Skeleton className="h-16 rounded-lg" />
<Skeleton className="h-16 rounded-lg" />
<Skeleton className="h-16 rounded-lg" />
</div>
</div>
{/* Group 2: Spend */}
<div className="space-y-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-12 rounded-lg" />
</div>
{/* Group 3: Speed + Objectives */}
<div className="space-y-3">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 rounded-lg" />
<Skeleton className="h-4 w-24 mt-2" />
<Skeleton className="h-10 rounded-lg" />
<Skeleton className="h-10 rounded-lg" />
</div>
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Section header helper
// ---------------------------------------------------------------------------
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{children}
</p>
);
}
// ---------------------------------------------------------------------------
// Delivery section
// ---------------------------------------------------------------------------
interface DeliveryMetricProps {
label: string;
value: number;
}
function DeliveryMetric({ label, value }: DeliveryMetricProps) {
return (
<div className="rounded-lg border bg-card p-3 text-center">
<div className="text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
</div>
);
}
interface DeliverySectionProps {
delivery: CockpitSummary["delivery"];
}
function DeliverySection({ delivery }: DeliverySectionProps) {
return (
<div className="space-y-2">
<SectionLabel>Delivery</SectionLabel>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<DeliveryMetric label="In flight" value={delivery.in_flight} />
<DeliveryMetric label="Blocked" value={delivery.blocked} />
<DeliveryMetric label="Awaiting CEO" value={delivery.awaiting_ceo} />
<DeliveryMetric
label="Done (30 d)"
value={delivery.completed_30d ?? 0}
/>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Spend section
// ---------------------------------------------------------------------------
interface SpendSectionProps {
spend: CockpitSummary["spend"];
}
function SpendSection({ spend }: SpendSectionProps) {
const { monthly_budget_cap_usd, spend_30d_usd, projected_monthly_usd, over_budget } =
spend;
// Red/destructive only when cap is a non-null number AND over_budget is true
const isOverBudget = monthly_budget_cap_usd !== null && over_budget;
return (
<div className="space-y-2">
<SectionLabel>Spend</SectionLabel>
<div className="rounded-lg border p-3 space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">30-day spend</span>
<span className="font-medium tabular-nums">
${spend_30d_usd.toFixed(2)}
</span>
</div>
{projected_monthly_usd !== null && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Projected monthly</span>
<span className="font-medium tabular-nums">
${projected_monthly_usd.toFixed(2)}
</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Monthly cap</span>
{monthly_budget_cap_usd === null ? (
<span className="text-muted-foreground italic">No budget cap set</span>
) : (
<span
className={
isOverBudget
? "font-semibold text-destructive tabular-nums"
: "font-medium tabular-nums"
}
>
${monthly_budget_cap_usd.toFixed(2)}
{isOverBudget && (
<span className="ml-1 text-xs">(over budget)</span>
)}
</span>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Speed section
// ---------------------------------------------------------------------------
interface SpeedSectionProps {
medianLeadTimeHours: number | null | undefined;
}
function SpeedSection({ medianLeadTimeHours }: SpeedSectionProps) {
// Show 'No data yet' when null or undefined. Never render '0h'.
const hasData = medianLeadTimeHours != null;
return (
<div className="space-y-2">
<SectionLabel>Speed</SectionLabel>
<div className="rounded-lg border p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Median lead time</span>
{hasData ? (
<span className="font-medium tabular-nums">
{medianLeadTimeHours.toFixed(1)}h median &mdash; target: &lt;&nbsp;24h
</span>
) : (
<span className="text-muted-foreground italic">No data yet</span>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Stub objectives section
// ---------------------------------------------------------------------------
function StubObjectivesSection() {
const stubs = [
{ id: "obj-1", label: "Revenue growth" },
{ id: "obj-2", label: "Customer retention" },
];
return (
<div className="space-y-2">
<SectionLabel>Objectives</SectionLabel>
<div className="space-y-2">
{stubs.map((stub) => (
<div
key={stub.id}
className="rounded-lg border border-dashed p-3 flex items-center justify-between"
>
<span className="text-sm text-muted-foreground">{stub.label}</span>
<span className="text-xs text-muted-foreground italic">
Not tracked yet
</span>
</div>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Scorecard body — rendered when data is available
// ---------------------------------------------------------------------------
interface ScorecardBodyProps {
data: CockpitSummary;
}
function ScorecardBody({ data }: ScorecardBodyProps) {
return (
<Card>
<CardHeader>
<CardTitle>Company Scorecard</CardTitle>
<CardDescription>Live performance against the charter</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<DeliverySection delivery={data.delivery} />
<SpendSection spend={data.spend} />
<SpeedSection medianLeadTimeHours={data.median_lead_time_hours} />
<StubObjectivesSection />
</CardContent>
</Card>
);
}
// ---------------------------------------------------------------------------
// Public export
// ---------------------------------------------------------------------------
export function CompanyScorecardCard() {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["cockpit-summary"],
queryFn: cockpitApi.summary,
});
if (isLoading) return <ScorecardSkeleton />;
if (isError || !data) {
return (
<OfflineState
title="Could not load scorecard data"
description="The cockpit summary could not be fetched. Check the backend is running."
onRetry={() => void refetch()}
/>
);
}
return <ScorecardBody data={data} />;
}