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,9 @@
|
|||||||
|
import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "CapaKraken — Mobile Summary",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MobilePage() {
|
||||||
|
return <MobileSummaryClient />;
|
||||||
|
}
|
||||||
@@ -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