From 4f48afe7b408041393b64cfa2667e8c5c33caf45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 28 Mar 2026 22:49:28 +0100 Subject: [PATCH] feat(planning): ship holiday-aware planning and assistant upgrades --- .env.example | 6 +- .github/workflows/ci.yml | 2 + .gitignore | 2 + Dockerfile.dev | 2 +- Dockerfile.prod | 4 +- apps/web/e2e/holiday-calendar.spec.ts | 57 + apps/web/e2e/test-server.mjs | 351 +++++ apps/web/e2e/timeline.spec.ts | 82 +- apps/web/e2e/vacations.spec.ts | 88 +- apps/web/next-env.d.ts | 2 +- apps/web/next.config.ts | 2 + apps/web/playwright.config.ts | 17 +- .../src/app/(app)/admin/vacations/page.tsx | 37 +- .../app/(app)/resources/ResourcesClient.tsx | 75 +- apps/web/src/app/global-error.tsx | 3 +- .../components/admin/SystemRolesClient.tsx | 4 + apps/web/src/components/admin/UsersClient.tsx | 2 + .../analytics/ComputationGraphClient.tsx | 164 ++ .../useComputationGraphData.ts | 9 + .../src/components/assistant/ChatMessage.tsx | 121 +- .../src/components/assistant/ChatPanel.tsx | 62 +- .../components/dashboard/DashboardClient.tsx | 6 + .../components/dashboard/WidgetContainer.tsx | 84 +- .../widgets/BudgetForecastWidget.tsx | 174 ++- .../dashboard/widgets/ChargeabilityWidget.tsx | 146 +- .../dashboard/widgets/DemandWidget.tsx | 129 +- .../dashboard/widgets/PeakTimesChart.tsx | 197 ++- .../dashboard/widgets/PeakTimesWidget.tsx | 355 ++++- .../dashboard/widgets/ProjectHealthWidget.tsx | 127 +- .../dashboard/widgets/StatCardsWidget.tsx | 60 +- apps/web/src/components/layout/AppShell.tsx | 1 + .../notifications/NotificationBell.tsx | 60 +- .../src/components/reports/ReportBuilder.tsx | 401 ++++- .../src/components/staffing/StaffingPanel.tsx | 264 +++- .../components/timeline/AllocationPopover.tsx | 32 +- .../src/components/timeline/DemandPopover.tsx | 29 +- .../timeline/NewAllocationPopover.tsx | 24 +- .../components/timeline/ResourceHoverCard.tsx | 42 +- .../components/timeline/TimelineContext.tsx | 48 +- .../components/timeline/TimelineFilter.tsx | 60 +- .../timeline/TimelineProjectPanel.tsx | 76 +- .../timeline/TimelineQuickFilters.tsx | 77 +- .../timeline/TimelineResourcePanel.tsx | 14 +- .../components/timeline/TimelineTooltip.tsx | 119 +- .../src/components/timeline/renderHelpers.tsx | 5 + .../src/components/ui/ColumnTogglePanel.tsx | 208 +-- .../vacations/HolidayCalendarEditor.tsx | 865 +++++++++++ .../components/vacations/VacationClient.tsx | 8 + .../components/vacations/VacationModal.tsx | 160 +- apps/web/src/hooks/useAnchoredOverlay.ts | 155 ++ apps/web/src/hooks/useViewportPopover.ts | 110 ++ apps/web/src/server/auth.ts | 7 +- apps/web/tsconfig.json | 5 +- docker-compose.prod.yml | 4 + docker-compose.yml | 5 +- docs/assistant-capability-gap-analysis.md | 492 ++++++ docs/holiday-calendar-implementation-plan.md | 393 +++++ package.json | 11 +- packages/api/package.json | 1 + .../src/__tests__/allocation-router.test.ts | 116 +- .../src/__tests__/assistant-insights.test.ts | 126 ++ .../src/__tests__/assistant-router.test.ts | 34 + .../assistant-tools-advanced.test.ts | 262 ++++ .../assistant-tools-holidays.test.ts | 575 +++++++ .../__tests__/chargeability-alerts.test.ts | 95 ++ .../chargeability-report-router.test.ts | 221 +++ .../computation-graph-router.test.ts | 195 +++ .../src/__tests__/dashboard-router.test.ts | 50 + .../src/__tests__/effort-rule-router.test.ts | 5 + .../src/__tests__/entitlement-router.test.ts | 202 ++- .../src/__tests__/event-bus-debounce.test.ts | 3 + .../experience-multiplier-router.test.ts | 5 + .../__tests__/holiday-calendar-router.test.ts | 168 ++ .../src/__tests__/notification-router.test.ts | 3 + .../api/src/__tests__/project-router.test.ts | 63 + .../api/src/__tests__/report-router.test.ts | 118 ++ .../api/src/__tests__/resource-router.test.ts | 241 +++ .../api/src/__tests__/staffing-router.test.ts | 237 ++- .../src/__tests__/timeline-allocation.test.ts | 79 + .../api/src/__tests__/vacation-router.test.ts | 396 ++++- packages/api/src/lib/auto-staffing.ts | 59 +- packages/api/src/lib/chargeability-alerts.ts | 125 +- packages/api/src/lib/holiday-auto-import.ts | 74 +- packages/api/src/lib/holiday-availability.ts | 464 ++++++ packages/api/src/lib/logger.ts | 25 +- packages/api/src/lib/resource-capacity.ts | 439 ++++++ .../api/src/lib/resource-holiday-context.ts | 102 ++ packages/api/src/lib/vacation-day-count.ts | 112 ++ packages/api/src/router/allocation.ts | 108 +- packages/api/src/router/assistant-insights.ts | 243 +++ packages/api/src/router/assistant-tools.ts | 1363 +++++++++++++++-- packages/api/src/router/assistant.ts | 71 +- .../api/src/router/chargeability-report.ts | 184 +-- packages/api/src/router/computation-graph.ts | 275 +++- packages/api/src/router/entitlement.ts | 100 +- packages/api/src/router/holiday-calendar.ts | 471 ++++++ packages/api/src/router/index.ts | 2 + packages/api/src/router/project.ts | 52 +- packages/api/src/router/report.ts | 706 ++++++++- packages/api/src/router/resource.ts | 289 +++- packages/api/src/router/scenario.ts | 240 ++- packages/api/src/router/staffing.ts | 580 ++++++- packages/api/src/router/timeline.ts | 113 +- packages/api/src/router/vacation.ts | 225 ++- packages/api/src/trpc.ts | 15 +- .../src/__tests__/dashboard.test.ts | 564 ++++++- .../src/__tests__/demand-assignment.test.ts | 13 +- packages/application/src/index.ts | 8 + .../dashboard/get-budget-forecast.ts | 196 ++- .../dashboard/get-chargeability-overview.ts | 237 ++- .../src/use-cases/dashboard/get-demand.ts | 332 +++- .../src/use-cases/dashboard/get-overview.ts | 98 +- .../src/use-cases/dashboard/get-peak-times.ts | 279 +++- .../use-cases/dashboard/get-project-health.ts | 157 +- .../use-cases/dashboard/holiday-capacity.ts | 459 ++++++ .../src/use-cases/dashboard/index.ts | 8 + .../load-dashboard-planning-read-model.ts | 14 + .../use-cases/dispo-import/read-workbook.ts | 2 +- .../recompute-resource-value-scores.ts | 68 +- packages/db/package.json | 25 +- packages/db/prisma/schema.prisma | 77 +- packages/db/src/destructive-db-guard.test.ts | 100 ++ packages/db/src/destructive-db-guard.ts | 70 + packages/db/src/generate-excel.ts | 81 +- .../db/src/holiday-calendar-seed-data.test.ts | 89 ++ packages/db/src/holiday-calendar-seed-data.ts | 325 ++++ packages/db/src/holiday-demo-profiles.test.ts | 30 + packages/db/src/holiday-demo-profiles.ts | 39 + packages/db/src/reset-dispo-import.ts | 7 + packages/db/src/safe-destructive-env.ts | 14 + packages/db/src/seed-dispo-v2.ts | 7 +- packages/db/src/seed-holiday-calendars.ts | 205 +++ .../db/src/seed-holiday-demo-resources.ts | 125 ++ packages/db/src/seed-vacations.ts | 5 + packages/db/src/seed.ts | 42 +- .../engine/src/__tests__/recurrence.test.ts | 8 +- .../src/__tests__/shoring-ratio.test.ts | 12 +- .../src/__tests__/dashboard-layout.test.ts | 3 +- .../shared/src/schemas/dashboard.schema.ts | 26 +- .../src/schemas/holiday-calendar.schema.ts | 50 + packages/shared/src/schemas/index.ts | 1 + packages/shared/src/types/dashboard.ts | 43 +- packages/shared/src/types/permissions.ts | 1 + .../src/__tests__/capacity-analyzer.test.ts | 25 +- scripts/db-doctor.mjs | 41 + scripts/harden-postgres.sh | 5 +- scripts/load-env.mjs | 45 + scripts/restart.sh | 2 +- scripts/start.sh | 27 +- scripts/stop.sh | 10 +- scripts/with-env.mjs | 26 + 151 files changed, 17738 insertions(+), 1940 deletions(-) create mode 100644 apps/web/e2e/holiday-calendar.spec.ts create mode 100644 apps/web/e2e/test-server.mjs create mode 100644 apps/web/src/components/vacations/HolidayCalendarEditor.tsx create mode 100644 apps/web/src/hooks/useAnchoredOverlay.ts create mode 100644 apps/web/src/hooks/useViewportPopover.ts create mode 100644 docs/assistant-capability-gap-analysis.md create mode 100644 docs/holiday-calendar-implementation-plan.md create mode 100644 packages/api/src/__tests__/assistant-insights.test.ts create mode 100644 packages/api/src/__tests__/assistant-router.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-advanced.test.ts create mode 100644 packages/api/src/__tests__/assistant-tools-holidays.test.ts create mode 100644 packages/api/src/__tests__/chargeability-alerts.test.ts create mode 100644 packages/api/src/__tests__/computation-graph-router.test.ts create mode 100644 packages/api/src/__tests__/holiday-calendar-router.test.ts create mode 100644 packages/api/src/__tests__/report-router.test.ts create mode 100644 packages/api/src/lib/holiday-availability.ts create mode 100644 packages/api/src/lib/resource-capacity.ts create mode 100644 packages/api/src/lib/resource-holiday-context.ts create mode 100644 packages/api/src/lib/vacation-day-count.ts create mode 100644 packages/api/src/router/assistant-insights.ts create mode 100644 packages/api/src/router/holiday-calendar.ts create mode 100644 packages/application/src/use-cases/dashboard/holiday-capacity.ts create mode 100644 packages/db/src/destructive-db-guard.test.ts create mode 100644 packages/db/src/destructive-db-guard.ts create mode 100644 packages/db/src/holiday-calendar-seed-data.test.ts create mode 100644 packages/db/src/holiday-calendar-seed-data.ts create mode 100644 packages/db/src/holiday-demo-profiles.test.ts create mode 100644 packages/db/src/holiday-demo-profiles.ts create mode 100644 packages/db/src/safe-destructive-env.ts create mode 100644 packages/db/src/seed-holiday-calendars.ts create mode 100644 packages/db/src/seed-holiday-demo-resources.ts create mode 100644 packages/shared/src/schemas/holiday-calendar.schema.ts create mode 100644 scripts/db-doctor.mjs create mode 100644 scripts/load-env.mjs create mode 100644 scripts/with-env.mjs diff --git a/.env.example b/.env.example index 44b12a7..2c82012 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # Database -DATABASE_URL=postgresql://planarchy:planarchy_dev@localhost:5433/planarchy -DATABASE_URL_TEST=postgresql://planarchy:planarchy_test@localhost:5434/planarchy_test +DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken +DATABASE_URL_TEST=postgresql://capakraken:capakraken_test@localhost:5434/capakraken_test +ALLOW_DESTRUCTIVE_DB_TOOLS=false +CONFIRM_DESTRUCTIVE_DB_NAME= # Redis REDIS_URL=redis://localhost:6380 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 747b686..5a4dcea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,8 @@ jobs: --health-retries=5 env: DATABASE_URL: postgresql://capakraken:capakraken_test@localhost:5432/capakraken_test + ALLOW_DESTRUCTIVE_DB_TOOLS: "true" + CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test REDIS_URL: redis://localhost:6379 PORT: 3100 steps: diff --git a/.gitignore b/.gitignore index 992bebe..d8051bf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ # Build outputs .next/ +.next-e2e/ dist/ build/ .turbo/ @@ -20,6 +21,7 @@ test-results/ .env.test.local .env.production.local .env.*.local +*.e2e-backup # Logs *.log diff --git a/Dockerfile.dev b/Dockerfile.dev index eafbfe4..a423f15 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile COPY . . # Generate Prisma client -RUN pnpm --filter @planarchy/db db:generate +RUN pnpm --filter @capakraken/db db:generate EXPOSE 3100 diff --git a/Dockerfile.prod b/Dockerfile.prod index 4894f3d..81f5758 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -39,12 +39,12 @@ COPY --from=deps /app/ ./ COPY . . # Generate Prisma client -RUN pnpm --filter @planarchy/db db:generate +RUN pnpm --filter @capakraken/db db:generate # Build the Next.js application ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production -RUN pnpm --filter @planarchy/web build +RUN pnpm --filter @capakraken/web build # ============================================================ # Stage 3: Production runtime diff --git a/apps/web/e2e/holiday-calendar.spec.ts b/apps/web/e2e/holiday-calendar.spec.ts new file mode 100644 index 0000000..3f8bd8c --- /dev/null +++ b/apps/web/e2e/holiday-calendar.spec.ts @@ -0,0 +1,57 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function signInAsAdmin(page: Page) { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@capakraken.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); +} + +test.describe("Holiday Calendar Editor", () => { + test("creates a city calendar, previews a holiday, blocks duplicates and confirms deletion", async ({ page }) => { + const suffix = Date.now().toString(); + const calendarName = `E2E City Calendar ${suffix}`; + const holidayName = `E2E Local Holiday ${suffix}`; + + await signInAsAdmin(page); + await page.goto("/admin/vacations"); + + await expect(page.getByTestId("holiday-calendar-editor")).toBeVisible(); + + await page.getByTestId("holiday-calendar-name-input").fill(calendarName); + await page.getByTestId("holiday-calendar-scope-select").selectOption("CITY"); + await page.getByTestId("holiday-calendar-country-select").selectOption({ label: "Germany (DE)" }); + await page.getByTestId("holiday-calendar-city-select").selectOption({ label: "Muenchen" }); + await page.getByTestId("holiday-calendar-create-button").click(); + + await expect(page.getByTestId(/holiday-calendar-row-/).filter({ hasText: calendarName }).first()).toBeVisible(); + await expect(page.getByRole("heading", { name: calendarName })).toBeVisible(); + await expect(page.getByTestId("holiday-entry-create-button")).toBeVisible(); + + await page.getByTestId("holiday-entry-date-input").fill("2026-08-08"); + await page.getByTestId("holiday-entry-name-input").fill(holidayName); + await page.getByTestId("holiday-entry-source-input").fill("E2E"); + await page.getByTestId("holiday-entry-create-button").click(); + + await expect(page.getByText(holidayName).first()).toBeVisible(); + + await page.getByTestId("holiday-preview-year-input").fill("2026"); + await expect(page.getByTestId("holiday-preview-table")).toContainText(holidayName); + await expect(page.getByTestId("holiday-preview-table")).toContainText("2026-08-08"); + + await page.getByTestId("holiday-entry-date-input").fill("2026-08-08"); + await page.getByTestId("holiday-entry-name-input").fill(`${holidayName} Duplicate`); + await page.getByTestId("holiday-entry-create-button").click(); + + await expect(page.getByText("A holiday entry for this calendar and date already exists")).toBeVisible(); + + page.once("dialog", (dialog) => dialog.accept()); + await page.getByTestId(/holiday-entry-delete-/).first().click(); + await expect(page.getByText(holidayName).first()).not.toBeVisible(); + + page.once("dialog", (dialog) => dialog.accept()); + await page.getByTestId("holiday-calendar-delete-button").click(); + await expect(page.getByRole("heading", { name: calendarName })).not.toBeVisible(); + }); +}); diff --git a/apps/web/e2e/test-server.mjs b/apps/web/e2e/test-server.mjs new file mode 100644 index 0000000..3eed60d --- /dev/null +++ b/apps/web/e2e/test-server.mjs @@ -0,0 +1,351 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = resolve(currentDir, "../../.."); +const webRoot = resolve(currentDir, ".."); +const webEnvLocal = resolve(webRoot, ".env.local"); +const webEnvBackup = resolve(webRoot, ".env.local.e2e-backup"); +const webDistDir = ".next-e2e"; +const webDistDirPath = resolve(webRoot, webDistDir); +const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110"; +const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`; +const composeProjectName = `capakraken-e2e-${process.pid}`; +const managedEnvKeys = [ + "DATABASE_URL", + "REDIS_URL", + "NEXTAUTH_URL", + "NEXTAUTH_SECRET", + "AUTH_SECRET", + "E2E_TEST_MODE", + "NODE_ENV", + "PORT", +]; +const e2eComposePrefix = "capakraken-e2e-"; + +function dockerComposeArgs(...args) { + return ["compose", "-p", composeProjectName, ...args]; +} + +function loadEnvFile(filePath) { + const env = {}; + + try { + const contents = readFileSync(filePath, "utf8"); + for (const rawLine of contents.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const separatorIndex = line.indexOf("="); + if (separatorIndex <= 0) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + const rawValue = line.slice(separatorIndex + 1).trim(); + const quoted = + (rawValue.startsWith("\"") && rawValue.endsWith("\"")) || + (rawValue.startsWith("'") && rawValue.endsWith("'")); + + env[key] = quoted ? rawValue.slice(1, -1) : rawValue; + } + } catch { + // Keep local runs working even when no workspace .env is present. + } + + return env; +} + +function applyEnv(env) { + for (const [key, value] of Object.entries(env)) { + process.env[key] = value; + } +} + +function writeManagedWebEnv(rootEnv) { + if (existsSync(webEnvBackup)) { + rmSync(webEnvBackup, { force: true }); + } + + if (existsSync(webEnvLocal)) { + renameSync(webEnvLocal, webEnvBackup); + } + + const contents = managedEnvKeys + .map((key) => { + const value = rootEnv[key] ?? process.env[key]; + return value ? `${key}=${value}` : null; + }) + .filter(Boolean) + .join("\n"); + + writeFileSync(webEnvLocal, `${contents}\n`, "utf8"); +} + +function restoreWebEnv() { + if (existsSync(webEnvLocal)) { + rmSync(webEnvLocal, { force: true }); + } + + if (existsSync(webEnvBackup)) { + renameSync(webEnvBackup, webEnvLocal); + } +} + +function run(command, args, cwd) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: "inherit", + }); + + child.on("error", rejectPromise); + child.on("exit", (code) => { + if (code === 0) { + resolvePromise(); + return; + } + + rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`)); + }); + }); +} + +function runQuiet(command, args, cwd) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: "ignore", + }); + + child.on("error", rejectPromise); + child.on("exit", (code) => { + if (code === 0) { + resolvePromise(); + return; + } + + rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`)); + }); + }); +} + +function runCapture(command, args, cwd) { + return new Promise((resolvePromise, rejectPromise) => { + let stdout = ""; + let stderr = ""; + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + child.on("error", rejectPromise); + child.on("exit", (code) => { + if (code === 0) { + resolvePromise(stdout); + return; + } + + rejectPromise( + new Error( + `${command} ${args.join(" ")} exited with code ${code ?? "null"}${stderr ? `: ${stderr.trim()}` : ""}`, + ), + ); + }); + }); +} + +async function cleanupStaleE2eArtifacts() { + try { + const containerOutput = await runCapture("docker", ["ps", "-a", "--format", "{{.Names}}"], workspaceRoot); + const staleContainers = containerOutput + .split(/\r?\n/u) + .map((value) => value.trim()) + .filter((name) => name.startsWith(e2eComposePrefix)); + + if (staleContainers.length > 0) { + await runQuiet("docker", ["rm", "-f", ...staleContainers], workspaceRoot); + } + } catch { + // Best-effort cleanup only. + } + + try { + const networkOutput = await runCapture("docker", ["network", "ls", "--format", "{{.Name}}"], workspaceRoot); + const staleNetworks = networkOutput + .split(/\r?\n/u) + .map((value) => value.trim()) + .filter((name) => name.startsWith(e2eComposePrefix)); + + if (staleNetworks.length > 0) { + await runQuiet("docker", ["network", "rm", ...staleNetworks], workspaceRoot); + } + } catch { + // Best-effort cleanup only. + } +} + +async function ensureE2eDatabaseContainer() { + try { + await runQuiet("docker", dockerComposeArgs("rm", "-sf", "postgres-test"), workspaceRoot); + } catch { + // No previous test container to remove. + } + + await run("docker", dockerComposeArgs("--profile", "test", "up", "-d", "--force-recreate", "postgres-test"), workspaceRoot); + + const maxAttempts = 30; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await runQuiet( + "docker", + dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"), + workspaceRoot, + ); + return; + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000)); + } + } +} + +function parseDatabaseName(databaseUrl) { + const parsed = new URL(databaseUrl); + return parsed.pathname.replace(/^\/+/u, ""); +} + +async function canBindPort(port) { + return new Promise((resolvePromise) => { + const server = createServer(); + + server.once("error", () => { + resolvePromise(false); + }); + server.once("listening", () => { + server.close(() => resolvePromise(true)); + }); + + server.listen(port, "127.0.0.1"); + }); +} + +async function selectAvailablePort(preferredPort) { + const candidates = [ + preferredPort, + ...Array.from({ length: 50 }, (_, index) => preferredPort + index + 1), + ]; + + for (const candidate of candidates) { + if (await canBindPort(candidate)) { + return candidate; + } + } + + throw new Error(`No free host port available for postgres-test near ${preferredPort}.`); +} + +function replaceDatabasePort(databaseUrl, port) { + const parsed = new URL(databaseUrl); + parsed.port = String(port); + return parsed.toString(); +} + +let cleanedUpComposeProject = false; + +async function cleanupComposeProject() { + if (cleanedUpComposeProject) { + return; + } + + cleanedUpComposeProject = true; + + try { + await runQuiet("docker", dockerComposeArgs("down", "--remove-orphans"), workspaceRoot); + } catch { + // Best-effort cleanup only. + } +} + +const rootEnv = loadEnvFile(resolve(workspaceRoot, ".env")); +applyEnv(rootEnv); +let playwrightDatabaseUrl = process.env.PLAYWRIGHT_DATABASE_URL ?? process.env.DATABASE_URL_TEST; + +if (!playwrightDatabaseUrl) { + throw new Error("PLAYWRIGHT_DATABASE_URL or DATABASE_URL_TEST must be configured for E2E runs."); +} + +const requestedTestDbPort = Number(new URL(playwrightDatabaseUrl).port || "5434"); +const selectedTestDbPort = await selectAvailablePort(requestedTestDbPort); +playwrightDatabaseUrl = replaceDatabasePort(playwrightDatabaseUrl, selectedTestDbPort); + +const playwrightDatabaseName = parseDatabaseName(playwrightDatabaseUrl); + +if (!/(^|_)(test|e2e|ci)$/u.test(playwrightDatabaseName)) { + throw new Error( + `Refusing to run E2E destructive setup against non-test database '${playwrightDatabaseName}'. Set PLAYWRIGHT_DATABASE_URL to an isolated test database.`, + ); +} + +process.env.DATABASE_URL = playwrightDatabaseUrl; +process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl; +process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort); +process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true"; +process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName; +process.env.NODE_ENV = process.env.NODE_ENV ?? "development"; +process.env.PORT = e2ePort; +process.env.NEXTAUTH_URL = e2eBaseUrl; +process.env.AUTH_URL = e2eBaseUrl; +process.env.NEXT_DIST_DIR = webDistDir; +process.env.E2E_TEST_MODE = "true"; +writeManagedWebEnv(rootEnv); + +try { + await cleanupStaleE2eArtifacts(); + await ensureE2eDatabaseContainer(); + await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot); + await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot); + await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot); + rmSync(webDistDirPath, { recursive: true, force: true }); + + const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], { + cwd: webRoot, + env: process.env, + stdio: "inherit", + }); + + for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + restoreWebEnv(); + void cleanupComposeProject(); + server.kill(signal); + }); + } + + server.on("exit", async (code) => { + restoreWebEnv(); + await cleanupComposeProject(); + process.exit(code ?? 0); + }); +} catch (error) { + restoreWebEnv(); + await cleanupComposeProject(); + throw error; +} diff --git a/apps/web/e2e/timeline.spec.ts b/apps/web/e2e/timeline.spec.ts index 33eeda7..f317f29 100644 --- a/apps/web/e2e/timeline.spec.ts +++ b/apps/web/e2e/timeline.spec.ts @@ -1,4 +1,12 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; + +async function signInAsAdmin(page: Page) { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@capakraken.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); +} test.describe("Timeline", () => { test.describe.configure({ mode: "serial" }); @@ -7,11 +15,7 @@ test.describe("Timeline", () => { await page.addInitScript(() => { localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" })); }); - await page.goto("/auth/signin"); - await page.fill('input[type="email"]', "admin@capakraken.dev"); - await page.fill('input[type="password"]', "admin123"); - await page.click('button[type="submit"]'); - await expect(page).toHaveURL(/\/(dashboard|resources)/); + await signInAsAdmin(page); await page.goto("/timeline"); }); @@ -87,8 +91,13 @@ test.describe("Timeline", () => { .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 } }); + const resourceHoverTarget = page.getByTestId("timeline-resource-row-canvas").first(); + const resourceHoverBox = await resourceHoverTarget.boundingBox(); + expect(resourceHoverBox).not.toBeNull(); + if (!resourceHoverBox) { + throw new Error("Expected a resource timeline row canvas to be available"); + } + await page.mouse.move(resourceHoverBox.x + 120, resourceHoverBox.y + 20); await expect(heatmapTooltip).toBeVisible(); await expect .poll(async () => { @@ -109,8 +118,19 @@ test.describe("Timeline", () => { 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 } }); + const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first(); + const projectHoverBox = await projectHoverTarget.boundingBox(); + const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1); + const projectAllocationBox = await projectAllocation.boundingBox(); + expect(projectHoverBox).not.toBeNull(); + expect(projectAllocationBox).not.toBeNull(); + if (!projectHoverBox) { + throw new Error("Expected a project timeline row canvas to be available"); + } + if (!projectAllocationBox) { + throw new Error("Expected a project allocation block to be available"); + } + await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20); await expect(heatmapTooltip).toBeVisible(); await expect .poll(async () => { @@ -118,8 +138,48 @@ test.describe("Timeline", () => { }) .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(); }); + + test("shows resolved holiday overlays in the resource timeline and exposes the holiday name in the tooltip", async ({ + page, + }) => { + await page.goto("/timeline?startDate=2026-04-01&days=14&eids=bruce.banner", { + waitUntil: "domcontentloaded", + }); + + const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first(); + await expect(row).toBeVisible(); + + const holidayBlock = row.locator( + '[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]', + ).first(); + await expect(holidayBlock).toBeVisible(); + + const rowBox = await row.boundingBox(); + const holidayBox = await holidayBlock.boundingBox(); + expect(rowBox).not.toBeNull(); + expect(holidayBox).not.toBeNull(); + + if (!rowBox || !holidayBox) { + throw new Error("Expected timeline row and holiday block bounding boxes to be available"); + } + + await row.hover({ + position: { + x: holidayBox.x - rowBox.x + holidayBox.width / 2, + y: holidayBox.y - rowBox.y + Math.min(holidayBox.height / 2, rowBox.height - 4), + }, + }); + + const holidayTooltip = page + .locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50") + .or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" })) + .first(); + + await expect(holidayTooltip).toBeVisible(); + await expect(holidayTooltip).toContainText("Karfreitag"); + await expect(holidayTooltip).toContainText("3 April 2026"); + }); }); diff --git a/apps/web/e2e/vacations.spec.ts b/apps/web/e2e/vacations.spec.ts index 3b8910a..1aeef6f 100644 --- a/apps/web/e2e/vacations.spec.ts +++ b/apps/web/e2e/vacations.spec.ts @@ -1,13 +1,22 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; + +async function signInAsAdmin(page: Page) { + await page.goto("/auth/signin"); + await page.fill('input[type="email"]', "admin@capakraken.dev"); + await page.fill('input[type="password"]', "admin123"); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL(/\/(dashboard|resources)/); +} + +async function fillDisplayDate(page: Page, label: RegExp, value: string) { + const [year, month, day] = value.split("-"); + await page.getByLabel(label).fill(`${day}/${month}/${year}`); +} test.describe("Vacations", () => { test.describe("My Vacations (self-service)", () => { test.beforeEach(async ({ page }) => { - await page.goto("/auth/signin"); - await page.fill('input[type="email"]', "admin@capakraken.dev"); - await page.fill('input[type="password"]', "admin123"); - await page.click('button[type="submit"]'); - await expect(page).toHaveURL(/\/(dashboard|resources)/); + await signInAsAdmin(page); await page.goto("/vacations/my"); }); @@ -23,25 +32,19 @@ test.describe("Vacations", () => { ).toBeVisible({ timeout: 10000 }); }); - test("request vacation modal opens", async ({ page }) => { + test("request vacation is blocked without linked resource", async ({ page }) => { await page.waitForLoadState("networkidle"); const reqBtn = page.locator("button", { hasText: /Request Vacation/i }); - await reqBtn.click(); - // Modal should show vacation form + await expect(reqBtn).toBeDisabled(); await expect( - page.locator("text=Request Vacation").or(page.locator("text=Vacation Type")), + page.getByText("Your account is not linked to a resource. Please contact an administrator."), ).toBeVisible({ timeout: 5000 }); - await page.keyboard.press("Escape"); }); }); test.describe("Vacation Management", () => { test.beforeEach(async ({ page }) => { - await page.goto("/auth/signin"); - await page.fill('input[type="email"]', "admin@capakraken.dev"); - await page.fill('input[type="password"]', "admin123"); - await page.click('button[type="submit"]'); - await expect(page).toHaveURL(/\/(dashboard|resources)/); + await signInAsAdmin(page); await page.goto("/vacations"); }); @@ -62,12 +65,59 @@ test.describe("Vacations", () => { ).toBeVisible({ timeout: 10000 }); }); - test("filter chips are visible on list tab", async ({ page }) => { + test("filter controls are visible on list tab", async ({ page }) => { await page.waitForLoadState("networkidle"); - // Status filter options should be visible + const filters = page.getByRole("combobox"); + + await expect(filters).toHaveCount(3); + await expect(filters.nth(0)).toHaveValue("ALL"); + await expect(filters.nth(1)).toHaveValue("ALL"); + await expect(filters.nth(2)).toHaveValue(""); + }); + + test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => { + await page.waitForLoadState("networkidle"); + await page.getByRole("button", { name: /request vacation/i }).click(); + + await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0); + await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" }); + await page.getByLabel(/^type/i).selectOption("ANNUAL"); + await fillDisplayDate(page, /start date/i, "2026-01-06"); + await fillDisplayDate(page, /end date/i, "2026-01-06"); + + await expect(page.getByTestId("vacation-preview-card")).toBeVisible({ timeout: 10000 }); + await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1"); + await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0"); + await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0"); + await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06"); + await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany"); + await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar"); + }); + }); + + test.describe("Admin Holiday Calendar", () => { + test.beforeEach(async ({ page }) => { + await signInAsAdmin(page); + await page.goto("/admin/vacations"); + }); + + test("seeded holiday calendars can be selected and previewed", async ({ page }) => { + await expect(page.getByTestId("holiday-calendar-editor")).toBeVisible({ timeout: 10000 }); + const germanyCalendarRow = page + .getByTestId(/holiday-calendar-row-/) + .filter({ hasText: "Referenzfeiertage Deutschland 2026-2027" }) + .first(); + + await expect(germanyCalendarRow).toBeVisible({ timeout: 10000 }); + await germanyCalendarRow.click(); + await expect( - page.locator("button", { hasText: /All|Pending|Approved/i }).first(), + page.getByRole("heading", { name: "Referenzfeiertage Deutschland 2026-2027" }), ).toBeVisible({ timeout: 10000 }); + + await page.getByTestId("holiday-preview-year-input").fill("2026"); + await expect(page.getByTestId("holiday-preview-table")).toContainText("2026-01-01"); + await expect(page.getByTestId("holiday-preview-table")).toContainText("Neujahr"); }); }); }); diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 830fb59..2781e99 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 944bfbf..b3e23db 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,7 +2,9 @@ import path from "path"; import type { NextConfig } from "next"; const nextConfig: NextConfig = { + distDir: process.env.NEXT_DIST_DIR ?? ".next", output: "standalone", + outputFileTracingRoot: path.resolve(__dirname, "../.."), devIndicators: false, experimental: { optimizePackageImports: ["recharts", "date-fns"], diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index c1ba0da..cd6eaa3 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,14 +1,17 @@ import { defineConfig, devices } from "@playwright/test"; +const e2ePort = process.env["PLAYWRIGHT_TEST_PORT"] ?? "3110"; +const e2eBaseUrl = process.env["PLAYWRIGHT_TEST_BASE_URL"] ?? `http://localhost:${e2ePort}`; + export default defineConfig({ testDir: "./e2e", - fullyParallel: true, + fullyParallel: false, forbidOnly: !!process.env["CI"], retries: process.env["CI"] ? 2 : 0, - ...(process.env["CI"] ? { workers: 1 } : {}), + workers: 1, reporter: "html", use: { - baseURL: "http://localhost:3100", + baseURL: e2eBaseUrl, trace: "on-first-retry", }, projects: [ @@ -18,9 +21,9 @@ export default defineConfig({ }, ], webServer: { - command: "pnpm dev", - url: "http://localhost:3100", - reuseExistingServer: !process.env["CI"], - timeout: 120000, + command: "node ./e2e/test-server.mjs", + url: e2eBaseUrl, + reuseExistingServer: false, + timeout: 180000, }, }); diff --git a/apps/web/src/app/(app)/admin/vacations/page.tsx b/apps/web/src/app/(app)/admin/vacations/page.tsx index 8ddf031..7c0e8d8 100644 --- a/apps/web/src/app/(app)/admin/vacations/page.tsx +++ b/apps/web/src/app/(app)/admin/vacations/page.tsx @@ -1,3 +1,4 @@ +import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEditor.js"; import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js"; import { EntitlementManager } from "~/components/vacations/EntitlementManager.js"; @@ -8,10 +9,40 @@ export default function AdminVacationsPage() {

