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
@@ -1,7 +1,8 @@
"use client";
import { clsx } from "clsx";
import { useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters {
@@ -38,6 +39,7 @@ export const DEFAULT_FILTERS: TimelineFilters = {
};
interface TimelineFilterProps {
anchorRef: RefObject<HTMLDivElement | null>;
filters: TimelineFilters;
onChange: (filters: TimelineFilters) => void;
isOpen: boolean;
@@ -48,12 +50,12 @@ interface TimelineFilterProps {
function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-brand-50 border border-brand-200 text-brand-700 rounded-full text-xs font-medium">
<span className="inline-flex items-center gap-1 rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700 dark:border-brand-800 dark:bg-brand-950/40 dark:text-brand-200">
{label}
<button
type="button"
onClick={onRemove}
className="text-brand-400 hover:text-brand-700 leading-none"
className="leading-none text-brand-400 hover:text-brand-700 dark:hover:text-brand-100"
>
×
</button>
@@ -79,7 +81,9 @@ function EidPicker({
{ staleTime: 15_000 },
);
type ResourceRow = { id: string; eid: string; displayName: string; chapter: string | null };
const suggestions = (data?.resources as ResourceRow[] | undefined ?? []).filter((r) => !selectedEids.includes(r.eid));
const suggestions = ((data?.resources as ResourceRow[] | undefined) ?? []).filter(
(r) => !selectedEids.includes(r.eid),
);
function add(eid: string) {
onChange([...selectedEids, eid]);
@@ -104,26 +108,38 @@ function EidPicker({
type="text"
placeholder="Search by name or EID…"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
className="app-input px-2.5 py-1.5 text-xs"
/>
{open && suggestions.length > 0 && (
<div
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-40 overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((r) => (
<button
key={r.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); add(r.eid); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
onMouseDown={(e) => {
e.preventDefault();
add(r.eid);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="font-mono text-gray-500 w-16 flex-shrink-0">{r.eid}</span>
<span className="text-gray-800 truncate">{r.displayName}</span>
{r.chapter && <span className="text-gray-400 flex-shrink-0">{r.chapter}</span>}
<span className="w-16 flex-shrink-0 font-mono text-gray-500 dark:text-gray-400">
{r.eid}
</span>
<span className="truncate text-gray-800 dark:text-gray-100">{r.displayName}</span>
{r.chapter && (
<span className="flex-shrink-0 text-gray-400 dark:text-gray-500">
{r.chapter}
</span>
)}
</button>
))}
</div>
@@ -146,19 +162,17 @@ function ProjectPicker({
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.project.list.useQuery(
{ search, limit: 200 },
{ staleTime: 15_000 },
);
const { data } = trpc.project.list.useQuery({ search, limit: 200 }, { staleTime: 15_000 });
type ProjectRow = { id: string; shortCode: string; name: string };
const suggestions = (data?.projects as ProjectRow[] | undefined ?? []).filter((p) => !selectedIds.includes(p.id));
const suggestions = ((data?.projects as ProjectRow[] | undefined) ?? []).filter(
(p) => !selectedIds.includes(p.id),
);
// Labels for selected chips — need to resolve names
const { data: allData } = trpc.project.list.useQuery(
{ limit: 500 },
{ staleTime: 60_000 },
const { data: allData } = trpc.project.list.useQuery({ limit: 500 }, { staleTime: 60_000 });
const projectMap = new Map(
((allData?.projects as ProjectRow[] | undefined) ?? []).map((p) => [p.id, p]),
);
const projectMap = new Map((allData?.projects as ProjectRow[] | undefined ?? []).map((p) => [p.id, p]));
function add(id: string) {
onChange([...selectedIds, id]);
@@ -175,13 +189,7 @@ function ProjectPicker({
<div className="flex flex-wrap gap-1 mb-1.5">
{selectedIds.map((id) => {
const p = projectMap.get(id);
return (
<Chip
key={id}
label={p ? p.name : id}
onRemove={() => remove(id)}
/>
);
return <Chip key={id} label={p ? p.name : id} onRemove={() => remove(id)} />;
})}
</div>
<div className="relative">
@@ -190,24 +198,30 @@ function ProjectPicker({
type="text"
placeholder="Search projects…"
value={search}
onChange={(e) => { setSearch(e.target.value); setOpen(true); }}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="w-full border border-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
className="app-input px-2.5 py-1.5 text-xs"
/>
{open && suggestions.length > 0 && (
<div
className="absolute top-full left-0 right-0 z-50 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg max-h-40 overflow-y-auto"
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-40 overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900"
onMouseDown={(e) => e.preventDefault()}
>
{suggestions.map((p) => (
<button
key={p.id}
type="button"
onMouseDown={(e) => { e.preventDefault(); add(p.id); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 flex items-center gap-2"
onMouseDown={(e) => {
e.preventDefault();
add(p.id);
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-gray-50 dark:hover:bg-gray-800"
>
<span className="text-gray-800 truncate">{p.name}</span>
<span className="truncate text-gray-800 dark:text-gray-100">{p.name}</span>
</button>
))}
</div>
@@ -219,44 +233,115 @@ function ProjectPicker({
// ─── Main filter panel ────────────────────────────────────────────────────────
export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineFilterProps) {
export function TimelineFilter({
anchorRef,
filters,
onChange,
isOpen,
onClose,
}: TimelineFilterProps) {
const { data: resourceData } = trpc.resource.list.useQuery({ isActive: true, limit: 500 });
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 });
const chapters = [
...new Set(
(resourceData?.resources as Array<{ chapter: string | null }> | undefined ?? []).map((r) => r.chapter).filter(Boolean) as string[],
((resourceData?.resources as Array<{ chapter: string | null }> | undefined) ?? [])
.map((r) => r.chapter)
.filter(Boolean) as string[],
),
].sort();
const updatePanelPosition = useCallback(() => {
const trigger = anchorRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? 320;
const viewportPadding = 16;
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
setPanelPosition({
top: rect.bottom + 8,
left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
});
}, [anchorRef]);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [anchorRef, isOpen, onClose, updatePanelPosition]);
if (!isOpen) return null;
const activeCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
const activeCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
return (
<div className="absolute right-0 top-12 z-30 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl w-80 p-4">
<div className="flex items-center justify-between mb-4">
return createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: panelPosition.top, left: panelPosition.left }}
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
Filters
{activeCount > 0 && (
<span className="ml-2 text-xs font-normal text-brand-600">{activeCount} active</span>
<span className="ml-2 text-xs font-normal text-brand-600 dark:text-brand-300">
{activeCount} active
</span>
)}
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
</button>
</div>
{/* Zoom level */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Zoom</label>
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Zoom
</label>
<div className="flex gap-2">
{(["day", "week", "month"] as const).map((z) => (
<button
type="button"
key={z}
onClick={() => onChange({ ...filters, zoom: z })}
className={clsx(
"flex-1 px-2 py-1.5 text-xs rounded-lg border capitalize",
"flex-1 rounded-xl border px-2 py-1.5 text-xs capitalize transition-colors",
filters.zoom === z
? "bg-brand-50 border-brand-300 text-brand-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
? "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-700 dark:bg-brand-950/40 dark:text-brand-200"
: "border-gray-300 text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800",
)}
>
{z}
@@ -267,7 +352,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* EID filter */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
People (EID)
</label>
<EidPicker
@@ -278,7 +363,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Project filter */}
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Projects
</label>
<ProjectPicker
@@ -290,12 +375,15 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Chapters */}
{chapters.length > 0 && (
<div className="mb-5">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Chapters
</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
<div className="max-h-32 space-y-1 overflow-y-auto rounded-xl border border-gray-200 p-2 dark:border-gray-700">
{chapters.map((ch) => (
<label key={ch} className="flex items-center gap-2 text-sm cursor-pointer">
<label
key={ch}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={filters.chapters.includes(ch)}
@@ -305,7 +393,7 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
: filters.chapters.filter((c) => c !== ch);
onChange({ ...filters, chapters: next });
}}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
</label>
@@ -316,73 +404,92 @@ export function TimelineFilter({ filters, onChange, isOpen, onClose }: TimelineF
{/* Visibility toggles */}
<div className="mb-4 space-y-2">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Visibility</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Visibility
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showWeekends}
onChange={(e) => onChange({ ...filters, showWeekends: e.target.checked })}
className="rounded border-gray-300"
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">Show weekends</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={!filters.hideCompletedProjects}
onChange={(e) => onChange({ ...filters, hideCompletedProjects: !e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show completed &amp; cancelled
<span className="block text-xs text-gray-400 font-normal">Default set in Preferences</span>
<span className="block text-xs text-gray-400 font-normal">
Default set in Preferences
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showDrafts}
onChange={(e) => onChange({ ...filters, showDrafts: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show draft projects
<span className="block text-xs text-gray-400 font-normal">Shows PROPOSED allocations</span>
<span className="block text-xs text-gray-400 font-normal">
Shows PROPOSED allocations
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showVacations}
onChange={(e) => onChange({ ...filters, showVacations: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show vacation blocks
<span className="block text-xs text-gray-400 font-normal">Approved leave on resource rows</span>
<span className="block text-xs text-gray-400 font-normal">
Approved leave on resource rows
</span>
</span>
</label>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<label className="flex cursor-pointer items-start gap-2 rounded-lg px-2 py-1 text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="checkbox"
checked={filters.showPlaceholders}
onChange={(e) => onChange({ ...filters, showPlaceholders: e.target.checked })}
className="rounded border-gray-300 mt-0.5"
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
Show open demand
<span className="block text-xs text-gray-400 font-normal">Dashed bars for unassigned staffing demand</span>
<span className="block text-xs text-gray-400 font-normal">
Dashed bars for unassigned staffing demand
</span>
</span>
</label>
</div>
<button
type="button"
onClick={() => onChange(DEFAULT_FILTERS)}
disabled={activeCount === 0 && !filters.showWeekends && filters.hideCompletedProjects && !filters.showDrafts && filters.showVacations && filters.showPlaceholders}
className="w-full text-xs text-gray-500 hover:text-gray-700 underline disabled:opacity-40 disabled:no-underline"
disabled={
activeCount === 0 &&
!filters.showWeekends &&
filters.hideCompletedProjects &&
!filters.showDrafts &&
filters.showVacations &&
filters.showPlaceholders
}
className="w-full text-xs text-gray-500 underline transition hover:text-gray-700 disabled:no-underline disabled:opacity-40 dark:text-gray-400 dark:hover:text-gray-200"
>
Reset all filters
</button>
</div>
</div>,
document.body,
);
}
@@ -1,6 +1,7 @@
"use client";
import { clsx } from "clsx";
import { useRef } from "react";
import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
interface TimelineToolbarProps {
@@ -40,35 +41,40 @@ export function TimelineToolbar({
onUndo,
onRedo,
}: TimelineToolbarProps) {
const activeFilterCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
const activeFilterCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
const filterAnchorRef = useRef<HTMLDivElement | null>(null);
return (
<div className="flex items-center justify-between mb-2 gap-3">
<div className="text-sm text-gray-500">
<div className="app-toolbar flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-gray-500 dark:text-gray-400">
{viewMode === "resource"
? `${resourceCount} resources · ${totalAllocCount} allocations`
: `${projectCount} projects`}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
{/* Timeline navigation */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={onNavigateBack}
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
title="Previous 4 weeks"
>
</button>
<button
type="button"
onClick={onNavigateToday}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
Today
</button>
<button
type="button"
onClick={onNavigateForward}
className="px-2.5 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
title="Next 4 weeks"
>
@@ -79,18 +85,20 @@ export function TimelineToolbar({
{(onUndo ?? onRedo) && (
<div className="flex items-center gap-1">
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
</button>
<button
type="button"
onClick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z / Ctrl+Y)"
className="px-2 py-1.5 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
>
</button>
@@ -98,25 +106,27 @@ export function TimelineToolbar({
)}
{/* View mode toggle */}
<div className="flex rounded-lg border border-gray-200 overflow-hidden text-sm">
<div className="flex overflow-hidden rounded-xl border border-gray-300 bg-white text-sm dark:border-gray-600 dark:bg-gray-900">
<button
type="button"
onClick={() => onViewModeChange("resource")}
className={clsx(
"px-3 py-1.5 transition-colors",
"px-3 py-2 transition-colors",
viewMode === "resource"
? "bg-brand-600 text-white"
: "text-gray-600 hover:bg-gray-50",
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Resource view
</button>
<button
type="button"
onClick={() => onViewModeChange("project")}
className={clsx(
"px-3 py-1.5 border-l border-gray-200 transition-colors",
"border-l border-gray-300 px-3 py-2 transition-colors dark:border-gray-600",
viewMode === "project"
? "bg-brand-600 text-white"
: "text-gray-600 hover:bg-gray-50",
: "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Project view
@@ -124,24 +134,26 @@ export function TimelineToolbar({
</div>
{/* Filter */}
<div className="relative">
<div ref={filterAnchorRef} className="relative">
<button
type="button"
onClick={() => onFilterOpenChange(!filterOpen)}
className={clsx(
"flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border transition-colors",
"flex items-center gap-2 rounded-xl border px-3 py-2 text-sm transition-colors",
filterOpen || activeFilterCount > 0
? "bg-brand-50 border-brand-300 text-brand-700"
: "border-gray-200 text-gray-600 hover:bg-gray-50",
? "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-700 dark:bg-brand-950/40 dark:text-brand-200"
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800",
)}
>
Filter
{activeFilterCount > 0 && (
<span className="w-4 h-4 rounded-full bg-brand-600 text-white text-xs flex items-center justify-center">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-brand-600 text-xs text-white">
{activeFilterCount}
</span>
)}
</button>
<TimelineFilter
anchorRef={filterAnchorRef}
filters={filters}
onChange={onFiltersChange}
isOpen={filterOpen}
@@ -14,11 +14,7 @@ import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
import { TimelineHeader } from "./TimelineHeader.js";
import { TimelineToolbar } from "./TimelineToolbar.js";
import { addDays } from "./utils.js";
import {
HEADER_DAY_HEIGHT,
HEADER_MONTH_HEIGHT,
LABEL_WIDTH,
} from "./timelineConstants.js";
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
import { formatDateShort } from "~/lib/format.js";
import {
TimelineProvider,
@@ -40,11 +36,18 @@ export function TimelineView() {
pushHistoryRef.current = pushHistory;
const [popover, setPopover] = useState<{
allocationId: string; projectId: string; x: number; y: number;
allocationId: string;
projectId: string;
x: number;
y: number;
} | null>(null);
const [newAllocPopover, setNewAllocPopover] = useState<{
resourceId: string; startDate: Date; endDate: Date;
suggestedProjectId: string | null; anchorX: number; anchorY: number;
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
} | null>(null);
// cellWidth placeholder — the real value comes from useTimelineLayout inside the content.
@@ -53,12 +56,24 @@ export function TimelineView() {
const cellWidthRef = useRef(40);
const {
dragState, allocDragState, rangeState,
shiftPreview, isPreviewLoading, isApplying, isAllocSaving,
onProjectBarMouseDown, onAllocMouseDown, onRowMouseDown,
onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave,
onProjectBarTouchStart, onAllocTouchStart, onRowTouchStart,
onCanvasTouchMove, onCanvasTouchEnd,
dragState,
allocDragState,
rangeState,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
onProjectBarMouseDown,
onAllocMouseDown,
onRowMouseDown,
onCanvasMouseMove,
onCanvasMouseUp,
onCanvasMouseLeave,
onProjectBarTouchStart,
onAllocTouchStart,
onRowTouchStart,
onCanvasTouchMove,
onCanvasTouchEnd,
} = useTimelineDrag({
cellWidth: cellWidthRef.current,
onBlockClick: (info) => {
@@ -189,7 +204,14 @@ function TimelineViewContent({
contextResourceIds: string[];
popover: { allocationId: string; projectId: string; x: number; y: number } | null;
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
newAllocPopover: { resourceId: string; startDate: Date; endDate: Date; suggestedProjectId: string | null; anchorX: number; anchorY: number } | null;
newAllocPopover: {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
} | null;
setNewAllocPopover: React.Dispatch<React.SetStateAction<typeof newAllocPopover>>;
openPanelProjectId: string | null;
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
@@ -231,13 +253,8 @@ function TimelineViewContent({
const [openDemandToAssign, setOpenDemandToAssign] = useState<OpenDemandAssignment | null>(null);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } = useTimelineLayout(
viewStart,
viewDays,
filters.zoom,
filters.showWeekends,
today,
);
const { CELL_WIDTH, dates, totalCanvasWidth, toLeft, toWidth, gridLines, monthGroups, xToDate } =
useTimelineLayout(viewStart, viewDays, filters.zoom, filters.showWeekends, today);
// Keep cellWidthRef in sync so the drag hook uses the correct value.
cellWidthRef.current = CELL_WIDTH;
@@ -295,8 +312,14 @@ function TimelineViewContent({
const isMac = navigator.platform.toUpperCase().includes("MAC");
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (!modKey) return;
if (e.key === "z" && !e.shiftKey) { e.preventDefault(); void undo(); }
if ((e.key === "z" && e.shiftKey) || e.key === "y") { e.preventDefault(); void redo(); }
if (e.key === "z" && !e.shiftKey) {
e.preventDefault();
void undo();
}
if ((e.key === "z" && e.shiftKey) || e.key === "y") {
e.preventDefault();
void redo();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
@@ -317,8 +340,7 @@ function TimelineViewContent({
};
return (
<div className="flex-1 flex flex-col min-h-0 relative mt-2 mx-4 mb-4">
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
{/* Toolbar */}
<TimelineToolbar
viewMode={viewMode}
@@ -335,23 +357,26 @@ function TimelineViewContent({
onNavigateForward={() => setViewStart((v) => addDays(v, 28))}
canUndo={canUndo}
canRedo={canRedo}
onUndo={() => { void undo(); }}
onRedo={() => { void redo(); }}
onUndo={() => {
void undo();
}}
onRedo={() => {
void redo();
}}
/>
{/* Scrollable canvas */}
<div
ref={scrollContainerRef}
onScroll={handleContainerScroll}
className="flex-1 overflow-auto border border-gray-200 rounded-xl bg-white"
className="app-surface relative flex-1 overflow-auto"
>
{isInitialLoading ? (
<div className="flex items-center justify-center py-20 text-gray-400">
<div className="flex items-center justify-center py-24 text-sm text-gray-500 dark:text-gray-400">
Loading timeline...
</div>
) : (
<div style={{ minWidth: LABEL_WIDTH + totalCanvasWidth }}>
<TimelineHeader
monthGroups={monthGroups}
dates={dates}
@@ -370,7 +395,9 @@ function TimelineViewContent({
onMouseMove={handleMouseMove}
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onTouchMove={(e) => { onCanvasTouchMove(e); }}
onTouchMove={(e) => {
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
@@ -423,15 +450,14 @@ function TimelineViewContent({
/>
)}
</div>
</div>
)}
</div>
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="absolute inset-0 bg-white/40 flex items-center justify-center z-50 rounded-xl pointer-events-none">
<div className="bg-white border border-gray-200 rounded-xl px-5 py-3 shadow-xl text-sm font-medium text-gray-700">
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
@@ -445,10 +471,17 @@ function TimelineViewContent({
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={shiftPreview ?? {
valid: true, deltaCents: 0, wouldExceedBudget: false,
budgetUtilizationAfter: 0, conflictCount: 0, errors: [], warnings: [],
}}
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
@@ -458,20 +491,23 @@ function TimelineViewContent({
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive && allocDragState.daysDelta !== 0 && allocDragState.currentStartDate && allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
</div>
)}
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
@@ -482,9 +518,10 @@ function TimelineViewContent({
>
{(() => {
const end = rangeState.currentDate;
const [s, e] = rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / 86400000) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
@@ -497,7 +534,10 @@ function TimelineViewContent({
allocationId={popover.allocationId}
projectId={popover.projectId}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => { setPopover(null); setOpenPanelProjectId(pid); }}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
/>
@@ -519,10 +559,7 @@ function TimelineViewContent({
{/* Project side panel */}
{openPanelProjectId && (
<ProjectPanel
projectId={openPanelProjectId}
onClose={() => setOpenPanelProjectId(null)}
/>
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}