feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -29,7 +29,11 @@ const GridLayout = dynamic(() => import("react-grid-layout").then((m) => m.Respo
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
function renderWidget(type: DashboardWidgetType, config: DashboardWidgetConfig, onConfigChange: (u: Record<string, unknown>) => void) {
|
||||
function renderWidget(
|
||||
type: DashboardWidgetType,
|
||||
config: DashboardWidgetConfig,
|
||||
onConfigChange: (u: Record<string, unknown>) => void,
|
||||
) {
|
||||
const widget = getWidget(type);
|
||||
const Component = widget.component;
|
||||
|
||||
@@ -73,48 +77,59 @@ export function DashboardClient() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Toolbar */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="app-page space-y-6">
|
||||
<div className="app-page-header gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Drag to rearrange, resize from corners</p>
|
||||
<h1 className="app-page-title">Dashboard</h1>
|
||||
<p className="app-page-subtitle mt-1">
|
||||
Drag widgets to rearrange them and resize from the corners.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetLayout}
|
||||
className="px-3 py-2 text-sm text-gray-500 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
className="rounded-xl border border-gray-300 px-3 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{config.widgets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center border-2 border-dashed border-gray-200 rounded-xl">
|
||||
<p className="text-gray-400 text-sm mb-4">No widgets yet. Add your first widget to get started.</p>
|
||||
<div className="app-surface-strong flex flex-col items-center justify-center border-dashed py-24 text-center">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
No widgets on this dashboard yet.
|
||||
</p>
|
||||
<p className="mt-2 max-w-md text-sm text-gray-500">
|
||||
Start with a widget and build a view that matches how your team actually plans and
|
||||
monitors work.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddModalOpen(true)}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||
className="mt-5 rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
|
||||
>
|
||||
+ Add Widget
|
||||
Add Widget
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef}>
|
||||
<div ref={containerRef} className="app-surface overflow-hidden p-3">
|
||||
{(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const AnyGridLayout = GridLayout as any;
|
||||
@@ -128,7 +143,13 @@ export function DashboardClient() {
|
||||
rowHeight={80}
|
||||
compactType={null}
|
||||
preventCollision={false}
|
||||
onLayoutChange={(_: unknown, allLayouts: Record<string, { i: string; x: number; y: number; w: number; h: number }[]>) => onLayoutChange(allLayouts["lg"] ?? [])}
|
||||
onLayoutChange={(
|
||||
_: unknown,
|
||||
allLayouts: Record<
|
||||
string,
|
||||
{ i: string; x: number; y: number; w: number; h: number }[]
|
||||
>,
|
||||
) => onLayoutChange(allLayouts["lg"] ?? [])}
|
||||
draggableHandle=".widget-drag-handle"
|
||||
margin={[12, 12]}
|
||||
>
|
||||
@@ -138,10 +159,8 @@ export function DashboardClient() {
|
||||
title={widget.title ?? getWidget(widget.type).label}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
>
|
||||
{renderWidget(
|
||||
widget.type,
|
||||
widget.config,
|
||||
(update) => updateWidgetConfig(widget.id, update),
|
||||
{renderWidget(widget.type, widget.config, (update) =>
|
||||
updateWidgetConfig(widget.id, update),
|
||||
)}
|
||||
</WidgetContainer>
|
||||
</div>
|
||||
@@ -152,9 +171,7 @@ export function DashboardClient() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addModalOpen && (
|
||||
<AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />
|
||||
)}
|
||||
{addModalOpen && <AddWidgetModal onAdd={addWidget} onClose={() => setAddModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode, type UIEvent } 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";
|
||||
@@ -8,27 +8,116 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
type TopSortKey = "name" | "actual" | "expected";
|
||||
type WatchSortKey = "name" | "actual" | "target";
|
||||
|
||||
type CountryOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ChargeabilityRow = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
chapter: string | null;
|
||||
chargeabilityTarget: number;
|
||||
actualChargeability: number;
|
||||
expectedChargeability: number;
|
||||
};
|
||||
|
||||
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
className="inline-flex min-w-44 items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-xs text-gray-700 shadow-sm transition hover:border-gray-400 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="text-[10px] text-gray-400">{isOpen ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="absolute right-0 z-20 mt-2 w-72 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
const config = _config as { topN?: number; watchlistThreshold?: number };
|
||||
const [includeProposed, setIncludeProposed] = useState(false);
|
||||
const [showDeparted, setShowDeparted] = useState(false);
|
||||
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
||||
const [topSort, setTopSort] = useState<TopSortKey>("actual");
|
||||
const [topDir, setTopDir] = useState<"asc" | "desc">("desc");
|
||||
const [watchSort, setWatchSort] = useState<WatchSortKey>("actual");
|
||||
const [watchDir, setWatchDir] = useState<"asc" | "desc">("asc");
|
||||
const batchSize = Math.max(config.topN ?? 10, 10);
|
||||
const [topVisibleCount, setTopVisibleCount] = useState(batchSize);
|
||||
const [watchVisibleCount, setWatchVisibleCount] = useState(batchSize);
|
||||
|
||||
const { data: countriesData } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
|
||||
const countries = useMemo(
|
||||
() =>
|
||||
((countriesData ?? []) as Array<{ id: string; name: string }>).map((country) => ({
|
||||
id: country.id,
|
||||
name: country.name,
|
||||
})),
|
||||
[countriesData],
|
||||
) as CountryOption[];
|
||||
const selectedCountryLabel = useMemo(() => {
|
||||
if (selectedCountryIds.length === 0) return "Countries: All";
|
||||
if (selectedCountryIds.length === 1) {
|
||||
return `Country: ${countries.find((country) => country.id === selectedCountryIds[0])?.name ?? "1 selected"}`;
|
||||
}
|
||||
return `Countries: ${selectedCountryIds.length} selected`;
|
||||
}, [countries, selectedCountryIds]);
|
||||
|
||||
function toggleTop(key: TopSortKey) {
|
||||
if (topSort === key) setTopDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setTopSort(key); setTopDir(key === "name" ? "asc" : "desc"); }
|
||||
else {
|
||||
setTopSort(key);
|
||||
setTopDir(key === "name" ? "asc" : "desc");
|
||||
}
|
||||
}
|
||||
function toggleWatch(key: WatchSortKey) {
|
||||
if (watchSort === key) setWatchDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setWatchSort(key); setWatchDir(key === "name" ? "asc" : "asc"); }
|
||||
else {
|
||||
setWatchSort(key);
|
||||
setWatchDir(key === "name" ? "asc" : "asc");
|
||||
}
|
||||
}
|
||||
|
||||
const { data, isLoading } = trpc.dashboard.getChargeabilityOverview.useQuery(
|
||||
{ topN: config.topN ?? 10, watchlistThreshold: config.watchlistThreshold ?? 15 },
|
||||
{
|
||||
includeProposed,
|
||||
topN: config.topN ?? 10,
|
||||
watchlistThreshold: config.watchlistThreshold ?? 15,
|
||||
...(selectedCountryIds.length > 0 ? { countryIds: selectedCountryIds } : {}),
|
||||
...(!showDeparted ? { departed: false } : {}),
|
||||
},
|
||||
{ staleTime: 60_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setTopVisibleCount(batchSize);
|
||||
setWatchVisibleCount(batchSize);
|
||||
}, [batchSize, includeProposed, selectedCountryIds, showDeparted]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse flex flex-col gap-3 pt-1">
|
||||
@@ -59,53 +148,158 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
const rawWatch = data?.watchlist ?? [];
|
||||
const month = data?.month ?? "";
|
||||
|
||||
const top = [...rawTop].sort((a, b) => {
|
||||
const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => {
|
||||
const mult = topDir === "asc" ? 1 : -1;
|
||||
switch (topSort) {
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
|
||||
case "expected": return mult * (a.expectedChargeability - b.expectedChargeability);
|
||||
default: return 0;
|
||||
case "name":
|
||||
return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "actual":
|
||||
return mult * (a.actualChargeability - b.actualChargeability);
|
||||
case "expected":
|
||||
return mult * (a.expectedChargeability - b.expectedChargeability);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const watchlist = [...rawWatch].sort((a, b) => {
|
||||
const watchlist = ([...rawWatch] as ChargeabilityRow[]).sort((a, b) => {
|
||||
const mult = watchDir === "asc" ? 1 : -1;
|
||||
switch (watchSort) {
|
||||
case "name": return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "actual": return mult * (a.actualChargeability - b.actualChargeability);
|
||||
case "target": return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
|
||||
default: return 0;
|
||||
case "name":
|
||||
return mult * a.displayName.localeCompare(b.displayName);
|
||||
case "actual":
|
||||
return mult * (a.actualChargeability - b.actualChargeability);
|
||||
case "target":
|
||||
return mult * (a.chargeabilityTarget - b.chargeabilityTarget);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
function TopInd({ k }: { k: TopSortKey }) {
|
||||
return topSort === k
|
||||
? <span className="text-[10px] ml-0.5">{topDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
return topSort === k ? (
|
||||
<span className="text-[10px] ml-0.5">{topDir === "asc" ? "▲" : "▼"}</span>
|
||||
) : (
|
||||
<span className="text-[10px] ml-0.5 text-gray-300">⇅</span>
|
||||
);
|
||||
}
|
||||
function WatchInd({ k }: { k: WatchSortKey }) {
|
||||
return watchSort === k
|
||||
? <span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
|
||||
: <span className="text-[10px] ml-0.5 text-gray-300">⇅</span>;
|
||||
return watchSort === k ? (
|
||||
<span className="text-[10px] ml-0.5">{watchDir === "asc" ? "▲" : "▼"}</span>
|
||||
) : (
|
||||
<span className="text-[10px] ml-0.5 text-gray-300">⇅</span>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleTop = top.slice(0, topVisibleCount);
|
||||
const visibleWatchlist = watchlist.slice(0, watchVisibleCount);
|
||||
|
||||
function handleSectionScroll(
|
||||
event: UIEvent<HTMLElement>,
|
||||
visibleCount: number,
|
||||
totalCount: number,
|
||||
setVisibleCount: (value: number | ((current: number) => number)) => void,
|
||||
) {
|
||||
const element = event.currentTarget;
|
||||
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
|
||||
if (distanceFromBottom > 48 || visibleCount >= totalCount) {
|
||||
return;
|
||||
}
|
||||
setVisibleCount((current) => Math.min(current + batchSize, totalCount));
|
||||
}
|
||||
|
||||
function toggleCountry(countryId: string, checked: boolean) {
|
||||
setSelectedCountryIds((current) => {
|
||||
if (checked) {
|
||||
return current.includes(countryId) ? current : [...current, countryId];
|
||||
}
|
||||
return current.filter((id) => id !== countryId);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-2 overflow-hidden">
|
||||
{month && (
|
||||
<p className="text-xs text-gray-400 px-1 flex-shrink-0 flex items-center gap-1">
|
||||
Period: {month}
|
||||
<InfoTooltip
|
||||
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
|
||||
width="w-72"
|
||||
/>
|
||||
</p>
|
||||
<div className="px-1 flex-shrink-0 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||
Period: {month}
|
||||
<InfoTooltip
|
||||
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
|
||||
width="w-72"
|
||||
/>
|
||||
</p>
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeProposed}
|
||||
onChange={(event) => setIncludeProposed(event.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Include proposed
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDeparted}
|
||||
onChange={(event) => setShowDeparted(event.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Show departed
|
||||
</label>
|
||||
<FilterDropdown label={selectedCountryLabel}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-gray-600">Countries</p>
|
||||
{selectedCountryIds.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCountryIds([])}
|
||||
className="text-[11px] text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Empty selection means all countries are included.
|
||||
</p>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
|
||||
{countries.map((country) => (
|
||||
<label
|
||||
key={country.id}
|
||||
className="flex items-center gap-2 text-xs text-gray-700"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCountryIds.includes(country.id)}
|
||||
onChange={(event) => toggleCountry(country.id, event.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<span>{country.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FilterDropdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top list */}
|
||||
<section className="flex-1 min-h-0 overflow-auto">
|
||||
<section
|
||||
className="flex-1 min-h-0 overflow-auto"
|
||||
onScroll={(event) =>
|
||||
handleSectionScroll(event, topVisibleCount, top.length, setTopVisibleCount)
|
||||
}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
|
||||
Top Chargeability
|
||||
<span className="ml-1 font-normal normal-case text-gray-400">
|
||||
{visibleTop.length}/{top.length}
|
||||
</span>
|
||||
</h3>
|
||||
{top.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-1">No data available.</p>
|
||||
@@ -115,28 +309,43 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="px-2 py-1 text-left font-medium w-6">#</th>
|
||||
<th className="px-2 py-1 text-left font-medium">
|
||||
<button type="button" onClick={() => toggleTop("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Name<TopInd k="name" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTop("name")}
|
||||
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
Name
|
||||
<TopInd k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleTop("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Actual<TopInd k="actual" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTop("actual")}
|
||||
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
Actual
|
||||
<TopInd k="actual" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available working hours this month × 100."
|
||||
content="Actual uses CONFIRMED and ACTIVE bookings on active projects. Turn on 'Include proposed' to also count proposed work and imported TBD planning."
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleTop("expected")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Expected<TopInd k="expected" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTop("expected")}
|
||||
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
Expected
|
||||
<TopInd k="expected" />
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="All non-CANCELLED allocations (including DRAFT projects and PROPOSED status) ÷ available working hours this month × 100."
|
||||
content="Expected includes all non-CANCELLED bookings this month, including draft projects and proposed planning rows."
|
||||
width="w-72"
|
||||
/>
|
||||
</span>
|
||||
@@ -144,7 +353,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{top.map((r, i) => (
|
||||
{visibleTop.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
|
||||
<td className="px-2 py-1 text-gray-800 truncate max-w-[120px]">
|
||||
@@ -154,9 +363,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700">
|
||||
{r.actualChargeability}%
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
{r.expectedChargeability}%
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">{r.expectedChargeability}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -167,9 +374,17 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
<div className="border-t border-gray-100 flex-shrink-0" />
|
||||
|
||||
{/* Watchlist */}
|
||||
<section className="flex-1 min-h-0 overflow-auto">
|
||||
<section
|
||||
className="flex-1 min-h-0 overflow-auto"
|
||||
onScroll={(event) =>
|
||||
handleSectionScroll(event, watchVisibleCount, watchlist.length, setWatchVisibleCount)
|
||||
}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white">
|
||||
Watchlist <span className="font-normal text-gray-400">(below target)</span>
|
||||
<span className="ml-1 font-normal normal-case text-gray-400">
|
||||
{visibleWatchlist.length}/{watchlist.length}
|
||||
</span>
|
||||
</h3>
|
||||
{watchlist.length === 0 ? (
|
||||
<p className="text-xs text-gray-400 px-1">All resources at or near target.</p>
|
||||
@@ -178,22 +393,37 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="px-2 py-1 text-left font-medium">
|
||||
<button type="button" onClick={() => toggleWatch("name")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Name<WatchInd k="name" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleWatch("name")}
|
||||
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
Name
|
||||
<WatchInd k="name" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleWatch("actual")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Actual<WatchInd k="actual" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleWatch("actual")}
|
||||
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
Actual
|
||||
<WatchInd k="actual" />
|
||||
</button>
|
||||
<InfoTooltip content="Actual chargeability this month: CONFIRMED + ACTIVE allocations on non-DRAFT projects ÷ available hours." />
|
||||
<InfoTooltip content="Actual chargeability this month. By default this counts CONFIRMED and ACTIVE bookings on ACTIVE projects; the toggle can also include PROPOSED work." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right font-medium">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleWatch("target")} className="inline-flex items-center hover:text-gray-600 cursor-pointer">
|
||||
Target<WatchInd k="target" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleWatch("target")}
|
||||
className="inline-flex items-center hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
Target
|
||||
<WatchInd k="target" />
|
||||
</button>
|
||||
<InfoTooltip content="Chargeability target set by management. Watchlist shows resources more than 15 percentage points below their target." />
|
||||
</span>
|
||||
@@ -201,7 +431,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{watchlist.map((r) => (
|
||||
{visibleWatchlist.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50">
|
||||
<td className="px-2 py-1 text-gray-800 truncate max-w-[140px]">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
@@ -210,9 +440,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) {
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600">
|
||||
{r.actualChargeability}%
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
{r.chargeabilityTarget}%
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">{r.chargeabilityTarget}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -22,7 +22,10 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -31,12 +34,19 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
{/* 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
|
||||
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
|
||||
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" />
|
||||
@@ -56,17 +66,24 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
totalCostCents: number;
|
||||
totalPersonDays: number;
|
||||
}
|
||||
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ?? []) as ProjectRow[];
|
||||
const list = ((projects as unknown as { projects: ProjectRow[] } | undefined)?.projects ??
|
||||
[]) as ProjectRow[];
|
||||
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case "code": return mult * a.shortCode.localeCompare(b.shortCode);
|
||||
case "name": return mult * a.name.localeCompare(b.name);
|
||||
case "status": return mult * a.status.localeCompare(b.status);
|
||||
case "cost": return mult * (a.totalCostCents - b.totalCostCents);
|
||||
case "personDays": return mult * (a.totalPersonDays - b.totalPersonDays);
|
||||
default: return 0;
|
||||
case "code":
|
||||
return mult * a.shortCode.localeCompare(b.shortCode);
|
||||
case "name":
|
||||
return mult * a.name.localeCompare(b.name);
|
||||
case "status":
|
||||
return mult * a.status.localeCompare(b.status);
|
||||
case "cost":
|
||||
return mult * (a.totalCostCents - b.totalCostCents);
|
||||
case "personDays":
|
||||
return mult * (a.totalPersonDays - b.totalPersonDays);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -79,48 +96,106 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => onConfigChange?.({ search: e.target.value })}
|
||||
className="flex-1 min-w-0 px-2 py-1 text-xs border border-gray-300 rounded-lg"
|
||||
className="app-input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<select
|
||||
value={status ?? ""}
|
||||
onChange={(e) => onConfigChange?.({ status: e.target.value || undefined })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
className="app-select text-xs"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{Object.values(ProjectStatus).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1">
|
||||
<div className="app-data-table flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<thead className="sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500">
|
||||
<button type="button" onClick={() => toggleSort("code")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("code")}
|
||||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
Code
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "code" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
<span className="text-[10px] ml-0.5">
|
||||
{sortKey === "code" ? (
|
||||
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("name")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
<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>
|
||||
<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("status")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("status")}
|
||||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
Status
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "status" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
<span className="text-[10px] ml-0.5">
|
||||
{sortKey === "status" ? (
|
||||
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("cost")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("cost")}
|
||||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
Cost
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "cost" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
<span className="text-[10px] ml-0.5">
|
||||
{sortKey === "cost" ? (
|
||||
sortDir === "asc" ? (
|
||||
"▲"
|
||||
) : (
|
||||
"▼"
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-300">⇅</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<InfoTooltip
|
||||
content="Sum of (resource LCR × hours per day × working days) across all non-cancelled allocations on this project."
|
||||
@@ -130,35 +205,57 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</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("personDays")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("personDays")}
|
||||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
Person Days
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "personDays" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
<span className="text-[10px] ml-0.5">
|
||||
{sortKey === "personDays" ? (
|
||||
sortDir === "asc" ? (
|
||||
"▲"
|
||||
) : (
|
||||
"▼"
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-300">⇅</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<InfoTooltip content="Total working days allocated across all non-cancelled allocations (sum of allocation durations in working days)." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{sorted.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{p.shortCode}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[180px] truncate">{p.name}</td>
|
||||
<tr key={p.id} className="transition hover:bg-gray-50 dark:hover:bg-gray-800/60">
|
||||
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">
|
||||
{p.shortCode}
|
||||
</td>
|
||||
<td className="px-3 py-2 max-w-[180px] truncate font-medium text-gray-900 dark:text-gray-100">
|
||||
{p.name}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded-full text-xs ${STATUS_COLORS[p.status] ?? ""}`}>
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded-full text-xs ${STATUS_COLORS[p.status] ?? ""}`}
|
||||
>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||||
{(p.totalCostCents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{p.totalPersonDays}d</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||||
{p.totalPersonDays}d
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No projects found.</div>
|
||||
<div className="py-8 text-center text-sm text-gray-400">No projects found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,13 +18,14 @@ interface ResourceRow {
|
||||
|
||||
export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const chapter = (config.chapter as string) || "";
|
||||
const [includeProposed, setIncludeProposed] = useState(false);
|
||||
|
||||
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 },
|
||||
{ chapter: chapter || undefined, includeProposed, startDate, endDate },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
@@ -37,7 +38,10 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -46,12 +50,19 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
{/* 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
|
||||
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
|
||||
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" />
|
||||
@@ -68,77 +79,174 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
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;
|
||||
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>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{chapters.length > 0 && (
|
||||
<select
|
||||
value={chapter}
|
||||
onChange={(e) => onConfigChange?.({ chapter: e.target.value })}
|
||||
className="app-select w-44 text-xs"
|
||||
>
|
||||
<option value="">All Chapters</option>
|
||||
{chapters.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label className="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeProposed}
|
||||
onChange={(event) => setIncludeProposed(event.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Include proposed
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1">
|
||||
<div className="app-data-table flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<thead className="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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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)." />
|
||||
<InfoTooltip content="Number of non-cancelled assignments in the period. Proposed rows are only counted when the toggle is enabled." />
|
||||
</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">
|
||||
<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>
|
||||
<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%
|
||||
Booked hours ÷ available hours × 100 for the period.
|
||||
<br />
|
||||
Available hours = working days × hours from personal schedule.
|
||||
<br />
|
||||
Proposed rows are only counted when the toggle is enabled.
|
||||
<br />
|
||||
<span className="text-orange-300">Orange</span> = >85% ·{" "}
|
||||
<span className="text-red-300">Red</span> = >100%
|
||||
</span>
|
||||
}
|
||||
width="w-72"
|
||||
@@ -147,34 +255,59 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</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">
|
||||
<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>
|
||||
<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">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{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>
|
||||
<tr
|
||||
key={r.id}
|
||||
className={`transition hover:bg-gray-50 dark:hover:bg-gray-800/60 ${r.isOverbooked ? "bg-amber-50 dark:bg-amber-950/20" : ""}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">{r.eid}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
{r.displayName}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">{r.chapter ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||||
{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"}`}>
|
||||
<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>
|
||||
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400">
|
||||
{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 className="py-8 text-center text-sm text-gray-400">No resources found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,15 +5,25 @@ import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
info,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
info?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-gray-500 flex items-center">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70">
|
||||
<span className="flex items-center text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">
|
||||
{label}
|
||||
{info && <InfoTooltip content={info} />}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-gray-900">{value}</span>
|
||||
{sub && <span className="text-xs text-gray-400">{sub}</span>}
|
||||
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">{value}</span>
|
||||
{sub && <span className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +39,10 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 h-full animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="rounded-xl bg-gray-100 dark:bg-gray-800 p-4 flex flex-col gap-2">
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-100 p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-7 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-2 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
|
||||
Reference in New Issue
Block a user