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:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
File diff suppressed because it is too large Load Diff
@@ -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> = &gt;85% · <span className="text-red-300">Red</span> = &gt;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> = &gt;85% ·{" "}
<span className="text-red-300">Red</span> = &gt;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" />
@@ -23,7 +23,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
{ enabled: showPreview && Boolean(selectedRuleSetId) },
);
const applyMutation = trpc.effortRule.apply.useMutation({
const applyMutation = trpc.effortRule.applyRules.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
@@ -0,0 +1,464 @@
"use client";
import { useEffect, useState } from "react";
import type { CommercialTerms, PaymentMilestone, PricingModel } from "@planarchy/shared";
import {
computeCommercialTermsSummary,
computeMilestoneAmounts,
validatePaymentMilestones,
} from "@planarchy/engine";
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
interface Props {
estimateId: string;
baseCostCents: number;
basePriceCents: number;
baseCurrency: string;
canEdit: boolean;
}
const PRICING_MODELS: Array<{ value: PricingModel; label: string }> = [
{ value: "fixed_price", label: "Fixed Price" },
{ value: "time_and_materials", label: "Time & Materials" },
{ value: "hybrid", label: "Hybrid" },
];
export function CommercialTermsEditor({
estimateId,
baseCostCents,
basePriceCents,
baseCurrency,
canEdit,
}: Props) {
const utils = trpc.useUtils();
const { data, isLoading } = trpc.estimate.getCommercialTerms.useQuery({
estimateId,
});
const updateMutation = trpc.estimate.updateCommercialTerms.useMutation({
onSuccess: async () => {
await utils.estimate.getCommercialTerms.invalidate({ estimateId });
},
});
const [terms, setTerms] = useState<CommercialTerms>({
pricingModel: "fixed_price",
contingencyPercent: 0,
discountPercent: 0,
paymentTermDays: 30,
paymentMilestones: [],
warrantyMonths: 0,
});
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (data?.terms) {
setTerms(data.terms);
setDirty(false);
}
}, [data]);
function update(patch: Partial<CommercialTerms>) {
setTerms((prev) => ({ ...prev, ...patch }));
setDirty(true);
}
function save() {
updateMutation.mutate({
estimateId,
terms,
});
setDirty(false);
}
const summary = computeCommercialTermsSummary({
baseCostCents,
basePriceCents,
terms,
});
const milestoneWarnings = validatePaymentMilestones(terms.paymentMilestones);
const milestoneAmounts = computeMilestoneAmounts(
summary.adjustedPriceCents,
terms.paymentMilestones,
);
if (isLoading) {
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="h-6 w-48 animate-pulse rounded bg-gray-200" />
</div>
);
}
return (
<div className="space-y-6">
{/* Adjusted financials summary */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Cost
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedCostCents, baseCurrency)}
</p>
{summary.contingencyCents > 0 && (
<p className="mt-1 text-xs text-amber-600">
+{formatMoney(summary.contingencyCents, baseCurrency)} contingency
</p>
)}
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Price
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedPriceCents, baseCurrency)}
</p>
{summary.discountCents > 0 && (
<p className="mt-1 text-xs text-red-600">
-{formatMoney(summary.discountCents, baseCurrency)} discount
</p>
)}
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Adjusted Margin
</p>
<p
className={clsx(
"mt-2 text-2xl font-semibold",
summary.adjustedMarginCents >= 0
? "text-emerald-700"
: "text-red-700",
)}
>
{formatMoney(summary.adjustedMarginCents, baseCurrency)}
</p>
<p className="mt-1 text-xs text-gray-500">
{summary.adjustedMarginPercent.toFixed(1)}% of price
</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">
Pricing Model
</p>
<p className="mt-2 text-lg font-semibold text-gray-900">
{PRICING_MODELS.find((m) => m.value === terms.pricingModel)?.label ??
terms.pricingModel}
</p>
{terms.warrantyMonths > 0 && (
<p className="mt-1 text-xs text-gray-500">
{terms.warrantyMonths} mo warranty
</p>
)}
</div>
</div>
{/* Terms editor */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Commercial Terms
</h3>
{canEdit && dirty && (
<button
type="button"
onClick={save}
disabled={updateMutation.isPending}
className="rounded-lg bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{updateMutation.isPending ? "Saving..." : "Save"}
</button>
)}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Pricing Model */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Pricing Model
</label>
<select
value={terms.pricingModel}
onChange={(e) =>
update({ pricingModel: e.target.value as PricingModel })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
>
{PRICING_MODELS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
{/* Contingency % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Contingency %
</label>
<input
type="number"
min={0}
max={100}
step={0.5}
value={terms.contingencyPercent}
onChange={(e) =>
update({ contingencyPercent: parseFloat(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Discount % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Discount %
</label>
<input
type="number"
min={0}
max={100}
step={0.5}
value={terms.discountPercent}
onChange={(e) =>
update({ discountPercent: parseFloat(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Payment Terms */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Payment Terms (days)
</label>
<input
type="number"
min={0}
max={365}
value={terms.paymentTermDays}
onChange={(e) =>
update({ paymentTermDays: parseInt(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Warranty */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Warranty (months)
</label>
<input
type="number"
min={0}
max={60}
value={terms.warrantyMonths}
onChange={(e) =>
update({ warrantyMonths: parseInt(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
</div>
{/* Notes */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Notes
</label>
<textarea
value={terms.notes ?? ""}
onChange={(e) =>
update({ notes: e.target.value || null })
}
disabled={!canEdit}
rows={2}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
placeholder="Additional commercial notes..."
/>
</div>
</div>
{/* Payment Milestones */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Payment Milestones
</h3>
{canEdit && (
<button
type="button"
onClick={() =>
update({
paymentMilestones: [
...terms.paymentMilestones,
{ label: "", percent: 0 },
],
})
}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
+ Add milestone
</button>
)}
</div>
{milestoneWarnings.length > 0 && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
<ul className="space-y-1">
{milestoneWarnings.map((w, i) => (
<li key={i} className="text-xs text-amber-700">
{w}
</li>
))}
</ul>
</div>
)}
{terms.paymentMilestones.length === 0 ? (
<p className="text-sm text-gray-400">
No payment milestones defined.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Milestone</th>
<th className="px-3 py-2 text-right font-medium w-24">%</th>
<th className="px-3 py-2 text-right font-medium">Amount</th>
<th className="px-3 py-2 font-medium w-36">Due Date</th>
{canEdit && (
<th className="pl-3 py-2 font-medium w-12" />
)}
</tr>
</thead>
<tbody>
{terms.paymentMilestones.map((ms, idx) => {
const amount = milestoneAmounts[idx];
return (
<tr key={idx} className="border-b border-gray-100">
<td className="py-2 pr-3">
{canEdit ? (
<input
type="text"
value={ms.label}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = { ...ms, label: e.target.value };
update({ paymentMilestones: updated });
}}
className="w-full rounded border border-gray-200 px-2 py-1 text-sm"
placeholder="e.g. Kickoff"
/>
) : (
<span className="text-gray-900">{ms.label}</span>
)}
</td>
<td className="px-3 py-2 text-right">
{canEdit ? (
<input
type="number"
min={0}
max={100}
step={1}
value={ms.percent}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = {
...ms,
percent: parseFloat(e.target.value) || 0,
};
update({ paymentMilestones: updated });
}}
className="w-20 rounded border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
) : (
<span className="tabular-nums text-gray-700">
{ms.percent}%
</span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{amount
? formatMoney(amount.amountCents, baseCurrency)
: "—"}
</td>
<td className="px-3 py-2">
{canEdit ? (
<input
type="date"
value={ms.dueDate ?? ""}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = {
...ms,
dueDate: e.target.value || null,
};
update({ paymentMilestones: updated });
}}
className="rounded border border-gray-200 px-2 py-1 text-sm"
/>
) : (
<span className="text-gray-700">
{ms.dueDate ?? "—"}
</span>
)}
</td>
{canEdit && (
<td className="pl-3 py-2 text-center">
<button
type="button"
onClick={() => {
const updated = terms.paymentMilestones.filter(
(_, i) => i !== idx,
);
update({ paymentMilestones: updated });
}}
className="text-red-400 hover:text-red-600 text-xs"
title="Remove"
>
X
</button>
</td>
)}
</tr>
);
})}
{/* Total row */}
<tr className="border-t-2 border-gray-300 font-semibold">
<td className="py-2 pr-3 text-gray-900">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{terms.paymentMilestones
.reduce((sum, m) => sum + m.percent, 0)
.toFixed(1)}
%
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{formatMoney(
milestoneAmounts.reduce((s, a) => s + a.amountCents, 0),
baseCurrency,
)}
</td>
<td />
{canEdit && <td />}
</tr>
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+123 -66
View File
@@ -1,5 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { signOut } from "next-auth/react";
import Link from "next/link";
import type { Route } from "next";
@@ -11,38 +12,83 @@ import { ThemeProvider } from "./ThemeProvider.js";
import { NotificationBell } from "../notifications/NotificationBell.js";
import { NavProgressBar } from "~/components/ui/NavProgressBar.js";
function IconFrame({ children }: { children: ReactNode }) {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/60 bg-white/80 text-slate-600 shadow-sm dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
{children}
</span>
);
}
function DashboardIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 13h6V5H4v8zm10 6h6V5h-6v14zM4 19h6v-2H4v2zm0-4h6v-2H4v2zm10 4h6v-6h-6v6z" /></svg>;
}
function ResourcesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2m18 0v-2a4 4 0 00-3-3.87M14 3.13a4 4 0 010 7.75M12 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>;
}
function ProjectsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 7h18M7 3v4m10-4v4M5 11h14v8H5z" /></svg>;
}
function EstimatesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7h8M8 11h4m-4 4h8M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg>;
}
function AllocationsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 7V3m8 4V3M4 11h16M5 5h14a1 1 0 011 1v13a1 1 0 01-1 1H5a1 1 0 01-1-1V6a1 1 0 011-1z" /></svg>;
}
function TimelineIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 6h16M4 12h10M4 18h7m9-8h-4m4 6h-7" /></svg>;
}
function StaffingIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 20l9-5-9-5-9 5 9 5zm0-10l9-5-9-5-9 5 9 5zm0 10v-10" /></svg>;
}
function VacationIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M4 19c3-4 6-6 8-6s5 2 8 6M7 12c.8-2.5 2.5-4 5-4s4.2 1.5 5 4M12 8V4" /></svg>;
}
function RolesIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M7 7h10v10H7zM4 4h4m8 0h4m-4 16h4M4 20h4" /></svg>;
}
function SkillsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 3l2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.2 6.5 20.2l1-6.2L3 9.6l6.2-.9L12 3z" /></svg>;
}
function ChargeabilityIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5 17l4-4 3 3 7-8M19 19H5V5" /></svg>;
}
function AdminIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
}
const allNavItems = [
{ href: "/dashboard", label: "Dashboard", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/resources", label: "Resources", icon: "👥", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: "📋", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/estimates", label: "Estimates", icon: "🧮", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: "📅", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/timeline", label: "Timeline", icon: "🗓️", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/staffing", label: "Staffing", icon: "🎯", roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations", label: "Vacations", icon: "🏖️", roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations/my", label: "My Vacations", icon: "🌴", roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: "🏷️", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/skills", label: "Skills Analytics", icon: "📈", roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: "📊", roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/dashboard", label: "Dashboard", icon: <DashboardIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/estimates", label: "Estimates", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations", label: "Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER"] },
{ href: "/vacations/my", label: "My Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
];
const adminNavItems = [
{ href: "/admin/blueprints", label: "Blueprints", icon: "🏗️" },
{ href: "/admin/countries", label: "Countries", icon: "🌍" },
{ href: "/admin/org-units", label: "Org Units", icon: "🏢" },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: "📊" },
{ href: "/admin/clients", label: "Clients", icon: "🏦" },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: "💲" },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: "📐" },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: "📈" },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: "📶" },
{ href: "/admin/users", label: "Users", icon: "👤" },
{ href: "/admin/settings", label: "Settings", icon: "⚙️" },
{ href: "/admin/skill-import", label: "Skill Import", icon: "📥" },
{ href: "/admin/blueprints", label: "Blueprints", icon: <AdminIcon /> },
{ href: "/admin/countries", label: "Countries", icon: <AdminIcon /> },
{ href: "/admin/org-units", label: "Org Units", icon: <AdminIcon /> },
{ href: "/admin/utilization-categories", label: "Util. Categories", icon: <AdminIcon /> },
{ href: "/admin/clients", label: "Clients", icon: <AdminIcon /> },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: <AdminIcon /> },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: <AdminIcon /> },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: <AdminIcon /> },
{ href: "/admin/management-levels", label: "Mgmt Levels", icon: <AdminIcon /> },
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
];
const managerNavItems = [
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: "🏖️" },
{ href: "/admin/vacations", label: "Vacation Mgmt", icon: <VacationIcon /> },
];
function Sidebar({ userRole }: { userRole: string }) {
@@ -55,38 +101,45 @@ function Sidebar({ userRole }: { userRole: string }) {
return (
<>
<nav className="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0">
<nav className="w-72 shrink-0 border-r border-white/60 bg-white/80 backdrop-blur-xl dark:border-slate-800 dark:bg-slate-950/75 flex flex-col">
{/* Logo */}
<div className="p-6 border-b border-gray-200 dark:border-gray-800">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
</h1>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">Resource Planning</p>
<div className="border-b border-gray-200/80 px-6 py-6 dark:border-slate-800">
<div className="inline-flex items-center gap-3 rounded-2xl border border-brand-200/70 bg-gradient-to-br from-white to-brand-50 px-4 py-3 shadow-sm dark:border-brand-900/50 dark:from-slate-950 dark:to-slate-900">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-600 text-white shadow-lg shadow-brand-600/25">
<DashboardIcon />
</div>
<div>
<h1 className="font-display text-xl font-semibold text-gray-900 dark:text-gray-50">
Pl<span className="text-brand-600">anarchy</span>
</h1>
<p className="text-xs uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">Resource Planning</p>
</div>
</div>
</div>
{/* Nav links */}
<div className="flex-1 p-4 space-y-1 overflow-y-auto">
<div className="flex-1 space-y-1 overflow-y-auto px-4 py-5">
{visibleNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
<span>{item.icon}</span>
{item.label}
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
{showManagerSection && (
<>
<div className="pt-3 pb-1">
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
<span className="px-3 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
<div className="pb-1 pt-4">
<div className="border-t border-gray-200 pt-4 dark:border-slate-800">
<span className="px-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
{showAdmin ? "Admin" : "Management"}
</span>
</div>
@@ -96,14 +149,14 @@ function Sidebar({ userRole }: { userRole: string }) {
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
<span>{item.icon}</span>
{item.label}
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
{managerNavItems.map((item) => (
@@ -111,14 +164,14 @@ function Sidebar({ userRole }: { userRole: string }) {
key={item.href}
href={item.href as Route}
className={clsx(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(item.href)
? "bg-brand-50 dark:bg-brand-900/30 text-brand-700 dark:text-brand-400"
: "text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800",
? "bg-gradient-to-r from-brand-50 to-brand-100/70 text-brand-800 shadow-sm ring-1 ring-brand-200/70 dark:from-brand-900/30 dark:to-brand-800/20 dark:text-brand-200 dark:ring-brand-900/40"
: "text-gray-700 hover:bg-gray-100/90 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-slate-900 dark:hover:text-white",
)}
>
<span>{item.icon}</span>
{item.label}
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
</>
@@ -126,31 +179,35 @@ function Sidebar({ userRole }: { userRole: string }) {
</div>
{/* Bottom actions */}
<div className="p-4 border-t border-gray-200 dark:border-gray-800 space-y-1">
<div className="flex items-center gap-2 px-3 py-1">
<div className="space-y-1 border-t border-gray-200/80 p-4 dark:border-slate-800">
<div className="flex items-center gap-2 px-3 py-2">
<NotificationBell />
<span className="text-xs text-gray-400 dark:text-gray-500">Notifications</span>
<span className="text-xs uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">Notifications</span>
</div>
<button
type="button"
onClick={() => setPrefsOpen(true)}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Preferences
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</IconFrame>
<span>Preferences</span>
</button>
<button
type="button"
onClick={() => void signOut({ callbackUrl: "/auth/signin" })}
className="w-full flex items-center gap-3 px-3 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-100/90 dark:text-gray-300 dark:hover:bg-slate-900"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
<IconFrame>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</IconFrame>
<span>Sign out</span>
</button>
</div>
</nav>
@@ -166,9 +223,9 @@ export function AppShell({ children, userRole = "USER" }: { children: React.Reac
<Suspense>
<NavProgressBar />
</Suspense>
<div className="flex h-screen bg-gray-50 dark:bg-gray-950">
<div className="flex h-screen bg-transparent">
<Sidebar userRole={userRole} />
<main className="flex-1 overflow-auto">{children}</main>
<main className="flex-1 overflow-auto bg-transparent">{children}</main>
</div>
</ThemeProvider>
);
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string {
@@ -21,16 +22,20 @@ function relativeTime(date: Date): string {
export function NotificationBell() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
const utils = trpc.useUtils();
const { data: unreadCount = 0 } = trpc.notification.unreadCount.useQuery(undefined, {
enabled: isAuthenticated,
refetchInterval: 30_000,
retry: false,
});
const { data: notifications = [] } = trpc.notification.list.useQuery(
{ limit: 20 },
{ enabled: open },
{ enabled: open && isAuthenticated, retry: false },
);
const markRead = trpc.notification.markRead.useMutation({
@@ -53,10 +58,12 @@ export function NotificationBell() {
}, [open]);
function handleMarkAllRead() {
if (!isAuthenticated) return;
markRead.mutate({});
}
function handleMarkOne(id: string) {
if (!isAuthenticated) return;
markRead.mutate({ id });
}
@@ -199,6 +199,8 @@ export function ChargeabilityReportClient() {
const [orgUnitId, setOrgUnitId] = useState<string>("");
const [mgmtGroupId, setMgmtGroupId] = useState<string>("");
const [countryId, setCountryId] = useState<string>("");
const [includeProposed, setIncludeProposed] = useState(false);
const [nameSearch, setNameSearch] = useState("");
const [groupBy, setGroupBy] = useState<GroupByField>("none");
const [expandedResource, setExpandedResource] = useState<string | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
@@ -215,6 +217,7 @@ export function ChargeabilityReportClient() {
...(orgUnitId ? { orgUnitId } : {}),
...(mgmtGroupId ? { managementLevelGroupId: mgmtGroupId } : {}),
...(countryId ? { countryId } : {}),
includeProposed,
},
{ placeholderData: (prev) => prev },
);
@@ -226,12 +229,32 @@ export function ChargeabilityReportClient() {
return items.filter((u: { level: number }) => u.level === 7);
}, [orgUnitsQuery.data]);
const filteredResources = useMemo(() => {
if (!data) return [];
const query = nameSearch.trim().toLowerCase();
if (!query) return data.resources;
return data.resources.filter((resource) =>
resource.displayName.toLowerCase().includes(query) ||
resource.eid.toLowerCase().includes(query),
);
}, [data, nameSearch]);
const filteredGroupTotals = useMemo(() => {
if (!data) return [];
return computeGroupMonthTotals(filteredResources, data.monthKeys);
}, [data, filteredResources]);
const averageTarget = filteredGroupTotals[0]?.target ?? 0;
const averageChargeability = filteredGroupTotals[0]?.chg ?? 0;
const averageGap = filteredGroupTotals[0]?.gap ?? 0;
// Group resources by selected dimension
const groups = useMemo((): GroupSummary[] => {
if (!data || groupBy === "none") return [];
const buckets = new Map<string, ResourceRow[]>();
for (const r of data.resources) {
for (const r of filteredResources) {
let key: string;
switch (groupBy) {
case "orgUnit": key = r.orgUnit ?? "(No Org Unit)"; break;
@@ -250,7 +273,7 @@ export function ChargeabilityReportClient() {
resources,
monthTotals: computeGroupMonthTotals(resources, data.monthKeys),
}));
}, [data, groupBy]);
}, [data, filteredResources, groupBy]);
const toggleGroup = useCallback((label: string) => {
setExpandedGroups((prev) => {
@@ -263,13 +286,13 @@ export function ChargeabilityReportClient() {
const handleExportExcel = useCallback(() => {
if (!data) return;
exportToExcel(data.resources, data.monthKeys, data.groupTotals, groups, groupBy);
}, [data, groups, groupBy]);
exportToExcel(filteredResources, data.monthKeys, filteredGroupTotals, groups, groupBy);
}, [data, filteredGroupTotals, filteredResources, groups, groupBy]);
const handleExportCsv = useCallback(() => {
if (!data) return;
exportToCsv(data.resources, data.monthKeys);
}, [data]);
exportToCsv(filteredResources, data.monthKeys);
}, [data, filteredResources]);
// ─── Render helpers ──────────────────────────────────────────────────────
@@ -277,21 +300,21 @@ export function ChargeabilityReportClient() {
return (
<tr
key={r.id}
className="hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
className="cursor-pointer transition-colors hover:bg-gray-50/90 dark:hover:bg-gray-800/50"
onClick={() => setExpandedResource(expandedResource === r.id ? null : r.id)}
>
<td className="sticky left-0 z-10 bg-white dark:bg-gray-900 px-3 py-2">
<div className="font-medium text-gray-900 dark:text-gray-100">{r.displayName}</div>
<div className="text-xs text-gray-400">
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" | ")}
<td className="sticky left-0 z-10 bg-white/95 px-4 py-3 backdrop-blur dark:bg-slate-950/95">
<div className="font-semibold text-gray-900 dark:text-gray-100">{r.displayName}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{[r.eid, r.country, r.city, r.orgUnit].filter(Boolean).join(" · ")}
</div>
</td>
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{r.fte.toFixed(2)}</td>
<td className="px-2 py-2 text-center text-gray-600 dark:text-gray-400">{pct(r.targetPct)}</td>
<td className="px-3 py-3 text-center font-medium text-gray-600 dark:text-gray-300">{r.fte.toFixed(2)}</td>
<td className="px-3 py-3 text-center font-medium text-gray-600 dark:text-gray-300">{pct(r.targetPct)}</td>
{r.months.map((m) => (
<td key={m.monthKey} className="px-2 py-2 text-center">
<td key={m.monthKey} className="px-3 py-3 text-center">
<div
className={`rounded px-1 ${chgColor(m.chg, r.targetPct)}`}
className={`rounded-xl border border-transparent px-2 py-1 font-semibold ${chgColor(m.chg, r.targetPct)}`}
style={barStyle(m.chg, m.chg >= r.targetPct ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.1)")}
>
{pct(m.chg)}
@@ -305,11 +328,11 @@ export function ChargeabilityReportClient() {
function renderExpandedRow(r: ResourceRow) {
if (expandedResource !== r.id) return null;
return (
<tr key={`${r.id}-detail`} className="bg-gray-50 dark:bg-gray-800/30">
<td className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800/30 px-3 py-2" colSpan={3}>
<div className="text-xs space-y-0.5 text-gray-500 dark:text-gray-400">
<tr key={`${r.id}-detail`} className="bg-gray-50/80 dark:bg-slate-900/70">
<td className="sticky left-0 z-10 bg-gray-50/95 px-4 py-3 backdrop-blur dark:bg-slate-900/95" colSpan={3}>
<div className="space-y-1 text-xs text-gray-500 dark:text-gray-400">
<div>Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-[10px]">
<div className="mt-2 grid grid-cols-7 gap-1 text-[10px] uppercase tracking-[0.16em] text-gray-400 dark:text-gray-500">
<span className="font-medium">Chg</span>
<span className="font-medium">BD</span>
<span className="font-medium">MD&I</span>
@@ -321,15 +344,15 @@ export function ChargeabilityReportClient() {
</div>
</td>
{r.months.map((m) => (
<td key={m.monthKey} className="px-2 py-2 text-center">
<div className="grid grid-cols-1 gap-0.5 text-[10px] text-gray-500 dark:text-gray-400">
<span className="text-green-600">{pct(m.chg)}</span>
<td key={m.monthKey} className="px-3 py-3 text-center">
<div className="grid grid-cols-1 gap-1 rounded-xl bg-white/70 px-2 py-2 text-[10px] text-gray-500 shadow-sm dark:bg-slate-950/40 dark:text-gray-400">
<span className="font-semibold text-green-600 dark:text-green-400">{pct(m.chg)}</span>
<span>{pct(m.bd)}</span>
<span>{pct(m.mdi)}</span>
<span>{pct(m.mo)}</span>
<span>{pct(m.pdr)}</span>
<span className="text-orange-500">{pct(m.absence)}</span>
<span className="text-gray-400">{pct(m.unassigned)}</span>
<span className="text-orange-500 dark:text-orange-300">{pct(m.absence)}</span>
<span className="text-gray-400 dark:text-gray-500">{pct(m.unassigned)}</span>
</div>
</td>
))}
@@ -345,28 +368,28 @@ export function ChargeabilityReportClient() {
onClick?: () => void,
) {
const bg = isOverall
? "bg-brand-50 dark:bg-brand-900/20"
: "bg-indigo-50 dark:bg-indigo-900/20";
? "bg-brand-50/90 dark:bg-brand-900/25"
: "bg-slate-100/90 dark:bg-slate-800/70";
return (
<tr
className={`${bg} font-semibold ${onClick ? "cursor-pointer" : ""}`}
onClick={onClick}
>
<td className={`sticky left-0 z-10 ${bg} px-3 py-2 text-gray-900 dark:text-gray-100`}>
{onClick && <span className="mr-1 text-xs">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
<td className={`sticky left-0 z-10 ${bg} px-4 py-3 text-gray-900 dark:text-gray-100`}>
{onClick && <span className="mr-2 text-xs text-gray-500 dark:text-gray-400">{expandedGroups.has(label) ? "▾" : "▸"}</span>}
{label} ({count} resources)
</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
<td className="px-3 py-3 text-center text-gray-700 dark:text-gray-300">
{monthTotals[0]?.totalFte.toFixed(1)}
</td>
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">
<td className="px-3 py-3 text-center text-gray-700 dark:text-gray-300">
{monthTotals[0] ? pct(monthTotals[0].target) : "—"}
</td>
{monthTotals.map((mt) => (
<td key={mt.monthKey} className="px-2 py-2 text-center">
<div className={chgColor(mt.chg, mt.target)}>{pct(mt.chg)}</div>
<td key={mt.monthKey} className="px-3 py-3 text-center">
<div className={`font-semibold ${chgColor(mt.chg, mt.target)}`}>{pct(mt.chg)}</div>
{mt.gap !== 0 && (
<div className="text-xs text-gray-400">
<div className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{mt.gap > 0 ? "+" : ""}{pct(mt.gap)}
</div>
)}
@@ -379,23 +402,32 @@ export function ChargeabilityReportClient() {
// ─── Main render ─────────────────────────────────────────────────────────
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">
Chargeability Forecast
</h1>
{/* Export buttons */}
<div className="app-page space-y-6">
<div className="app-page-header gap-4">
<div className="space-y-2">
<p className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:border-brand-800/80 dark:bg-brand-900/30 dark:text-brand-200">
Forecast Report
</p>
<div>
<h1 className="app-page-title" data-page-title="true">
Chargeability Forecast
</h1>
<p className="app-page-subtitle">
Review expected utilization, search specific people quickly, and compare monthly chargeability against target.
</p>
</div>
</div>
{data && data.resources.length > 0 && (
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<button
onClick={handleExportExcel}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
className="inline-flex items-center justify-center rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700"
>
Export Excel
</button>
<button
onClick={handleExportCsv}
className="px-3 py-1.5 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
className="inline-flex items-center justify-center rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
Export CSV
</button>
@@ -403,135 +435,191 @@ export function ChargeabilityReportClient() {
)}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 items-end bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">From</label>
<input
type="month"
value={startMonth}
onChange={(e) => setStartMonth(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
{data ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Resources</div>
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{filteredResources.length}</div>
<div className="mt-1 text-sm text-gray-500">People in the current filter scope</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Chargeability</div>
<div className={`mt-2 text-3xl font-semibold ${chgColor(averageChargeability, averageTarget)}`}>{pct(averageChargeability)}</div>
<div className="mt-1 text-sm text-gray-500">Weighted across visible resources</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Target</div>
<div className="mt-2 text-3xl font-semibold text-gray-900 dark:text-gray-100">{pct(averageTarget)}</div>
<div className="mt-1 text-sm text-gray-500">Planning target for the same population</div>
</div>
<div className="app-surface p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Average Gap</div>
<div className={`mt-2 text-3xl font-semibold ${averageGap >= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}>
{averageGap > 0 ? "+" : ""}{pct(averageGap)}
</div>
<div className="mt-1 text-sm text-gray-500">Difference between chargeability and target</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">To</label>
<input
type="month"
value={endMonth}
onChange={(e) => setEndMonth(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
/>
) : null}
<div className="app-toolbar">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
<div>
<label className="app-label">From</label>
<input
type="month"
value={startMonth}
onChange={(e) => setStartMonth(e.target.value)}
className="app-input"
/>
</div>
<div>
<label className="app-label">To</label>
<input
type="month"
value={endMonth}
onChange={(e) => setEndMonth(e.target.value)}
className="app-input"
/>
</div>
<div>
<label className="app-label">Country</label>
<select
value={countryId}
onChange={(e) => setCountryId(e.target.value)}
className="app-select w-full"
>
<option value="">All countries</option>
{(countriesQuery.data ?? []).map((c: { id: string; code: string; name: string }) => (
<option key={c.id} value={c.id}>{c.code} - {c.name}</option>
))}
</select>
</div>
<div>
<label className="app-label">Org Unit</label>
<select
value={orgUnitId}
onChange={(e) => setOrgUnitId(e.target.value)}
className="app-select w-full"
>
<option value="">All org units</option>
{orgUnits.map((u: { id: string; name: string }) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<label className="app-label">Mgmt Level Group</label>
<select
value={mgmtGroupId}
onChange={(e) => setMgmtGroupId(e.target.value)}
className="app-select w-full"
>
<option value="">All groups</option>
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="app-label">Group By</label>
<select
value={groupBy}
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
className="app-select w-full"
>
<option value="none">No grouping</option>
<option value="orgUnit">Org unit</option>
<option value="mgmtGroup">Mgmt level group</option>
<option value="country">Country</option>
</select>
</div>
<div className="md:col-span-2 xl:col-span-1 2xl:col-span-1">
<label className="app-label">Name Search</label>
<input
type="search"
value={nameSearch}
onChange={(e) => setNameSearch(e.target.value)}
placeholder="Search by name or EID"
className="app-input"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Country</label>
<select
value={countryId}
onChange={(e) => setCountryId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{(countriesQuery.data ?? []).map((c: { id: string; code: string; name: string }) => (
<option key={c.id} value={c.id}>{c.code} {c.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Org Unit</label>
<select
value={orgUnitId}
onChange={(e) => setOrgUnitId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{orgUnits.map((u: { id: string; name: string }) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Mgmt Level Group</label>
<select
value={mgmtGroupId}
onChange={(e) => setMgmtGroupId(e.target.value)}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All</option>
{(mgmtGroupsQuery.data ?? []).map((g: { id: string; name: string }) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Group By</label>
<select
value={groupBy}
onChange={(e) => { setGroupBy(e.target.value as GroupByField); setExpandedGroups(new Set()); }}
className="border border-gray-300 dark:border-gray-600 rounded px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="none">No Grouping</option>
<option value="orgUnit">Org Unit</option>
<option value="mgmtGroup">Mgmt Level Group</option>
<option value="country">Country</option>
</select>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<label className="inline-flex items-center gap-3 text-sm font-medium text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={includeProposed}
onChange={(e) => setIncludeProposed(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span>Include proposed work in utilization calculations</span>
</label>
<div className="text-xs uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{groupBy === "none" ? "Flat resource view" : `Grouped by ${groupBy}`}
</div>
</div>
</div>
{/* Report table */}
{reportQuery.isLoading && !data ? (
<div className="text-center py-12 text-gray-500">Loading report...</div>
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">Loading report...</div>
) : reportQuery.error ? (
<div className="text-center py-12 text-red-600">
<div className="app-surface-strong py-16 text-center text-sm text-red-600 dark:text-red-400">
Error: {reportQuery.error.message}
</div>
) : data && data.resources.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
No resources match the current filters.
</div>
) : data && filteredResources.length === 0 ? (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
No resources match the current search.
</div>
) : data ? (
<div className="overflow-x-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-gray-800">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-800 px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400 min-w-[200px]">
Resource
</th>
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">FTE</th>
<th className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 w-16">Target</th>
{data.monthKeys.map((key) => (
<th key={key} className="px-2 py-2 text-center font-medium text-gray-500 dark:text-gray-400 min-w-[80px]">
{formatMonth(key)}
<div className="app-data-table">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr>
<th className="sticky left-0 z-10 min-w-[240px] bg-gray-50/95 px-4 py-3 text-left backdrop-blur dark:bg-gray-800/95">
Resource
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{/* Overall group total */}
{renderGroupTotalsRow("Group Total", data.groupTotals, data.resources.length, true)}
<th className="w-20 px-3 py-3 text-center">FTE</th>
<th className="w-24 px-3 py-3 text-center">Target</th>
{data.monthKeys.map((key) => (
<th key={key} className="min-w-[96px] px-3 py-3 text-center">
{formatMonth(key)}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{renderGroupTotalsRow("Group Total", filteredGroupTotals, filteredResources.length, true)}
{/* Grouped view */}
{groupBy !== "none" ? (
groups.map((g) => (
<React.Fragment key={g.label}>
{renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))}
{expandedGroups.has(g.label) && g.resources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))}
</React.Fragment>
))
) : (
data.resources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))
)}
</tbody>
</table>
{groupBy !== "none" ? (
groups.map((g) => (
<React.Fragment key={g.label}>
{renderGroupTotalsRow(g.label, g.monthTotals, g.resources.length, false, () => toggleGroup(g.label))}
{expandedGroups.has(g.label) && g.resources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))}
</React.Fragment>
))
) : (
filteredResources.map((r) => (
<React.Fragment key={r.id}>
{renderResourceRow(r)}
{renderExpandedRow(r)}
</React.Fragment>
))
)}
</tbody>
</table>
</div>
</div>
) : null}
</div>
@@ -2,7 +2,6 @@
import { useState } from "react";
import Link from "next/link";
import { useSession } from "next-auth/react";
import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { formatDate } from "~/lib/format.js";
@@ -59,10 +58,10 @@ function StatCard({ label, value, sub }: { label: string; value: string | number
export function ResourceDetail({ resourceId }: ResourceDetailProps) {
const [editOpen, setEditOpen] = useState(false);
const [includeProposedChargeability, setIncludeProposedChargeability] = useState(false);
const [uploadOpen, setUploadOpen] = useState(false);
const utils = trpc.useUtils();
const { canViewCosts, canEdit, canViewScores } = usePermissions();
const { data: session } = useSession();
const _resourceQuery = trpc.resource.getById.useQuery({ id: resourceId });
const resource = _resourceQuery.data as unknown as Resource | undefined;
@@ -98,7 +97,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
);
const chargeabilityStatsResult = trpc.resource.getChargeabilityStats.useQuery(
{ resourceId },
{ includeProposed: includeProposedChargeability, resourceId },
{ enabled: canViewCosts, staleTime: 60_000 },
);
const chargeStats = (chargeabilityStatsResult.data as unknown as Array<{
@@ -156,10 +155,9 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
total: number;
} | null;
valueScoreUpdatedAt?: Date | null;
isOwnedByCurrentUser?: boolean;
};
const currentUserEmail = session?.user?.email;
const isOwner = !!(resourceWithMeta.userId && currentUserEmail &&
(resource as unknown as { user?: { email?: string } }).user?.email === currentUserEmail);
const isOwner = resourceWithMeta.isOwnedByCurrentUser === true;
const canUpload = isOwner || canEdit;
// Compute stats
@@ -260,6 +258,19 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
</div>
{/* Stats cards */}
{canViewCosts && (
<div className="flex justify-end">
<label className="flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={includeProposedChargeability}
onChange={(event) => setIncludeProposedChargeability(event.target.checked)}
className="rounded border-gray-300"
/>
Include proposed in chargeability
</label>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{canViewCosts && (
<StatCard
@@ -278,17 +289,21 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
value={`${resource.chargeabilityTarget}%`}
/>
{canViewCosts && (
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
sub="Excl. draft projects"
/>
<StatCard
label="Actual (this month)"
value={chargeStats != null ? `${chargeStats.actualChargeability}%` : "—"}
sub={
includeProposedChargeability
? "Incl. proposed + imported TBD planning"
: "Confirmed + active only"
}
/>
)}
{canViewCosts && (
<StatCard
label="Expected (this month)"
value={chargeStats != null ? `${chargeStats.expectedChargeability}%` : "—"}
sub="Incl. draft projects"
sub="All non-cancelled bookings"
/>
)}
<StatCard
@@ -11,6 +11,9 @@ interface RoleAssignment {
isPrimary: boolean;
}
type CountryWithCities = { id: string; metroCities: { id: string; name: string }[] };
type ManagementGroupWithLevels = { id: string; levels: { id: string; name: string }[] };
interface SkillRow {
skill: string;
proficiency: 1 | 2 | 3 | 4 | 5;
@@ -47,6 +50,8 @@ interface FormState {
managementLevelId: string;
resourceType: string;
chgResponsibility: boolean;
rolledOff: boolean;
departed: boolean;
enterpriseId: string;
clientUnitId: string;
fte: string;
@@ -99,6 +104,8 @@ function resourceToFormState(resource: Resource): FormState {
managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "",
resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE",
chgResponsibility: (resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true,
rolledOff: (resource as unknown as { rolledOff?: boolean }).rolledOff ?? false,
departed: (resource as unknown as { departed?: boolean }).departed ?? false,
enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "",
clientUnitId: (resource as unknown as { clientUnitId?: string | null }).clientUnitId ?? "",
fte: String((resource as unknown as { fte?: number }).fte ?? 1),
@@ -133,6 +140,8 @@ function defaultFormState(): FormState {
managementLevelId: "",
resourceType: "EMPLOYEE",
chgResponsibility: true,
rolledOff: false,
departed: false,
enterpriseId: "",
clientUnitId: "",
fte: "1",
@@ -197,11 +206,13 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
// Derive metro cities from selected country
const selectedCountry = (countries ?? []).find((c) => c.id === form.countryId) as unknown as { id: string; metroCities: { id: string; name: string }[] } | undefined;
const countryRows = (countries ?? []) as unknown as CountryWithCities[];
const selectedCountry = countryRows.find((c) => c.id === form.countryId);
const metroCities = selectedCountry?.metroCities ?? [];
// Derive levels from selected group
const selectedGroup = (mgmtGroups ?? []).find((g) => g.id === form.managementLevelGroupId) as unknown as { id: string; levels: { id: string; name: string }[] } | undefined;
const managementGroups = (mgmtGroups ?? []) as unknown as ManagementGroupWithLevels[];
const selectedGroup = managementGroups.find((g) => g.id === form.managementLevelGroupId);
const mgmtLevels = selectedGroup?.levels ?? [];
const createMutation = trpc.resource.create.useMutation({
@@ -294,6 +305,8 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
resourceType: form.resourceType as ResourceType,
chgResponsibility: form.chgResponsibility,
rolledOff: form.rolledOff,
departed: form.departed,
...(form.enterpriseId.trim() !== "" ? { enterpriseId: form.enterpriseId.trim() } : {}),
...(form.clientUnitId ? { clientUnitId: form.clientUnitId } : {}),
fte: parseFloat(form.fte) || 1,
@@ -628,7 +641,7 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4">
<div className="grid grid-cols-4 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type</label>
<select
@@ -653,6 +666,28 @@ export function ResourceModal({ mode, resource, onClose }: ResourceModalProps) {
Chg Responsibility
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.rolledOff}
onChange={(e) => setField("rolledOff", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Rolled Off
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.departed}
onChange={(e) => setField("departed", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Departed
</label>
</div>
</div>
{/* Section 2: Cost & Chargeability */}
+59 -19
View File
@@ -6,8 +6,16 @@ import { trpc } from "~/lib/trpc/client.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
const PRESET_COLORS = [
"#6366f1", "#8b5cf6", "#ec4899", "#ef4444", "#f97316",
"#eab308", "#22c55e", "#14b8a6", "#06b6d4", "#3b82f6",
"#6366f1",
"#8b5cf6",
"#ec4899",
"#ef4444",
"#f97316",
"#eab308",
"#22c55e",
"#14b8a6",
"#06b6d4",
"#3b82f6",
];
interface RoleModalProps {
@@ -69,33 +77,48 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
}
}
const inputClass = "w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm";
const labelClass = "block text-sm font-medium text-gray-700 mb-1";
const inputClass = "app-input";
const labelClass = "app-label";
return (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/55 py-8 backdrop-blur-sm"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className="bg-white rounded-xl shadow-2xl w-full max-w-md mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
className="mx-4 w-full max-w-md rounded-3xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900"
onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{isEditing ? "Edit Role" : "New Role"}
</h2>
<button type="button" onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
<button
type="button"
onClick={onClose}
className="text-xl leading-none text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-200"
>
&times;
</button>
</div>
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
<div>
<label className={labelClass}>Name <span className="text-red-500">*</span></label>
<label className={labelClass}>
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); setServerError(null); }}
onChange={(e) => {
setName(e.target.value);
setServerError(null);
}}
placeholder="e.g. 3D Artist"
className={inputClass}
maxLength={100}
@@ -117,8 +140,16 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
<div>
<label className={labelClass}>Color</label>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full border-2 border-gray-200 flex-shrink-0" style={{ backgroundColor: color }} />
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/70">
<div className="mb-3 flex items-center gap-3">
<div
className="h-8 w-8 flex-shrink-0 rounded-full border-2 border-gray-200 dark:border-gray-600"
style={{ backgroundColor: color }}
/>
<p className="text-sm text-gray-600 dark:text-gray-300">
Pick a color that stays readable in timelines and chips.
</p>
</div>
<div className="flex flex-wrap gap-2 flex-1">
{PRESET_COLORS.map((c) => (
<button
@@ -137,23 +168,32 @@ export function RoleModal({ role, onClose, onSuccess }: RoleModalProps) {
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border border-gray-300"
className="mt-3 h-8 w-10 cursor-pointer rounded border border-gray-300 bg-transparent dark:border-gray-600"
title="Custom color"
/>
</div>
</div>
{serverError && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{serverError}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<button type="button" onClick={onClose} disabled={isPending} className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 disabled:opacity-50">
<button
type="button"
onClick={onClose}
disabled={isPending}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-100 hover:text-gray-900 disabled:opacity-50 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<button type="submit" disabled={isPending} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50">
<button
type="submit"
disabled={isPending}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50"
>
{isPending ? "Saving…" : "Save"}
</button>
</div>
+95 -42
View File
@@ -77,37 +77,36 @@ export function RolesClient() {
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(showInactive ? [{ label: "Inactive included", onRemove: () => setShowInactive(false) }] : []),
...(showInactive
? [{ label: "Inactive included", onRemove: () => setShowInactive(false) }]
: []),
];
return (
<div className="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="app-page mx-auto max-w-6xl space-y-5">
<div className="app-page-header gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Roles</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage role definitions and resource assignments
</p>
<h1 className="app-page-title">Roles</h1>
<p className="app-page-subtitle mt-1">Manage role definitions and resource assignments</p>
</div>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
>
+ New Role
New Role
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-3">
<div className="app-toolbar flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64"
className="app-input w-64"
/>
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={showInactive}
@@ -125,34 +124,75 @@ export function RolesClient() {
)}
{actionError && (
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 flex items-center justify-between">
<div className="flex items-center justify-between rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{actionError}
<button type="button" onClick={() => setActionError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
<button
type="button"
onClick={() => setActionError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none"
>
&times;
</button>
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="app-data-table">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</th>
<SortableColumnHeader label="Resources" field="resourceRoles" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => r._count.resourceRoles)} align="center" tooltip="Number of resources that currently have this role assigned (active assignments only)." />
<SortableColumnHeader label="Allocations" field="allocations" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => r._count.allocations)} align="center" tooltip="Total number of planning entries that use this role, including open-demand compatibility rows." />
<SortableColumnHeader label="Status" field="isActive" sortField={sortField} sortDir={sortDir} onSort={(f) => toggle(f, (r) => (r.isActive ? 0 : 1))} align="center" tooltip="Active roles are available for assignment. Inactive roles are hidden from pickers but existing assignments remain." />
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Actions</th>
<tr className="border-b border-gray-200 dark:border-gray-700">
<SortableColumnHeader
label="Name"
field="name"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
/>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Description
</th>
<SortableColumnHeader
label="Resources"
field="resourceRoles"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => r._count.resourceRoles)}
align="center"
tooltip="Number of resources that currently have this role assigned (active assignments only)."
/>
<SortableColumnHeader
label="Allocations"
field="allocations"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => r._count.allocations)}
align="center"
tooltip="Total number of planning entries that use this role, including open-demand compatibility rows."
/>
<SortableColumnHeader
label="Status"
field="isActive"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => (r.isActive ? 0 : 1))}
align="center"
tooltip="Active roles are available for assignment. Inactive roles are hidden from pickers but existing assignments remain."
/>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">Loading</td>
<td colSpan={6} className="py-12 text-center text-gray-400">
Loading
</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-400">
<td colSpan={6} className="py-12 text-center text-gray-400">
No roles found. Create one to get started.
</td>
</tr>
@@ -168,7 +208,9 @@ export function RolesClient() {
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }}
/>
<span className="font-medium text-gray-900 dark:text-gray-100">{role.name}</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{role.name}
</span>
</div>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-xs truncate">
@@ -185,11 +227,13 @@ export function RolesClient() {
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
role.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
}`}>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
role.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
}`}
>
{role.isActive ? "Active" : "Inactive"}
</span>
</td>
@@ -230,7 +274,10 @@ export function RolesClient() {
)}
<button
type="button"
onClick={() => { setConfirmDelete(role); setActionError(null); }}
onClick={() => {
setConfirmDelete(role);
setActionError(null);
}}
className="text-xs text-red-500 hover:text-red-700"
>
Delete
@@ -243,7 +290,6 @@ export function RolesClient() {
</table>
</div>
{/* Modals */}
{modalOpen && (
<RoleModal
role={editRole}
@@ -253,26 +299,33 @@ export function RolesClient() {
)}
{confirmDelete && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Delete Role</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
Delete Role
</h3>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to delete <strong>{confirmDelete.name}</strong>?
{(confirmDelete._count.resourceRoles > 0 || confirmDelete._count.allocations > 0) && (
<span className="block mt-1 text-amber-600">
This role is assigned to {confirmDelete._count.resourceRoles} resource(s) and {confirmDelete._count.allocations} allocation(s). Deletion will be blocked.
This role is assigned to {confirmDelete._count.resourceRoles} resource(s) and{" "}
{confirmDelete._count.allocations} allocation(s). Deletion will be blocked.
</span>
)}
</p>
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setConfirmDelete(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmDelete(null)}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 hover:text-gray-800 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
type="button"
onClick={() => deleteMutation.mutate({ id: confirmDelete.id })}
disabled={deleteMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50"
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</button>
+168 -125
View File
@@ -25,138 +25,181 @@ export function StaffingPanel() {
);
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Search Form */}
<div className="lg:col-span-1">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Search Criteria</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Required Skills
</label>
<SkillTagInput
value={requiredSkills}
onChange={setRequiredSkills}
placeholder="Add skill…"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Start Date</label>
<DateInput
value={startDate}
onChange={setStartDate}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">End Date</label>
<DateInput
value={endDate}
onChange={setEndDate}
min={startDate}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Hours per Day</label>
<input
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={1}
max={24}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<button
onClick={() => setSubmitted(true)}
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-medium py-2.5 px-4 rounded-lg transition-colors"
>
Find Matches
</button>
<div className="app-page space-y-6">
<div className="app-page-header gap-4">
<div className="space-y-3">
<span className="inline-flex items-center rounded-full border border-brand-200 bg-brand-50 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-brand-700 dark:border-brand-900/60 dark:bg-brand-900/30 dark:text-brand-200">
Staffing
</span>
<div>
<h1 className="app-page-title">Staffing Suggestions</h1>
<p className="app-page-subtitle max-w-2xl">
Match open work with the strongest available people based on skills, availability, utilization, and cost.
</p>
</div>
</div>
<div className="app-surface max-w-xl p-4">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">How scoring works</p>
<p className="mt-1 text-sm text-gray-500">
Planarchy blends skill fit, free capacity, cost, and current utilization. Add the must-have skills first, then narrow the date window to get cleaner results.
</p>
</div>
</div>
{/* Results */}
<div className="lg:col-span-2">
{isLoading && (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
Finding best matches...
</div>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
<div className="space-y-6">
<div className="app-surface-strong p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Search Criteria</h2>
<p className="mt-1 text-sm text-gray-500">Define the role needs and let the matching engine rank the best candidates.</p>
{suggestions && suggestions.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
No resources found matching your criteria.
</div>
)}
<div className="mt-6 space-y-5">
<div>
<label className="app-label">Required Skills</label>
<SkillTagInput
value={requiredSkills}
onChange={setRequiredSkills}
placeholder="Add skill…"
/>
</div>
{suggestions && suggestions.length > 0 && (
<div className="space-y-3">
{suggestions.map((suggestion, idx) => (
<div key={suggestion.resourceId} className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center font-bold text-brand-700">
#{idx + 1}
</div>
<div>
<div className="font-semibold text-gray-900">{suggestion.resourceName}</div>
<div className="text-xs text-gray-500">{suggestion.eid}</div>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-brand-600">{suggestion.score}</div>
<div className="text-xs text-gray-400">score</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-1">
<div>
<label className="app-label">Start Date</label>
<DateInput
value={startDate}
onChange={setStartDate}
className="app-input"
/>
</div>
<div className="mt-4 grid grid-cols-4 gap-3">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} />
<ScoreBar label="Avail." value={suggestion.scoreBreakdown.availabilityScore} />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
<ScoreBar label="Util." value={suggestion.scoreBreakdown.utilizationScore} />
</div>
<div className="mt-3 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="px-2 py-0.5 bg-green-50 text-green-700 text-xs rounded-full">
{skill}
</span>
))}
{suggestion.missingSkills.map((skill) => (
<span key={skill} className="px-2 py-0.5 bg-red-50 text-red-600 text-xs rounded-full">
{skill} (missing)
</span>
))}
</div>
<div className="mt-3 flex items-center gap-4 text-xs text-gray-500">
<span>
LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} /h
</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
{suggestion.availabilityConflicts.length > 0 && (
<span className="text-yellow-600"> {suggestion.availabilityConflicts.length} conflicts</span>
)}
<div>
<label className="app-label">End Date</label>
<DateInput
value={endDate}
onChange={setEndDate}
min={startDate}
className="app-input"
/>
</div>
</div>
))}
</div>
)}
{!submitted && (
<div className="bg-white rounded-xl border border-gray-200 border-dashed p-12 text-center text-gray-300">
Fill in the criteria and click "Find Matches" to see staffing suggestions.
<div>
<label className="app-label">Hours per Day</label>
<input
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(Number(e.target.value))}
min={1}
max={24}
className="app-input"
/>
</div>
<button
onClick={() => setSubmitted(true)}
className="inline-flex w-full items-center justify-center rounded-xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-700"
>
Find Matches
</button>
</div>
</div>
)}
<div className="app-surface p-5">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Skills</div>
<div className="mt-1 text-gray-600 dark:text-gray-300">Quality of skill overlap with the requested stack.</div>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Availability</div>
<div className="mt-1 text-gray-600 dark:text-gray-300">Conflicts and free capacity during the selected period.</div>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">Cost + Load</div>
<div className="mt-1 text-gray-600 dark:text-gray-300">Cost efficiency and current utilization weighting.</div>
</div>
</div>
</div>
</div>
<div className="space-y-4">
{isLoading && (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
Finding best matches...
</div>
)}
{suggestions && suggestions.length === 0 && (
<div className="app-surface-strong py-16 text-center text-sm text-gray-500">
No resources found matching your criteria.
</div>
)}
{suggestions && suggestions.length > 0 && (
<div className="space-y-4">
{suggestions.map((suggestion, idx) => (
<div key={suggestion.resourceId} className="app-surface p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
{idx + 1}
</div>
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
<div className="text-sm text-gray-500">{suggestion.eid}</div>
</div>
</div>
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200">Match Score</div>
<div className="mt-1 text-3xl font-semibold text-brand-700 dark:text-brand-100">{suggestion.score}</div>
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} />
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
{skill}
</span>
))}
{suggestion.missingSkills.map((skill) => (
<span key={skill} className="rounded-full bg-red-50 px-2.5 py-1 text-xs font-medium text-red-600 dark:bg-red-950/30 dark:text-red-300">
{skill} missing
</span>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} /h</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
{suggestion.availabilityConflicts.length > 0 && (
<span className="font-medium text-amber-600 dark:text-amber-300">
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
</span>
)}
</div>
</div>
))}
</div>
)}
{!submitted && (
<div className="app-surface-strong border-dashed py-20 text-center">
<div className="mx-auto max-w-md">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">No suggestions yet</div>
<p className="mt-2 text-sm text-gray-500">
Add the required skills and date range, then run the search to see ranked staffing matches.
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
@@ -164,15 +207,15 @@ export function StaffingPanel() {
function ScoreBar({ label, value }: { label: string; value: number }) {
return (
<div>
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-3 dark:border-gray-700 dark:bg-gray-900/40">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
<div className="h-2 rounded-full bg-gray-200/80 dark:bg-gray-800">
<div
className="h-full bg-brand-500 rounded-full"
className="h-full rounded-full bg-brand-500"
style={{ width: `${value}%` }}
/>
</div>
<div className="text-xs text-gray-600 mt-0.5">{value}</div>
<div className="mt-2 text-sm font-medium text-gray-700 dark:text-gray-200">{value}</div>
</div>
);
}
@@ -1,7 +1,8 @@
"use client";
import { clsx } from "clsx";
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters {
@@ -38,6 +39,7 @@ export const DEFAULT_FILTERS: TimelineFilters = {
};
interface TimelineFilterProps {
anchorRef: RefObject<HTMLDivElement | null>;
filters: TimelineFilters;
onChange: (filters: TimelineFilters) => void;
isOpen: boolean;
@@ -48,12 +50,12 @@ interface TimelineFilterProps {
function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-50 border border-brand-200 text-brand-700 rounded-full text-xs font-medium">
<span className="inline-flex items-center gap-1 rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:border-brand-800 dark:bg-brand-950/40 dark:text-brand-200">
{label}
<button
type="button"
onClick={onRemove}
className="text-brand-400 hover:text-brand-700 leading-none"
className="leading-none text-brand-400 hover:text-brand-700 dark:hover:text-brand-100"
>
×
</button>
@@ -79,7 +81,9 @@ function EidPicker({
{ staleTime: 15_000 },
);
type ResourceRow = { id: string; eid: string; displayName: string; chapter: string | null };
const suggestions = (data?.resources as ResourceRow[] | undefined ?? []).filter((r) => !selectedEids.includes(r.eid));
const suggestions = ((data?.resources as ResourceRow[] | undefined) ?? []).filter(
(r) => !selectedEids.includes(r.eid),
);
function add(eid: string) {
onChange([...selectedEids, eid]);
@@ -104,26 +108,38 @@ function EidPicker({
type="text"
placeholder="Search by name or EID…"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
className="app-input px-2.5 py-1.5 text-xs"
/>
{open && suggestions.length > 0 && (
<div
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-40 overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((r) => (
<button
key={r.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); add(r.eid); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
onMouseDown={(e) => {
e.preventDefault();
add(r.eid);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="font-mono text-gray-500 w-16 flex-shrink-0">{r.eid}</span>
<span className="text-gray-800 truncate">{r.displayName}</span>
{r.chapter && <span className="text-gray-400 flex-shrink-0">{r.chapter}</span>}
<span className="w-16 flex-shrink-0 font-mono text-gray-500 dark:text-gray-400">
{r.eid}
</span>
<span className="truncate text-gray-800 dark:text-gray-100">{r.displayName}</span>
{r.chapter && (
<span className="flex-shrink-0 text-gray-400 dark:text-gray-500">
{r.chapter}
</span>
)}
</button>
))}
</div>
@@ -146,19 +162,17 @@ function ProjectPicker({
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.project.list.useQuery(
{ search, limit: 200 },
{ staleTime: 15_000 },
);
const { data } = trpc.project.list.useQuery({ search, limit: 200 }, { staleTime: 15_000 });
type ProjectRow = { id: string; shortCode: string; name: string };
const suggestions = (data?.projects as ProjectRow[] | undefined ?? []).filter((p) => !selectedIds.includes(p.id));
const suggestions = ((data?.projects as ProjectRow[] | undefined) ?? []).filter(
(p) => !selectedIds.includes(p.id),
);
// Labels for selected chips — need to resolve names
const { data: allData } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
const { data: allData } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
const projectMap = new Map(
((allData?.projects as ProjectRow[] | undefined) ?? []).map((p) => [p.id, p]),
);
const projectMap = new Map((allData?.projects as ProjectRow[] | undefined ?? []).map((p) => [p.id, p]));
function add(id: string) {
onChange([...selectedIds, id]);
@@ -175,13 +189,7 @@ function ProjectPicker({
<div className="flex flex-wrap gap-1 mb-1.5">
{selectedIds.map((id) => {
const p = projectMap.get(id);
return (
<Chip
key={id}
label={p ? p.name : id}
onRemove={() => remove(id)}
/>
);
return <Chip key={id} label={p ? p.name : id} onRemove={() => remove(id)} />;
})}
</div>
<div className="relative">
@@ -190,24 +198,30 @@ function ProjectPicker({
type="text"
placeholder="Search projects…"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
className="app-input px-2.5 py-1.5 text-xs"
/>
{open && suggestions.length > 0 && (
<div
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-40 overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((p) => (
<button
key={p.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); add(p.id); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
onMouseDown={(e) => {
e.preventDefault();
add(p.id);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="text-gray-800 truncate">{p.name}</span>
<span className="truncate text-gray-800 dark:text-gray-100">{p.name}</span>
</button>
))}
</div>
@@ -219,44 +233,115 @@ function ProjectPicker({
// ─── Main filter panel ────────────────────────────────────────────────────────
export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineFilterProps) {
export function TimelineFilter({
anchorRef,
filters,
onChange,
isOpen,
onClose,
}: TimelineFilterProps) {
const { data: resourceData } = trpc.resource.list.useQuery({ isActive: true, limit: 500 });
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 });
const chapters = [
...new Set(
(resourceData?.resources as Array<{ chapter: string | null }> | undefined ?? []).map((r) => r.chapter).filter(Boolean) as string[],
((resourceData?.resources as Array<{ chapter: string | null }> | undefined) ?? [])
.map((r) => r.chapter)
.filter(Boolean) as string[],
),
].sort();
const updatePanelPosition = useCallback(() => {
const trigger = anchorRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? 320;
const viewportPadding = 16;
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
setPanelPosition({
top: rect.bottom + 8,
left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
});
}, [anchorRef]);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [anchorRef, isOpen, onClose, updatePanelPosition]);
if (!isOpen) return null;
const activeCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
const activeCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
return (
<div className="absolute right-0 top-12 z-30 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl w-80 p-4">
<div className="flex items-center justify-between mb-4">
return createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: panelPosition.top, left: panelPosition.left }}
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Filters
{activeCount > 0 && (
<span className="ml-2 text-xs font-normal text-brand-600">{activeCount} active</span>
<span className="ml-2 text-xs font-normal text-brand-600 dark:text-brand-300">
{activeCount} active
</span>
)}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
</button>
</div>
{/* Zoom level */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Zoom</label>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Zoom
</label>
<div className="flex gap-2">
{(["day", "week", "month"] as const).map((z) => (
<button
type="button"
key={z}
onClick={() => onChange({ ...filters, zoom: z })}
className={clsx(
"flex-1 px-2 py-1.5 text-xs rounded-lg border capitalize",
"flex-1 rounded-xl border px-2 py-1.5 text-xs capitalize transition-colors",
filters.zoom === z
? "bg-brand-50 border-brand-300 text-brand-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
? "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-700 dark:bg-brand-950/40 dark:text-brand-200"
: "border-gray-300 text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800",
)}
>
{z}
@@ -267,7 +352,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* EID filter */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
People (EID)
</label>
<EidPicker
@@ -278,7 +363,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Project filter */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Projects
</label>
<ProjectPicker
@@ -290,12 +375,15 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Chapters */}
{chapters.length > 0 && (
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Chapters
</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
<div className="max-h-32 space-y-1 overflow-y-auto rounded-xl border border-gray-200 p-2 dark:border-gray-700">
{chapters.map((ch) => (
<label key={ch} className="flex items-center gap-2 text-sm cursor-pointer">
<label
key={ch}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={filters.chapters.includes(ch)}
@@ -305,7 +393,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
: filters.chapters.filter((c) => c !== ch);
onChange({ ...filters, chapters: next });
}}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
</label>
@@ -316,73 +404,92 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Visibility toggles */}
<div className="mb-4 space-y-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Visibility</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Visibility
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showWeekends}
onChange={(e) => onChange({ ...filters, showWeekends: e.target.checked })}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">Show weekends</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={!filters.hideCompletedProjects}
onChange={(e) => onChange({ ...filters, hideCompletedProjects: !e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show completed &amp; cancelled
<span className="block text-xs text-gray-400 font-normal">Default set in Preferences</span>
<span className="block text-xs text-gray-400 font-normal">
Default set in Preferences
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showDrafts}
onChange={(e) => onChange({ ...filters, showDrafts: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show draft projects
<span className="block text-xs text-gray-400 font-normal">Shows PROPOSED allocations</span>
<span className="block text-xs text-gray-400 font-normal">
Shows PROPOSED allocations
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showVacations}
onChange={(e) => onChange({ ...filters, showVacations: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show vacation blocks
<span className="block text-xs text-gray-400 font-normal">Approved leave on resource rows</span>
<span className="block text-xs text-gray-400 font-normal">
Approved leave on resource rows
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showPlaceholders}
onChange={(e) => onChange({ ...filters, showPlaceholders: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show open demand
<span className="block text-xs text-gray-400 font-normal">Dashed bars for unassigned staffing demand</span>
<span className="block text-xs text-gray-400 font-normal">
Dashed bars for unassigned staffing demand
</span>
</span>
</label>
</div>
<button
type="button"
onClick={() => onChange(DEFAULT_FILTERS)}
disabled={activeCount === 0 && !filters.showWeekends && filters.hideCompletedProjects && !filters.showDrafts && filters.showVacations && filters.showPlaceholders}
className="w-full text-xs text-gray-500 hover:text-gray-700 underline disabled:opacity-40 disabled:no-underline"
disabled={
activeCount === 0 &&
!filters.showWeekends &&
filters.hideCompletedProjects &&
!filters.showDrafts &&
filters.showVacations &&
filters.showPlaceholders
}
className="w-full text-xs text-gray-500 underline transition hover:text-gray-700 disabled:no-underline disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-200"
>
Reset all filters
</button>
</div>
</div>,
document.body,
);
}
@@ -1,6 +1,7 @@
"use client";
import { clsx } from "clsx";
import { useRef } from "react";
import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
interface TimelineToolbarProps {
@@ -40,35 +41,40 @@ export function TimelineToolbar({
onUndo,
onRedo,
}: TimelineToolbarProps) {
const activeFilterCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
const activeFilterCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
const filterAnchorRef = useRef<HTMLDivElement | null>(null);
return (
<div className="flex items-center justify-between mb-2 gap-3">
<div className="text-sm text-gray-500">
<div className="app-toolbar flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-gray-500 dark:text-gray-400">
{viewMode === "resource"
? `${resourceCount} resources · ${totalAllocCount} allocations`
: `${projectCount} projects`}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
{/* Timeline navigation */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={onNavigateBack}
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
title="Previous 4 weeks"
>
</button>
<button
type="button"
onClick={onNavigateToday}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
Today
</button>
<button
type="button"
onClick={onNavigateForward}
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
title="Next 4 weeks"
>
@@ -79,18 +85,20 @@ export function TimelineToolbar({
{(onUndo ?? onRedo) && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
</button>
<button
type="button"
onClick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z / Ctrl+Y)"
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
</button>
@@ -98,25 +106,27 @@ export function TimelineToolbar({
)}
{/* View mode toggle */}
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-sm">
<div className="flex overflow-hidden rounded-xl border border-gray-300 bg-white text-sm dark:border-gray-600 dark:bg-gray-900">
<button
type="button"
onClick={() => onViewModeChange("resource")}
className={clsx(
"px-3 py-1.5 transition-colors",
"px-3 py-2 transition-colors",
viewMode === "resource"
? "bg-brand-600 text-white"
: "text-gray-600 hover:bg-gray-50",
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Resource view
</button>
<button
type="button"
onClick={() => onViewModeChange("project")}
className={clsx(
"px-3 py-1.5 border-l border-gray-200 transition-colors",
"border-l border-gray-300 px-3 py-2 transition-colors dark:border-gray-600",
viewMode === "project"
? "bg-brand-600 text-white"
: "text-gray-600 hover:bg-gray-50",
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Project view
@@ -124,24 +134,26 @@ export function TimelineToolbar({
</div>
{/* Filter */}
<div className="relative">
<div ref={filterAnchorRef} className="relative">
<button
type="button"
onClick={() => onFilterOpenChange(!filterOpen)}
className={clsx(
"flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border transition-colors",
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-colors",
filterOpen || activeFilterCount > 0
? "bg-brand-50 border-brand-300 text-brand-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
? "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-700 dark:bg-brand-950/40 dark:text-brand-200"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Filter
{activeFilterCount > 0 && (
<span className="w-4 h-4 rounded-full bg-brand-600 text-white text-xs flex items-center justify-center">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-brand-600 text-xs text-white">
{activeFilterCount}
</span>
)}
</button>
<TimelineFilter
anchorRef={filterAnchorRef}
filters={filters}
onChange={onFiltersChange}
isOpen={filterOpen}
@@ -14,11 +14,7 @@ import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
import { TimelineHeader } from "./TimelineHeader.js";
import { TimelineToolbar } from "./TimelineToolbar.js";
import { addDays } from "./utils.js";
import {
HEADER_DAY_HEIGHT,
HEADER_MONTH_HEIGHT,
LABEL_WIDTH,
} from "./timelineConstants.js";
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
import { formatDateShort } from "~/lib/format.js";
import {
TimelineProvider,
@@ -40,11 +36,18 @@ export function TimelineView() {
pushHistoryRef.current = pushHistory;
const [popover, setPopover] = useState<{
allocationId: string; projectId: string; x: number; y: number;
allocationId: string;
projectId: string;
x: number;
y: number;
} | null>(null);
const [newAllocPopover, setNewAllocPopover] = useState<{
resourceId: string; startDate: Date; endDate: Date;
suggestedProjectId: string | null; anchorX: number; anchorY: number;
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
} | null>(null);
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
@@ -53,12 +56,24 @@ export function TimelineView() {
const cellWidthRef = useRef(40);
const {
dragState, allocDragState, rangeState,
shiftPreview, isPreviewLoading, isApplying, isAllocSaving,
onProjectBarMouseDown, onAllocMouseDown, onRowMouseDown,
onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave,
onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart,
onCanvasTouchMove, onCanvasTouchEnd,
dragState,
allocDragState,
rangeState,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
onProjectBarMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
} = useTimelineDrag({
cellWidth: cellWidthRef.current,
onBlockClick: (info) => {
@@ -189,7 +204,14 @@ function TimelineViewContent({
contextResourceIds: string[];
popover: { allocationId: string; projectId: string; x: number; y: number } | null;
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
newAllocPopover: { resourceId: string; startDate: Date; endDate: Date; suggestedProjectId: string | null; anchorX: number; anchorY: number } | null;
newAllocPopover: {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
} | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null;
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
@@ -231,13 +253,8 @@ function TimelineViewContent({
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } = useTimelineLayout(
viewStart,
viewDays,
filters.zoom,
filters.showWeekends,
today,
);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
// Keep cellWidthRef in sync so the drag hook uses the correct value.
cellWidthRef.current = CELL_WIDTH;
@@ -295,8 +312,14 @@ function TimelineViewContent({
const isMac = navigator.platform.toUpperCase().includes("MAC");
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (!modKey) return;
if (e.key === "z" && !e.shiftKey) { e.preventDefault(); void undo(); }
if ((e.key === "z" && e.shiftKey) || e.key === "y") { e.preventDefault(); void redo(); }
if (e.key === "z" && !e.shiftKey) {
e.preventDefault();
void undo();
}
if ((e.key === "z" && e.shiftKey) || e.key === "y") {
e.preventDefault();
void redo();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
@@ -317,8 +340,7 @@ function TimelineViewContent({
};
return (
<div className="flex-1 flex flex-col min-h-0 relative mt-2 mx-4 mb-4">
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */}
<TimelineToolbar
viewMode={viewMode}
@@ -335,23 +357,26 @@ function TimelineViewContent({
onNavigateForward={() => setViewStart((v) => addDays(v, 28))}
canUndo={canUndo}
canRedo={canRedo}
onUndo={() => { void undo(); }}
onRedo={() => { void redo(); }}
onUndo={() => {
void undo();
}}
onRedo={() => {
void redo();
}}
/>
{/* Scrollable canvas */}
<div
ref={scrollContainerRef}
onScroll={handleContainerScroll}
className="flex-1 overflow-auto border border-gray-200 rounded-xl bg-white"
className="app-surface relative flex-1 overflow-auto"
>
{isInitialLoading ? (
<div className="flex items-center justify-center py-20 text-gray-400">
<div className="flex items-center justify-center py-24 text-sm text-gray-500 dark:text-gray-400">
Loading timeline...
</div>
) : (
<div style={{ minWidth: LABEL_WIDTH + totalCanvasWidth }}>
<TimelineHeader
monthGroups={monthGroups}
dates={dates}
@@ -370,7 +395,9 @@ function TimelineViewContent({
onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onTouchMove={(e) => { onCanvasTouchMove(e); }}
onTouchMove={(e) => {
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
@@ -423,15 +450,14 @@ function TimelineViewContent({
/>
)}
</div>
</div>
)}
</div>
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="absolute inset-0 bg-white/40 flex items-center justify-center z-50 rounded-xl pointer-events-none">
<div className="bg-white border border-gray-200 rounded-xl px-5 py-3 shadow-xl text-sm font-medium text-gray-700">
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
@@ -445,10 +471,17 @@ function TimelineViewContent({
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={shiftPreview ?? {
valid: true, deltaCents: 0, wouldExceedBudget: false,
budgetUtilizationAfter: 0, conflictCount: 0, errors: [], warnings: [],
}}
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
@@ -458,20 +491,23 @@ function TimelineViewContent({
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive && allocDragState.daysDelta !== 0 && allocDragState.currentStartDate && allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
</div>
)}
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
@@ -482,9 +518,10 @@ function TimelineViewContent({
>
{(() => {
const end = rangeState.currentDate;
const [s, e] = rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
@@ -497,7 +534,10 @@ function TimelineViewContent({
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => { setPopover(null); setOpenPanelProjectId(pid); }}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
/>
@@ -519,10 +559,7 @@ function TimelineViewContent({
{/* Project side panel */}
{openPanelProjectId && (
<ProjectPanel
projectId={openPanelProjectId}
onClose={() => setOpenPanelProjectId(null)}
/>
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
+2 -2
View File
@@ -8,13 +8,13 @@ interface FilterBarProps {
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
return (
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="app-toolbar mb-4 flex flex-wrap items-center gap-3">
{children}
{hasActiveFilters && onClearFilters && (
<button
type="button"
onClick={onClearFilters}
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 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 text-gray-600 transition hover:border-gray-400 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
>
Clear filters
</button>
@@ -54,10 +54,10 @@ export function NavProgressBar() {
return (
<div
aria-hidden="true"
className="fixed top-0 left-0 right-0 z-[9999] h-0.5 pointer-events-none"
className="pointer-events-none fixed left-0 right-0 top-0 z-[9999] h-1"
>
<div
className="h-full bg-brand-500 transition-all ease-out"
className="h-full rounded-r-full bg-gradient-to-r from-brand-400 via-brand-500 to-brand-600 shadow-[0_0_18px_rgba(var(--accent-500),0.45)] transition-all ease-out"
style={{
width: `${width}%`,
transitionDuration: width === 100 ? "200ms" : "400ms",