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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user