feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+4 -2
View File
@@ -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
+2
View File
@@ -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:
+2
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+57
View File
@@ -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();
});
});
+351
View File
@@ -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;
}
+71 -11
View File
@@ -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");
});
});
+67 -17
View File
@@ -1,13 +1,22 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
test.describe("Vacations", () => {
test.describe("My Vacations (self-service)", () => {
test.beforeEach(async ({ page }) => {
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 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");
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
/// <reference path="./.next-e2e/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+2
View File
@@ -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"],
+10 -7
View File
@@ -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,
},
});
@@ -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() {
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage public holidays, entitlements, and year summaries</p>
<p className="mt-1 text-sm text-gray-500">
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
</p>
</div>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
<p className="text-sm text-gray-600">
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
</p>
</div>
<HolidayCalendarEditor />
</section>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
<p className="text-sm text-gray-600">
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
</p>
</div>
<PublicHolidayBatch />
</section>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
<p className="text-sm text-gray-600">
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
</p>
</div>
<EntitlementManager />
</section>
</div>
);
}
@@ -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<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
open: isOpen,
onClose: () => setIsOpen(false),
matchTriggerWidth: true,
});
}, []);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("keydown", handleEscape);
};
}, [isOpen, updatePanelPosition]);
return (
<div ref={dropdownRef} className="relative">
<div ref={triggerRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
onClick={() => {
const nextOpen = !isOpen;
handleOpenChange(nextOpen);
setIsOpen(nextOpen);
}}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
@@ -160,9 +115,9 @@ function FilterDropdown({
ref={panelRef}
style={{
position: "fixed",
top: panelPosition.top,
left: panelPosition.left,
minWidth: panelPosition.minWidth,
top: position.top,
left: position.left,
minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
+1 -2
View File
@@ -1,6 +1,5 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
@@ -11,7 +10,7 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
console.error(error);
}, [error]);
return (
@@ -9,6 +9,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs",
useAssistantAdvancedTools: "Assistant Advanced Tools",
exportData: "Export Data",
importData: "Import Data",
approveVacations: "Approve Vacations",
@@ -24,6 +25,7 @@ const PERMISSION_LABELS: Record<string, string> = {
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
viewCosts: "Access to cost data, budget views, and financial reports",
useAssistantAdvancedTools: "Unlocks advanced AI assistant workflows for complex cross-entity analyses",
exportData: "Export data to Excel, CSV, or PDF formats",
importData: "Import data from external sources (Dispo, Excel)",
approveVacations: "Approve or reject vacation requests",
@@ -97,6 +99,8 @@ export function SystemRolesClient() {
staleTime: 10_000,
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TS2589: tRPC infers union type too deeply for the role config update payload
const updateMutation = trpc.systemRoleConfig.update.useMutation({
onSuccess: async () => {
await utils.systemRoleConfig.list.invalidate();
@@ -15,6 +15,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs",
useAssistantAdvancedTools: "Assistant Advanced Tools",
exportData: "Export Data",
importData: "Import Data",
approveVacations: "Approve Vacations",
@@ -25,6 +26,7 @@ const PERMISSION_LABELS: Record<string, string> = {
manageAllocations: "Manage Allocations",
manageRoles: "Manage Roles",
manageUsers: "Manage Users",
viewScores: "View Scores",
};
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
@@ -11,6 +11,50 @@ import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
type Dimension = "2d" | "3d";
interface ResourceHolidayMeta {
date: string;
name: string;
scope: string;
calendarName: string | null;
}
interface ResourceFactorMeta {
weeklyAvailability?: Record<string, number>;
baseWorkingDays?: number;
effectiveWorkingDays?: number;
baseAvailableHours?: number;
effectiveAvailableHours?: number;
publicHolidayCount?: number;
publicHolidayWorkdayCount?: number;
publicHolidayHoursDeduction?: number;
absenceDayCount?: number;
absenceHoursDeduction?: number;
chargeableHours?: number;
utilizationPct?: number;
}
interface ResourceGraphMeta {
resourceName?: string;
resourceEid?: string;
month?: string;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
resolvedHolidays?: ResourceHolidayMeta[];
factors?: ResourceFactorMeta;
}
function formatNumber(value: number | undefined, digits = 1): string {
if (typeof value !== "number" || Number.isNaN(value)) {
return "—";
}
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value);
}
export default function ComputationGraphClient() {
const state = useComputationGraphData();
const [dimension, setDimension] = useState<Dimension>("2d");
@@ -24,10 +68,34 @@ export default function ComputationGraphClient() {
isLoading,
activeDomains,
graphData,
rawData,
highlightedNodes, setHighlightedNodes,
domainFilter, toggleDomain,
} = state;
const resourceMeta = viewMode === "resource"
? (rawData?.meta as ResourceGraphMeta | undefined)
: undefined;
const resourceFactors = resourceMeta?.factors;
const weeklyAvailabilityEntries: Array<[string, number | undefined]> = resourceFactors?.weeklyAvailability
? [
["Mo", resourceFactors.weeklyAvailability.monday],
["Di", resourceFactors.weeklyAvailability.tuesday],
["Mi", resourceFactors.weeklyAvailability.wednesday],
["Do", resourceFactors.weeklyAvailability.thursday],
["Fr", resourceFactors.weeklyAvailability.friday],
["Sa", resourceFactors.weeklyAvailability.saturday],
["So", resourceFactors.weeklyAvailability.sunday],
]
: [];
const weeklyAvailability = resourceFactors?.weeklyAvailability
? weeklyAvailabilityEntries
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && entry[1] > 0)
.map(([label, hours]) => `${label} ${formatNumber(hours, 1)}h`)
.join(" · ")
: "—";
const topHolidays = resourceMeta?.resolvedHolidays?.slice(0, 6) ?? [];
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
{/* ── Header Bar ── */}
@@ -173,6 +241,102 @@ export default function ComputationGraphClient() {
<ComputationGraph3D state={state} />
)}
</div>
{viewMode === "resource" && resourceMeta && (
<aside className="w-[24rem] overflow-y-auto border-l border-zinc-200 bg-white/90 p-4 dark:border-zinc-700 dark:bg-zinc-950/90">
<div className="space-y-4">
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Bezugsgroessen</div>
<div className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{resourceMeta.resourceName ?? "Resource"}
</div>
<div className="text-sm text-zinc-500">{resourceMeta.resourceEid ?? "—"} · {resourceMeta.month ?? month}</div>
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Land</div>
<div>{resourceMeta.countryName ?? resourceMeta.countryCode ?? "—"}{resourceMeta.countryCode ? ` (${resourceMeta.countryCode})` : ""}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Bundesland / Region</div>
<div>{resourceMeta.federalState ?? "—"}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Ort / Metro</div>
<div>{resourceMeta.metroCityName ?? "—"}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Wochenverfuegbarkeit</div>
<div>{weeklyAvailability}</div>
</div>
</div>
</section>
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Feiertagsbasis</div>
<div className="text-xs text-zinc-500">
{resourceFactors?.publicHolidayCount ?? 0} Feiertage, {resourceFactors?.publicHolidayWorkdayCount ?? 0} wirksam
</div>
</div>
<div className="mt-3 space-y-2">
{topHolidays.length > 0 ? topHolidays.map((holiday) => (
<div
key={`${holiday.date}-${holiday.name}`}
className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
>
<div className="font-medium text-zinc-900 dark:text-zinc-100">{holiday.name}</div>
<div className="text-xs text-zinc-500">
{holiday.date} · {holiday.scope} · {holiday.calendarName ?? "Kalender"}
</div>
</div>
)) : (
<div className="rounded-lg border border-dashed border-zinc-200 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800">
Keine aufgeloesten Feiertage im gewaehlten Monat.
</div>
)}
</div>
</section>
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Herleitung</div>
<div className="mt-3 space-y-2">
<div className="rounded-lg bg-white px-3 py-2 text-sm dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">SAH Formel</div>
<div className="font-medium text-zinc-900 dark:text-zinc-100">
{formatNumber(resourceFactors?.baseAvailableHours)}h - {formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h - {formatNumber(resourceFactors?.absenceHoursDeduction)}h = {formatNumber(resourceFactors?.effectiveAvailableHours)}h
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Basistage</div>
<div>{formatNumber(resourceFactors?.baseWorkingDays, 0)}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Effektive Tage</div>
<div>{formatNumber(resourceFactors?.effectiveWorkingDays, 0)}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Feiertagsabzug</div>
<div>{formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Abwesenheitsabzug</div>
<div>{formatNumber(resourceFactors?.absenceHoursDeduction)}h</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Chargeable Hours</div>
<div>{formatNumber(resourceFactors?.chargeableHours)}h</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Auslastung</div>
<div>{formatNumber(resourceFactors?.utilizationPct)}%</div>
</div>
</div>
</div>
</section>
</div>
</aside>
)}
</div>
</div>
);
@@ -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<string, unknown>;
}
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<string> | null;
setHighlightedNodes: (s: Set<string> | 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,
+100 -21
View File
@@ -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(
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "list-disc pl-4 my-1 space-y-0.5" : "list-decimal pl-4 my-1 space-y-0.5"}>
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "my-1 list-disc space-y-0.5 pl-4" : "my-1 list-decimal space-y-0.5 pl-4"}>
{listItems}
</Tag>,
);
@@ -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(<div key={`br-${i}`} className="h-2" />);
continue;
}
// Regular paragraph
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
}
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(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) {
// *italic*
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
} else if (match[4]) {
// `code`
parts.push(
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
{match[4]}
</code>,
);
}
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 (
<div className={clsx("rounded-xl border px-2.5 py-2", metricToneClasses(metric.tone))}>
<div className="text-[10px] font-medium uppercase tracking-[0.08em] opacity-70">{metric.label}</div>
<div className="mt-1 text-sm font-semibold leading-tight">{metric.value}</div>
</div>
);
}
function InsightCard({ insight }: { insight: AssistantInsight }) {
return (
<div className="rounded-2xl border border-slate-200 bg-white/90 p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/85">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{insight.title}</div>
{insight.subtitle && (
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{insight.subtitle}</div>
)}
</div>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
{insight.kind.replace("_", " ")}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{insight.metrics.map((metric, index) => (
<InsightMetric key={`${insight.kind}-${metric.label}-${index}`} metric={metric} />
))}
</div>
{insight.sections && insight.sections.length > 0 && (
<div className="mt-3 space-y-2">
{insight.sections.map((section, sectionIndex) => (
<div key={`${insight.kind}-${section.title}-${sectionIndex}`} className="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 p-2.5 dark:border-slate-700 dark:bg-slate-800/60">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
{section.title}
</div>
<div className="grid grid-cols-2 gap-2">
{section.metrics.map((metric, metricIndex) => (
<InsightMetric key={`${section.title}-${metric.label}-${metricIndex}`} metric={metric} />
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
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) {
<span className="whitespace-pre-wrap break-words">{content}</span>
) : (
<>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 mb-1.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span className="mb-1.5 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Generated
</span>
{insights && insights.length > 0 && (
<div className="mb-2 space-y-2">
{insights.map((insight, index) => (
<InsightCard key={`${insight.kind}-${insight.title}-${index}`} insight={insight} />
))}
</div>
)}
<div className="space-y-0.5 break-words">{rendered}</div>
</>
)}
@@ -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<Message> & { 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 }) {
</div>
)}
{messages.map((msg, i) => (
<ChatMessage key={i} role={msg.role} content={msg.content} />
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
{...(msg.insights ? { insights: msg.insights } : {})}
/>
))}
{isLoading && <TypingIndicator />}
{error && (
@@ -158,6 +158,12 @@ export function DashboardClient() {
<WidgetContainer
title={widget.title ?? getWidget(widget.type).label}
description={getWidget(widget.type).description}
showDetails={widget.config.showDetails === true}
onToggleDetails={() =>
updateWidgetConfig(widget.id, {
showDetails: widget.config.showDetails !== true,
})
}
onRemove={() => removeWidget(widget.id)}
>
{renderWidget(widget.type, widget.config, (update) =>
@@ -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 (
<motion.div
initial={{ opacity: 0, y: 16 }}
@@ -19,14 +29,12 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
isDragging
? "shadow-xl border-brand-400 dark:border-brand-500 scale-[1.01] ring-2 ring-brand-400/30"
: "bg-white dark:bg-gray-900 border-gray-200/80 dark:border-gray-700/60 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600"
: "border-gray-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95),rgba(255,255,255,0.98))] shadow-sm hover:shadow-md hover:border-gray-300 dark:border-gray-700/60 dark:bg-[linear-gradient(180deg,rgba(17,24,39,0.96),rgba(17,24,39,0.92))] dark:hover:border-gray-600"
}`}
>
{/* Header — clean, no background separation */}
<div className="flex items-center justify-between px-4 pt-3.5 pb-2 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle group">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3 shrink-0 widget-drag-handle group">
<div className="min-w-0 flex-1 cursor-grab active:cursor-grabbing">
<div className="flex items-center gap-2">
{/* Drag grip dots */}
<svg
className="w-3.5 h-5 text-gray-300 dark:text-gray-600 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
viewBox="0 0 14 20"
@@ -39,19 +47,46 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
<circle cx="4" cy="16" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{title}</span>
<span className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{title}
</span>
{showDetails ? (
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-brand-700 dark:bg-brand-500/10 dark:text-brand-300">
Details
</span>
) : null}
</div>
{description && (
<p className="text-[11px] text-gray-400 dark:text-gray-500 truncate mt-0.5 ml-[22px]">{description}</p>
<p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{onToggleDetails ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleDetails();
}}
className={`rounded-xl border px-3 py-1.5 text-[11px] font-semibold transition ${
showDetails
? "border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-500/30 dark:bg-brand-500/10 dark:text-brand-300"
: "border-gray-200 bg-white/80 text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-400 dark:hover:text-gray-200"
}`}
title={showDetails ? "Hide details" : "Show details"}
>
{showDetails ? "Details on" : "Details off"}
</button>
) : null}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-2 p-1.5 text-gray-300 dark:text-gray-600 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors shrink-0 opacity-0 group-hover:opacity-100"
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500 dark:text-gray-600 dark:hover:bg-red-950/30 dark:hover:text-red-400"
title="Remove widget"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -59,12 +94,11 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
</svg>
</button>
</div>
</div>
{/* Subtle separator */}
<div className="mx-4 border-t border-gray-100 dark:border-gray-800" />
<div className="mx-4 border-t border-gray-200/80 dark:border-gray-800" />
{/* Body */}
<div className="flex-1 overflow-auto p-4">{children}</div>
<div className="flex-1 overflow-auto p-4 pt-3">{children}</div>
</motion.div>
);
}
@@ -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 (
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/40">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{label}
</div>
<div className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{value}</div>
<div className="mt-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">{helper}</div>
</div>
);
}
export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
@@ -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 (
<div className="flex flex-col gap-1 pt-1">
@@ -75,6 +150,28 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
return (
<div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="mb-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard
label="Projects"
value={String(rows.length)}
helper={`${totals.activeAssignmentCount} active assignments in scope`}
/>
<SummaryCard
label="Budget"
value={formatCurrency(totals.budgetCents)}
helper={`${formatCurrency(totals.spentCents)} spent`}
/>
<SummaryCard
label="Remaining"
value={formatCurrency(totals.remainingCents)}
helper={`${rows.filter((row) => row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`}
/>
<SummaryCard
label="Burn / Month"
value={formatCurrency(totals.burnRate)}
helper="Holiday- and absence-adjusted active burn"
/>
</div>
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
@@ -86,7 +183,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
Burn/mo <InfoTooltip content="Current-month burn rate based on active allocations, adjusted for regional holidays and approved absences where resource calendars are available." />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
@@ -96,11 +193,41 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[140px] truncate">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[260px] align-top">
<div>
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</div>
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{row.clientName ?? "No client"}
{!showDetails && row.calendarLocations && row.calendarLocations.length > 0
? ` · ${formatLocation(row.calendarLocations[0]!)}`
: ""}
</div>
{showDetails ? (
<div className="mt-1 space-y-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
<div>Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}</div>
</div>
<div className="flex flex-wrap gap-1">
{row.calendarLocations && row.calendarLocations.length > 0 ? (
row.calendarLocations.slice(0, 4).map((location) => (
<span
key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}
className="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 dark:border-gray-700 dark:bg-gray-900/70"
>
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
</span>
))
) : (
<span>No active calendar basis in the current month</span>
)}
</div>
</div>
) : null}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-top">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
@@ -112,14 +239,37 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
{row.pctUsed}%
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
{formatCurrency(row.spentCents)} / {formatCurrency(row.budgetCents)}
</div>
{showDetails ? (
<div className="text-[10px] leading-4 text-gray-500 dark:text-gray-400">
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums align-top">
<div>
{row.burnRate > 0 ? formatCurrency(row.burnRate) : "\u2014"}
</div>
{showDetails ? (
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
{(row.calendarLocations ?? []).slice(0, 3).map((location) => (
<div key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}>
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
</div>
))}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums">
{row.estimatedExhaustionDate ?? "\u2014"}
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums align-top">
<div>{row.estimatedExhaustionDate ?? "\u2014"}</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
at {formatCurrency(row.burnRate)} / month
</div>
) : null}
</td>
</tr>
))}
@@ -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 (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<span className="text-gray-400 dark:text-gray-500">{label}</span>
<span className="text-gray-700 dark:text-gray-200">{value}</span>
</span>
);
}
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 (
<div className="mt-1.5 space-y-1.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap gap-1">
<MetricPill label="Loc" value={formatLocation(row)} />
<MetricPill label="Week" value={formatHours(derivation.weeklyAvailabilityHours)} />
<MetricPill label="Target" value={formatHours(derivation.targetBookedHours)} />
</div>
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
<div>
Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)}
</div>
<div>
Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)})
</div>
<div>
Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)}
</div>
<div>
Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)})
</div>
<div>
Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)}
</div>
<div>
Free {formatHours(derivation.unassignedHours)}
</div>
</div>
</div>
);
}
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(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<WidgetFilter[]>(
@@ -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<string[]>([]);
@@ -266,7 +356,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
<p className="text-xs text-gray-400 flex items-center gap-1">
Period: {month}
<InfoTooltip
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
content="Chargeability is calculated for the current calendar month. Available hours are derived from each person's weekly schedule and reduced by regional public holidays plus approved absences. Watchlist threshold: 15 percentage points below target."
width="w-72"
/>
</p>
@@ -330,7 +420,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
Top Chargeability
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours / total available hours." />
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours divided by holiday- and absence-adjusted available hours." />
<span className="ml-1 font-normal normal-case text-gray-400">
{visibleTop.length}/{top.length}
</span>
@@ -390,18 +480,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
{visibleTop.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[120px]">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
<div className="truncate">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</div>
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
<UtilizationBar percent={r.actualChargeability} />
</td>
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400">
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400 align-top">
<div>
<AnimatedNumber value={r.actualChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
<td className="px-2 py-1 text-right text-gray-400">
<td className="px-2 py-1 text-right text-gray-400 align-top">
<div>
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
</tr>
))}
@@ -473,18 +578,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
<tbody className="divide-y divide-gray-50">
{visibleWatchlist.map((r) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[140px]">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
<div className="truncate">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</div>
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
<UtilizationBar percent={r.actualChargeability} />
</td>
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400">
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400 align-top">
<div>
<AnimatedNumber value={r.actualChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
<td className="px-2 py-1 text-right text-gray-400">
<td className="px-2 py-1 text-right text-gray-400 align-top">
<div>
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)}
</div>
) : null}
</td>
</tr>
))}
@@ -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<DemandRow["derivation"]>;
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) {
<tbody className="divide-y divide-gray-100">
{sorted.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
<td className="px-3 py-2 text-gray-900 max-w-[280px] align-top">
<div className="font-medium truncate">
{groupBy === "project" ? (
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
) : (
row.name
)}
</div>
{showDetails && groupBy === "project" && row.derivation ? (
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
<div>
{row.derivation.periodStart} to {row.derivation.periodEnd}
</div>
<div>
{row.derivation.calendarLocations.length > 0
? row.derivation.calendarLocations
.slice(0, 2)
.map((location) =>
`${formatLocation(location)} (${formatHours(location.allocatedHours)})`,
)
.join(" · ")
: "No location-based booking basis"}
</div>
{row.derivation.calendarLocations.length > 2 ? (
<div>+ {row.derivation.calendarLocations.length - 2} more calendar contexts</div>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right align-top">
<div className="text-gray-700">{row.allocatedHours}h</div>
{showDetails && groupBy === "project" && row.derivation ? (
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
<div>{row.derivation.calendarLocations.length} calendar basis{row.derivation.calendarLocations.length === 1 ? "" : "es"}</div>
<div>{row.resourceCount} resource{row.resourceCount === 1 ? "" : "s"} in scope</div>
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
{groupBy === "project" && (
<td className="px-3 py-2 text-right text-gray-700">
<td className="px-3 py-2 text-right align-top text-gray-700">
{(() => {
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 (
<div className="inline-flex flex-col items-end gap-1">
<span className="inline-flex items-center gap-1.5">
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE
</span>
</span>
{showDetails ? (
<div className="space-y-0.5 text-[10px] leading-4 text-gray-500">
<div>{formatHours(row.allocatedHours)} / {formatHours(requiredHours)}</div>
<div>{rawFillPct == null ? "—" : `${rawFillPct}% coverage`} · {formatHours(row.derivation?.periodWorkingHoursBase)} per 1.0 FTE</div>
<div>{formatDemandSource(row.derivation?.demandSource)}</div>
</div>
) : null}
</div>
);
})()}
</td>
)}
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
<td className="px-3 py-2 text-right align-top text-gray-500">
<div>{row.resourceCount}</div>
{showDetails && groupBy === "project" && row.derivation?.calendarLocations.length ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500">
{row.derivation.calendarLocations.reduce((sum, location) => sum + location.resourceCount, 0)} resource entries across locations
</div>
) : null}
</td>
</tr>
))}
</tbody>
@@ -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<string, number | string>[];
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<string | null>(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 (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No allocation data in selected period.
<div className="flex h-full items-center justify-center rounded-[22px] border border-dashed border-slate-200 bg-slate-50/80 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
No allocation data in the selected horizon.
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<ReferenceLine
{...({ dataKey: "capacity" } as any)}
stroke="#ef4444"
strokeDasharray="5 5"
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
/>
{groups.map((g, i) => (
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
<div className="flex h-full min-h-[15rem] flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Overall Utilization
</div>
{activeRow ? (
<div className="min-w-0 text-right">
<div className={`truncate text-sm font-semibold ${utilizationTextTone(activeRow.utilizationPct)}`}>
{activeRow.label} · {activeRow.utilizationPct}%
</div>
<div className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{formatHours(activeRow.bookedHours)}h / {formatHours(activeRow.capacityHours)}h
</div>
</div>
) : null}
</div>
<div className="mt-3 flex min-h-[12rem] flex-1 gap-2">
<div className="flex w-8 shrink-0 flex-col justify-between pb-6 text-right text-[9px] font-medium text-slate-400">
{[...tickValues].reverse().map((tick) => (
<span key={tick}>{tick}%</span>
))}
</BarChart>
</ResponsiveContainer>
</div>
<div className="relative min-w-0 flex-1">
<div className="pointer-events-none absolute inset-0 bottom-6">
{[...tickValues].reverse().map((tick) => {
const bottom = (tick / chartMaxPct) * 100;
return (
<div
key={tick}
className="absolute left-0 right-0 border-t border-dashed border-slate-200/80 dark:border-slate-700/50"
style={{ bottom: `${bottom}%` }}
/>
);
})}
<div
className="absolute left-0 right-0 border-t border-slate-300/90 dark:border-slate-500/80"
style={{ bottom: `${referenceLineBottom}%` }}
/>
</div>
<div
className="grid h-full items-end gap-1.5 pb-6 sm:gap-2"
style={{ gridTemplateColumns: `repeat(${rows.length}, minmax(0, 1fr))` }}
>
{rows.map((row) => {
const height = Math.min((row.utilizationPct / chartMaxPct) * 100, 100);
const isActive = row.period === activePeriod;
const isPinned = row.period === fallbackPeriod;
return (
<button
key={row.period}
type="button"
className="group flex h-full min-w-0 flex-col items-center rounded-2xl px-1 text-left transition-colors"
title={`${row.label}: ${row.utilizationPct}% utilization, ${formatHours(row.bookedHours)}h booked, ${formatHours(row.capacityHours)}h capacity, ${formatHours(row.remainingHours)}h free, ${formatHours(row.overbookedHours)}h overbooked`}
onMouseEnter={() => setHoveredPeriod(row.period)}
onMouseLeave={() => setHoveredPeriod((current) => (current === row.period ? null : current))}
onClick={() => onSelectedPeriodChange?.(row.period)}
style={{
backgroundColor: isPinned
? "rgba(14, 165, 233, 0.08)"
: isActive
? "rgba(148, 163, 184, 0.08)"
: "transparent",
}}
>
<div className="relative flex min-h-0 flex-1 w-full items-end justify-center px-0.5">
<div className="relative h-full w-full max-w-[34px] sm:max-w-[42px]">
<div className="absolute inset-x-0 bottom-0 h-full rounded-t-xl bg-slate-100 dark:bg-slate-800/80" />
<div
className={`absolute inset-x-0 bottom-0 rounded-t-xl transition-all duration-150 ${utilizationBarTone(row.utilizationPct)} ${
isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100"
}`}
style={{ height: `${Math.max(height, row.utilizationPct > 0 ? 6 : 0)}%` }}
/>
</div>
</div>
<div className="mt-2 min-w-0 shrink-0">
<div className="truncate text-center text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
{row.label}
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -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: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
);
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<PeakPeriodRow[]>(
() =>
(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 (
<div className="flex flex-col gap-3 h-full pt-2">
<div className="flex gap-2">
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
</div>
<div className="flex items-end gap-1 flex-1 px-2">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="flex-1 shimmer-skeleton rounded-t"
style={{ height: `${30 + Math.random() * 50}%` }}
/>
<div className="flex h-full flex-col gap-3 pt-2">
<div className="grid grid-cols-3 gap-2">
{[...Array(3)].map((_, index) => (
<div key={index} className="h-14 rounded-2xl shimmer-skeleton" />
))}
</div>
<div className="flex-1 rounded-[22px] shimmer-skeleton" />
<div className="h-32 rounded-[22px] shimmer-skeleton" />
</div>
);
}
const periods = data ?? [];
// Collect all group names
const allGroups = new Set<string>();
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<string, number | string> = { period: p.period, capacity: p.capacityHours };
for (const g of p.groups) {
row[g.name] = g.hours;
}
return row;
});
return (
<div className="flex flex-col h-full gap-3">
{/* Controls + info */}
<div className="flex gap-2 items-center">
<select
value={granularity}
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="month">Monthly</option>
<option value="week">Weekly</option>
</select>
<select
value={groupBy}
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="project">By Project</option>
<option value="chapter">By Chapter</option>
<option value="resource">By Resource</option>
</select>
<div className="flex h-full flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between gap-3">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Current
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(currentPeriodRow?.utilizationPct ?? 0)}`}>
{currentPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{currentPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Selected
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(selectedPeriodRow?.utilizationPct ?? 0)}`}>
{selectedPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "Hover or pin"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Peak
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(peakPeriodRow?.utilizationPct ?? 0)}`}>
{peakPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{peakPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
</div>
<InfoTooltip
content={
<span>
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
Bars exceeding the capacity line indicate over-allocation risk.
The top chart shows total booked load against effective capacity.<br />
The current month is marked with a blue accent.<br />
Hover any month to inspect details and click to pin the department breakdown.
</span>
}
width="w-80"
@@ -95,9 +261,72 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
/>
</div>
{/* Chart */}
<div className="flex-1 min-h-0">
<PeakTimesChart chartData={chartData} groups={groups} />
<div className="min-h-0 flex-1 lg:grid lg:grid-cols-[minmax(0,1.85fr)_minmax(18rem,0.95fr)] lg:gap-3">
<div className="min-h-0">
<PeakTimesChart
rows={periods}
selectedPeriod={selectedPeriod}
onSelectedPeriodChange={(period) => onConfigChange?.({ activePeriod: period })}
/>
</div>
<div className="mt-2 min-h-0 lg:mt-0">
<div className="flex h-full flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Department Utilization
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "No active month"}
</div>
</div>
<div className="text-right text-[11px] text-slate-500 dark:text-slate-400">
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}</div>
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}</div>
</div>
</div>
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
{departmentRows.length > 0 ? (
departmentRows.map((group) => {
const barWidth = Math.min(group.utilizationPct, 100);
return (
<div key={group.name} className="space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate text-xs font-medium text-slate-700 dark:text-slate-200">
{group.name}
</div>
<div className={`shrink-0 text-[11px] font-semibold ${utilizationTextTone(group.utilizationPct)}`}>
{group.utilizationPct}%
</div>
</div>
<div
className="relative h-2.5 overflow-visible rounded-full bg-slate-100 dark:bg-slate-800/80"
title={`${group.name}: ${group.utilizationPct}% utilization, ${formatHours(group.hours)}h booked, ${formatHours(group.capacityHours)}h capacity, ${formatHours(group.remainingHours)}h free, ${formatHours(group.overbookedHours)}h overbooked`}
>
<div
className={`h-full rounded-full ${utilizationTone(group.utilizationPct)}`}
style={{ width: `${barWidth}%` }}
/>
{group.overbookedHours > 0 ? (
<div
className="absolute right-0 top-0 h-full rounded-full bg-red-600/85"
style={{ width: `${Math.min(22, Math.max(8, group.utilizationPct - 100))}%` }}
/>
) : null}
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 px-3 py-4 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
No department data in the selected month.
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
@@ -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<WidgetFilter[]>(
@@ -87,10 +136,10 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Project <InfoTooltip content="Active projects scored across three health dimensions" />
Project <InfoTooltip content="Active projects scored across three health dimensions including visible budget, staffing, and timeline basis." />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demanded headcount), Timeline health (end date and remaining horizon)." />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
@@ -103,13 +152,44 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[160px] truncate">
<Link href={`/projects/${(row as any).id}`} className="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<td className="px-3 py-2 text-gray-900 dark:text-gray-100 max-w-[320px]">
<Link href={`/projects/${(row as any).id}`} className="block hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<div className="truncate font-medium">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</div>
</Link>
{showDetails ? (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
<div>
Budget: {formatMoney(row.spentCents ?? 0)} spent
{row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"}
{row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""}
</div>
<div>
Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC
{typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""}
{typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""}
</div>
<div>
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
</div>
{(row.calendarLocations ?? []).length > 0 ? (
<div>
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`
: ""}
</div>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-2">
<div className="flex flex-col items-center justify-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
@@ -124,6 +204,15 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
<div className="text-center tabular-nums">
B {row.budgetUtilizationPercent ?? 0}% used
</div>
{showDetails ? (
<div className="text-center tabular-nums">
S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
</div>
) : null}
</div>
</td>
<td className="px-3 py-2 text-center">
<ShoringBadge projectId={(row as any).id} />
@@ -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({
</div>
)}
{sub && <p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{sub}</p>}
{showDetails && details && details.length > 0 ? (
<div className="mt-2 space-y-1 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{details.map((detail) => (
<p key={detail}>{detail}</p>
))}
</div>
) : null}
</div>
</FadeIn>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
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<WidgetProps> = {}) {
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<WidgetProps> = {}) {
<StatCard
label="Total Resources"
value={data.totalResources}
sub={`${data.activeResources} active`}
info="All resources in the system. Sub-line shows active resources only."
sub={`${data.activeResources} active / ${data.inactiveResources ?? Math.max(data.totalResources - data.activeResources, 0)} inactive`}
details={[
"Basis: all resource master records",
]}
showDetails={showDetails}
info="All resources in the system. Sub-line shows active versus inactive records."
delay={0}
/>
<StatCard
label="Active Projects"
value={data.activeProjects}
sub={`${data.totalProjects} total`}
sub={`${data.totalProjects} total / ${data.inactiveProjects ?? Math.max(data.totalProjects - data.activeProjects, 0)} non-active`}
details={[
"Basis: project status on the dashboard snapshot",
]}
showDetails={showDetails}
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
delay={0.05}
/>
<StatCard
label="Total Allocations"
value={data.totalAllocations}
sub={`${data.activeAllocations} not cancelled`}
sub={`${data.activeAllocations} not cancelled / ${data.cancelledAllocations ?? Math.max(data.totalAllocations - data.activeAllocations, 0)} cancelled`}
details={[
"Basis: split allocation read model across explicit and legacy rows",
]}
showDetails={showDetails}
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
delay={0.1}
/>
@@ -127,7 +167,13 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
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] }}
@@ -231,6 +231,7 @@ const adminNavEntries: AdminEntry[] = [
],
},
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
{ href: "/admin/vacations", label: "Vacations & Holidays", icon: <VacationIcon /> },
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
@@ -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<TabKey>("all");
const ref = useRef<HTMLDivElement>(null);
const bellRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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<HTMLButtonElement>({
open,
onClose: () => setOpen(false),
side: "right",
crossAlign: "start",
triggerRef: bellRef,
});
const badgeControls = useAnimationControls();
const prevUnreadRef = useRef<number | null>(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 (
<div ref={ref} className="relative">
<div className="relative">
{/* Bell button */}
<button
ref={bellRef}
type="button"
onClick={() => setOpen((v) => !v)}
onClick={() => {
setOpen((current) => {
const nextOpen = !current;
handleOpenChange(nextOpen);
return nextOpen;
});
}}
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Notifications"
>
@@ -193,12 +171,12 @@ export function NotificationBell() {
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
{open && createPortal(
<motion.div
ref={dropdownRef}
ref={panelRef}
initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }}
animate={{ opacity: 1, scaleY: 1, scaleX: 1 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden origin-top"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
style={{ top: position.top, left: position.left }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
@@ -7,7 +7,7 @@ import { clsx } from "clsx";
// ─── Types ──────────────────────────────────────────────────────────────────
type EntityType = "resource" | "project" | "assignment";
type EntityType = "resource" | "project" | "assignment" | "resource_month";
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
interface FilterRow {
@@ -17,10 +17,50 @@ interface FilterRow {
value: string;
}
interface AvailableColumn {
key: string;
label: string;
dataType: "string" | "number" | "date" | "boolean";
}
interface TemplateConfig {
entity: EntityType;
columns: string[];
filters: Omit<FilterRow, "id">[];
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
periodMonth?: string;
}
interface ReportTemplateSummary {
id: string;
name: string;
description?: string | null;
entity: EntityType;
config: TemplateConfig;
isShared: boolean;
isOwner: boolean;
updatedAt: string | Date;
}
interface ReportBlueprint {
id: string;
label: string;
description: string;
entity: EntityType;
columns: string[];
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
templateName: string;
}
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "resource", label: "Resources" },
{ value: "project", label: "Projects" },
{ value: "assignment", label: "Assignments" },
{ value: "resource_month", label: "Resource Months" },
];
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
@@ -36,10 +76,120 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
const PAGE_SIZE = 50;
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"displayName",
"eid",
"chapter",
"countryCode",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
const REPORT_BLUEPRINTS: ReportBlueprint[] = [
{
id: "resource-month-sah-transparency",
label: "SAH transparency",
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
entity: "resource_month",
templateName: "Monthly SAH transparency",
columns: [
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
],
sortBy: "displayName",
sortDir: "asc",
},
{
id: "resource-month-chargeability-audit",
label: "Chargeability audit",
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
entity: "resource_month",
templateName: "Monthly chargeability audit",
columns: [
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
"lcrCents",
"currency",
],
sortBy: "monthlyActualChargeabilityPct",
sortDir: "desc",
},
{
id: "resource-month-location-comparison",
label: "Location comparison",
description: "Compares holiday impact across country, state and city contexts for the same month.",
entity: "resource_month",
templateName: "Monthly holiday comparison by location",
columns: [
"displayName",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyActualChargeabilityPct",
],
groupBy: "federalState",
sortBy: "monthlyPublicHolidayHoursDeduction",
sortDir: "desc",
},
];
function generateId(): string {
return Math.random().toString(36).slice(2, 10);
}
function getCurrentPeriodMonth(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() {
@@ -50,6 +200,9 @@ export function ReportBuilder() {
const [groupBy, setGroupBy] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [templateName, setTemplateName] = useState<string>("");
const [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false);
@@ -59,7 +212,21 @@ export function ReportBuilder() {
{ placeholderData: keepPreviousData },
);
const availableColumns = columnsQuery.data ?? [];
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
const templatesQuery = trpc.report.listTemplates.useQuery();
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
onSuccess: async (result) => {
setSelectedTemplateId(result.id);
await templatesQuery.refetch();
},
});
const deleteTemplateMutation = trpc.report.deleteTemplate.useMutation({
onSuccess: async () => {
setSelectedTemplateId("");
setTemplateName("");
await templatesQuery.refetch();
},
});
// Scalar columns (for filter/sort/group — only non-relation columns)
const scalarColumns = useMemo(
@@ -76,12 +243,13 @@ export function ReportBuilder() {
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
};
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
// Fetch report data
const reportQuery = trpc.report.getReportData.useQuery(
@@ -99,6 +267,40 @@ export function ReportBuilder() {
setFilters([]);
setGroupBy("");
setSortBy("");
if (newEntity === "resource_month") {
setPeriodMonth((current) => current || getCurrentPeriodMonth());
}
setRunQuery(false);
setPage(0);
}, []);
const applyTemplate = useCallback((template: ReportTemplateSummary) => {
const config = template.config;
setSelectedTemplateId(template.id);
setTemplateName(template.name);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
setRunQuery(false);
setPage(0);
}, [templatesQuery.data]);
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
setSelectedTemplateId("");
setTemplateName(blueprint.templateName);
setEntity(blueprint.entity);
setSelectedColumns(new Set(blueprint.columns));
setFilters([]);
setGroupBy(blueprint.groupBy ?? "");
setSortBy(blueprint.sortBy ?? "");
setSortDir(blueprint.sortDir ?? "asc");
if (blueprint.entity === "resource_month") {
setPeriodMonth((current) => current || getCurrentPeriodMonth());
}
setRunQuery(false);
setPage(0);
}, []);
@@ -163,6 +365,7 @@ export function ReportBuilder() {
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
@@ -179,7 +382,42 @@ export function ReportBuilder() {
} catch {
// Error handled by tRPC
}
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
const handleSaveTemplate = useCallback(async () => {
if (!templateName.trim() || selectedColumns.size === 0) return;
await saveTemplateMutation.mutateAsync({
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
name: templateName.trim(),
config: {
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((filter) => filter.field && filter.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
},
});
}, [
entity,
filters,
groupBy,
periodMonth,
saveTemplateMutation,
selectedColumns,
selectedTemplateId,
sortBy,
sortDir,
templateName,
]);
const handleDeleteTemplate = useCallback(async () => {
if (!selectedTemplateId) return;
await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
}, [deleteTemplateMutation, selectedTemplateId]);
// ─── Derived ──────────────────────────────────────────────────────────
@@ -188,6 +426,15 @@ export function ReportBuilder() {
const outputColumns = reportQuery.data?.columns ?? [];
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching;
const templates = templatesQuery.data ?? [];
const resourceMonthBlueprints = useMemo(
() => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity),
[entity],
);
const recommendedColumnSet = useMemo(
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
[entity],
);
// Column label lookup
const columnLabelMap = useMemo(() => {
@@ -212,6 +459,61 @@ export function ReportBuilder() {
{/* Config Panel */}
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/60 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Template
</label>
<select
value={selectedTemplateId}
onChange={(e) => {
const nextId = e.target.value;
setSelectedTemplateId(nextId);
const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
if (template) {
applyTemplate(template);
}
}}
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
>
<option value="">Unsaved view</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Template name
</label>
<input
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="Monthly SAH by location"
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
/>
</div>
<button
type="button"
onClick={() => void handleSaveTemplate()}
disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
</button>
<button
type="button"
onClick={() => void handleDeleteTemplate()}
disabled={!selectedTemplateId || deleteTemplateMutation.isPending}
className="self-end rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300 dark:hover:bg-slate-900"
>
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
</button>
</div>
{/* Entity Selector */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -234,6 +536,73 @@ export function ReportBuilder() {
</button>
))}
</div>
{entity === "resource_month" && (
<div className="mt-4 space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200">
Period month
</label>
<input
type="month"
value={periodMonth}
onChange={(e) => 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"
/>
</div>
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
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.
</p>
</div>
<div className="grid gap-3 lg:grid-cols-3">
{resourceMonthBlueprints.map((blueprint) => (
<button
key={blueprint.id}
type="button"
onClick={() => applyBlueprint(blueprint)}
className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
>
<div className="text-sm font-semibold text-emerald-950 dark:text-emerald-100">
{blueprint.label}
</div>
<p className="mt-1 text-xs leading-5 text-emerald-900/75 dark:text-emerald-200/75">
{blueprint.description}
</p>
</button>
))}
</div>
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
Recommended transparency columns
</div>
<div className="mt-2 flex flex-wrap gap-2">
{RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => (
<button
key={column}
type="button"
onClick={() => toggleColumn(column)}
className={clsx(
"rounded-full border px-3 py-1 text-xs font-medium transition",
selectedColumns.has(column)
? "border-emerald-500 bg-emerald-500 text-white"
: "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
)}
>
{columnLabelMap.get(column) ?? column}
</button>
))}
</div>
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
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.
</p>
</div>
</div>
)}
</div>
{/* 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"
/>
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
{recommendedColumnSet.has(col.key) && (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
Rec
</span>
)}
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
{col.dataType}
</span>
@@ -428,6 +802,7 @@ export function ReportBuilder() {
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
@@ -436,6 +811,10 @@ export function ReportBuilder() {
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here.
</p>
</div>
<button
type="button"
onClick={() => void handleExport()}
@@ -209,17 +209,74 @@ interface SuggestionLike {
resourceName: string;
eid: string;
score: number;
valueScore?: number;
scoreBreakdown: {
skillScore: number;
availabilityScore: number;
costScore: number;
utilizationScore: number;
total?: number;
};
matchedSkills: string[];
missingSkills: string[];
availabilityConflicts: string[];
estimatedDailyCostCents: number;
currentUtilization: number;
remainingHours?: number;
remainingHoursPerDay?: number;
baseAvailableHours?: number;
effectiveAvailableHours?: number;
holidayHoursDeduction?: number;
location?: {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
label: string;
};
capacity?: {
requestedHoursPerDay: number;
requestedHoursTotal: number;
baseWorkingDays: number;
effectiveWorkingDays: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
bookedHours: number;
remainingHours: number;
remainingHoursPerDay: number;
holidayCount: number;
holidayWorkdayCount: number;
holidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
};
conflicts?: {
count: number;
conflictDays: string[];
details: Array<{
date: string;
baseHours: number;
effectiveHours: number;
allocatedHours: number;
remainingHours: number;
requestedHours: number;
shortageHours: number;
absenceFraction: number;
isHoliday: boolean;
}>;
};
ranking?: {
rank: number;
baseRank: number;
tieBreakerApplied: boolean;
tieBreakerReason: string | null;
model: string;
components: Array<{
key: string;
label: string;
score: number;
}>;
};
}
interface SuggestionCardProps {
@@ -231,10 +288,24 @@ interface SuggestionCardProps {
}
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) {
const [expanded, setExpanded] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showAssignForm, setShowAssignForm] = useState(false);
const locationLabel = suggestion.location?.label
|| [suggestion.location?.countryCode, suggestion.location?.federalState, suggestion.location?.metroCityName]
.filter(Boolean)
.join(" / ")
|| "No location";
const capacity = suggestion.capacity;
const conflicts = suggestion.conflicts;
const conflictCount = conflicts?.count ?? suggestion.availabilityConflicts.length;
const remainingHours = capacity?.remainingHours ?? suggestion.remainingHours ?? 0;
const remainingHoursPerDay = capacity?.remainingHoursPerDay ?? suggestion.remainingHoursPerDay ?? 0;
const baseAvailableHours = capacity?.baseAvailableHours ?? suggestion.baseAvailableHours ?? 0;
const effectiveAvailableHours = capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0;
const holidayHoursDeduction = capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0;
return (
<div className="app-surface p-5">
<div data-suggestion className="app-surface p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
@@ -243,15 +314,23 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
<div className="text-sm text-gray-500">{suggestion.eid}</div>
<div className="mt-1 text-xs text-gray-500">{locationLabel}</div>
</div>
</div>
<div className="flex items-start gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails((prev) => !prev)}
>
{showDetails ? "Hide Details" : "Details"}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => setExpanded((prev) => !prev)}
onClick={() => setShowAssignForm((prev) => !prev)}
>
{expanded ? "Cancel" : "Assign"}
{showAssignForm ? "Close Assign" : "Assign"}
</Button>
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
@@ -260,13 +339,6 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
@@ -280,24 +352,144 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
))}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Free / Workday"
value={formatHours(remainingHoursPerDay)}
tone={remainingHoursPerDay >= searchCriteria.hoursPerDay ? "good" : "warn"}
helper={`${formatHours(remainingHours)} total in window`}
/>
<StatCard
label="Capacity"
value={`${formatHours(effectiveAvailableHours)} effective`}
helper={`${formatHours(baseAvailableHours)} base`}
/>
<StatCard
label="Holiday Deduction"
value={holidayHoursDeduction > 0 ? formatHours(holidayHoursDeduction) : "0h"}
tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
/>
<StatCard
label="Conflicts"
value={String(conflictCount)}
tone={conflictCount > 0 ? "warn" : "good"}
helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"}
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
{suggestion.availabilityConflicts.length > 0 && (
{suggestion.valueScore != null && (
<span>Value Score: {suggestion.valueScore}</span>
)}
{conflictCount > 0 && (
<span className="font-medium text-amber-600 dark:text-amber-300">
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
{conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"}
</span>
)}
</div>
{expanded && (
{showDetails && (
<div className="mt-5 space-y-4 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-950/40">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
</div>
<div className="grid gap-4 xl:grid-cols-[1.15fr_1fr]">
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Capacity Basis</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<MetricLine label="Requested load" value={`${formatHours(capacity?.requestedHoursPerDay ?? searchCriteria.hoursPerDay)} / day`} />
<MetricLine label="Requested total" value={formatHours(capacity?.requestedHoursTotal ?? 0)} />
<MetricLine label="Base working days" value={String(capacity?.baseWorkingDays ?? 0)} />
<MetricLine label="Effective working days" value={String(capacity?.effectiveWorkingDays ?? 0)} />
<MetricLine label="Base available hours" value={formatHours(baseAvailableHours)} />
<MetricLine label="Effective available hours" value={formatHours(effectiveAvailableHours)} />
<MetricLine label="Booked hours" value={formatHours(capacity?.bookedHours ?? 0)} />
<MetricLine label="Remaining hours" value={formatHours(remainingHours)} />
<MetricLine label="Holiday deduction" value={formatHours(holidayHoursDeduction)} />
<MetricLine label="Absence deduction" value={formatHours(capacity?.absenceHoursDeduction ?? 0)} />
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Ranking Basis</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}
</p>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
{(suggestion.ranking?.components ?? []).map((component) => (
<MetricLine key={component.key} label={component.label} value={`${component.score}`} />
))}
{suggestion.ranking?.tieBreakerReason && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
{suggestion.ranking.tieBreakerReason}
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Location + Calendar</div>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
<MetricLine label="Location" value={locationLabel} />
<MetricLine label="Holiday dates" value={String(capacity?.holidayCount ?? 0)} />
<MetricLine label="Holiday workdays" value={String(capacity?.holidayWorkdayCount ?? 0)} />
<MetricLine label="Absence days" value={String(capacity?.absenceDayEquivalent ?? 0)} />
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Conflict Check</div>
<div className="text-xs text-gray-500">
Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
</div>
</div>
{conflictCount === 0 ? (
<p className="mt-3 text-sm text-green-700 dark:text-green-300">
No overloaded working days in the selected window.
</p>
) : (
<div className="mt-3 space-y-2">
{(conflicts?.details ?? []).slice(0, 6).map((item) => (
<div key={item.date} className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{item.date}</span>
<span>Short by {formatHours(item.shortageHours)}</span>
</div>
<div className="mt-1 text-xs">
Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
</div>
</div>
))}
{conflictCount > 6 && (
<p className="text-xs text-gray-500">
+{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"}
</p>
)}
</div>
)}
</div>
</div>
)}
{showAssignForm && (
<AssignForm
resourceId={suggestion.resourceId}
resourceName={suggestion.resourceName}
searchCriteria={searchCriteria}
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
onError={onError}
onCancel={() => setExpanded(false)}
onCancel={() => setShowAssignForm(false)}
/>
)}
</div>
@@ -499,3 +691,45 @@ function ScoreBar({ label, value, tooltip }: { label: string; value: number; too
</div>
);
}
function formatHours(value: number): string {
const rounded = Math.round(value * 10) / 10;
return `${rounded}h`;
}
function MetricLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-gray-100 pb-2 text-sm last:border-b-0 last:pb-0 dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">{label}</span>
<span className="text-right font-medium text-gray-900 dark:text-gray-100">{value}</span>
</div>
);
}
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 (
<div className={`rounded-2xl border p-3 ${toneClass}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">{value}</div>
{helper && (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{helper}</div>
)}
</div>
);
}
@@ -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<HTMLDivElement>(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 (
<div ref={ref} style={popoverStyle} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
Loading...
</div>
);
@@ -115,7 +101,7 @@ export function AllocationPopover({
return (
<div
ref={ref}
style={popoverStyle}
style={style}
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
style={popoverStyle}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
>
{/* Header */}
@@ -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<HTMLDivElement>(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 (
<div
ref={ref}
data-resource-hover-card="true"
style={popoverStyle}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
onMouseLeave={onClose}
>
@@ -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<string, VacationEntry[]>();
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(
@@ -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<HTMLDivElement | null>(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)),
const { panelRef, position } = useAnchoredOverlay<HTMLDivElement>({
open: isOpen,
onClose,
align: "end",
triggerRef: anchorRef,
});
}, [anchorRef]);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [anchorRef, isOpen, onClose, updatePanelPosition]);
if (!isOpen) return null;
@@ -221,7 +179,7 @@ export function TimelineFilter({
return createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: panelPosition.top, left: panelPosition.left }}
style={{ position: "fixed", top: position.top, left: position.left }}
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
@@ -188,8 +188,10 @@ function TimelineProjectPanelInner({
} | null>(null);
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
const demandTooltipRef = useRef<HTMLDivElement | null>(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 | {
roleName: string;
roleColor: string;
projectName: string;
projectShortCode?: string | null;
requestedHeadcount: number;
unfilledHeadcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
totalHours: number;
percentage?: number;
status?: string;
totalCostCents?: number;
dailyCostCents?: number;
}>(null);
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
const dateIndexByTime = new Map<number, number>();
@@ -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({
</div>
<div
data-testid="timeline-project-resource-row-canvas"
data-project-id={row.project.id}
data-resource-id={row.resource.id}
className="relative overflow-hidden touch-none"
style={{
width: totalCanvasWidth,
@@ -792,8 +856,11 @@ function TimelineProjectPanelInner({
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
vacationTooltipPos={vacationTooltipPosRef.current}
demandTooltipRef={demandTooltipRef}
demandTooltipPos={demandTooltipPosRef.current}
heatmapHover={heatmapHover}
vacationHover={vacationHover}
demandHover={demandHover}
/>
</div>
);
@@ -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(
<div
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
style={{ width: totalCanvasWidth, height: rowHeight }}
onMouseLeave={onClearHoverTooltips}
>
{rowGridLines}
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
@@ -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 */}
<div
@@ -1,8 +1,9 @@
"use client";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js";
import type { TimelineFilters } from "./TimelineFilter.js";
@@ -20,68 +21,22 @@ function TimelineFilterDropdown({
tooltipContent?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
open: isOpen,
onClose: () => setIsOpen(false),
matchTriggerWidth: true,
});
}, []);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("keydown", handleEscape);
};
}, [isOpen, updatePanelPosition]);
return (
<div ref={dropdownRef} className="relative">
<div ref={triggerRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
onClick={() => {
const nextOpen = !isOpen;
handleOpenChange(nextOpen);
setIsOpen(nextOpen);
}}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
@@ -95,9 +50,9 @@ function TimelineFilterDropdown({
ref={panelRef}
style={{
position: "fixed",
top: panelPosition.top,
left: panelPosition.left,
minWidth: panelPosition.minWidth,
top: position.top,
left: position.left,
minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
@@ -359,6 +359,7 @@ function TimelineResourcePanelInner({
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
date.setHours(0, 0, 0, 0);
const t = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
@@ -494,6 +495,10 @@ function TimelineResourcePanelInner({
{/* Row canvas */}
<div
data-testid="timeline-resource-row-canvas"
data-resource-id={resource.id}
data-resource-eid={resource.eid}
data-resource-name={resource.displayName}
className="relative overflow-hidden touch-none"
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
onMouseDown={(e) => {
@@ -542,7 +547,8 @@ function TimelineResourcePanelInner({
onAllocationContextMenu,
multiSelectState,
)}
{renderVacationBlocks(
{filters.showVacations &&
renderVacationBlocks(
vacationBlocksByResource.get(resource.id) ?? [],
rowHeight,
)}
@@ -1,6 +1,13 @@
"use client";
import { formatDateLong } from "~/lib/format.js";
import { formatCents, formatDateLong } from "~/lib/format.js";
function getVacationTitle(vacation: VacationHoverData): string {
if (vacation.type === "PUBLIC_HOLIDAY" && vacation.note) {
return vacation.note;
}
return vacation.type.replaceAll("_", " ");
}
export type HeatmapHoverData = {
date: Date;
@@ -30,6 +37,23 @@ export type VacationHoverData = {
approvedAt?: Date | string | null;
};
export type DemandHoverData = {
roleName: string;
roleColor: string;
projectName: string;
projectShortCode?: string | null;
requestedHeadcount: number;
unfilledHeadcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
totalHours: number;
percentage?: number;
status?: string;
totalCostCents?: number;
dailyCostCents?: number;
};
interface TimelineTooltipProps {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
@@ -37,6 +61,9 @@ interface TimelineTooltipProps {
vacationTooltipPos: { left: number; top: number };
heatmapHover: HeatmapHoverData | null;
vacationHover: VacationHoverData | null;
demandTooltipRef?: React.RefObject<HTMLDivElement | null>;
demandTooltipPos?: { left: number; top: number };
demandHover?: DemandHoverData | null;
}
export function TimelineTooltip({
@@ -46,7 +73,87 @@ export function TimelineTooltip({
vacationTooltipPos,
heatmapHover,
vacationHover,
demandTooltipRef,
demandTooltipPos,
demandHover,
}: TimelineTooltipProps) {
const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
if (demandHover && demandTooltipRef && demandTooltipPos) {
return (
<div
ref={demandTooltipRef}
style={{
left: demandTooltipPos.left,
top: demandTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: demandHover.roleColor }}
/>
<span className="truncate font-semibold">{demandHover.roleName}</span>
</div>
<div className="truncate text-[11px] text-gray-400">
{demandHover.projectShortCode ? `${demandHover.projectShortCode} · ` : ""}
{demandHover.projectName}
</div>
</div>
{demandHover.status ? (
<span className="text-[10px] uppercase tracking-wide text-amber-300">
{demandHover.status}
</span>
) : null}
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div>
<div className="text-gray-500">Requested</div>
<div className="font-medium text-gray-100">{demandHover.requestedHeadcount}</div>
</div>
<div>
<div className="text-gray-500">Open</div>
<div className="font-medium text-amber-300">{demandHover.unfilledHeadcount}</div>
</div>
<div>
<div className="text-gray-500">Range</div>
<div className="font-medium text-gray-100">
{formatDateLong(demandHover.startDate)} to {formatDateLong(demandHover.endDate)}
</div>
</div>
<div>
<div className="text-gray-500">Load</div>
<div className="font-medium text-gray-100">
{demandHover.hoursPerDay}h/day · {demandHover.totalHours}h
</div>
</div>
{typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? (
<div>
<div className="text-gray-500">Allocation</div>
<div className="font-medium text-gray-100">{demandHover.percentage}%</div>
</div>
) : null}
{typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
<div>
<div className="text-gray-500">Cost</div>
<div className="font-medium text-gray-100">
{formatCents(demandHover.totalCostCents)} EUR
{typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
: ""}
</div>
</div>
) : null}
</div>
</div>
);
}
// When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) {
return (
@@ -114,14 +221,12 @@ export function TimelineTooltip({
<div className="mt-2 pt-2 border-t border-amber-700/40">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
<span className="font-semibold text-amber-300">
{vacationHover.type.replaceAll("_", " ")}
</span>
<span className="font-semibold text-amber-300">{vacationTitle}</span>
</div>
<div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
) : null}
</div>
@@ -200,11 +305,11 @@ export function TimelineTooltip({
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="font-semibold">{vacationTitle}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
@@ -35,6 +35,11 @@ export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: num
return (
<div
key={`vac-${v.id}`}
data-testid="timeline-vacation-block"
data-vacation-id={v.id}
data-vacation-type={v.type}
data-vacation-status={v.status}
data-vacation-note={v.note ?? ""}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
colorClass,
@@ -1,7 +1,9 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useState, useRef, useCallback } from "react";
import type { ColumnDef } from "@capakraken/shared";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
interface ColumnTogglePanelProps {
allColumns: ColumnDef[];
@@ -17,18 +19,11 @@ export function ColumnTogglePanel({
defaultKeys,
}: ColumnTogglePanelProps) {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
open,
onClose: () => setOpen(false),
align: "end",
});
const dragKey = useRef<string | null>(null);
@@ -59,11 +54,20 @@ export function ColumnTogglePanel({
const builtins = allColumns.filter((c) => !c.isCustom);
const customs = allColumns.filter((c) => c.isCustom);
const handleToggleOpen = useCallback(() => {
setOpen((current) => {
const nextOpen = !current;
handleOpenChange(nextOpen);
return nextOpen;
});
}, [handleOpenChange]);
return (
<div className="relative" ref={panelRef}>
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((o) => !o)}
onClick={handleToggleOpen}
title="Toggle columns"
className={`p-1.5 rounded-lg border text-sm transition-colors ${
open
@@ -80,10 +84,17 @@ export function ColumnTogglePanel({
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-xl py-2">
<div className="px-3 pb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span>
{open &&
createPortal(
<div
ref={panelRef}
className="fixed z-[9998] w-52 rounded-xl border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-900"
style={{ top: position.top, left: position.left }}
>
<div className="flex items-center justify-between px-3 pb-1">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Columns
</span>
<button
type="button"
onClick={reset}
@@ -100,17 +111,24 @@ export function ColumnTogglePanel({
<div
key={col.key}
draggable={col.hideable && isVisible}
onDragStart={() => { 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 ${
onDragStart={() => {
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 && (
<span className="text-gray-300 text-xs select-none"></span>
<span className="select-none text-xs text-gray-300"></span>
)}
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<label className="flex flex-1 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={isVisible}
@@ -118,7 +136,7 @@ export function ColumnTogglePanel({
disabled={!col.hideable}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{col.label}</span>
<span className="text-sm text-gray-700 dark:text-gray-200">{col.label}</span>
</label>
</div>
);
@@ -126,28 +144,37 @@ export function ColumnTogglePanel({
{customs.length > 0 && (
<>
<div className="my-1 border-t border-gray-100" />
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p>
<div className="my-1 border-t border-gray-100 dark:border-gray-800" />
<p className="px-3 py-1 text-xs font-medium text-gray-400">Custom Fields</p>
{customs.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={isVisible}
onDragStart={() => { 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"
onDragStart={() => {
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 && <span className="text-gray-300 text-xs select-none"></span>}
<label className="flex items-center gap-2 flex-1 cursor-pointer">
{isVisible && <span className="select-none text-xs text-gray-300"></span>}
<label className="flex flex-1 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{col.label}</span>
<span className="text-sm text-gray-700 dark:text-gray-200">
{col.label}
</span>
</label>
</div>
);
@@ -155,7 +182,8 @@ export function ColumnTogglePanel({
</>
)}
</div>
</div>
</div>,
document.body,
)}
</div>
);
@@ -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<ScopeType, string> = {
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<string | null>(null);
const [scopeType, setScopeType] = useState<ScopeType>("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<string | null>(null);
const [calendarDraft, setCalendarDraft] = useState({
name: "",
priority: 0,
stateCode: "",
metroCityId: "",
isActive: true,
});
const [editingEntryId, setEditingEntryId] = useState<string | null>(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 (
<div
data-testid="holiday-calendar-editor"
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5"
>
<div>
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Holiday Calendar Editor</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Pflege Feiertagskalender pro Land, Bundesland/Region oder Stadt. Die Vorschau zeigt den effektiv aufgeloesten Kalender fuer den gewaelten Scope.
</p>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
{error}
</div>
)}
<div className="grid gap-5 lg:grid-cols-[1.1fr_1.4fr]">
<form onSubmit={handleCreateCalendar} className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</span>
<input
data-testid="holiday-calendar-name-input"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full 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"
placeholder="Bayern Feiertage"
required
/>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Scope</span>
<select
data-testid="holiday-calendar-scope-select"
value={scopeType}
onChange={(e) => setScopeType(e.target.value as ScopeType)}
className="w-full 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"
>
{Object.entries(SCOPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Land</span>
<select
data-testid="holiday-calendar-country-select"
value={countryId}
onChange={(e) => {
setCountryId(e.target.value);
setMetroCityId("");
}}
className="w-full 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"
required
>
<option value="">Land waehlen</option>
{countryRows.map((country) => (
<option key={country.id} value={country.id}>{country.name} ({country.code})</option>
))}
</select>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
<input
data-testid="holiday-calendar-priority-input"
type="number"
value={priority}
onChange={(e) => setPriority(parseInt(e.target.value, 10) || 0)}
className="w-full 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"
/>
</label>
</div>
{scopeType === "STATE" && (
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
<input
data-testid="holiday-calendar-state-input"
value={stateCode}
onChange={(e) => setStateCode(e.target.value.toUpperCase())}
className="w-full 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"
placeholder="BY"
required
/>
</label>
)}
{scopeType === "CITY" && (
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
<select
data-testid="holiday-calendar-city-select"
value={metroCityId}
onChange={(e) => setMetroCityId(e.target.value)}
className="w-full 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"
required
>
<option value="">Stadt waehlen</option>
{(selectedCountry?.metroCities ?? []).map((city) => (
<option key={city.id} value={city.id}>{city.name}</option>
))}
</select>
</label>
)}
<button
data-testid="holiday-calendar-create-button"
type="submit"
disabled={createCalendar.isPending || !isCreateScopeValid}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{createCalendar.isPending ? "Speichert..." : "Kalender anlegen"}
</button>
</form>
<div className="space-y-4">
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Kalender</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Scope</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Zuordnung</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Eintraege</th>
</tr>
</thead>
<tbody>
{calendarRows.length === 0 && (
<tr>
<td colSpan={4} className="px-3 py-6 text-center text-sm text-gray-400">Noch keine Feiertagskalender vorhanden.</td>
</tr>
)}
{calendarRows.map((calendar) => (
<tr
key={calendar.id}
data-testid={`holiday-calendar-row-${calendar.id}`}
className={`cursor-pointer border-t border-gray-200 dark:border-gray-700 ${selectedCalendarId === calendar.id ? "bg-brand-50 dark:bg-brand-950/20" : "hover:bg-gray-50 dark:hover:bg-gray-900/40"}`}
onClick={() => setSelectedCalendarId(calendar.id)}
>
<td className="px-3 py-2">
<div className="font-medium text-gray-900 dark:text-gray-100">{calendar.name}</div>
<div className="text-xs text-gray-500">{calendar.country.name}</div>
</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{SCOPE_LABELS[calendar.scopeType]}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
{calendar.scopeType === "COUNTRY" && calendar.country.code}
{calendar.scopeType === "STATE" && calendar.stateCode}
{calendar.scopeType === "CITY" && calendar.metroCity?.name}
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-400">{calendar._count?.entries ?? calendar.entries.length}</td>
</tr>
))}
</tbody>
</table>
</div>
{selectedCalendar && (
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{selectedCalendar.name}</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{SCOPE_LABELS[selectedCalendar.scopeType]} · {selectedCalendar.country.name}
{selectedCalendar.stateCode ? ` · ${selectedCalendar.stateCode}` : ""}
{selectedCalendar.metroCity?.name ? ` · ${selectedCalendar.metroCity.name}` : ""}
</p>
</div>
<div className="flex gap-2">
<button
data-testid="holiday-calendar-toggle-active-button"
type="button"
onClick={() => updateCalendar.mutate({
id: selectedCalendar.id,
data: { isActive: !selectedCalendar.isActive },
})}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
>
{selectedCalendar.isActive ? "Deaktivieren" : "Aktivieren"}
</button>
<button
data-testid="holiday-calendar-delete-button"
type="button"
onClick={() => handleDeleteCalendar(selectedCalendar)}
disabled={deleteCalendar.isPending}
className="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950/30"
>
Loeschen
</button>
</div>
</div>
<form onSubmit={handleUpdateCalendar} className="grid gap-3 md:grid-cols-2 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Kalendername</span>
<input
data-testid="holiday-calendar-draft-name-input"
value={calendarDraft.name}
onChange={(e) => setCalendarDraft((current) => ({ ...current, name: e.target.value }))}
className="w-full 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"
required
/>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
<input
data-testid="holiday-calendar-draft-priority-input"
type="number"
value={calendarDraft.priority}
onChange={(e) => setCalendarDraft((current) => ({
...current,
priority: parseInt(e.target.value, 10) || 0,
}))}
className="w-full 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"
/>
</label>
{selectedCalendar.scopeType === "STATE" && (
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
<input
data-testid="holiday-calendar-draft-state-input"
value={calendarDraft.stateCode}
onChange={(e) => setCalendarDraft((current) => ({
...current,
stateCode: e.target.value.toUpperCase(),
}))}
className="w-full 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"
placeholder="BY"
required
/>
</label>
)}
{selectedCalendar.scopeType === "CITY" && (
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
<select
data-testid="holiday-calendar-draft-city-select"
value={calendarDraft.metroCityId}
onChange={(e) => setCalendarDraft((current) => ({
...current,
metroCityId: e.target.value,
}))}
className="w-full 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"
required
>
<option value="">Stadt waehlen</option>
{(selectedCalendarCountry?.metroCities ?? []).map((city) => (
<option key={city.id} value={city.id}>{city.name}</option>
))}
</select>
</label>
)}
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input
type="checkbox"
checked={calendarDraft.isActive}
onChange={(e) => setCalendarDraft((current) => ({
...current,
isActive: e.target.checked,
}))}
className="rounded border-gray-300 dark:border-gray-600"
/>
Kalender aktiv
</label>
<div className="flex items-end justify-end gap-2 md:col-span-2">
<button
data-testid="holiday-calendar-reset-button"
type="button"
onClick={resetCalendarDraft}
disabled={!isCalendarDirty || updateCalendar.isPending}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
>
Zuruecksetzen
</button>
<button
data-testid="holiday-calendar-save-button"
type="submit"
disabled={!isCalendarDirty || updateCalendar.isPending}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{updateCalendar.isPending ? "Speichert..." : "Kalender speichern"}
</button>
</div>
</form>
<form onSubmit={handleAddEntry} className="grid gap-3 md:grid-cols-[1fr_1.25fr_1fr_auto]">
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</span>
<input
data-testid="holiday-entry-date-input"
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
className="w-full 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"
required
/>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Feiertagsname</span>
<input
data-testid="holiday-entry-name-input"
value={entryName}
onChange={(e) => setEntryName(e.target.value)}
className="w-full 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"
placeholder="Augsburger Friedensfest"
required
/>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Quelle</span>
<input
data-testid="holiday-entry-source-input"
value={entrySource}
onChange={(e) => setEntrySource(e.target.value)}
className="w-full 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"
placeholder="Kommunale Satzung"
/>
</label>
<button
data-testid="holiday-entry-create-button"
type="submit"
disabled={createEntry.isPending || !entryDate || !entryName.trim()}
className="self-end rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
Hinzufuegen
</button>
</form>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input
data-testid="holiday-entry-recurring-checkbox"
type="checkbox"
checked={entryRecurring}
onChange={(e) => setEntryRecurring(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Jaehrlich wiederkehrend
</label>
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Typ</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Aktion</th>
</tr>
</thead>
<tbody>
{selectedCalendar.entries.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-sm text-gray-400">Keine Eintraege vorhanden.</td>
</tr>
)}
{selectedCalendar.entries.map((entry) => (
<tr key={entry.id} className="border-t border-gray-200 dark:border-gray-700">
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">
{editingEntryId === entry.id ? (
<input
data-testid={`holiday-entry-edit-date-${entry.id}`}
type="date"
value={entryDraft.date}
onChange={(e) => 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)}
</td>
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">
{editingEntryId === entry.id ? (
<input
data-testid={`holiday-entry-edit-name-${entry.id}`}
value={entryDraft.name}
onChange={(e) => 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}
</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
{editingEntryId === entry.id ? (
<label className="flex items-center gap-2">
<input
data-testid={`holiday-entry-edit-recurring-${entry.id}`}
type="checkbox"
checked={entryDraft.isRecurringAnnual}
onChange={(e) => setEntryDraft((current) => ({
...current,
isRecurringAnnual: e.target.checked,
}))}
className="rounded border-gray-300 dark:border-gray-600"
/>
Jaehrlich
</label>
) : entry.isRecurringAnnual ? "jaehrlich" : "fix"}
</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
{editingEntryId === entry.id ? (
<input
data-testid={`holiday-entry-edit-source-${entry.id}`}
value={entryDraft.source}
onChange={(e) => 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"}
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-3">
{editingEntryId === entry.id ? (
<>
<button
data-testid={`holiday-entry-save-${entry.id}`}
type="button"
onClick={() => handleUpdateEntry(entry.id)}
disabled={updateEntry.isPending || !entryDraft.date || !entryDraft.name.trim()}
className="text-xs font-medium text-brand-600 hover:text-brand-700 disabled:opacity-50"
>
Speichern
</button>
<button
data-testid={`holiday-entry-cancel-${entry.id}`}
type="button"
onClick={() => setEditingEntryId(null)}
disabled={updateEntry.isPending}
className="text-xs font-medium text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
Abbrechen
</button>
</>
) : (
<button
data-testid={`holiday-entry-edit-${entry.id}`}
type="button"
onClick={() => startEditingEntry(entry)}
className="text-xs font-medium text-brand-600 hover:text-brand-700"
>
Bearbeiten
</button>
)}
<button
data-testid={`holiday-entry-delete-${entry.id}`}
type="button"
onClick={() => handleDeleteEntry(entry)}
disabled={deleteEntry.isPending}
className="text-xs font-medium text-red-600 hover:text-red-700"
>
Entfernen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="space-y-3 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Vorschau</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Effektiv aufgeloeste Feiertage fuer den gewaehlten Scope.</p>
</div>
<input
data-testid="holiday-preview-year-input"
type="number"
value={previewYear}
onChange={(e) => 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"
/>
</div>
<div className="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table data-testid="holiday-preview-table" className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
</tr>
</thead>
<tbody>
{(previewQuery.data ?? []).length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-6 text-center text-sm text-gray-400">
{previewQuery.isLoading ? "Laedt Vorschau..." : "Keine Feiertage fuer diese Auswahl vorhanden."}
</td>
</tr>
)}
{(previewQuery.data ?? []).map((entry) => (
<tr key={`${entry.date}-${entry.name}`} className="border-t border-gray-200 dark:border-gray-700">
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{entry.date}</td>
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">{entry.name}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{entry.calendarName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -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() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Regional public holidays are maintained in{" "}
<Link href="/admin/vacations" className="font-medium text-brand-700 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300">
Holiday Calendars
</Link>
.
</p>
</div>
<button
type="button"
@@ -10,6 +10,34 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
const VACATION_TYPES = Object.values(VacationType);
const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter((type) => type !== VacationType.PUBLIC_HOLIDAY);
const HOLIDAY_SOURCE_LABELS = {
CALENDAR: "Calendar",
LEGACY_PUBLIC_HOLIDAY: "Legacy import",
CALENDAR_AND_LEGACY: "Calendar + legacy",
} as const;
type VacationPreviewData = {
requestedDays: number;
effectiveDays: number;
deductedDays: number;
publicHolidayDates: string[];
holidayDetails: Array<{
date: string;
source: string;
}>;
holidayContext: {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
sources: {
hasCalendarHolidays: boolean;
hasLegacyPublicHolidayEntries: boolean;
};
};
};
interface VacationModalProps {
resourceId?: string;
@@ -17,13 +45,34 @@ interface VacationModalProps {
onSuccess: () => void;
}
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
function toUtcInputDate(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
}
function buildHolidayBasisLabel(preview: VacationPreviewData): string[] {
const parts = [];
if (preview.holidayContext.countryName || preview.holidayContext.countryCode) {
parts.push(preview.holidayContext.countryName ?? preview.holidayContext.countryCode ?? "");
}
if (preview.holidayContext.federalState) {
parts.push(preview.holidayContext.federalState);
}
if (preview.holidayContext.metroCityName) {
parts.push(preview.holidayContext.metroCityName);
}
return parts.filter(Boolean);
}
function getHolidaySourceLabel(source: string): string {
if (source in HOLIDAY_SOURCE_LABELS) {
return HOLIDAY_SOURCE_LABELS[source as keyof typeof HOLIDAY_SOURCE_LABELS];
}
return source;
}
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
@@ -70,6 +119,24 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
);
const previewQuery = trpc.vacation.previewRequest.useQuery(
{
resourceId,
type,
startDate: toUtcInputDate(debouncedStart || "1970-01-01"),
endDate: toUtcInputDate(debouncedEnd || "1970-01-01"),
...(isHalfDay ? { isHalfDay: true } : {}),
},
{
enabled:
!!resourceId
&& !!debouncedStart
&& !!debouncedEnd
&& (!isHalfDay || debouncedStart === debouncedEnd),
staleTime: 10_000,
},
);
const utils = trpc.useUtils();
const createMutation = trpc.vacation.create.useMutation({
@@ -166,7 +233,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{/* Type */}
<div>
<label htmlFor="vac-type" className={labelClass}>
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · PUBLIC_HOLIDAY = national/regional holiday · OTHER = special leave." />
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." />
</label>
<select
id="vac-type"
@@ -174,7 +241,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
onChange={(e) => setType(e.target.value as VacationType)}
className={inputClass}
>
{VACATION_TYPES.map((t) => (
{REQUESTABLE_VACATION_TYPES.map((t) => (
<option key={t} value={t}>
{VACATION_TYPE_LABELS[t]}
</option>
@@ -282,6 +349,81 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
</div>
)}
{!!resourceId && !!startDate && !!endDate && (
<div
data-testid="vacation-preview-card"
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900"
>
<div className="flex items-center justify-between gap-3">
<strong>Leave preview</strong>
{previewQuery.isLoading && (
<span className="text-xs text-emerald-700">Calculating</span>
)}
</div>
{previewQuery.data && (
<div className="mt-2 space-y-2">
<div className="grid grid-cols-3 gap-2 text-xs sm:text-sm">
<div className="rounded-md bg-white/70 px-3 py-2">
<div className="text-emerald-700">Requested</div>
<div data-testid="vacation-preview-requested-days" className="font-semibold">
{previewQuery.data.requestedDays}
</div>
</div>
<div className="rounded-md bg-white/70 px-3 py-2">
<div className="text-emerald-700">Effective</div>
<div data-testid="vacation-preview-effective-days" className="font-semibold">
{previewQuery.data.effectiveDays}
</div>
</div>
<div className="rounded-md bg-white/70 px-3 py-2">
<div className="text-emerald-700">Deducted</div>
<div data-testid="vacation-preview-deducted-days" className="font-semibold">
{previewQuery.data.deductedDays}
</div>
</div>
</div>
{buildHolidayBasisLabel(previewQuery.data).length > 0 && (
<div data-testid="vacation-preview-holiday-basis" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
<span className="font-medium">Holiday basis:</span>{" "}
{buildHolidayBasisLabel(previewQuery.data).join(" / ")}
</div>
)}
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays || previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
<div data-testid="vacation-preview-holiday-sources" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
<span className="font-medium">Sources:</span>{" "}
{[
previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null,
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null,
].filter(Boolean).join(" + ")}
</div>
)}
{previewQuery.data.publicHolidayDates.length > 0 && (
<div data-testid="vacation-preview-public-holidays" className="text-xs sm:text-sm">
<span className="font-medium">Excluded public holidays:</span>{" "}
{previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")}
</div>
)}
{previewQuery.data.requestedDays !== previewQuery.data.deductedDays && (
<div className="text-xs sm:text-sm text-emerald-800">
Public holidays in the selected range are excluded from deducted leave days.
</div>
)}
</div>
)}
{previewQuery.error && (
<div className="mt-2 text-xs text-red-700">
{previewQuery.error.message}
</div>
)}
</div>
)}
{/* Note */}
<div>
<label htmlFor="vac-note" className={labelClass}>
+155
View File
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
type HorizontalAlign = "start" | "end" | "center";
type VerticalAlign = "start" | "end" | "center";
type OverlaySide = "bottom" | "right";
interface UseAnchoredOverlayOptions<TTrigger extends HTMLElement> {
open: boolean;
onClose: () => void;
offset?: number;
viewportPadding?: number;
side?: OverlaySide;
align?: HorizontalAlign;
crossAlign?: VerticalAlign;
matchTriggerWidth?: boolean;
triggerRef?: RefObject<TTrigger | null>;
}
interface OverlayPosition {
top: number;
left: number;
minWidth?: number;
}
export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
open,
onClose,
offset = 8,
viewportPadding = 16,
side = "bottom",
align = "start",
crossAlign = "start",
matchTriggerWidth = false,
triggerRef: externalTriggerRef,
}: UseAnchoredOverlayOptions<TTrigger>) {
const internalTriggerRef = useRef<TTrigger | null>(null);
const triggerRef = externalTriggerRef ?? internalTriggerRef;
const panelRef = useRef<HTMLDivElement | null>(null);
const [position, setPosition] = useState<OverlayPosition>({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) {
return;
}
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const panelHeight = panelRef.current?.offsetHeight ?? 0;
let nextTop = rect.bottom + offset;
let nextLeft = rect.left;
if (side === "right") {
nextLeft = rect.right + offset;
if (crossAlign === "center") {
nextTop = rect.top + rect.height / 2 - panelHeight / 2;
} else if (crossAlign === "end") {
nextTop = rect.bottom - panelHeight;
} else {
nextTop = rect.top;
}
} else {
if (align === "end") {
nextLeft = rect.right - panelWidth;
} else if (align === "center") {
nextLeft = rect.left + rect.width / 2 - panelWidth / 2;
}
nextTop = rect.bottom + offset;
const nextBottom = nextTop + panelHeight;
const flippedTop = rect.top - panelHeight - offset;
if (panelHeight > 0 && nextBottom > window.innerHeight - viewportPadding && flippedTop >= viewportPadding) {
nextTop = flippedTop;
}
}
const boundedLeft = Math.min(
Math.max(nextLeft, viewportPadding),
Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding),
);
const boundedTop = Math.min(
Math.max(nextTop, viewportPadding),
Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding),
);
setPosition({
top: boundedTop,
left: boundedLeft,
...(matchTriggerWidth ? { minWidth: rect.width } : {}),
});
}, [align, crossAlign, matchTriggerWidth, offset, side, triggerRef, viewportPadding]);
useEffect(() => {
if (!open) {
return;
}
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
onClose();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [onClose, open]);
useEffect(() => {
if (!open) {
return;
}
updatePosition();
const rafId = window.requestAnimationFrame(updatePosition);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open, updatePosition]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (nextOpen) {
updatePosition();
return;
}
onClose();
}, [onClose, updatePosition]);
return {
triggerRef,
panelRef,
position,
updatePosition,
handleOpenChange,
};
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
type PopoverAnchor =
| { kind: "point"; x: number; y: number }
| { kind: "element"; element: HTMLElement };
type PopoverSide = "bottom" | "right";
type PopoverAlign = "start" | "end" | "center";
interface UseViewportPopoverOptions {
anchor: PopoverAnchor;
width: number;
estimatedHeight: number;
onClose: () => void;
side?: PopoverSide;
align?: PopoverAlign;
offset?: number;
viewportPadding?: number;
ignoreElements?: Array<HTMLElement | null>;
}
export function useViewportPopover({
anchor,
width,
estimatedHeight,
onClose,
side = "bottom",
align = "start",
offset = 8,
viewportPadding = 16,
ignoreElements = [],
}: UseViewportPopoverOptions) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (ref.current?.contains(target)) {
return;
}
if (ignoreElements.some((element) => element?.contains(target))) {
return;
}
onClose();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [ignoreElements, onClose]);
const style = useMemo<CSSProperties>(() => {
let left = 0;
let top = 0;
if (anchor.kind === "element") {
const rect = anchor.element.getBoundingClientRect();
if (side === "right") {
left = rect.right + offset;
if (align === "end") {
top = rect.bottom - estimatedHeight;
} else if (align === "center") {
top = rect.top + rect.height / 2 - estimatedHeight / 2;
} else {
top = rect.top;
}
} else {
left = rect.left;
if (align === "end") {
left = rect.right - width;
} else if (align === "center") {
left = rect.left + rect.width / 2 - width / 2;
}
top = rect.bottom + offset;
}
} else {
left = anchor.x;
top = anchor.y + offset;
if (align === "end") {
left = anchor.x - width;
} else if (align === "center") {
left = anchor.x - width / 2;
}
}
const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedHeight - viewportPadding);
return {
position: "fixed",
left: Math.min(Math.max(left, viewportPadding), maxLeft),
top: Math.min(Math.max(top, viewportPadding), maxTop),
width,
zIndex: 60,
};
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width]);
return { ref, style };
}
+5 -2
View File
@@ -1,6 +1,6 @@
import { prisma } from "@capakraken/db";
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
import { createAuditEntry } from "@capakraken/api";
import { createAuditEntry } from "@capakraken/api/lib/audit";
import { logger } from "@capakraken/api/lib/logger";
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
@@ -27,9 +27,12 @@ const authConfig = {
if (!parsed.success) return null;
const { email, password, totp } = parsed.data;
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
// Rate limit: 5 login attempts per 15 minutes per email
const rateLimitResult = authRateLimiter(email.toLowerCase());
const rateLimitResult = isE2eTestMode
? { allowed: true }
: authRateLimiter(email.toLowerCase());
if (!rateLimitResult.allowed) {
// Audit failed login (rate limited)
void createAuditEntry({
+3 -2
View File
@@ -16,10 +16,11 @@
"isolatedModules": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
"next-env.d.ts",
".next-e2e/types/**/*.ts"
],
"exclude": [
"node_modules"
+4
View File
@@ -1,3 +1,5 @@
name: capakraken-prod
services:
postgres:
image: postgres:16-alpine
@@ -66,4 +68,6 @@ services:
volumes:
capakraken_prod_pgdata:
name: capakraken_prod_pgdata
capakraken_prod_redis:
name: capakraken_prod_redis
+4 -1
View File
@@ -1,3 +1,5 @@
name: capakraken
services:
postgres:
image: postgres:16-alpine
@@ -69,7 +71,7 @@ services:
postgres-test:
image: postgres:16-alpine
ports:
- "5434:5432"
- "${POSTGRES_TEST_PORT:-5434}:5432"
environment:
POSTGRES_DB: capakraken_test
POSTGRES_USER: capakraken
@@ -81,3 +83,4 @@ services:
volumes:
capakraken_pgdata:
name: capakraken_pgdata
+492
View File
@@ -0,0 +1,492 @@
# Assistant Capability Gap Analysis
## Zielbild
Der AI Assistant soll grundsaetzlich alles lesen und ausfuehren koennen, was ein eingeloggter Nutzer gemaess seiner Rolle, Permission-Overrides und Objekt-Sichtbarkeit auch kann. Er darf weder weniger fachlich relevante Informationen sehen als die UI noch mehr Rechte erhalten als der Nutzer selbst.
## Ist-Zustand
Der Assistant ist bereits relativ breit aufgestellt:
- Er haengt an `packages/api/src/router/assistant.ts`.
- Er exponiert aktuell 88 Function-Calling-Tools aus `packages/api/src/router/assistant-tools.ts`.
- Er deckt viele Kernbereiche bereits ab: Ressourcen, Projekte, Allokationen, Urlaub, Feiertagsabfragen, Staffing, Demand, Dashboard, einfache Insights, Kommentare, Notifications, Tasks, Reporting, Szenario-Simulation und Navigation.
Trotzdem ist die Paritaet zur eigentlichen App/API noch nicht erreicht. Die groessten Luecken liegen nicht bei "gar nichts vorhanden", sondern bei:
- fehlenden Admin- und Konfigurationsfaehigkeiten,
- fehlenden tiefen Fach-Readmodels,
- inkonsistentem Permission-Gating,
- fehlender serverseitiger Absicherung fuer schreibende AI-Aktionen,
- und einigen objektbezogenen Sichtbarkeitsfehlern.
## Architektur des Assistants
### Routing und Tool-Aufruf
- `assistant.chat` baut den System Prompt, filtert die verfuegbaren Tools und laesst das Modell Tools aufrufen.
- Der eigentliche Datenzugriff liegt fast komplett in `executeTool(...)` und den `executors` in `packages/api/src/router/assistant-tools.ts`.
### Permission-Gating
Es gibt aktuell vier Permission-/Scope-Ebenen:
1. Tool-Sichtbarkeit vor dem Modellaufruf in `assistant.ts`
- `TOOL_PERMISSION_MAP` blendet bestimmte Schreib-Tools aus.
- `COST_TOOLS` blendet kostenrelevante Tools ohne `viewCosts` aus.
2. Laufzeit-Guards in einzelnen Tool-Executors
- Viele Mutationen nutzen `assertPermission(...)`.
3. Objekt-/Ownership-Checks in einzelnen Tools
- Beispiel: `update_task_status` und `execute_task_action` pruefen, ob das Task dem Nutzer gehoert.
4. Normale DB-/TRPC-Semantik der zugrunde liegenden Queries
- Diese ist aber im Assistant nicht automatisch identisch mit den eigentlichen Routern, weil die Assistant-Tools oft eigene DB-Queries verwenden.
## Assistant Capability Matrix
### Bereits gut abgedeckt
- Ressourcen lesen und teilweise verwalten
- Projekte lesen und teilweise verwalten
- Allokationen lesen sowie erstellen/stornieren/status aendern
- Vacation-Grundfaelle: erstellen, genehmigen, ablehnen, stornieren, Balance, Overlap, Pending Approvals
- Feiertage aufgeloest nach Region oder Ressource lesen
- Staffing/Demand-Grundfaelle
- Dashboard-Detailabfragen auf grober Ebene
- Basis-Insights
- Kommentare lesen/anlegen/resolve
- Notifications und Tasks in Grundzuegen
- Szenario-Simulation read-only
- Navigation in die UI
### Teilweise abgedeckt
- Timeline: nur indirekt ueber Navigation und Allokations-Basisabfragen
- Estimates: nur Suche, Detail und Anlegen, aber kein voller Lifecycle
- Reports: `run_report` ist flexibel, deckt aber nicht die spezialisierten Report-/Analyse-Readmodels ab
- Audit/History: nur einfache History-Abfragen, keine volle Audit-API
- Notification/Tasking: Kernfaelle vorhanden, aber keine volle Reminder-/Task-/Notification-Paritaet
- Country-/Location-Stammdaten: nur lesend und auch dort nur flach
- Insights: Summary-Ebene vorhanden, Drilldowns fehlen
### Vollstaendig fehlend oder fachlich nicht ausreichend
- Holiday-Calendar-Admin und Editor-Funktionen
- Computation Graph fuer vollstaendige Herleitungen
- Chargeability Report Readmodel
- Webhook-Administration
- System Settings / AI / SMTP / Image-Provider Administration
- System Role Config Administration
- Import/Export-Flows
- User Self-Service und Preferences
- Country- und Metro-City-Administration
- Volle Timeline-Readmodels und Timeline-Mutationen
- Voller Estimate-Lifecycle
- Dispo-/Import-spezifische Flows
## Kritische Inkonsistenzen und Risiken
### P0: Human-in-the-Loop nur im Prompt, nicht serverseitig erzwungen
Der System Prompt fordert bestaetigte Freigabe vor jeder schreibenden Aktion. Technisch wird das aber nicht serverseitig erzwungen. Wenn das Modell direkt ein Mutation-Tool aufruft, wird es ausgefuehrt.
Betroffene Stellen:
- `packages/api/src/router/assistant.ts`
- `packages/api/src/router/assistant-tools.ts`
Konsequenz:
- Die wichtigste Governance-Regel ist aktuell nur Prompt-Disziplin, keine technische Policy.
### P0: Notification-Scoping im Assistant ist fachlich/sicherheitsseitig falsch
Die dedizierte `notificationRouter` scoped strikt auf den aktuellen Nutzer. Die Assistant-Tools tun das in `list_notifications` und `mark_notification_read` nicht.
Assistant-Verhalten:
- `list_notifications` listet Notifications ohne `userId`-Filter.
- `mark_notification_read` markiert per ID ohne Ownership-Check.
Konsequenz:
- Der Assistant kann Informationen sehen oder veraendern, die der Nutzer in der normalen Notification-UI nicht sehen duerfte.
### P0: `list_users` ist als admin-only beschrieben, aber nicht effektiv admin-only
Der Tool-Text sagt "Requires admin permission", aber es gibt weder einen Eintrag in `TOOL_PERMISSION_MAP` noch einen `assertPermission(...)` im Executor.
Konsequenz:
- Jeder Nutzer mit Assistant-Zugriff kann potenziell die User-Liste lesen, obwohl die normale App dies ueber `userRouter.list` nur Admins gibt.
### P1: Permission-Beschreibungen und technische Guards sind nicht konsistent
Beispiele:
- `create_estimate`
- Beschreibung: "Requires manageEstimates permission"
- Technik: `TOOL_PERMISSION_MAP` und Executor verlangen `manageProjects`
- `create_org_unit` / `update_org_unit`
- Beschreibung: "Requires admin permission"
- Technik: `manageResources`
- `send_broadcast`
- Beschreibung: "Requires manager permission"
- Technik: `manageProjects`
Konsequenz:
- Der Assistant ist fuer Nutzer und fuer uns selbst schwer vorhersehbar.
- Ein sauberer Rechteabgleich "User kann X in UI, also Assistant auch" ist dadurch nicht belastbar.
### P1: Nicht alle Assistant-Mutationen sind als Mutation-Typ sauber nachverfolgbar
`MUTATION_TOOLS` dient dem Logging von AI-Mutationen. Nicht jede schreibende Aktion ist dort gleich gut abgebildet.
Beispiel:
- `mark_notification_read` aendert Daten, ist aber nicht in `MUTATION_TOOLS`.
Konsequenz:
- Luecken im AI-spezifischen Audit-Trail.
## Was der Assistant heute noch nicht "weiss"
Die folgende Liste meint: Informationen, die in App/API bereits existieren oder fuer Nutzer sichtbar sind, aber im Assistant heute gar nicht oder nicht in gleichwertiger Tiefe/Struktur verfuegbar sind.
### Feiertage und Kalender
- Vollstaendige Holiday-Calendar-Stammdaten:
- Kalender-Liste mit Scope, Prioritaet, Aktiv-Status, Entry-Count
- einzelne Kalender inklusive aller Entries
- Preview der aufgeloesten Feiertage fuer geplante Kalenderaenderungen
- Editierkontext des Holiday-Editors:
- was global, state-spezifisch oder city-spezifisch konfiguriert ist
- welche Kalender sich gegenseitig ueberschreiben oder ergaenzen
Aktuell im Assistant vorhanden:
- aufgeloeste Feiertage nach Region oder Ressource
Fehlend:
- die eigentlichen Kalenderobjekte und deren Pflegekontext
### Timeline und Disposition
- Vollstaendiges Timeline-Readmodel:
- `getEntriesView`
- Projekt-/Demand-/Assignment-Kontext in derselben Struktur wie die UI
- Holiday-Overlays der Timeline
- Projektkontext fuer Drag/Shift/Panel-Interaktionen
- Timeline-spezifische Vorschau-/Validierungsdaten:
- `previewShift`
- genaue Konflikte, Kosten-Delta, Auswirkungen vor Commit
- Batch- und Inline-Operationen der Timeline:
- `updateAllocationInline`
- `quickAssign`
- `batchQuickAssign`
- `batchShiftAllocations`
- `applyShift`
- Dispo-spezifische Import-/Workbook-Flows
Konsequenz:
- Der Assistant kann heute nicht denselben Timeline-Arbeitsmodus wie ein Nutzer in der UI abbilden.
### Transparenz, Herleitungen und Berechnungsgraphen
- Vollstaendige Computation-Graph-Daten fuer Resource- und Project-Views:
- Herleitungsfaktoren
- Formeln
- Holiday-/State-/City-Kontext pro Berechnung
- Node/Link-Zusammenhaenge
- Spezialisierter Chargeability Report:
- Monatsreihen
- Org-Unit-, Management-Level- und Country-Filter
- Gruppenaggregate und Luecken zum Target
Konsequenz:
- Der Assistant kann zwar Teilantworten zu Chargeability/Budget geben, aber noch nicht dieselbe Erklaerungstiefe wie die spezialisierten Analyseansichten.
### Audit, Verlauf und Governance
- Vollstaendige Audit-API:
- paginierte Listen
- Detailansicht mit voller `changes`-Struktur
- Timeline-View
- Activity Summary
Aktuell im Assistant vorhanden:
- vereinfachte History-Suche (`query_change_history`)
- Entity-History (`get_entity_timeline`)
Fehlend:
- die vollstaendige Governance-/Revisionstiefe der Audit-Oberflaeche
### Admin- und Systemkonfiguration
- System Settings:
- AI-Provider-Konfiguration
- SMTP-Konfiguration
- Image-Provider-Konfiguration
- Score Weights / Sichtbarkeiten
- Vacation Defaults
- Timeline Undo Settings
- Connection Tests
- System Role Config:
- Rollenlabels
- Beschreibungen
- Farben
- Default-Permissions
- Webhooks:
- Liste, Detail, Create, Update, Delete, Test
Konsequenz:
- Ein Admin kann in der UI deutlich mehr Systemsteuerung als der Assistant.
### User Self-Service
- `user.me`
- Dashboard-Layout lesen/speichern
- Favorite Projects lesen/toggeln
- Column Preferences lesen/speichern
- MFA / TOTP aktivieren, pruefen, Status lesen
- einige Nutzerverwaltungsaktionen aus `userRouter`
Konsequenz:
- Der Assistant kennt den Nutzerkontext nur oberflaechlich, aber nicht dessen persoenliche Einstellungen und Self-Service-Moeglichkeiten.
### Stammdaten fuer Laender und Orte
- Country-Details inklusive `scheduleRules`
- Metro-City-Verwaltung
- Country-/City-CRUD
Aktuell im Assistant vorhanden:
- `list_countries` mit relativ flachem Output
Fehlend:
- volle fachliche Pflege und die tieferen Standortregeln, die fuer Feiertage, SAH und Forecasts relevant sind
### Estimate-Lifecycle und Fachobjekte unterhalb des Estimates
- volle Estimate-Listen-/Detail-Paritaet
- Versionen, Scope Items, Demand Lines, Locking, Freigaben, weiterfuehrende Mutationen
Aktuell im Assistant vorhanden:
- Suche
- Baseline-Detail
- Anlegen
Fehlend:
- der eigentliche Arbeitsprozess auf Estimate-Ebene
### Notifications, Tasks und Reminder
Vorhanden:
- Listen, Task-Detail, Statuswechsel, Reminder anlegen, Task fuer User anlegen, Broadcast senden
Fehlend:
- Reminder-Liste
- Reminder-Update/Delete
- Unread Count
- Task Counts
- generische Notification-Erstellung mit derselben Tiefe wie `notificationRouter`
## Capability Gaps nach Router
### Komplett fehlende Router-Paritaet
- `holidayCalendar`
- `importExport`
- `chargeabilityReport`
- `computationGraph`
- `settings`
- `systemRoleConfig`
- `webhook`
- `dispo`
### Deutlich unvollstaendige Router-Paritaet
- `timeline`
- `vacation`
- `estimate`
- `notification`
- `user`
- `country`
- `auditLog`
- `insights`
- `scenario`
- `resource`
- `project`
- `comment`
### Nahe an brauchbarer Grundabdeckung, aber nicht vollstaendig
- `resource`
- `project`
- `staffing`
- `report`
- `dashboard`
## System Prompt: offensichtliche Uebertreibungen / Irrefuehrungen
Der Prompt suggeriert an mehreren Stellen mehr Paritaet, als technisch heute vorhanden ist.
### Problematische Aussagen
- "Urlaub, Feiertage" ist fuer Leseabfragen ok, aber nicht fuer Holiday-Calendar-Administration.
- "Notifications anzeigen" stimmt nur eingeschraenkt, weil das Assistant-Tooling aktuell nicht sauber auf den aktuellen Nutzer scoped.
- "Dashboard-Details abrufen" stimmt nur fuer einen Teil der Dashboard-/Analysewelt.
- "Den User zu relevanten Seiten navigieren" stimmt, ersetzt aber keine echte Daten-/Aktionsparitaet in Timeline, Holiday Editor oder Admin-Bereichen.
- "Ressourcenplanung und Projektmanagement" klingt umfassender, als die reale Tool-Abdeckung in spezialisierten Subsystemen ist.
### Wichtigste Prompt-Luecke
Die Human-in-the-Loop-Regel wird als harte Pflicht formuliert, ist technisch aber nicht hart erzwungen.
## Was getan werden muss, damit der Assistant wirklich dieselben Nutzerfaehigkeiten hat
### P0: Sicherheits- und Governance-Hardening
1. Serverseitige Confirm-Policy fuer alle schreibenden Assistant-Tools einziehen.
- Schreibende Tool-Calls duerfen ohne bestaetigten Confirmation-Token nicht ausgefuehrt werden.
- Diese Policy darf nicht nur im Prompt stehen.
2. Assistant-Tools auf denselben Objekt-Scope wie die eigentlichen Router bringen.
- Besonders:
- Notifications
- Tasks
- User-Liste
- alle personenbezogenen oder sensitiven Admin-Daten
3. Permission-Quellen vereinheitlichen.
- Ein zentraler Capability-/Policy-Registry sollte festlegen:
- welches Tool welche Permission braucht,
- ob Objekt-Ownership gilt,
- ob `viewCosts` zusaetzlich erforderlich ist,
- ob Human Confirmation erforderlich ist.
### P1: Fachliche Paritaet fuer die wichtigsten Nutzer-Workflows
1. Holiday-Calendar-Assistant-Strang bauen
- `list_holiday_calendars`
- `get_holiday_calendar`
- `create_holiday_calendar`
- `update_holiday_calendar`
- `delete_holiday_calendar`
- `create_holiday_entry`
- `update_holiday_entry`
- `delete_holiday_entry`
- `preview_resolved_holidays`
2. Timeline-Assistant-Strang bauen
- Read:
- `get_timeline_entries_view`
- `get_timeline_holiday_overlays`
- `get_timeline_project_context`
- `preview_project_shift`
- Write:
- `update_allocation_inline`
- `apply_project_shift`
- `quick_assign`
- `batch_quick_assign`
- `batch_shift_allocations`
3. Analyse-/Transparenz-Paritaet bauen
- `get_chargeability_report`
- `get_resource_computation_graph`
- `get_project_computation_graph`
### P2: Admin- und Stammdaten-Paritaet
1. Settings-Admin-Tools
- lesen
- aktualisieren
- Connection Tests
2. SystemRoleConfig-Tools
- listen
- update
3. Country-/City-Tools
- Country-Detail
- Country-Create/Update
- City-Create/Update/Delete
4. Webhook-Tools
- list/get/create/update/delete/test
### P3: Self-Service- und Workflow-Paritaet
1. User-Tools
- `get_me`
- Dashboard-Layout lesen/schreiben
- Favoriten lesen/toggeln
- Column Preferences lesen/schreiben
- MFA-Status / TOTP-Flows
2. Notification-/Reminder-Paritaet
- Reminder listen/update/delete
- unreadCount
- taskCounts
3. Estimate-Lifecycle vertiefen
- Versionen
- Scope Items
- Demand Lines
- Status-/Locking-/Approval-Flows
## Empfohlene Umsetzungsreihenfolge
### Stream A: Safety / Policy
- serverseitige Confirmation-Gates
- Ownership-/Permission-Fixes
- Mutation-Audit vervollstaendigen
### Stream B: Holiday + Timeline Parity
- Holiday-Calendar-Editor-Tools
- Timeline-Readmodels
- Timeline-Shift-/Assign-Aktionen
### Stream C: Explainability / Analytics
- Chargeability Report
- Computation Graph
- Audit Summary
### Stream D: Admin / Ops
- Settings
- System Role Config
- Webhooks
- Import/Export
## Kurzfazit
Der Assistant ist bereits breit genug, um viele operative Fragen und Standardaktionen abzudecken. Er ist aber noch nicht in dem Zustand, dass man sagen kann: "Alles, was der Nutzer kann, kann auch der Assistant."
Die drei groessten Blocker dafuer sind:
1. fehlende serverseitige Absicherung fuer schreibende AI-Aktionen,
2. unvollstaendige fachliche Paritaet in Holiday/Timeline/Analytics/Admin-Bereichen,
3. inkonsistente oder zu schwache Permission- und Ownership-Pruefungen in einzelnen Tools.
@@ -0,0 +1,393 @@
# Holiday Calendar Implementation Plan
## Ziel
Planarchy soll standortabhaengige Feiertage fachlich korrekt berechnen koennen, sodass zwei Personen im selben Land, aber in unterschiedlichen Regionen oder Staedten, unterschiedliche `SAH` und damit unterschiedliche Chargeability erhalten koennen.
Die Feiertagsaufloesung soll kuenftig diese Prioritaet haben:
1. `metroCity`
2. `federalState` / Region
3. `country`
Manuelle, ressourcenspezifische `PUBLIC_HOLIDAY`-Eintraege bleiben weiterhin moeglich und ueberschreiben bzw. ergaenzen den Kalender.
## Ist-Zustand
Aktuell existieren drei getrennte Mechanismen:
1. Statisch codierte Feiertage in `packages/shared/src/constants/publicHolidays.ts`
2. Batch-/Auto-Import von `PUBLIC_HOLIDAY`-Vacations
3. Laufzeitberechnung von `SAH` bzw. Chargeability aus Land/Bundesland
Die zentralen Luecken:
- Es gibt kein Holiday-Stammdatenmodell in der Datenbank.
- Es gibt keinen Editor fuer Feiertagskalender.
- `metroCity` wird fuer Feiertage nicht ausgewertet.
- Die aktuelle Logik ist faktisch auf Deutschland plus `federalState` zugeschnitten.
- Feiertagswissen ist doppelt vorhanden: statische Kalenderlogik plus importierte `Vacation`-Datensaetze.
## Zielarchitektur
### 1. Holiday Calendar als Stammdatenmodell
Neue Stammdatenobjekte:
- `HolidayCalendar`
- `HolidayCalendarEntry`
`HolidayCalendar` beschreibt den Gueltigkeitsbereich eines Kalenders:
- `scopeType`: `COUNTRY | STATE | CITY`
- `countryId`
- optional `stateCode`
- optional `metroCityId`
- `name`
- `isActive`
- optional `priority`
`HolidayCalendarEntry` beschreibt den einzelnen Feiertag:
- `holidayCalendarId`
- `date`
- `name`
- optional `isRecurringAnnual`
- optional `source`
Fachregel:
- Pro Scope soll es genau einen aktiven Kalender geben.
- Die effektiven Feiertage eines Mitarbeiters ergeben sich aus Merge mit Prioritaet `country < state < city`.
- Gleiche Daten auf engerem Scope ueberschreiben denselben Tag vom breiteren Scope.
### 2. Laufzeit-Resolver statt statischer Sonderlogik
Neue gemeinsame Backend-Komponente:
- `resolveResourceHolidayCalendar(...)`
Aufgaben:
- liest Kalenderdaten fuer `countryId`, `federalState`, `metroCityId`
- ermittelt die effektiven Feiertage fuer einen Zeitraum
- merged diese mit expliziten `Vacation`-Eintraegen vom Typ `PUBLIC_HOLIDAY`
- liefert:
- `publicHolidayStrings`
- `absenceDays`
- optional Debug-Metadaten zur Herkunft eines Feiertags
Diese Komponente wird die einzige Quelle fuer Feiertagslogik in:
- Chargeability Report
- Chargeability Alerts
- Computation Graph
- ggf. weitere SAH-/Allocation-Pfade
### 3. Import und Editor werden auf Stammdaten umgestellt
Der heutige Batch-/Auto-Import darf nicht die Primarlogik fuer Feiertage bleiben.
Zielbild:
- Stammdatenkalender sind die Quelle der Wahrheit.
- Optionaler Import in `Vacation` bleibt nur als Kompatibilitaets- oder Exportfunktion.
- Bestehende `PUBLIC_HOLIDAY`-Vacations werden fuer Uebergangszeit weiter beruecksichtigt.
## Datenmodell
### Prisma-Erweiterungen
Neue Modelle:
```prisma
model HolidayCalendar {
id String @id @default(cuid())
name String
scopeType HolidayCalendarScope
countryId String
stateCode String?
metroCityId String?
isActive Boolean @default(true)
priority Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
country Country @relation(fields: [countryId], references: [id])
metroCity MetroCity? @relation(fields: [metroCityId], references: [id])
entries HolidayCalendarEntry[]
@@index([countryId, scopeType])
@@index([metroCityId])
}
model HolidayCalendarEntry {
id String @id @default(cuid())
holidayCalendarId String
date DateTime @db.Date
name String
isRecurringAnnual Boolean @default(false)
source String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
holidayCalendar HolidayCalendar @relation(fields: [holidayCalendarId], references: [id], onDelete: Cascade)
@@unique([holidayCalendarId, date, name])
@@index([date])
}
```
Neues Enum:
```prisma
enum HolidayCalendarScope {
COUNTRY
STATE
CITY
}
```
### Integritaetsregeln
- `STATE` verlangt `stateCode`.
- `CITY` verlangt `metroCityId`.
- `CITY` und `STATE` muessen zum selben `countryId` passen.
- Ein `CITY`-Kalender darf nur fuer eine `MetroCity` des angegebenen Landes existieren.
Diese Regeln werden teils im Schema, teils in Router-Validierung erzwungen.
## API- und Backend-Pakete
### Paket A: Schema und Datenzugriff
Dateien:
- `packages/db/prisma/schema.prisma`
- neue Migration
- ggf. `packages/shared/src/types/*`
- ggf. `packages/shared/src/schemas/*`
Ergebnis:
- Holiday-Calendar-Datenmodell ist vorhanden
- Zod-/Shared-Typen fuer CRUD sind definiert
### Paket B: Holiday Calendar Router
Neue oder erweiterte API:
- `packages/api/src/router/holiday-calendar.ts`
- Router in `packages/api/src/index.ts`
Operationen:
- `listCalendars`
- `getCalendarById`
- `createCalendar`
- `updateCalendar`
- `deleteCalendar`
- `createEntry`
- `updateEntry`
- `deleteEntry`
- optional `previewResolvedHolidays`
### Paket C: Gemeinsamer Resolver
Dateien:
- `packages/api/src/lib/holiday-resolver.ts`
- bestehende Hilfen in `packages/api/src/lib/holiday-availability.ts` refactoren oder ersetzen
Ergebnis:
- einheitliche Feiertagsaufloesung fuer alle Backend-Pfade
- keine neue statische Sonderlogik in Routern
### Paket D: Integration in Berechnungen
Betroffene Stellen:
- `packages/api/src/router/chargeability-report.ts`
- `packages/api/src/lib/chargeability-alerts.ts`
- `packages/api/src/router/computation-graph.ts`
- weitere `calculateSAH`-Aufrufer mit Feiertagsbezug
Abnahme:
- dieselbe Ressource liefert je nach `metroCity` / `federalState` unterschiedliche `SAH`
- gleiche Eingaben erzeugen in allen Reports denselben Feiertagseffekt
### Paket E: UI / Admin
Betroffene Stellen:
- neue Admin-Seite oder Erweiterung im Country-Admin
- Wiederverwendung moeglicher Muster aus:
- `apps/web/src/components/admin/CountriesClient.tsx`
- `apps/web/src/components/vacations/PublicHolidayBatch.tsx`
- vorhandene Modal-/Table-Komponenten
Ziel:
- Kalender pro Land / Bundesland / Stadt anlegen und bearbeiten
- Eintraege pro Jahr pflegen
- Aufloesung fuer eine Beispiel-Ressource optional vorschauen
### Paket F: Kompatibilitaet / Migration
Uebergangsstrategie:
1. Bestehende `PUBLIC_HOLIDAY`-Vacations bleiben gueltig.
2. Neuer Resolver nutzt zuerst Stammdatenkalender plus manuelle Overrides.
3. Batch-/Auto-Import wird als Legacy-Funktion markiert.
4. Spaeter kann entschieden werden, ob Import nur noch Materialisierung fuer Sonderfaelle ist.
## Fachliche Aufloesungsregeln
### Prioritaet
1. Manuelle ressourcenspezifische `PUBLIC_HOLIDAY`-Vacation
2. `CITY`-Kalender
3. `STATE`-Kalender
4. `COUNTRY`-Kalender
### Merge-Regeln
- Gleiches Datum mehrfach:
- engster Scope gewinnt fuer Anzeige/Quelle
- fuer `SAH` zaehlt der Tag genau einmal
- Feiertag auf Wochenende:
- erscheint im Kalender
- reduziert `SAH` nur, wenn der Tag laut Verfuegbarkeit ein Arbeitstag ist
- Halbtag-Feiertage:
- aktuell nicht erforderlich
- nur aufnehmen, wenn fachlich explizit benoetigt
## Umsetzung in parallelen Workern
### Worker 1: Schema + Shared Contracts
Verantwortung:
- Prisma-Modelle
- Migration
- Shared Types / Zod Schemas
Write Scope:
- `packages/db/prisma/schema.prisma`
- `packages/shared/src/types/*`
- `packages/shared/src/schemas/*`
### Worker 2: Backend Router + Validation
Verantwortung:
- CRUD-API fuer Holiday Calendars
- Validierung von Scope-Regeln
- Audit-Logging
Write Scope:
- `packages/api/src/router/holiday-calendar.ts`
- `packages/api/src/index.ts`
- eng verbundene Tests
### Worker 3: Resolver + Berechnungsintegration
Verantwortung:
- gemeinsamer Holiday Resolver
- Integration in Report, Alerts, Computation Graph
- Entfernung duplizierter Feiertagslogik
Write Scope:
- `packages/api/src/lib/holiday-resolver.ts`
- `packages/api/src/lib/holiday-availability.ts`
- `packages/api/src/router/chargeability-report.ts`
- `packages/api/src/lib/chargeability-alerts.ts`
- `packages/api/src/router/computation-graph.ts`
- eng verbundene Tests
### Worker 4: Admin UI
Verantwortung:
- neue Holiday-Calendar-Admin-Oberflaeche
- Calendar-Entry-Editing
- optional Preview fuer aufgeloeste Feiertage
Write Scope:
- `apps/web/src/components/admin/*`
- relevante App-Routen
- eng verbundene UI-Tests falls vorhanden
### Worker 5: Migration / Legacy Behavior / Verify
Verantwortung:
- Legacy Import klar einhaengen oder abgrenzen
- Verify-/Smoke-Pfade
- End-to-End-Pruefung der fachlichen Szenarien
Write Scope:
- `packages/api/src/lib/holiday-auto-import.ts`
- `packages/api/src/router/vacation.ts`
- Verify-Skripte und Tests
## Teststrategie
### Unit
- Resolver merged `country + state + city` korrekt
- `CITY` ueberschreibt `STATE`, `STATE` ergaenzt `COUNTRY`
- manuelle `PUBLIC_HOLIDAY`-Vacation wird beruecksichtigt
- identisches Datum wird nur einmal auf `SAH` angerechnet
### Integration
- Chargeability Report: zwei Ressourcen, gleiches Land, unterschiedliche Stadt, unterschiedliche `SAH`
- Chargeability Alerts: derselbe Feiertagseffekt wie im Report
- Computation Graph: dieselbe Feiertagsanzahl wie Resolver
### UI
- Kalender anlegen fuer `COUNTRY`, `STATE`, `CITY`
- Eintrag anlegen/aendern/loeschen
- Scope-Validierung verhindert ungueltige Kombinationen
### Datenmigration / Regression
- bestehende `PUBLIC_HOLIDAY`-Vacations bleiben wirksam
- alte Batch-Funktion erzeugt keine Konflikte
- Repo-weit:
- `pnpm test`
- `pnpm typecheck`
- relevanter E2E-Smoke fuer Admin-Pfad, falls vorhanden
## Abnahme-Kriterien
- Feiertage sind nicht mehr hart an Deutschland/Bundesland im Laufzeitpfad gekoppelt.
- `metroCity` kann `SAH` fachlich beeinflussen.
- Es gibt eine Admin-faehige Pflege fuer Feiertagskalender.
- Report, Alerts und Computation Graph verwenden denselben Resolver.
- Bestehende manuelle Feiertagsabwesenheiten bleiben kompatibel.
## Empfohlene Reihenfolge
1. Schema + Shared Contracts
2. Backend Router
3. Resolver + Integration
4. UI
5. Migration/Legacy und Gesamttests
## Offene Produktentscheidungen
- Sollen Feiertage kuenftig nur manuell gepflegt werden oder auch per externem Provider importierbar sein?
- Brauchen wir Halbtag-Feiertage?
- Reicht `metroCity` als lokaler Scope oder brauchen wir spaeter feinere Geo-Einheiten?
- Soll Legacy-Batch-Import langfristig entfernt oder als Materialisierung behalten werden?
+6 -5
View File
@@ -6,13 +6,14 @@
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test",
"test": "turbo run test:unit",
"test:unit": "turbo test:unit",
"test:e2e": "turbo test:e2e",
"db:push": "pnpm --filter @capakraken/db db:push",
"db:migrate": "pnpm --filter @capakraken/db db:migrate",
"db:seed": "pnpm --filter @capakraken/db db:seed",
"db:studio": "pnpm --filter @capakraken/db db:studio",
"db:doctor": "node ./scripts/db-doctor.mjs capakraken",
"db:push": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:push",
"db:migrate": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:migrate",
"db:seed": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:seed",
"db:studio": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:studio",
"db:reset:dispo": "pnpm --filter @capakraken/db db:reset:dispo",
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
+1
View File
@@ -8,6 +8,7 @@
"./router": "./src/router/index.ts",
"./trpc": "./src/trpc.ts",
"./sse": "./src/sse/event-bus.ts",
"./lib/audit": "./src/lib/audit.ts",
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
"./lib/logger": "./src/lib/logger.ts",
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
@@ -1,13 +1,14 @@
import { AllocationStatus, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { allocationRouter } from "../router/allocation.js";
import { emitAllocationCreated, emitAllocationDeleted } from "../sse/event-bus.js";
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitAllocationCreated: vi.fn(),
emitAllocationDeleted: vi.fn(),
emitAllocationUpdated: vi.fn(),
emitNotificationCreated: vi.fn(),
}));
vi.mock("../lib/budget-alerts.js", () => ({
@@ -18,6 +19,10 @@ vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn(),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
const createCaller = createCallerFactory(allocationRouter);
function createManagerCaller(db: Record<string, unknown>) {
@@ -35,7 +40,100 @@ function createManagerCaller(db: Record<string, unknown>) {
});
}
function createDemandWorkflowDb(overrides: Record<string, unknown> = {}) {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1", name: "Project One" }),
},
role: {
findUnique: vi.fn().mockResolvedValue({ name: "FX Artist" }),
},
user: {
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
},
notification: {
create: vi.fn().mockImplementation(async ({ data }: { data: { userId: string } }) => ({
id: `notif_${data.userId}`,
})),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
};
return {
...db,
...overrides,
project: { ...db.project, ...(overrides.project as Record<string, unknown> | undefined) },
role: { ...db.role, ...(overrides.role as Record<string, unknown> | undefined) },
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
notification: {
...db.notification,
...(overrides.notification as Record<string, unknown> | undefined),
},
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
};
}
describe("allocation entry resolution router", () => {
it("excludes regional holidays from resource availability coverage", async () => {
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue({
id: "resource_1",
displayName: "Bruce Banner",
eid: "E-001",
fte: 1,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { dailyWorkingHours: 8, code: "DE" },
metroCity: null,
}),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
status: AllocationStatus.CONFIRMED,
project: { name: "Gamma", shortCode: "GAM" },
},
]),
},
};
const caller = createManagerCaller(db);
const result = await caller.checkResourceAvailability({
resourceId: "resource_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
});
expect(result).toMatchObject({
dailyCapacity: 8,
totalWorkingDays: 1,
availableDays: 0,
partialDays: 0,
conflictDays: 1,
totalAvailableHours: 0,
totalRequestedHours: 8,
coveragePercent: 0,
});
});
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
const createdDemandRequirement = {
id: "demand_1",
@@ -187,6 +285,7 @@ describe("allocation entry resolution router", () => {
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear();
vi.mocked(emitNotificationCreated).mockClear();
const createdDemandRequirement = {
id: "demand_explicit_1",
@@ -206,18 +305,14 @@ describe("allocation entry resolution router", () => {
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
const db = createDemandWorkflowDb({
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
}) as Record<string, unknown>;
Object.assign(db, {
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
};
});
const caller = createManagerCaller(db);
const result = await caller.createDemandRequirement({
@@ -247,6 +342,8 @@ describe("allocation entry resolution router", () => {
projectId: "project_1",
resourceId: null,
});
expect(db.notification.create).toHaveBeenCalledTimes(2);
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
});
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
@@ -730,4 +827,3 @@ describe("allocation entry resolution router", () => {
});
});
});
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import { buildAssistantInsight } from "../router/assistant-insights.js";
describe("assistant insights", () => {
it("builds a transparent chargeability insight from holiday-aware payloads", () => {
const insight = buildAssistantInsight("get_chargeability", {
resource: "Bruce Banner",
month: "2026-01",
chargeability: "42.9%",
chargeabilityPct: 42.9,
targetPct: 80,
availableHours: 168,
bookedHours: 72,
unassignedHours: 96,
targetHours: 134.4,
baseWorkingDays: 23,
workingDays: 21,
baseAvailableHours: 184,
locationContext: { country: "Deutschland", federalState: "BY", metroCity: "Augsburg" },
holidaySummary: { count: 2, workdayCount: 2, hoursDeduction: 16 },
absenceSummary: { dayEquivalent: 0.5, hoursDeduction: 4 },
});
expect(insight).toEqual(
expect.objectContaining({
kind: "chargeability",
title: "Bruce Banner · 2026-01",
metrics: expect.arrayContaining([
expect.objectContaining({ label: "Chargeability", value: "42.9%", tone: "warn" }),
expect.objectContaining({ label: "Available", value: "168 h" }),
expect.objectContaining({ label: "Target", value: "134.4 h" }),
]),
sections: expect.arrayContaining([
expect.objectContaining({
title: "Basis",
metrics: expect.arrayContaining([
expect.objectContaining({ label: "Location", value: "Augsburg, BY, Deutschland" }),
]),
}),
expect.objectContaining({
title: "Deductions",
metrics: expect.arrayContaining([
expect.objectContaining({ label: "Holiday deduction", value: "16 h" }),
expect.objectContaining({ label: "Absence deduction", value: "4 h" }),
]),
}),
]),
}),
);
});
it("builds a holiday comparison insight with regional scope counts", () => {
const insight = buildAssistantInsight("list_holidays_by_region", {
locationContext: { countryCode: "DE", federalState: "BY" },
count: 14,
periodStart: "2026-01-01",
periodEnd: "2026-12-31",
summary: {
byScope: [
{ scope: "NATIONAL", count: 9 },
{ scope: "STATE", count: 5 },
],
},
});
expect(insight).toEqual(
expect.objectContaining({
kind: "holiday_region",
title: "BY, DE",
metrics: expect.arrayContaining([
expect.objectContaining({ label: "Resolved holidays", value: "14" }),
]),
sections: [
expect.objectContaining({
title: "Scopes",
metrics: expect.arrayContaining([
expect.objectContaining({ label: "STATE", value: "5" }),
]),
}),
],
}),
);
});
it("builds a best-resource insight from staffing recommendations", () => {
const insight = buildAssistantInsight("find_best_project_resource", {
project: { name: "Gelddruckmaschine", shortCode: "GDM" },
period: { startDate: "2026-04-01", endDate: "2026-04-21", minHoursPerDay: 3, rankingMode: "lowest_lcr" },
candidateCount: 4,
bestMatch: {
name: "Jane Doe",
role: "TD",
chapter: "Lighting",
country: "Deutschland",
federalState: "BY",
metroCity: "Muenchen",
lcr: "€85.00",
remainingHours: 74,
remainingHoursPerDay: 3.5,
availableHours: 120,
baseAvailableHours: 136,
holidaySummary: { hoursDeduction: 8 },
absenceSummary: { hoursDeduction: 0 },
},
});
expect(insight).toEqual(
expect.objectContaining({
kind: "resource_match",
title: "GDM staffing",
metrics: expect.arrayContaining([
expect.objectContaining({ label: "Best match", value: "Jane Doe" }),
expect.objectContaining({ label: "Remaining", value: "74 h", tone: "good" }),
]),
sections: expect.arrayContaining([
expect.objectContaining({
title: "Selection",
metrics: expect.arrayContaining([
expect.objectContaining({ label: "Location", value: "Muenchen, BY, Deutschland" }),
]),
}),
]),
}),
);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
import { getAvailableAssistantTools } from "../router/assistant.js";
function getToolNames(permissions: PermissionKeyValue[]) {
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
}
describe("assistant router tool gating", () => {
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
const withAdvanced = getToolNames([
PermissionKey.VIEW_COSTS,
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
]);
expect(withoutAdvanced).not.toContain("find_best_project_resource");
expect(withAdvanced).toContain("find_best_project_resource");
});
it("keeps user administration tools behind manageUsers", () => {
const withoutManageUsers = getToolNames([]);
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
expect(withoutManageUsers).not.toContain("list_users");
expect(withManageUsers).toContain("list_users");
});
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
expect(names).not.toContain("find_best_project_resource");
});
});
@@ -0,0 +1,262 @@
import { describe, expect, it, vi } from "vitest";
import { PermissionKey } from "@capakraken/shared";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
};
});
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
function createToolContext(
db: Record<string, unknown>,
permissions: PermissionKey[] = [],
): ToolContext {
return {
db: db as ToolContext["db"],
userId: "user_1",
userRole: "ADMIN",
permissions: new Set(permissions),
};
}
describe("assistant advanced tools and scoping", () => {
it("finds the best project resource with holiday-aware remaining capacity and LCR ranking", async () => {
const assignmentFindMany = vi
.fn()
.mockResolvedValueOnce([
{
resourceId: "res_carol",
hoursPerDay: 2,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "PROPOSED",
resource: {
id: "res_carol",
eid: "carol.danvers",
displayName: "Carol Danvers",
chapter: "Delivery",
lcrCents: 7664,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "HH",
metroCityId: "city_hamburg",
country: { code: "DE", name: "Deutschland" },
metroCity: { name: "Hamburg" },
areaRole: { name: "Artist" },
},
},
{
resourceId: "res_steve",
hoursPerDay: 4,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "CONFIRMED",
resource: {
id: "res_steve",
eid: "steve.rogers",
displayName: "Steve Rogers",
chapter: "Delivery",
lcrCents: 13377,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_augsburg",
country: { code: "DE", name: "Deutschland" },
metroCity: { name: "Augsburg" },
areaRole: { name: "Artist" },
},
},
])
.mockResolvedValueOnce([
{
resourceId: "res_carol",
projectId: "project_lari",
hoursPerDay: 2,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "PROPOSED",
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
},
{
resourceId: "res_steve",
projectId: "project_lari",
hoursPerDay: 4,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-16T00:00:00.000Z"),
status: "CONFIRMED",
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
},
]);
const ctx = createToolContext(
{
project: {
findUnique: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "project_lari",
name: "Gelddruckmaschine",
shortCode: "LARI",
status: "ACTIVE",
responsiblePerson: "Larissa Joos",
}),
findFirst: vi.fn(),
},
assignment: {
findMany: assignmentFindMany,
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
},
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
);
const result = await executeTool(
"find_best_project_resource",
JSON.stringify({
projectIdentifier: "LARI",
startDate: "2026-01-05",
endDate: "2026-01-16",
minHoursPerDay: 3,
rankingMode: "lowest_lcr",
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
project: { shortCode: string };
candidateCount: number;
bestMatch: {
name: string;
remainingHoursPerDay: number;
lcrCents: number | null;
federalState: string | null;
metroCity: string | null;
baseAvailableHours: number;
holidaySummary: { count: number };
};
candidates: Array<{
name: string;
remainingHoursPerDay: number;
workingDays: number;
baseAvailableHours: number;
holidaySummary: { count: number; hoursDeduction: number };
capacityBreakdown: { holidayHoursDeduction: number };
}>;
};
expect(parsed.project.shortCode).toBe("LARI");
expect(parsed.candidateCount).toBe(2);
expect(parsed.bestMatch).toEqual(
expect.objectContaining({
name: "Carol Danvers",
remainingHoursPerDay: 6,
lcrCents: 7664,
federalState: "HH",
metroCity: "Hamburg",
baseAvailableHours: 80,
holidaySummary: expect.objectContaining({ count: 0 }),
}),
);
expect(parsed.candidates).toEqual([
expect.objectContaining({
name: "Carol Danvers",
remainingHoursPerDay: 6,
workingDays: 10,
baseAvailableHours: 80,
holidaySummary: expect.objectContaining({ count: 0, hoursDeduction: 0 }),
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 0 }),
}),
expect.objectContaining({
name: "Steve Rogers",
remainingHoursPerDay: 4,
workingDays: 9,
baseAvailableHours: 80,
holidaySummary: expect.objectContaining({ count: 1, hoursDeduction: 8 }),
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 8 }),
}),
]);
});
it("requires the dedicated advanced assistant permission for the high-level resource tool", async () => {
const ctx = createToolContext({}, [PermissionKey.VIEW_COSTS]);
const result = await executeTool(
"find_best_project_resource",
JSON.stringify({ projectIdentifier: "LARI" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
error: expect.stringContaining(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS),
}),
);
});
it("scopes assistant notification listing to the current user", async () => {
const findMany = vi.fn().mockResolvedValue([]);
const ctx = createToolContext({
notification: {
findMany,
},
});
await executeTool("list_notifications", JSON.stringify({ unreadOnly: true }), ctx);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
userId: "user_1",
readAt: null,
}),
}),
);
});
it("rejects marking notifications that do not belong to the current user", async () => {
const update = vi.fn();
const ctx = createToolContext({
notification: {
findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
update,
},
});
const result = await executeTool(
"mark_notification_read",
JSON.stringify({ notificationId: "notif_1" }),
ctx,
);
expect(JSON.parse(result.content)).toEqual({
error: "Access denied: this notification does not belong to you",
});
expect(update).not.toHaveBeenCalled();
});
it("requires manageUsers before listing users through the assistant", async () => {
const findMany = vi.fn();
const ctx = createToolContext({
user: {
findMany,
},
});
const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
expect(JSON.parse(result.content)).toEqual(
expect.objectContaining({
error: expect.stringContaining(PermissionKey.MANAGE_USERS),
}),
);
expect(findMany).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,575 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
};
});
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
function createToolContext(
db: Record<string, unknown>,
permissions: string[] = [],
): ToolContext {
return {
db: db as ToolContext["db"],
userId: "user_1",
userRole: "ADMIN",
permissions: new Set(permissions) as ToolContext["permissions"],
};
}
describe("assistant holiday tools", () => {
it("lists regional holidays and distinguishes Bavaria from Hamburg", async () => {
const ctx = createToolContext({});
const bavaria = await executeTool(
"list_holidays_by_region",
JSON.stringify({ countryCode: "DE", federalState: "BY", year: 2026 }),
ctx,
);
const hamburg = await executeTool(
"list_holidays_by_region",
JSON.stringify({ countryCode: "DE", federalState: "HH", year: 2026 }),
ctx,
);
const bavariaResult = JSON.parse(bavaria.content) as {
count: number;
locationContext: { federalState: string | null };
summary: { byScope: Array<{ scope: string; count: number }> };
holidays: Array<{ name: string; date: string }>;
};
const hamburgResult = JSON.parse(hamburg.content) as {
count: number;
locationContext: { federalState: string | null };
holidays: Array<{ name: string; date: string }>;
};
expect(bavariaResult.count).toBeGreaterThan(hamburgResult.count);
expect(bavariaResult.locationContext.federalState).toBe("BY");
expect(bavariaResult.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
);
expect(bavariaResult.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
]),
);
expect(hamburgResult.holidays).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
]),
);
});
it("resolves resource-specific holidays including city-local dates", async () => {
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
findFirst: vi.fn(),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"get_resource_holidays",
JSON.stringify({ identifier: "bruce.banner", year: 2026 }),
ctx,
);
const parsed = JSON.parse(result.content) as {
resource: { eid: string; federalState: string | null; metroCity: string | null };
summary: { byScope: Array<{ scope: string; count: number }> };
holidays: Array<{ name: string; date: string }>;
};
expect(parsed.resource).toEqual(
expect.objectContaining({
eid: "bruce.banner",
federalState: "BY",
metroCity: "Augsburg",
}),
);
expect(parsed.holidays).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "Augsburger Friedensfest", date: "2026-08-08" }),
]),
);
expect(parsed.summary.byScope).toEqual(
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
);
});
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "res_1",
displayName: "Bruce Banner",
eid: "bruce.banner",
fte: 1,
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
}),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
status: "CONFIRMED",
project: { name: "Gamma", shortCode: "GAM" },
},
]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"get_chargeability",
JSON.stringify({ resourceId: "res_1", month: "2026-01" }),
ctx,
);
const parsed = JSON.parse(result.content) as {
baseWorkingDays: number;
baseAvailableHours: number;
availableHours: number;
bookedHours: number;
workingDays: number;
targetHours: number;
unassignedHours: number;
holidaySummary: { count: number; workdayCount: number; hoursDeduction: number };
capacityBreakdown: { formula: string; holidayHoursDeduction: number; absenceHoursDeduction: number };
locationContext: { federalState: string | null };
allocations: Array<{ hours: number }>;
};
expect(parsed.bookedHours).toBe(8);
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
expect(parsed.baseWorkingDays).toBe(23);
expect(parsed.baseAvailableHours).toBe(184);
expect(parsed.availableHours).toBe(168);
expect(parsed.workingDays).toBe(21);
expect(parsed.targetHours).toBe(134.4);
expect(parsed.unassignedHours).toBe(160);
expect(parsed.locationContext.federalState).toBe("BY");
expect(parsed.holidaySummary).toEqual(
expect.objectContaining({
count: 2,
workdayCount: 2,
hoursDeduction: 16,
}),
);
expect(parsed.capacityBreakdown).toEqual(
expect.objectContaining({
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
holidayHoursDeduction: 16,
absenceHoursDeduction: 0,
}),
);
});
it("returns holiday-aware budget forecast data from the dashboard use-case", async () => {
const { getDashboardBudgetForecast } = await import("@capakraken/application");
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
{
projectId: "project_1",
projectName: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
spentCents: 60_000,
burnRate: 5_000,
pctUsed: 60,
estimatedExhaustionDate: "2026-02-20",
},
]);
const ctx = createToolContext({}, ["viewCosts"]);
const result = await executeTool("get_budget_forecast", "{}", ctx);
const parsed = JSON.parse(result.content) as {
forecasts: Array<{
projectName: string;
shortCode: string;
budgetCents: number;
spentCents: number;
remainingCents: number;
projectedCents: number;
burnRateCents: number;
burnStatus: string;
}>;
};
expect(getDashboardBudgetForecast).toHaveBeenCalled();
expect(parsed.forecasts).toEqual([
expect.objectContaining({
projectName: "Gelddruckmaschine",
shortCode: "GDM",
budgetCents: 100_000,
spentCents: 60_000,
remainingCents: 40_000,
projectedCents: 100_000,
burnRateCents: 5_000,
burnStatus: "on_track",
}),
]);
});
it("checks resource availability with regional holidays excluded from capacity", async () => {
const db = {
resource: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "res_1",
displayName: "Bruce Banner",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
}),
findFirst: vi.fn(),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
status: "CONFIRMED",
project: { name: "Gamma", shortCode: "GAM" },
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"check_resource_availability",
JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }),
ctx,
);
const parsed = JSON.parse(result.content) as {
workingDays: number;
periodAvailableHours: number;
periodBookedHours: number;
periodRemainingHours: number;
availableHoursPerDay: number;
isFullyAvailable: boolean;
};
expect(parsed.workingDays).toBe(1);
expect(parsed.periodAvailableHours).toBe(8);
expect(parsed.periodBookedHours).toBe(8);
expect(parsed.periodRemainingHours).toBe(0);
expect(parsed.availableHoursPerDay).toBe(0);
expect(parsed.isFullyAvailable).toBe(false);
});
it("keeps scenario simulation flat when a proposed change falls on a local holiday", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Holiday Project",
budgetCents: 500_000,
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-01-31T00:00:00.000Z"),
}),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
resourceId: "res_1",
hoursPerDay: 8,
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-05T00:00:00.000Z"),
status: "CONFIRMED",
resource: {
id: "res_1",
displayName: "Bruce Banner",
lcrCents: 100,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
chargeabilityTarget: 80,
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
displayName: "Bruce Banner",
lcrCents: 100,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
chargeabilityTarget: 80,
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE", dailyWorkingHours: 8 },
metroCity: null,
},
]),
},
};
const ctx = createToolContext(db, ["manageAllocations"]);
const result = await executeTool(
"simulate_scenario",
JSON.stringify({
projectId: "project_1",
changes: [
{
resourceId: "res_1",
startDate: "2026-01-06",
endDate: "2026-01-06",
hoursPerDay: 8,
},
],
}),
ctx,
);
const parsed = JSON.parse(result.content) as {
baseline: { totalHours: number; totalCostCents: number };
scenario: { totalHours: number; totalCostCents: number };
delta: { hours: number; costCents: number };
};
expect(parsed.baseline).toEqual(
expect.objectContaining({
totalHours: 8,
totalCostCents: 800,
}),
);
expect(parsed.scenario).toEqual(
expect.objectContaining({
totalHours: 8,
totalCostCents: 800,
}),
);
expect(parsed.delta).toEqual(
expect.objectContaining({
hours: 0,
costCents: 0,
}),
);
});
it("prefers resources without a local holiday in staffing suggestions", async () => {
const db = {
project: {
findFirst: vi.fn().mockResolvedValue({
id: "project_1",
name: "Holiday Project",
shortCode: "HP",
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
}),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
displayName: "Bavaria",
eid: "BY-1",
fte: 1,
lcrCents: 10000,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
areaRole: { name: "Consultant" },
chapter: "CGI",
assignments: [],
},
{
id: "res_hh",
displayName: "Hamburg",
eid: "HH-1",
fte: 1,
lcrCents: 10000,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "HH",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
areaRole: { name: "Consultant" },
chapter: "CGI",
assignments: [],
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"get_staffing_suggestions",
JSON.stringify({ projectId: "project_1", limit: 5 }),
ctx,
);
const parsed = JSON.parse(result.content) as {
suggestions: Array<{ name: string; availableHours: number }>;
};
expect(parsed.suggestions).toHaveLength(1);
expect(parsed.suggestions[0]).toEqual(
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
);
});
it("finds capacity with local holidays respected", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_by",
displayName: "Bavaria",
eid: "BY-1",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
areaRole: { name: "Consultant" },
chapter: "CGI",
assignments: [],
},
{
id: "res_hh",
displayName: "Hamburg",
eid: "HH-1",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "HH",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
areaRole: { name: "Consultant" },
chapter: "CGI",
assignments: [],
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"find_capacity",
JSON.stringify({ startDate: "2026-01-06", endDate: "2026-01-06", minHoursPerDay: 1 }),
ctx,
);
const parsed = JSON.parse(result.content) as {
results: Array<{ name: string; availableHours: number; availableHoursPerDay: number }>;
};
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0]).toEqual(
expect.objectContaining({ name: "Hamburg", availableHours: 8, availableHoursPerDay: 8 }),
);
});
it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
const db = {
project: {
findUnique: vi
.fn()
.mockResolvedValueOnce({
id: "project_1",
name: "Holiday Project",
shortCode: "HP",
shoringThreshold: 55,
onshoreCountryCode: "DE",
}),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
resourceId: "res_by",
hoursPerDay: 8,
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
resource: {
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
},
},
{
resourceId: "res_in",
hoursPerDay: 8,
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
resource: {
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_in",
federalState: null,
metroCityId: null,
country: { code: "IN" },
metroCity: null,
},
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const ctx = createToolContext(db);
const result = await executeTool(
"get_shoring_ratio",
JSON.stringify({ projectId: "project_1" }),
ctx,
);
expect(result.content).toContain("0% onshore (DE), 100% offshore");
expect(result.content).toContain("IN 100% (1 people)");
});
});
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", async (importOriginal) => {
const actual = await importOriginal<typeof import("@capakraken/application")>();
return {
...actual,
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
listAssignmentBookings: vi.fn(),
};
});
import { listAssignmentBookings } from "@capakraken/application";
import { checkChargeabilityAlerts } from "../lib/chargeability-alerts.js";
describe("chargeability alerts", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("creates an alert when a regional holiday reduces booked hours below threshold", async () => {
const notifications: Array<{ userId: string; title: string; body?: string }> = [];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
displayName: "Bruce Banner",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
metroCityId: null,
federalState: "BY",
chargeabilityTarget: 21,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
managementLevelGroup: { targetPercentage: 0.21 },
metroCity: null,
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
notification: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockImplementation(async ({ data }) => {
notifications.push(data);
return { id: `notification_${notifications.length}`, userId: data.userId };
}),
},
user: {
findMany: vi.fn().mockResolvedValue([{ id: "manager_1" }]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_1",
projectId: "project_1",
resourceId: "res_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_1",
name: "Gamma",
shortCode: "GAM",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "res_1", displayName: "Bruce Banner", chapter: "CGI" },
},
]);
const alertCount = await checkChargeabilityAlerts(db);
expect(alertCount).toBe(1);
expect(notifications).toHaveLength(1);
expect(notifications[0]?.title).toContain("Bruce Banner");
expect(notifications[0]?.body).toContain("gap: 16pp");
});
});
@@ -45,6 +45,10 @@ describe("chargeability report router", () => {
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_es",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_es",
@@ -143,6 +147,10 @@ describe("chargeability report router", () => {
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_es",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_es",
@@ -204,4 +212,217 @@ describe("chargeability report router", () => {
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
});
it("reduces SAH for German public holidays based on the calendar", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_de",
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Munich" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_full_month", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_full_month",
projectId: "project_full_month",
resourceId: "resource_de",
startDate: new Date("2026-01-01T00:00:00.000Z"),
endDate: new Date("2026-01-31T00:00:00.000Z"),
hoursPerDay: 7,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_full_month",
name: "Full Month Project",
shortCode: "FMP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_de", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const report = await caller.getReport({
startMonth: "2026-01",
endMonth: "2026-01",
});
const month = report.resources[0]?.months[0];
expect(month).toBeDefined();
expect(month?.sah).toBe(168);
expect(month?.chg).toBeCloseTo(0.875, 5);
});
it("applies city-specific public holidays to SAH", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_augsburg",
eid: "E-001",
displayName: "Alice",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_1", name: "Augsburg" },
},
{
id: "resource_munich",
eid: "E-002",
displayName: "Bob",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: "city_2",
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_2", name: "Munich" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
const caller = createControllerCaller(db);
const report = await caller.getReport({
startMonth: "2028-08",
endMonth: "2028-08",
});
const augsburg = report.resources.find((resource) => resource.city === "Augsburg");
const munich = report.resources.find((resource) => resource.city === "Munich");
expect(augsburg?.months[0]?.sah).toBe((munich?.months[0]?.sah ?? 0) - 8);
});
it("respects individual weekday availability when computing booked hours", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_pt",
eid: "E-003",
displayName: "Carla",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 0 },
countryId: "country_de",
federalState: null,
metroCityId: "city_3",
chargeabilityTarget: 80,
country: {
id: "country_de",
code: "DE",
dailyWorkingHours: 8,
scheduleRules: null,
},
orgUnit: { id: "org_1", name: "CGI" },
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
managementLevel: { id: "level_1", name: "L7" },
metroCity: { id: "city_3", name: "Berlin" },
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([
{ id: "project_week", utilizationCategory: { code: "Chg" } },
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_week",
projectId: "project_week",
resourceId: "resource_pt",
startDate: new Date("2026-03-02T00:00:00.000Z"),
endDate: new Date("2026-03-06T00:00:00.000Z"),
hoursPerDay: 4,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_week",
name: "Week Project",
shortCode: "WP",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_pt", displayName: "Carla", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const report = await caller.getReport({
startMonth: "2026-03",
endMonth: "2026-03",
});
const month = report.resources[0]?.months[0];
expect(month).toBeDefined();
expect(month?.chg).toBeCloseTo(16 / 144, 5);
});
});
@@ -0,0 +1,195 @@
import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { computationGraphRouter } from "../router/computation-graph.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(computationGraphRouter);
type ResourceGraphMeta = {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
resolvedHolidays: Array<{
date: string;
name: string;
scope: "COUNTRY" | "STATE" | "CITY";
calendarName: string | null;
}>;
factors: {
baseAvailableHours: number;
effectiveAvailableHours: number;
publicHolidayCount: number;
publicHolidayWorkdayCount: number;
publicHolidayHoursDeduction: number;
absenceDayCount: number;
absenceHoursDeduction: number;
};
};
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2026-03-14T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
function createDb(resourceFindImpl: ReturnType<typeof vi.fn>) {
return {
resource: {
findUniqueOrThrow: resourceFindImpl,
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
calculationRule: {
findMany: vi.fn().mockResolvedValue([]),
},
};
}
function buildResource(overrides: Record<string, unknown> = {}) {
return {
id: "resource_1",
displayName: "Bruce Banner",
eid: "bruce.banner",
fte: 1,
lcrCents: 5_000,
chargeabilityTarget: 80,
countryId: "country_de",
federalState: "BY",
metroCityId: null,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
country: {
id: "country_de",
code: "DE",
name: "Deutschland",
dailyWorkingHours: 8,
scheduleRules: null,
},
metroCity: null,
managementLevelGroup: {
id: "mlg_1",
name: "Senior",
targetPercentage: 0.8,
},
...overrides,
};
}
describe("computation graph router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("exposes location context and city-local holidays in the resource graph", async () => {
const db = createDb(vi.fn().mockResolvedValue(buildResource({
id: "resource_augsburg",
metroCityId: "city_augsburg",
metroCity: { id: "city_augsburg", name: "Augsburg" },
})));
const caller = createControllerCaller(db);
const result = await caller.getResourceData({
resourceId: "resource_augsburg",
month: "2026-08",
});
const meta = result.meta as ResourceGraphMeta;
const nodeIds = result.nodes.map((node) => node.id);
const holidayExamples = result.nodes.find((node) => node.id === "input.holidayExamples");
expect(new Set(nodeIds).size).toBe(nodeIds.length);
expect(nodeIds).toEqual(expect.arrayContaining([
"input.country",
"input.state",
"input.city",
"input.holidayContext",
"input.holidayExamples",
"sah.baseHours",
"sah.publicHolidayHours",
"sah.absenceHours",
]));
expect(meta).toMatchObject({
countryCode: "DE",
countryName: "Deutschland",
federalState: "BY",
metroCityName: "Augsburg",
});
expect(meta.resolvedHolidays).toEqual(expect.arrayContaining([
expect.objectContaining({
date: "2026-08-08",
name: "Augsburger Friedensfest",
scope: "CITY",
}),
]));
expect(meta.factors.publicHolidayCount).toBeGreaterThan(0);
expect(meta.factors.publicHolidayWorkdayCount).toBe(0);
expect(holidayExamples?.value).toEqual(expect.stringContaining("Augsburger Friedensfest"));
});
it("derives different effective SAH values for Bavaria and Hamburg", async () => {
const db = createDb(vi.fn()
.mockResolvedValueOnce(buildResource({
id: "resource_by",
federalState: "BY",
managementLevelGroup: null,
}))
.mockResolvedValueOnce(buildResource({
id: "resource_hh",
federalState: "HH",
managementLevelGroup: null,
})));
const caller = createControllerCaller(db);
const bavaria = await caller.getResourceData({
resourceId: "resource_by",
month: "2026-01",
});
const hamburg = await caller.getResourceData({
resourceId: "resource_hh",
month: "2026-01",
});
const bavariaMeta = bavaria.meta as ResourceGraphMeta;
const hamburgMeta = hamburg.meta as ResourceGraphMeta;
expect(bavariaMeta.federalState).toBe("BY");
expect(hamburgMeta.federalState).toBe("HH");
expect(bavariaMeta.factors.baseAvailableHours).toBe(176);
expect(hamburgMeta.factors.baseAvailableHours).toBe(176);
expect(bavariaMeta.factors.effectiveAvailableHours).toBe(160);
expect(hamburgMeta.factors.effectiveAvailableHours).toBe(168);
expect(bavariaMeta.factors.publicHolidayWorkdayCount).toBe(2);
expect(hamburgMeta.factors.publicHolidayWorkdayCount).toBe(1);
expect(bavariaMeta.factors.publicHolidayHoursDeduction).toBe(16);
expect(hamburgMeta.factors.publicHolidayHoursDeduction).toBe(8);
expect(bavariaMeta.resolvedHolidays).toEqual(expect.arrayContaining([
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
]));
expect(hamburgMeta.resolvedHolidays).not.toEqual(expect.arrayContaining([
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
]));
});
});
@@ -10,6 +10,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
getDashboardDemand: vi.fn(),
getDashboardTopValueResources: vi.fn(),
getDashboardChargeabilityOverview: vi.fn(),
getDashboardBudgetForecast: vi.fn(),
};
});
@@ -29,6 +30,7 @@ import {
getDashboardDemand,
getDashboardTopValueResources,
getDashboardChargeabilityOverview,
getDashboardBudgetForecast,
} from "@capakraken/application";
import { dashboardRouter } from "../router/dashboard.js";
import { createCallerFactory } from "../trpc.js";
@@ -302,4 +304,52 @@ describe("dashboard router", () => {
);
});
});
describe("getBudgetForecast", () => {
it("returns budget forecast rows with calendar location context", async () => {
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
{
projectId: "project_1",
projectName: "Alpha",
shortCode: "ALPHA",
clientId: "client_1",
clientName: "Client One",
budgetCents: 100_000,
spentCents: 40_000,
remainingCents: 60_000,
burnRate: 10_000,
estimatedExhaustionDate: "2026-06-30",
pctUsed: 40,
activeAssignmentCount: 2,
calendarLocations: [
{
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Munich",
activeAssignmentCount: 2,
burnRateCents: 10_000,
},
],
},
]);
const caller = createProtectedCaller({});
const result = await caller.getBudgetForecast();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
projectName: "Alpha",
activeAssignmentCount: 2,
calendarLocations: [
expect.objectContaining({
countryCode: "DE",
federalState: "BY",
metroCityName: "Munich",
}),
],
});
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
});
});
});
@@ -150,6 +150,7 @@ describe("effortRule.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -180,6 +181,7 @@ describe("effortRule.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -212,6 +214,7 @@ describe("effortRule.update", () => {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -236,6 +239,7 @@ describe("effortRule.update", () => {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -281,6 +285,7 @@ describe("effortRule.delete", () => {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -16,7 +16,17 @@ const createCaller = createCallerFactory(entitlementRouter);
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
function createProtectedCaller(db: Record<string, unknown>) {
const withResourceOwnership = {
resource: { findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }) },
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.userId ? { userId: "user_1" } : {}),
...(select.federalState ? { federalState: "BY" } : {}),
...(select.country ? { country: { code: "DE" } } : {}),
...(select.metroCity ? { metroCity: null } : {}),
};
}),
},
...db,
};
return createCaller({
@@ -80,6 +90,14 @@ function sampleEntitlement(overrides: Record<string, unknown> = {}) {
};
}
function mockEntitlementFindUniqueByYear(
entitlementsByYear: Record<number, ReturnType<typeof sampleEntitlement> | null>,
) {
return vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
entitlementsByYear[where.resourceId_year.year] ?? null
));
}
// ─── getBalance ──────────────────────────────────────────────────────────────
describe("entitlement.getBalance", () => {
@@ -90,7 +108,7 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
@@ -129,10 +147,9 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
findUnique: vi
.fn()
.mockResolvedValueOnce(null) // current year not found
.mockResolvedValueOnce(prevEntitlement), // previous year found
findUnique: mockEntitlementFindUniqueByYear({
2025: prevEntitlement,
}),
create: vi.fn().mockResolvedValue(createdEntitlement),
update: vi.fn().mockResolvedValue(createdEntitlement),
},
@@ -164,7 +181,7 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
@@ -185,12 +202,14 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi
.fn()
// Public holiday vacations for holiday context
.mockResolvedValueOnce([])
// First call: balance-type vacations (for syncEntitlement)
.mockResolvedValueOnce([])
// Second call: sick days
@@ -209,19 +228,169 @@ describe("entitlement.getBalance", () => {
expect(result.sickDays).toBe(3);
});
it("does not deduct city-specific public holidays from leave balance", async () => {
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "BY",
country: { code: "DE" },
metroCity: { name: "Augsburg" },
}),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
update: vi.fn().mockImplementation(async ({ data }) => ({
...entitlement,
...data,
})),
},
vacation: {
findMany: vi
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
startDate: new Date("2028-08-08T00:00:00.000Z"),
endDate: new Date("2028-08-08T00:00:00.000Z"),
status: "APPROVED",
isHalfDay: false,
},
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
expect(result.usedDays).toBe(0);
expect(result.remainingDays).toBe(30);
});
it("recomputes carryover from the previous year when the next year already exists", async () => {
const entitlements = new Map([
[2025, sampleEntitlement({
id: "ent_2025",
year: 2025,
entitledDays: 28,
carryoverDays: 0,
usedDays: 8,
pendingDays: 0,
})],
[2026, sampleEntitlement({
id: "ent_2026",
year: 2026,
entitledDays: 28,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
})],
]);
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "BY",
countryId: "country_de",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
vacationEntitlement: {
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
entitlements.get(where.resourceId_year.year) ?? null
)),
create: vi.fn(),
update: vi.fn().mockImplementation(async ({ where, data }: {
where: { id: string };
data: Record<string, number>;
}) => {
const current = [...entitlements.values()].find((entry) => entry.id === where.id);
if (!current) {
throw new Error(`Unknown entitlement ${where.id}`);
}
const updated = { ...current, ...data };
entitlements.set(updated.year, updated);
return updated;
}),
},
vacation: {
findMany: vi
.fn()
// 2025 holiday context
.mockResolvedValueOnce([])
// 2025 balance vacations
.mockResolvedValueOnce([
{
startDate: new Date("2025-06-10T00:00:00.000Z"),
endDate: new Date("2025-06-17T00:00:00.000Z"),
status: "APPROVED",
isHalfDay: false,
},
])
// 2026 holiday context
.mockResolvedValueOnce([])
// 2026 balance vacations
.mockResolvedValueOnce([])
// 2026 sick days
.mockResolvedValueOnce([]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
expect(result.carryoverDays).toBe(20);
expect(result.entitledDays).toBe(48);
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ent_2026" },
data: expect.objectContaining({
carryoverDays: 20,
entitledDays: 48,
}),
}),
);
});
});
// ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => {
it("returns existing entitlement (manager role)", async () => {
const entitlement = sampleEntitlement();
const entitlement = sampleEntitlement({
entitledDays: 30,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
});
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 30 }),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, number> }) => ({
...entitlement,
...data,
})),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
};
@@ -259,6 +428,7 @@ describe("entitlement.set", () => {
update: vi.fn().mockResolvedValue(updated),
create: vi.fn(),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -286,6 +456,7 @@ describe("entitlement.set", () => {
update: vi.fn(),
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -324,6 +495,7 @@ describe("entitlement.bulkSet", () => {
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createAdminCaller(db);
@@ -350,6 +522,7 @@ describe("entitlement.bulkSet", () => {
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createAdminCaller(db);
@@ -396,10 +569,15 @@ describe("entitlement.getYearSummary", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({
federalState: "BY",
country: { code: "DE" },
metroCity: null,
}),
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement),
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
@@ -24,10 +24,12 @@ vi.mock("ioredis", () => {
describe("event-bus debounce", () => {
let received: SseEvent[];
let unsubscribe: () => void;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
received = [];
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
unsubscribe = eventBus.subscribe((event) => {
received.push(event);
});
@@ -36,6 +38,7 @@ describe("event-bus debounce", () => {
afterEach(() => {
unsubscribe();
cancelPendingEvents();
consoleWarnSpy.mockRestore();
vi.useRealTimers();
});
@@ -174,6 +174,7 @@ describe("experienceMultiplier.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -203,6 +204,7 @@ describe("experienceMultiplier.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -235,6 +237,7 @@ describe("experienceMultiplier.update", () => {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -259,6 +262,7 @@ describe("experienceMultiplier.update", () => {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -308,6 +312,7 @@ describe("experienceMultiplier.delete", () => {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -0,0 +1,168 @@
import { SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { createCallerFactory } from "../trpc.js";
import { holidayCalendarRouter } from "../router/holiday-calendar.js";
vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
const createCaller = createCallerFactory(holidayCalendarRouter);
function createProtectedCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "user@example.com", name: "User", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_1",
systemRole: SystemRole.USER,
permissionOverrides: null,
},
});
}
function createAdminCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "admin@example.com", name: "Admin", image: null },
expires: "2026-12-31T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "admin_1",
systemRole: SystemRole.ADMIN,
permissionOverrides: null,
},
});
}
describe("holiday calendar router", () => {
it("merges built-in and scoped custom holidays in preview", async () => {
const db = {
country: {
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
},
metroCity: {
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_city",
name: "Augsburg lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-01-01T00:00:00.000Z"),
name: "Augsburg Neujahr",
isRecurringAnnual: true,
},
{
date: new Date("2020-08-08T00:00:00.000Z"),
name: "Friedensfest lokal",
isRecurringAnnual: true,
},
],
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.previewResolvedHolidays({
countryId: "country_de",
metroCityId: "city_augsburg",
year: 2026,
});
expect(db.holidayCalendar.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
countryId: "country_de",
isActive: true,
}),
}),
);
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
date: "2026-01-01",
name: "Augsburg Neujahr",
scopeType: "CITY",
calendarName: "Augsburg lokal",
}),
expect.objectContaining({
date: "2026-08-08",
name: "Friedensfest lokal",
scopeType: "CITY",
calendarName: "Augsburg lokal",
}),
]),
);
});
it("rejects duplicate calendar scopes on create", async () => {
const db = {
country: {
findUnique: vi
.fn()
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" })
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" }),
},
metroCity: {
findUnique: vi.fn(),
},
holidayCalendar: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_scope" }),
create: vi.fn(),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
};
const caller = createAdminCaller(db);
await expect(caller.createCalendar({
name: "Deutschland Standard",
scopeType: "COUNTRY",
countryId: "country_de",
priority: 0,
isActive: true,
})).rejects.toThrow("A holiday calendar for this exact scope already exists");
expect(db.holidayCalendar.create).not.toHaveBeenCalled();
});
it("rejects duplicate entry dates within the same calendar", async () => {
const db = {
holidayCalendar: {
findUnique: vi.fn().mockResolvedValue({ id: "cal_1", name: "Deutschland Standard" }),
},
holidayCalendarEntry: {
findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }),
create: vi.fn(),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
};
const caller = createAdminCaller(db);
await expect(caller.createEntry({
holidayCalendarId: "cal_1",
date: new Date("2026-12-24T00:00:00.000Z"),
name: "Heiligabend lokal",
isRecurringAnnual: true,
source: "manual",
})).rejects.toThrow("A holiday entry for this calendar and date already exists");
expect(db.holidayCalendarEntry.create).not.toHaveBeenCalled();
});
});
@@ -187,6 +187,7 @@ describe("notification.create", () => {
{
notification: {
create: vi.fn().mockResolvedValue(created),
findUnique: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
@@ -209,6 +210,7 @@ describe("notification.create", () => {
}),
}),
);
expect(db.notification.findUnique).toHaveBeenCalledWith({ where: { id: "notif_1" } });
});
it("creates a notification with optional fields", async () => {
@@ -222,6 +224,7 @@ describe("notification.create", () => {
{
notification: {
create: vi.fn().mockResolvedValue(created),
findUnique: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
@@ -134,12 +134,14 @@ describe("project router", () => {
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
webhook: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createManagerCaller(db);
const result = await caller.create({
shortCode: "PRJ-001",
name: "Test Project",
responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
winProbability: 80,
@@ -167,6 +169,7 @@ describe("project router", () => {
caller.create({
shortCode: "PRJ-001",
name: "Duplicate",
responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
budgetCents: 100_00,
@@ -189,6 +192,7 @@ describe("project router", () => {
caller.create({
shortCode: "PRJ-002",
name: "Blocked",
responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
budgetCents: 100_00,
@@ -239,6 +243,64 @@ describe("project router", () => {
});
});
describe("getShoringRatio", () => {
it("excludes regional holidays from shoring weighting", async () => {
const db = {
project: {
findUnique: vi.fn().mockResolvedValue({
id: "project_1",
name: "Test Project",
shoringThreshold: 55,
onshoreCountryCode: "DE",
}),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "a1",
resourceId: "res_de",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
resource: {
id: "res_de",
countryId: "country_de",
federalState: "BY",
metroCityId: null,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
country: { id: "country_de", code: "DE" },
metroCity: null,
},
},
{
id: "a2",
resourceId: "res_es",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
resource: {
id: "res_es",
countryId: "country_es",
federalState: null,
metroCityId: null,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
country: { id: "country_es", code: "ES" },
metroCity: null,
},
},
]),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getShoringRatio({ projectId: "project_1" });
expect(result.totalHours).toBe(24);
expect(result.onshoreRatio).toBe(33);
expect(result.offshoreRatio).toBe(67);
});
});
// ─── update ───────────────────────────────────────────────────────────────
describe("update", () => {
@@ -294,6 +356,7 @@ describe("project router", () => {
project: {
update: vi.fn().mockResolvedValue(updated),
},
webhook: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createManagerCaller(db);
@@ -0,0 +1,118 @@
import { SystemRole } from "@capakraken/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@capakraken/application", () => ({
isChargeabilityActualBooking: vi.fn(() => false),
isChargeabilityRelevantProject: vi.fn(() => false),
listAssignmentBookings: vi.fn().mockResolvedValue([]),
}));
vi.mock("../lib/resource-capacity.js", () => ({
calculateEffectiveAvailableHours: vi.fn(({ context }: { context?: unknown }) => (context ? 156 : 168)),
calculateEffectiveBookedHours: vi.fn(() => 0),
countEffectiveWorkingDays: vi.fn(({ context }: { context?: unknown }) => (context ? 19.5 : 21)),
getAvailabilityHoursForDate: vi.fn(() => 8),
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map([
[
"res_1",
{
holidayDates: new Set(["2026-04-10"]),
vacationFractionsByDate: new Map([["2026-04-14", 0.5]]),
},
],
])),
}));
import { reportRouter } from "../router/report.js";
import { createCallerFactory } from "../trpc.js";
const createCaller = createCallerFactory(reportRouter);
function createControllerCaller(db: Record<string, unknown>) {
return createCaller({
session: {
user: { email: "controller@example.com", name: "Controller", image: null },
expires: "2099-01-01T00:00:00.000Z",
},
db: db as never,
dbUser: {
id: "user_controller",
systemRole: SystemRole.CONTROLLER,
permissionOverrides: null,
},
});
}
describe("report router", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("lists the new resource month transparency columns", async () => {
const caller = createControllerCaller({});
const columns = await caller.getAvailableColumns({ entity: "resource_month" });
expect(columns).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "monthlyPublicHolidayCount", label: "Holiday Dates" }),
expect.objectContaining({ key: "monthlyTargetHours", label: "Target Hours" }),
expect.objectContaining({ key: "monthlyUnassignedHours", label: "Unassigned Hours" }),
]));
});
it("exports resource month basis and computed columns in CSV", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "res_1",
eid: "alice",
displayName: "Alice",
email: "alice@example.com",
chapter: "VFX",
resourceType: "EMPLOYEE",
isActive: true,
chgResponsibility: false,
rolledOff: false,
departed: false,
lcrCents: 7500,
ucrCents: 10000,
currency: "EUR",
fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
chargeabilityTarget: 80,
federalState: "BY",
countryId: "country_de",
metroCityId: null,
country: { code: "DE", name: "Germany" },
metroCity: null,
orgUnit: { name: "Delivery" },
managementLevelGroup: null,
managementLevel: { name: "Senior" },
},
]),
},
};
const caller = createControllerCaller(db);
const result = await caller.exportReport({
entity: "resource_month",
columns: [
"displayName",
"countryCode",
"monthlyPublicHolidayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyTargetHours",
"monthlyUnassignedHours",
],
filters: [],
periodMonth: "2026-04",
limit: 100,
});
expect(result.rowCount).toBe(1);
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
});
});
@@ -86,6 +86,10 @@ describe("resource router", () => {
valueScoreBreakdown: null,
valueScoreUpdatedAt: null,
userId: null,
countryId: "country_de",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
};
const db = {
resource: {
@@ -158,6 +162,165 @@ describe("resource router", () => {
});
});
it("calculates utilization with regional holidays removed from available hours", async () => {
const resource = {
id: "resource_1",
eid: "E-001",
displayName: "Alice",
email: "alice@example.com",
chapter: "CGI",
lcrCents: 5000,
ucrCents: 9000,
currency: "EUR",
chargeabilityTarget: 80,
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
skills: [],
dynamicFields: {},
blueprintId: null,
isActive: true,
createdAt: new Date("2026-03-01"),
updatedAt: new Date("2026-03-01"),
roleId: null,
portfolioUrl: null,
postalCode: null,
federalState: "BY",
countryId: "country_de",
metroCityId: null,
valueScore: null,
valueScoreBreakdown: null,
valueScoreUpdatedAt: null,
userId: null,
country: { code: "DE" },
metroCity: null,
};
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([resource]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_confirmed",
projectId: "project_1",
resourceId: "resource_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_1",
name: "Project 1",
shortCode: "P1",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
},
]);
const caller = createControllerCaller(db);
const result = await caller.listWithUtilization({
startDate: "2026-01-05T00:00:00.000Z",
endDate: "2026-01-06T00:00:00.000Z",
});
expect(result[0]).toMatchObject({
bookingCount: 1,
bookedHours: 8,
availableHours: 8,
utilizationPercent: 100,
});
});
it("shifts marketplace availability when a local holiday blocks today", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-06T10:00:00.000Z"));
try {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_by",
displayName: "Bavaria Artist",
eid: "E-BY",
chapter: "CGI",
skills: [{ skill: "Houdini", proficiency: 5 }],
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
chargeabilityTarget: 80,
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
},
{
id: "resource_hh",
displayName: "Hamburg Artist",
eid: "E-HH",
chapter: "CGI",
skills: [{ skill: "Houdini", proficiency: 5 }],
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
},
chargeabilityTarget: 80,
countryId: "country_de",
federalState: "HH",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
},
]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([]),
},
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const caller = createControllerCaller(db);
const result = await caller.getSkillMarketplace({
searchSkill: "houdini",
availableOnly: true,
});
const bavaria = result.searchResults.find((resource) => resource.id === "resource_by");
const hamburg = result.searchResults.find((resource) => resource.id === "resource_hh");
expect(bavaria?.availableFrom).toBe("2026-01-07T00:00:00.000Z");
expect(hamburg?.availableFrom).toBe("2026-01-06T00:00:00.000Z");
} finally {
vi.useRealTimers();
}
});
it("uses a composite displayName/id cursor for stable pagination", async () => {
const db = {
resource: {
@@ -314,6 +477,84 @@ describe("resource router", () => {
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
});
it("excludes regional public holidays from chargeability stats", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_by",
eid: "E-BY",
displayName: "Bavaria",
chapter: "CGI",
chargeabilityTarget: 80,
countryId: "country_de",
federalState: "BY",
metroCityId: "city_munich",
country: { code: "DE" },
metroCity: { name: "Munich" },
availability: {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
},
},
]),
},
};
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "assignment_holiday",
projectId: "project_1",
resourceId: "resource_by",
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
status: "CONFIRMED",
project: {
id: "project_1",
name: "Project 1",
shortCode: "P1",
status: "ACTIVE",
orderType: "CLIENT",
dynamicFields: null,
},
resource: { id: "resource_by", displayName: "Bavaria", chapter: "CGI" },
},
]);
const RealDate = Date;
class MockDate extends Date {
constructor(...args: ConstructorParameters<typeof Date>) {
if (args.length === 0) {
super("2026-01-15T00:00:00.000Z");
return;
}
super(...args);
}
static now() {
return new RealDate("2026-01-15T00:00:00.000Z").getTime();
}
}
vi.stubGlobal("Date", MockDate);
try {
const caller = createControllerCaller(db);
const result = await caller.getChargeabilityStats({});
expect(result[0]).toMatchObject({
actualChargeability: 0,
expectedChargeability: 0,
availableHours: 168,
});
} finally {
vi.unstubAllGlobals();
}
});
it("applies country filters including explicit no-country toggle", async () => {
const db = {
resource: {
@@ -17,23 +17,6 @@ vi.mock("@capakraken/staffing", () => ({
},
})),
),
analyzeUtilization: vi.fn().mockReturnValue({
resourceId: "res_1",
displayName: "Alice",
totalDays: 20,
allocatedDays: 15,
utilizationPercent: 75,
chargeablePercent: 60,
overallocatedDays: 0,
dailyBreakdown: [],
}),
findCapacityWindows: vi.fn().mockReturnValue([
{
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-10"),
availableHoursPerDay: 6,
},
]),
}));
vi.mock("@capakraken/application", () => ({
@@ -76,6 +59,11 @@ function sampleResource(overrides: Record<string, unknown> = {}) {
isActive: true,
valueScore: 85,
chapter: "VFX",
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
...overrides,
};
}
@@ -105,6 +93,30 @@ describe("staffing.getSuggestions", () => {
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("score");
expect(result[0]).toMatchObject({
resourceName: "Alice",
eid: "alice",
location: {
countryCode: "DE",
federalState: "BY",
},
capacity: expect.objectContaining({
requestedHoursPerDay: 8,
baseAvailableHours: expect.any(Number),
effectiveAvailableHours: expect.any(Number),
remainingHoursPerDay: expect.any(Number),
holidayHoursDeduction: expect.any(Number),
}),
conflicts: {
count: expect.any(Number),
conflictDays: expect.any(Array),
details: expect.any(Array),
},
ranking: expect.objectContaining({
rank: 1,
components: expect.any(Array),
}),
});
});
it("filters resources by chapter when provided", async () => {
@@ -175,6 +187,58 @@ describe("staffing.getSuggestions", () => {
}),
);
});
it("uses value score as a transparent tiebreaker within two score points", async () => {
const resources = [
sampleResource({ id: "res_1", displayName: "Alice", eid: "alice", valueScore: 60 }),
sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 95 }),
];
const db = {
resource: {
findMany: vi.fn().mockResolvedValue(resources),
},
};
const { rankResources } = await import("@capakraken/staffing");
vi.mocked(rankResources).mockImplementationOnce((input: { resources: Array<{ id: string }> }) => ([
{
resourceId: input.resources[0]!.id,
score: 80,
breakdown: {
skillScore: 80,
availabilityScore: 80,
costScore: 80,
utilizationScore: 80,
},
},
{
resourceId: input.resources[1]!.id,
score: 79,
breakdown: {
skillScore: 79,
availabilityScore: 79,
costScore: 79,
utilizationScore: 79,
},
},
]));
const caller = createProtectedCaller(db);
const result = await caller.getSuggestions({
requiredSkills: ["Compositing"],
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
hoursPerDay: 8,
});
expect(result[0]?.resourceId).toBe("res_2");
expect(result[0]?.ranking).toMatchObject({
rank: 1,
baseRank: 2,
tieBreakerApplied: true,
});
expect(result[0]?.ranking.tieBreakerReason).toContain("value score");
});
});
// ─── analyzeUtilization ──────────────────────────────────────────────────────
@@ -186,6 +250,11 @@ describe("staffing.analyzeUtilization", () => {
displayName: "Alice",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
};
const db = {
resource: {
@@ -200,10 +269,56 @@ describe("staffing.analyzeUtilization", () => {
endDate: new Date("2026-04-30"),
});
expect(result).toHaveProperty("utilizationPercent");
expect(result).toHaveProperty("currentChargeability");
expect(result.resourceId).toBe("res_1");
});
it("excludes Bavarian public holidays from chargeability analysis", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "a1",
projectId: "project_1",
resourceId: "res_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
hoursPerDay: 8,
dailyCostCents: 0,
status: "CONFIRMED",
project: { id: "project_1", name: "Chargeable", shortCode: "CHG", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
},
]);
const caller = createProtectedCaller(db);
const result = await caller.analyzeUtilization({
resourceId: "res_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
});
expect(result.currentChargeability).toBe(100);
expect(result.overallocatedDays).toEqual([]);
expect(result.underutilizedDays).toEqual([]);
});
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
@@ -230,6 +345,11 @@ describe("staffing.findCapacity", () => {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
};
const db = {
resource: {
@@ -244,8 +364,53 @@ describe("staffing.findCapacity", () => {
endDate: new Date("2026-04-30"),
});
expect(result).toHaveLength(1);
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("availableHoursPerDay");
expect(result.every((window) => window.availableHoursPerDay > 0)).toBe(true);
expect(result.reduce((sum, window) => sum + window.availableDays, 0)).toBeGreaterThan(0);
});
it("splits capacity windows around Bavarian public holidays", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
};
const db = {
resource: {
findUnique: vi.fn().mockResolvedValue(resource),
},
};
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
const caller = createProtectedCaller(db);
const result = await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-07T00:00:00.000Z"),
minAvailableHoursPerDay: 4,
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual(
expect.objectContaining({
startDate: new Date("2026-01-05T00:00:00.000Z"),
endDate: new Date("2026-01-05T00:00:00.000Z"),
}),
);
expect(result[1]).toEqual(
expect.objectContaining({
startDate: new Date("2026-01-07T00:00:00.000Z"),
endDate: new Date("2026-01-07T00:00:00.000Z"),
}),
);
});
it("throws NOT_FOUND when resource does not exist", async () => {
@@ -265,11 +430,16 @@ describe("staffing.findCapacity", () => {
).rejects.toThrow("Resource not found");
});
it("passes minAvailableHoursPerDay to engine", async () => {
it("honors minAvailableHoursPerDay when computing holiday-aware windows", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
};
const db = {
resource: {
@@ -277,21 +447,30 @@ describe("staffing.findCapacity", () => {
},
};
const { findCapacityWindows } = await import("@capakraken/staffing");
const { listAssignmentBookings } = await import("@capakraken/application");
vi.mocked(listAssignmentBookings).mockResolvedValue([
{
id: "a1",
projectId: "project_1",
resourceId: "res_1",
startDate: new Date("2026-04-01T00:00:00.000Z"),
endDate: new Date("2026-04-30T00:00:00.000Z"),
hoursPerDay: 3,
dailyCostCents: 0,
status: "CONFIRMED",
project: { id: "project_1", name: "Project", shortCode: "PRJ", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
},
]);
const caller = createProtectedCaller(db);
await caller.findCapacity({
const result = await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
minAvailableHoursPerDay: 6,
});
expect(findCapacityWindows).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.any(Date),
expect.any(Date),
6,
);
expect(result.every((window) => window.availableHoursPerDay >= 6)).toBe(true);
});
});
@@ -290,4 +290,83 @@ describe("timeline allocation entry resolution", () => {
}),
);
});
it("returns resolved holiday overlays for assigned resources", async () => {
const db = {
demandRequirement: {
findMany: vi.fn().mockResolvedValue([]),
},
assignment: {
findMany: vi.fn().mockResolvedValue([
{
id: "assignment_1",
kind: "assignment",
resourceId: "resource_by",
projectId: "project_1",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-01-31"),
hoursPerDay: 8,
status: AllocationStatus.CONFIRMED,
metadata: {},
project: {
id: "project_1",
name: "Project One",
shortCode: "PRJ",
status: "ACTIVE",
startDate: new Date("2026-01-01"),
endDate: new Date("2026-03-31"),
orderType: "CHARGEABLE",
clientId: null,
},
resource: {
id: "resource_by",
displayName: "Alice",
eid: "E-001",
chapter: null,
},
},
]),
},
resource: {
findMany: vi.fn().mockResolvedValue([
{
id: "resource_by",
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
},
]),
},
project: {
findMany: vi.fn().mockResolvedValue([]),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([]),
},
country: {
findUnique: vi.fn(),
},
metroCity: {
findUnique: vi.fn(),
},
};
const caller = createManagerCaller(db);
const overlays = await caller.getHolidayOverlays({
startDate: new Date("2026-01-01"),
endDate: new Date("2026-01-31"),
});
expect(overlays).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: "resource_by",
type: "PUBLIC_HOLIDAY",
note: "Heilige Drei Könige",
}),
]),
);
});
});
@@ -9,12 +9,30 @@ vi.mock("../sse/event-bus.js", () => ({
emitVacationUpdated: vi.fn(),
emitVacationDeleted: vi.fn(),
emitNotificationCreated: vi.fn(),
emitTaskAssigned: vi.fn(),
}));
vi.mock("../lib/email.js", () => ({
sendEmail: vi.fn(),
}));
vi.mock("../lib/create-notification.js", () => ({
createNotification: vi.fn().mockResolvedValue("notif_1"),
}));
vi.mock("../lib/vacation-conflicts.js", () => ({
checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
}));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../lib/audit.js", () => ({
createAuditEntry: vi.fn().mockResolvedValue(undefined),
}));
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) {
@@ -91,6 +109,56 @@ const sampleVacation = {
approvedBy: null,
};
function createVacationDb(overrides: Record<string, unknown> = {}) {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
},
resource: {
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
const select = args?.select ?? {};
return {
...(select.userId ? { userId: "user_1" } : {}),
...(select.displayName ? { displayName: "Alice" } : {}),
...(select.user ? { user: null } : {}),
...(select.federalState ? { federalState: "BY" } : {}),
...(select.country ? { country: { code: "DE", name: "Germany" } } : {}),
...(select.metroCity ? { metroCity: null } : {}),
};
}),
count: vi.fn().mockResolvedValue(0),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(sampleVacation),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(sampleVacation),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
notification: {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
auditLog: {
create: vi.fn().mockResolvedValue({}),
},
};
return {
...db,
...overrides,
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
resource: { ...db.resource, ...(overrides.resource as Record<string, unknown> | undefined) },
vacation: { ...db.vacation, ...(overrides.vacation as Record<string, unknown> | undefined) },
notification: {
...db.notification,
...(overrides.notification as Record<string, unknown> | undefined),
},
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
};
}
describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
@@ -199,18 +267,11 @@ describe("vacation router", () => {
status: VacationStatus.PENDING,
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
const db = createVacationDb({
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
});
const caller = createProtectedCaller(db);
const result = await caller.create({
@@ -239,15 +300,14 @@ describe("vacation router", () => {
approvedById: "mgr_1",
};
const db = {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.create({
@@ -269,17 +329,11 @@ describe("vacation router", () => {
});
it("rejects overlapping vacation", async () => {
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
const db = createVacationDb({
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
},
};
});
const caller = createProtectedCaller(db);
await expect(
@@ -293,10 +347,10 @@ describe("vacation router", () => {
});
it("rejects when end date is before start date", async () => {
const db = {
const db = createVacationDb({
user: { findUnique: vi.fn() },
vacation: { findFirst: vi.fn() },
};
});
const caller = createProtectedCaller(db);
await expect(
@@ -316,18 +370,11 @@ describe("vacation router", () => {
halfDayPart: "MORNING",
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
const db = createVacationDb({
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
};
});
const caller = createProtectedCaller(db);
const result = await caller.create({
@@ -349,6 +396,235 @@ describe("vacation router", () => {
}),
);
});
it("rejects multi-day half-day vacations", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-02"),
isHalfDay: true,
halfDayPart: "MORNING",
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects half-day vacations without a half-day part", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
isHalfDay: true,
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects half-day parts on full-day vacations", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-01"),
halfDayPart: "AFTERNOON",
})).rejects.toThrow();
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("rejects leave requests that only hit public holidays", async () => {
const db = createVacationDb({
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-01-06T00:00:00.000Z"),
endDate: new Date("2026-01-06T00:00:00.000Z"),
})).rejects.toThrow("does not deduct any vacation days");
expect(db.vacation.create).not.toHaveBeenCalled();
});
});
describe("previewRequest", () => {
it("shows public holidays as non-deductible leave days", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Augsburg" },
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2028-08-08T00:00:00.000Z"),
endDate: new Date("2028-08-08T00:00:00.000Z"),
});
expect(result.requestedDays).toBe(1);
expect(result.effectiveDays).toBe(0);
expect(result.deductedDays).toBe(0);
expect(result.publicHolidayDates).toContain("2028-08-08");
expect(result.holidayContext).toEqual({
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Augsburg",
sources: {
hasCalendarHolidays: true,
hasLegacyPublicHolidayEntries: false,
},
});
expect(result.holidayDetails).toContainEqual({
date: "2028-08-08",
source: "CALENDAR",
});
});
it("uses custom city holiday calendars for non-deductible leave days", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacation: {
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-11-15T00:00:00.000Z"),
endDate: new Date("2026-11-15T00:00:00.000Z"),
});
expect(result.requestedDays).toBe(1);
expect(result.effectiveDays).toBe(0);
expect(result.publicHolidayDates).toContain("2026-11-15");
expect(result.holidayContext.countryName).toBe("Germany");
expect(result.holidayContext.metroCityName).toBe("Muenchen");
expect(db.holidayCalendar.findMany).toHaveBeenCalled();
});
it("marks legacy public holiday entries as a separate preview source", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
federalState: "HH",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Hamburg" },
}),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
},
]),
},
});
const caller = createProtectedCaller(db);
const result = await caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
});
expect(result.publicHolidayDates).toContain("2026-05-01");
expect(result.holidayContext.sources).toEqual({
hasCalendarHolidays: true,
hasLegacyPublicHolidayEntries: true,
});
expect(result.holidayDetails).toContainEqual({
date: "2026-05-01",
source: "CALENDAR_AND_LEGACY",
});
});
it("rejects multi-day half-day previews", async () => {
const db = createVacationDb();
const caller = createProtectedCaller(db);
await expect(caller.previewRequest({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01"),
endDate: new Date("2026-06-02"),
isHalfDay: true,
})).rejects.toThrow();
});
});
describe("create manual public holiday handling", () => {
it("rejects manual public holiday creation requests", async () => {
const db = createVacationDb();
const caller = createManagerCaller(db);
await expect(caller.create({
resourceId: "res_1",
type: VacationType.PUBLIC_HOLIDAY,
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-01T00:00:00.000Z"),
})).rejects.toThrow("Public holidays must be managed via Holiday Calendars or the legacy holiday import");
expect(db.vacation.create).not.toHaveBeenCalled();
});
});
describe("approve", () => {
@@ -359,7 +635,7 @@ describe("vacation router", () => {
approvedById: "mgr_1",
};
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
@@ -370,7 +646,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
@@ -388,25 +664,25 @@ describe("vacation router", () => {
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("rejects approving an already APPROVED vacation", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
@@ -429,7 +705,7 @@ describe("vacation router", () => {
rejectionReason: "Team conflict",
};
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
@@ -437,7 +713,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
@@ -454,14 +730,14 @@ describe("vacation router", () => {
});
it("throws when rejecting non-PENDING vacation", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
};
});
const caller = createManagerCaller(db);
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
@@ -477,15 +753,12 @@ describe("vacation router", () => {
status: VacationStatus.CANCELLED,
};
const db = {
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
};
});
const caller = createProtectedCaller(db);
const result = await caller.cancel({ id: "vac_1" });
@@ -494,25 +767,25 @@ describe("vacation router", () => {
});
it("throws NOT_FOUND for missing vacation", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("throws when already cancelled", async () => {
const db = {
const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.CANCELLED,
}),
},
};
});
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
@@ -521,7 +794,7 @@ describe("vacation router", () => {
describe("batchApprove", () => {
it("approves multiple pending vacations", async () => {
const db = {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
@@ -535,7 +808,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
@@ -552,7 +825,7 @@ describe("vacation router", () => {
});
it("only approves PENDING vacations from the requested set", async () => {
const db = {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
@@ -565,7 +838,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
@@ -581,7 +854,10 @@ describe("vacation router", () => {
describe("batchReject", () => {
it("rejects multiple pending vacations with optional reason", async () => {
const db = {
const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
@@ -591,7 +867,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
};
});
const caller = createManagerCaller(db);
const result = await caller.batchReject({
@@ -731,8 +1007,8 @@ describe("vacation router", () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
{ id: "res_1" },
{ id: "res_2" },
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
{ id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
]),
},
user: {
@@ -759,7 +1035,9 @@ describe("vacation router", () => {
it("skips already existing holidays", async () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
findMany: vi.fn().mockResolvedValue([
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
+48 -11
View File
@@ -1,6 +1,11 @@
import { listAssignmentBookings } from "@capakraken/application";
import { rankResources } from "@capakraken/staffing";
import type { SkillEntry } from "@capakraken/shared";
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "./resource-capacity.js";
import { createNotificationsForUsers } from "./create-notification.js";
/**
@@ -58,6 +63,11 @@ type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
chargeabilityTarget: number;
availability: unknown;
valueScore: number | null;
countryId: string | null;
federalState: string | null;
metroCityId: string | null;
country: { code: string | null } | null;
metroCity: { name: string | null } | null;
}>>;
};
notification: {
@@ -154,27 +164,54 @@ export async function generateAutoSuggestions(
endDate: demand.endDate,
resourceIds: resources.map((r) => r.id),
});
const contexts = await loadResourceDailyAvailabilityContexts(
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
demand.startDate,
demand.endDate,
);
// 5. Enrich resources with utilization data for the demand's date range
const enrichedResources = resources.map((resource) => {
const avail = resource.availability as
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
| null;
const totalAvailableHours = avail?.monday ?? 8;
const availability = resource.availability as unknown as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
const allocatedHoursPerDay = resourceBookings.reduce(
(sum, b) => sum + b.hoursPerDay,
const totalAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: demand.startDate,
periodEnd: demand.endDate,
context,
});
const allocatedHours = resourceBookings.reduce(
(sum, booking) =>
sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: demand.startDate,
periodEnd: demand.endDate,
context,
}),
0,
);
const utilizationPercent =
totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
: 0;
const wouldExceedCapacity =
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
const wouldExceedCapacity = totalAvailableHours > 0
? allocatedHours + demand.hoursPerDay > totalAvailableHours
: demand.hoursPerDay > 0;
return {
id: resource.id,
+58 -67
View File
@@ -1,14 +1,16 @@
import {
deriveResourceForecast,
getMonthRange,
countWorkingDaysInOverlap,
calculateSAH,
type AssignmentSlice,
} from "@capakraken/engine";
import type { SpainScheduleRule } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { VacationStatus } from "@capakraken/db";
import { createNotificationsForUsers } from "./create-notification.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "./resource-capacity.js";
/**
* Minimal DB client type for chargeability alerts.
@@ -24,23 +26,19 @@ type DbClient = {
id: string;
displayName: string;
fte: number;
availability: unknown;
countryId: string | null;
metroCityId: string | null;
federalState: string | null;
chargeabilityTarget: number;
country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null;
country: {
id?: string | null;
code: string | null;
dailyWorkingHours: number | null;
scheduleRules: unknown;
} | null;
managementLevelGroup: { targetPercentage: number | null } | null;
}>
>;
};
vacation: {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, unknown>;
}) => Promise<
Array<{
resourceId: string;
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
metroCity: { id?: string | null; name: string | null } | null;
}>
>;
};
@@ -105,9 +103,14 @@ export async function checkChargeabilityAlerts(
id: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
metroCityId: true,
federalState: true,
chargeabilityTarget: true,
country: { select: { dailyWorkingHours: true, scheduleRules: true } },
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
managementLevelGroup: { select: { targetPercentage: true } },
metroCity: { select: { id: true, name: true } },
},
});
@@ -121,56 +124,32 @@ export async function checkChargeabilityAlerts(
endDate: monthEnd,
resourceIds,
});
// Fetch vacations for the current month
const vacations = await (db as DbClient).vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: monthEnd },
endDate: { gte: monthStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
monthStart,
monthEnd,
);
// Compute chargeability per resource
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
for (const resource of resources) {
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
// Compute absence dates for SAH
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
const absenceDates: string[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
while (cursor <= endNorm) {
absenceDates.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null;
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [],
absenceDays: absenceDates,
context,
});
// Build assignment slices
@@ -178,12 +157,24 @@ export async function checkChargeabilityAlerts(
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
);
const slices: AssignmentSlice[] = resourceBookings.map((b) => {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate);
const slices: AssignmentSlice[] = resourceBookings.flatMap((b) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
return {
hoursPerDay: b.hoursPerDay,
workingDays,
workingDays: 0,
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
totalChargeableHours,
};
});
@@ -194,7 +185,7 @@ export async function checkChargeabilityAlerts(
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
sah: availableHours,
});
const chgPct = forecast.chg * 100;
+53 -21
View File
@@ -9,7 +9,7 @@
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
*/
import { getPublicHolidays } from "@capakraken/shared";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "./holiday-availability.js";
interface MinimalVacation {
resourceId: string;
@@ -19,14 +19,20 @@ interface MinimalVacation {
interface AutoImportDb {
resource: {
findMany: (args: {
where: { isActive: boolean };
select: { id: string; federalState: string };
}) => Promise<Array<{ id: string; federalState: string | null }>>;
findMany: (args: any) => any;
};
country?: {
findUnique: (args: any) => any;
};
metroCity?: {
findUnique: (args: any) => any;
};
holidayCalendar?: {
findMany: (args: any) => any;
};
vacation: {
findMany: (args: unknown) => Promise<MinimalVacation[]>;
createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>;
findMany: (args: any) => any;
createMany: (args: any) => any;
};
}
@@ -42,34 +48,60 @@ export interface AutoImportResult {
* Returns the number of holiday vacation records created.
*/
export async function autoImportPublicHolidays(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
db: any,
db: AutoImportDb,
year: number,
): Promise<AutoImportResult> {
const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({
const resources = await db.resource.findMany({
where: { isActive: true },
select: { id: true, federalState: true },
select: {
id: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
if (resources.length === 0) {
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
}
// Group resources by federal state (null = federal-only holidays)
const byState = new Map<string | null, string[]>();
const nextYearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const nextYearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const byHolidayProfile = new Map<string, typeof resources>();
for (const resource of resources) {
const state = resource.federalState ?? null;
const group = byState.get(state) ?? [];
group.push(resource.id);
byState.set(state, group);
const profileKey = JSON.stringify({
countryCode: resource.country?.code ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
});
const group = byHolidayProfile.get(profileKey) ?? [];
group.push(resource);
byHolidayProfile.set(profileKey, group);
}
let totalCreated = 0;
let totalSkipped = 0;
for (const [state, resourceIds] of byState) {
const holidays = getPublicHolidays(year, state ?? undefined);
for (const [, groupedResources] of byHolidayProfile) {
const sample = groupedResources[0];
if (!sample) {
continue;
}
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
periodStart: nextYearStart,
periodEnd: nextYearEnd,
countryId: sample.countryId,
countryCode: sample.country?.code ?? null,
federalState: sample.federalState,
metroCityId: sample.metroCityId,
metroCityName: sample.metroCity?.name ?? null,
});
if (holidays.length === 0) continue;
const resourceIds = groupedResources.map((resource: { id: string }) => resource.id);
for (const holiday of holidays) {
const holidayDate = new Date(holiday.date);
@@ -86,13 +118,13 @@ export async function autoImportPublicHolidays(
});
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id));
const newResourceIds = resourceIds.filter((id: string) => !existingResourceIds.has(id));
totalSkipped += existingResourceIds.size;
if (newResourceIds.length === 0) continue;
const records = newResourceIds.map((resourceId) => ({
const records = newResourceIds.map((resourceId: string) => ({
resourceId,
type: "PUBLIC_HOLIDAY",
status: "APPROVED",
@@ -0,0 +1,464 @@
import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
type VacationLike = {
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
};
type HolidayAvailabilityInput = {
vacations: VacationLike[];
periodStart: Date;
periodEnd: Date;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityName?: string | null | undefined;
resolvedHolidayStrings?: string[] | undefined;
};
type HolidayAvailabilityResult = {
absenceDateStrings: string[];
publicHolidayStrings: string[];
absenceDays: AbsenceDay[];
};
export type CalendarHoliday = {
date: string;
name: string;
scope: "COUNTRY" | "STATE" | "CITY";
};
type CalendarScope = CalendarHoliday["scope"];
type HolidayCalendarEntryRecord = {
date: Date;
name: string;
isRecurringAnnual: boolean;
};
type HolidayCalendarRecord = {
id: string;
name: string;
scopeType: CalendarScope;
priority: number;
createdAt?: Date;
entries: HolidayCalendarEntryRecord[];
};
type HolidayResolverDb = {
[key: string]: unknown;
country?: {
findUnique: (args: any) => any;
};
metroCity?: {
findUnique: (args: any) => any;
};
holidayCalendar?: {
findMany: (args: any) => any;
};
};
type ResolvedHoliday = CalendarHoliday & {
calendarName: string;
priority: number;
sourceType: "BUILTIN" | "CUSTOM";
};
export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
return db as HolidayResolverDb;
}
export function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
type CityHolidayRule = {
countryCode: string;
cityName: string;
resolveDates: (year: number) => string[];
};
const CITY_HOLIDAY_RULES: CityHolidayRule[] = [
{
countryCode: "DE",
cityName: "Augsburg",
resolveDates: (year) => [`${year}-08-08`],
},
];
const SCOPE_WEIGHT: Record<CalendarScope, number> = {
COUNTRY: 1,
STATE: 2,
CITY: 3,
};
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function resolveCalendarEntries(
calendars: HolidayCalendarRecord[],
periodStart: Date,
periodEnd: Date,
): ResolvedHoliday[] {
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
const startIso = toIsoDate(periodStart);
const endIso = toIsoDate(periodEnd);
const resolved = new Map<string, ResolvedHoliday>();
for (const calendar of calendars) {
for (const entry of calendar.entries) {
const baseDate = new Date(entry.date);
for (let year = startYear; year <= endYear; year += 1) {
const effectiveDate = entry.isRecurringAnnual
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
: baseDate;
const key = toIsoDate(effectiveDate);
if (key < startIso || key > endIso) {
if (!entry.isRecurringAnnual) {
break;
}
continue;
}
const candidate: ResolvedHoliday = {
date: key,
name: entry.name,
scope: calendar.scopeType,
calendarName: calendar.name,
priority: calendar.priority,
sourceType: "CUSTOM",
};
const existing = resolved.get(key);
if (
!existing
|| SCOPE_WEIGHT[candidate.scope] > SCOPE_WEIGHT[existing.scope]
|| (
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
&& candidate.priority > existing.priority
)
|| (
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
&& candidate.priority === existing.priority
&& existing.sourceType === "BUILTIN"
)
) {
resolved.set(key, candidate);
}
if (!entry.isRecurringAnnual) {
break;
}
}
}
}
return [...resolved.values()].sort((left, right) => left.date.localeCompare(right.date));
}
function mergeResolvedHolidays(
builtInHolidays: CalendarHoliday[],
customHolidays: ResolvedHoliday[],
): ResolvedHoliday[] {
const merged = new Map<string, ResolvedHoliday>();
for (const holiday of builtInHolidays) {
merged.set(holiday.date, {
...holiday,
calendarName: "System",
priority: Number.MIN_SAFE_INTEGER,
sourceType: "BUILTIN",
});
}
for (const holiday of customHolidays) {
const existing = merged.get(holiday.date);
if (
!existing
|| SCOPE_WEIGHT[holiday.scope] > SCOPE_WEIGHT[existing.scope]
|| (
SCOPE_WEIGHT[holiday.scope] === SCOPE_WEIGHT[existing.scope]
&& holiday.priority >= existing.priority
)
) {
merged.set(holiday.date, holiday);
}
}
return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
}
async function loadScopedHolidayCalendars(
db: HolidayResolverDb,
input: {
countryId?: string | null | undefined;
stateCode?: string | null | undefined;
metroCityId?: string | null | undefined;
},
): Promise<HolidayCalendarRecord[]> {
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
return [];
}
const stateCode = normalizeStateCode(input.stateCode);
const metroCityId = input.metroCityId?.trim() || null;
return db.holidayCalendar.findMany({
where: {
isActive: true,
countryId: input.countryId,
OR: [
{ scopeType: "COUNTRY" },
...(stateCode ? [{ scopeType: "STATE" as const, stateCode }] : []),
...(metroCityId ? [{ scopeType: "CITY" as const, metroCityId }] : []),
],
},
include: { entries: true },
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
});
}
export function getCalendarHolidayStrings(
periodStart: Date,
periodEnd: Date,
countryCode?: string | null,
federalState?: string | null,
metroCityName?: string | null,
): string[] {
return getCalendarHolidays(
periodStart,
periodEnd,
countryCode,
federalState,
metroCityName,
).map((holiday) => holiday.date);
}
export function getCalendarHolidays(
periodStart: Date,
periodEnd: Date,
countryCode?: string | null,
federalState?: string | null,
metroCityName?: string | null,
): CalendarHoliday[] {
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
const holidays = new Map<string, CalendarHoliday>();
if (countryCode === "DE") {
for (let year = startYear; year <= endYear; year += 1) {
for (const holiday of getPublicHolidays(year, federalState ?? undefined)) {
if (holiday.date >= toIsoDate(periodStart) && holiday.date <= toIsoDate(periodEnd)) {
holidays.set(holiday.date, {
date: holiday.date,
name: holiday.name,
scope: holiday.federal ? "COUNTRY" : "STATE",
});
}
}
}
}
const normalizedCityName = normalizeCityName(metroCityName);
if (countryCode && normalizedCityName) {
for (const rule of CITY_HOLIDAY_RULES) {
if (
rule.countryCode === countryCode
&& normalizeCityName(rule.cityName) === normalizedCityName
) {
for (let year = startYear; year <= endYear; year += 1) {
for (const holidayDate of rule.resolveDates(year)) {
if (holidayDate >= toIsoDate(periodStart) && holidayDate <= toIsoDate(periodEnd)) {
holidays.set(holidayDate, {
date: holidayDate,
name: "Augsburger Friedensfest",
scope: "CITY",
});
}
}
}
}
}
}
return [...holidays.values()].sort((left, right) => left.date.localeCompare(right.date));
}
export async function getResolvedCalendarHolidays(
db: HolidayResolverDb,
input: {
periodStart: Date;
periodEnd: Date;
countryId?: string | null | undefined;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityId?: string | null | undefined;
metroCityName?: string | null | undefined;
},
): Promise<ResolvedHoliday[]> {
let countryCode = input.countryCode ?? null;
if (!countryCode && input.countryId && typeof db.country?.findUnique === "function") {
const country = await db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
});
countryCode = country?.code ?? null;
}
let metroCityName = input.metroCityName ?? null;
if (!metroCityName && input.metroCityId && typeof db.metroCity?.findUnique === "function") {
const metroCity = await db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
});
metroCityName = metroCity?.name ?? null;
}
const builtIn = getCalendarHolidays(
input.periodStart,
input.periodEnd,
countryCode,
input.federalState,
metroCityName,
);
const calendars = await loadScopedHolidayCalendars(db, {
countryId: input.countryId,
stateCode: input.federalState,
metroCityId: input.metroCityId,
});
const custom = resolveCalendarEntries(calendars, input.periodStart, input.periodEnd);
return mergeResolvedHolidays(builtIn, custom);
}
export async function getResolvedCalendarHolidayStrings(
db: HolidayResolverDb,
input: {
periodStart: Date;
periodEnd: Date;
countryId?: string | null | undefined;
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityId?: string | null | undefined;
metroCityName?: string | null | undefined;
},
): Promise<string[]> {
const holidays = await getResolvedCalendarHolidays(db, input);
return holidays.map((holiday) => holiday.date);
}
export function collectHolidayAvailability(
input: HolidayAvailabilityInput,
): HolidayAvailabilityResult {
const periodStartIso = toIsoDate(input.periodStart);
const periodEndIso = toIsoDate(input.periodEnd);
const publicHolidaySet = new Set(
input.resolvedHolidayStrings
? input.resolvedHolidayStrings.filter((date) => date >= periodStartIso && date <= periodEndIso)
: getCalendarHolidayStrings(
input.periodStart,
input.periodEnd,
input.countryCode,
input.federalState,
input.metroCityName,
),
);
const absenceDateSet = new Set<string>();
const absenceDayMap = new Map<string, AbsenceDay>();
for (const isoDate of publicHolidaySet) {
absenceDayMap.set(isoDate, {
date: new Date(`${isoDate}T00:00:00.000Z`),
type: "PUBLIC_HOLIDAY",
});
}
for (const vacation of input.vacations) {
if (vacation.type !== "PUBLIC_HOLIDAY") {
continue;
}
const overlapStart = new Date(
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
);
if (overlapStart > overlapEnd) {
continue;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
publicHolidaySet.add(isoDate);
absenceDayMap.set(isoDate, {
date: new Date(cursor),
type: "PUBLIC_HOLIDAY",
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
});
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
for (const vacation of input.vacations) {
if (vacation.type === "PUBLIC_HOLIDAY") {
continue;
}
const overlapStart = new Date(
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
);
const overlapEnd = new Date(
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
);
if (overlapStart > overlapEnd) {
continue;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
const triggerType = vacation.type === "SICK" ? "SICK" : "VACATION";
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
if (!publicHolidaySet.has(isoDate)) {
absenceDateSet.add(isoDate);
absenceDayMap.set(isoDate, {
date: new Date(cursor),
type: triggerType,
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
});
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
return {
absenceDateStrings: [...absenceDateSet].sort(),
publicHolidayStrings: [...publicHolidaySet].sort(),
absenceDays: [...absenceDayMap.values()],
};
}
+11 -10
View File
@@ -3,23 +3,24 @@ import pino from "pino";
const isProduction = process.env["NODE_ENV"] === "production";
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
const devDestination = pino.destination({ dest: 1, sync: true });
export const logger = pino({
export const logger = isProduction
? pino({
level: LOG_LEVEL,
base: { service: "capakraken-api" },
})
: pino(
{
level: LOG_LEVEL,
base: { service: "capakraken-api" },
...(isProduction
? {}
: {
transport: {
target: "pino/file",
options: { destination: 1 }, // stdout
},
formatters: {
level(label: string) {
return { level: label };
},
},
}),
});
},
devDestination,
);
export type Logger = typeof logger;
+439
View File
@@ -0,0 +1,439 @@
import { VacationStatus } from "@capakraken/db";
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
type HolidayCalendarEntryRecord = {
date: Date;
isRecurringAnnual: boolean;
};
type HolidayCalendarRecord = {
entries: HolidayCalendarEntryRecord[];
};
type VacationRecord = {
resourceId: string;
startDate: Date;
endDate: Date;
type: string;
isHalfDay: boolean;
};
export type ResourceCapacityProfile = {
id: string;
availability: WeekdayAvailability;
countryId: string | null | undefined;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityId: string | null | undefined;
metroCityName: string | null | undefined;
};
export type ResourceDailyAvailabilityContext = {
absenceFractionsByDate: Map<string, number>;
holidayDates: Set<string>;
vacationFractionsByDate: Map<string, number>;
};
type ResourceCapacityDbClient = {
holidayCalendar?: {
findMany: (args: {
where: Record<string, unknown>;
include: { entries: true };
orderBy: Array<Record<string, "asc" | "desc">>;
}) => Promise<unknown[]>;
};
vacation?: {
findMany: (args: {
where: Record<string, unknown>;
select: Record<string, boolean | Record<string, boolean>>;
}) => Promise<unknown[]>;
};
};
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const CITY_HOLIDAY_RULES: Array<{
countryCode: string;
cityName: string;
resolveDates: (year: number) => string[];
}> = [
{
countryCode: "DE",
cityName: "Augsburg",
resolveDates: (year) => [`${year}-08-08`],
},
];
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function normalizeCityName(cityName?: string | null): string | null {
const normalized = cityName?.trim().toLowerCase();
return normalized && normalized.length > 0 ? normalized : null;
}
function normalizeStateCode(stateCode?: string | null): string | null {
const normalized = stateCode?.trim().toUpperCase();
return normalized && normalized.length > 0 ? normalized : null;
}
export function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const key = DAY_KEYS[date.getUTCDay()];
return key ? (availability[key] ?? 0) : 0;
}
function listBuiltinHolidayDates(input: {
periodStart: Date;
periodEnd: Date;
countryCode: string | null | undefined;
federalState: string | null | undefined;
metroCityName: string | null | undefined;
}): Set<string> {
const dates = new Set<string>();
const startIso = toIsoDate(input.periodStart);
const endIso = toIsoDate(input.periodEnd);
const startYear = input.periodStart.getUTCFullYear();
const endYear = input.periodEnd.getUTCFullYear();
if (input.countryCode === "DE") {
for (let year = startYear; year <= endYear; year += 1) {
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
if (holiday.date >= startIso && holiday.date <= endIso) {
dates.add(holiday.date);
}
}
}
}
const normalizedCityName = normalizeCityName(input.metroCityName);
if (input.countryCode && normalizedCityName) {
for (const rule of CITY_HOLIDAY_RULES) {
if (
rule.countryCode === input.countryCode
&& normalizeCityName(rule.cityName) === normalizedCityName
) {
for (let year = startYear; year <= endYear; year += 1) {
for (const date of rule.resolveDates(year)) {
if (date >= startIso && date <= endIso) {
dates.add(date);
}
}
}
}
}
}
return dates;
}
function resolveCalendarEntryDates(
calendars: HolidayCalendarRecord[],
periodStart: Date,
periodEnd: Date,
): Set<string> {
const dates = new Set<string>();
const startIso = toIsoDate(periodStart);
const endIso = toIsoDate(periodEnd);
const startYear = periodStart.getUTCFullYear();
const endYear = periodEnd.getUTCFullYear();
for (const calendar of calendars) {
for (const entry of calendar.entries) {
const baseDate = new Date(entry.date);
for (let year = startYear; year <= endYear; year += 1) {
const effectiveDate = entry.isRecurringAnnual
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
: baseDate;
const isoDate = toIsoDate(effectiveDate);
if (isoDate >= startIso && isoDate <= endIso) {
dates.add(isoDate);
}
if (!entry.isRecurringAnnual) {
break;
}
}
}
}
return dates;
}
async function loadCustomHolidayDates(
db: ResourceCapacityDbClient,
input: {
periodStart: Date;
periodEnd: Date;
countryId: string | null | undefined;
federalState: string | null | undefined;
metroCityId: string | null | undefined;
},
): Promise<Set<string>> {
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
return new Set();
}
const stateCode = normalizeStateCode(input.federalState);
const metroCityId = input.metroCityId?.trim() || null;
const calendars = await db.holidayCalendar.findMany({
where: {
isActive: true,
countryId: input.countryId,
OR: [
{ scopeType: "COUNTRY" as CalendarScope },
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
],
},
include: { entries: true },
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
});
return resolveCalendarEntryDates(
calendars as HolidayCalendarRecord[],
input.periodStart,
input.periodEnd,
);
}
function buildProfileKey(profile: ResourceCapacityProfile): string {
return JSON.stringify({
countryId: profile.countryId ?? null,
countryCode: profile.countryCode ?? null,
federalState: profile.federalState ?? null,
metroCityId: profile.metroCityId ?? null,
metroCityName: profile.metroCityName ?? null,
});
}
export async function loadResourceDailyAvailabilityContexts(
db: ResourceCapacityDbClient,
resources: ResourceCapacityProfile[],
periodStart: Date,
periodEnd: Date,
): Promise<Map<string, ResourceDailyAvailabilityContext>> {
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
const resourceIds = resources.map((resource) => resource.id);
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
? await db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
})
: [];
const vacationsByResourceId = new Map<string, VacationRecord[]>();
for (const vacation of vacations as VacationRecord[]) {
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
items.push(vacation);
vacationsByResourceId.set(vacation.resourceId, items);
}
const contexts = new Map<string, ResourceDailyAvailabilityContext>();
for (const resource of resources) {
const profileKey = buildProfileKey(resource);
const holidayPromise = profileHolidayCache.get(profileKey)
?? (async () => {
const builtin = listBuiltinHolidayDates({
periodStart,
periodEnd,
countryCode: resource.countryCode,
federalState: resource.federalState,
metroCityName: resource.metroCityName,
});
const custom = await loadCustomHolidayDates(db, {
periodStart,
periodEnd,
countryId: resource.countryId,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
});
return new Set([...builtin, ...custom]);
})();
if (!profileHolidayCache.has(profileKey)) {
profileHolidayCache.set(profileKey, holidayPromise);
}
const holidayDates = new Set(await holidayPromise);
const absenceFractionsByDate = new Map<string, number>();
const vacationFractionsByDate = new Map<string, number>();
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
for (const vacation of resourceVacations) {
const overlapStart = new Date(Math.max(vacation.startDate.getTime(), periodStart.getTime()));
const overlapEnd = new Date(Math.min(vacation.endDate.getTime(), periodEnd.getTime()));
if (overlapStart > overlapEnd) {
continue;
}
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
const fraction = vacation.isHalfDay ? 0.5 : 1;
if (vacation.type === "PUBLIC_HOLIDAY") {
holidayDates.add(isoDate);
}
if (vacation.type !== "PUBLIC_HOLIDAY") {
const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
}
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
for (const isoDate of holidayDates) {
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
}
contexts.set(resource.id, {
absenceFractionsByDate,
holidayDates,
vacationFractionsByDate,
});
}
return contexts;
}
function calculateDayAvailabilityFraction(
context: ResourceDailyAvailabilityContext | undefined,
isoDate: string,
): number {
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
return Math.max(0, 1 - fraction);
}
export function calculateEffectiveDayAvailability(input: {
availability: WeekdayAvailability;
date: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
const baseHours = getAvailabilityHoursForDate(input.availability, input.date);
if (baseHours <= 0) {
return 0;
}
return baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(input.date));
}
export function calculateEffectiveAvailableHours(input: {
availability: WeekdayAvailability;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
let hours = 0;
const cursor = new Date(input.periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
hours += calculateEffectiveDayAvailability({
availability: input.availability,
date: cursor,
context: input.context,
});
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return hours;
}
export function countEffectiveWorkingDays(input: {
availability: WeekdayAvailability;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
let days = 0;
const cursor = new Date(input.periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(input.periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
if (calculateEffectiveDayAvailability({
availability: input.availability,
date: cursor,
context: input.context,
}) > 0) {
days += 1;
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return days;
}
export function calculateEffectiveBookedHours(input: {
availability: WeekdayAvailability;
startDate: Date;
endDate: Date;
hoursPerDay: number;
periodStart: Date;
periodEnd: Date;
context: ResourceDailyAvailabilityContext | undefined;
}): number {
const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime()));
const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime()));
if (overlapStart > overlapEnd) {
return 0;
}
let hours = 0;
const cursor = new Date(overlapStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(overlapEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const dayBaseHours = getAvailabilityHoursForDate(input.availability, cursor);
if (dayBaseHours > 0) {
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return hours;
}
@@ -0,0 +1,102 @@
import { VacationStatus, VacationType } from "@capakraken/db";
import { getResolvedCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
type ResourceHolidayContextDb = {
resource: {
findUnique: (args: any) => any;
};
country?: {
findUnique: (args: any) => any;
};
metroCity?: {
findUnique: (args: any) => any;
};
holidayCalendar?: {
findMany: (args: any) => any;
};
vacation: {
findMany: (args: any) => any;
};
};
export type ResourceHolidayContext = {
countryId?: string | null;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityId?: string | null;
metroCityName?: string | null;
calendarHolidayStrings: string[];
publicHolidayStrings: string[];
};
function clampToDay(value: Date): Date {
const date = new Date(value);
date.setUTCHours(0, 0, 0, 0);
return date;
}
export async function loadResourceHolidayContext(
db: ResourceHolidayContextDb,
resourceId: string,
periodStart: Date,
periodEnd: Date,
): Promise<ResourceHolidayContext> {
const resource = typeof db.resource?.findUnique === "function"
? await db.resource.findUnique({
where: { id: resourceId },
select: {
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
})
: null;
const holidayVacations = typeof db.vacation?.findMany === "function"
? await db.vacation.findMany({
where: {
resourceId,
type: VacationType.PUBLIC_HOLIDAY,
status: VacationStatus.APPROVED,
startDate: { lte: periodEnd },
endDate: { gte: periodStart },
},
select: { startDate: true, endDate: true },
})
: [];
const calendarHolidayStrings = await getResolvedCalendarHolidayStrings(db, {
periodStart,
periodEnd,
countryId: resource?.countryId ?? null,
countryCode: resource?.country?.code ?? null,
federalState: resource?.federalState ?? null,
metroCityId: resource?.metroCityId ?? null,
metroCityName: resource?.metroCity?.name ?? null,
});
const publicHolidayStrings = new Set<string>();
for (const holiday of holidayVacations) {
const cursor = clampToDay(new Date(Math.max(holiday.startDate.getTime(), periodStart.getTime())));
const end = clampToDay(new Date(Math.min(holiday.endDate.getTime(), periodEnd.getTime())));
while (cursor <= end) {
publicHolidayStrings.add(toIsoDate(cursor));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
return {
countryId: resource?.countryId ?? null,
countryCode: resource?.country?.code ?? null,
countryName: resource?.country?.name ?? null,
federalState: resource?.federalState ?? null,
metroCityId: resource?.metroCityId ?? null,
metroCityName: resource?.metroCity?.name ?? null,
calendarHolidayStrings,
publicHolidayStrings: [...publicHolidayStrings].sort(),
};
}
+112
View File
@@ -0,0 +1,112 @@
import { getCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
type VacationSpan = {
startDate: Date;
endDate: Date;
isHalfDay: boolean;
};
type HolidayContext = {
countryCode?: string | null | undefined;
federalState?: string | null | undefined;
metroCityName?: string | null | undefined;
calendarHolidayStrings?: string[] | undefined;
publicHolidayStrings?: string[] | undefined;
};
type CountVacationChargeableDaysInput = HolidayContext & {
vacation: VacationSpan;
periodStart?: Date | undefined;
periodEnd?: Date | undefined;
};
function clampToDay(value: Date): Date {
const date = new Date(value);
date.setUTCHours(0, 0, 0, 0);
return date;
}
function getOverlapRange(
startDate: Date,
endDate: Date,
periodStart?: Date,
periodEnd?: Date,
): { start: Date; end: Date } | null {
const startBoundary = clampToDay(periodStart ?? startDate);
const endBoundary = clampToDay(periodEnd ?? endDate);
const overlapStart = clampToDay(new Date(Math.max(startDate.getTime(), startBoundary.getTime())));
const overlapEnd = clampToDay(new Date(Math.min(endDate.getTime(), endBoundary.getTime())));
if (overlapStart > overlapEnd) {
return null;
}
return { start: overlapStart, end: overlapEnd };
}
export function countCalendarDaysInPeriod(
vacation: VacationSpan,
periodStart?: Date,
periodEnd?: Date,
): number {
const overlap = getOverlapRange(vacation.startDate, vacation.endDate, periodStart, periodEnd);
if (!overlap) {
return 0;
}
if (vacation.isHalfDay) {
return 0.5;
}
const ms = overlap.end.getTime() - overlap.start.getTime();
return Math.round(ms / 86_400_000) + 1;
}
export function countVacationChargeableDays(
input: CountVacationChargeableDaysInput,
): number {
const overlap = getOverlapRange(
input.vacation.startDate,
input.vacation.endDate,
input.periodStart,
input.periodEnd,
);
if (!overlap) {
return 0;
}
const holidaySet = new Set(
input.calendarHolidayStrings
? input.calendarHolidayStrings.filter((isoDate) => isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end))
: getCalendarHolidayStrings(
overlap.start,
overlap.end,
input.countryCode,
input.federalState,
input.metroCityName,
),
);
for (const isoDate of input.publicHolidayStrings ?? []) {
if (isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end)) {
holidaySet.add(isoDate);
}
}
if (input.vacation.isHalfDay) {
return holidaySet.has(toIsoDate(overlap.start)) ? 0 : 0.5;
}
let total = 0;
const cursor = new Date(overlap.start);
while (cursor <= overlap.end) {
if (!holidaySet.has(toIsoDate(cursor))) {
total += 1;
}
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return total;
}
+69 -37
View File
@@ -21,6 +21,7 @@ import {
FillDemandRequirementSchema,
FillOpenDemandByAllocationSchema,
PermissionKey,
type WeekdayAvailability,
UpdateAssignmentSchema,
UpdateAllocationSchema,
UpdateDemandRequirementSchema,
@@ -34,6 +35,13 @@ import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
import { invalidateDashboardCache } from "../lib/cache.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
@@ -328,12 +336,26 @@ export const allocationRouter = createTRPCRouter({
where: { id: input.resourceId },
select: {
id: true, displayName: true, eid: true, fte: true,
country: { select: { dailyWorkingHours: true } },
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { dailyWorkingHours: true, code: true } },
metroCity: { select: { name: true } },
},
});
if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
const dailyCapacity = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
const availability = (resource.availability as WeekdayAvailability | null) ?? {
monday: fallbackDailyHours,
tuesday: fallbackDailyHours,
wednesday: fallbackDailyHours,
thursday: fallbackDailyHours,
friday: fallbackDailyHours,
saturday: 0,
sunday: 0,
};
// Get existing assignments in the date range
const existingAssignments = await ctx.db.assignment.findMany({
@@ -350,19 +372,29 @@ export const allocationRouter = createTRPCRouter({
orderBy: { startDate: "asc" },
});
// Get vacations in the date range
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
status: "APPROVED",
startDate: { lte: input.endDate },
endDate: { gte: input.startDate },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
availability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
input.startDate,
input.endDate,
);
const context = contexts.get(resource.id);
// Calculate day-by-day availability
let totalWorkingDays = 0;
const totalWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
let availableDays = 0;
let conflictDays = 0;
let partialDays = 0;
@@ -372,36 +404,27 @@ export const allocationRouter = createTRPCRouter({
const d = new Date(input.startDate);
const end = new Date(input.endDate);
while (d <= end) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) {
totalWorkingDays++;
// Check vacation
const isVacation = vacations.some((v) => {
const vs = new Date(v.startDate); vs.setHours(0, 0, 0, 0);
const ve = new Date(v.endDate); ve.setHours(0, 0, 0, 0);
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
return dc >= vs && dc <= ve;
const effectiveDayCapacity = calculateEffectiveDayAvailability({
availability,
date: d,
context,
});
if (isVacation) {
conflictDays++;
d.setDate(d.getDate() + 1);
continue;
}
// Sum existing hours on this day
if (effectiveDayCapacity > 0) {
let bookedHours = 0;
for (const a of existingAssignments) {
const as2 = new Date(a.startDate); as2.setHours(0, 0, 0, 0);
const ae = new Date(a.endDate); ae.setHours(0, 0, 0, 0);
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
if (dc >= as2 && dc <= ae) {
bookedHours += a.hoursPerDay;
}
bookedHours += calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart: d,
periodEnd: d,
context,
});
}
const remainingCapacity = Math.max(0, dailyCapacity - bookedHours);
const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours);
if (remainingCapacity >= requestedHpd) {
availableDays++;
totalAvailableHours += requestedHpd;
@@ -416,6 +439,15 @@ export const allocationRouter = createTRPCRouter({
}
const totalRequestedHours = totalWorkingDays * requestedHpd;
const totalPeriodCapacity = calculateEffectiveAvailableHours({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
const dailyCapacity = totalWorkingDays > 0
? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10
: 0;
return {
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
@@ -0,0 +1,243 @@
export interface AssistantInsightMetric {
label: string;
value: string;
tone?: "neutral" | "good" | "warn" | "danger" | "info";
}
export interface AssistantInsightSection {
title: string;
metrics: AssistantInsightMetric[];
}
export interface AssistantInsight {
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
title: string;
subtitle?: string;
metrics: AssistantInsightMetric[];
sections?: AssistantInsightSection[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
function asNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function formatHours(value: unknown): string | null {
const num = asNumber(value);
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} h`;
}
function formatDays(value: unknown): string | null {
const num = asNumber(value);
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} d`;
}
function pushMetric(
metrics: AssistantInsightMetric[],
label: string,
value: string | null,
tone?: AssistantInsightMetric["tone"],
) {
if (!value) return;
metrics.push({ label, value, ...(tone ? { tone } : {}) });
}
function createLocationLabel(locationContext: Record<string, unknown> | undefined): string | null {
if (!locationContext) return null;
const parts = [
asString(locationContext.metroCity),
asString(locationContext.federalState),
asString(locationContext.country),
asString(locationContext.countryCode),
].filter(Boolean);
return parts.length > 0 ? parts.join(", ") : null;
}
function buildChargeabilityInsight(data: Record<string, unknown>): AssistantInsight | null {
const resource = asString(data.resource);
const month = asString(data.month);
if (!resource || !month) return null;
const holidaySummary = isRecord(data.holidaySummary) ? data.holidaySummary : undefined;
const absenceSummary = isRecord(data.absenceSummary) ? data.absenceSummary : undefined;
const capacityBreakdown = isRecord(data.capacityBreakdown) ? data.capacityBreakdown : undefined;
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
const chargeabilityPct = asNumber(data.chargeabilityPct);
const targetPct = asNumber(data.targetPct);
const metrics: AssistantInsightMetric[] = [];
pushMetric(metrics, "Chargeability", asString(data.chargeability), chargeabilityPct == null || targetPct == null
? "info"
: chargeabilityPct >= targetPct ? "good" : "warn");
pushMetric(metrics, "Available", formatHours(data.availableHours));
pushMetric(metrics, "Booked", formatHours(data.bookedHours));
pushMetric(metrics, "Unassigned", formatHours(data.unassignedHours));
pushMetric(metrics, "Target", formatHours(data.targetHours));
pushMetric(metrics, "Holidays", formatDays(holidaySummary?.workdayCount ?? holidaySummary?.count));
const sections: AssistantInsightSection[] = [];
const basisMetrics: AssistantInsightMetric[] = [];
pushMetric(basisMetrics, "Location", createLocationLabel(locationContext), "info");
pushMetric(basisMetrics, "Base working days", formatDays(data.baseWorkingDays));
pushMetric(basisMetrics, "Effective working days", formatDays(data.workingDays));
pushMetric(basisMetrics, "Base capacity", formatHours(data.baseAvailableHours));
if (basisMetrics.length > 0) {
sections.push({ title: "Basis", metrics: basisMetrics });
}
const deductionMetrics: AssistantInsightMetric[] = [];
pushMetric(deductionMetrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction ?? capacityBreakdown?.holidayHoursDeduction), "warn");
pushMetric(deductionMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
pushMetric(deductionMetrics, "Absence days", formatDays(absenceSummary?.dayEquivalent));
if (deductionMetrics.length > 0) {
sections.push({ title: "Deductions", metrics: deductionMetrics });
}
return {
kind: "chargeability",
title: `${resource} · ${month}`,
subtitle: "Holiday-aware monthly capacity",
metrics,
...(sections.length > 0 ? { sections } : {}),
};
}
function buildHolidayRegionInsight(data: Record<string, unknown>): AssistantInsight | null {
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
const periodStart = asString(data.periodStart);
const periodEnd = asString(data.periodEnd);
const metrics: AssistantInsightMetric[] = [];
pushMetric(metrics, "Region", createLocationLabel(locationContext), "info");
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
const summary = isRecord(data.summary) ? data.summary : undefined;
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
const scopeMetrics = scopeItems
.map((item) => {
if (!isRecord(item)) return null;
const scope = asString(item.scope);
const count = asNumber(item.count);
if (!scope || count == null) return null;
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
})
.filter((item): item is AssistantInsightMetric => item !== null);
return {
kind: "holiday_region",
title: createLocationLabel(locationContext) ?? "Regional holidays",
subtitle: "Resolved public holiday set",
metrics,
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
};
}
function buildResourceHolidayInsight(data: Record<string, unknown>): AssistantInsight | null {
const resource = isRecord(data.resource) ? data.resource : undefined;
const summary = isRecord(data.summary) ? data.summary : undefined;
const periodStart = asString(data.periodStart);
const periodEnd = asString(data.periodEnd);
const metrics: AssistantInsightMetric[] = [];
pushMetric(metrics, "Employee", asString(resource?.name) ?? asString(resource?.eid));
pushMetric(metrics, "Location", createLocationLabel(resource), "info");
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
const scopeMetrics = scopeItems
.map((item) => {
if (!isRecord(item)) return null;
const scope = asString(item.scope);
const count = asNumber(item.count);
if (!scope || count == null) return null;
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
})
.filter((item): item is AssistantInsightMetric => item !== null);
return {
kind: "resource_holidays",
title: `${asString(resource?.name) ?? "Resource"} holidays`,
subtitle: "Location-specific holiday resolution",
metrics,
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
};
}
function buildResourceMatchInsight(data: Record<string, unknown>): AssistantInsight | null {
const project = isRecord(data.project) ? data.project : undefined;
const period = isRecord(data.period) ? data.period : undefined;
const bestMatch = isRecord(data.bestMatch) ? data.bestMatch : undefined;
if (!project || !period || !bestMatch) return null;
const remainingHours = asNumber(bestMatch.remainingHours);
const remainingHoursPerDay = asNumber(bestMatch.remainingHoursPerDay);
const lcr = asString(bestMatch.lcr);
const holidaySummary = isRecord(bestMatch.holidaySummary) ? bestMatch.holidaySummary : undefined;
const absenceSummary = isRecord(bestMatch.absenceSummary) ? bestMatch.absenceSummary : undefined;
const capacityBreakdown = isRecord(bestMatch.capacityBreakdown) ? bestMatch.capacityBreakdown : undefined;
const metrics: AssistantInsightMetric[] = [];
pushMetric(metrics, "Best match", asString(bestMatch.name) ?? asString(bestMatch.eid), "good");
pushMetric(metrics, "Project", asString(project.name) ?? asString(project.shortCode));
pushMetric(metrics, "Remaining", formatHours(remainingHours), remainingHours != null && remainingHours > 0 ? "good" : "warn");
pushMetric(metrics, "Per workday", formatHours(remainingHoursPerDay));
pushMetric(metrics, "LCR", lcr);
pushMetric(metrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction), "warn");
const sections: AssistantInsightSection[] = [];
const profileMetrics: AssistantInsightMetric[] = [];
pushMetric(profileMetrics, "Role", asString(bestMatch.role));
pushMetric(profileMetrics, "Chapter", asString(bestMatch.chapter));
pushMetric(profileMetrics, "Location", createLocationLabel(bestMatch), "info");
pushMetric(profileMetrics, "Candidate pool", asNumber(data.candidateCount)?.toString() ?? null);
if (profileMetrics.length > 0) {
sections.push({ title: "Selection", metrics: profileMetrics });
}
const basisMetrics: AssistantInsightMetric[] = [];
pushMetric(basisMetrics, "Window", asString(period.startDate) && asString(period.endDate) ? `${asString(period.startDate)} to ${asString(period.endDate)}` : null);
pushMetric(basisMetrics, "Ranking", asString(period.rankingMode));
pushMetric(basisMetrics, "Min/day", formatHours(period.minHoursPerDay));
pushMetric(basisMetrics, "Base capacity", formatHours(capacityBreakdown?.baseAvailableHours ?? bestMatch.baseAvailableHours));
pushMetric(basisMetrics, "Effective capacity", formatHours(bestMatch.availableHours));
pushMetric(basisMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
if (basisMetrics.length > 0) {
sections.push({ title: "Capacity basis", metrics: basisMetrics });
}
return {
kind: "resource_match",
title: `${asString(project.shortCode) ?? asString(project.name) ?? "Project"} staffing`,
subtitle: "Holiday-aware best-fit resource",
metrics,
...(sections.length > 0 ? { sections } : {}),
};
}
export function buildAssistantInsight(toolName: string, data: unknown): AssistantInsight | null {
if (!isRecord(data)) return null;
switch (toolName) {
case "get_chargeability":
return buildChargeabilityInsight(data);
case "find_best_project_resource":
return buildResourceMatchInsight(data);
case "list_holidays_by_region":
return buildHolidayRegionInsight(data);
case "get_resource_holidays":
return buildResourceHolidayInsight(data);
default:
return null;
}
}
File diff suppressed because it is too large Load Diff
+51 -20
View File
@@ -5,10 +5,11 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
import { checkPromptInjection } from "../lib/prompt-guard.js";
import { checkAiOutput } from "../lib/content-filter.js";
import { createAuditEntry } from "../lib/audit.js";
@@ -20,7 +21,7 @@ const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-A
Deine Fähigkeiten:
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
- Chargeability-Analysen, Urlaubsübersichten, Feiertagskalender nach Land/Bundesland/Stadt, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
@@ -40,6 +41,12 @@ Wichtige Regeln:
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
- Fasse Ergebnisse kompakt zusammen keine unnötigen Wiederholungen der Tool-Ergebnisse
- Wenn Feiertage, SAH, Chargeability, Verfügbarkeit oder Ressourcenauswahl relevant sind, erkläre IMMER transparent:
1. Standortkontext (Land/Bundesland/Stadt falls relevant)
2. Feiertagsbasis bzw. Feiertagsanzahl
3. Abzüge durch Feiertage/Abwesenheiten
4. resultierende verfügbare Stunden / Zielstunden / Restkapazität
- Wenn strukturierte UI-Karten vorhanden sind, wiederhole dort gezeigte Zahlen NICHT vollständig im Freitext. Gib nur die Kernaussage und die wichtigste Begründung an.
- Wenn eine Suche keine Treffer ergibt, versuche einzelne Wörter aus der Anfrage als Suchbegriffe. Die Tools unterstützen automatisch wort-basierte Fuzzy-Suche zeige dem User die Vorschläge wenn welche gefunden werden
Datenmodell:
@@ -48,10 +55,12 @@ Datenmodell:
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
- Chargeability = gebuchte/verfügbare Stunden × 100%
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
`;
/** Map tool names to the permission required to use them */
const TOOL_PERMISSION_MAP: Record<string, string> = {
list_users: PermissionKey.MANAGE_USERS,
// Resource management
update_resource: "manageResources",
create_resource: "manageResources",
@@ -89,7 +98,36 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
};
/** Tools that require cost visibility */
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail"]);
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail", "find_best_project_resource"]);
export function getAvailableAssistantTools(permissions: Set<PermissionKey>) {
return TOOL_DEFINITIONS.filter((tool) => {
const toolName = tool.function.name;
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
return false;
}
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
return false;
}
if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
return false;
}
return true;
});
}
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
if (duplicateIndex >= 0) {
const copy = [...existing];
copy[duplicateIndex] = next;
return copy;
}
return [...existing, next].slice(-6);
}
export const assistantRouter = createTRPCRouter({
chat: protectedProcedure
@@ -176,26 +214,12 @@ export const assistantRouter = createTRPCRouter({
}
// 4. Filter tools based on granular permissions
const availableTools = TOOL_DEFINITIONS.filter((t) => {
const toolName = t.function.name;
// Check write permission
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
if (requiredPerm && !permissions.has(requiredPerm as import("@capakraken/shared").PermissionKey)) {
return false;
}
// Hide cost/budget tools if user lacks viewCosts
if (COST_TOOLS.has(toolName) && !permissions.has("viewCosts" as import("@capakraken/shared").PermissionKey)) {
return false;
}
return true;
});
const availableTools = getAvailableAssistantTools(permissions);
// 5. Function calling loop
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
const collectedActions: ToolAction[] = [];
let collectedInsights: AssistantInsight[] = [];
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -240,6 +264,11 @@ export const assistantRouter = createTRPCRouter({
toolCtx,
);
const insight = buildAssistantInsight(toolCall.function.name, result.data);
if (insight) {
collectedInsights = mergeInsights(collectedInsights, insight);
}
// Collect any actions (e.g. navigation)
if (result.action) {
collectedActions.push(result.action);
@@ -298,6 +327,7 @@ export const assistantRouter = createTRPCRouter({
return {
content: finalContent,
role: "assistant" as const,
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}
@@ -306,6 +336,7 @@ export const assistantRouter = createTRPCRouter({
return {
content: "I had to stop after too many tool calls. Please try a simpler question.",
role: "assistant" as const,
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}),
+51 -127
View File
@@ -5,19 +5,18 @@ import {
sumFte,
getMonthRange,
getMonthKeys,
countWorkingDaysInOverlap,
calculateSAH,
calculateAllocation,
DEFAULT_CALCULATION_RULES,
type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
import type { SpainScheduleRule } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { VacationStatus } from "@capakraken/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
@@ -59,6 +58,10 @@ export const chargeabilityReportRouter = createTRPCRouter({
eid: true,
displayName: true,
fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
@@ -90,6 +93,20 @@ export const chargeabilityReportRouter = createTRPCRouter({
endDate: rangeEnd,
resourceIds,
});
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
rangeStart,
rangeEnd,
);
// Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
@@ -118,152 +135,59 @@ export const chargeabilityReportRouter = createTRPCRouter({
},
}));
// Fetch vacations/absences in the range (including type for rules engine)
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: { in: resourceIds },
status: VacationStatus.APPROVED,
startDate: { lte: rangeEnd },
endDate: { gte: rangeStart },
},
select: {
resourceId: true,
startDate: true,
endDate: true,
type: true,
isHalfDay: true,
},
});
// Load calculation rules for chargeability adjustments
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
try {
const dbRules = await ctx.db.calculationRule.findMany({
where: { isActive: true },
orderBy: [{ priority: "desc" }],
});
if (dbRules.length > 0) {
calcRules = dbRules as unknown as CalculationRule[];
}
} catch {
// table may not exist yet
}
// Build per-resource, per-month forecasts
const resourceRows = resources.map((resource) => {
const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
const months = monthKeys.map((key) => {
const months = await Promise.all(monthKeys.map(async (key) => {
const [y, m] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
// Compute absence days for SAH
const absenceDates: string[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
while (cursor <= endNorm) {
absenceDates.push(cursor.toISOString().slice(0, 10));
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
// Calculate SAH for this resource+month
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
fte: resource.fte,
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: monthStart,
periodEnd: monthEnd,
publicHolidays: [], // TODO: integrate public holidays from country
absenceDays: absenceDates,
context,
});
// Build typed absence days for this resource in this month
const monthAbsenceDays: AbsenceDay[] = [];
for (const v of resourceVacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const absCursor = new Date(vStart);
absCursor.setUTCHours(0, 0, 0, 0);
const absEndNorm = new Date(vEnd);
absEndNorm.setUTCHours(0, 0, 0, 0);
const triggerType = v.type === "SICK" ? "SICK" as const
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (absCursor <= absEndNorm) {
monthAbsenceDays.push({
date: new Date(absCursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
});
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
}
}
// Build assignment slices for this month, using rules to compute chargeable hours
const slices: AssignmentSlice[] = [];
for (const a of resourceAssignments) {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
// If there are absences and rules, compute rules-adjusted chargeable hours
if (monthAbsenceDays.length > 0) {
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const calcResult = calculateAllocation({
lcrCents: 0, // we only need hours, not costs
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
absenceDays: monthAbsenceDays,
calculationRules: calcRules,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
slices.push({
return {
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
workingDays: 0,
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
totalChargeableHours,
};
});
} else {
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
});
}
}
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
sah: availableHours,
});
return {
monthKey: key,
sah: sahResult.standardAvailableHours,
sah: availableHours,
...forecast,
};
});
}));
return {
id: resource.id,
@@ -278,7 +202,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
targetPct,
months,
};
});
}));
// Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => {
+205 -70
View File
@@ -4,18 +4,27 @@ import {
deriveResourceForecast,
computeBudgetStatus,
getMonthRange,
countWorkingDaysInOverlap,
DEFAULT_CALCULATION_RULES,
summarizeEstimateDemandLines,
computeEvenSpread,
distributeHoursToWeeks,
type AssignmentSlice,
} from "@capakraken/engine";
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import { VacationStatus } from "@capakraken/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.js";
import {
asHolidayResolverDb,
collectHolidayAvailability,
getResolvedCalendarHolidays,
} from "../lib/holiday-availability.js";
import {
calculateEffectiveAvailableHours,
countEffectiveWorkingDays,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
// ─── Graph Types (mirrored from client for API response) ────────────────────
@@ -62,6 +71,21 @@ function fmtNum(v: number, decimals = 1): string {
return v.toFixed(decimals);
}
function getAvailabilityHoursForDate(
availability: WeekdayAvailability,
date: Date,
): number {
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
return availability[dayKey] ?? 0;
}
function sumAvailabilityHoursForDates(
availability: WeekdayAvailability,
dates: Date[],
): number {
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
}
// ─── Router ─────────────────────────────────────────────────────────────────
export const computationGraphRouter = createTRPCRouter({
@@ -88,8 +112,12 @@ export const computationGraphRouter = createTRPCRouter({
fte: true,
lcrCents: true,
chargeabilityTarget: true,
countryId: true,
federalState: true,
metroCityId: true,
availability: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
metroCity: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
},
});
@@ -133,7 +161,7 @@ export const computationGraphRouter = createTRPCRouter({
},
});
// ── 3. Load absences ──
// ── 3. Load absences + holiday context ──
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
@@ -143,45 +171,47 @@ export const computationGraphRouter = createTRPCRouter({
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
// Build absence dates for SAH (ISO strings), separating public holidays
const publicHolidayStrings: string[] = [];
const absenceDateStrings: string[] = [];
const absenceDays: AbsenceDay[] = [];
let halfDayCount = 0;
let vacationDayCount = 0;
let sickDayCount = 0;
let publicHolidayCount = 0;
for (const v of vacations) {
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
if (vStart > vEnd) continue;
const cursor = new Date(vStart);
cursor.setUTCHours(0, 0, 0, 0);
const endNorm = new Date(vEnd);
endNorm.setUTCHours(0, 0, 0, 0);
const triggerType = v.type === "SICK" ? "SICK" as const
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
: "VACATION" as const;
while (cursor <= endNorm) {
const isoDate = cursor.toISOString().slice(0, 10);
if (triggerType === "PUBLIC_HOLIDAY") {
publicHolidayStrings.push(isoDate);
publicHolidayCount++;
} else {
absenceDateStrings.push(isoDate);
if (triggerType === "VACATION") vacationDayCount++;
if (triggerType === "SICK") sickDayCount++;
}
absenceDays.push({
date: new Date(cursor),
type: triggerType,
...(v.isHalfDay ? { isHalfDay: true } : {}),
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: monthStart,
periodEnd: monthEnd,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
});
if (v.isHalfDay) halfDayCount++;
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
const holidayAvailability = collectHolidayAvailability({
vacations,
periodStart: monthStart,
periodEnd: monthEnd,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name,
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
});
const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
const absenceDateStrings = holidayAvailability.absenceDateStrings;
const absenceDays = holidayAvailability.absenceDays;
const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
const publicHolidayCount = resolvedHolidays.length;
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
[{
id: resource.id,
availability: weeklyAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
}],
monthStart,
monthEnd,
);
const availabilityContext = contexts.get(resource.id);
// ── 4. Load calculation rules ──
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
@@ -197,7 +227,7 @@ export const computationGraphRouter = createTRPCRouter({
// table may not exist yet
}
// ── 5. Calculate SAH ──
// ── 5. Calculate SAH / effective capacity ──
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
@@ -207,6 +237,60 @@ export const computationGraphRouter = createTRPCRouter({
publicHolidays: publicHolidayStrings,
absenceDays: absenceDateStrings,
});
const baseWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: undefined,
});
const effectiveAvailableHours = calculateEffectiveAvailableHours({
availability: weeklyAvailability,
periodStart: monthStart,
periodEnd: monthEnd,
context: availabilityContext,
});
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
weeklyAvailability,
publicHolidayDates,
);
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
if (absence.type === "PUBLIC_HOLIDAY") {
return sum;
}
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
}, 0);
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
? effectiveAvailableHours / effectiveWorkingDays
: 0;
const holidayScopeSummary = [
resource.country?.code ?? "—",
resource.federalState ?? "—",
resource.metroCity?.name ?? "—",
].join(" / ");
const holidayExamples = resolvedHolidays.length > 0
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
: "none";
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
return counts;
}, {});
// ── 6. Calculate allocations + chargeability slices ──
const slices: AssignmentSlice[] = [];
@@ -217,9 +301,6 @@ export const computationGraphRouter = createTRPCRouter({
let hasRulesEffect = false;
for (const a of assignments) {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
if (workingDays <= 0) continue;
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
@@ -233,6 +314,7 @@ export const computationGraphRouter = createTRPCRouter({
absenceDays,
calculationRules: calcRules,
});
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
totalAllocHours += calcResult.totalHours;
totalAllocCostCents += calcResult.totalCostCents;
@@ -247,7 +329,7 @@ export const computationGraphRouter = createTRPCRouter({
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
workingDays: calcResult.workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours }
@@ -260,7 +342,7 @@ export const computationGraphRouter = createTRPCRouter({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
sah: sahResult.standardAvailableHours,
sah: effectiveAvailableHours,
});
// ── 8. Build budget status for first project with budget ──
@@ -319,7 +401,18 @@ export const computationGraphRouter = createTRPCRouter({
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
: 0;
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const calcResult = calculateAllocation({
lcrCents: resource.lcrCents,
hoursPerDay: a.hoursPerDay,
startDate: overlapStart,
endDate: overlapEnd,
availability: weeklyAvailability,
absenceDays,
calculationRules: calcRules,
});
return sum + calcResult.workingDays;
}, 0);
// Format weekly availability for display
@@ -332,9 +425,10 @@ export const computationGraphRouter = createTRPCRouter({
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
// Derived utilization ratio
const utilizationPct = sahResult.standardAvailableHours > 0
? (totalAllocHours / sahResult.standardAvailableHours) * 100
const utilizationPct = effectiveAvailableHours > 0
? (totalAllocHours / effectiveAvailableHours) * 100
: 0;
const chargeableHours = forecast.chg * effectiveAvailableHours;
// Has schedule rules (Spain variable hours)?
const hasScheduleRules = !!scheduleRules;
@@ -342,6 +436,11 @@ export const computationGraphRouter = createTRPCRouter({
const nodes: GraphNode[] = [
// INPUT
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0),
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
...(hasScheduleRules ? [
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
@@ -350,7 +449,7 @@ export const computationGraphRouter = createTRPCRouter({
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0),
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0),
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
@@ -358,12 +457,15 @@ export const computationGraphRouter = createTRPCRouter({
// SAH
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"),
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),
// ALLOCATION
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
@@ -387,24 +489,24 @@ export const computationGraphRouter = createTRPCRouter({
] : []),
// CHARGEABILITY — full breakdown from deriveResourceForecast
n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
...(forecast.bd > 0 ? [
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"),
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"),
] : []),
...(forecast.mdi > 0 ? [
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"),
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"),
] : []),
...(forecast.mo > 0 ? [
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"),
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"),
] : []),
...(forecast.pdr > 0 ? [
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"),
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"),
] : []),
...(forecast.absence > 0 ? [
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"),
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"),
] : []),
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability target"),
@@ -414,7 +516,16 @@ export const computationGraphRouter = createTRPCRouter({
const links: GraphLink[] = [
// INPUT → SAH
l("input.country", "input.holidayContext", "holiday base", 1),
l("input.state", "input.holidayContext", "regional scope", 1),
l("input.city", "input.holidayContext", "local scope", 1),
l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1),
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2),
l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2),
l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
...(hasScheduleRules ? [
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
] : []),
@@ -422,14 +533,14 @@ export const computationGraphRouter = createTRPCRouter({
l("sah.weekendDays", "sah.grossWorkingDays", "", 1),
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
l("sah.grossWorkingDays", "sah.netWorkingDays", "", 2),
l("sah.grossWorkingDays", "sah.netWorkingDays", " holiday/absence days", 2),
l("sah.publicHolidayDays", "sah.netWorkingDays", "", 1),
l("sah.absenceDays", "sah.netWorkingDays", "", 1),
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
l("sah.baseHours", "sah.sah", "start from base capacity", 2),
l("sah.publicHolidayHours", "sah.sah", " holiday hours", 2),
l("sah.absenceHours", "sah.sah", " absence hours", 2),
l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1),
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
l("sah.netWorkingDays", "sah.sah", "×", 2),
// INPUT → ALLOCATION
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
@@ -489,6 +600,30 @@ export const computationGraphRouter = createTRPCRouter({
resourceEid: resource.eid,
month: input.month,
assignmentCount: assignments.length,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
resolvedHolidays: resolvedHolidays.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scope: holiday.scope,
calendarName: holiday.calendarName,
})),
factors: {
weeklyAvailability,
baseWorkingDays,
effectiveWorkingDays,
baseAvailableHours,
effectiveAvailableHours,
publicHolidayCount,
publicHolidayWorkdayCount,
publicHolidayHoursDeduction,
absenceDayCount: absenceDateStrings.length,
absenceHoursDeduction,
chargeableHours,
utilizationPct,
},
},
};
}),
+83 -17
View File
@@ -9,19 +9,19 @@ import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
/**
* Count calendar days between two dates (inclusive).
* Half-day vacations count as 0.5.
*/
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
if (isHalfDay) return 0.5;
const ms = endDate.getTime() - startDate.getTime();
return Math.round(ms / 86_400_000) + 1;
}
type EntitlementSnapshot = {
id: string;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
};
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
@@ -61,6 +61,14 @@ async function getOrCreateEntitlement(
return entitlement;
}
function calculateCarryoverDays(entitlement: {
entitledDays: number;
usedDays: number;
pendingDays: number;
}): number {
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
@@ -69,14 +77,57 @@ async function syncEntitlement(
resourceId: string,
year: number,
defaultDays: number,
) {
visitedYears: Set<number> = new Set(),
): Promise<EntitlementSnapshot> {
if (visitedYears.has(year)) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Detected recursive entitlement sync for year ${year}`,
});
}
visitedYears.add(year);
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
if (previousYearEntitlement) {
previousYearEntitlement = await syncEntitlement(
db,
resourceId,
year - 1,
defaultDays,
visitedYears,
);
}
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
const carryoverDays = previousYearEntitlement
? calculateCarryoverDays(previousYearEntitlement)
: 0;
const expectedEntitledDays = defaultDays + carryoverDays;
const entitlementWithCarryover = (
entitlement.carryoverDays !== carryoverDays
|| entitlement.entitledDays !== expectedEntitledDays
)
? await db.vacationEntitlement.update({
where: { id: entitlement.id },
data: {
carryoverDays,
entitledDays: expectedEntitledDays,
},
})
: entitlement;
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
startDate: { lte: yearEnd },
endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
@@ -86,13 +137,22 @@ async function syncEntitlement(
let pendingDays = 0;
for (const v of vacations) {
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
const days = countVacationChargeableDays({
vacation: v,
periodStart: yearStart,
periodEnd: yearEnd,
countryCode: holidayContext.countryCode,
federalState: holidayContext.federalState,
metroCityName: holidayContext.metroCityName,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days;
}
return db.vacationEntitlement.update({
where: { id: entitlement.id },
where: { id: entitlementWithCarryover.id },
data: { usedDays, pendingDays },
});
}
@@ -134,17 +194,23 @@ export const entitlementRouter = createTRPCRouter({
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
const sickVacations = await ctx.db.vacation.findMany({
const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
(sum, v) => sum + countCalendarDaysInPeriod(
v,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
@@ -171,7 +237,7 @@ export const entitlementRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
}),
/**
+471
View File
@@ -0,0 +1,471 @@
import {
CreateHolidayCalendarEntrySchema,
CreateHolidayCalendarSchema,
type HolidayCalendarScopeInput,
PreviewResolvedHolidaysSchema,
UpdateHolidayCalendarEntrySchema,
UpdateHolidayCalendarSchema,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { createAuditEntry } from "../lib/audit.js";
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
type HolidayCalendarScope = HolidayCalendarScopeInput;
const HOLIDAY_SCOPE = {
COUNTRY: "COUNTRY",
STATE: "STATE",
CITY: "CITY",
} as const satisfies Record<HolidayCalendarScope, HolidayCalendarScope>;
type HolidayCalendarDb = TRPCContext["db"] & {
holidayCalendar: {
findFirst: (args: unknown) => Promise<{ id: string } | null>;
findMany: (args: unknown) => Promise<any[]>;
findUnique: (args: unknown) => Promise<any | null>;
create: (args: unknown) => Promise<any>;
update: (args: unknown) => Promise<any>;
delete: (args: unknown) => Promise<any>;
};
holidayCalendarEntry: {
findFirst: (args: unknown) => Promise<{ id: string } | null>;
findUnique: (args: unknown) => Promise<any | null>;
create: (args: unknown) => Promise<any>;
update: (args: unknown) => Promise<any>;
delete: (args: unknown) => Promise<any>;
};
};
function asHolidayCalendarDb(db: TRPCContext["db"]): HolidayCalendarDb {
return db as unknown as HolidayCalendarDb;
}
function clampDate(date: Date): Date {
const value = new Date(date);
value.setUTCHours(0, 0, 0, 0);
return value;
}
async function assertEntryDateAvailable(
db: HolidayCalendarDb,
input: {
holidayCalendarId: string;
date: Date;
},
ignoreId?: string,
) {
const existing = await db.holidayCalendarEntry.findFirst({
where: {
holidayCalendarId: input.holidayCalendarId,
date: clampDate(input.date),
...(ignoreId ? { id: { not: ignoreId } } : {}),
},
select: { id: true },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "A holiday entry for this calendar and date already exists",
});
}
}
async function assertScopeConsistency(
db: HolidayCalendarDb,
input: {
scopeType: HolidayCalendarScope;
countryId: string;
stateCode?: string | null;
metroCityId?: string | null;
},
ignoreId?: string,
) {
if (input.scopeType === HOLIDAY_SCOPE.COUNTRY) {
if (input.stateCode || input.metroCityId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Country calendars may not define a state or metro city",
});
}
}
if (input.scopeType === HOLIDAY_SCOPE.STATE) {
if (!input.stateCode) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "State calendars require a state code",
});
}
if (input.metroCityId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "State calendars may not define a metro city",
});
}
}
if (input.scopeType === HOLIDAY_SCOPE.CITY) {
if (!input.metroCityId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "City calendars require a metro city",
});
}
const metroCity = await findUniqueOrThrow(
db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { id: true, countryId: true },
}),
"Metro city",
);
if (metroCity.countryId !== input.countryId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Metro city must belong to the selected country",
});
}
}
const existing = await db.holidayCalendar.findFirst({
where: {
countryId: input.countryId,
scopeType: input.scopeType,
...(input.scopeType === HOLIDAY_SCOPE.STATE ? { stateCode: input.stateCode ?? null } : {}),
...(input.scopeType === HOLIDAY_SCOPE.CITY ? { metroCityId: input.metroCityId ?? null } : {}),
...(ignoreId ? { id: { not: ignoreId } } : {}),
},
select: { id: true },
});
if (existing) {
throw new TRPCError({
code: "CONFLICT",
message: "A holiday calendar for this exact scope already exists",
});
}
}
export const holidayCalendarRouter = createTRPCRouter({
listCalendars: protectedProcedure
.input(z.object({ includeInactive: z.boolean().optional() }).optional())
.query(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const where = input?.includeInactive ? undefined : { isActive: true };
return db.holidayCalendar.findMany({
...(where ? { where } : {}),
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
_count: { select: { entries: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
orderBy: [
{ country: { name: "asc" } },
{ scopeType: "asc" },
{ priority: "desc" },
{ name: "asc" },
],
});
}),
getCalendarById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
return findUniqueOrThrow(
db.holidayCalendar.findUnique({
where: { id: input.id },
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
}),
"Holiday calendar",
);
}),
createCalendar: adminProcedure
.input(CreateHolidayCalendarSchema)
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { id: true, name: true },
}),
"Country",
);
await assertScopeConsistency(db, {
scopeType: input.scopeType,
countryId: input.countryId,
stateCode: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
});
const created = await db.holidayCalendar.create({
data: {
name: input.name,
scopeType: input.scopeType,
countryId: input.countryId,
...(input.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}),
...(input.metroCityId ? { metroCityId: input.metroCityId } : {}),
isActive: input.isActive ?? true,
priority: input.priority ?? 0,
},
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: true,
},
});
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendar",
entityId: created.id,
entityName: created.name,
action: "CREATE",
userId: ctx.dbUser?.id,
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
}),
updateCalendar: adminProcedure
.input(z.object({ id: z.string(), data: UpdateHolidayCalendarSchema }))
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendar.findUnique({ where: { id: input.id } }),
"Holiday calendar",
);
const stateCode = input.data.stateCode === undefined
? existing.stateCode
: input.data.stateCode?.trim().toUpperCase() ?? null;
const metroCityId = input.data.metroCityId === undefined
? existing.metroCityId
: input.data.metroCityId ?? null;
await assertScopeConsistency(db, {
scopeType: existing.scopeType,
countryId: existing.countryId,
stateCode,
metroCityId,
}, existing.id);
const updated = await db.holidayCalendar.update({
where: { id: input.id },
data: {
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.stateCode !== undefined ? { stateCode } : {}),
...(input.data.metroCityId !== undefined ? { metroCityId } : {}),
...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
...(input.data.priority !== undefined ? { priority: input.data.priority } : {}),
},
include: {
country: { select: { id: true, code: true, name: true } },
metroCity: { select: { id: true, name: true } },
entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
},
});
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendar",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
deleteCalendar: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendar.findUnique({
where: { id: input.id },
include: { entries: true },
}),
"Holiday calendar",
);
await db.holidayCalendar.delete({ where: { id: input.id } });
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendar",
entityId: existing.id,
entityName: existing.name,
action: "DELETE",
userId: ctx.dbUser?.id,
before: existing as unknown as Record<string, unknown>,
source: "ui",
});
return { success: true };
}),
createEntry: adminProcedure
.input(CreateHolidayCalendarEntrySchema)
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
await findUniqueOrThrow(
db.holidayCalendar.findUnique({
where: { id: input.holidayCalendarId },
select: { id: true, name: true },
}),
"Holiday calendar",
);
await assertEntryDateAvailable(db, {
holidayCalendarId: input.holidayCalendarId,
date: input.date,
});
const created = await db.holidayCalendarEntry.create({
data: {
holidayCalendarId: input.holidayCalendarId,
date: clampDate(input.date),
name: input.name,
isRecurringAnnual: input.isRecurringAnnual ?? false,
...(input.source ? { source: input.source } : {}),
},
});
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendarEntry",
entityId: created.id,
entityName: created.name,
action: "CREATE",
userId: ctx.dbUser?.id,
after: created as unknown as Record<string, unknown>,
source: "ui",
});
return created;
}),
updateEntry: adminProcedure
.input(z.object({ id: z.string(), data: UpdateHolidayCalendarEntrySchema }))
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
"Holiday calendar entry",
);
const nextDate = input.data.date !== undefined ? clampDate(input.data.date) : existing.date;
await assertEntryDateAvailable(db, {
holidayCalendarId: existing.holidayCalendarId,
date: nextDate,
}, existing.id);
const updated = await db.holidayCalendarEntry.update({
where: { id: input.id },
data: {
...(input.data.date !== undefined ? { date: nextDate } : {}),
...(input.data.name !== undefined ? { name: input.data.name } : {}),
...(input.data.isRecurringAnnual !== undefined ? { isRecurringAnnual: input.data.isRecurringAnnual } : {}),
...(input.data.source !== undefined ? { source: input.data.source ?? null } : {}),
},
});
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendarEntry",
entityId: updated.id,
entityName: updated.name,
action: "UPDATE",
userId: ctx.dbUser?.id,
before: existing as unknown as Record<string, unknown>,
after: updated as unknown as Record<string, unknown>,
source: "ui",
});
return updated;
}),
deleteEntry: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const db = asHolidayCalendarDb(ctx.db);
const existing = await findUniqueOrThrow<any>(
db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
"Holiday calendar entry",
);
await db.holidayCalendarEntry.delete({ where: { id: input.id } });
void createAuditEntry({
db: ctx.db,
entityType: "HolidayCalendarEntry",
entityId: existing.id,
entityName: existing.name,
action: "DELETE",
userId: ctx.dbUser?.id,
before: existing as unknown as Record<string, unknown>,
source: "ui",
});
return { success: true };
}),
previewResolvedHolidays: protectedProcedure
.input(PreviewResolvedHolidaysSchema)
.query(async ({ ctx, input }) => {
const country = await findUniqueOrThrow(
ctx.db.country.findUnique({
where: { id: input.countryId },
select: { code: true },
}),
"Country",
);
const metroCity = input.metroCityId
? await ctx.db.metroCity.findUnique({
where: { id: input.metroCityId },
select: { name: true },
})
: null;
const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
countryId: input.countryId,
countryCode: country.code,
federalState: input.stateCode?.trim().toUpperCase() ?? null,
metroCityId: input.metroCityId ?? null,
metroCityName: metroCity?.name ?? null,
});
return resolved.map((holiday) => ({
date: holiday.date,
name: holiday.name,
scopeType: holiday.scope,
calendarName: holiday.calendarName,
}));
}),
});
+2
View File
@@ -15,6 +15,7 @@ import { effortRuleRouter } from "./effort-rule.js";
import { experienceMultiplierRouter } from "./experience-multiplier.js";
import { estimateRouter } from "./estimate.js";
import { entitlementRouter } from "./entitlement.js";
import { holidayCalendarRouter } from "./holiday-calendar.js";
import { importExportRouter } from "./import-export.js";
import { insightsRouter } from "./insights.js";
import { managementLevelRouter } from "./management-level.js";
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
insights: insightsRouter,
vacation: vacationRouter,
entitlement: entitlementRouter,
holidayCalendar: holidayCalendarRouter,
notification: notificationRouter,
settings: settingsRouter,
country: countryRouter,
+45 -7
View File
@@ -2,6 +2,7 @@ import {
countPlanningEntries,
listAssignmentBookings,
} from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared";
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -17,6 +18,10 @@ import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../ge
import { invalidateDashboardCache } from "../lib/cache.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.js";
import {
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
@@ -127,20 +132,53 @@ export const projectRouter = createTRPCRouter({
const assignments = await ctx.db.assignment.findMany({
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
include: { resource: { include: { country: { select: { code: true } } } } },
include: {
resource: {
include: {
country: { select: { id: true, code: true } },
metroCity: { select: { id: true, name: true } },
},
},
},
});
const periodStart = assignments.length > 0
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
: new Date();
const periodEnd = assignments.length > 0
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
: new Date();
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
assignments.map((assignment) => ({
id: assignment.resource.id,
availability: assignment.resource.availability as unknown as WeekdayAvailability,
countryId: assignment.resource.country?.id ?? assignment.resource.countryId,
countryCode: assignment.resource.country?.code,
federalState: assignment.resource.federalState,
metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId,
metroCityName: assignment.resource.metroCity?.name,
})),
periodStart,
periodEnd,
);
const mapped: ShoringAssignment[] = assignments.map((a) => {
const start = new Date(a.startDate);
const end = new Date(a.endDate);
const diffMs = end.getTime() - start.getTime();
const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1);
const workingDays = Math.round(diffDays / 7 * 5);
const workingDays = a.hoursPerDay > 0
? calculateEffectiveBookedHours({
availability: a.resource.availability as unknown as WeekdayAvailability,
startDate: a.startDate,
endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart,
periodEnd,
context: contexts.get(a.resourceId ?? a.resource.id),
}) / a.hoursPerDay
: 0;
return {
resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay,
workingDays: Math.max(1, workingDays),
workingDays: Math.max(0, workingDays),
};
});
+637 -69
View File
@@ -1,6 +1,20 @@
import { z } from "zod";
import { Prisma } from "@capakraken/db";
import {
isChargeabilityActualBooking,
isChargeabilityRelevantProject,
listAssignmentBookings,
} from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
getAvailabilityHoursForDate,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
// ─── Column Definitions ──────────────────────────────────────────────────────
@@ -30,6 +44,7 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "departed", label: "Departed", dataType: "boolean" },
{ key: "postalCode", label: "Postal Code", dataType: "string" },
{ key: "federalState", label: "Federal State", dataType: "string" },
{ key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" },
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
@@ -49,6 +64,7 @@ const PROJECT_COLUMNS: ColumnDef[] = [
{ key: "status", label: "Status", dataType: "string" },
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
{ key: "clientId", label: "Client ID", dataType: "string" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
@@ -61,10 +77,19 @@ const PROJECT_COLUMNS: ColumnDef[] = [
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "id", label: "ID", dataType: "string" },
{ key: "resourceId", label: "Resource ID", dataType: "string" },
{ key: "projectId", label: "Project ID", dataType: "string" },
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
{ key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" },
{ key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" },
{ key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" },
{ key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" },
{ key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" },
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
{ key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" },
{ key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
@@ -77,10 +102,55 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "updatedAt", label: "Updated At", dataType: "date" },
];
const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [
{ key: "id", label: "Row ID", dataType: "string" },
{ key: "resourceId", label: "Resource ID", dataType: "string" },
{ key: "monthKey", label: "Month", dataType: "string" },
{ key: "periodStart", label: "Period Start", dataType: "date" },
{ key: "periodEnd", label: "Period End", dataType: "date" },
{ key: "eid", label: "Employee ID", dataType: "string" },
{ key: "displayName", label: "Name", dataType: "string" },
{ key: "email", label: "Email", dataType: "string" },
{ key: "chapter", label: "Chapter", dataType: "string" },
{ key: "resourceType", label: "Resource Type", dataType: "string" },
{ key: "isActive", label: "Active", dataType: "boolean" },
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
{ key: "departed", label: "Departed", dataType: "boolean" },
{ key: "countryCode", label: "Country Code", dataType: "string" },
{ key: "countryName", label: "Country", dataType: "string" },
{ key: "federalState", label: "Federal State", dataType: "string" },
{ key: "metroCityName", label: "Metro City", dataType: "string" },
{ key: "orgUnitName", label: "Org Unit", dataType: "string" },
{ key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" },
{ key: "managementLevelName", label: "Mgmt Level", dataType: "string" },
{ key: "fte", label: "FTE", dataType: "number" },
{ key: "lcrCents", label: "LCR (cents)", dataType: "number" },
{ key: "ucrCents", label: "UCR (cents)", dataType: "number" },
{ key: "currency", label: "Currency", dataType: "string" },
{ key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" },
{ key: "monthlyTargetHours", label: "Target Hours", dataType: "number" },
{ key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" },
{ key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" },
{ key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" },
{ key: "monthlySahHours", label: "SAH", dataType: "number" },
{ key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" },
{ key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" },
{ key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" },
{ key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" },
{ key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" },
{ key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" },
{ key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" },
{ key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" },
{ key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" },
{ key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" },
];
const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
resource: RESOURCE_COLUMNS,
project: PROJECT_COLUMNS,
assignment: ASSIGNMENT_COLUMNS,
resource_month: RESOURCE_MONTH_COLUMNS,
};
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -89,6 +159,7 @@ const ENTITY_MAP = {
resource: "resource",
project: "project",
assignment: "assignment",
resource_month: "resource_month",
} as const;
type EntityKey = keyof typeof ENTITY_MAP;
@@ -110,6 +181,7 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
"id", "startDate", "endDate", "hoursPerDay", "percentage",
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
]),
resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)),
};
function getValidScalarField(entity: EntityKey, field: string): string | null {
@@ -132,15 +204,14 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
if (!def) continue;
if (colKey.includes(".")) {
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
const fieldName = colKey.split(".").slice(1).join(".");
const existing = select[relationName];
if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
(existing as { select: Record<string, boolean> }).select[fieldName] = true;
} else {
select[relationName] = { select: { [fieldName]: true } };
}
const fieldSegments = colKey.split(".").slice(1);
const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
? (existing as { select: Record<string, unknown> }).select
: {};
mergeSelectPath(relationSelect, fieldSegments);
select[relationName] = { select: relationSelect };
} else {
select[colKey] = true;
}
@@ -149,6 +220,29 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
return select;
}
function mergeSelectPath(
target: Record<string, unknown>,
segments: string[],
): void {
const [head, ...tail] = segments;
if (!head) {
return;
}
if (tail.length === 0) {
target[head] = true;
return;
}
const existing = target[head];
const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
? (existing as { select: Record<string, unknown> }).select
: {};
mergeSelectPath(nestedSelect, tail);
target[head] = { select: nestedSelect };
}
/**
* Build a Prisma `where` from the filter array.
* Only scalar top-level fields are allowed for safety.
@@ -246,6 +340,8 @@ function csvEscape(value: unknown): string {
// ─── Input Schema ───────────────────────────────────────────────────────────
const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]);
const FilterSchema = z.object({
field: z.string().min(1),
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
@@ -253,24 +349,171 @@ const FilterSchema = z.object({
});
const ReportInputSchema = z.object({
entity: z.enum(["resource", "project", "assignment"]),
entity: reportEntitySchema,
columns: z.array(z.string()).min(1),
filters: z.array(FilterSchema).default([]),
groupBy: z.string().optional(),
sortBy: z.string().optional(),
sortDir: z.enum(["asc", "desc"]).default("asc"),
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
limit: z.number().int().min(1).max(5000).default(50),
offset: z.number().int().min(0).default(0),
});
const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true });
const ReportTemplateEntity = {
RESOURCE: "RESOURCE",
PROJECT: "PROJECT",
ASSIGNMENT: "ASSIGNMENT",
RESOURCE_MONTH: "RESOURCE_MONTH",
} as const;
type ReportTemplateEntity = (typeof ReportTemplateEntity)[keyof typeof ReportTemplateEntity];
type ReportTemplateRecord = {
id: string;
name: string;
description: string | null;
entity: ReportTemplateEntity;
config: unknown;
isShared: boolean;
ownerId: string;
updatedAt: Date;
};
function getReportTemplateDelegate(db: unknown) {
return (db as {
reportTemplate: {
findMany: (args: unknown) => Promise<ReportTemplateRecord[]>;
findUnique: (args: unknown) => Promise<{ ownerId: string } | null>;
update: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
upsert: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
delete: (args: unknown) => Promise<unknown>;
};
}).reportTemplate;
}
// ─── Router ──────────────────────────────────────────────────────────────────
export const reportRouter = createTRPCRouter({
listTemplates: controllerProcedure.query(async ({ ctx }) => {
const reportTemplate = getReportTemplateDelegate(ctx.db);
const templates = await reportTemplate.findMany({
where: {
OR: [
{ ownerId: ctx.dbUser!.id },
{ isShared: true },
],
},
orderBy: [{ name: "asc" }],
select: {
id: true,
name: true,
description: true,
entity: true,
config: true,
isShared: true,
ownerId: true,
updatedAt: true,
},
});
return templates.map((template: ReportTemplateRecord) => ({
id: template.id,
name: template.name,
description: template.description,
entity: fromTemplateEntity(template.entity),
config: ReportTemplateConfigSchema.parse(template.config),
isShared: template.isShared,
isOwner: template.ownerId === ctx.dbUser!.id,
updatedAt: template.updatedAt,
}));
}),
saveTemplate: controllerProcedure
.input(z.object({
id: z.string().optional(),
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).optional(),
isShared: z.boolean().default(false),
config: ReportTemplateConfigSchema,
}))
.mutation(async ({ ctx, input }) => {
const reportTemplate = getReportTemplateDelegate(ctx.db);
const payload = input.config as unknown as Prisma.InputJsonValue;
const entity = toTemplateEntity(input.config.entity);
if (input.id) {
const existing = await reportTemplate.findUnique({
where: { id: input.id },
select: { ownerId: true },
});
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
}
return reportTemplate.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
select: { id: true, updatedAt: true },
});
}
return reportTemplate.upsert({
where: {
ownerId_name: {
ownerId: ctx.dbUser!.id,
name: input.name,
},
},
update: {
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
create: {
ownerId: ctx.dbUser!.id,
name: input.name,
description: input.description,
entity,
config: payload,
isShared: input.isShared,
},
select: { id: true, updatedAt: true },
});
}),
deleteTemplate: controllerProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const reportTemplate = getReportTemplateDelegate(ctx.db);
const existing = await reportTemplate.findUnique({
where: { id: input.id },
select: { ownerId: true },
});
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" });
}
await reportTemplate.delete({ where: { id: input.id } });
return { ok: true };
}),
/**
* Return available columns for a given entity type.
*/
getAvailableColumns: controllerProcedure
.input(z.object({ entity: z.enum(["resource", "project", "assignment"]) }))
.input(z.object({ entity: reportEntitySchema }))
.query(({ input }) => {
const columns = COLUMN_MAP[input.entity];
if (!columns) {
@@ -285,40 +528,7 @@ export const reportRouter = createTRPCRouter({
getReportData: controllerProcedure
.input(ReportInputSchema)
.query(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
// Build orderBy (only scalar fields)
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(ctx.db, entity);
const [rawRows, totalCount] = await Promise.all([
(modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
skip: offset,
}),
(modelDelegate as any).count({ where }),
]);
// Flatten nested relations into dot-notation keys
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
// Ensure column order matches request (plus id)
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
return { rows, columns: outputColumns, totalCount };
return executeReportQuery(ctx.db, input);
}),
/**
@@ -329,33 +539,12 @@ export const reportRouter = createTRPCRouter({
limit: z.number().int().min(1).max(50000).default(5000),
}))
.mutation(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(ctx.db, entity);
const rawRows = await (modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
});
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
const rows = result.rows;
const outputColumns = result.columns;
// Build CSV
const entityColumns = COLUMN_MAP[entity];
const entityColumns = COLUMN_MAP[input.entity];
const headerLabels = outputColumns.map((key) => {
const def = entityColumns.find((c) => c.key === key);
return def?.label ?? key;
@@ -372,6 +561,385 @@ export const reportRouter = createTRPCRouter({
}),
});
type ReportInput = z.infer<typeof ReportInputSchema>;
type FilterInput = z.infer<typeof FilterSchema>;
async function executeReportQuery(
db: any,
input: ReportInput,
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
if (input.entity === "resource_month") {
return executeResourceMonthReport(db, input);
}
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
const select = buildSelect(entity, columns);
const where = buildWhere(entity, filters);
let orderBy: Record<string, string> | undefined;
if (sortBy) {
const validField = getValidScalarField(entity, sortBy);
if (validField) {
orderBy = { [validField]: sortDir };
}
}
const modelDelegate = getModelDelegate(db, entity);
const [rawRows, totalCount] = await Promise.all([
(modelDelegate as any).findMany({
select,
where,
...(orderBy ? { orderBy } : {}),
take: limit,
skip: offset,
}),
(modelDelegate as any).count({ where }),
]);
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
const outputColumns = ["id", ...columns.filter((column) => column !== "id")];
return {
rows: rows.map((row) => pickColumns(row, outputColumns)),
columns: outputColumns,
totalCount,
};
}
async function executeResourceMonthReport(
db: any,
input: ReportInput,
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7);
const [year, month] = periodMonth.split("-").map(Number) as [number, number];
const periodStart = new Date(Date.UTC(year, month - 1, 1));
const periodEnd = new Date(Date.UTC(year, month, 0));
const resources = await db.resource.findMany({
select: {
id: true,
eid: true,
displayName: true,
email: true,
chapter: true,
resourceType: true,
isActive: true,
chgResponsibility: true,
rolledOff: true,
departed: true,
lcrCents: true,
ucrCents: true,
currency: true,
fte: true,
availability: true,
chargeabilityTarget: true,
federalState: true,
countryId: true,
metroCityId: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
orgUnit: { select: { name: true } },
managementLevelGroup: { select: { name: true, targetPercentage: true } },
managementLevel: { select: { name: true } },
},
orderBy: { displayName: "asc" },
});
const resourceIds = resources.map((resource: any) => resource.id);
const [bookings, contexts] = await Promise.all([
resourceIds.length > 0
? listAssignmentBookings(db, {
startDate: periodStart,
endDate: periodEnd,
resourceIds,
})
: Promise.resolve([]),
loadResourceDailyAvailabilityContexts(
db,
resources.map((resource: any) => ({
id: resource.id,
availability: resource.availability as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
periodStart,
periodEnd,
),
]);
const rows = resources.map((resource: any) => {
const availability = resource.availability as WeekdayAvailability;
const context = contexts.get(resource.id);
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
const baseWorkingDays = countEffectiveWorkingDays({
availability,
periodStart,
periodEnd,
context: undefined,
});
const effectiveWorkingDays = countEffectiveWorkingDays({
availability,
periodStart,
periodEnd,
context,
});
const baseAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context: undefined,
});
const sahHours = calculateEffectiveAvailableHours({
availability,
periodStart,
periodEnd,
context,
});
const holidayDates = [...(context?.holidayDates ?? new Set<string>())];
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
), 0);
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`))
), 0);
let absenceDayEquivalent = 0;
let absenceHoursDeduction = 0;
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`));
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
continue;
}
absenceDayEquivalent += fraction;
absenceHoursDeduction += dayHours * fraction;
}
const actualBookedHours = resourceBookings
.filter((booking) => isChargeabilityActualBooking(booking, false))
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart,
periodEnd,
context,
}), 0);
const expectedBookedHours = resourceBookings
.filter((booking) => isChargeabilityRelevantProject(booking.project, true))
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart,
periodEnd,
context,
}), 0);
const targetPct = resource.managementLevelGroup?.targetPercentage != null
? resource.managementLevelGroup.targetPercentage * 100
: resource.chargeabilityTarget;
return {
id: `${resource.id}:${periodMonth}`,
resourceId: resource.id,
monthKey: periodMonth,
periodStart: periodStart.toISOString(),
periodEnd: periodEnd.toISOString(),
eid: resource.eid,
displayName: resource.displayName,
email: resource.email,
chapter: resource.chapter,
resourceType: resource.resourceType,
isActive: resource.isActive,
chgResponsibility: resource.chgResponsibility,
rolledOff: resource.rolledOff,
departed: resource.departed,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState,
metroCityName: resource.metroCity?.name ?? null,
orgUnitName: resource.orgUnit?.name ?? null,
managementLevelGroupName: resource.managementLevelGroup?.name ?? null,
managementLevelName: resource.managementLevel?.name ?? null,
fte: roundMetric(resource.fte),
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
currency: resource.currency,
monthlyChargeabilityTargetPct: roundMetric(targetPct),
monthlyTargetHours: roundMetric((sahHours * targetPct) / 100),
monthlyBaseWorkingDays: roundMetric(baseWorkingDays),
monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays),
monthlyBaseAvailableHours: roundMetric(baseAvailableHours),
monthlySahHours: roundMetric(sahHours),
monthlyPublicHolidayCount: holidayDates.length,
monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount,
monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction),
monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent),
monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction),
monthlyActualBookedHours: roundMetric(actualBookedHours),
monthlyExpectedBookedHours: roundMetric(expectedBookedHours),
monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0),
monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0),
monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)),
};
});
const filteredRows = rows.filter((row: Record<string, unknown>) => input.filters.every((filter) => matchesInMemoryFilter(
row,
filter,
RESOURCE_MONTH_COLUMNS,
)));
const sortedRows = sortInMemoryRows(filteredRows, input.sortBy, input.sortDir, RESOURCE_MONTH_COLUMNS);
const totalCount = sortedRows.length;
const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit);
const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")];
return {
rows: pagedRows.map((row) => pickColumns(row, outputColumns)),
columns: outputColumns,
totalCount,
};
}
function parseFilterValue(def: ColumnDef | undefined, value: string): unknown {
if (!def) return value;
if (def.dataType === "number") {
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
if (def.dataType === "boolean") {
return value === "true";
}
if (def.dataType === "date") {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime();
}
return value;
}
function matchesInMemoryFilter(
row: Record<string, unknown>,
filter: FilterInput,
columns: ColumnDef[],
): boolean {
const def = columns.find((column) => column.key === filter.field);
if (!def) {
return true;
}
const rowValueRaw = row[filter.field];
const rowValue = def.dataType === "date" && typeof rowValueRaw === "string"
? new Date(rowValueRaw).getTime()
: rowValueRaw;
const parsedFilterValue = parseFilterValue(def, filter.value);
if (parsedFilterValue === null) {
return false;
}
switch (filter.op) {
case "eq":
return rowValue === parsedFilterValue;
case "neq":
return rowValue !== parsedFilterValue;
case "gt":
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue > parsedFilterValue;
case "lt":
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue < parsedFilterValue;
case "gte":
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue >= parsedFilterValue;
case "lte":
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue <= parsedFilterValue;
case "contains":
return typeof rowValue === "string" && rowValue.toLowerCase().includes(filter.value.toLowerCase());
case "in":
return filter.value.split(",").map((value) => value.trim()).includes(String(rowValue ?? ""));
default:
return true;
}
}
function sortInMemoryRows(
rows: Record<string, unknown>[],
sortBy: string | undefined,
sortDir: "asc" | "desc",
columns: ColumnDef[],
): Record<string, unknown>[] {
if (!sortBy) {
return rows;
}
const def = columns.find((column) => column.key === sortBy);
if (!def) {
return rows;
}
const direction = sortDir === "asc" ? 1 : -1;
return [...rows].sort((left, right) => {
const leftValue = left[sortBy];
const rightValue = right[sortBy];
if (leftValue == null && rightValue == null) return 0;
if (leftValue == null) return 1;
if (rightValue == null) return -1;
if (def.dataType === "number") {
return direction * (Number(leftValue) - Number(rightValue));
}
if (def.dataType === "boolean") {
return direction * (Number(Boolean(leftValue)) - Number(Boolean(rightValue)));
}
if (def.dataType === "date") {
return direction * (new Date(String(leftValue)).getTime() - new Date(String(rightValue)).getTime());
}
return direction * String(leftValue).localeCompare(String(rightValue), "de");
});
}
function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
return Object.fromEntries(columns.map((column) => [column, row[column]]));
}
function roundMetric(value: number): number {
return Math.round(value * 10) / 10;
}
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
switch (entity) {
case "resource":
return ReportTemplateEntity.RESOURCE;
case "project":
return ReportTemplateEntity.PROJECT;
case "assignment":
return ReportTemplateEntity.ASSIGNMENT;
case "resource_month":
return ReportTemplateEntity.RESOURCE_MONTH;
default:
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
}
}
function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey {
switch (entity) {
case ReportTemplateEntity.RESOURCE:
return "resource";
case ReportTemplateEntity.PROJECT:
return "project";
case ReportTemplateEntity.ASSIGNMENT:
return "assignment";
case ReportTemplateEntity.RESOURCE_MONTH:
return "resource_month";
default:
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
}
}
/** Resolve the Prisma model delegate from entity key. */
function getModelDelegate(db: any, entity: EntityKey) {
switch (entity) {
+239 -50
View File
@@ -7,7 +7,6 @@ import {
} from "@capakraken/application";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared";
import { computeChargeability } from "@capakraken/engine";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import {
@@ -17,6 +16,12 @@ import {
getAnonymizationDirectory,
resolveResourceIdsByDisplayedEids,
} from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
calculateEffectiveDayAvailability,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
@@ -46,6 +51,50 @@ function parseResourceCursor(cursor: string | undefined): { displayName: string;
return null;
}
type BookingForCapacity = {
startDate: Date;
endDate: Date;
hoursPerDay: number;
};
function toIsoDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function buildDailyBookedHoursMap(
bookings: BookingForCapacity[],
availability: WeekdayAvailability,
context: Parameters<typeof calculateEffectiveBookedHours>[0]["context"],
periodStart: Date,
periodEnd: Date,
): Map<string, number> {
const dailyBookedHours = new Map<string, number>();
const cursor = new Date(periodStart);
cursor.setUTCHours(0, 0, 0, 0);
const end = new Date(periodEnd);
end.setUTCHours(0, 0, 0, 0);
while (cursor <= end) {
const isoDate = toIsoDate(cursor);
const bookedHours = bookings.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: cursor,
periodEnd: cursor,
context,
}),
0,
);
dailyBookedHours.set(isoDate, bookedHours);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return dailyBookedHours;
}
export const resourceRouter = createTRPCRouter({
list: protectedProcedure
.input(
@@ -1056,10 +1105,14 @@ export const resourceRouter = createTRPCRouter({
portfolioUrl: true,
postalCode: true,
federalState: true,
countryId: true,
metroCityId: true,
valueScore: true,
valueScoreBreakdown: true,
valueScoreUpdatedAt: true,
userId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const bookings = await listAssignmentBookings(ctx.db, {
@@ -1067,30 +1120,67 @@ export const resourceRouter = createTRPCRouter({
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const bookingsByResourceId = new Map<string, typeof bookings>();
for (const booking of bookings) {
if (!booking.resourceId) {
continue;
}
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
items.push(booking);
bookingsByResourceId.set(booking.resourceId, items);
}
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
start,
end,
);
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as Record<string, number>;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
const periodDays =
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
const availableHours = dailyAvailHours * periodDays * (5 / 7);
let bookedHours = 0;
let isOverbooked = false;
const resourceBookings = bookings.filter(
const availability = r.availability as unknown as WeekdayAvailability;
const context = contexts.get(r.id);
const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
(booking) =>
booking.resourceId === r.id &&
(input.includeProposed || booking.status !== "PROPOSED"),
);
for (const a of resourceBookings) {
const days =
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
(1000 * 60 * 60 * 24) +
1;
bookedHours += a.hoursPerDay * days;
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
}
const availableHours = calculateEffectiveAvailableHours({
availability,
periodStart: start,
periodEnd: end,
context,
});
const bookedHours = resourceBookings.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
const date = new Date(`${isoDate}T00:00:00.000Z`);
const dayCapacity = calculateEffectiveDayAvailability({
availability,
date,
context,
});
return dayCapacity > 0 && hours > dayCapacity;
});
const utilizationPercent =
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
@@ -1125,6 +1215,11 @@ export const resourceRouter = createTRPCRouter({
chapter: true,
chargeabilityTarget: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
const bookings = await listAssignmentBookings(ctx.db, {
@@ -1132,10 +1227,25 @@ export const resourceRouter = createTRPCRouter({
endDate: end,
resourceIds: resources.map((resource) => resource.id),
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
start,
end,
);
const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => {
const avail = r.availability as unknown as WeekdayAvailability;
const context = contexts.get(r.id);
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
const actualAllocs = resourceBookings.filter((booking) =>
@@ -1146,8 +1256,42 @@ export const resourceRouter = createTRPCRouter({
isChargeabilityRelevantProject(booking.project, true),
);
const actual = computeChargeability(avail, actualAllocs, start, end);
const expected = computeChargeability(avail, expectedAllocs, start, end);
const availableHours = calculateEffectiveAvailableHours({
availability: avail,
periodStart: start,
periodEnd: end,
context,
});
const actualBookedHours = actualAllocs.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability: avail,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const expectedBookedHours = expectedAllocs.reduce(
(sum, booking) => sum + calculateEffectiveBookedHours({
availability: avail,
startDate: booking.startDate,
endDate: booking.endDate,
hoursPerDay: booking.hoursPerDay,
periodStart: start,
periodEnd: end,
context,
}),
0,
);
const actualChargeability = availableHours > 0
? Math.round((actualBookedHours / availableHours) * 100)
: 0;
const expectedChargeability = availableHours > 0
? Math.round((expectedBookedHours / availableHours) * 100)
: 0;
return anonymizeResource({
id: r.id,
@@ -1155,9 +1299,9 @@ export const resourceRouter = createTRPCRouter({
displayName: r.displayName,
chapter: r.chapter,
chargeabilityTarget: r.chargeabilityTarget,
actualChargeability: actual.chargeability,
expectedChargeability: expected.chargeability,
availableHours: actual.availableHours,
actualChargeability,
expectedChargeability,
availableHours: Math.round(availableHours),
}, directory);
});
}),
@@ -1208,7 +1352,10 @@ export const resourceRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const now = new Date();
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
const today = new Date(now);
today.setUTCHours(0, 0, 0, 0);
const thirtyDaysFromNow = new Date(today);
thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
@@ -1223,6 +1370,11 @@ export const resourceRouter = createTRPCRouter({
skills: true,
availability: true,
chargeabilityTarget: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
},
});
@@ -1232,7 +1384,7 @@ export const resourceRouter = createTRPCRouter({
where: {
resourceId: { in: allResourceIds },
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
endDate: { gte: now },
endDate: { gte: today },
startDate: { lte: thirtyDaysFromNow },
},
select: {
@@ -1242,41 +1394,78 @@ export const resourceRouter = createTRPCRouter({
hoursPerDay: true,
},
});
const contexts = await loadResourceDailyAvailabilityContexts(
ctx.db,
resources.map((resource) => ({
id: resource.id,
availability: resource.availability as unknown as WeekdayAvailability,
countryId: resource.countryId,
countryCode: resource.country?.code,
federalState: resource.federalState,
metroCityId: resource.metroCityId,
metroCityName: resource.metroCity?.name,
})),
today,
thirtyDaysFromNow,
);
const assignmentsByResourceId = new Map<string, typeof assignments>();
for (const assignment of assignments) {
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
items.push(assignment);
assignmentsByResourceId.set(assignment.resourceId, items);
}
// Build utilization map (simple: booked hours per day / available hours per day)
// Build utilization map with holiday-aware daily capacity over the next 30 days.
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
for (const r of resources) {
const avail = r.availability as Record<string, number>;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
const resourceAssignments = assignments.filter((a) => a.resourceId === r.id);
const availability = r.availability as unknown as WeekdayAvailability;
const context = contexts.get(r.id);
const resourceAssignments = assignmentsByResourceId.get(r.id) ?? [];
const todayAvailableHours = calculateEffectiveAvailableHours({
availability,
periodStart: today,
periodEnd: today,
context,
});
const todayBookedHours = resourceAssignments.reduce(
(sum, assignment) => sum + calculateEffectiveBookedHours({
availability,
startDate: assignment.startDate,
endDate: assignment.endDate,
hoursPerDay: assignment.hoursPerDay,
periodStart: today,
periodEnd: today,
context,
}),
0,
);
const utilizationPercent = todayAvailableHours > 0
? Math.round((todayBookedHours / todayAvailableHours) * 100)
: 0;
const dailyBookedHours = buildDailyBookedHoursMap(
resourceAssignments,
availability,
context,
today,
thirtyDaysFromNow,
);
// Current daily booked hours (assignments overlapping today)
let todayBooked = 0;
for (const a of resourceAssignments) {
if (a.startDate <= now && a.endDate >= now) {
todayBooked += a.hoursPerDay;
}
}
const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0;
// Find earliest date when resource has capacity (within 30 days)
let earliestAvailableDate: Date | null = null;
const checkDate = new Date(now);
const checkDate = new Date(today);
for (let i = 0; i < 30; i++) {
const day = checkDate.getDay();
if (day !== 0 && day !== 6) {
let dayBooked = 0;
for (const a of resourceAssignments) {
if (a.startDate <= checkDate && a.endDate >= checkDate) {
dayBooked += a.hoursPerDay;
}
}
if (dayBooked < dailyAvailHours * 0.8) {
const dayAvailableHours = calculateEffectiveDayAvailability({
availability,
date: checkDate,
context,
});
if (dayAvailableHours > 0) {
const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
if (dayBookedHours < dayAvailableHours * 0.8) {
earliestAvailableDate = new Date(checkDate);
break;
}
}
checkDate.setDate(checkDate.getDate() + 1);
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
}
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });

Some files were not shown because too many files have changed in this diff Show More