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:
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user