diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts index 0e26f79..c68f84e 100644 --- a/apps/web/e2e/auth.spec.ts +++ b/apps/web/e2e/auth.spec.ts @@ -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 }) => { diff --git a/apps/web/e2e/timeline.spec.ts b/apps/web/e2e/timeline.spec.ts index 460819a..b442c77 100644 --- a/apps/web/e2e/timeline.spec.ts +++ b/apps/web/e2e/timeline.spec.ts @@ -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(); + }); }); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 558121d..49e369a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -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 = { diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 38dea86..def4c9a 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -57,48 +57,115 @@ function AdminIcon() { return ; } -const allNavItems = [ - { href: "/dashboard", label: "Dashboard", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - { href: "/resources", label: "Resources", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/projects", label: "Projects", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, - { href: "/estimates", label: "Estimates", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - { href: "/allocations", label: "Allocations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/timeline", label: "Timeline", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - { href: "/staffing", label: "Staffing", icon: , roles: ["ADMIN", "MANAGER"] }, - { href: "/vacations", label: "Vacations", icon: , roles: ["ADMIN", "MANAGER"] }, - { href: "/vacations/my", label: "My Vacations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, - { href: "/roles", label: "Roles", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, - { href: "/analytics/skills", label: "Skills Analytics", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, - { href: "/reports/chargeability", label: "Chargeability", icon: , 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: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, + { href: "/timeline", label: "Timeline", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, + { href: "/allocations", label: "Allocations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { href: "/staffing", label: "Staffing", icon: , roles: ["ADMIN", "MANAGER"] }, + ], + }, + { + label: "Estimating", + collapsed: true, + items: [ + { href: "/estimates", label: "Estimates", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, + { href: "/admin/rate-cards", label: "Rate Cards", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { href: "/admin/effort-rules", label: "Effort Rules", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + ], + }, + { + label: "Resources", + items: [ + { href: "/resources", label: "Resources", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + { href: "/projects", label: "Projects", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, + { href: "/roles", label: "Roles", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + ], + }, + { + label: "Analytics", + items: [ + { href: "/analytics/skills", label: "Skills Analytics", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, + { href: "/reports/chargeability", label: "Chargeability", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, + ], + }, + { + label: "Time Off", + items: [ + { href: "/vacations/my", label: "My Vacations", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] }, + { href: "/vacations", label: "Vacation Mgmt", icon: , 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: }, - { href: "/admin/countries", label: "Countries", icon: }, - { href: "/admin/org-units", label: "Org Units", icon: }, - { href: "/admin/utilization-categories", label: "Util. Categories", icon: }, { href: "/admin/clients", label: "Clients", icon: }, - { href: "/admin/rate-cards", label: "Rate Cards", icon: }, - { href: "/admin/effort-rules", label: "Effort Rules", icon: }, - { href: "/admin/experience-multipliers", label: "Exp. Multipliers", icon: }, - { href: "/admin/management-levels", label: "Mgmt Levels", icon: }, + { + label: "ACN-Orga", + collapsed: true, + items: [ + { href: "/admin/countries", label: "Countries", icon: }, + { href: "/admin/org-units", label: "Org Units", icon: }, + { href: "/admin/utilization-categories", label: "Util. Categories", icon: }, + { href: "/admin/management-levels", label: "Mgmt Levels", icon: }, + ], + }, + { href: "/admin/calculation-rules", label: "Calc. Rules", icon: }, { href: "/admin/users", label: "Users", icon: }, { href: "/admin/settings", label: "Settings", icon: }, { href: "/admin/skill-import", label: "Skill Import", icon: }, ]; -const managerNavItems = [ - { href: "/admin/vacations", label: "Vacation Mgmt", icon: }, -]; - 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>(() => { + const initial: Record = {}; + 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 ( <>