Vacation Management

-

Manage public holidays, entitlements, and year summaries

+

+ Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe. +

- - + +
+
+

Holiday Calendars

+

+ Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet. +

+
+ +
+ +
+
+

Legacy Batch Import

+

+ Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden. +

+
+ +
+ +
+
+

Entitlements

+

+ Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden. +

+
+ +
); } diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index 35edc0d..84ef356 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -33,6 +33,7 @@ import { usePermissions } from "~/hooks/usePermissions.js"; import { useColumnConfig } from "~/hooks/useColumnConfig.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { useRowOrder } from "~/hooks/useRowOrder.js"; +import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js"; type ModalState = @@ -85,68 +86,22 @@ function FilterDropdown({ tooltipContent?: ReactNode; }) { const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); - const panelRef = useRef(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]); + const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay({ + open: isOpen, + onClose: () => setIsOpen(false), + matchTriggerWidth: true, + }); return ( -
+
); diff --git a/apps/web/src/components/analytics/computation-graph/useComputationGraphData.ts b/apps/web/src/components/analytics/computation-graph/useComputationGraphData.ts index de3d088..a5329a4 100644 --- a/apps/web/src/components/analytics/computation-graph/useComputationGraphData.ts +++ b/apps/web/src/components/analytics/computation-graph/useComputationGraphData.ts @@ -6,12 +6,19 @@ import { RESOURCE_VIEW_DOMAINS, PROJECT_VIEW_DOMAINS, type Domain, + type GraphLink, type GraphNode, } from "./domain-colors"; import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data"; export type ViewMode = "resource" | "project"; +export interface ComputationGraphResponse { + nodes: GraphNode[]; + links: GraphLink[]; + meta?: Record; +} + export interface ComputationGraphState { viewMode: ViewMode; setViewMode: (m: ViewMode) => void; @@ -26,6 +33,7 @@ export interface ComputationGraphState { isLoading: boolean; activeDomains: Domain[]; graphData: ForceGraphData; + rawData: ComputationGraphResponse | null; highlightedNodes: Set | null; setHighlightedNodes: (s: Set | null) => void; hoveredNode: PositionedNode | null; @@ -144,6 +152,7 @@ export function useComputationGraphData(): ComputationGraphState { isLoading, activeDomains, graphData, + rawData: (rawData as ComputationGraphResponse | undefined) ?? null, highlightedNodes, setHighlightedNodes, hoveredNode, diff --git a/apps/web/src/components/assistant/ChatMessage.tsx b/apps/web/src/components/assistant/ChatMessage.tsx index e1e699e..f3b3159 100644 --- a/apps/web/src/components/assistant/ChatMessage.tsx +++ b/apps/web/src/components/assistant/ChatMessage.tsx @@ -1,16 +1,33 @@ "use client"; import { useMemo } from "react"; +import { clsx } from "clsx"; + +interface AssistantInsightMetric { + label: string; + value: string; + tone?: "neutral" | "good" | "warn" | "danger" | "info"; +} + +interface AssistantInsightSection { + title: string; + metrics: AssistantInsightMetric[]; +} + +interface AssistantInsight { + kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays"; + title: string; + subtitle?: string; + metrics: AssistantInsightMetric[]; + sections?: AssistantInsightSection[]; +} interface ChatMessageProps { role: "user" | "assistant"; content: string; + insights?: AssistantInsight[]; } -/** - * Lightweight inline markdown renderer — handles bold, italic, code, - * bullet lists, and numbered lists without a full markdown library. - */ function renderMarkdown(text: string) { const lines = text.split("\n"); const elements: React.ReactNode[] = []; @@ -21,7 +38,7 @@ function renderMarkdown(text: string) { if (listItems.length > 0 && listType) { const Tag = listType; elements.push( - + {listItems} , ); @@ -31,7 +48,6 @@ function renderMarkdown(text: string) { }; for (const [i, line] of lines.entries()) { - // Bullet list: "- item" or "* item" const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/); if (bulletMatch?.[1]) { if (listType !== "ul") flushList(); @@ -40,7 +56,6 @@ function renderMarkdown(text: string) { continue; } - // Numbered list: "1. item" const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/); if (numMatch?.[1]) { if (listType !== "ol") flushList(); @@ -49,54 +64,46 @@ function renderMarkdown(text: string) { continue; } - // Not a list item — flush any pending list flushList(); - // Empty line → spacing if (line.trim() === "") { elements.push(
); continue; } - // Regular paragraph elements.push(

{inlineFormat(line)}

); } + flushList(); return elements; } -/** Parse inline formatting: **bold**, *italic*, `code` */ function inlineFormat(text: string): React.ReactNode { - // Split by inline patterns, preserving delimiters const parts: React.ReactNode[] = []; - // Regex: **bold**, *italic*, `code` const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { - // Text before this match if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } + if (match[2]) { - // **bold** parts.push({match[2]}); } else if (match[3]) { - // *italic* parts.push({match[3]}); } else if (match[4]) { - // `code` parts.push( {match[4]} , ); } + lastIndex = match.index + match[0].length; } - // Remaining text if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } @@ -104,7 +111,72 @@ function inlineFormat(text: string): React.ReactNode { return parts.length === 1 ? parts[0] : <>{parts}; } -export function ChatMessage({ role, content }: ChatMessageProps) { +function metricToneClasses(tone: AssistantInsightMetric["tone"] | undefined): string { + switch (tone) { + case "good": + return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300"; + case "warn": + return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300"; + case "danger": + return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300"; + case "info": + return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300"; + default: + return "border-gray-200 bg-white text-gray-700 dark:border-slate-700 dark:bg-slate-900/60 dark:text-gray-200"; + } +} + +function InsightMetric({ metric }: { metric: AssistantInsightMetric }) { + return ( +
+
{metric.label}
+
{metric.value}
+
+ ); +} + +function InsightCard({ insight }: { insight: AssistantInsight }) { + return ( +
+
+
+
{insight.title}
+ {insight.subtitle && ( +
{insight.subtitle}
+ )} +
+ + {insight.kind.replace("_", " ")} + +
+ +
+ {insight.metrics.map((metric, index) => ( + + ))} +
+ + {insight.sections && insight.sections.length > 0 && ( +
+ {insight.sections.map((section, sectionIndex) => ( +
+
+ {section.title} +
+
+ {section.metrics.map((metric, metricIndex) => ( + + ))} +
+
+ ))} +
+ )} +
+ ); +} + +export function ChatMessage({ role, content, insights }: ChatMessageProps) { const isUser = role === "user"; const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]); @@ -121,12 +193,19 @@ export function ChatMessage({ role, content }: ChatMessageProps) { {content} ) : ( <> - - + + AI Generated + {insights && insights.length > 0 && ( +
+ {insights.map((insight, index) => ( + + ))} +
+ )}
{rendered}
)} diff --git a/apps/web/src/components/assistant/ChatPanel.tsx b/apps/web/src/components/assistant/ChatPanel.tsx index 33ce83f..0931c99 100644 --- a/apps/web/src/components/assistant/ChatPanel.tsx +++ b/apps/web/src/components/assistant/ChatPanel.tsx @@ -38,6 +38,26 @@ function resolvePageContext(pathname: string): string { interface Message { role: "user" | "assistant"; content: string; + insights?: AssistantInsight[]; +} + +interface AssistantInsightMetric { + label: string; + value: string; + tone?: "neutral" | "good" | "warn" | "danger" | "info"; +} + +interface AssistantInsightSection { + title: string; + metrics: AssistantInsightMetric[]; +} + +interface AssistantInsight { + kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays"; + title: string; + subtitle?: string; + metrics: AssistantInsightMetric[]; + sections?: AssistantInsightSection[]; } const STORAGE_KEY = "capakraken-chat-messages"; @@ -47,7 +67,23 @@ function loadPersistedMessages(): Message[] { if (typeof window === "undefined") return []; try { const raw = sessionStorage.getItem(STORAGE_KEY); - if (raw) return JSON.parse(raw) as Message[]; + if (raw) { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) { + return parsed + .filter((item): item is Partial & { role: Message["role"]; content: string } => ( + typeof item === "object" + && item !== null + && (item.role === "user" || item.role === "assistant") + && typeof item.content === "string" + )) + .map((item) => ({ + role: item.role, + content: item.content, + ...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}), + })); + } + } } catch { /* ignore corrupt data */ } return []; } @@ -101,10 +137,23 @@ export function ChatPanel({ onClose }: { onClose: () => void }) { messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })), ...(pathname ? { pageContext: resolvePageContext(pathname) } : {}), }); - setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]); + const typedReply = reply as { + content: string; + role: "assistant"; + actions?: Array<{ type: string; url?: string; scope?: string[] }>; + insights?: AssistantInsight[]; + }; + setMessages((prev) => [ + ...prev, + { + role: "assistant", + content: typedReply.content, + ...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}), + }, + ]); // Handle actions from the AI (navigation, data invalidation) - const actions = (reply as { actions?: Array<{ type: string; url?: string; scope?: string[] }> }).actions; + const actions = typedReply.actions; if (actions) { for (const action of actions) { if (action.type === "navigate" && action.url) { @@ -230,7 +279,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
)} {messages.map((msg, i) => ( - + ))} {isLoading && } {error && ( diff --git a/apps/web/src/components/dashboard/DashboardClient.tsx b/apps/web/src/components/dashboard/DashboardClient.tsx index 6980481..05e3b9b 100644 --- a/apps/web/src/components/dashboard/DashboardClient.tsx +++ b/apps/web/src/components/dashboard/DashboardClient.tsx @@ -158,6 +158,12 @@ export function DashboardClient() { + updateWidgetConfig(widget.id, { + showDetails: widget.config.showDetails !== true, + }) + } onRemove={() => removeWidget(widget.id)} > {renderWidget(widget.type, widget.config, (update) => diff --git a/apps/web/src/components/dashboard/WidgetContainer.tsx b/apps/web/src/components/dashboard/WidgetContainer.tsx index 02e70ef..a52d11b 100644 --- a/apps/web/src/components/dashboard/WidgetContainer.tsx +++ b/apps/web/src/components/dashboard/WidgetContainer.tsx @@ -8,9 +8,19 @@ interface WidgetContainerProps { onRemove: () => void; children: React.ReactNode; isDragging?: boolean; + showDetails?: boolean; + onToggleDetails?: () => void; } -export function WidgetContainer({ title, description, onRemove, children, isDragging }: WidgetContainerProps) { +export function WidgetContainer({ + title, + description, + onRemove, + children, + isDragging, + showDetails = false, + onToggleDetails, +}: WidgetContainerProps) { return ( - {/* Header — clean, no background separation */} -
-
+
+
- {/* Drag grip dots */} - {title} + + {title} + + {showDetails ? ( + + Details + + ) : null}
{description && ( -

{description}

+

+ {description} +

)}
- +
+ {onToggleDetails ? ( + + ) : null} + +
- {/* Subtle separator */} -
+
- {/* Body */} -
{children}
+
{children}
); } diff --git a/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx b/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx index 844d38b..38e8ff0 100644 --- a/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/BudgetForecastWidget.tsx @@ -19,7 +19,67 @@ function textColorClass(pct: number): string { return "text-green-700"; } +type BudgetForecastLocation = { + countryCode?: string | null; + countryName?: string | null; + federalState?: string | null; + metroCityName?: string | null; + activeAssignmentCount?: number; + burnRateCents?: number; +}; + +type BudgetForecastRow = { + projectId?: string; + projectName: string; + shortCode: string; + clientId: string | null; + clientName: string | null; + budgetCents: number; + spentCents: number; + remainingCents?: number; + burnRate: number; + estimatedExhaustionDate: string | null; + pctUsed: number; + activeAssignmentCount?: number; + calendarLocations?: BudgetForecastLocation[]; +}; + +function formatCurrency(cents: number | undefined): string { + if (cents === undefined) return "—"; + return `${(cents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €`; +} + +function formatLocation(location: BudgetForecastLocation): string { + const parts = [ + location.countryCode ?? location.countryName ?? null, + location.federalState ?? null, + location.metroCityName ?? null, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(" / ") : "No calendar context"; +} + +function SummaryCard({ + label, + value, + helper, +}: { + label: string; + value: string; + helper: string; +}) { + return ( +
+
+ {label} +
+
{value}
+
{helper}
+
+ ); +} + export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { + const showDetails = config.showDetails === true; const { clients } = useWidgetFilterOptions(); const filters = useMemo( @@ -39,7 +99,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { const clientId = (config.clientId as string) ?? ""; const rows = useMemo(() => { - const all = data ?? []; + const all = (data ?? []) as BudgetForecastRow[]; return all.filter((r) => { if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false; if (clientId && r.clientId !== clientId) return false; @@ -47,6 +107,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { }); }, [data, search, clientId]); + const totals = useMemo(() => rows.reduce((acc, row) => { + acc.budgetCents += row.budgetCents; + acc.spentCents += row.spentCents; + acc.remainingCents += row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents); + acc.burnRate += row.burnRate; + acc.activeAssignmentCount += row.activeAssignmentCount ?? 0; + return acc; + }, { + budgetCents: 0, + spentCents: 0, + remainingCents: 0, + burnRate: 0, + activeAssignmentCount: 0, + }), [rows]); + if (isLoading && !data) { return (
@@ -75,6 +150,28 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { return (
{})} /> +
+ + + row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`} + /> + +
@@ -86,7 +183,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) { Budget Usage {rows.map((row) => ( - - - - ))} diff --git a/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx b/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx index f1669fa..1876217 100644 --- a/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx @@ -36,8 +36,91 @@ type ChargeabilityRow = { chargeabilityTarget: number; actualChargeability: number; expectedChargeability: number; + countryCode?: string | null; + countryName?: string | null; + federalState?: string | null; + metroCityName?: string | null; + derivation?: { + weeklyAvailabilityHours: number; + baseWorkingDays: number; + effectiveWorkingDayEquivalent: number; + baseAvailableHours: number; + effectiveAvailableHours: number; + publicHolidayCount: number; + publicHolidayWorkdayCount: number; + publicHolidayHoursDeduction: number; + absenceDayEquivalent: number; + absenceHoursDeduction: number; + actualBookedHours: number; + expectedBookedHours: number; + targetBookedHours: number; + unassignedHours: number; + }; }; +function formatHours(value: number | undefined): string { + if (value === undefined) return "—"; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`; +} + +function formatDayEquivalent(value: number | undefined): string { + if (value === undefined) return "—"; + return Number.isInteger(value) ? `${value}` : value.toFixed(1); +} + +function MetricPill({ label, value }: { label: string; value: string }) { + return ( + + {label} + {value} + + ); +} + +function formatLocation(row: ChargeabilityRow): string { + const parts = [row.countryCode ?? row.countryName ?? null, row.federalState ?? null, row.metroCityName ?? null] + .filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(" / ") : "No calendar context"; +} + +function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) { + const derivation = row.derivation; + + if (!derivation) { + return null; + } + + return ( +
+
+ + + +
+
+
+ Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)} +
+
+ Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)}) +
+
+ Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)} +
+
+ Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)}) +
+
+ Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)} +
+
+ Free {formatHours(derivation.unassignedHours)} +
+
+
+ ); +} + function FilterDropdown({ label, children }: { label: string; children: ReactNode }) { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); @@ -74,7 +157,13 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod } export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) { - const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean }; + const config = _config as { + topN?: number; + watchlistThreshold?: number; + chapter?: string; + includeProposed?: boolean; + showDetails?: boolean; + }; const { chapters } = useWidgetFilterOptions(); const widgetFilters = useMemo( @@ -86,6 +175,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP ); const includeProposed = !!config.includeProposed; + const showDetails = !!config.showDetails; const chapterFilter = (config.chapter as string) ?? ""; const [showDeparted, setShowDeparted] = useState(false); const [selectedCountryIds, setSelectedCountryIds] = useState([]); @@ -266,7 +356,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP

