feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish

Dashboard: expanded chargeability widget, resource/project table widgets
with sorting and filters, stat cards with formatMoney integration.

Chargeability: new report client with filtering, chargeability-bookings
use case, updated dashboard overview logic.

Dispo import: TBD project handling, parse-dispo-matrix improvements,
stage-dispo-projects resource value scores, new tests.

Estimates: CommercialTermsEditor component, commercial-terms engine
module, expanded estimate schemas and types.

UI: AppShell navigation updates, timeline filter/toolbar enhancements,
role management improvements, signin page redesign, Tailwind/globals
polish, SystemSettings SMTP section, anonymization support.

Tests: new router tests (anonymization, chargeability, effort-rule,
entitlement, estimate, experience-multiplier, notification, resource,
staffing, vacation).

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:29:07 +01:00
parent ad0855902b
commit 625a842d89
74 changed files with 11680 additions and 1583 deletions
@@ -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 */}