feat: timeline UI overhaul with project/resource panel redesign, quick filters, and API improvements

Redesigned timeline project and resource panels with expanded detail views,
added quick filter toolbar, improved drag handling, and enhanced vacation/entitlement
router logic. Includes e2e test updates and minor API fixes.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-15 09:28:59 +01:00
parent fa2019f521
commit a83edb2f9d
23 changed files with 2464 additions and 734 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ test.describe("Authentication", () => {
await page.fill('input[type="email"]', "admin@planarchy.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/resources/);
await expect(page).toHaveURL(/\/(dashboard|resources)/);
});
test("shows error on invalid credentials", async ({ page }) => {
+68 -7
View File
@@ -1,25 +1,35 @@
import { expect, test } from "@playwright/test";
test.describe("Timeline", () => {
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("planarchy_theme", JSON.stringify({ mode: "dark" }));
});
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@planarchy.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/resources/);
await expect(page).toHaveURL(/\/(dashboard|resources)/);
await page.goto("/timeline");
});
test("loads and displays the timeline", async ({ page }) => {
await expect(page.locator("text=Resource view")).toBeVisible();
await expect(page.locator("text=Project view")).toBeVisible();
await expect(page.getByRole("button", { name: /All Clients|Clients:/ })).toBeVisible();
await expect(page.getByRole("button", { name: /All Chapters|Chapters:/ })).toBeVisible();
await expect(page.getByRole("button", { name: /All people|People:/ })).toBeVisible();
// Timeline canvas should be visible
await expect(page.locator(".overflow-auto")).toBeVisible();
await expect(page.locator("div.app-surface.relative.flex-1.overflow-auto")).toBeVisible();
});
test("can switch between resource and project view", async ({ page }) => {
await page.click("text=Project view");
await expect(page.locator("text=0 projects").or(page.locator("text=/\\d+ projects/"))).toBeVisible();
await expect(
page.locator("text=0 projects").or(page.locator("text=/\\d+ projects/")),
).toBeVisible();
await page.click("text=Resource view");
await expect(page.locator("text=/\\d+ resources/")).toBeVisible();
});
@@ -34,8 +44,10 @@ test.describe("Timeline", () => {
test("filter panel opens and closes", async ({ page }) => {
await page.locator("button", { hasText: "Filter" }).click();
await expect(page.locator("text=Chapters")).toBeVisible();
await expect(page.getByRole("heading", { name: "Filters" })).toBeVisible();
await expect(page.getByPlaceholder("Search projects…")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("heading", { name: "Filters" })).not.toBeVisible();
});
test("shows placeholder bars for unassigned allocations", async ({ page }) => {
@@ -44,7 +56,7 @@ test.describe("Timeline", () => {
await page.waitForSelector(".overflow-auto", { state: "visible" });
// Check that the timeline loaded (resource rows or empty state visible)
await expect(
page.locator("text=resources").or(page.locator("text=No allocations"))
page.locator(".app-toolbar").getByText(/\d+ resources · \d+ allocations/),
).toBeVisible();
});
@@ -55,10 +67,59 @@ test.describe("Timeline", () => {
// Try to find and click a placeholder bar (dashed border style)
const placeholderBar = page.locator("[style*='dashed']").first();
if (await placeholderBar.count() > 0) {
if ((await placeholderBar.count()) > 0) {
await placeholderBar.click();
await expect(page.locator("text=Fill Placeholder").or(page.locator("text=Assign Resource"))).toBeVisible();
await expect(
page.locator("text=Fill Placeholder").or(page.locator("text=Assign Resource")),
).toBeVisible();
await page.keyboard.press("Escape");
}
});
test("resource and project views keep tooltips opaque in dark mode and support right click", async ({
page,
}) => {
await page.waitForSelector(".overflow-auto", { state: "visible" });
await page.waitForTimeout(1000);
const heatmapTooltip = page
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-gray-800")
.first();
const allocationPopoverField = page.getByText("Hours / day");
const resourceHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
await resourceHoverTarget.hover({ position: { x: 120, y: 20 } });
await expect(heatmapTooltip).toBeVisible();
await expect
.poll(async () => {
return heatmapTooltip.evaluate((element) => getComputedStyle(element).backgroundColor);
})
.toBe("rgba(3, 7, 18, 0.96)");
const resourceAllocation = page
.locator(
"div.absolute.rounded-md.flex.items-stretch.overflow-hidden.transition-all.duration-75",
)
.first();
await resourceAllocation.click({ button: "right" });
await expect(allocationPopoverField).toBeVisible();
await page.mouse.click(40, 40);
await page.getByText("Project view").click();
await expect(page.getByText(/projects/)).toBeVisible();
await page.waitForTimeout(500);
const projectHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
await projectHoverTarget.hover({ position: { x: 120, y: 20 } });
await expect(heatmapTooltip).toBeVisible();
await expect
.poll(async () => {
return heatmapTooltip.evaluate((element) => getComputedStyle(element).backgroundColor);
})
.toBe("rgba(3, 7, 18, 0.96)");
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
await projectAllocation.click({ button: "right" });
await expect(allocationPopoverField).toBeVisible();
});
});
+13
View File
@@ -11,6 +11,19 @@ const nextConfig: NextConfig = {
"@planarchy/ui",
],
typedRoutes: true,
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
];
},
// Webpack config (used by `next build` and `next dev` without --turbo)
webpack(config) {
config.resolve.alias = {
+216 -80
View File
@@ -57,48 +57,115 @@ 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: <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"] },
type NavItem = { href: string; label: string; icon: ReactNode; roles: string[] };
type NavSection = { label: string; collapsed?: boolean; items: NavItem[] };
const navSections: NavSection[] = [
{
label: "Planning",
items: [
{ href: "/dashboard", label: "Dashboard", icon: <DashboardIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
],
},
{
label: "Estimating",
collapsed: true,
items: [
{ href: "/estimates", label: "Estimates", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/admin/rate-cards", label: "Rate Cards", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/admin/effort-rules", label: "Effort Rules", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: <EstimatesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Resources",
items: [
{ href: "/resources", label: "Resources", icon: <ResourcesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/projects", label: "Projects", icon: <ProjectsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/roles", label: "Roles", icon: <RolesIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Analytics",
items: [
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
label: "Time Off",
items: [
{ href: "/vacations/my", label: "My Vacations", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
{ href: "/vacations", label: "Vacation Mgmt", icon: <VacationIcon />, roles: ["ADMIN", "MANAGER"] },
],
},
];
const adminNavItems = [
type AdminNavItem = { href: string; label: string; icon: ReactNode };
type AdminSubGroup = { label: string; collapsed: boolean; items: AdminNavItem[] };
type AdminEntry = AdminNavItem | AdminSubGroup;
function isSubGroup(entry: AdminEntry): entry is AdminSubGroup {
return "items" in entry;
}
const adminNavEntries: AdminEntry[] = [
{ 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 /> },
{
label: "ACN-Orga",
collapsed: true,
items: [
{ 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/management-levels", label: "Mgmt Levels", icon: <AdminIcon /> },
],
},
{ href: "/admin/calculation-rules", label: "Calc. Rules", 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: <VacationIcon /> },
];
function Sidebar({ userRole }: { userRole: string }) {
const pathname = usePathname();
const [prefsOpen, setPrefsOpen] = useState(false);
const visibleNavItems = allNavItems.filter((item) => item.roles.includes(userRole));
const visibleSections = navSections
.map((section) => ({
...section,
items: section.items.filter((item) => item.roles.includes(userRole)),
}))
.filter((section) => section.items.length > 0);
const showAdmin = userRole === "ADMIN";
const showManagerSection = userRole === "ADMIN" || userRole === "MANAGER";
// Sections and sub-groups auto-expand when the current route matches an item inside them
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
for (const section of visibleSections) {
if (section.collapsed) {
const hasActiveRoute = section.items.some((item) => pathname.startsWith(item.href));
initial[section.label] = !hasActiveRoute;
}
}
for (const entry of adminNavEntries) {
if (isSubGroup(entry) && entry.collapsed) {
const hasActiveRoute = entry.items.some((item) => pathname.startsWith(item.href));
initial[entry.label] = !hasActiveRoute;
}
}
return initial;
});
const toggleSection = (label: string) => {
setCollapsedSections((prev) => ({ ...prev, [label]: !prev[label] }));
};
return (
<>
<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">
@@ -118,63 +185,132 @@ function Sidebar({ userRole }: { userRole: string }) {
</div>
{/* Nav links */}
<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(
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(item.href)
? "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",
)}
>
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
<div className="flex-1 overflow-y-auto px-4 py-5">
{visibleSections.map((section, idx) => {
const isCollapsed = collapsedSections[section.label] ?? false;
const isCollapsible = section.collapsed === true;
{showManagerSection && (
<>
<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"}
return (
<div key={section.label} className={idx > 0 ? "mt-2" : ""}>
<button
type="button"
onClick={isCollapsible ? () => toggleSection(section.label) : undefined}
className={clsx(
"flex w-full items-center px-3 pb-1.5 pt-3",
isCollapsible && "cursor-pointer hover:text-gray-600 dark:hover:text-gray-300",
)}
>
<span className="flex-1 text-left text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
{section.label}
</span>
</div>
{isCollapsible && (
<svg
className={clsx(
"h-3 w-3 text-gray-400 transition-transform dark:text-gray-500",
isCollapsed ? "-rotate-90" : "rotate-0",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</button>
{!isCollapsed && (
<div className="space-y-0.5">
{section.items.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
pathname.startsWith(item.href)
? "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",
)}
>
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
</div>
)}
</div>
{showAdmin && adminNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(item.href)
? "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",
)}
>
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
{managerNavItems.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-all",
pathname.startsWith(item.href)
? "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",
)}
>
<IconFrame>{item.icon}</IconFrame>
<span className="flex-1">{item.label}</span>
</Link>
))}
</>
);
})}
{showManagerSection && showAdmin && (
<div className="mt-2">
<div className="px-3 pb-1.5 pt-3">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">
Admin
</span>
</div>
<div className="space-y-0.5">
{adminNavEntries.map((entry) => {
if (isSubGroup(entry)) {
const subCollapsed = collapsedSections[entry.label] ?? false;
return (
<div key={entry.label}>
<button
type="button"
onClick={() => toggleSection(entry.label)}
className="flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-gray-500 transition-all hover:bg-gray-100/90 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-slate-900 dark:hover:text-gray-200"
>
<IconFrame><AdminIcon /></IconFrame>
<span className="flex-1 text-left">{entry.label}</span>
<svg
className={clsx(
"h-3 w-3 transition-transform",
subCollapsed ? "-rotate-90" : "rotate-0",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{!subCollapsed && (
<div className="ml-4 space-y-0.5 border-l border-gray-200 pl-2 dark:border-slate-800">
{entry.items.map((item) => (
<Link
key={item.href}
href={item.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-1.5 text-sm font-medium transition-all",
pathname.startsWith(item.href)
? "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 className="flex-1">{item.label}</span>
</Link>
))}
</div>
)}
</div>
);
}
return (
<Link
key={entry.href}
href={entry.href as Route}
className={clsx(
"group flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium transition-all",
pathname.startsWith(entry.href)
? "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",
)}
>
<IconFrame>{entry.icon}</IconFrame>
<span className="flex-1">{entry.label}</span>
</Link>
);
})}
</div>
</div>
)}
</div>
@@ -39,6 +39,7 @@ export type TimelineProject = {
startDate: Date | string;
endDate: Date | string;
orderType: string;
clientId?: string | null;
budgetCents?: number;
winProbability?: number;
staffingReqs?: unknown;
@@ -51,7 +52,10 @@ export type TimelineRole = {
color: string | null;
};
export type TimelineAllocation = Omit<AllocationLike, "resource" | "project" | "roleEntity" | "metadata"> & {
export type TimelineAllocation = Omit<
AllocationLike,
"resource" | "project" | "roleEntity" | "metadata"
> & {
resource?: TimelineResource | null;
project: TimelineProject;
roleEntity?: TimelineRole | null;
@@ -83,6 +87,7 @@ export type ProjectGroup = {
name: string;
shortCode: string;
orderType: string;
clientId: string | null;
startDate: Date;
endDate: Date;
status: string;
@@ -196,7 +201,14 @@ export function TimelineProvider({
// ─── Data queries ──────────────────────────────────────────────────────────
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
{ startDate: viewStart, endDate: viewEnd },
{
startDate: viewStart,
endDate: viewEnd,
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ placeholderData: (prev: any) => prev },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -232,21 +244,23 @@ export function TimelineProvider({
// ─── Filtered entries ──────────────────────────────────────────────────────
const visibleAssignments = useMemo(
() => assignments.filter((entry) => {
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
return true;
}),
() =>
assignments.filter((entry) => {
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
return true;
}),
[assignments, filters.hideCompletedProjects, filters.showDrafts],
);
const visibleDemands = useMemo(
() => demands.filter((entry) => {
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
if (!filters.showPlaceholders) return false;
return true;
}),
() =>
demands.filter((entry) => {
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
if (!filters.showPlaceholders) return false;
return true;
}),
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
);
@@ -266,9 +280,19 @@ export function TimelineProvider({
const allocsByResource = new Map<string, TimelineAssignmentEntry[]>();
if (eidFilterData?.resources) {
for (const r of eidFilterData.resources as { id: string; displayName: string; eid: string; chapter: string | null }[]) {
for (const r of eidFilterData.resources as {
id: string;
displayName: string;
eid: string;
chapter: string | null;
}[]) {
if (!resourceMap.has(r.id)) {
resourceMap.set(r.id, { id: r.id, displayName: r.displayName, eid: r.eid, chapter: r.chapter });
resourceMap.set(r.id, {
id: r.id,
displayName: r.displayName,
eid: r.eid,
chapter: r.chapter,
});
}
}
}
@@ -320,9 +344,32 @@ export function TimelineProvider({
),
);
}
if (filters.clientIds.length > 0) {
resources = resources.filter((r) =>
visibleAssignments.some(
(entry) => {
const clientId = entry.project.clientId;
return (
entry.resourceId === r.id &&
typeof clientId === "string" &&
filters.clientIds.includes(clientId)
);
},
),
);
}
return { resourceMap, allocsByResource, resources };
}, [visibleAssignments, eidFilterData, isDragging, contextAllocations, filters.chapters, filters.eids, filters.projectIds]); // eslint-disable-line react-hooks/exhaustive-deps
}, [
visibleAssignments,
eidFilterData,
isDragging,
contextAllocations,
filters.chapters,
filters.eids,
filters.projectIds,
filters.clientIds,
]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Project groups (for project view) ────────────────────────────────────
const projectGroups = useMemo(() => {
@@ -336,6 +383,7 @@ export function TimelineProvider({
name: entry.project.name,
shortCode: entry.project.shortCode,
orderType: entry.project.orderType,
clientId: entry.project.clientId ?? null,
startDate: new Date(entry.project.startDate as unknown as string),
endDate: new Date(entry.project.endDate as unknown as string),
status: entry.project.status,
@@ -344,8 +392,11 @@ export function TimelineProvider({
projectGroupMap.set(entry.projectId, group);
}
const currentGroup = group;
if (!currentGroup) continue;
if (entry.kind === "assignment" && entry.resourceId) {
const existingRow = currentGroup.resourceRows.find((r) => r.resource.id === entry.resourceId);
const existingRow = currentGroup.resourceRows.find(
(r) => r.resource.id === entry.resourceId,
);
if (existingRow) {
existingRow.allocs.push(entry);
} else {
@@ -357,23 +408,68 @@ export function TimelineProvider({
}
}
for (const group of projectGroupMap.values()) {
group.resourceRows.sort((a, b) => a.resource.displayName.localeCompare(b.resource.displayName));
group.resourceRows = group.resourceRows.filter(({ resource, allocs }) => {
if (filters.chapters.length > 0) {
if (!resource.chapter || !filters.chapters.includes(resource.chapter)) {
return false;
}
}
if (filters.eids.length > 0 && !filters.eids.includes(resource.eid)) {
return false;
}
if (filters.clientIds.length > 0) {
const matchesClient = allocs.some(
(alloc) => {
const clientId = alloc.project.clientId;
return typeof clientId === "string" && filters.clientIds.includes(clientId);
},
);
if (!matchesClient) {
return false;
}
}
return true;
});
group.resourceRows.sort((a, b) =>
a.resource.displayName.localeCompare(b.resource.displayName),
);
}
return [...projectGroupMap.values()]
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
.filter((pg) => {
if (filters.projectIds.length > 0 && !filters.projectIds.includes(pg.id)) return false;
if (filters.chapters.length > 0 && !pg.resourceRows.some((r) => r.resource.chapter && filters.chapters.includes(r.resource.chapter))) return false;
if (filters.eids.length > 0 && !pg.resourceRows.some((r) => filters.eids.includes(r.resource.eid))) return false;
if (
filters.clientIds.length > 0 &&
(!pg.clientId || !filters.clientIds.includes(pg.clientId))
)
return false;
if (
filters.chapters.length > 0 &&
pg.resourceRows.length === 0
)
return false;
if (filters.eids.length > 0 && pg.resourceRows.length === 0)
return false;
return true;
});
}, [visibleAssignments, visibleDemands, resourceMap, filters.projectIds, filters.chapters, filters.eids]); // eslint-disable-line react-hooks/exhaustive-deps
}, [
visibleAssignments,
visibleDemands,
resourceMap,
filters.projectIds,
filters.clientIds,
filters.chapters,
filters.eids,
]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Derived counts ───────────────────────────────────────────────────────
const isInitialLoading = isLoading && !entriesView;
const totalAllocCount = entriesView?.allocations.length ?? 0;
const activeFilterCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
filters.clientIds.length +
filters.chapters.length +
filters.eids.length +
filters.projectIds.length;
const value = useMemo<TimelineContextValue>(
() => ({
@@ -433,9 +529,5 @@ export function TimelineProvider({
],
);
return (
<TimelineContext.Provider value={value}>
{children}
</TimelineContext.Provider>
);
return <TimelineContext.Provider value={value}>{children}</TimelineContext.Provider>;
}
@@ -6,6 +6,8 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react"
import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters {
/** Filter to projects that belong to one of the selected clients. */
clientIds: string[];
chapters: string[];
/** Filter to specific resource EIDs */
eids: string[];
@@ -27,6 +29,7 @@ export interface TimelineFilters {
}
export const DEFAULT_FILTERS: TimelineFilters = {
clientIds: [],
chapters: [],
eids: [],
projectIds: [],
@@ -63,92 +66,6 @@ function Chip({ label, onRemove }: { label: string; onRemove: () => void }) {
);
}
// ─── EID picker ───────────────────────────────────────────────────────────────
function EidPicker({
selectedEids,
onChange,
}: {
selectedEids: string[];
onChange: (eids: string[]) => void;
}) {
const [search, setSearch] = useState("");
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { data } = trpc.resource.list.useQuery(
{ search, isActive: true, limit: 200 },
{ 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),
);
function add(eid: string) {
onChange([...selectedEids, eid]);
setSearch("");
inputRef.current?.focus();
}
function remove(eid: string) {
onChange(selectedEids.filter((e) => e !== eid));
}
return (
<div>
<div className="flex flex-wrap gap-1 mb-1.5">
{selectedEids.map((eid) => (
<Chip key={eid} label={eid} onRemove={() => remove(eid)} />
))}
</div>
<div className="relative">
<input
ref={inputRef}
type="text"
placeholder="Search by name or EID…"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
className="app-input px-2.5 py-1.5 text-xs"
/>
{open && suggestions.length > 0 && (
<div
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="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="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>
)}
</div>
</div>
);
}
// ─── Project picker ───────────────────────────────────────────────────────────
function ProjectPicker({
@@ -168,7 +85,6 @@ function ProjectPicker({
(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 projectMap = new Map(
((allData?.projects as ProjectRow[] | undefined) ?? []).map((p) => [p.id, p]),
@@ -188,8 +104,8 @@ function ProjectPicker({
<div>
<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)} />;
const project = projectMap.get(id);
return <Chip key={id} label={project ? project.name : id} onRemove={() => remove(id)} />;
})}
</div>
<div className="relative">
@@ -211,17 +127,17 @@ function ProjectPicker({
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) => (
{suggestions.map((project) => (
<button
key={p.id}
key={project.id}
type="button"
onMouseDown={(e) => {
e.preventDefault();
add(p.id);
add(project.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="truncate text-gray-800 dark:text-gray-100">{p.name}</span>
<span className="truncate text-gray-800 dark:text-gray-100">{project.name}</span>
</button>
))}
</div>
@@ -240,16 +156,8 @@ export function TimelineFilter({
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[],
),
].sort();
const updatePanelPosition = useCallback(() => {
const trigger = anchorRef.current;
@@ -300,7 +208,11 @@ export function TimelineFilter({
if (!isOpen) return null;
const activeCount = filters.chapters.length + filters.eids.length + filters.projectIds.length;
const activeCount =
filters.clientIds.length +
filters.chapters.length +
filters.eids.length +
filters.projectIds.length;
return createPortal(
<div
@@ -350,17 +262,6 @@ export function TimelineFilter({
</div>
</div>
{/* EID filter */}
<div className="mb-5">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
People (EID)
</label>
<EidPicker
selectedEids={filters.eids}
onChange={(eids) => onChange({ ...filters, eids })}
/>
</div>
{/* Project filter */}
<div className="mb-5">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
@@ -372,36 +273,6 @@ export function TimelineFilter({
/>
</div>
{/* Chapters */}
{chapters.length > 0 && (
<div className="mb-5">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
Chapters
</label>
<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 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)}
onChange={(e) => {
const next = e.target.checked
? [...filters.chapters, ch]
: filters.chapters.filter((c) => c !== ch);
onChange({ ...filters, chapters: next });
}}
className="rounded border-gray-300 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">{ch}</span>
</label>
))}
</div>
</div>
)}
{/* Visibility toggles */}
<div className="mb-4 space-y-2">
<label className="mb-2 block text-xs font-medium uppercase tracking-wider text-gray-500">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,407 @@
"use client";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
import type { TimelineFilters } from "./TimelineFilter.js";
function TimelineFilterDropdown({
label,
children,
widthClassName = "w-80",
buttonClassName = "min-w-44",
tooltipContent,
}: {
label: string;
children: ReactNode;
widthClassName?: string;
buttonClassName?: string;
tooltipContent?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
});
}, []);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("keydown", handleEscape);
};
}, [isOpen, updatePanelPosition]);
return (
<div ref={dropdownRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm 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 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">{isOpen ? "▲" : "▼"}</span>
</button>
{tooltipContent ? <InfoTooltip content={tooltipContent} width="w-72" /> : null}
</div>
{isOpen &&
createPortal(
<div
ref={panelRef}
style={{
position: "fixed",
top: panelPosition.top,
left: panelPosition.left,
minWidth: panelPosition.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
{children}
</div>,
document.body,
)}
</div>
);
}
function FilterChip({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<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="leading-none text-brand-400 hover:text-brand-700 dark:hover:text-brand-100"
>
×
</button>
</span>
);
}
function buildSelectionLabel(prefix: string, selectedCount: number, totalCount: number) {
if (selectedCount === 0) return `All ${prefix}`;
return `${prefix}: ${selectedCount}/${totalCount}`;
}
type ResourceOption = {
id: string;
eid: string;
displayName: string;
chapter: string | null;
};
type ClientOption = {
id: string;
name: string;
code: string | null;
isActive?: boolean;
};
interface TimelineQuickFiltersProps {
filters: TimelineFilters;
onChange: (filters: TimelineFilters) => void;
}
export function TimelineQuickFilters({ filters, onChange }: TimelineQuickFiltersProps) {
const [eidSearch, setEidSearch] = useState("");
const { data: resourceData } = trpc.resource.list.useQuery(
{ isActive: true, limit: 500 },
{ staleTime: 60_000 },
);
const { data: eidSearchData } = trpc.resource.list.useQuery(
{ isActive: true, search: eidSearch, limit: 100 },
{ staleTime: 15_000 },
);
const { data: clientsData } = trpc.clientEntity.list.useQuery(
{ isActive: true },
{ staleTime: 60_000 },
);
const resources = ((resourceData?.resources as ResourceOption[] | undefined) ?? []).slice();
const eidSuggestions = (
(eidSearchData?.resources as ResourceOption[] | undefined) ??
resources
).filter((resource) => !filters.eids.includes(resource.eid));
const chapters = useMemo(
() =>
[
...new Set(
resources
.map((resource) => resource.chapter)
.filter((chapter): chapter is string => Boolean(chapter)),
),
].sort((left, right) => left.localeCompare(right)),
[resources],
);
const clients = useMemo(
() =>
((clientsData ?? []) as ClientOption[])
.filter((client) => client.isActive !== false)
.map((client) => ({ id: client.id, name: client.name, code: client.code })),
[clientsData],
);
const resourceMap = useMemo(
() => new Map(resources.map((resource) => [resource.eid, resource])),
[resources],
);
const clientMap = useMemo(
() => new Map(clients.map((client) => [client.id, client])),
[clients],
);
const clientLabel = buildSelectionLabel("Clients", filters.clientIds.length, clients.length || 1);
const chapterLabel = buildSelectionLabel(
"Chapters",
filters.chapters.length,
chapters.length || 1,
);
const peopleLabel =
filters.eids.length === 0 ? "All people" : `People: ${filters.eids.length}`;
function toggleClient(clientId: string) {
const nextClientIds = filters.clientIds.includes(clientId)
? filters.clientIds.filter((id) => id !== clientId)
: [...filters.clientIds, clientId].sort((left, right) => {
const leftName = clientMap.get(left)?.name ?? left;
const rightName = clientMap.get(right)?.name ?? right;
return leftName.localeCompare(rightName);
});
onChange({ ...filters, clientIds: nextClientIds });
}
function toggleChapter(chapter: string) {
const nextChapters = filters.chapters.includes(chapter)
? filters.chapters.filter((value) => value !== chapter)
: [...filters.chapters, chapter].sort((left, right) => left.localeCompare(right));
onChange({ ...filters, chapters: nextChapters });
}
function addEid(eid: string) {
if (filters.eids.includes(eid)) return;
onChange({ ...filters, eids: [...filters.eids, eid] });
setEidSearch("");
}
function removeEid(eid: string) {
onChange({ ...filters, eids: filters.eids.filter((value) => value !== eid) });
}
return (
<>
<TimelineFilterDropdown
label={clientLabel}
widthClassName="w-80"
tooltipContent="Multi-select project client filter. Checked clients stay visible in both resource and project views."
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Clients</h2>
<p className="text-xs text-gray-500">Checked clients stay visible.</p>
</div>
<button
type="button"
onClick={() => onChange({ ...filters, clientIds: [] })}
className="text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
>
Show all
</button>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
<label className="flex items-center gap-3 border-b border-gray-200 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:text-gray-200">
<input
type="checkbox"
checked={filters.clientIds.length === 0}
onChange={() => onChange({ ...filters, clientIds: [] })}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="font-medium">All clients</span>
</label>
<div className="max-h-64 overflow-auto">
{clients.map((client) => (
<label
key={client.id}
className="flex items-center gap-3 border-b border-gray-100 px-3 py-2 text-sm text-gray-700 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={filters.clientIds.length === 0 || filters.clientIds.includes(client.id)}
onChange={() => toggleClient(client.id)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="min-w-0 flex-1 truncate">{client.name}</span>
{client.code ? (
<span className="flex-shrink-0 text-xs text-gray-400 dark:text-gray-500">
{client.code}
</span>
) : null}
</label>
))}
</div>
</div>
</TimelineFilterDropdown>
<TimelineFilterDropdown
label={chapterLabel}
widthClassName="w-80"
tooltipContent="Multi-select resource chapter filter. Checked chapters stay visible in both timeline views."
>
<div className="mb-3 flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Chapters</h2>
<p className="text-xs text-gray-500">Checked chapters stay visible.</p>
</div>
<button
type="button"
onClick={() => onChange({ ...filters, chapters: [] })}
className="text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
>
Show all
</button>
</div>
<div className="rounded-xl border border-gray-200 dark:border-gray-700">
<label className="flex items-center gap-3 border-b border-gray-200 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:text-gray-200">
<input
type="checkbox"
checked={filters.chapters.length === 0}
onChange={() => onChange({ ...filters, chapters: [] })}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span className="font-medium">All chapters</span>
</label>
<div className="max-h-64 overflow-auto">
{chapters.map((chapter) => (
<label
key={chapter}
className="flex items-center gap-3 border-b border-gray-100 px-3 py-2 text-sm text-gray-700 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
<input
type="checkbox"
checked={filters.chapters.length === 0 || filters.chapters.includes(chapter)}
onChange={() => toggleChapter(chapter)}
className="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-gray-600"
/>
<span>{chapter}</span>
</label>
))}
</div>
</div>
</TimelineFilterDropdown>
<TimelineFilterDropdown
label={peopleLabel}
widthClassName="w-96"
buttonClassName="min-w-52"
tooltipContent="Multi-select people filter by displayed EID. Use search to add one or more resources and keep only those people visible."
>
<div className="mb-3">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
People (EID)
</h2>
<p className="text-xs text-gray-500">Search by displayed name or EID.</p>
</div>
<button
type="button"
onClick={() => onChange({ ...filters, eids: [] })}
className="text-xs font-medium text-brand-600 hover:text-brand-800 dark:text-brand-300 dark:hover:text-brand-100"
>
Clear
</button>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{filters.eids.map((eid) => {
const resource = resourceMap.get(eid);
const label = resource ? `${resource.displayName} (${eid})` : eid;
return <FilterChip key={eid} label={label} onRemove={() => removeEid(eid)} />;
})}
</div>
</div>
<div className="relative">
<input
type="search"
placeholder="Search by name or EID..."
value={eidSearch}
onChange={(event) => setEidSearch(event.target.value)}
className="app-input w-full px-2.5 py-1.5 text-xs"
/>
{eidSuggestions.length > 0 ? (
<div className="mt-2 max-h-64 overflow-auto rounded-xl border border-gray-200 dark:border-gray-700">
{eidSuggestions.map((resource) => (
<button
key={resource.id}
type="button"
onClick={() => addEid(resource.eid)}
className="flex w-full items-center gap-3 border-b border-gray-100 px-3 py-2 text-left text-sm text-gray-700 last:border-b-0 hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
>
<span className="w-24 flex-shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">
{resource.eid}
</span>
<span className="min-w-0 flex-1 truncate">{resource.displayName}</span>
{resource.chapter ? (
<span className="flex-shrink-0 text-xs text-gray-400 dark:text-gray-500">
{resource.chapter}
</span>
) : null}
</button>
))}
</div>
) : (
<div className="mt-2 rounded-xl border border-dashed border-gray-300 px-3 py-4 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400">
No matching people found.
</div>
)}
</div>
</TimelineFilterDropdown>
</>
);
}
@@ -3,18 +3,28 @@
import { clsx } from "clsx";
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useTimelineContext, type TimelineAssignmentEntry, type VacationEntry } from "./TimelineContext.js";
import {
useTimelineContext,
type TimelineAssignmentEntry,
type VacationEntry,
} from "./TimelineContext.js";
import { ConflictOverlay } from "./ConflictOverlay.js";
import { computeSubLanes } from "./utils.js";
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { formatDateLong } from "~/lib/format.js";
import {
ROW_HEIGHT,
SUB_LANE_HEIGHT,
LABEL_WIDTH,
ORDER_TYPE_COLORS,
} from "./timelineConstants.js";
import type { DragState, AllocDragState, RangeState, ShiftPreviewData } from "~/hooks/useTimelineDrag.js";
import type {
DragState,
AllocDragState,
RangeState,
ShiftPreviewData,
} from "~/hooks/useTimelineDrag.js";
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
// ─── Props ──────────────────────────────────────────────────────────────────
@@ -30,6 +40,11 @@ interface TimelineResourcePanelProps {
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void;
onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void;
onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void;
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
anchorX: number,
anchorY: number,
) => void;
// Layout from useTimelineLayout
CELL_WIDTH: number;
dates: Date[];
@@ -70,6 +85,7 @@ export function TimelineResourcePanel({
onAllocTouchStart,
onRowMouseDown,
onRowTouchStart,
onAllocationContextMenu,
CELL_WIDTH,
dates,
totalCanvasWidth,
@@ -95,17 +111,35 @@ export function TimelineResourcePanel({
const lastHeatmapDayRef = useRef<number>(-1);
const vacationHoverRafRef = useRef<number | null>(null);
const hoveredVacationKeyRef = useRef<string | null>(null);
const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; allocs: TimelineAssignmentEntry[] } | null>(null);
const pendingHeatmapRef = useRef<{
clientX: number;
rect: DOMRect;
allocs: TimelineAssignmentEntry[];
} | null>(null);
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
const [heatmapHover, setHeatmapHover] = useState<{
date: Date;
totalH: number;
pct: number;
breakdown: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null }[];
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
} | null>(null);
const [vacationHover, setVacationHover] = useState<null | {
type: string; startDate: Date | string; endDate: Date | string; note?: string | null;
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
@@ -157,14 +191,19 @@ export function TimelineResourcePanel({
// ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ──
// (Bar mode computes differently per-day, so we only pre-compute for strip.)
const assignmentBlocksByResource = useMemo(() => {
if (displayMode === "bar") return new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
if (displayMode === "bar")
return new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
const result = new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
for (const { resource, allocs } of resourceRows) {
if (allocs.length === 0) continue;
const subLaneMap = computeSubLanes(
allocs.map((a) => ({ id: a.id, startDate: new Date(a.startDate), endDate: new Date(a.endDate) })),
allocs.map((a) => ({
id: a.id,
startDate: new Date(a.startDate),
endDate: new Date(a.endDate),
})),
);
const laneCount = subLaneMap.size > 0 ? Math.max(...subLaneMap.values()) + 1 : 1;
const blockData: AllocBlockData[] = allocs.map((alloc) => ({
@@ -177,89 +216,120 @@ export function TimelineResourcePanel({
}, [displayMode, resourceRows]);
// ─── Heatmap row hover handler ────────────────────────────────────────────
const handleRowHeatmapMove = useCallback((e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
const rect = e.currentTarget.getBoundingClientRect();
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
if (dayIndex === lastHeatmapDayRef.current) return;
pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs };
if (heatmapRafRef.current !== null) return;
heatmapRafRef.current = requestAnimationFrame(() => {
heatmapRafRef.current = null;
const pending = pendingHeatmapRef.current;
pendingHeatmapRef.current = null;
if (!pending) return;
const { clientX, rect: r, allocs: a } = pending;
const dayIdx = Math.floor((clientX - r.left) / CELL_WIDTH);
const date = dates[dayIdx];
if (!date) {
lastHeatmapDayRef.current = -1;
startTransition(() => setHeatmapHover(null));
return;
const handleRowHeatmapMove = useCallback(
(e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 };
if (heatmapTooltipRef.current) {
heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`;
heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`;
}
lastHeatmapDayRef.current = dayIdx;
const rect = e.currentTarget.getBoundingClientRect();
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
if (dayIndex === lastHeatmapDayRef.current) return;
const t = date.getTime();
const REF_H = 8;
const projectHours = new Map<string, { shortCode: string; projectName: string; orderType: string; hours: number; responsiblePerson?: string | null }>();
for (const alloc of a) {
const s = new Date(alloc.startDate); s.setHours(0, 0, 0, 0);
const ev = new Date(alloc.endDate); ev.setHours(0, 0, 0, 0);
if (t < s.getTime() || t > ev.getTime()) continue;
const existing = projectHours.get(alloc.projectId);
if (existing) {
existing.hours += alloc.hoursPerDay;
} else {
projectHours.set(alloc.projectId, {
shortCode: alloc.project.shortCode,
projectName: alloc.project.name,
orderType: alloc.project.orderType,
hours: alloc.hoursPerDay,
responsiblePerson: (alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
});
pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs };
if (heatmapRafRef.current !== null) return;
heatmapRafRef.current = requestAnimationFrame(() => {
heatmapRafRef.current = null;
const pending = pendingHeatmapRef.current;
pendingHeatmapRef.current = null;
if (!pending) return;
const { clientX, rect: r, allocs: a } = pending;
const dayIdx = Math.floor((clientX - r.left) / CELL_WIDTH);
const date = dates[dayIdx];
if (!date) {
lastHeatmapDayRef.current = -1;
startTransition(() => setHeatmapHover(null));
return;
}
}
lastHeatmapDayRef.current = dayIdx;
const breakdown = [...projectHours.entries()]
.map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours }))
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
const t = date.getTime();
const REF_H = 8;
const projectHours = new Map<
string,
{
shortCode: string;
projectName: string;
orderType: string;
hours: number;
responsiblePerson?: string | null;
}
>();
for (const alloc of a) {
const s = new Date(alloc.startDate);
s.setHours(0, 0, 0, 0);
const ev = new Date(alloc.endDate);
ev.setHours(0, 0, 0, 0);
if (t < s.getTime() || t > ev.getTime()) continue;
const existing = projectHours.get(alloc.projectId);
if (existing) {
existing.hours += alloc.hoursPerDay;
} else {
projectHours.set(alloc.projectId, {
shortCode: alloc.project.shortCode,
projectName: alloc.project.name,
orderType: alloc.project.orderType,
hours: alloc.hoursPerDay,
responsiblePerson:
(alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
});
}
}
const totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0);
startTransition(() => {
setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown });
const breakdown = [...projectHours.entries()]
.map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours }))
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
const totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0);
startTransition(() => {
setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown });
});
});
});
}, [CELL_WIDTH, dates]);
},
[CELL_WIDTH, dates],
);
// ─── Vacation hover ───────────────────────────────────────────────────────
const handleRowVacationHover = useCallback((e: React.MouseEvent, resourceId: string) => {
const rect = e.currentTarget.getBoundingClientRect();
const clientX = e.clientX;
const handleRowVacationHover = useCallback(
(e: React.MouseEvent, resourceId: string) => {
vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 };
if (vacationTooltipRef.current) {
vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`;
vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`;
}
const rect = e.currentTarget.getBoundingClientRect();
const clientX = e.clientX;
if (vacationHoverRafRef.current !== null) return;
if (vacationHoverRafRef.current !== null) return;
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
const t = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit = resourceVacations.find((v) => {
const s = new Date(v.startDate); s.setHours(0, 0, 0, 0);
const end = new Date(v.endDate); end.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= end.getTime();
}) ?? null;
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
const t = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
resourceVacations.find((v) => {
const s = new Date(v.startDate);
s.setHours(0, 0, 0, 0);
const end = new Date(v.endDate);
end.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= end.getTime();
}) ?? null;
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
if (nextKey === hoveredVacationKeyRef.current) return;
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
if (nextKey === hoveredVacationKeyRef.current) return;
hoveredVacationKeyRef.current = nextKey;
startTransition(() => {
setVacationHover(hit);
hoveredVacationKeyRef.current = nextKey;
startTransition(() => {
setVacationHover(hit);
});
});
});
}, [vacationsByResource, xToDate]);
},
[vacationsByResource, xToDate],
);
const clearHoverTooltips = useCallback(() => {
if (heatmapRafRef.current !== null) {
@@ -286,10 +356,13 @@ export function TimelineResourcePanel({
}, []);
// ─── Cleanup rAF on unmount ───────────────────────────────────────────────
useEffect(() => () => {
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
}, []);
useEffect(
() => () => {
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
},
[],
);
// ─── Render helpers ───────────────────────────────────────────────────────
@@ -315,7 +388,9 @@ export function TimelineResourcePanel({
const inBarMode = displayMode === "bar";
const precomputed = assignmentBlocksByResource.get(resource.id);
const laneCount = inBarMode ? 1 : (precomputed?.laneCount ?? 1);
const rowHeight = inBarMode ? ROW_HEIGHT : Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
const rowHeight = inBarMode
? ROW_HEIGHT
: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
return (
<div
@@ -349,8 +424,12 @@ export function TimelineResourcePanel({
{resource.displayName.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{resource.displayName}</div>
<div className="text-xs text-gray-400 truncate">{resource.chapter ?? resource.eid}</div>
<div className="text-sm font-medium text-gray-900 truncate">
{resource.displayName}
</div>
<div className="text-xs text-gray-400 truncate">
{resource.chapter ?? resource.eid}
</div>
</div>
</div>
@@ -368,27 +447,75 @@ export function TimelineResourcePanel({
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
onRowTouchStart(e, { resourceId: resource.id, startDate: date });
}}
onMouseMove={(e) => { handleRowHeatmapMove(e, allocs); handleRowVacationHover(e, resource.id); }}
onMouseMove={(e) => {
handleRowHeatmapMove(e, allocs);
handleRowVacationHover(e, resource.id);
}}
onMouseLeave={clearHoverTooltips}
>
{gridLines}
{inBarMode
? renderDailyBars(allocs, rowHeight, CELL_WIDTH, dates, allocDragState, onAllocMouseDown, onAllocTouchStart, toLeft, toWidth, totalCanvasWidth)
: renderAllocBlocksFromData(precomputed?.blockData ?? [], allocs, dragState, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart)}
{renderVacationBlocksForRow(vacationBlocksByResource.get(resource.id) ?? [], rowHeight)}
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" && renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
{renderRangeOverlay(rangeState, resource.id, rowHeight, toLeft, toWidth, CELL_WIDTH)}
{dragState.isDragging && dragState.projectId && shiftPreview && !shiftPreview.valid && shiftPreview.conflictCount > 0 && allocs.some((a) => a.projectId === dragState.projectId) && (
<ConflictOverlay
left={toLeft(dragState.currentStartDate ?? viewStart) + 2}
width={toWidth(dragState.currentStartDate ?? viewStart, dragState.currentEndDate ?? viewEnd) - 4}
height={rowHeight - 8}
type="availability"
message={`${shiftPreview.conflictCount} conflict(s)`}
/>
? renderDailyBars(
allocs,
rowHeight,
CELL_WIDTH,
dates,
allocDragState,
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
toLeft,
toWidth,
totalCanvasWidth,
)
: renderAllocBlocksFromData(
precomputed?.blockData ?? [],
allocs,
dragState,
allocDragState,
toLeft,
toWidth,
CELL_WIDTH,
totalCanvasWidth,
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
)}
{renderVacationBlocksForRow(
vacationBlocksByResource.get(resource.id) ?? [],
rowHeight,
)}
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" &&
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
{renderRangeOverlay(
rangeState,
resource.id,
rowHeight,
toLeft,
toWidth,
CELL_WIDTH,
)}
{dragState.isDragging &&
dragState.projectId &&
shiftPreview &&
!shiftPreview.valid &&
shiftPreview.conflictCount > 0 &&
allocs.some((a) => a.projectId === dragState.projectId) && (
<ConflictOverlay
left={toLeft(dragState.currentStartDate ?? viewStart) + 2}
width={
toWidth(
dragState.currentStartDate ?? viewStart,
dragState.currentEndDate ?? viewEnd,
) - 4
}
height={rowHeight - 8}
type="availability"
message={`${shiftPreview.conflictCount} conflict(s)`}
/>
)}
</div>
</div>
</div>
@@ -397,6 +524,10 @@ export function TimelineResourcePanel({
{/* Tooltips rendered inside the panel so they live near their data source */}
<ResourcePanelTooltips
heatmapTooltipRef={heatmapTooltipRef}
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
vacationTooltipPos={vacationTooltipPosRef.current}
heatmapHover={heatmapHover}
vacationHover={vacationHover}
/>
@@ -407,33 +538,109 @@ export function TimelineResourcePanel({
// ─── Tooltip sub-component (portal-free: positioned fixed) ──────────────────
function ResourcePanelTooltips({
heatmapTooltipRef,
heatmapTooltipPos,
vacationTooltipRef,
vacationTooltipPos,
heatmapHover,
vacationHover,
}: {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
vacationTooltipPos: { left: number; top: number };
heatmapHover: {
date: Date;
totalH: number;
pct: number;
breakdown: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null }[];
breakdown: {
projectId: string;
shortCode: string;
projectName: string;
orderType: string;
hoursPerDay: number;
responsiblePerson?: string | null;
}[];
} | null;
vacationHover: {
type: string; startDate: Date | string; endDate: Date | string; note?: string | null;
type: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
requestedBy?: { name?: string | null; email: string } | null;
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
} | null;
}) {
// These tooltips are rendered here but positioned by the parent's native
// mousemove handler via ref. The parent passes tooltip refs via the
// TimelineView orchestrator. For simplicity, we keep the tooltip DOM
// here but expose ref-based positioning from the parent via
// data-attributes that the parent's mousemove handler targets.
//
// NOTE: The actual positioning is still done by the parent TimelineView's
// native mousemove event handler using refs. These tooltips are rendered
// inside TimelineView's return, not here. This sub-component is a no-op
// for tooltip DOM — the parent handles it.
return null;
return (
<>
{heatmapHover ? (
<div
ref={heatmapTooltipRef}
style={{
left: heatmapTooltipPos.left,
top: heatmapTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
<span className="text-[11px] text-gray-300">
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
</span>
</div>
<div className="mt-2 space-y-1.5">
{heatmapHover.breakdown.length > 0 ? (
heatmapHover.breakdown.slice(0, 6).map((entry) => (
<div
key={`${entry.projectId}-${entry.shortCode}`}
className="flex items-start justify-between gap-3"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{entry.shortCode ? `${entry.shortCode} · ` : ""}
{entry.projectName}
</div>
<div className="truncate text-[11px] text-gray-400">
{entry.responsiblePerson
? `Lead: ${entry.responsiblePerson}`
: entry.orderType}
</div>
</div>
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
{entry.hoursPerDay}h
</span>
</div>
))
) : (
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
)}
</div>
</div>
) : null}
{vacationHover ? (
<div
ref={vacationTooltipRef}
style={{
left: vacationTooltipPos.left,
top: vacationTooltipPos.top,
backgroundColor: "rgba(120, 53, 15, 0.95)",
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
) : null}
</>
);
}
// ─── Helper types ───────────────────────────────────────────────────────────
@@ -511,9 +718,7 @@ function renderRangeOverlay(
}
const end = rangeState.currentDate ?? rangeState.startDate;
const [selStart, selEnd] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate];
const left = toLeft(selStart);
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
@@ -537,6 +742,11 @@ function renderAllocBlocksFromData(
totalCanvasWidth: number,
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
anchorX: number,
anchorY: number,
) => void,
) {
const anyDragActive = dragState.isDragging || allocDragState.isActive;
@@ -566,7 +776,11 @@ function renderAllocBlocksFromData(
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
const blockHeight = SUB_LANE_HEIGHT - 8;
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "" };
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
bg: "bg-gray-400",
text: "text-white",
light: "",
};
const HANDLE_W = width >= 48 ? 10 : 0;
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
@@ -586,16 +800,25 @@ function renderAllocBlocksFromData(
key={alloc.id}
className={clsx(
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block",
colors.bg, colors.text,
colors.bg,
colors.text,
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
isBeingDragged
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]"
: isOtherDragged
? "opacity-30 z-[10]"
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
? "opacity-30 z-[10]"
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
)}
style={{ left: left + 2, width: width - 4, top: blockTop, height: blockHeight }}
onContextMenu={(e) => e.preventDefault()}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
e.clientX,
e.clientY,
);
}}
>
{/* Left resize handle */}
{HANDLE_W > 0 && (
@@ -603,7 +826,10 @@ function renderAllocBlocksFromData(
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
>
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
@@ -619,12 +845,19 @@ function renderAllocBlocksFromData(
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
)}
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, allocInfo);
}}
>
{hasRecurrence && width > 28 && <span className="text-[10px] opacity-80 flex-shrink-0"></span>}
{hasRecurrence && width > 28 && (
<span className="text-[10px] opacity-80 flex-shrink-0"></span>
)}
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
{width > 130 && <span className="text-[10px] opacity-75 truncate">{alloc.role}</span>}
{width > 190 && <span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>}
{width > 190 && (
<span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>
)}
</div>
{/* Right resize handle */}
@@ -633,7 +866,10 @@ function renderAllocBlocksFromData(
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
style={{ width: HANDLE_W }}
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
>
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
<div className="w-px h-2.5 bg-white rounded" />
@@ -654,17 +890,16 @@ function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
return list.reduce((sum, a) => {
const s = new Date(a.startDate); s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate); e.setHours(0, 0, 0, 0);
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
}, 0);
}
return (
<div
className="absolute inset-x-0 bottom-1 pointer-events-none"
style={{ height: GRAPH_H }}
>
<div className="absolute inset-x-0 bottom-1 pointer-events-none" style={{ height: GRAPH_H }}>
{dates.map((date, i) => {
const t = date.getTime();
const totalH = hoursOnDay(allocs, t);
@@ -677,9 +912,11 @@ function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_
key={i}
className={clsx(
"absolute bottom-0 rounded-t-sm",
totalH > 12 ? "bg-red-500 opacity-80"
: totalH > 8 ? "bg-amber-400 opacity-80"
: "bg-brand-500 opacity-80",
totalH > 12
? "bg-red-500 opacity-80"
: totalH > 8
? "bg-amber-400 opacity-80"
: "bg-brand-500 opacity-80",
)}
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: totalBarH }}
/>
@@ -691,13 +928,20 @@ function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_
// ─── Heatmap-mode: utilisation colour overlay ────────────────────────────────
function renderHeatmapOverlay(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number, heatmapScheme: HeatmapColorScheme) {
function renderHeatmapOverlay(
allocs: TimelineAssignmentEntry[],
dates: Date[],
CELL_WIDTH: number,
heatmapScheme: HeatmapColorScheme,
) {
const REF_H = 8;
return dates.map((date, i) => {
const t = date.getTime();
const totalH = allocs.reduce((sum, a) => {
const s = new Date(a.startDate); s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate); e.setHours(0, 0, 0, 0);
const s = new Date(a.startDate);
s.setHours(0, 0, 0, 0);
const e = new Date(a.endDate);
e.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
}, 0);
const bg = heatmapBgColor((totalH / REF_H) * 100, heatmapScheme);
@@ -722,6 +966,11 @@ function renderDailyBars(
allocDragState: AllocDragState,
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
onAllocationContextMenu: (
info: { allocationId: string; projectId: string },
anchorX: number,
anchorY: number,
) => void,
toLeft: (d: Date) => number,
toWidth: (s: Date, e: Date) => number,
totalCanvasWidth: number,
@@ -734,9 +983,16 @@ function renderDailyBars(
const covering = allocs.filter((a) => {
const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id;
const s = new Date(isDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : a.startDate);
const e = new Date(isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate);
s.setHours(0, 0, 0, 0); e.setHours(0, 0, 0, 0);
const s = new Date(
isDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: a.startDate,
);
const e = new Date(
isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate,
);
s.setHours(0, 0, 0, 0);
e.setHours(0, 0, 0, 0);
return t >= s.getTime() && t <= e.getTime();
});
@@ -747,20 +1003,33 @@ function renderDailyBars(
let stackedH = 0;
const segs: React.ReactNode[] = covering.map((alloc) => {
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "" };
const segH = Math.max(2, Math.min(
BAR_AREA - stackedH,
Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA),
));
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? {
bg: "bg-gray-400",
text: "text-white",
light: "",
};
const segH = Math.max(
2,
Math.min(BAR_AREA - stackedH, Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA)),
);
const bottom = 4 + stackedH;
stackedH += segH;
const isBeingDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
const dispStart = new Date(isBeingDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : alloc.startDate);
const dispEnd = new Date(isBeingDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : alloc.endDate);
dispStart.setHours(0, 0, 0, 0); dispEnd.setHours(0, 0, 0, 0);
const dispStart = new Date(
isBeingDragged && allocDragState.currentStartDate
? allocDragState.currentStartDate
: alloc.startDate,
);
const dispEnd = new Date(
isBeingDragged && allocDragState.currentEndDate
? allocDragState.currentEndDate
: alloc.endDate,
);
dispStart.setHours(0, 0, 0, 0);
dispEnd.setHours(0, 0, 0, 0);
const isFirstDay = t === dispStart.getTime();
const isLastDay = t === dispEnd.getTime();
const isLastDay = t === dispEnd.getTime();
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
const allocInfo: AllocMouseDownInfo = {
@@ -785,27 +1054,53 @@ function renderDailyBars(
: "hover:opacity-80 z-[10]",
)}
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, height: segH, bottom }}
onContextMenu={(e) => e.preventDefault()}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onAllocationContextMenu(
{ allocationId: alloc.id, projectId: alloc.projectId },
e.clientX,
e.clientY,
);
}}
>
{isFirstDay && EDGE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
style={{ width: EDGE_W }}
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" }); }}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
}}
/>
)}
<div
className={clsx("flex-1 min-w-0", "cursor-grab")}
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, allocInfo); }}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, allocInfo);
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, allocInfo);
}}
/>
{isLastDay && EDGE_W > 0 && (
<div
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
style={{ width: EDGE_W }}
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" }); }}
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
onMouseDown={(e) => {
e.stopPropagation();
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
}}
onTouchStart={(e) => {
e.stopPropagation();
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
}}
/>
)}
</div>
@@ -3,6 +3,7 @@
import { clsx } from "clsx";
import { useRef } from "react";
import { TimelineFilter, type TimelineFilters } from "./TimelineFilter.js";
import { TimelineQuickFilters } from "./TimelineQuickFilters.js";
interface TimelineToolbarProps {
viewMode: "resource" | "project";
@@ -42,9 +43,22 @@ export function TimelineToolbar({
onRedo,
}: TimelineToolbarProps) {
const activeFilterCount =
filters.chapters.length + filters.eids.length + filters.projectIds.length;
filters.clientIds.length +
filters.chapters.length +
filters.eids.length +
filters.projectIds.length;
const filterAnchorRef = useRef<HTMLDivElement | null>(null);
function clearQuickFilters() {
onFiltersChange({
...filters,
clientIds: [],
chapters: [],
eids: [],
projectIds: [],
});
}
return (
<div className="app-toolbar flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-gray-500 dark:text-gray-400">
@@ -54,6 +68,17 @@ export function TimelineToolbar({
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<TimelineQuickFilters filters={filters} onChange={onFiltersChange} />
{activeFilterCount > 0 && (
<button
type="button"
onClick={clearQuickFilters}
className="rounded-xl border border-gray-300 bg-white 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:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
Clear filters
</button>
)}
{/* Timeline navigation */}
<div className="flex items-center gap-1">
<button
@@ -248,19 +248,35 @@ function TimelineViewContent({
const dragTooltipRef = useRef<HTMLDivElement>(null);
const allocTooltipRef = useRef<HTMLDivElement>(null);
const rangeHintRef = useRef<HTMLDivElement>(null);
const heatmapTooltipRef = useRef<HTMLDivElement>(null);
const vacationTooltipRef = useRef<HTMLDivElement>(null);
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 hasActivePointerOverlay =
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting;
function openAllocationPopoverAt(
info: {
allocationId: string;
projectId: string;
},
anchorX: number,
anchorY: number,
) {
setPopover({
allocationId: info.allocationId,
projectId: info.projectId,
x: anchorX,
y: anchorY,
});
}
// Keep cellWidthRef in sync so the drag hook uses the correct value.
cellWidthRef.current = CELL_WIDTH;
// ─── Native mousemove listener — updates tooltips without React state ─────
useEffect(() => {
if (!hasActivePointerOverlay) return;
const el = canvasRef.current;
if (!el) return;
const handler = (e: MouseEvent) => {
@@ -279,18 +295,10 @@ function TimelineViewContent({
rangeHintRef.current.style.left = `${x + 12}px`;
rangeHintRef.current.style.top = `${y - 28}px`;
}
if (heatmapTooltipRef.current) {
heatmapTooltipRef.current.style.left = `${x + 16}px`;
heatmapTooltipRef.current.style.top = `${y - 52}px`;
}
if (vacationTooltipRef.current) {
vacationTooltipRef.current.style.left = `${x + 14}px`;
vacationTooltipRef.current.style.top = `${y - 8}px`;
}
};
el.addEventListener("mousemove", handler, { passive: true });
return () => el.removeEventListener("mousemove", handler);
}, [isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
}, [hasActivePointerOverlay, isLoading, mousePosRef]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Shift+wheel → horizontal scroll ──────────────────────────────────────
useEffect(() => {
@@ -336,6 +344,7 @@ function TimelineViewContent({
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!hasActivePointerOverlay) return;
onCanvasMouseMove(e);
};
@@ -396,10 +405,10 @@ function TimelineViewContent({
onMouseUp={(e) => void onCanvasMouseUp(e)}
onMouseLeave={onCanvasMouseLeave}
onTouchMove={(e) => {
if (!hasActivePointerOverlay) return;
onCanvasTouchMove(e);
}}
onTouchEnd={(e) => void onCanvasTouchEnd(e)}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
(dragState.isDragging || allocDragState.isActive) && "cursor-grabbing select-none",
rangeState.isSelecting && "cursor-crosshair select-none",
@@ -417,6 +426,7 @@ function TimelineViewContent({
onAllocTouchStart={onAllocTouchStart}
onRowMouseDown={onRowMouseDown}
onRowTouchStart={onRowTouchStart}
onAllocationContextMenu={openAllocationPopoverAt}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
@@ -429,6 +439,7 @@ function TimelineViewContent({
{viewMode === "project" && (
<TimelineProjectPanel
scrollContainerRef={scrollContainerRef}
dragState={dragState}
allocDragState={allocDragState}
rangeState={rangeState}
@@ -440,6 +451,7 @@ function TimelineViewContent({
onRowTouchStart={onRowTouchStart}
onOpenPanel={setOpenPanelProjectId}
onOpenDemandClick={setOpenDemandToAssign}
onAllocationContextMenu={openAllocationPopoverAt}
CELL_WIDTH={CELL_WIDTH}
dates={dates}
totalCanvasWidth={totalCanvasWidth}
+108 -60
View File
@@ -154,7 +154,11 @@ export function useTimelineDrag({
cellWidthRef.current = cellWidth;
// Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({ x: 0, y: 0, decided: false });
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({
x: 0,
y: 0,
decided: false,
});
const onBlockClickRef = useRef(onBlockClick);
onBlockClickRef.current = onBlockClick;
@@ -190,7 +194,11 @@ export function useTimelineDrag({
void utils.project.list.invalidate();
onShiftApplied?.(data.project.id);
},
}) as { isPending: boolean; mutate: (...args: unknown[]) => void; mutateAsync: (...args: unknown[]) => Promise<unknown> };
}) as {
isPending: boolean;
mutate: (...args: unknown[]) => void;
mutateAsync: (...args: unknown[]) => Promise<unknown>;
};
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
@@ -211,12 +219,16 @@ export function useTimelineDrag({
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
const onProjectBarMouseDown = useCallback(
(e: React.MouseEvent, opts: {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
}) => {
(
e: React.MouseEvent,
opts: {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
},
) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const state: DragState = {
@@ -241,15 +253,19 @@ export function useTimelineDrag({
// Legacy — kept for backward compat (triggers project shift from allocation block)
const onBlockMouseDown = useCallback(
(e: React.MouseEvent, opts: {
projectId: string;
projectName: string;
allocationId?: string;
startDate: Date;
endDate: Date;
blockLeft: number;
blockWidth: number;
}) => {
(
e: React.MouseEvent,
opts: {
projectId: string;
projectName: string;
allocationId?: string;
startDate: Date;
endDate: Date;
blockLeft: number;
blockWidth: number;
},
) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const state: DragState = {
@@ -279,16 +295,20 @@ export function useTimelineDrag({
// moving quickly or scrolling into the sticky header area).
const onAllocMouseDown = useCallback(
(e: React.MouseEvent, opts: {
mode: AllocDragMode;
allocationId: string;
mutationAllocationId?: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
}) => {
(
e: React.MouseEvent,
opts: {
mode: AllocDragMode;
allocationId: string;
mutationAllocationId?: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
},
) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
@@ -389,12 +409,16 @@ export function useTimelineDrag({
// ── Range-select ────────────────────────────────────────────────────────────
const onRowMouseDown = useCallback(
(e: React.MouseEvent, opts: {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
}) => {
(
e: React.MouseEvent,
opts: {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
},
) => {
if (dragStateRef.current.isDragging || allocDragRef.current.isActive) return;
if (e.button !== 0) return;
e.preventDefault();
const state: RangeState = {
isSelecting: true,
@@ -424,7 +448,12 @@ export function useTimelineDrag({
newStart.setDate(newStart.getDate() + daysDelta);
const newEnd = new Date(drag.originalEndDate);
newEnd.setDate(newEnd.getDate() + daysDelta);
const updated: DragState = { ...drag, currentStartDate: newStart, currentEndDate: newEnd, daysDelta };
const updated: DragState = {
...drag,
currentStartDate: newStart,
currentEndDate: newEnd,
daysDelta,
};
dragStateRef.current = updated;
setDragState(updated);
}
@@ -488,9 +517,7 @@ export function useTimelineDrag({
if (range.isSelecting && range.resourceId && range.startDate) {
const endDate = range.currentDate ?? range.startDate;
const [startDate, finalEnd] =
range.startDate <= endDate
? [range.startDate, endDate]
: [endDate, range.startDate];
range.startDate <= endDate ? [range.startDate, endDate] : [endDate, range.startDate];
onRangeSelected?.({
resourceId: range.resourceId,
@@ -529,16 +556,23 @@ export function useTimelineDrag({
}
const onProjectBarTouchStart = useCallback(
(e: React.TouchEvent, opts: {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
}) => {
(
e: React.TouchEvent,
opts: {
projectId: string;
projectName: string;
startDate: Date;
endDate: Date;
},
) => {
e.preventDefault();
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
onProjectBarMouseDown(
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
{
clientX: toClientX(e),
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.MouseEvent,
opts,
);
},
@@ -546,20 +580,27 @@ export function useTimelineDrag({
);
const onAllocTouchStart = useCallback(
(e: React.TouchEvent, opts: {
mode: AllocDragMode;
allocationId: string;
mutationAllocationId?: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
}) => {
(
e: React.TouchEvent,
opts: {
mode: AllocDragMode;
allocationId: string;
mutationAllocationId?: string;
projectId: string;
projectName: string;
resourceId: string | null;
startDate: Date;
endDate: Date;
},
) => {
e.preventDefault();
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: true };
onAllocMouseDown(
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
{
clientX: toClientX(e),
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.MouseEvent,
opts,
);
},
@@ -567,15 +608,22 @@ export function useTimelineDrag({
);
const onRowTouchStart = useCallback(
(e: React.TouchEvent, opts: {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
}) => {
(
e: React.TouchEvent,
opts: {
resourceId: string;
startDate: Date;
suggestedProjectId?: string;
},
) => {
e.preventDefault();
touchStartRef.current = { x: toClientX(e), y: e.touches[0]?.clientY ?? 0, decided: false };
onRowMouseDown(
{ clientX: toClientX(e), preventDefault: () => {}, stopPropagation: () => {} } as unknown as React.MouseEvent,
{
clientX: toClientX(e),
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as React.MouseEvent,
opts,
);
},