Period: {month}

@@ -330,7 +420,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP >

Top Chargeability - + {visibleTop.length}/{top.length} @@ -390,18 +480,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP {visibleTop.map((r, i) => (

- - - ))} @@ -473,18 +578,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP {visibleWatchlist.map((r) => ( - - - ))} diff --git a/apps/web/src/components/dashboard/widgets/DemandWidget.tsx b/apps/web/src/components/dashboard/widgets/DemandWidget.tsx index eb71d2e..206a383 100644 --- a/apps/web/src/components/dashboard/widgets/DemandWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/DemandWidget.tsx @@ -8,7 +8,53 @@ import { ProgressRing } from "~/components/ui/ProgressRing.js"; type GroupBy = "project" | "person" | "chapter"; +type DemandRow = { + id: string; + name: string; + shortCode: string; + allocatedHours: number; + requiredFTEs: number; + resourceCount: number; + derivation?: { + periodStart: string; + periodEnd: string; + periodWorkingHoursBase: number; + requiredHours: number | null; + requiredFTEs: number; + fillPct: number | null; + demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE"; + calendarLocations: Array<{ + countryCode: string | null; + federalState: string | null; + metroCityName: string | null; + resourceCount: number; + allocatedHours: number; + }>; + }; +}; + +type DemandDerivation = NonNullable; +type DemandCalendarLocation = DemandDerivation["calendarLocations"][number]; + +function formatHours(value: number | null | undefined): string { + if (value == null) return "—"; + return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`; +} + +function formatLocation(location: DemandCalendarLocation): string { + const parts = [location.countryCode, location.federalState, location.metroCityName] + .filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(" / ") : "No calendar context"; +} + +function formatDemandSource(source: DemandDerivation["demandSource"] | undefined): string { + if (source === "DEMAND_REQUIREMENTS") return "Source: Demand requirements"; + if (source === "PROJECT_STAFFING_REQS") return "Source: Project staffing reqs"; + return "No demand basis"; +} + export function DemandWidget({ config, onConfigChange }: WidgetProps) { + const showDetails = config.showDetails === true; const groupBy = (config.groupBy as GroupBy) || "project"; type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount"; @@ -48,7 +94,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) { ); } - const rows = data ?? []; + const rows = (data ?? []) as DemandRow[]; const sorted = [...rows].sort((a, b) => { const mult = sortDir === "asc" ? 1 : -1; @@ -144,37 +190,84 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) { {sorted.map((row) => ( - + - {groupBy === "project" && ( - )} - + ))} diff --git a/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx b/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx index 91429b6..c2bebee 100644 --- a/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx +++ b/apps/web/src/components/dashboard/widgets/PeakTimesChart.tsx @@ -1,55 +1,172 @@ "use client"; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ReferenceLine, - ResponsiveContainer, - Legend, -} from "recharts"; +import { useMemo, useState } from "react"; -const COLORS = [ - "#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", - "#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6", -]; +type PeakTimesChartRow = { + period: string; + label: string; + bookedHours: number; + capacityHours: number; + utilizationPct: number; + remainingHours: number; + overbookedHours: number; + isCurrentPeriod: boolean; +}; interface PeakTimesChartProps { - chartData: Record[]; - groups: string[]; + rows: PeakTimesChartRow[]; + selectedPeriod: string | null; + onSelectedPeriodChange?: (period: string) => void; } -export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) { - if (chartData.length === 0) { +function formatHours(value: number): string { + return new Intl.NumberFormat("de-DE", { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }).format(value); +} + +function utilizationBarTone(utilizationPct: number): string { + if (utilizationPct > 100) return "bg-red-500"; + if (utilizationPct > 75) return "bg-emerald-500"; + if (utilizationPct >= 50) return "bg-amber-400"; + return "bg-rose-400"; +} + +function utilizationTextTone(utilizationPct: number): string { + if (utilizationPct > 100) return "text-red-600 dark:text-red-300"; + if (utilizationPct > 75) return "text-emerald-600 dark:text-emerald-300"; + if (utilizationPct >= 50) return "text-amber-600 dark:text-amber-300"; + return "text-rose-600 dark:text-rose-300"; +} + +export default function PeakTimesChart({ + rows, + selectedPeriod, + onSelectedPeriodChange, +}: PeakTimesChartProps) { + const [hoveredPeriod, setHoveredPeriod] = useState(null); + + const fallbackPeriod = selectedPeriod && rows.some((row) => row.period === selectedPeriod) + ? selectedPeriod + : rows[0]?.period ?? null; + const activePeriod = hoveredPeriod ?? fallbackPeriod; + const activeRow = useMemo( + () => rows.find((row) => row.period === activePeriod) ?? rows[0] ?? null, + [activePeriod, rows], + ); + const chartMaxPct = useMemo(() => { + const maxUtilization = Math.max(100, ...rows.map((row) => row.utilizationPct)); + return Math.max(120, Math.ceil(maxUtilization / 20) * 20); + }, [rows]); + const tickValues = useMemo(() => { + const base = [0, 50, 100]; + return chartMaxPct > 100 ? [...base, chartMaxPct] : base; + }, [chartMaxPct]); + const referenceLineBottom = (100 / chartMaxPct) * 100; + + if (rows.length === 0) { return ( -
- No allocation data in selected period. +
+ No allocation data in the selected horizon.
); } return ( - - - - - - - - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {groups.map((g, i) => ( - - ))} - - +
+
+
+ Overall Utilization +
+ + {activeRow ? ( +
+
+ {activeRow.label} · {activeRow.utilizationPct}% +
+
+ {formatHours(activeRow.bookedHours)}h / {formatHours(activeRow.capacityHours)}h +
+
+ ) : null} +
+ +
+
+ {[...tickValues].reverse().map((tick) => ( + {tick}% + ))} +
+ +
+
+ {[...tickValues].reverse().map((tick) => { + const bottom = (tick / chartMaxPct) * 100; + return ( +
+ ); + })} + +
+
+ +
+ {rows.map((row) => { + const height = Math.min((row.utilizationPct / chartMaxPct) * 100, 100); + const isActive = row.period === activePeriod; + const isPinned = row.period === fallbackPeriod; + + return ( + + ); + })} +
+
+
+
); } diff --git a/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx b/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx index 15b971d..7bc9407 100644 --- a/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/PeakTimesWidget.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import dynamic from "next/dynamic"; import { trpc } from "~/lib/trpc/client.js"; import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; @@ -10,84 +11,249 @@ const PeakTimesChart = dynamic( { ssr: false, loading: () =>
}, ); -export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) { - const granularity = (config.granularity as "week" | "month") || "month"; - const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project"; +type PeakDepartmentRow = { + name: string; + hours: number; + capacityHours: number; + remainingHours: number; + overbookedHours: number; + utilizationPct: number; +}; +type PeakPeriodRow = { + period: string; + label: string; + bookedHours: number; + capacityHours: number; + remainingHours: number; + overbookedHours: number; + utilizationPct: number; + isCurrentPeriod: boolean; + groups: PeakDepartmentRow[]; +}; + +function formatHours(value: number): string { + return new Intl.NumberFormat("de-DE", { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }).format(value); +} + +function formatMonthLabel(periodStart: string | undefined, fallback: string): string { + if (!periodStart) { + return fallback; + } + + const date = new Date(`${periodStart}T00:00:00.000Z`); + if (Number.isNaN(date.getTime())) { + return fallback; + } + + return new Intl.DateTimeFormat("en-US", { + month: "short", + year: "2-digit", + timeZone: "UTC", + }).format(date); +} + +function utilizationTone(utilizationPct: number): string { + if (utilizationPct >= 100) return "bg-red-500"; + if (utilizationPct >= 85) return "bg-amber-400"; + return "bg-emerald-500"; +} + +function utilizationTextTone(utilizationPct: number): string { + if (utilizationPct >= 100) return "text-red-600 dark:text-red-300"; + if (utilizationPct >= 85) return "text-amber-600 dark:text-amber-300"; + return "text-emerald-600 dark:text-emerald-300"; +} + +function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepartmentRow[] { + if (rows.length <= limit) { + return rows; + } + + const visibleRows = rows.slice(0, limit - 1); + const hiddenRows = rows.slice(limit - 1); + const hiddenHours = hiddenRows.reduce((sum, row) => sum + row.hours, 0); + const hiddenCapacityHours = hiddenRows.reduce((sum, row) => sum + row.capacityHours, 0); + const hiddenRemainingHours = hiddenRows.reduce((sum, row) => sum + row.remainingHours, 0); + const hiddenOverbookedHours = hiddenRows.reduce((sum, row) => sum + row.overbookedHours, 0); + + return [ + ...visibleRows, + { + name: `Other (${hiddenRows.length})`, + hours: hiddenHours, + capacityHours: hiddenCapacityHours, + remainingHours: hiddenRemainingHours, + overbookedHours: hiddenOverbookedHours, + utilizationPct: + hiddenCapacityHours > 0 ? Math.round((hiddenHours / hiddenCapacityHours) * 100) : 0, + }, + ]; +} + +export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) { const now = new Date(); - const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(); - const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString(); + const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(); + const endDate = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 12, 0, 23, 59, 59, 999), + ).toISOString(); + const currentPeriodKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`; + const persistedPeriod = typeof config.activePeriod === "string" ? config.activePeriod : null; const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery( - { startDate, endDate, granularity, groupBy }, + { startDate, endDate, granularity: "month", groupBy: "chapter" }, { staleTime: 120_000, placeholderData: (prev) => prev }, ); - if (isLoading) { + const periods = useMemo( + () => + (data ?? []).map((period) => { + const derivation = period.derivation; + const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours; + const capacityHours = period.capacityHours ?? derivation.capacityHours ?? 0; + const remainingHours = + period.remainingHours ?? + derivation.remainingCapacityHours ?? + Math.max(capacityHours - bookedHours, 0); + const overbookedHours = + period.overbookedHours ?? + derivation.overbookedHours ?? + Math.max(bookedHours - capacityHours, 0); + const utilizationPct = + period.utilizationPct ?? + derivation.utilizationPct ?? + (capacityHours > 0 ? Math.round((bookedHours / capacityHours) * 100) : 0); + + return { + period: period.period, + label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period), + bookedHours, + capacityHours, + remainingHours, + overbookedHours, + utilizationPct, + isCurrentPeriod: period.period === currentPeriodKey, + groups: (period.groups ?? []) + .map((group) => { + const groupCapacityHours = group.capacityHours ?? 0; + const groupRemainingHours = + group.remainingHours ?? Math.max(groupCapacityHours - group.hours, 0); + const groupOverbookedHours = + group.overbookedHours ?? Math.max(group.hours - groupCapacityHours, 0); + const groupUtilizationPct = + group.utilizationPct ?? + (groupCapacityHours > 0 ? Math.round((group.hours / groupCapacityHours) * 100) : 0); + + return { + name: group.name, + hours: group.hours, + capacityHours: groupCapacityHours, + remainingHours: groupRemainingHours, + overbookedHours: groupOverbookedHours, + utilizationPct: groupUtilizationPct, + }; + }) + .sort( + (left, right) => + right.utilizationPct - left.utilizationPct || + right.hours - left.hours || + left.name.localeCompare(right.name), + ), + }; + }), + [currentPeriodKey, data], + ); + + const selectedPeriod = + (persistedPeriod && periods.some((period) => period.period === persistedPeriod) ? persistedPeriod : null) ?? + (periods.some((period) => period.period === currentPeriodKey) ? currentPeriodKey : periods[0]?.period ?? null); + + const selectedPeriodRow = + periods.find((period) => period.period === selectedPeriod) ?? periods[0] ?? null; + const currentPeriodRow = + periods.find((period) => period.period === currentPeriodKey) ?? selectedPeriodRow; + const peakPeriodRow = useMemo( + () => + [...periods].sort( + (left, right) => + right.utilizationPct - left.utilizationPct || right.bookedHours - left.bookedHours, + )[0] ?? null, + [periods], + ); + const departmentRows = useMemo( + () => aggregateDepartmentRows(selectedPeriodRow?.groups ?? []), + [selectedPeriodRow], + ); + + if (isLoading && periods.length === 0) { return ( -
-
-
-
-
-
- {[...Array(12)].map((_, i) => ( -
+
+
+ {[...Array(3)].map((_, index) => ( +
))}
+
+
); } - const periods = data ?? []; - - // Collect all group names - const allGroups = new Set(); - for (const p of periods) { - for (const g of p.groups) allGroups.add(g.name); - } - const groups = [...allGroups].slice(0, 10); - - // Build recharts data - const chartData = periods.map((p) => { - const row: Record = { period: p.period, capacity: p.capacityHours }; - for (const g of p.groups) { - row[g.name] = g.hours; - } - return row; - }); - return ( -
- {/* Controls + info */} -
- - +
+
+
+
+
+ Current +
+
+ + {currentPeriodRow?.utilizationPct ?? 0}% + + + {currentPeriodRow?.label ?? "No data"} + +
+
+ +
+
+ Selected +
+
+ + {selectedPeriodRow?.utilizationPct ?? 0}% + + + {selectedPeriodRow?.label ?? "Hover or pin"} + +
+
+ +
+
+ Peak +
+
+ + {peakPeriodRow?.utilizationPct ?? 0}% + + + {peakPeriodRow?.label ?? "No data"} + +
+
+
- Stacked bars = booked hours per group per period (last 2 months to next 6 months).
- Red dashed line = total capacity estimate (all active resources × available hours per day × working days).
- Bars exceeding the capacity line indicate over-allocation risk. + The top chart shows total booked load against effective capacity.
+ The current month is marked with a blue accent.
+ Hover any month to inspect details and click to pin the department breakdown. } width="w-80" @@ -95,9 +261,72 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) { />
- {/* Chart */} -
- +
+
+ onConfigChange?.({ activePeriod: period })} + /> +
+ +
+
+
+
+
+ Department Utilization +
+
+ {selectedPeriodRow?.label ?? "No active month"} +
+
+
+
{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}
+
{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}
+
+
+ +
+ {departmentRows.length > 0 ? ( + departmentRows.map((group) => { + const barWidth = Math.min(group.utilizationPct, 100); + return ( +
+
+
+ {group.name} +
+
+ {group.utilizationPct}% +
+
+
+
+ {group.overbookedHours > 0 ? ( +
+ ) : null} +
+
+ ); + }) + ) : ( +
+ No department data in the selected month. +
+ )} +
+
+
); diff --git a/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx b/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx index ec0efae..fdc73c9 100644 --- a/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/ProjectHealthWidget.tsx @@ -8,6 +8,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { ShoringBadge } from "~/components/projects/ShoringIndicator.js"; import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js"; import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js"; +import { formatMoney } from "~/lib/format.js"; function healthDot(value: number): string { if (value >= 70) return "bg-green-500"; @@ -21,7 +22,55 @@ function scoreBadge(score: number): string { return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300"; } +function formatShortDate(value?: string | Date | null): string { + if (!value) { + return "No end date"; + } + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return "No end date"; + } + + return new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(date); +} + +function formatTimeline(daysUntilEndDate?: number | null, timelineStatus?: string | null): string { + if (timelineStatus === "UNSCHEDULED" || daysUntilEndDate == null) { + return "No end date"; + } + + if (daysUntilEndDate < 0) { + return `${Math.abs(daysUntilEndDate)} days overdue`; + } + + if (daysUntilEndDate === 0) { + return "Due today"; + } + + return `${daysUntilEndDate} days left`; +} + +function formatLocation(location: { + countryCode?: string | null; + countryName?: string | null; + federalState?: string | null; + metroCityName?: string | null; +}): string { + const parts = [ + location.countryCode ?? location.countryName ?? null, + location.federalState ?? null, + location.metroCityName ?? null, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(" / ") : "No calendar context"; +} + export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) { + const showDetails = config.showDetails === true; const { clients } = useWidgetFilterOptions(); const filters = useMemo( @@ -87,10 +136,10 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
{rows.map((row) => ( -
- Burn/mo + Burn/mo Exhaustion @@ -96,11 +193,41 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
- {row.shortCode} - {row.projectName} + +
+ {row.shortCode} + {row.projectName} +
+
+ {row.clientName ?? "No client"} + {!showDetails && row.calendarLocations && row.calendarLocations.length > 0 + ? ` · ${formatLocation(row.calendarLocations[0]!)}` + : ""} +
+ {showDetails ? ( +
+
+
{row.activeAssignmentCount ?? 0} active assignments
+
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
+
+
+ {row.calendarLocations && row.calendarLocations.length > 0 ? ( + row.calendarLocations.slice(0, 4).map((location) => ( + + {formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)} + + )) + ) : ( + No active calendar basis in the current month + )} +
+
+ ) : null}
+
+
+ {formatCurrency(row.spentCents)} / {formatCurrency(row.budgetCents)} +
+ {showDetails ? ( +
+ Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))} +
+ ) : null}
- {row.burnRate > 0 - ? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC` - : "\u2014"} + +
+ {row.burnRate > 0 ? formatCurrency(row.burnRate) : "\u2014"} +
+ {showDetails ? ( +
+
{row.activeAssignmentCount ?? 0} active assignments
+ {(row.calendarLocations ?? []).slice(0, 3).map((location) => ( +
+ {formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)} +
+ ))} +
+ ) : null}
- {row.estimatedExhaustionDate ?? "\u2014"} + +
{row.estimatedExhaustionDate ?? "\u2014"}
+ {showDetails ? ( +
+ at {formatCurrency(row.burnRate)} / month +
+ ) : null}
{i + 1} +
{r.displayName} {r.chapter && · {r.chapter}}
+ {showDetails ? : null}
- + +
+ +
+ {showDetails ? ( +
+ {formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)} +
+ ) : null}
- + +
+ +
+ {showDetails ? ( +
+ {formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)} +
+ ) : null}
+
{r.displayName} {r.chapter && · {r.chapter}}
+ {showDetails ? : null}
- + +
+ +
+ {showDetails ? ( +
+ {formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)} +
+ ) : null}
- + +
+ +
+ {showDetails ? ( +
+ Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)} +
+ ) : null}
- {groupBy === "project" ? ( - {row.shortCode}{row.name} - ) : ( - row.name - )} + +
+ {groupBy === "project" ? ( + {row.shortCode}{row.name} + ) : ( + row.name + )} +
+ {showDetails && groupBy === "project" && row.derivation ? ( +
+
+ {row.derivation.periodStart} to {row.derivation.periodEnd} +
+
+ {row.derivation.calendarLocations.length > 0 + ? row.derivation.calendarLocations + .slice(0, 2) + .map((location) => + `${formatLocation(location)} (${formatHours(location.allocatedHours)})`, + ) + .join(" · ") + : "No location-based booking basis"} +
+ {row.derivation.calendarLocations.length > 2 ? ( +
+ {row.derivation.calendarLocations.length - 2} more calendar contexts
+ ) : null} +
+ ) : null} +
+
{row.allocatedHours}h
+ {showDetails && groupBy === "project" && row.derivation ? ( +
+
{row.derivation.calendarLocations.length} calendar basis{row.derivation.calendarLocations.length === 1 ? "" : "es"}
+
{row.resourceCount} resource{row.resourceCount === 1 ? "" : "s"} in scope
+
+ ) : null}
{row.allocatedHours}h + {(() => { const ftes = row.requiredFTEs as unknown as number; if (ftes <= 0) return "—"; - const requiredHours = ftes * 22 * 3 * 8; - const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100)); - const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3; + const requiredHours = row.derivation?.requiredHours ?? null; + const rawFillPct = row.derivation?.fillPct ?? null; + const fillPct = Math.min(100, rawFillPct ?? 0); + const isBelowTarget = rawFillPct !== null ? rawFillPct < 100 : false; const ringColor = isBelowTarget ? "var(--color-red-500, #ef4444)" : "var(--color-green-500, #22c55e)"; return ( - - - - {ftes} FTE +
+ + + + {ftes} FTE + - + {showDetails ? ( +
+
{formatHours(row.allocatedHours)} / {formatHours(requiredHours)}
+
{rawFillPct == null ? "—" : `${rawFillPct}% coverage`} · {formatHours(row.derivation?.periodWorkingHoursBase)} per 1.0 FTE
+
{formatDemandSource(row.derivation?.demandSource)}
+
+ ) : null} +
); })()}
{row.resourceCount} +
{row.resourceCount}
+ {showDetails && groupBy === "project" && row.derivation?.calendarLocations.length ? ( +
+ {row.derivation.calendarLocations.reduce((sum, location) => sum + location.resourceCount, 0)} resource entries across locations +
+ ) : null} +
- Project + Project - B / S / T + B / S / T Shoring @@ -103,26 +152,66 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
- - {row.shortCode} - {row.projectName} + + +
+ {row.shortCode} + {row.projectName} +
+ {showDetails ? ( +
+
+ Budget: {formatMoney(row.spentCents ?? 0)} spent + {row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"} + {row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""} +
+
+ Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC + {typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""} + {typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""} +
+
+ Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)} +
+ {(row.calendarLocations ?? []).length > 0 ? ( +
+ Calendar basis: {(row.calendarLocations ?? []) + .slice(0, 2) + .map((location) => `${formatLocation(location)} (${formatMoney(location.spentCents)} / ${location.assignmentCount} assign.)`) + .join(" · ")} + {(row.calendarLocations ?? []).length > 2 + ? ` · +${(row.calendarLocations ?? []).length - 2} more` + : ""} +
+ ) : null} +
+ ) : null}
-
- - - +
+
+ + + +
+
+ B {row.budgetUtilizationPercent ?? 0}% used +
+ {showDetails ? ( +
+ S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)} +
+ ) : null}
diff --git a/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx b/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx index beab32b..1ad8163 100644 --- a/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx @@ -19,6 +19,8 @@ function StatCard({ value, suffix, sub, + details, + showDetails = false, info, accentColor, delay = 0, @@ -28,6 +30,8 @@ function StatCard({ value: number; suffix?: string; sub?: string; + details?: string[]; + showDetails?: boolean; info?: React.ReactNode; accentColor?: "green" | "amber" | "red"; delay?: number; @@ -66,13 +70,37 @@ function StatCard({ )} {sub &&

{sub}

} + {showDetails && details && details.length > 0 ? ( +
+ {details.map((detail) => ( +

{detail}

+ ))} +
+ ) : null} ); } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function StatCardsWidget(_props: Partial = {}) { +function formatShortDate(value?: string | Date | null): string { + if (!value) { + return "n/a"; + } + + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + return "n/a"; + } + + return new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }).format(date); +} + +export function StatCardsWidget(props: Partial = {}) { + const showDetails = props.config?.showDetails === true; const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, { staleTime: 60_000, placeholderData: (prev) => prev, @@ -104,21 +132,33 @@ export function StatCardsWidget(_props: Partial = {}) { @@ -127,7 +167,13 @@ export function StatCardsWidget(_props: Partial = {}) { value={budgetPct} suffix="%" sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`} - info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours." + details={[ + `Remaining: ${formatMoney(data.budgetBasis?.remainingBudgetCents ?? (data.budgetSummary.totalBudgetCents - data.budgetSummary.totalCostCents))}`, + `Basis: ${data.budgetBasis?.trackedAssignmentCount ?? 0} non-cancelled assignments across ${data.budgetBasis?.budgetedProjects ?? 0} budgeted projects`, + `Window: ${formatShortDate(data.budgetBasis?.windowStart)} - ${formatShortDate(data.budgetBasis?.windowEnd)}`, + ]} + showDetails={showDetails} + info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost uses the effective allocation cost basis including holiday-adjusted working capacity where available." accentColor={budgetAccent} delay={0.15} ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index b59989d..da31a9d 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -231,6 +231,7 @@ const adminNavEntries: AdminEntry[] = [ ], }, { href: "/admin/calculation-rules", label: "Calc. Rules", icon: }, + { href: "/admin/vacations", label: "Vacations & Holidays", icon: }, { href: "/admin/users", label: "Users", icon: }, { href: "/admin/system-roles", label: "System Roles", icon: }, { href: "/admin/settings", label: "Settings", icon: }, diff --git a/apps/web/src/components/notifications/NotificationBell.tsx b/apps/web/src/components/notifications/NotificationBell.tsx index 9c37753..f45597d 100644 --- a/apps/web/src/components/notifications/NotificationBell.tsx +++ b/apps/web/src/components/notifications/NotificationBell.tsx @@ -1,11 +1,12 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useSession } from "next-auth/react"; import Link from "next/link"; import type { Route } from "next"; import { motion, useAnimationControls } from "framer-motion"; +import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; import { trpc } from "~/lib/trpc/client.js"; function relativeTime(date: Date): string { @@ -28,12 +29,16 @@ type TabKey = "all" | "tasks" | "reminders"; export function NotificationBell() { const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState("all"); - const ref = useRef(null); const bellRef = useRef(null); - const dropdownRef = useRef(null); - const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const { data: session, status } = useSession(); const isAuthenticated = status === "authenticated" && !!session?.user?.email; + const { panelRef, position, handleOpenChange } = useAnchoredOverlay({ + open, + onClose: () => setOpen(false), + side: "right", + crossAlign: "start", + triggerRef: bellRef, + }); const badgeControls = useAnimationControls(); const prevUnreadRef = useRef(null); @@ -96,39 +101,6 @@ export function NotificationBell() { }, }); - // Compute dropdown position when opening - const updatePosition = useCallback(() => { - if (!bellRef.current) return; - const rect = bellRef.current.getBoundingClientRect(); - const panelHeight = 440; // approximate max height - let top = rect.top; - // If it would overflow the bottom, flip upward - if (top + panelHeight > window.innerHeight) { - top = Math.max(8, window.innerHeight - panelHeight - 8); - } - setDropdownPos({ top, left: rect.right + 8 }); - }, []); - - useEffect(() => { - if (open) updatePosition(); - }, [open, updatePosition]); - - // Close dropdown on outside click - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - const target = e.target as Node; - if ( - ref.current && !ref.current.contains(target) && - dropdownRef.current && !dropdownRef.current.contains(target) - ) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open]); - function handleMarkAllRead() { if (!isAuthenticated) return; markRead.mutate({}); @@ -150,12 +122,18 @@ export function NotificationBell() { ]; return ( -
+
{/* Bell button */} + +
+ {/* Entity Selector */}
+ {entity === "resource_month" && ( +
+
+
+ + setPeriodMonth(e.target.value)} + className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300" + /> +
+

+ Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context. +

+
+ +
+ {resourceMonthBlueprints.map((blueprint) => ( + + ))} +
+ +
+
+ Recommended transparency columns +
+
+ {RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => ( + + ))} +
+

+ Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH. +

+

+ Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product. +

+
+
+ )}
{/* Column Picker */} @@ -276,6 +645,11 @@ export function ReportBuilder() { className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600" /> {col.label} + {recommendedColumnSet.has(col.key) && ( + + Rec + + )} {col.dataType} @@ -428,13 +802,18 @@ export function ReportBuilder() {
{/* Results Header */}
-
-

Results

- {!isLoading && ( - - {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""} - - )} +
+
+

Results

+ {!isLoading && ( + + {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""} + + )} +
+

+ CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here. +

Match Score
@@ -260,13 +339,6 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
-
- - - - -
-
{suggestion.matchedSkills.map((skill) => ( @@ -280,24 +352,144 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError ))}
+
+ = searchCriteria.hoursPerDay ? "good" : "warn"} + helper={`${formatHours(remainingHours)} total in window`} + /> + + 0 ? formatHours(holidayHoursDeduction) : "0h"} + tone={holidayHoursDeduction > 0 ? "warn" : "neutral"} + helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"} + /> + 0 ? "warn" : "good"} + helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"} + /> +
+
LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h Utilization: {Math.round(suggestion.currentUtilization)}% - {suggestion.availabilityConflicts.length > 0 && ( + {suggestion.valueScore != null && ( + Value Score: {suggestion.valueScore} + )} + {conflictCount > 0 && ( - {suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"} + {conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"} )}
- {expanded && ( + {showDetails && ( +
+
+ + + + +
+ +
+
+
Capacity Basis
+
+ + + + + + + + + + +
+
+ +
+
+
Ranking Basis
+

+ {suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."} +

+
+ {(suggestion.ranking?.components ?? []).map((component) => ( + + ))} + {suggestion.ranking?.tieBreakerReason && ( +
+ {suggestion.ranking.tieBreakerReason} +
+ )} +
+
+ +
+
Location + Calendar
+
+ + + + +
+
+
+
+ +
+
+
Conflict Check
+
+ Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate} +
+
+ {conflictCount === 0 ? ( +

+ No overloaded working days in the selected window. +

+ ) : ( +
+ {(conflicts?.details ?? []).slice(0, 6).map((item) => ( +
+
+ {item.date} + Short by {formatHours(item.shortageHours)} +
+
+ Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)} +
+
+ ))} + {conflictCount > 6 && ( +

+ +{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"} +

+ )} +
+ )} +
+
+ )} + + {showAssignForm && ( onAssigned(suggestion.resourceId, suggestion.resourceName)} onError={onError} - onCancel={() => setExpanded(false)} + onCancel={() => setShowAssignForm(false)} /> )}
@@ -499,3 +691,45 @@ function ScoreBar({ label, value, tooltip }: { label: string; value: number; too
); } + +function formatHours(value: number): string { + const rounded = Math.round(value * 10) / 10; + return `${rounded}h`; +} + +function MetricLine({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +function StatCard({ + label, + value, + helper, + tone = "neutral", +}: { + label: string; + value: string; + helper?: string; + tone?: "neutral" | "good" | "warn"; +}) { + const toneClass = tone === "good" + ? "border-green-200 bg-green-50/70 dark:border-green-900/40 dark:bg-green-950/20" + : tone === "warn" + ? "border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20" + : "border-gray-200 bg-gray-50/70 dark:border-gray-700 dark:bg-gray-900/40"; + + return ( +
+
{label}
+
{value}
+ {helper && ( +
{helper}
+ )} +
+ ); +} diff --git a/apps/web/src/components/timeline/AllocationPopover.tsx b/apps/web/src/components/timeline/AllocationPopover.tsx index 653a665..96244e7 100644 --- a/apps/web/src/components/timeline/AllocationPopover.tsx +++ b/apps/web/src/components/timeline/AllocationPopover.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; +import { useViewportPopover } from "~/hooks/useViewportPopover.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { DateInput } from "~/components/ui/DateInput.js"; @@ -28,9 +29,14 @@ export function AllocationPopover({ anchorX, anchorY, }: AllocationPopoverProps) { - const ref = useRef(null); const utils = trpc.useUtils(); const invalidateTimeline = useInvalidateTimeline(); + const { ref, style } = useViewportPopover({ + anchor: { kind: "point", x: anchorX, y: anchorY }, + width: 300, + estimatedHeight: 360, + onClose, + }); const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery( { projectId }, @@ -63,17 +69,6 @@ export function AllocationPopover({ }, }); - // Close on outside click - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - onClose(); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [onClose]); - function toDateInput(d: Date): string { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); @@ -93,18 +88,9 @@ export function AllocationPopover({ }); } - // Position popover so it stays on screen - const popoverStyle: React.CSSProperties = { - position: "fixed", - left: Math.min(anchorX, window.innerWidth - 320), - top: Math.min(anchorY + 8, window.innerHeight - 360), - zIndex: 50, - width: 300, - }; - if (isLoading || !allocation) { return ( -
+
Loading...
); @@ -115,7 +101,7 @@ export function AllocationPopover({ return (
{/* Header */} diff --git a/apps/web/src/components/timeline/DemandPopover.tsx b/apps/web/src/components/timeline/DemandPopover.tsx index 18ba71c..42fe775 100644 --- a/apps/web/src/components/timeline/DemandPopover.tsx +++ b/apps/web/src/components/timeline/DemandPopover.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useRef } from "react"; import type { TimelineDemandEntry } from "./TimelineContext.js"; import { formatCents, formatDateLong } from "~/lib/format.js"; +import { useViewportPopover } from "~/hooks/useViewportPopover.js"; interface DemandPopoverProps { demand: TimelineDemandEntry; @@ -21,17 +21,12 @@ export function DemandPopover({ anchorX, anchorY, }: DemandPopoverProps) { - const ref = useRef(null); - - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - onClose(); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [onClose]); + const { ref, style } = useViewportPopover({ + anchor: { kind: "point", x: anchorX, y: anchorY }, + width: 300, + estimatedHeight: 340, + onClose, + }); const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified"; const roleColor = demand.roleEntity?.color ?? "#f59e0b"; @@ -41,18 +36,10 @@ export function DemandPopover({ const totalHours = demand.hoursPerDay * days; const budgetCents = demand.dailyCostCents * days; - const popoverStyle: React.CSSProperties = { - position: "fixed", - left: Math.min(anchorX, window.innerWidth - 320), - top: Math.min(anchorY + 8, window.innerHeight - 340), - zIndex: 50, - width: 300, - }; - return (
{/* Header */} diff --git a/apps/web/src/components/timeline/NewAllocationPopover.tsx b/apps/web/src/components/timeline/NewAllocationPopover.tsx index 19876d9..c58e29b 100644 --- a/apps/web/src/components/timeline/NewAllocationPopover.tsx +++ b/apps/web/src/components/timeline/NewAllocationPopover.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react"; import { AllocationStatus } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; +import { useViewportPopover } from "~/hooks/useViewportPopover.js"; import { DateInput } from "~/components/ui/DateInput.js"; interface NewAllocationPopoverProps { @@ -36,7 +37,12 @@ export function NewAllocationPopover({ onClose, onCreated, }: NewAllocationPopoverProps) { - const ref = useRef(null); + const { ref, style } = useViewportPopover({ + anchor: { kind: "point", x: anchorX - 10, y: anchorY }, + width: 320, + estimatedHeight: 440, + onClose, + }); const invalidateTimeline = useInvalidateTimeline(); const [search, setSearch] = useState(""); @@ -67,17 +73,6 @@ export function NewAllocationPopover({ }, }); - // Close on outside click - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - onClose(); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [onClose]); - function handleCreate() { if (!selectedProjectId) return; createMutation.mutate({ @@ -93,13 +88,10 @@ export function NewAllocationPopover({ const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0; - const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX); - const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY); - return (
{/* Header */} diff --git a/apps/web/src/components/timeline/ResourceHoverCard.tsx b/apps/web/src/components/timeline/ResourceHoverCard.tsx index cbc89f9..f69539e 100644 --- a/apps/web/src/components/timeline/ResourceHoverCard.tsx +++ b/apps/web/src/components/timeline/ResourceHoverCard.tsx @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useRef, useState } from "react"; import { trpc } from "~/lib/trpc/client.js"; import { formatCents } from "~/lib/format.js"; import type { SkillEntry } from "@capakraken/shared"; +import { useViewportPopover } from "~/hooks/useViewportPopover.js"; interface ResourceHoverCardProps { resourceId: string; @@ -12,34 +12,20 @@ interface ResourceHoverCardProps { } export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) { - const ref = useRef(null); - const [pos, setPos] = useState({ left: 0, top: 0 }); + const { ref, style } = useViewportPopover({ + anchor: { kind: "element", element: anchorEl }, + width: 280, + estimatedHeight: 320, + onClose, + side: "right", + ignoreElements: [anchorEl], + }); const { data, isLoading } = trpc.resource.getHoverCard.useQuery( { id: resourceId }, { staleTime: 60_000 }, ); - // Position relative to anchor element - useEffect(() => { - const rect = anchorEl.getBoundingClientRect(); - setPos({ - left: rect.right + 8, - top: Math.min(rect.top, window.innerHeight - 320), - }); - }, [anchorEl]); - - // Close on outside click - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) { - onClose(); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [onClose, anchorEl]); - const skills = (data?.skills ?? []) as unknown as SkillEntry[]; const mainSkills = skills.filter((s) => s.isMainSkill); const topSkills = skills @@ -47,19 +33,11 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov .sort((a, b) => b.proficiency - a.proficiency) .slice(0, 6); - const popoverStyle: React.CSSProperties = { - position: "fixed", - left: Math.min(pos.left, window.innerWidth - 300), - top: pos.top, - zIndex: 50, - width: 280, - }; - return (
diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx index c03758c..c54892c 100644 --- a/apps/web/src/components/timeline/TimelineContext.tsx +++ b/apps/web/src/components/timeline/TimelineContext.tsx @@ -113,6 +113,16 @@ export type VacationEntry = { halfDayPart?: string | null; }; +export type HolidayOverlayEntry = { + id: string; + resourceId: string; + type: string; + status: string; + startDate: Date | string; + endDate: Date | string; + note?: string | null; +}; + // ─── Context shape ────────────────────────────────────────────────────────── export interface TimelineContextValue { @@ -314,9 +324,43 @@ export function TimelineProvider({ { placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 }, ); + const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery( + { + 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 } : {}), + ...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}), + }, + { placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 }, + ); + const vacationsByResource = useMemo(() => { const map = new Map(); - for (const vacation of vacationEntries as VacationEntry[]) { + const mergedEntries = [...(vacationEntries as VacationEntry[])]; + const existingKeys = new Set( + mergedEntries.map((vacation) => { + const start = new Date(vacation.startDate).toISOString().slice(0, 10); + const end = new Date(vacation.endDate).toISOString().slice(0, 10); + return `${vacation.resourceId}:${vacation.type}:${start}:${end}`; + }), + ); + + for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) { + const start = new Date(holiday.startDate).toISOString().slice(0, 10); + const end = new Date(holiday.endDate).toISOString().slice(0, 10); + const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`; + if (existingKeys.has(key)) { + continue; + } + + existingKeys.add(key); + mergedEntries.push(holiday as VacationEntry); + } + + for (const vacation of mergedEntries) { const existing = map.get(vacation.resourceId); if (existing) { existing.push(vacation); @@ -325,7 +369,7 @@ export function TimelineProvider({ } } return map; - }, [vacationEntries]); + }, [holidayOverlayEntries, vacationEntries]); // When EID filter is active, explicitly fetch those resources. const { data: eidFilterData } = trpc.resource.list.useQuery( diff --git a/apps/web/src/components/timeline/TimelineFilter.tsx b/apps/web/src/components/timeline/TimelineFilter.tsx index 63ab1c4..13eaf15 100644 --- a/apps/web/src/components/timeline/TimelineFilter.tsx +++ b/apps/web/src/components/timeline/TimelineFilter.tsx @@ -2,7 +2,8 @@ import { clsx } from "clsx"; import { createPortal } from "react-dom"; -import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { useRef, useState, type RefObject } from "react"; +import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; import { trpc } from "~/lib/trpc/client.js"; export interface TimelineFilters { @@ -159,55 +160,12 @@ export function TimelineFilter({ isOpen, onClose, }: TimelineFilterProps) { - const panelRef = useRef(null); - const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 }); - - const updatePanelPosition = useCallback(() => { - const trigger = anchorRef.current; - if (!trigger) return; - - const rect = trigger.getBoundingClientRect(); - const panelWidth = panelRef.current?.offsetWidth ?? 320; - const viewportPadding = 16; - const maxLeft = window.innerWidth - panelWidth - viewportPadding; - - setPanelPosition({ - top: rect.bottom + 8, - left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)), - }); - }, [anchorRef]); - - useEffect(() => { - if (!isOpen) return; - - updatePanelPosition(); - const rafId = window.requestAnimationFrame(updatePanelPosition); - const handlePointerDown = (event: MouseEvent) => { - const target = event.target as Node; - if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) { - return; - } - onClose(); - }; - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - onClose(); - } - }; - - window.addEventListener("resize", updatePanelPosition); - window.addEventListener("scroll", updatePanelPosition, true); - window.addEventListener("mousedown", handlePointerDown); - window.addEventListener("keydown", handleEscape); - - return () => { - window.cancelAnimationFrame(rafId); - window.removeEventListener("resize", updatePanelPosition); - window.removeEventListener("scroll", updatePanelPosition, true); - window.removeEventListener("mousedown", handlePointerDown); - window.removeEventListener("keydown", handleEscape); - }; - }, [anchorRef, isOpen, onClose, updatePanelPosition]); + const { panelRef, position } = useAnchoredOverlay({ + open: isOpen, + onClose, + align: "end", + triggerRef: anchorRef, + }); if (!isOpen) return null; @@ -221,7 +179,7 @@ export function TimelineFilter({ return createPortal(
diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx index e6caa68..d67515f 100644 --- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx +++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx @@ -188,8 +188,10 @@ function TimelineProjectPanelInner({ } | null>(null); const heatmapTooltipRef = useRef(null); const vacationTooltipRef = useRef(null); + const demandTooltipRef = useRef(null); const heatmapTooltipPosRef = useRef({ left: 0, top: 0 }); const vacationTooltipPosRef = useRef({ left: 0, top: 0 }); + const demandTooltipPosRef = useRef({ left: 0, top: 0 }); const [heatmapHover, setHeatmapHover] = useState<{ date: Date; @@ -206,6 +208,22 @@ function TimelineProjectPanelInner({ approvedBy?: { name?: string | null; email: string } | null; approvedAt?: Date | string | null; }>(null); + const [demandHover, setDemandHover] = useState(null); const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => { const dateIndexByTime = new Map(); @@ -472,6 +490,7 @@ function TimelineProjectPanelInner({ vacationHoverRafRef.current = requestAnimationFrame(() => { vacationHoverRafRef.current = null; const date = xToDate(clientX, rect); + date.setHours(0, 0, 0, 0); const time = date.getTime(); const resourceVacations = vacationsByResource.get(resourceId) ?? []; const hit = @@ -507,18 +526,58 @@ function TimelineProjectPanelInner({ const shouldClearHeatmap = lastHeatmapDayRef.current !== -1; const shouldClearVacation = hoveredVacationKeyRef.current !== null; + const shouldClearDemand = demandHover !== null; lastHeatmapDayRef.current = -1; lastHeatmapResourceRef.current = null; hoveredVacationKeyRef.current = null; - if (shouldClearHeatmap || shouldClearVacation) { + if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) { startTransition(() => { if (shouldClearHeatmap) setHeatmapHover(null); if (shouldClearVacation) setVacationHover(null); + if (shouldClearDemand) setDemandHover(null); }); } - }, []); + }, [demandHover]); + + const handleDemandHoverMove = useCallback( + (e: React.MouseEvent, demand: TimelineDemandEntry) => { + demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 }; + if (demandTooltipRef.current) { + demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`; + demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`; + } + + const startDate = new Date(demand.startDate); + const endDate = new Date(demand.endDate); + const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1); + + startTransition(() => { + setDemandHover({ + roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand", + roleColor: demand.roleEntity?.color ?? "#f59e0b", + projectName: demand.project.name, + projectShortCode: demand.project.shortCode, + requestedHeadcount: demand.requestedHeadcount, + unfilledHeadcount: demand.unfilledHeadcount, + startDate: demand.startDate, + endDate: demand.endDate, + hoursPerDay: demand.hoursPerDay, + totalHours: demand.hoursPerDay * days, + percentage: demand.percentage, + status: demand.status, + ...(demand.dailyCostCents > 0 + ? { + totalCostCents: demand.dailyCostCents * days, + dailyCostCents: demand.dailyCostCents, + } + : {}), + }); + }); + }, + [], + ); useEffect( () => () => { @@ -672,6 +731,8 @@ function TimelineProjectPanelInner({ onAllocMouseDown, onAllocTouchStart, onAllocationContextMenu, + handleDemandHoverMove, + clearHoverTooltips, multiSelectState, allocDragState, ) @@ -699,6 +760,9 @@ function TimelineProjectPanelInner({
); @@ -852,6 +919,8 @@ function renderOpenDemandRow( anchorX: number, anchorY: number, ) => void, + onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void, + onClearHoverTooltips: () => void, multiSelectState: MultiSelectState, allocDragState: AllocDragState, ) { @@ -889,6 +958,7 @@ function renderOpenDemandRow(
{rowGridLines}
@@ -962,7 +1032,6 @@ function renderOpenDemandRow( : "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1", multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20", )} - title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} – ${formatDateLong(allocEnd)}`} style={{ left: left + 2, width: width - 4, @@ -986,6 +1055,7 @@ function renderOpenDemandRow( e.clientY, ); }} + onMouseMove={(e) => onDemandHoverMove(e, alloc)} > {/* Left resize handle */}
(null); - const panelRef = useRef(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]); + const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay({ + open: isOpen, + onClose: () => setIsOpen(false), + matchTriggerWidth: true, + }); return ( -
+
- {open && ( -
-
- Columns - -
+ {open && + createPortal( +
+
+ + Columns + + +
-
- {builtins.map((col) => { - const isVisible = visibleKeys.includes(col.key); - return ( -
{ dragKey.current = col.key; }} - onDragOver={(e) => { e.preventDefault(); }} - onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }} - className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${ - !col.hideable ? "opacity-50" : "cursor-grab" - }`} - > - {col.hideable && isVisible && ( - - )} - -
- ); - })} +
+ {builtins.map((col) => { + const isVisible = visibleKeys.includes(col.key); + return ( +
{ + dragKey.current = col.key; + }} + onDragOver={(event) => { + event.preventDefault(); + }} + onDrop={() => { + if (dragKey.current) reorder(dragKey.current, col.key); + dragKey.current = null; + }} + className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 ${ + !col.hideable ? "opacity-50" : "cursor-grab" + }`} + > + {col.hideable && isVisible && ( + + )} + +
+ ); + })} - {customs.length > 0 && ( - <> -
-

Custom Fields

- {customs.map((col) => { - const isVisible = visibleKeys.includes(col.key); - return ( -
{ dragKey.current = col.key; }} - onDragOver={(e) => { e.preventDefault(); }} - onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }} - className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab" - > - {isVisible && } - -
- ); - })} - - )} -
-
- )} + {customs.length > 0 && ( + <> +
+

Custom Fields

+ {customs.map((col) => { + const isVisible = visibleKeys.includes(col.key); + return ( +
{ + dragKey.current = col.key; + }} + onDragOver={(event) => { + event.preventDefault(); + }} + onDrop={() => { + if (dragKey.current) reorder(dragKey.current, col.key); + dragKey.current = null; + }} + className="flex cursor-grab items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800" + > + {isVisible && } + +
+ ); + })} + + )} +
+
, + document.body, + )}
); } diff --git a/apps/web/src/components/vacations/HolidayCalendarEditor.tsx b/apps/web/src/components/vacations/HolidayCalendarEditor.tsx new file mode 100644 index 0000000..ed6db9a --- /dev/null +++ b/apps/web/src/components/vacations/HolidayCalendarEditor.tsx @@ -0,0 +1,865 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { trpc } from "~/lib/trpc/client.js"; + +type ScopeType = "COUNTRY" | "STATE" | "CITY"; + +type CalendarRow = { + id: string; + name: string; + scopeType: ScopeType; + stateCode: string | null; + metroCityId: string | null; + isActive: boolean; + priority: number; + country: { id: string; code: string; name: string }; + metroCity: { id: string; name: string } | null; + entries: Array<{ + id: string; + date: string | Date; + name: string; + isRecurringAnnual: boolean; + source: string | null; + }>; + _count?: { entries: number }; +}; + +type CountryRow = { + id: string; + code: string; + name: string; + metroCities: { id: string; name: string }[]; +}; + +const SCOPE_LABELS: Record = { + COUNTRY: "Land", + STATE: "Bundesland/Region", + CITY: "Stadt", +}; + +function formatDate(value: string | Date): string { + return new Date(value).toISOString().slice(0, 10); +} + +export function HolidayCalendarEditor() { + const utils = trpc.useUtils(); + const [selectedCalendarId, setSelectedCalendarId] = useState(null); + const [scopeType, setScopeType] = useState("COUNTRY"); + const [countryId, setCountryId] = useState(""); + const [stateCode, setStateCode] = useState(""); + const [metroCityId, setMetroCityId] = useState(""); + const [name, setName] = useState(""); + const [priority, setPriority] = useState(0); + const [entryDate, setEntryDate] = useState(""); + const [entryName, setEntryName] = useState(""); + const [entryRecurring, setEntryRecurring] = useState(false); + const [entrySource, setEntrySource] = useState(""); + const [previewYear, setPreviewYear] = useState(new Date().getFullYear()); + const [error, setError] = useState(null); + const [calendarDraft, setCalendarDraft] = useState({ + name: "", + priority: 0, + stateCode: "", + metroCityId: "", + isActive: true, + }); + const [editingEntryId, setEditingEntryId] = useState(null); + const [entryDraft, setEntryDraft] = useState({ + date: "", + name: "", + isRecurringAnnual: false, + source: "", + }); + + const { data: countries } = trpc.country.list.useQuery(); + const { data: calendars } = trpc.holidayCalendar.listCalendars.useQuery({ includeInactive: true }); + + const selectedCalendar = ((calendars ?? []) as unknown as CalendarRow[]).find((calendar) => calendar.id === selectedCalendarId) ?? null; + + const selectedCountry = useMemo(() => { + const rows = (countries ?? []) as unknown as CountryRow[]; + return rows.find((country) => country.id === countryId) ?? null; + }, [countries, countryId]); + + const selectedCalendarCountry = useMemo(() => { + const rows = (countries ?? []) as unknown as CountryRow[]; + return rows.find((country) => country.id === selectedCalendar?.country.id) ?? null; + }, [countries, selectedCalendar]); + + const previewQuery = trpc.holidayCalendar.previewResolvedHolidays.useQuery( + { + countryId: selectedCalendar?.country.id ?? countryId, + year: previewYear, + ...(selectedCalendar?.stateCode ? { stateCode: selectedCalendar.stateCode } : {}), + ...(selectedCalendar?.metroCityId ? { metroCityId: selectedCalendar.metroCityId } : {}), + }, + { + enabled: Boolean(selectedCalendar?.country.id ?? countryId), + staleTime: 30_000, + }, + ); + + const invalidate = async () => { + await Promise.all([ + utils.holidayCalendar.listCalendars.invalidate(), + utils.holidayCalendar.getCalendarById.invalidate(), + utils.holidayCalendar.previewResolvedHolidays.invalidate(), + ]); + }; + + const createCalendar = trpc.holidayCalendar.createCalendar.useMutation({ + onSuccess: async (calendar) => { + await invalidate(); + setSelectedCalendarId(calendar.id); + setName(""); + setStateCode(""); + setMetroCityId(""); + setPriority(0); + setError(null); + }, + onError: (mutationError) => setError(mutationError.message), + }); + + const updateCalendar = trpc.holidayCalendar.updateCalendar.useMutation({ + onSuccess: async () => { + await invalidate(); + setError(null); + }, + onError: (mutationError) => setError(mutationError.message), + }); + + const deleteCalendar = trpc.holidayCalendar.deleteCalendar.useMutation({ + onSuccess: async () => { + await invalidate(); + setSelectedCalendarId(null); + setError(null); + }, + onError: (mutationError) => setError(mutationError.message), + }); + + const createEntry = trpc.holidayCalendar.createEntry.useMutation({ + onSuccess: async () => { + await invalidate(); + setEntryDate(""); + setEntryName(""); + setEntryRecurring(false); + setEntrySource(""); + setError(null); + }, + onError: (mutationError) => setError(mutationError.message), + }); + + const updateEntry = trpc.holidayCalendar.updateEntry.useMutation({ + onSuccess: async () => { + await invalidate(); + setEditingEntryId(null); + setError(null); + }, + onError: (mutationError) => setError(mutationError.message), + }); + + const deleteEntry = trpc.holidayCalendar.deleteEntry.useMutation({ + onSuccess: async () => { + await invalidate(); + setError(null); + }, + onError: (mutationError) => setError(mutationError.message), + }); + + const countryRows = (countries ?? []) as unknown as CountryRow[]; + const calendarRows = (calendars ?? []) as unknown as CalendarRow[]; + const isCreateScopeValid = scopeType === "COUNTRY" + ? Boolean(countryId && name.trim()) + : scopeType === "STATE" + ? Boolean(countryId && name.trim() && stateCode.trim()) + : Boolean(countryId && name.trim() && metroCityId); + const isCalendarDirty = selectedCalendar !== null && ( + calendarDraft.name !== selectedCalendar.name + || calendarDraft.priority !== selectedCalendar.priority + || calendarDraft.isActive !== selectedCalendar.isActive + || calendarDraft.stateCode !== (selectedCalendar.stateCode ?? "") + || calendarDraft.metroCityId !== (selectedCalendar.metroCityId ?? "") + ); + + useEffect(() => { + if (!selectedCalendar) { + setCalendarDraft({ + name: "", + priority: 0, + stateCode: "", + metroCityId: "", + isActive: true, + }); + return; + } + + setCalendarDraft({ + name: selectedCalendar.name, + priority: selectedCalendar.priority, + stateCode: selectedCalendar.stateCode ?? "", + metroCityId: selectedCalendar.metroCityId ?? "", + isActive: selectedCalendar.isActive, + }); + setEditingEntryId(null); + }, [selectedCalendar]); + + function handleCreateCalendar(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!isCreateScopeValid) { + setError("Bitte alle Pflichtfelder fuer den gewaehlten Scope ausfuellen."); + return; + } + createCalendar.mutate({ + name: name.trim(), + scopeType, + countryId, + ...(scopeType === "STATE" && stateCode.trim() ? { stateCode: stateCode.trim().toUpperCase() } : {}), + ...(scopeType === "CITY" && metroCityId ? { metroCityId } : {}), + priority, + isActive: true, + }); + } + + function handleAddEntry(e: React.FormEvent) { + e.preventDefault(); + if (!selectedCalendarId) return; + if (!entryDate || !entryName.trim()) { + setError("Datum und Feiertagsname sind erforderlich."); + return; + } + createEntry.mutate({ + holidayCalendarId: selectedCalendarId, + date: new Date(`${entryDate}T00:00:00.000Z`), + name: entryName.trim(), + isRecurringAnnual: entryRecurring, + ...(entrySource.trim() ? { source: entrySource.trim() } : {}), + }); + } + + function resetCalendarDraft() { + if (!selectedCalendar) { + return; + } + + setCalendarDraft({ + name: selectedCalendar.name, + priority: selectedCalendar.priority, + stateCode: selectedCalendar.stateCode ?? "", + metroCityId: selectedCalendar.metroCityId ?? "", + isActive: selectedCalendar.isActive, + }); + } + + function handleUpdateCalendar(e: React.FormEvent) { + e.preventDefault(); + if (!selectedCalendar) { + return; + } + + setError(null); + const normalizedStateCode = calendarDraft.stateCode.trim().toUpperCase(); + if (selectedCalendar.scopeType === "STATE" && !normalizedStateCode) { + setError("State-Kalender benoetigen einen Regionscode."); + return; + } + if (selectedCalendar.scopeType === "CITY" && !calendarDraft.metroCityId) { + setError("City-Kalender benoetigen eine Stadtzuordnung."); + return; + } + + updateCalendar.mutate({ + id: selectedCalendar.id, + data: { + name: calendarDraft.name.trim(), + priority: calendarDraft.priority, + isActive: calendarDraft.isActive, + ...(selectedCalendar.scopeType === "STATE" ? { stateCode: normalizedStateCode } : {}), + ...(selectedCalendar.scopeType === "CITY" ? { metroCityId: calendarDraft.metroCityId } : {}), + }, + }); + } + + function startEditingEntry(entry: CalendarRow["entries"][number]) { + setEditingEntryId(entry.id); + setEntryDraft({ + date: formatDate(entry.date), + name: entry.name, + isRecurringAnnual: entry.isRecurringAnnual, + source: entry.source ?? "", + }); + } + + function handleUpdateEntry(entryId: string) { + if (!entryDraft.date || !entryDraft.name.trim()) { + setError("Ein Feiertagseintrag braucht Datum und Name."); + return; + } + + setError(null); + updateEntry.mutate({ + id: entryId, + data: { + date: new Date(`${entryDraft.date}T00:00:00.000Z`), + name: entryDraft.name.trim(), + isRecurringAnnual: entryDraft.isRecurringAnnual, + source: entryDraft.source.trim() || null, + }, + }); + } + + function handleDeleteCalendar(calendar: CalendarRow) { + if (deleteCalendar.isPending) { + return; + } + + const confirmed = globalThis.confirm( + `Feiertagskalender "${calendar.name}" wirklich loeschen? Alle Eintraege gehen dabei verloren.`, + ); + + if (!confirmed) { + return; + } + + setError(null); + deleteCalendar.mutate({ id: calendar.id }); + } + + function handleDeleteEntry(entry: CalendarRow["entries"][number]) { + if (deleteEntry.isPending) { + return; + } + + const confirmed = globalThis.confirm( + `Feiertag "${entry.name}" am ${formatDate(entry.date)} wirklich entfernen?`, + ); + + if (!confirmed) { + return; + } + + setError(null); + deleteEntry.mutate({ id: entry.id }); + } + + return ( +
+
+

Holiday Calendar Editor

+

+ Pflege Feiertagskalender pro Land, Bundesland/Region oder Stadt. Die Vorschau zeigt den effektiv aufgeloesten Kalender fuer den gewaelten Scope. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ + + +
+ +
+ + + +
+ + {scopeType === "STATE" && ( + + )} + + {scopeType === "CITY" && ( + + )} + + +
+ +
+
+ + + + + + + + + + + {calendarRows.length === 0 && ( + + + + )} + {calendarRows.map((calendar) => ( + setSelectedCalendarId(calendar.id)} + > + + + + + + ))} + +
KalenderScopeZuordnungEintraege
Noch keine Feiertagskalender vorhanden.
+
{calendar.name}
+
{calendar.country.name}
+
{SCOPE_LABELS[calendar.scopeType]} + {calendar.scopeType === "COUNTRY" && calendar.country.code} + {calendar.scopeType === "STATE" && calendar.stateCode} + {calendar.scopeType === "CITY" && calendar.metroCity?.name} + {calendar._count?.entries ?? calendar.entries.length}
+
+ + {selectedCalendar && ( +
+
+
+
+

{selectedCalendar.name}

+

+ {SCOPE_LABELS[selectedCalendar.scopeType]} · {selectedCalendar.country.name} + {selectedCalendar.stateCode ? ` · ${selectedCalendar.stateCode}` : ""} + {selectedCalendar.metroCity?.name ? ` · ${selectedCalendar.metroCity.name}` : ""} +

+
+
+ + +
+
+ +
+ + + + + {selectedCalendar.scopeType === "STATE" && ( + + )} + + {selectedCalendar.scopeType === "CITY" && ( + + )} + + + +
+ + +
+
+ +
+ + + + +
+ + + +
+ + + + + + + + + + + + {selectedCalendar.entries.length === 0 && ( + + + + )} + {selectedCalendar.entries.map((entry) => ( + + + + + + + + ))} + +
DatumNameTypQuelleAktion
Keine Eintraege vorhanden.
+ {editingEntryId === entry.id ? ( + setEntryDraft((current) => ({ ...current, date: e.target.value }))} + className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" + /> + ) : formatDate(entry.date)} + + {editingEntryId === entry.id ? ( + setEntryDraft((current) => ({ ...current, name: e.target.value }))} + className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" + /> + ) : entry.name} + + {editingEntryId === entry.id ? ( + + ) : entry.isRecurringAnnual ? "jaehrlich" : "fix"} + + {editingEntryId === entry.id ? ( + setEntryDraft((current) => ({ ...current, source: e.target.value }))} + className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" + placeholder="Quelle" + /> + ) : entry.source ?? "System/ohne Quelle"} + +
+ {editingEntryId === entry.id ? ( + <> + + + + ) : ( + + )} + +
+
+
+
+ +
+
+
+

Vorschau

+

Effektiv aufgeloeste Feiertage fuer den gewaehlten Scope.

+
+ setPreviewYear(parseInt(e.target.value, 10) || new Date().getFullYear())} + className="w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100" + /> +
+ +
+ + + + + + + + + + {(previewQuery.data ?? []).length === 0 && ( + + + + )} + {(previewQuery.data ?? []).map((entry) => ( + + + + + + ))} + +
DatumNameQuelle
+ {previewQuery.isLoading ? "Laedt Vorschau..." : "Keine Feiertage fuer diese Auswahl vorhanden."} +
{entry.date}{entry.name}{entry.calendarName}
+
+
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/vacations/VacationClient.tsx b/apps/web/src/components/vacations/VacationClient.tsx index 66c3879..c053104 100644 --- a/apps/web/src/components/vacations/VacationClient.tsx +++ b/apps/web/src/components/vacations/VacationClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import Link from "next/link"; import { VacationStatus, VacationType } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { VacationModal } from "./VacationModal.js"; @@ -137,6 +138,13 @@ export function VacationClient() {

Vacations

Manage vacation requests and approvals

+

+ Regional public holidays are maintained in{" "} + + Holiday Calendars + + . +