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:
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> = >85% · <span className="text-red-300">Red</span> = >100%
|
||||
Booked hours ÷ available hours × 100 for the period.
|
||||
<br />
|
||||
Available hours = working days × hours from personal schedule.
|
||||
<br />
|
||||
Proposed rows are only counted when the toggle is enabled.
|
||||
<br />
|
||||
<span className="text-orange-300">Orange</span> = >85% ·{" "}
|
||||
<span className="text-red-300">Red</span> = >100%
|
||||
</span>
|
||||
}
|
||||
width="w-72"
|
||||
@@ -147,34 +255,59 @@ export function ResourceTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500">
|
||||
<span className="inline-flex items-center justify-end">
|
||||
<button type="button" onClick={() => toggleSort("target")} className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort("target")}
|
||||
className="inline-flex items-center gap-0.5 hover:text-gray-700 cursor-pointer"
|
||||
>
|
||||
Target
|
||||
<span className="text-[10px] ml-0.5">{sortKey === "target" ? (sortDir === "asc" ? "▲" : "▼") : <span className="text-gray-300">⇅</span>}</span>
|
||||
<span className="text-[10px] ml-0.5">
|
||||
{sortKey === "target" ? (
|
||||
sortDir === "asc" ? (
|
||||
"▲"
|
||||
) : (
|
||||
"▼"
|
||||
)
|
||||
) : (
|
||||
<span className="text-gray-300">⇅</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
<InfoTooltip content="Chargeability target set by management per resource. Not a computed value." />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{sorted.map((r) => (
|
||||
<tr key={r.id} className={`hover:bg-gray-50 ${r.isOverbooked ? "bg-amber-50" : ""}`}>
|
||||
<td className="px-3 py-2 font-mono text-gray-600">{r.eid}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900">{r.displayName}</td>
|
||||
<td className="px-3 py-2 text-gray-500">{r.chapter ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{r.bookingCount}</td>
|
||||
<tr
|
||||
key={r.id}
|
||||
className={`transition hover:bg-gray-50 dark:hover:bg-gray-800/60 ${r.isOverbooked ? "bg-amber-50 dark:bg-amber-950/20" : ""}`}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-gray-600 dark:text-gray-300">{r.eid}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
{r.displayName}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">{r.chapter ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||||
{r.bookingCount}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}>
|
||||
<span
|
||||
className={`font-semibold ${r.utilizationPercent > 100 ? "text-red-600" : r.utilizationPercent > 85 ? "text-orange-600" : "text-green-700"}`}
|
||||
>
|
||||
{r.utilizationPercent}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500">{r.chargeabilityTarget}%</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400">
|
||||
{r.chargeabilityTarget}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-gray-400">No resources found.</div>
|
||||
<div className="py-8 text-center text-sm text-gray-400">No resources found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,15 +5,25 @@ import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
info,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
info?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-gray-500 flex items-center">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900/70">
|
||||
<span className="flex items-center text-xs font-semibold uppercase tracking-[0.18em] text-gray-500">
|
||||
{label}
|
||||
{info && <InfoTooltip content={info} />}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-gray-900">{value}</span>
|
||||
{sub && <span className="text-xs text-gray-400">{sub}</span>}
|
||||
<span className="mt-2 text-2xl font-semibold text-gray-900 dark:text-gray-50">{value}</span>
|
||||
{sub && <span className="mt-1 text-xs text-gray-500 dark:text-gray-400">{sub}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +39,10 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 h-full animate-pulse">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="rounded-xl bg-gray-100 dark:bg-gray-800 p-4 flex flex-col gap-2">
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-100 p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div className="h-3 w-20 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-7 w-16 bg-gray-300 dark:bg-gray-600 rounded" />
|
||||
<div className="h-2 w-24 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">×</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-xl leading-none text-gray-400 transition hover:text-gray-600 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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">×</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionError(null)}
|
||||
className="text-red-400 hover:text-red-600 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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 & 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 */}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user