mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [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>
273 lines
8.7 KiB
TypeScript
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 — target: < 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} />;
|
|
}
|