chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
interface ResourceRow {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
chargeabilityTarget: number;
|
||||
bookingCount: number;
|
||||
utilizationPercent: number;
|
||||
isOverbooked: boolean;
|
||||
}
|
||||
|
||||
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const chapter = (config.chapter as string) || "";
|
||||
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 3, 0).toISOString();
|
||||
|
||||
const { data: resources, isLoading } = trpc.resource.listWithUtilization.useQuery(
|
||||
{ chapter: chapter || undefined, startDate, endDate },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const { data: chapterData } = trpc.resource.chapters.useQuery(undefined, { staleTime: 120_000 });
|
||||
const chapters = chapterData ?? [];
|
||||
|
||||
type SortKey = "eid" | "name" | "chapter" | "bookings" | "utilization" | "target";
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-2 pt-1">
|
||||
{/* header row */}
|
||||
<div className="flex gap-3 px-3 py-2">
|
||||
{[40, 120, 80, 60, 60].map((w, i) => (
|
||||
<div key={i} className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded" style={{ width: w }} />
|
||||
))}
|
||||
</div>
|
||||
{/* data rows */}
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex gap-3 px-3 py-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded font-mono" />
|
||||
<div className="h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = (resources ?? []) as unknown as ResourceRow[];
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "eid": return mult * a.eid.localeCompare(b.eid);
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "chapter": return mult * (a.chapter ?? "").localeCompare(b.chapter ?? "");
|
||||
case "bookings": return mult * (a.bookingCount - b.bookingCount);
|
||||
case "utilization": return mult * (a.utilizationPercent - b.utilizationPercent);
|
||||
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Filter */}
|
||||
{chapters.length > 0 && (
|
||||
<select
|
||||
value={chapter}
|
||||
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
|
||||
className="w-40 px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="">All Chapters</option>
|
||||
{chapters.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<span className="inline-flex items-center">
|
||||
<button type="button" onClick={() => toggleSort("eid")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
EID
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "eid" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Employee ID — unique identifier for each resource." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Name
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "name" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("chapter")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Chapter
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "chapter" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("bookings")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Bookings
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "bookings" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Number of non-cancelled allocations in the period (current month + next 3 months)." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("utilization")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Utilization
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "utilization" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
Booked hours ÷ available hours × 100 for the period.<br />
|
||||
Available hours = working days × hours from personal schedule.<br />
|
||||
<span className="text-orange-300">Orange</span> = >85% · <span className="text-red-300">Red</span> = >100%
|
||||
</span>
|
||||
}
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("target")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
Target
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "target" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
</button>
|
||||
<InfoTooltip content="Chargeability target set by management per resource. Not a computed value." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((r) => (
|
||||
<tr key={r.id} className={`hover:bg-gray-50 ${r.isOverbooked ? "bg-amber-50" : ""}`}>
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{r.bookingCount}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}>
|
||||
{r.utilizationPercent}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500">{r.chargeabilityTarget}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No resources found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user