diff --git a/apps/web/src/app/(app)/mobile/page.tsx b/apps/web/src/app/(app)/mobile/page.tsx new file mode 100644 index 0000000..833965d --- /dev/null +++ b/apps/web/src/app/(app)/mobile/page.tsx @@ -0,0 +1,9 @@ +import { MobileSummaryClient } from "~/components/mobile/MobileSummaryClient.js"; + +export const metadata = { + title: "CapaKraken — Mobile Summary", +}; + +export default function MobilePage() { + return ; +} diff --git a/apps/web/src/components/mobile/MobileCapacityCard.tsx b/apps/web/src/components/mobile/MobileCapacityCard.tsx new file mode 100644 index 0000000..0d594ac --- /dev/null +++ b/apps/web/src/components/mobile/MobileCapacityCard.tsx @@ -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 ( +
+
+ Team Capacity +
+
+ {/* CSS-only donut */} + + + + + {Math.round(pct)}% + + +
+
+ Resources + + {activeResources} / {totalResources} + +
+ {overbookedCount > 0 && ( +
+ Overbooked + {overbookedCount} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/mobile/MobileProjectCard.tsx b/apps/web/src/components/mobile/MobileProjectCard.tsx new file mode 100644 index 0000000..f6b57ed --- /dev/null +++ b/apps/web/src/components/mobile/MobileProjectCard.tsx @@ -0,0 +1,39 @@ +"use client"; + +import Link from "next/link"; + +const STATUS_BADGE: Record = { + 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 ( + +
{shortCode}
+
+
{name}
+ {allocationsCount !== undefined && ( +
{allocationsCount} allocation{allocationsCount !== 1 ? "s" : ""}
+ )} +
+ + {status.charAt(0) + status.slice(1).toLowerCase().replace("_", " ")} + + + ); +} diff --git a/apps/web/src/components/mobile/MobileSummaryClient.tsx b/apps/web/src/components/mobile/MobileSummaryClient.tsx new file mode 100644 index 0000000..475cc85 --- /dev/null +++ b/apps/web/src/components/mobile/MobileSummaryClient.tsx @@ -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 ( +
+ {/* Top nav bar */} +
+

CapaKraken

+ + Full Dashboard → + +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : ( + <> + {/* Capacity snapshot */} + {overview && ( + + )} + + {/* Open demand alert */} + {openDemandCount > 0 && ( + +
+ {openDemandCount} +
+
+
+ Open Demand +
+
+ {openDemandCount} unfilled role{openDemandCount !== 1 ? "s" : ""} — tap to fill +
+
+ + )} + + {/* Active projects */} +
+
+ + Active Projects + + + See all + +
+ {projects.length === 0 ? ( + + ) : ( +
+ {projects.map((p) => ( + + ))} +
+ )} +
+ + {/* Quick links */} +
+ {( + [ + ["/timeline" as Route, "Timeline"], + ["/bench" as Route, "Bench Board"], + ["/allocations" as Route, "Allocations"], + ["/staffing" as Route, "Staffing"], + ] as [Route, string][] + ).map(([href, label]) => ( + + {label} + + ))} +
+ + )} +
+
+ ); +}