d58f121c12
Project names in the health widget now link to /projects/[id] detail page. Hover: brand color transition for visual feedback. Co-Authored-By: claude-flow <ruv@ruv.net>
146 lines
6.3 KiB
TypeScript
146 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo } from "react";
|
|
import Link from "next/link";
|
|
import { trpc } from "~/lib/trpc/client.js";
|
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
|
|
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
|
|
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
|
|
|
|
function healthDot(value: number): string {
|
|
if (value >= 70) return "bg-green-500";
|
|
if (value >= 40) return "bg-amber-400";
|
|
return "bg-red-500";
|
|
}
|
|
|
|
function scoreBadge(score: number): string {
|
|
if (score >= 70) return "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300";
|
|
if (score >= 40) return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
|
|
return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300";
|
|
}
|
|
|
|
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
|
const { clients } = useWidgetFilterOptions();
|
|
|
|
const filters = useMemo<WidgetFilter[]>(
|
|
() => [
|
|
{ type: "search", key: "search", placeholder: "Search project..." },
|
|
{ type: "select", key: "clientId", label: "Client", options: clients },
|
|
],
|
|
[clients],
|
|
);
|
|
|
|
const { data, isLoading } = trpc.dashboard.getProjectHealth.useQuery(
|
|
undefined,
|
|
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
|
);
|
|
|
|
const search = ((config.search as string) ?? "").toLowerCase();
|
|
const clientId = (config.clientId as string) ?? "";
|
|
|
|
const rows = useMemo(() => {
|
|
const all = data ?? [];
|
|
return all.filter((r) => {
|
|
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
|
|
if (clientId && r.clientId !== clientId) return false;
|
|
return true;
|
|
});
|
|
}, [data, search, clientId]);
|
|
|
|
if (isLoading && !data) {
|
|
return (
|
|
<div className="flex flex-col gap-1 pt-1">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3 px-3 py-2">
|
|
<div className="h-3 w-16 shimmer-skeleton rounded" />
|
|
<div className="h-3 flex-1 shimmer-skeleton rounded" />
|
|
<div className="flex gap-1">
|
|
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
|
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
|
<div className="h-4 w-4 shimmer-skeleton rounded-full" />
|
|
</div>
|
|
<div className="h-5 w-10 shimmer-skeleton rounded-full" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
|
<div className="flex items-center justify-center flex-1 text-sm text-gray-400">
|
|
No active projects found.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
|
<div className="overflow-auto flex-1">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
|
Project <InfoTooltip content="Active projects scored across three health dimensions" />
|
|
</th>
|
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
|
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
|
|
</th>
|
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
|
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
|
|
</th>
|
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
|
Score <InfoTooltip content="Composite score: average of Budget, Staffing, and Timeline health (0-100)" />
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
|
{rows.map((row) => (
|
|
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
|
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[160px] truncate">
|
|
<Link href={`/projects/${(row as any).id}`} className="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
|
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
|
|
{row.projectName}
|
|
</Link>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<span
|
|
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
|
|
title={`Budget: ${row.budgetHealth}%`}
|
|
/>
|
|
<span
|
|
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
|
|
title={`Staffing: ${row.staffingHealth}%`}
|
|
/>
|
|
<span
|
|
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
|
|
title={`Timeline: ${row.timelineHealth}%`}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2 text-center">
|
|
<ShoringBadge projectId={(row as any).id} />
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<span
|
|
className={`inline-block px-2 py-0.5 rounded-full font-semibold tabular-nums ${scoreBadge(row.compositeScore)}`}
|
|
>
|
|
{row.compositeScore}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|