feat(mobile): add mobile summary view for 320-428px viewports (Sprint 4c)
Read-only capacity snapshot with utilization donut, top 5 active projects, open demand alert banner, and quick-link grid — single-column card layout optimised for PWA standalone mode. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
interface MobileCapacityCardProps {
|
||||
totalResources: number;
|
||||
activeResources: number;
|
||||
avgUtilizationPct: number;
|
||||
overbookedCount?: number;
|
||||
}
|
||||
|
||||
export function MobileCapacityCard({
|
||||
totalResources,
|
||||
activeResources,
|
||||
avgUtilizationPct,
|
||||
overbookedCount = 0,
|
||||
}: MobileCapacityCardProps) {
|
||||
const pct = Math.min(100, Math.max(0, avgUtilizationPct));
|
||||
const circumference = 2 * Math.PI * 34; // radius = 34
|
||||
const dashOffset = circumference * (1 - pct / 100);
|
||||
const color =
|
||||
pct >= 90 ? "#d97706" : pct >= 70 ? "#059669" : "#6b7280";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-4">
|
||||
Team Capacity
|
||||
</div>
|
||||
<div className="flex items-center gap-5">
|
||||
{/* CSS-only donut */}
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" className="shrink-0">
|
||||
<circle cx="40" cy="40" r="34" fill="none" stroke="#e5e7eb" strokeWidth="8" className="dark:stroke-gray-700" />
|
||||
<circle
|
||||
cx="40"
|
||||
cy="40"
|
||||
r="34"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
transform="rotate(-90 40 40)"
|
||||
/>
|
||||
<text x="40" y="40" textAnchor="middle" dominantBaseline="middle" fontSize="15" fontWeight="700" fill={color}>
|
||||
{Math.round(pct)}%
|
||||
</text>
|
||||
</svg>
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">Resources</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{activeResources} / {totalResources}
|
||||
</span>
|
||||
</div>
|
||||
{overbookedCount > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-amber-600 dark:text-amber-400">Overbooked</span>
|
||||
<span className="font-semibold text-amber-600 dark:text-amber-400">{overbookedCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
ACTIVE: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
DRAFT: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
|
||||
ON_HOLD: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300",
|
||||
COMPLETED: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
|
||||
CANCELLED: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
|
||||
};
|
||||
|
||||
interface MobileProjectCardProps {
|
||||
id: string;
|
||||
shortCode: string;
|
||||
name: string;
|
||||
status: string;
|
||||
allocationsCount?: number;
|
||||
}
|
||||
|
||||
export function MobileProjectCard({ id, shortCode, name, status, allocationsCount }: MobileProjectCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/projects/${id}`}
|
||||
className="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="font-mono text-xs text-gray-500 dark:text-gray-400 w-16 shrink-0">{shortCode}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{name}</div>
|
||||
{allocationsCount !== undefined && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{allocationsCount} allocation{allocationsCount !== 1 ? "s" : ""}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium ${STATUS_BADGE[status] ?? STATUS_BADGE["DRAFT"]}`}>
|
||||
{status.charAt(0) + status.slice(1).toLowerCase().replace("_", " ")}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { MobileCapacityCard } from "./MobileCapacityCard.js";
|
||||
import { MobileProjectCard } from "./MobileProjectCard.js";
|
||||
import { EmptyState } from "~/components/ui/EmptyState.js";
|
||||
|
||||
export function MobileSummaryClient() {
|
||||
const { data: overview, isLoading: overviewLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: projectsData, isLoading: projectsLoading } = (trpc.project.list.useQuery as any)(
|
||||
{ limit: 5, status: "ACTIVE" },
|
||||
{ staleTime: 60_000 },
|
||||
) as { data: { projects: Array<{ id: string; shortCode: string; name: string; status: string }> } | undefined; isLoading: boolean };
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: demandData } = (trpc.dashboard.getDemand.useQuery as any)(
|
||||
undefined,
|
||||
{ staleTime: 60_000 },
|
||||
) as { data: { openDemandCount?: number; openDemands?: unknown[] } | undefined };
|
||||
|
||||
const projects = projectsData?.projects ?? [];
|
||||
const openDemandCount = demandData?.openDemandCount ?? demandData?.openDemands?.length ?? 0;
|
||||
|
||||
const isLoading = overviewLoading || projectsLoading;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Top nav bar */}
|
||||
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-between">
|
||||
<h1 className="text-base font-semibold text-gray-900 dark:text-gray-100">CapaKraken</h1>
|
||||
<Link href="/dashboard" className="text-xs font-medium text-brand-600 dark:text-brand-400">
|
||||
Full Dashboard →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="max-w-[428px] mx-auto px-4 py-5 space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="h-32 shimmer-skeleton rounded-2xl" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Capacity snapshot */}
|
||||
{overview && (
|
||||
<MobileCapacityCard
|
||||
totalResources={overview.totalResources}
|
||||
activeResources={overview.activeResources}
|
||||
avgUtilizationPct={overview.budgetSummary?.avgUtilizationPercent ?? 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Open demand alert */}
|
||||
{openDemandCount > 0 && (
|
||||
<Link
|
||||
href="/timeline"
|
||||
className="flex items-center gap-3 rounded-xl border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/30 px-4 py-3"
|
||||
>
|
||||
<div className="h-8 w-8 shrink-0 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-amber-700 dark:text-amber-300">{openDemandCount}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-amber-800 dark:text-amber-300">
|
||||
Open Demand
|
||||
</div>
|
||||
<div className="text-xs text-amber-700 dark:text-amber-400">
|
||||
{openDemandCount} unfilled role{openDemandCount !== 1 ? "s" : ""} — tap to fill
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Active projects */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Active Projects
|
||||
</span>
|
||||
<Link href="/projects" className="text-xs text-brand-600 dark:text-brand-400">
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<EmptyState title="No active projects" detail="Create a project to get started." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{projects.map((p) => (
|
||||
<MobileProjectCard
|
||||
key={p.id}
|
||||
id={p.id}
|
||||
shortCode={p.shortCode}
|
||||
name={p.name}
|
||||
status={p.status}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick links */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(
|
||||
[
|
||||
["/timeline" as Route, "Timeline"],
|
||||
["/bench" as Route, "Bench Board"],
|
||||
["/allocations" as Route, "Allocations"],
|
||||
["/staffing" as Route, "Staffing"],
|
||||
] as [Route, string][]
|
||||
).map(([href, label]) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-4 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 text-center hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user