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
DATABASE_URL=postgresql://planarchy:planarchy_dev@localhost:5433/planarchy DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
DATABASE_URL_TEST=postgresql://planarchy:planarchy_test@localhost:5434/planarchy_test DATABASE_URL_TEST=postgresql://capakraken:capakraken_test@localhost:5434/capakraken_test
ALLOW_DESTRUCTIVE_DB_TOOLS=false
CONFIRM_DESTRUCTIVE_DB_NAME=
# Redis # Redis
REDIS_URL=redis://localhost:6380 REDIS_URL=redis://localhost:6380
+2
View File
@@ -218,6 +218,8 @@ jobs:
--health-retries=5 --health-retries=5
env: env:
DATABASE_URL: postgresql://capakraken:capakraken_test@localhost:5432/capakraken_test 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 REDIS_URL: redis://localhost:6379
PORT: 3100 PORT: 3100
steps: steps:
+2
View File
@@ -6,6 +6,7 @@ node_modules/
# Build outputs # Build outputs
.next/ .next/
.next-e2e/
dist/ dist/
build/ build/
.turbo/ .turbo/
@@ -20,6 +21,7 @@ test-results/
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.*.local .env.*.local
*.e2e-backup
# Logs # Logs
*.log *.log
+1 -1
View File
@@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile
COPY . . COPY . .
# Generate Prisma client # Generate Prisma client
RUN pnpm --filter @planarchy/db db:generate RUN pnpm --filter @capakraken/db db:generate
EXPOSE 3100 EXPOSE 3100
+2 -2
View File
@@ -39,12 +39,12 @@ COPY --from=deps /app/ ./
COPY . . COPY . .
# Generate Prisma client # Generate Prisma client
RUN pnpm --filter @planarchy/db db:generate RUN pnpm --filter @capakraken/db db:generate
# Build the Next.js application # Build the Next.js application
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production ENV NODE_ENV=production
RUN pnpm --filter @planarchy/web build RUN pnpm --filter @capakraken/web build
# ============================================================ # ============================================================
# Stage 3: Production runtime # 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("Timeline", () => {
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
@@ -7,11 +15,7 @@ test.describe("Timeline", () => {
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" })); localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
}); });
await page.goto("/auth/signin"); await signInAsAdmin(page);
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 page.goto("/timeline"); await page.goto("/timeline");
}); });
@@ -87,8 +91,13 @@ test.describe("Timeline", () => {
.first(); .first();
const allocationPopoverField = page.getByText("Hours / day"); const allocationPopoverField = page.getByText("Hours / day");
const resourceHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first(); const resourceHoverTarget = page.getByTestId("timeline-resource-row-canvas").first();
await resourceHoverTarget.hover({ position: { x: 120, y: 20 } }); 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(heatmapTooltip).toBeVisible();
await expect await expect
.poll(async () => { .poll(async () => {
@@ -109,8 +118,19 @@ test.describe("Timeline", () => {
await expect(page.getByText(/projects/)).toBeVisible(); await expect(page.getByText(/projects/)).toBeVisible();
await page.waitForTimeout(500); await page.waitForTimeout(500);
const projectHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first(); const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first();
await projectHoverTarget.hover({ position: { x: 120, y: 20 } }); 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(heatmapTooltip).toBeVisible();
await expect await expect
.poll(async () => { .poll(async () => {
@@ -118,8 +138,48 @@ test.describe("Timeline", () => {
}) })
.toBe("rgba(3, 7, 18, 0.96)"); .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 projectAllocation.click({ button: "right" });
await expect(allocationPopoverField).toBeVisible(); 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");
});
}); });
+69 -19
View File
@@ -1,13 +1,22 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
async function signInAsAdmin(page: Page) {
await page.goto("/auth/signin");
await page.fill('input[type="email"]', "admin@capakraken.dev");
await page.fill('input[type="password"]', "admin123");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/(dashboard|resources)/);
}
async function fillDisplayDate(page: Page, label: RegExp, value: string) {
const [year, month, day] = value.split("-");
await page.getByLabel(label).fill(`${day}/${month}/${year}`);
}
test.describe("Vacations", () => { test.describe("Vacations", () => {
test.describe("My Vacations (self-service)", () => { test.describe("My Vacations (self-service)", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await signInAsAdmin(page);
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 page.goto("/vacations/my"); await page.goto("/vacations/my");
}); });
@@ -23,25 +32,19 @@ test.describe("Vacations", () => {
).toBeVisible({ timeout: 10000 }); ).toBeVisible({ timeout: 10000 });
}); });
test("request vacation modal opens", async ({ page }) => { test("request vacation is blocked without linked resource", async ({ page }) => {
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
const reqBtn = page.locator("button", { hasText: /Request Vacation/i }); const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
await reqBtn.click(); await expect(reqBtn).toBeDisabled();
// Modal should show vacation form
await expect( 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 }); ).toBeVisible({ timeout: 5000 });
await page.keyboard.press("Escape");
}); });
}); });
test.describe("Vacation Management", () => { test.describe("Vacation Management", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto("/auth/signin"); await signInAsAdmin(page);
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 page.goto("/vacations"); await page.goto("/vacations");
}); });
@@ -62,12 +65,59 @@ test.describe("Vacations", () => {
).toBeVisible({ timeout: 10000 }); ).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"); 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( await expect(
page.locator("button", { hasText: /All|Pending|Approved/i }).first(), page.getByRole("heading", { name: "Referenzfeiertage Deutschland 2026-2027" }),
).toBeVisible({ timeout: 10000 }); ).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" />
/// <reference types="next/image-types/global" /> /// <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 // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // 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"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
distDir: process.env.NEXT_DIST_DIR ?? ".next",
output: "standalone", output: "standalone",
outputFileTracingRoot: path.resolve(__dirname, "../.."),
devIndicators: false, devIndicators: false,
experimental: { experimental: {
optimizePackageImports: ["recharts", "date-fns"], optimizePackageImports: ["recharts", "date-fns"],
+10 -7
View File
@@ -1,14 +1,17 @@
import { defineConfig, devices } from "@playwright/test"; 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({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
fullyParallel: true, fullyParallel: false,
forbidOnly: !!process.env["CI"], forbidOnly: !!process.env["CI"],
retries: process.env["CI"] ? 2 : 0, retries: process.env["CI"] ? 2 : 0,
...(process.env["CI"] ? { workers: 1 } : {}), workers: 1,
reporter: "html", reporter: "html",
use: { use: {
baseURL: "http://localhost:3100", baseURL: e2eBaseUrl,
trace: "on-first-retry", trace: "on-first-retry",
}, },
projects: [ projects: [
@@ -18,9 +21,9 @@ export default defineConfig({
}, },
], ],
webServer: { webServer: {
command: "pnpm dev", command: "node ./e2e/test-server.mjs",
url: "http://localhost:3100", url: e2eBaseUrl,
reuseExistingServer: !process.env["CI"], reuseExistingServer: false,
timeout: 120000, timeout: 180000,
}, },
}); });
@@ -1,3 +1,4 @@
import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEditor.js";
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js"; import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.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 className="p-6 max-w-5xl mx-auto space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1> <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> </div>
<PublicHolidayBatch />
<EntitlementManager /> <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> </div>
); );
} }
@@ -33,6 +33,7 @@ import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js"; import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js"; import { useRowOrder } from "~/hooks/useRowOrder.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js"; import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
type ModalState = type ModalState =
@@ -85,68 +86,22 @@ function FilterDropdown({
tooltipContent?: ReactNode; tooltipContent?: ReactNode;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null); const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
const panelRef = useRef<HTMLDivElement | null>(null); open: isOpen,
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 }); onClose: () => setIsOpen(false),
matchTriggerWidth: true,
const updatePanelPosition = useCallback(() => { });
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
});
}, []);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("keydown", handleEscape);
};
}, [isOpen, updatePanelPosition]);
return ( return (
<div ref={dropdownRef} className="relative"> <div ref={triggerRef} className="relative">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="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}`} className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
> >
<span className="text-left">{label}</span> <span className="text-left">{label}</span>
@@ -160,9 +115,9 @@ function FilterDropdown({
ref={panelRef} ref={panelRef}
style={{ style={{
position: "fixed", position: "fixed",
top: panelPosition.top, top: position.top,
left: panelPosition.left, left: position.left,
minWidth: panelPosition.minWidth, 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}`} 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"; "use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react"; import { useEffect } from "react";
export default function GlobalError({ export default function GlobalError({
@@ -11,7 +10,7 @@ export default function GlobalError({
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
Sentry.captureException(error); console.error(error);
}, [error]); }, [error]);
return ( return (
@@ -9,6 +9,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = { const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs", viewCosts: "View Costs",
useAssistantAdvancedTools: "Assistant Advanced Tools",
exportData: "Export Data", exportData: "Export Data",
importData: "Import Data", importData: "Import Data",
approveVacations: "Approve Vacations", approveVacations: "Approve Vacations",
@@ -24,6 +25,7 @@ const PERMISSION_LABELS: Record<string, string> = {
const PERMISSION_DESCRIPTIONS: Record<string, string> = { const PERMISSION_DESCRIPTIONS: Record<string, string> = {
viewCosts: "Access to cost data, budget views, and financial reports", 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", exportData: "Export data to Excel, CSV, or PDF formats",
importData: "Import data from external sources (Dispo, Excel)", importData: "Import data from external sources (Dispo, Excel)",
approveVacations: "Approve or reject vacation requests", approveVacations: "Approve or reject vacation requests",
@@ -97,6 +99,8 @@ export function SystemRolesClient() {
staleTime: 10_000, 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({ const updateMutation = trpc.systemRoleConfig.update.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.systemRoleConfig.list.invalidate(); await utils.systemRoleConfig.list.invalidate();
@@ -15,6 +15,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = { const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs", viewCosts: "View Costs",
useAssistantAdvancedTools: "Assistant Advanced Tools",
exportData: "Export Data", exportData: "Export Data",
importData: "Import Data", importData: "Import Data",
approveVacations: "Approve Vacations", approveVacations: "Approve Vacations",
@@ -25,6 +26,7 @@ const PERMISSION_LABELS: Record<string, string> = {
manageAllocations: "Manage Allocations", manageAllocations: "Manage Allocations",
manageRoles: "Manage Roles", manageRoles: "Manage Roles",
manageUsers: "Manage Users", manageUsers: "Manage Users",
viewScores: "View Scores",
}; };
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = { const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
@@ -11,6 +11,50 @@ import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
type Dimension = "2d" | "3d"; 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() { export default function ComputationGraphClient() {
const state = useComputationGraphData(); const state = useComputationGraphData();
const [dimension, setDimension] = useState<Dimension>("2d"); const [dimension, setDimension] = useState<Dimension>("2d");
@@ -24,10 +68,34 @@ export default function ComputationGraphClient() {
isLoading, isLoading,
activeDomains, activeDomains,
graphData, graphData,
rawData,
highlightedNodes, setHighlightedNodes, highlightedNodes, setHighlightedNodes,
domainFilter, toggleDomain, domainFilter, toggleDomain,
} = state; } = 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 ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col"> <div className="flex h-[calc(100vh-4rem)] flex-col">
{/* ── Header Bar ── */} {/* ── Header Bar ── */}
@@ -173,6 +241,102 @@ export default function ComputationGraphClient() {
<ComputationGraph3D state={state} /> <ComputationGraph3D state={state} />
)} )}
</div> </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>
</div> </div>
); );
@@ -6,12 +6,19 @@ import {
RESOURCE_VIEW_DOMAINS, RESOURCE_VIEW_DOMAINS,
PROJECT_VIEW_DOMAINS, PROJECT_VIEW_DOMAINS,
type Domain, type Domain,
type GraphLink,
type GraphNode, type GraphNode,
} from "./domain-colors"; } from "./domain-colors";
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data"; import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
export type ViewMode = "resource" | "project"; export type ViewMode = "resource" | "project";
export interface ComputationGraphResponse {
nodes: GraphNode[];
links: GraphLink[];
meta?: Record<string, unknown>;
}
export interface ComputationGraphState { export interface ComputationGraphState {
viewMode: ViewMode; viewMode: ViewMode;
setViewMode: (m: ViewMode) => void; setViewMode: (m: ViewMode) => void;
@@ -26,6 +33,7 @@ export interface ComputationGraphState {
isLoading: boolean; isLoading: boolean;
activeDomains: Domain[]; activeDomains: Domain[];
graphData: ForceGraphData; graphData: ForceGraphData;
rawData: ComputationGraphResponse | null;
highlightedNodes: Set<string> | null; highlightedNodes: Set<string> | null;
setHighlightedNodes: (s: Set<string> | null) => void; setHighlightedNodes: (s: Set<string> | null) => void;
hoveredNode: PositionedNode | null; hoveredNode: PositionedNode | null;
@@ -144,6 +152,7 @@ export function useComputationGraphData(): ComputationGraphState {
isLoading, isLoading,
activeDomains, activeDomains,
graphData, graphData,
rawData: (rawData as ComputationGraphResponse | undefined) ?? null,
highlightedNodes, highlightedNodes,
setHighlightedNodes, setHighlightedNodes,
hoveredNode, hoveredNode,
+100 -21
View File
@@ -1,16 +1,33 @@
"use client"; "use client";
import { useMemo } from "react"; 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 { interface ChatMessageProps {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; 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) { function renderMarkdown(text: string) {
const lines = text.split("\n"); const lines = text.split("\n");
const elements: React.ReactNode[] = []; const elements: React.ReactNode[] = [];
@@ -21,7 +38,7 @@ function renderMarkdown(text: string) {
if (listItems.length > 0 && listType) { if (listItems.length > 0 && listType) {
const Tag = listType; const Tag = listType;
elements.push( 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} {listItems}
</Tag>, </Tag>,
); );
@@ -31,7 +48,6 @@ function renderMarkdown(text: string) {
}; };
for (const [i, line] of lines.entries()) { for (const [i, line] of lines.entries()) {
// Bullet list: "- item" or "* item"
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/); const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
if (bulletMatch?.[1]) { if (bulletMatch?.[1]) {
if (listType !== "ul") flushList(); if (listType !== "ul") flushList();
@@ -40,7 +56,6 @@ function renderMarkdown(text: string) {
continue; continue;
} }
// Numbered list: "1. item"
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/); const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
if (numMatch?.[1]) { if (numMatch?.[1]) {
if (listType !== "ol") flushList(); if (listType !== "ol") flushList();
@@ -49,54 +64,46 @@ function renderMarkdown(text: string) {
continue; continue;
} }
// Not a list item — flush any pending list
flushList(); flushList();
// Empty line → spacing
if (line.trim() === "") { if (line.trim() === "") {
elements.push(<div key={`br-${i}`} className="h-2" />); elements.push(<div key={`br-${i}`} className="h-2" />);
continue; continue;
} }
// Regular paragraph
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>); elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
} }
flushList(); flushList();
return elements; return elements;
} }
/** Parse inline formatting: **bold**, *italic*, `code` */
function inlineFormat(text: string): React.ReactNode { function inlineFormat(text: string): React.ReactNode {
// Split by inline patterns, preserving delimiters
const parts: React.ReactNode[] = []; const parts: React.ReactNode[] = [];
// Regex: **bold**, *italic*, `code`
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g; const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
let lastIndex = 0; let lastIndex = 0;
let match: RegExpExecArray | null; let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {
// Text before this match
if (match.index > lastIndex) { if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index)); parts.push(text.slice(lastIndex, match.index));
} }
if (match[2]) { if (match[2]) {
// **bold**
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>); parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) { } else if (match[3]) {
// *italic*
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>); parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
} else if (match[4]) { } else if (match[4]) {
// `code`
parts.push( 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"> <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]} {match[4]}
</code>, </code>,
); );
} }
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }
// Remaining text
if (lastIndex < text.length) { if (lastIndex < text.length) {
parts.push(text.slice(lastIndex)); parts.push(text.slice(lastIndex));
} }
@@ -104,7 +111,72 @@ function inlineFormat(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}</>; 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 isUser = role === "user";
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]); 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="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"> <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="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
AI Generated AI Generated
</span> </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> <div className="space-y-0.5 break-words">{rendered}</div>
</> </>
)} )}
@@ -38,6 +38,26 @@ function resolvePageContext(pathname: string): string {
interface Message { interface Message {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; 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"; const STORAGE_KEY = "capakraken-chat-messages";
@@ -47,7 +67,23 @@ function loadPersistedMessages(): Message[] {
if (typeof window === "undefined") return []; if (typeof window === "undefined") return [];
try { try {
const raw = sessionStorage.getItem(STORAGE_KEY); 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 */ } } catch { /* ignore corrupt data */ }
return []; return [];
} }
@@ -101,10 +137,23 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })), messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}), ...(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) // 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) { if (actions) {
for (const action of actions) { for (const action of actions) {
if (action.type === "navigate" && action.url) { if (action.type === "navigate" && action.url) {
@@ -230,7 +279,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
</div> </div>
)} )}
{messages.map((msg, i) => ( {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 />} {isLoading && <TypingIndicator />}
{error && ( {error && (
@@ -158,6 +158,12 @@ export function DashboardClient() {
<WidgetContainer <WidgetContainer
title={widget.title ?? getWidget(widget.type).label} title={widget.title ?? getWidget(widget.type).label}
description={getWidget(widget.type).description} description={getWidget(widget.type).description}
showDetails={widget.config.showDetails === true}
onToggleDetails={() =>
updateWidgetConfig(widget.id, {
showDetails: widget.config.showDetails !== true,
})
}
onRemove={() => removeWidget(widget.id)} onRemove={() => removeWidget(widget.id)}
> >
{renderWidget(widget.type, widget.config, (update) => {renderWidget(widget.type, widget.config, (update) =>
@@ -8,9 +8,19 @@ interface WidgetContainerProps {
onRemove: () => void; onRemove: () => void;
children: React.ReactNode; children: React.ReactNode;
isDragging?: boolean; 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 ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 16 }} 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 ${ className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
isDragging isDragging
? "shadow-xl border-brand-400 dark:border-brand-500 scale-[1.01] ring-2 ring-brand-400/30" ? "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-start justify-between gap-3 px-4 pt-3.5 pb-3 shrink-0 widget-drag-handle group">
<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 cursor-grab active:cursor-grabbing">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Drag grip dots */}
<svg <svg
className="w-3.5 h-5 text-gray-300 dark:text-gray-600 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" 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" viewBox="0 0 14 20"
@@ -39,32 +47,58 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
<circle cx="4" cy="16" r="1.5" /> <circle cx="4" cy="16" r="1.5" />
<circle cx="10" cy="16" r="1.5" /> <circle cx="10" cy="16" r="1.5" />
</svg> </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> </div>
{description && ( {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>
<button <div className="flex items-center gap-2 shrink-0">
type="button" {onToggleDetails ? (
onClick={(e) => { <button
e.stopPropagation(); type="button"
onRemove(); onClick={(e) => {
}} e.stopPropagation();
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" onToggleDetails();
title="Remove widget" }}
> className={`rounded-xl border px-3 py-1.5 text-[11px] font-semibold transition ${
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> showDetails
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> ? "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"
</svg> : "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"
</button> }`}
title={showDetails ? "Hide details" : "Show details"}
>
{showDetails ? "Details on" : "Details off"}
</button>
) : null}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div> </div>
{/* Subtle separator */} <div className="mx-4 border-t border-gray-200/80 dark:border-gray-800" />
<div className="mx-4 border-t border-gray-100 dark:border-gray-800" />
{/* Body */} <div className="flex-1 overflow-auto p-4 pt-3">{children}</div>
<div className="flex-1 overflow-auto p-4">{children}</div>
</motion.div> </motion.div>
); );
} }
@@ -19,7 +19,67 @@ function textColorClass(pct: number): string {
return "text-green-700"; 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) { export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const { clients } = useWidgetFilterOptions(); const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>( const filters = useMemo<WidgetFilter[]>(
@@ -39,7 +99,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
const clientId = (config.clientId as string) ?? ""; const clientId = (config.clientId as string) ?? "";
const rows = useMemo(() => { const rows = useMemo(() => {
const all = data ?? []; const all = (data ?? []) as BudgetForecastRow[];
return all.filter((r) => { return all.filter((r) => {
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false; if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
if (clientId && r.clientId !== clientId) return false; if (clientId && r.clientId !== clientId) return false;
@@ -47,6 +107,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
}); });
}, [data, search, clientId]); }, [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) { if (isLoading && !data) {
return ( return (
<div className="flex flex-col gap-1 pt-1"> <div className="flex flex-col gap-1 pt-1">
@@ -75,6 +150,28 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
return ( return (
<div className="flex flex-col h-full overflow-hidden"> <div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} /> <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"> <div className="overflow-auto flex-1">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0"> <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" /> Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
</th> </th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400"> <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>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400"> <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" /> 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"> <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30"> <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">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span> <div>
{row.projectName} <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>
<td className="px-3 py-2"> <td className="px-3 py-2 align-top">
<div className="flex items-center gap-2"> <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 className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div <div
@@ -112,14 +239,37 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
{row.pctUsed}% {row.pctUsed}%
</span> </span>
</div> </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>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums"> <td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums align-top">
{row.burnRate > 0 <div>
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC` {row.burnRate > 0 ? formatCurrency(row.burnRate) : "\u2014"}
: "\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>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums"> <td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums align-top">
{row.estimatedExhaustionDate ?? "\u2014"} <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> </td>
</tr> </tr>
))} ))}
@@ -36,8 +36,91 @@ type ChargeabilityRow = {
chargeabilityTarget: number; chargeabilityTarget: number;
actualChargeability: number; actualChargeability: number;
expectedChargeability: 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 }) { function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null); 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) { 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 { chapters } = useWidgetFilterOptions();
const widgetFilters = useMemo<WidgetFilter[]>( const widgetFilters = useMemo<WidgetFilter[]>(
@@ -86,6 +175,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
); );
const includeProposed = !!config.includeProposed; const includeProposed = !!config.includeProposed;
const showDetails = !!config.showDetails;
const chapterFilter = (config.chapter as string) ?? ""; const chapterFilter = (config.chapter as string) ?? "";
const [showDeparted, setShowDeparted] = useState(false); const [showDeparted, setShowDeparted] = useState(false);
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]); 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"> <p className="text-xs text-gray-400 flex items-center gap-1">
Period: {month} Period: {month}
<InfoTooltip <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" width="w-72"
/> />
</p> </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"> <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 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"> <span className="ml-1 font-normal normal-case text-gray-400">
{visibleTop.length}/{top.length} {visibleTop.length}/{top.length}
</span> </span>
@@ -390,18 +480,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
{visibleTop.map((r, i) => ( {visibleTop.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40"> <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-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"> <div className="truncate">
<span title={r.displayName}>{r.displayName}</span> <span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>} {r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</div> </div>
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
<UtilizationBar percent={r.actualChargeability} /> <UtilizationBar percent={r.actualChargeability} />
</td> </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">
<AnimatedNumber value={r.actualChargeability} suffix="%" /> <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>
<td className="px-2 py-1 text-right text-gray-400"> <td className="px-2 py-1 text-right text-gray-400 align-top">
<AnimatedNumber value={r.expectedChargeability} suffix="%" /> <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> </td>
</tr> </tr>
))} ))}
@@ -473,18 +578,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
<tbody className="divide-y divide-gray-50"> <tbody className="divide-y divide-gray-50">
{visibleWatchlist.map((r) => ( {visibleWatchlist.map((r) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40"> <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"> <div className="truncate">
<span title={r.displayName}>{r.displayName}</span> <span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>} {r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</div> </div>
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
<UtilizationBar percent={r.actualChargeability} /> <UtilizationBar percent={r.actualChargeability} />
</td> </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">
<AnimatedNumber value={r.actualChargeability} suffix="%" /> <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>
<td className="px-2 py-1 text-right text-gray-400"> <td className="px-2 py-1 text-right text-gray-400 align-top">
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" /> <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> </td>
</tr> </tr>
))} ))}
@@ -8,7 +8,53 @@ import { ProgressRing } from "~/components/ui/ProgressRing.js";
type GroupBy = "project" | "person" | "chapter"; 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) { export function DemandWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const groupBy = (config.groupBy as GroupBy) || "project"; const groupBy = (config.groupBy as GroupBy) || "project";
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount"; 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 sorted = [...rows].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1; const mult = sortDir === "asc" ? 1 : -1;
@@ -144,37 +190,84 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{sorted.map((row) => ( {sorted.map((row) => (
<tr key={row.id} className="hover:bg-gray-50"> <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">
{groupBy === "project" ? ( <div className="font-medium truncate">
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span> {groupBy === "project" ? (
) : ( <span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
row.name ) : (
)} 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>
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
{groupBy === "project" && ( {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; const ftes = row.requiredFTEs as unknown as number;
if (ftes <= 0) return "—"; if (ftes <= 0) return "—";
const requiredHours = ftes * 22 * 3 * 8; const requiredHours = row.derivation?.requiredHours ?? null;
const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100)); const rawFillPct = row.derivation?.fillPct ?? null;
const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3; const fillPct = Math.min(100, rawFillPct ?? 0);
const isBelowTarget = rawFillPct !== null ? rawFillPct < 100 : false;
const ringColor = isBelowTarget const ringColor = isBelowTarget
? "var(--color-red-500, #ef4444)" ? "var(--color-red-500, #ef4444)"
: "var(--color-green-500, #22c55e)"; : "var(--color-green-500, #22c55e)";
return ( return (
<span className="inline-flex items-center gap-1.5"> <div className="inline-flex flex-col items-end gap-1">
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} /> <span className="inline-flex items-center gap-1.5">
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}> <ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
{ftes} FTE <span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE
</span>
</span> </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>
)} )}
<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> </tr>
))} ))}
</tbody> </tbody>
@@ -1,55 +1,172 @@
"use client"; "use client";
import { import { useMemo, useState } from "react";
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Legend,
} from "recharts";
const COLORS = [ type PeakTimesChartRow = {
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", period: string;
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6", label: string;
]; bookedHours: number;
capacityHours: number;
utilizationPct: number;
remainingHours: number;
overbookedHours: number;
isCurrentPeriod: boolean;
};
interface PeakTimesChartProps { interface PeakTimesChartProps {
chartData: Record<string, number | string>[]; rows: PeakTimesChartRow[];
groups: string[]; selectedPeriod: string | null;
onSelectedPeriodChange?: (period: string) => void;
} }
export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) { function formatHours(value: number): string {
if (chartData.length === 0) { 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 ( return (
<div className="flex items-center justify-center h-full text-sm text-gray-400"> <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 selected period. No allocation data in the selected horizon.
</div> </div>
); );
} }
return ( return (
<ResponsiveContainer width="100%" height="100%"> <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))]">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" /> <div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
<XAxis dataKey="period" tick={{ fontSize: 10 }} /> Overall Utilization
<YAxis tick={{ fontSize: 10 }} /> </div>
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} /> {activeRow ? (
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} <div className="min-w-0 text-right">
<ReferenceLine <div className={`truncate text-sm font-semibold ${utilizationTextTone(activeRow.utilizationPct)}`}>
{...({ dataKey: "capacity" } as any)} {activeRow.label} · {activeRow.utilizationPct}%
stroke="#ef4444" </div>
strokeDasharray="5 5" <div className="truncate text-[11px] text-slate-500 dark:text-slate-400">
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }} {formatHours(activeRow.bookedHours)}h / {formatHours(activeRow.capacityHours)}h
/> </div>
{groups.map((g, i) => ( </div>
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} /> ) : null}
))} </div>
</BarChart>
</ResponsiveContainer> <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>
))}
</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"; "use client";
import { useMemo } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.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" /> }, { ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
); );
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) { type PeakDepartmentRow = {
const granularity = (config.granularity as "week" | "month") || "month"; name: string;
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project"; 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 now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(); const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).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( const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
{ startDate, endDate, granularity, groupBy }, { startDate, endDate, granularity: "month", groupBy: "chapter" },
{ staleTime: 120_000, placeholderData: (prev) => prev }, { 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 ( return (
<div className="flex flex-col gap-3 h-full pt-2"> <div className="flex h-full flex-col gap-3 pt-2">
<div className="flex gap-2"> <div className="grid grid-cols-3 gap-2">
<div className="h-7 w-28 shimmer-skeleton rounded-lg" /> {[...Array(3)].map((_, index) => (
<div className="h-7 w-28 shimmer-skeleton rounded-lg" /> <div key={index} className="h-14 rounded-2xl shimmer-skeleton" />
</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> </div>
<div className="flex-1 rounded-[22px] shimmer-skeleton" />
<div className="h-32 rounded-[22px] shimmer-skeleton" />
</div> </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 ( return (
<div className="flex flex-col h-full gap-3"> <div className="flex h-full flex-col gap-2 overflow-hidden">
{/* Controls + info */} <div className="flex items-center justify-between gap-3">
<div className="flex gap-2 items-center"> <div className="grid min-w-0 flex-1 grid-cols-3 gap-2">
<select <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">
value={granularity} <div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
onChange={(e) => onConfigChange?.({ granularity: e.target.value })} Current
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white" </div>
> <div className="mt-0.5 flex items-baseline justify-between gap-3">
<option value="month">Monthly</option> <span className={`text-base font-semibold ${utilizationTextTone(currentPeriodRow?.utilizationPct ?? 0)}`}>
<option value="week">Weekly</option> {currentPeriodRow?.utilizationPct ?? 0}%
</select> </span>
<select <span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
value={groupBy} {currentPeriodRow?.label ?? "No data"}
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })} </span>
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white" </div>
> </div>
<option value="project">By Project</option>
<option value="chapter">By Chapter</option> <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">
<option value="resource">By Resource</option> <div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
</select> 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 <InfoTooltip
content={ content={
<span> <span>
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br /> The top chart shows total booked load against effective capacity.<br />
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br /> The current month is marked with a blue accent.<br />
Bars exceeding the capacity line indicate over-allocation risk. Hover any month to inspect details and click to pin the department breakdown.
</span> </span>
} }
width="w-80" width="w-80"
@@ -95,9 +261,72 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
/> />
</div> </div>
{/* Chart */} <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="flex-1 min-h-0"> <div className="min-h-0">
<PeakTimesChart chartData={chartData} groups={groups} /> <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>
</div> </div>
); );
@@ -8,6 +8,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js"; import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js"; import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js"; import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
import { formatMoney } from "~/lib/format.js";
function healthDot(value: number): string { function healthDot(value: number): string {
if (value >= 70) return "bg-green-500"; 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"; 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) { export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const { clients } = useWidgetFilterOptions(); const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>( 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"> <thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr> <tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400"> <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>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400"> <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>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400"> <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." /> Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
@@ -103,26 +152,66 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<tbody className="divide-y divide-gray-100 dark:divide-gray-800"> <tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30"> <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"> <td className="px-3 py-2 text-gray-900 dark:text-gray-100 max-w-[320px]">
<Link href={`/projects/${(row as any).id}`} className="hover:text-brand-600 dark:hover:text-brand-400 transition-colors"> <Link href={`/projects/${(row as any).id}`} className="block hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span> <div className="truncate font-medium">
{row.projectName} <span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</div>
</Link> </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>
<td className="px-3 py-2"> <td className="px-3 py-2">
<div className="flex items-center justify-center gap-2"> <div className="flex flex-col items-center justify-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
<span <div className="flex items-center justify-center gap-2">
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`} <span
title={`Budget: ${row.budgetHealth}%`} className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
/> title={`Budget: ${row.budgetHealth}%`}
<span />
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`} <span
title={`Staffing: ${row.staffingHealth}%`} className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
/> title={`Staffing: ${row.staffingHealth}%`}
<span />
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`} <span
title={`Timeline: ${row.timelineHealth}%`} className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
/> 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> </div>
</td> </td>
<td className="px-3 py-2 text-center"> <td className="px-3 py-2 text-center">
@@ -19,6 +19,8 @@ function StatCard({
value, value,
suffix, suffix,
sub, sub,
details,
showDetails = false,
info, info,
accentColor, accentColor,
delay = 0, delay = 0,
@@ -28,6 +30,8 @@ function StatCard({
value: number; value: number;
suffix?: string; suffix?: string;
sub?: string; sub?: string;
details?: string[];
showDetails?: boolean;
info?: React.ReactNode; info?: React.ReactNode;
accentColor?: "green" | "amber" | "red"; accentColor?: "green" | "amber" | "red";
delay?: number; delay?: number;
@@ -66,13 +70,37 @@ function StatCard({
</div> </div>
)} )}
{sub && <p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{sub}</p>} {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> </div>
</FadeIn> </FadeIn>
); );
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars function formatShortDate(value?: string | Date | null): string {
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) { 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, { const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
staleTime: 60_000, staleTime: 60_000,
placeholderData: (prev) => prev, placeholderData: (prev) => prev,
@@ -104,21 +132,33 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
<StatCard <StatCard
label="Total Resources" label="Total Resources"
value={data.totalResources} value={data.totalResources}
sub={`${data.activeResources} active`} sub={`${data.activeResources} active / ${data.inactiveResources ?? Math.max(data.totalResources - data.activeResources, 0)} inactive`}
info="All resources in the system. Sub-line shows active resources only." details={[
"Basis: all resource master records",
]}
showDetails={showDetails}
info="All resources in the system. Sub-line shows active versus inactive records."
delay={0} delay={0}
/> />
<StatCard <StatCard
label="Active Projects" label="Active Projects"
value={data.activeProjects} 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)." info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
delay={0.05} delay={0.05}
/> />
<StatCard <StatCard
label="Total Allocations" label="Total Allocations"
value={data.totalAllocations} 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." info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
delay={0.1} delay={0.1}
/> />
@@ -127,7 +167,13 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
value={budgetPct} value={budgetPct}
suffix="%" suffix="%"
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`} 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} accentColor={budgetAccent}
delay={0.15} delay={0.15}
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }} 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/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
{ href: "/admin/vacations", label: "Vacations & Holidays", icon: <VacationIcon /> },
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> }, { href: "/admin/users", label: "Users", icon: <UsersIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> }, { href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> }, { href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import type { Route } from "next"; import type { Route } from "next";
import { motion, useAnimationControls } from "framer-motion"; import { motion, useAnimationControls } from "framer-motion";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string { function relativeTime(date: Date): string {
@@ -28,12 +29,16 @@ type TabKey = "all" | "tasks" | "reminders";
export function NotificationBell() { export function NotificationBell() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>("all"); const [activeTab, setActiveTab] = useState<TabKey>("all");
const ref = useRef<HTMLDivElement>(null);
const bellRef = useRef<HTMLButtonElement>(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 { data: session, status } = useSession();
const isAuthenticated = status === "authenticated" && !!session?.user?.email; 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 badgeControls = useAnimationControls();
const prevUnreadRef = useRef<number | null>(null); 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() { function handleMarkAllRead() {
if (!isAuthenticated) return; if (!isAuthenticated) return;
markRead.mutate({}); markRead.mutate({});
@@ -150,12 +122,18 @@ export function NotificationBell() {
]; ];
return ( return (
<div ref={ref} className="relative"> <div className="relative">
{/* Bell button */} {/* Bell button */}
<button <button
ref={bellRef} ref={bellRef}
type="button" 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" 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" aria-label="Notifications"
> >
@@ -193,12 +171,12 @@ export function NotificationBell() {
{/* Dropdown panel — rendered via portal to escape sidebar overflow */} {/* Dropdown panel — rendered via portal to escape sidebar overflow */}
{open && createPortal( {open && createPortal(
<motion.div <motion.div
ref={dropdownRef} ref={panelRef}
initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }} initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }}
animate={{ opacity: 1, scaleY: 1, scaleX: 1 }} animate={{ opacity: 1, scaleY: 1, scaleX: 1 }}
transition={{ duration: 0.15, ease: "easeOut" }} 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" 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 */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800"> <div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
+390 -11
View File
@@ -7,7 +7,7 @@ import { clsx } from "clsx";
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
type EntityType = "resource" | "project" | "assignment"; type EntityType = "resource" | "project" | "assignment" | "resource_month";
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in"; type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
interface FilterRow { interface FilterRow {
@@ -17,10 +17,50 @@ interface FilterRow {
value: string; 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 }[] = [ const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "resource", label: "Resources" }, { value: "resource", label: "Resources" },
{ value: "project", label: "Projects" }, { value: "project", label: "Projects" },
{ value: "assignment", label: "Assignments" }, { value: "assignment", label: "Assignments" },
{ value: "resource_month", label: "Resource Months" },
]; ];
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
@@ -36,10 +76,120 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
const PAGE_SIZE = 50; 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 { function generateId(): string {
return Math.random().toString(36).slice(2, 10); 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 ────────────────────────────────────────────────────────────── // ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() { export function ReportBuilder() {
@@ -50,6 +200,9 @@ export function ReportBuilder() {
const [groupBy, setGroupBy] = useState<string>(""); const [groupBy, setGroupBy] = useState<string>("");
const [sortBy, setSortBy] = useState<string>(""); const [sortBy, setSortBy] = useState<string>("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); 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 [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false); const [runQuery, setRunQuery] = useState(false);
@@ -59,7 +212,21 @@ export function ReportBuilder() {
{ placeholderData: keepPreviousData }, { 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) // Scalar columns (for filter/sort/group — only non-relation columns)
const scalarColumns = useMemo( const scalarColumns = useMemo(
@@ -76,12 +243,13 @@ export function ReportBuilder() {
filters: filters filters: filters
.filter((f) => f.field && f.value) .filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })), .map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}), ...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}), ...(sortBy ? { sortBy, sortDir } : {}),
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset: page * 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 // Fetch report data
const reportQuery = trpc.report.getReportData.useQuery( const reportQuery = trpc.report.getReportData.useQuery(
@@ -99,6 +267,40 @@ export function ReportBuilder() {
setFilters([]); setFilters([]);
setGroupBy(""); setGroupBy("");
setSortBy(""); 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); setRunQuery(false);
setPage(0); setPage(0);
}, []); }, []);
@@ -163,6 +365,7 @@ export function ReportBuilder() {
filters: filters filters: filters
.filter((f) => f.field && f.value) .filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })), .map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}), ...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}), ...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000, limit: 5000,
@@ -179,7 +382,42 @@ export function ReportBuilder() {
} catch { } catch {
// Error handled by tRPC // 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 ────────────────────────────────────────────────────────── // ─── Derived ──────────────────────────────────────────────────────────
@@ -188,6 +426,15 @@ export function ReportBuilder() {
const outputColumns = reportQuery.data?.columns ?? []; const outputColumns = reportQuery.data?.columns ?? [];
const totalPages = Math.ceil(totalCount / PAGE_SIZE); const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching; 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 // Column label lookup
const columnLabelMap = useMemo(() => { const columnLabelMap = useMemo(() => {
@@ -212,6 +459,61 @@ export function ReportBuilder() {
{/* Config Panel */} {/* 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="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 */} {/* Entity Selector */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -234,6 +536,73 @@ export function ReportBuilder() {
</button> </button>
))} ))}
</div> </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> </div>
{/* Column Picker */} {/* 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" 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> <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"> <span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
{col.dataType} {col.dataType}
</span> </span>
@@ -428,13 +802,18 @@ export function ReportBuilder() {
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950"> <div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */} {/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800"> <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="flex items-center gap-3"> <div className="space-y-1">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2> <div className="flex items-center gap-3">
{!isLoading && ( <h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400"> {!isLoading && (
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""} <span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
</span> {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
)} </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> </div>
<button <button
type="button" type="button"
@@ -209,17 +209,74 @@ interface SuggestionLike {
resourceName: string; resourceName: string;
eid: string; eid: string;
score: number; score: number;
valueScore?: number;
scoreBreakdown: { scoreBreakdown: {
skillScore: number; skillScore: number;
availabilityScore: number; availabilityScore: number;
costScore: number; costScore: number;
utilizationScore: number; utilizationScore: number;
total?: number;
}; };
matchedSkills: string[]; matchedSkills: string[];
missingSkills: string[]; missingSkills: string[];
availabilityConflicts: string[]; availabilityConflicts: string[];
estimatedDailyCostCents: number; estimatedDailyCostCents: number;
currentUtilization: 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 { interface SuggestionCardProps {
@@ -231,10 +288,24 @@ interface SuggestionCardProps {
} }
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: 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 ( 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 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-center gap-4"> <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"> <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>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</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="text-sm text-gray-500">{suggestion.eid}</div>
<div className="mt-1 text-xs text-gray-500">{locationLabel}</div>
</div> </div>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails((prev) => !prev)}
>
{showDetails ? "Hide Details" : "Details"}
</Button>
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => setExpanded((prev) => !prev)} onClick={() => setShowAssignForm((prev) => !prev)}
> >
{expanded ? "Cancel" : "Assign"} {showAssignForm ? "Close Assign" : "Assign"}
</Button> </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="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> <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> </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"> <div className="mt-4 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => ( {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"> <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>
<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"> <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>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</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"> <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> </span>
)} )}
</div> </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 <AssignForm
resourceId={suggestion.resourceId} resourceId={suggestion.resourceId}
resourceName={suggestion.resourceName} resourceName={suggestion.resourceName}
searchCriteria={searchCriteria} searchCriteria={searchCriteria}
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)} onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
onError={onError} onError={onError}
onCancel={() => setExpanded(false)} onCancel={() => setShowAssignForm(false)}
/> />
)} )}
</div> </div>
@@ -499,3 +691,45 @@ function ScoreBar({ label, value, tooltip }: { label: string; value: number; too
</div> </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 type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
@@ -28,9 +29,14 @@ export function AllocationPopover({
anchorX, anchorX,
anchorY, anchorY,
}: AllocationPopoverProps) { }: AllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline(); 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( const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId }, { 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 { function toDateInput(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0"); 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) { if (isLoading || !allocation) {
return ( 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... Loading...
</div> </div>
); );
@@ -115,7 +101,7 @@ export function AllocationPopover({
return ( return (
<div <div
ref={ref} ref={ref}
style={popoverStyle} style={style}
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden" className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
> >
{/* Header */} {/* Header */}
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js"; import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatCents, formatDateLong } from "~/lib/format.js"; import { formatCents, formatDateLong } from "~/lib/format.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface DemandPopoverProps { interface DemandPopoverProps {
demand: TimelineDemandEntry; demand: TimelineDemandEntry;
@@ -21,17 +21,12 @@ export function DemandPopover({
anchorX, anchorX,
anchorY, anchorY,
}: DemandPopoverProps) { }: DemandPopoverProps) {
const ref = useRef<HTMLDivElement>(null); const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX, y: anchorY },
useEffect(() => { width: 300,
function handleClick(e: MouseEvent) { estimatedHeight: 340,
if (ref.current && !ref.current.contains(e.target as Node)) { onClose,
onClose(); });
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified"; const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
const roleColor = demand.roleEntity?.color ?? "#f59e0b"; const roleColor = demand.roleEntity?.color ?? "#f59e0b";
@@ -41,18 +36,10 @@ export function DemandPopover({
const totalHours = demand.hoursPerDay * days; const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * 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 ( return (
<div <div
ref={ref} 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" className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
> >
{/* Header */} {/* Header */}
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@capakraken/shared"; import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js"; import { DateInput } from "~/components/ui/DateInput.js";
interface NewAllocationPopoverProps { interface NewAllocationPopoverProps {
@@ -36,7 +37,12 @@ export function NewAllocationPopover({
onClose, onClose,
onCreated, onCreated,
}: NewAllocationPopoverProps) { }: 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 invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState(""); 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() { function handleCreate() {
if (!selectedProjectId) return; if (!selectedProjectId) return;
createMutation.mutate({ createMutation.mutate({
@@ -93,13 +88,10 @@ export function NewAllocationPopover({
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0; 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 ( return (
<div <div
ref={ref} 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" 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 */} {/* Header */}
@@ -1,9 +1,9 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { formatCents } from "~/lib/format.js"; import { formatCents } from "~/lib/format.js";
import type { SkillEntry } from "@capakraken/shared"; import type { SkillEntry } from "@capakraken/shared";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface ResourceHoverCardProps { interface ResourceHoverCardProps {
resourceId: string; resourceId: string;
@@ -12,34 +12,20 @@ interface ResourceHoverCardProps {
} }
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) { export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
const ref = useRef<HTMLDivElement>(null); const { ref, style } = useViewportPopover({
const [pos, setPos] = useState({ left: 0, top: 0 }); anchor: { kind: "element", element: anchorEl },
width: 280,
estimatedHeight: 320,
onClose,
side: "right",
ignoreElements: [anchorEl],
});
const { data, isLoading } = trpc.resource.getHoverCard.useQuery( const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
{ id: resourceId }, { id: resourceId },
{ staleTime: 60_000 }, { 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 skills = (data?.skills ?? []) as unknown as SkillEntry[];
const mainSkills = skills.filter((s) => s.isMainSkill); const mainSkills = skills.filter((s) => s.isMainSkill);
const topSkills = skills const topSkills = skills
@@ -47,19 +33,11 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
.sort((a, b) => b.proficiency - a.proficiency) .sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 6); .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 ( return (
<div <div
ref={ref} ref={ref}
data-resource-hover-card="true" 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" className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
onMouseLeave={onClose} onMouseLeave={onClose}
> >
@@ -113,6 +113,16 @@ export type VacationEntry = {
halfDayPart?: string | null; 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 ────────────────────────────────────────────────────────── // ─── Context shape ──────────────────────────────────────────────────────────
export interface TimelineContextValue { export interface TimelineContextValue {
@@ -314,9 +324,43 @@ export function TimelineProvider({
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 }, { 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 vacationsByResource = useMemo(() => {
const map = new Map<string, VacationEntry[]>(); 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); const existing = map.get(vacation.resourceId);
if (existing) { if (existing) {
existing.push(vacation); existing.push(vacation);
@@ -325,7 +369,7 @@ export function TimelineProvider({
} }
} }
return map; return map;
}, [vacationEntries]); }, [holidayOverlayEntries, vacationEntries]);
// When EID filter is active, explicitly fetch those resources. // When EID filter is active, explicitly fetch those resources.
const { data: eidFilterData } = trpc.resource.list.useQuery( const { data: eidFilterData } = trpc.resource.list.useQuery(
@@ -2,7 +2,8 @@
import { clsx } from "clsx"; import { clsx } from "clsx";
import { createPortal } from "react-dom"; 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"; import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters { export interface TimelineFilters {
@@ -159,55 +160,12 @@ export function TimelineFilter({
isOpen, isOpen,
onClose, onClose,
}: TimelineFilterProps) { }: TimelineFilterProps) {
const panelRef = useRef<HTMLDivElement | null>(null); const { panelRef, position } = useAnchoredOverlay<HTMLDivElement>({
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 }); open: isOpen,
onClose,
const updatePanelPosition = useCallback(() => { align: "end",
const trigger = anchorRef.current; triggerRef: anchorRef,
if (!trigger) return; });
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? 320;
const viewportPadding = 16;
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
setPanelPosition({
top: rect.bottom + 8,
left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
});
}, [anchorRef]);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
onClose();
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [anchorRef, isOpen, onClose, updatePanelPosition]);
if (!isOpen) return null; if (!isOpen) return null;
@@ -221,7 +179,7 @@ export function TimelineFilter({
return createPortal( return createPortal(
<div <div
ref={panelRef} 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" 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"> <div className="mb-4 flex items-center justify-between">
@@ -188,8 +188,10 @@ function TimelineProjectPanelInner({
} | null>(null); } | null>(null);
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null); const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
const vacationTooltipRef = useRef<HTMLDivElement | null>(null); const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
const demandTooltipRef = useRef<HTMLDivElement | null>(null);
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 }); const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
const vacationTooltipPosRef = useRef({ left: 0, top: 0 }); const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
const [heatmapHover, setHeatmapHover] = useState<{ const [heatmapHover, setHeatmapHover] = useState<{
date: Date; date: Date;
@@ -206,6 +208,22 @@ function TimelineProjectPanelInner({
approvedBy?: { name?: string | null; email: string } | null; approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null; approvedAt?: Date | string | null;
}>(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 { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
const dateIndexByTime = new Map<number, number>(); const dateIndexByTime = new Map<number, number>();
@@ -472,6 +490,7 @@ function TimelineProjectPanelInner({
vacationHoverRafRef.current = requestAnimationFrame(() => { vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null; vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect); const date = xToDate(clientX, rect);
date.setHours(0, 0, 0, 0);
const time = date.getTime(); const time = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? []; const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit = const hit =
@@ -507,18 +526,58 @@ function TimelineProjectPanelInner({
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1; const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null; const shouldClearVacation = hoveredVacationKeyRef.current !== null;
const shouldClearDemand = demandHover !== null;
lastHeatmapDayRef.current = -1; lastHeatmapDayRef.current = -1;
lastHeatmapResourceRef.current = null; lastHeatmapResourceRef.current = null;
hoveredVacationKeyRef.current = null; hoveredVacationKeyRef.current = null;
if (shouldClearHeatmap || shouldClearVacation) { if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
startTransition(() => { startTransition(() => {
if (shouldClearHeatmap) setHeatmapHover(null); if (shouldClearHeatmap) setHeatmapHover(null);
if (shouldClearVacation) setVacationHover(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( useEffect(
() => () => { () => () => {
@@ -672,6 +731,8 @@ function TimelineProjectPanelInner({
onAllocMouseDown, onAllocMouseDown,
onAllocTouchStart, onAllocTouchStart,
onAllocationContextMenu, onAllocationContextMenu,
handleDemandHoverMove,
clearHoverTooltips,
multiSelectState, multiSelectState,
allocDragState, allocDragState,
) )
@@ -699,6 +760,9 @@ function TimelineProjectPanelInner({
</div> </div>
<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" className="relative overflow-hidden touch-none"
style={{ style={{
width: totalCanvasWidth, width: totalCanvasWidth,
@@ -792,8 +856,11 @@ function TimelineProjectPanelInner({
heatmapTooltipPos={heatmapTooltipPosRef.current} heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef} vacationTooltipRef={vacationTooltipRef}
vacationTooltipPos={vacationTooltipPosRef.current} vacationTooltipPos={vacationTooltipPosRef.current}
demandTooltipRef={demandTooltipRef}
demandTooltipPos={demandTooltipPosRef.current}
heatmapHover={heatmapHover} heatmapHover={heatmapHover}
vacationHover={vacationHover} vacationHover={vacationHover}
demandHover={demandHover}
/> />
</div> </div>
); );
@@ -852,6 +919,8 @@ function renderOpenDemandRow(
anchorX: number, anchorX: number,
anchorY: number, anchorY: number,
) => void, ) => void,
onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
onClearHoverTooltips: () => void,
multiSelectState: MultiSelectState, multiSelectState: MultiSelectState,
allocDragState: AllocDragState, allocDragState: AllocDragState,
) { ) {
@@ -889,6 +958,7 @@ function renderOpenDemandRow(
<div <div
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950" className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
style={{ width: totalCanvasWidth, height: rowHeight }} style={{ width: totalCanvasWidth, height: rowHeight }}
onMouseLeave={onClearHoverTooltips}
> >
{rowGridLines} {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" /> <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", : "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", 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={{ style={{
left: left + 2, left: left + 2,
width: width - 4, width: width - 4,
@@ -986,6 +1055,7 @@ function renderOpenDemandRow(
e.clientY, e.clientY,
); );
}} }}
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
> >
{/* Left resize handle */} {/* Left resize handle */}
<div <div
@@ -1,8 +1,9 @@
"use client"; "use client";
import { createPortal } from "react-dom"; 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 { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import type { TimelineFilters } from "./TimelineFilter.js"; import type { TimelineFilters } from "./TimelineFilter.js";
@@ -20,68 +21,22 @@ function TimelineFilterDropdown({
tooltipContent?: ReactNode; tooltipContent?: ReactNode;
}) { }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null); const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
const panelRef = useRef<HTMLDivElement | null>(null); open: isOpen,
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 }); onClose: () => setIsOpen(false),
matchTriggerWidth: true,
const updatePanelPosition = useCallback(() => { });
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
});
}, []);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("keydown", handleEscape);
};
}, [isOpen, updatePanelPosition]);
return ( return (
<div ref={dropdownRef} className="relative"> <div ref={triggerRef} className="relative">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="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}`} className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
> >
<span className="text-left">{label}</span> <span className="text-left">{label}</span>
@@ -95,9 +50,9 @@ function TimelineFilterDropdown({
ref={panelRef} ref={panelRef}
style={{ style={{
position: "fixed", position: "fixed",
top: panelPosition.top, top: position.top,
left: panelPosition.left, left: position.left,
minWidth: panelPosition.minWidth, 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}`} 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 = requestAnimationFrame(() => {
vacationHoverRafRef.current = null; vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect); const date = xToDate(clientX, rect);
date.setHours(0, 0, 0, 0);
const t = date.getTime(); const t = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? []; const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit = const hit =
@@ -494,6 +495,10 @@ function TimelineResourcePanelInner({
{/* Row canvas */} {/* Row canvas */}
<div <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" className="relative overflow-hidden touch-none"
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }} style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
onMouseDown={(e) => { onMouseDown={(e) => {
@@ -542,10 +547,11 @@ function TimelineResourcePanelInner({
onAllocationContextMenu, onAllocationContextMenu,
multiSelectState, multiSelectState,
)} )}
{renderVacationBlocks( {filters.showVacations &&
vacationBlocksByResource.get(resource.id) ?? [], renderVacationBlocks(
rowHeight, vacationBlocksByResource.get(resource.id) ?? [],
)} rowHeight,
)}
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)} {displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" && {displayMode === "heatmap" &&
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)} renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
@@ -1,6 +1,13 @@
"use client"; "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 = { export type HeatmapHoverData = {
date: Date; date: Date;
@@ -30,6 +37,23 @@ export type VacationHoverData = {
approvedAt?: Date | string | null; 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 { interface TimelineTooltipProps {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>; heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number }; heatmapTooltipPos: { left: number; top: number };
@@ -37,6 +61,9 @@ interface TimelineTooltipProps {
vacationTooltipPos: { left: number; top: number }; vacationTooltipPos: { left: number; top: number };
heatmapHover: HeatmapHoverData | null; heatmapHover: HeatmapHoverData | null;
vacationHover: VacationHoverData | null; vacationHover: VacationHoverData | null;
demandTooltipRef?: React.RefObject<HTMLDivElement | null>;
demandTooltipPos?: { left: number; top: number };
demandHover?: DemandHoverData | null;
} }
export function TimelineTooltip({ export function TimelineTooltip({
@@ -46,7 +73,87 @@ export function TimelineTooltip({
vacationTooltipPos, vacationTooltipPos,
heatmapHover, heatmapHover,
vacationHover, vacationHover,
demandTooltipRef,
demandTooltipPos,
demandHover,
}: TimelineTooltipProps) { }: 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 // When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) { if (heatmapHover && vacationHover) {
return ( return (
@@ -114,14 +221,12 @@ export function TimelineTooltip({
<div className="mt-2 pt-2 border-t border-amber-700/40"> <div className="mt-2 pt-2 border-t border-amber-700/40">
<div className="flex items-center gap-1.5"> <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="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
<span className="font-semibold text-amber-300"> <span className="font-semibold text-amber-300">{vacationTitle}</span>
{vacationHover.type.replaceAll("_", " ")}
</span>
</div> </div>
<div className="mt-0.5 text-[11px] text-amber-200/80"> <div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)} {formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div> </div>
{vacationHover.note ? ( {vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div> <div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
) : null} ) : null}
</div> </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" 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"> <div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)} {formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div> </div>
{vacationHover.note ? ( {vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div> <div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null} ) : null}
</div> </div>
@@ -35,6 +35,11 @@ export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: num
return ( return (
<div <div
key={`vac-${v.id}`} 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( className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none", "absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
colorClass, colorClass,
+118 -90
View File
@@ -1,7 +1,9 @@
"use client"; "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 type { ColumnDef } from "@capakraken/shared";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
interface ColumnTogglePanelProps { interface ColumnTogglePanelProps {
allColumns: ColumnDef[]; allColumns: ColumnDef[];
@@ -17,18 +19,11 @@ export function ColumnTogglePanel({
defaultKeys, defaultKeys,
}: ColumnTogglePanelProps) { }: ColumnTogglePanelProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null); const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
open,
useEffect(() => { onClose: () => setOpen(false),
if (!open) return; align: "end",
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 dragKey = useRef<string | null>(null); const dragKey = useRef<string | null>(null);
@@ -59,11 +54,20 @@ export function ColumnTogglePanel({
const builtins = allColumns.filter((c) => !c.isCustom); const builtins = allColumns.filter((c) => !c.isCustom);
const customs = 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 ( return (
<div className="relative" ref={panelRef}> <div className="relative">
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={handleToggleOpen}
title="Toggle columns" title="Toggle columns"
className={`p-1.5 rounded-lg border text-sm transition-colors ${ className={`p-1.5 rounded-lg border text-sm transition-colors ${
open open
@@ -80,83 +84,107 @@ export function ColumnTogglePanel({
</svg> </svg>
</button> </button>
{open && ( {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"> createPortal(
<div className="px-3 pb-1 flex items-center justify-between"> <div
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span> ref={panelRef}
<button 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"
type="button" style={{ top: position.top, left: position.left }}
onClick={reset} >
className="text-xs text-brand-600 hover:text-brand-800" <div className="flex items-center justify-between px-3 pb-1">
> <span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Reset Columns
</button> </span>
</div> <button
type="button"
onClick={reset}
className="text-xs text-brand-600 hover:text-brand-800"
>
Reset
</button>
</div>
<div className="max-h-72 overflow-y-auto"> <div className="max-h-72 overflow-y-auto">
{builtins.map((col) => { {builtins.map((col) => {
const isVisible = visibleKeys.includes(col.key); const isVisible = visibleKeys.includes(col.key);
return ( return (
<div <div
key={col.key} key={col.key}
draggable={col.hideable && isVisible} draggable={col.hideable && isVisible}
onDragStart={() => { dragKey.current = col.key; }} onDragStart={() => {
onDragOver={(e) => { e.preventDefault(); }} dragKey.current = col.key;
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 ${ onDragOver={(event) => {
!col.hideable ? "opacity-50" : "cursor-grab" event.preventDefault();
}`} }}
> onDrop={() => {
{col.hideable && isVisible && ( if (dragKey.current) reorder(dragKey.current, col.key);
<span className="text-gray-300 text-xs select-none"></span> dragKey.current = null;
)} }}
<label className="flex items-center gap-2 flex-1 cursor-pointer"> className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 ${
<input !col.hideable ? "opacity-50" : "cursor-grab"
type="checkbox" }`}
checked={isVisible} >
onChange={() => toggle(col.key)} {col.hideable && isVisible && (
disabled={!col.hideable} <span className="select-none text-xs text-gray-300"></span>
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" )}
/> <label className="flex flex-1 cursor-pointer items-center gap-2">
<span className="text-sm text-gray-700">{col.label}</span> <input
</label> type="checkbox"
</div> checked={isVisible}
); onChange={() => toggle(col.key)}
})} disabled={!col.hideable}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-200">{col.label}</span>
</label>
</div>
);
})}
{customs.length > 0 && ( {customs.length > 0 && (
<> <>
<div className="my-1 border-t border-gray-100" /> <div className="my-1 border-t border-gray-100 dark:border-gray-800" />
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p> <p className="px-3 py-1 text-xs font-medium text-gray-400">Custom Fields</p>
{customs.map((col) => { {customs.map((col) => {
const isVisible = visibleKeys.includes(col.key); const isVisible = visibleKeys.includes(col.key);
return ( return (
<div <div
key={col.key} key={col.key}
draggable={isVisible} draggable={isVisible}
onDragStart={() => { dragKey.current = col.key; }} onDragStart={() => {
onDragOver={(e) => { e.preventDefault(); }} dragKey.current = col.key;
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" onDragOver={(event) => {
> event.preventDefault();
{isVisible && <span className="text-gray-300 text-xs select-none"></span>} }}
<label className="flex items-center gap-2 flex-1 cursor-pointer"> onDrop={() => {
<input if (dragKey.current) reorder(dragKey.current, col.key);
type="checkbox" dragKey.current = null;
checked={isVisible} }}
onChange={() => toggle(col.key)} className="flex cursor-grab items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800"
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500" >
/> {isVisible && <span className="select-none text-xs text-gray-300"></span>}
<span className="text-sm text-gray-700">{col.label}</span> <label className="flex flex-1 cursor-pointer items-center gap-2">
</label> <input
</div> type="checkbox"
); checked={isVisible}
})} onChange={() => toggle(col.key)}
</> className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
)} />
</div> <span className="text-sm text-gray-700 dark:text-gray-200">
</div> {col.label}
)} </span>
</label>
</div>
);
})}
</>
)}
</div>
</div>,
document.body,
)}
</div> </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"; "use client";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import Link from "next/link";
import { VacationStatus, VacationType } from "@capakraken/shared"; import { VacationStatus, VacationType } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { VacationModal } from "./VacationModal.js"; import { VacationModal } from "./VacationModal.js";
@@ -137,6 +138,13 @@ export function VacationClient() {
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1> <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="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> </div>
<button <button
type="button" type="button"
@@ -10,6 +10,34 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js"; import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
const VACATION_TYPES = Object.values(VacationType); 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 { interface VacationModalProps {
resourceId?: string; resourceId?: string;
@@ -17,13 +45,34 @@ interface VacationModalProps {
onSuccess: () => void; onSuccess: () => void;
} }
function toDateInputValue(date: Date | string | null | undefined): string { function toUtcInputDate(value: string): Date {
if (!date) return ""; return new Date(`${value}T00:00:00.000Z`);
const d = typeof date === "string" ? new Date(date) : date; }
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0"); function buildHolidayBasisLabel(preview: VacationPreviewData): string[] {
const day = String(d.getDate()).padStart(2, "0"); const parts = [];
return `${y}-${m}-${day}`;
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) { 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 }, { 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 utils = trpc.useUtils();
const createMutation = trpc.vacation.create.useMutation({ const createMutation = trpc.vacation.create.useMutation({
@@ -166,7 +233,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{/* Type */} {/* Type */}
<div> <div>
<label htmlFor="vac-type" className={labelClass}> <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> </label>
<select <select
id="vac-type" id="vac-type"
@@ -174,7 +241,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
onChange={(e) => setType(e.target.value as VacationType)} onChange={(e) => setType(e.target.value as VacationType)}
className={inputClass} className={inputClass}
> >
{VACATION_TYPES.map((t) => ( {REQUESTABLE_VACATION_TYPES.map((t) => (
<option key={t} value={t}> <option key={t} value={t}>
{VACATION_TYPE_LABELS[t]} {VACATION_TYPE_LABELS[t]}
</option> </option>
@@ -282,6 +349,81 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
</div> </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 */} {/* Note */}
<div> <div>
<label htmlFor="vac-note" className={labelClass}> <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 { prisma } from "@capakraken/db";
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit"; 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 { logger } from "@capakraken/api/lib/logger";
import NextAuth, { type NextAuthConfig } from "next-auth"; import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials"; import Credentials from "next-auth/providers/credentials";
@@ -27,9 +27,12 @@ const authConfig = {
if (!parsed.success) return null; if (!parsed.success) return null;
const { email, password, totp } = parsed.data; const { email, password, totp } = parsed.data;
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
// Rate limit: 5 login attempts per 15 minutes per email // 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) { if (!rateLimitResult.allowed) {
// Audit failed login (rate limited) // Audit failed login (rate limited)
void createAuditEntry({ void createAuditEntry({
+3 -2
View File
@@ -16,10 +16,11 @@
"isolatedModules": true "isolatedModules": true
}, },
"include": [ "include": [
"next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts" ".next/types/**/*.ts",
"next-env.d.ts",
".next-e2e/types/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"
+4
View File
@@ -1,3 +1,5 @@
name: capakraken-prod
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@@ -66,4 +68,6 @@ services:
volumes: volumes:
capakraken_prod_pgdata: capakraken_prod_pgdata:
name: capakraken_prod_pgdata
capakraken_prod_redis: capakraken_prod_redis:
name: capakraken_prod_redis
+4 -1
View File
@@ -1,3 +1,5 @@
name: capakraken
services: services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@@ -69,7 +71,7 @@ services:
postgres-test: postgres-test:
image: postgres:16-alpine image: postgres:16-alpine
ports: ports:
- "5434:5432" - "${POSTGRES_TEST_PORT:-5434}:5432"
environment: environment:
POSTGRES_DB: capakraken_test POSTGRES_DB: capakraken_test
POSTGRES_USER: capakraken POSTGRES_USER: capakraken
@@ -81,3 +83,4 @@ services:
volumes: volumes:
capakraken_pgdata: 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", "dev": "turbo dev",
"build": "turbo build", "build": "turbo build",
"lint": "turbo lint", "lint": "turbo lint",
"test": "turbo test", "test": "turbo run test:unit",
"test:unit": "turbo test:unit", "test:unit": "turbo test:unit",
"test:e2e": "turbo test:e2e", "test:e2e": "turbo test:e2e",
"db:push": "pnpm --filter @capakraken/db db:push", "db:doctor": "node ./scripts/db-doctor.mjs capakraken",
"db:migrate": "pnpm --filter @capakraken/db db:migrate", "db:push": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:push",
"db:seed": "pnpm --filter @capakraken/db db:seed", "db:migrate": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:migrate",
"db:studio": "pnpm --filter @capakraken/db db:studio", "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:reset:dispo": "pnpm --filter @capakraken/db db:reset:dispo",
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo", "db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment", "db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
+1
View File
@@ -8,6 +8,7 @@
"./router": "./src/router/index.ts", "./router": "./src/router/index.ts",
"./trpc": "./src/trpc.ts", "./trpc": "./src/trpc.ts",
"./sse": "./src/sse/event-bus.ts", "./sse": "./src/sse/event-bus.ts",
"./lib/audit": "./src/lib/audit.ts",
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts", "./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
"./lib/logger": "./src/lib/logger.ts", "./lib/logger": "./src/lib/logger.ts",
"./middleware/rate-limit": "./src/middleware/rate-limit.ts" "./middleware/rate-limit": "./src/middleware/rate-limit.ts"
@@ -1,13 +1,14 @@
import { AllocationStatus, SystemRole } from "@capakraken/shared"; import { AllocationStatus, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { allocationRouter } from "../router/allocation.js"; 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"; import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({ vi.mock("../sse/event-bus.js", () => ({
emitAllocationCreated: vi.fn(), emitAllocationCreated: vi.fn(),
emitAllocationDeleted: vi.fn(), emitAllocationDeleted: vi.fn(),
emitAllocationUpdated: vi.fn(), emitAllocationUpdated: vi.fn(),
emitNotificationCreated: vi.fn(),
})); }));
vi.mock("../lib/budget-alerts.js", () => ({ vi.mock("../lib/budget-alerts.js", () => ({
@@ -18,6 +19,10 @@ vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn(), invalidateDashboardCache: vi.fn(),
})); }));
vi.mock("../lib/webhook-dispatcher.js", () => ({
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
}));
const createCaller = createCallerFactory(allocationRouter); const createCaller = createCallerFactory(allocationRouter);
function createManagerCaller(db: Record<string, unknown>) { 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", () => { 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 () => { it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
const createdDemandRequirement = { const createdDemandRequirement = {
id: "demand_1", 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 () => { it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear(); vi.mocked(emitAllocationCreated).mockClear();
vi.mocked(emitNotificationCreated).mockClear();
const createdDemandRequirement = { const createdDemandRequirement = {
id: "demand_explicit_1", id: "demand_explicit_1",
@@ -206,18 +305,14 @@ describe("allocation entry resolution router", () => {
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" }, roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
}; };
const db = { const db = createDemandWorkflowDb({
project: {
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
},
demandRequirement: { demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement), create: vi.fn().mockResolvedValue(createdDemandRequirement),
}, },
auditLog: { }) as Record<string, unknown>;
create: vi.fn().mockResolvedValue({}), Object.assign(db, {
},
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)), $transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.createDemandRequirement({ const result = await caller.createDemandRequirement({
@@ -247,6 +342,8 @@ describe("allocation entry resolution router", () => {
projectId: "project_1", projectId: "project_1",
resourceId: null, 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 () => { 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", eid: "E-001",
displayName: "Alice", displayName: "Alice",
fte: 1, fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_es",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80, chargeabilityTarget: 80,
country: { country: {
id: "country_es", id: "country_es",
@@ -143,6 +147,10 @@ describe("chargeability report router", () => {
eid: "E-001", eid: "E-001",
displayName: "Alice", displayName: "Alice",
fte: 1, fte: 1,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
countryId: "country_es",
federalState: null,
metroCityId: "city_1",
chargeabilityTarget: 80, chargeabilityTarget: 80,
country: { country: {
id: "country_es", id: "country_es",
@@ -204,4 +212,217 @@ describe("chargeability report router", () => {
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0); expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 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(), getDashboardDemand: vi.fn(),
getDashboardTopValueResources: vi.fn(), getDashboardTopValueResources: vi.fn(),
getDashboardChargeabilityOverview: vi.fn(), getDashboardChargeabilityOverview: vi.fn(),
getDashboardBudgetForecast: vi.fn(),
}; };
}); });
@@ -29,6 +30,7 @@ import {
getDashboardDemand, getDashboardDemand,
getDashboardTopValueResources, getDashboardTopValueResources,
getDashboardChargeabilityOverview, getDashboardChargeabilityOverview,
getDashboardBudgetForecast,
} from "@capakraken/application"; } from "@capakraken/application";
import { dashboardRouter } from "../router/dashboard.js"; import { dashboardRouter } from "../router/dashboard.js";
import { createCallerFactory } from "../trpc.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 }), updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -180,6 +181,7 @@ describe("effortRule.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 1 }), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -212,6 +214,7 @@ describe("effortRule.update", () => {
deleteMany: vi.fn(), deleteMany: vi.fn(),
createMany: vi.fn(), createMany: vi.fn(),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -236,6 +239,7 @@ describe("effortRule.update", () => {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }), deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }), createMany: vi.fn().mockResolvedValue({ count: 2 }),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -281,6 +285,7 @@ describe("effortRule.delete", () => {
findUnique: vi.fn().mockResolvedValue(existing), findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing), delete: vi.fn().mockResolvedValue(existing),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); 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 */ /** Injects a default resource ownership mock so the ownership check in getBalance passes */
function createProtectedCaller(db: Record<string, unknown>) { function createProtectedCaller(db: Record<string, unknown>) {
const withResourceOwnership = { 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, ...db,
}; };
return createCaller({ 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 ────────────────────────────────────────────────────────────── // ─── getBalance ──────────────────────────────────────────────────────────────
describe("entitlement.getBalance", () => { describe("entitlement.getBalance", () => {
@@ -90,7 +108,7 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }), findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
}, },
vacationEntitlement: { vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement), findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement), update: vi.fn().mockResolvedValue(entitlement),
}, },
vacation: { vacation: {
@@ -129,10 +147,9 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }), findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
}, },
vacationEntitlement: { vacationEntitlement: {
findUnique: vi findUnique: mockEntitlementFindUniqueByYear({
.fn() 2025: prevEntitlement,
.mockResolvedValueOnce(null) // current year not found }),
.mockResolvedValueOnce(prevEntitlement), // previous year found
create: vi.fn().mockResolvedValue(createdEntitlement), create: vi.fn().mockResolvedValue(createdEntitlement),
update: vi.fn().mockResolvedValue(createdEntitlement), update: vi.fn().mockResolvedValue(createdEntitlement),
}, },
@@ -164,7 +181,7 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
vacationEntitlement: { vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement), findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement), update: vi.fn().mockResolvedValue(entitlement),
}, },
vacation: { vacation: {
@@ -185,12 +202,14 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
vacationEntitlement: { vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement), findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement), update: vi.fn().mockResolvedValue(entitlement),
}, },
vacation: { vacation: {
findMany: vi findMany: vi
.fn() .fn()
// Public holiday vacations for holiday context
.mockResolvedValueOnce([])
// First call: balance-type vacations (for syncEntitlement) // First call: balance-type vacations (for syncEntitlement)
.mockResolvedValueOnce([]) .mockResolvedValueOnce([])
// Second call: sick days // Second call: sick days
@@ -209,19 +228,169 @@ describe("entitlement.getBalance", () => {
expect(result.sickDays).toBe(3); 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 ───────────────────────────────────────────────────────────────────── // ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => { describe("entitlement.get", () => {
it("returns existing entitlement (manager role)", async () => { it("returns existing entitlement (manager role)", async () => {
const entitlement = sampleEntitlement(); const entitlement = sampleEntitlement({
entitledDays: 30,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
});
const db = { const db = {
systemSettings: { systemSettings: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 30 }),
}, },
vacationEntitlement: { 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), update: vi.fn().mockResolvedValue(updated),
create: vi.fn(), create: vi.fn(),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -286,6 +456,7 @@ describe("entitlement.set", () => {
update: vi.fn(), update: vi.fn(),
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -324,6 +495,7 @@ describe("entitlement.bulkSet", () => {
vacationEntitlement: { vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()), upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createAdminCaller(db); const caller = createAdminCaller(db);
@@ -350,6 +522,7 @@ describe("entitlement.bulkSet", () => {
vacationEntitlement: { vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()), upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createAdminCaller(db); const caller = createAdminCaller(db);
@@ -396,10 +569,15 @@ describe("entitlement.getYearSummary", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }), findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
}, },
resource: { resource: {
findUnique: vi.fn().mockResolvedValue({
federalState: "BY",
country: { code: "DE" },
metroCity: null,
}),
findMany: vi.fn().mockResolvedValue(resources), findMany: vi.fn().mockResolvedValue(resources),
}, },
vacationEntitlement: { vacationEntitlement: {
findUnique: vi.fn().mockResolvedValue(entitlement), findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement), update: vi.fn().mockResolvedValue(entitlement),
}, },
vacation: { vacation: {
@@ -24,10 +24,12 @@ vi.mock("ioredis", () => {
describe("event-bus debounce", () => { describe("event-bus debounce", () => {
let received: SseEvent[]; let received: SseEvent[];
let unsubscribe: () => void; let unsubscribe: () => void;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
received = []; received = [];
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
unsubscribe = eventBus.subscribe((event) => { unsubscribe = eventBus.subscribe((event) => {
received.push(event); received.push(event);
}); });
@@ -36,6 +38,7 @@ describe("event-bus debounce", () => {
afterEach(() => { afterEach(() => {
unsubscribe(); unsubscribe();
cancelPendingEvents(); cancelPendingEvents();
consoleWarnSpy.mockRestore();
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -174,6 +174,7 @@ describe("experienceMultiplier.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 0 }), updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -203,6 +204,7 @@ describe("experienceMultiplier.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 1 }), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -235,6 +237,7 @@ describe("experienceMultiplier.update", () => {
deleteMany: vi.fn(), deleteMany: vi.fn(),
createMany: vi.fn(), createMany: vi.fn(),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -259,6 +262,7 @@ describe("experienceMultiplier.update", () => {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }), deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }), createMany: vi.fn().mockResolvedValue({ count: 2 }),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
@@ -308,6 +312,7 @@ describe("experienceMultiplier.delete", () => {
findUnique: vi.fn().mockResolvedValue(existing), findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing), delete: vi.fn().mockResolvedValue(existing),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) },
}; };
const caller = createManagerCaller(db); 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: { notification: {
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
findUnique: vi.fn().mockResolvedValue(created),
}, },
}, },
"user_mgr", "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 () => { it("creates a notification with optional fields", async () => {
@@ -222,6 +224,7 @@ describe("notification.create", () => {
{ {
notification: { notification: {
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
findUnique: vi.fn().mockResolvedValue(created),
}, },
}, },
"user_mgr", "user_mgr",
@@ -134,12 +134,14 @@ describe("project router", () => {
create: vi.fn().mockResolvedValue(created), create: vi.fn().mockResolvedValue(created),
}, },
auditLog: { create: vi.fn().mockResolvedValue({}) }, auditLog: { create: vi.fn().mockResolvedValue({}) },
webhook: { findMany: vi.fn().mockResolvedValue([]) },
}; };
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.create({ const result = await caller.create({
shortCode: "PRJ-001", shortCode: "PRJ-001",
name: "Test Project", name: "Test Project",
responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE, orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT, allocationType: AllocationType.INT,
winProbability: 80, winProbability: 80,
@@ -167,6 +169,7 @@ describe("project router", () => {
caller.create({ caller.create({
shortCode: "PRJ-001", shortCode: "PRJ-001",
name: "Duplicate", name: "Duplicate",
responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE, orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT, allocationType: AllocationType.INT,
budgetCents: 100_00, budgetCents: 100_00,
@@ -189,6 +192,7 @@ describe("project router", () => {
caller.create({ caller.create({
shortCode: "PRJ-002", shortCode: "PRJ-002",
name: "Blocked", name: "Blocked",
responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE, orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT, allocationType: AllocationType.INT,
budgetCents: 100_00, 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 ─────────────────────────────────────────────────────────────── // ─── update ───────────────────────────────────────────────────────────────
describe("update", () => { describe("update", () => {
@@ -294,6 +356,7 @@ describe("project router", () => {
project: { project: {
update: vi.fn().mockResolvedValue(updated), update: vi.fn().mockResolvedValue(updated),
}, },
webhook: { findMany: vi.fn().mockResolvedValue([]) },
}; };
const caller = createManagerCaller(db); 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, valueScoreBreakdown: null,
valueScoreUpdatedAt: null, valueScoreUpdatedAt: null,
userId: null, userId: null,
countryId: "country_de",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
}; };
const db = { const db = {
resource: { 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 () => { it("uses a composite displayName/id cursor for stable pagination", async () => {
const db = { const db = {
resource: { resource: {
@@ -314,6 +477,84 @@ describe("resource router", () => {
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability); 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 () => { it("applies country filters including explicit no-country toggle", async () => {
const db = { const db = {
resource: { 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", () => ({ vi.mock("@capakraken/application", () => ({
@@ -76,6 +59,11 @@ function sampleResource(overrides: Record<string, unknown> = {}) {
isActive: true, isActive: true,
valueScore: 85, valueScore: 85,
chapter: "VFX", chapter: "VFX",
countryId: "country_de",
federalState: "BY",
metroCityId: null,
country: { code: "DE" },
metroCity: null,
...overrides, ...overrides,
}; };
} }
@@ -105,6 +93,30 @@ describe("staffing.getSuggestions", () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId"); expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("score"); 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 () => { 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 ────────────────────────────────────────────────────── // ─── analyzeUtilization ──────────────────────────────────────────────────────
@@ -186,6 +250,11 @@ describe("staffing.analyzeUtilization", () => {
displayName: "Alice", displayName: "Alice",
chargeabilityTarget: 80, chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, 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 = { const db = {
resource: { resource: {
@@ -200,10 +269,56 @@ describe("staffing.analyzeUtilization", () => {
endDate: new Date("2026-04-30"), endDate: new Date("2026-04-30"),
}); });
expect(result).toHaveProperty("utilizationPercent"); expect(result).toHaveProperty("currentChargeability");
expect(result.resourceId).toBe("res_1"); 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 () => { it("throws NOT_FOUND when resource does not exist", async () => {
const db = { const db = {
resource: { resource: {
@@ -230,6 +345,11 @@ describe("staffing.findCapacity", () => {
id: "res_1", id: "res_1",
displayName: "Alice", displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, 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 = { const db = {
resource: { resource: {
@@ -244,8 +364,53 @@ describe("staffing.findCapacity", () => {
endDate: new Date("2026-04-30"), endDate: new Date("2026-04-30"),
}); });
expect(result).toHaveLength(1); expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("availableHoursPerDay"); 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 () => { it("throws NOT_FOUND when resource does not exist", async () => {
@@ -265,11 +430,16 @@ describe("staffing.findCapacity", () => {
).rejects.toThrow("Resource not found"); ).rejects.toThrow("Resource not found");
}); });
it("passes minAvailableHoursPerDay to engine", async () => { it("honors minAvailableHoursPerDay when computing holiday-aware windows", async () => {
const resource = { const resource = {
id: "res_1", id: "res_1",
displayName: "Alice", displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 }, 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 = { const db = {
resource: { 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); const caller = createProtectedCaller(db);
await caller.findCapacity({ const result = await caller.findCapacity({
resourceId: "res_1", resourceId: "res_1",
startDate: new Date("2026-04-01"), startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"), endDate: new Date("2026-04-30"),
minAvailableHoursPerDay: 6, minAvailableHoursPerDay: 6,
}); });
expect(findCapacityWindows).toHaveBeenCalledWith( expect(result.every((window) => window.availableHoursPerDay >= 6)).toBe(true);
expect.anything(),
expect.anything(),
expect.any(Date),
expect.any(Date),
6,
);
}); });
}); });
@@ -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(), emitVacationUpdated: vi.fn(),
emitVacationDeleted: vi.fn(), emitVacationDeleted: vi.fn(),
emitNotificationCreated: vi.fn(), emitNotificationCreated: vi.fn(),
emitTaskAssigned: vi.fn(),
})); }));
vi.mock("../lib/email.js", () => ({ vi.mock("../lib/email.js", () => ({
sendEmail: vi.fn(), 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); const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record<string, unknown>) { function createProtectedCaller(db: Record<string, unknown>) {
@@ -91,6 +109,56 @@ const sampleVacation = {
approvedBy: null, 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("vacation router", () => {
describe("list", () => { describe("list", () => {
it("returns vacations with default filters", async () => { it("returns vacations with default filters", async () => {
@@ -199,18 +267,11 @@ describe("vacation router", () => {
status: VacationStatus.PENDING, status: VacationStatus.PENDING,
}; };
const db = { const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
vacation: { vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation), create: vi.fn().mockResolvedValue(createdVacation),
}, },
}; });
const caller = createProtectedCaller(db); const caller = createProtectedCaller(db);
const result = await caller.create({ const result = await caller.create({
@@ -239,15 +300,14 @@ describe("vacation router", () => {
approvedById: "mgr_1", approvedById: "mgr_1",
}; };
const db = { const db = createVacationDb({
user: { user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }), findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
}, },
vacation: { vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation), create: vi.fn().mockResolvedValue(createdVacation),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.create({ const result = await caller.create({
@@ -269,17 +329,11 @@ describe("vacation router", () => {
}); });
it("rejects overlapping vacation", async () => { it("rejects overlapping vacation", async () => {
const db = { const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
vacation: { vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }), findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
}, },
}; });
const caller = createProtectedCaller(db); const caller = createProtectedCaller(db);
await expect( await expect(
@@ -293,10 +347,10 @@ describe("vacation router", () => {
}); });
it("rejects when end date is before start date", async () => { it("rejects when end date is before start date", async () => {
const db = { const db = createVacationDb({
user: { findUnique: vi.fn() }, user: { findUnique: vi.fn() },
vacation: { findFirst: vi.fn() }, vacation: { findFirst: vi.fn() },
}; });
const caller = createProtectedCaller(db); const caller = createProtectedCaller(db);
await expect( await expect(
@@ -316,18 +370,11 @@ describe("vacation router", () => {
halfDayPart: "MORNING", halfDayPart: "MORNING",
}; };
const db = { const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
resource: {
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
},
vacation: { vacation: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation), create: vi.fn().mockResolvedValue(createdVacation),
}, },
}; });
const caller = createProtectedCaller(db); const caller = createProtectedCaller(db);
const result = await caller.create({ 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", () => { describe("approve", () => {
@@ -359,7 +635,7 @@ describe("vacation router", () => {
approvedById: "mgr_1", approvedById: "mgr_1",
}; };
const db = { const db = createVacationDb({
vacation: { vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation), findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation), update: vi.fn().mockResolvedValue(updatedVacation),
@@ -370,7 +646,7 @@ describe("vacation router", () => {
resource: { resource: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" }); const result = await caller.approve({ id: "vac_1" });
@@ -388,25 +664,25 @@ describe("vacation router", () => {
}); });
it("throws NOT_FOUND for missing vacation", async () => { it("throws NOT_FOUND for missing vacation", async () => {
const db = { const db = createVacationDb({
vacation: { vacation: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found"); await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
}); });
it("rejects approving an already APPROVED vacation", async () => { it("rejects approving an already APPROVED vacation", async () => {
const db = { const db = createVacationDb({
vacation: { vacation: {
findUnique: vi.fn().mockResolvedValue({ findUnique: vi.fn().mockResolvedValue({
...sampleVacation, ...sampleVacation,
status: VacationStatus.APPROVED, status: VacationStatus.APPROVED,
}), }),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow( await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
@@ -429,7 +705,7 @@ describe("vacation router", () => {
rejectionReason: "Team conflict", rejectionReason: "Team conflict",
}; };
const db = { const db = createVacationDb({
vacation: { vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation), findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation), update: vi.fn().mockResolvedValue(updatedVacation),
@@ -437,7 +713,7 @@ describe("vacation router", () => {
resource: { resource: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" }); 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 () => { it("throws when rejecting non-PENDING vacation", async () => {
const db = { const db = createVacationDb({
vacation: { vacation: {
findUnique: vi.fn().mockResolvedValue({ findUnique: vi.fn().mockResolvedValue({
...sampleVacation, ...sampleVacation,
status: VacationStatus.APPROVED, status: VacationStatus.APPROVED,
}), }),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow( await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
@@ -477,15 +753,12 @@ describe("vacation router", () => {
status: VacationStatus.CANCELLED, status: VacationStatus.CANCELLED,
}; };
const db = { const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
},
vacation: { vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation), findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation), update: vi.fn().mockResolvedValue(updatedVacation),
}, },
}; });
const caller = createProtectedCaller(db); const caller = createProtectedCaller(db);
const result = await caller.cancel({ id: "vac_1" }); const result = await caller.cancel({ id: "vac_1" });
@@ -494,25 +767,25 @@ describe("vacation router", () => {
}); });
it("throws NOT_FOUND for missing vacation", async () => { it("throws NOT_FOUND for missing vacation", async () => {
const db = { const db = createVacationDb({
vacation: { vacation: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
}; });
const caller = createProtectedCaller(db); const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found"); await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
}); });
it("throws when already cancelled", async () => { it("throws when already cancelled", async () => {
const db = { const db = createVacationDb({
vacation: { vacation: {
findUnique: vi.fn().mockResolvedValue({ findUnique: vi.fn().mockResolvedValue({
...sampleVacation, ...sampleVacation,
status: VacationStatus.CANCELLED, status: VacationStatus.CANCELLED,
}), }),
}, },
}; });
const caller = createProtectedCaller(db); const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled"); await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
@@ -521,7 +794,7 @@ describe("vacation router", () => {
describe("batchApprove", () => { describe("batchApprove", () => {
it("approves multiple pending vacations", async () => { it("approves multiple pending vacations", async () => {
const db = { const db = createVacationDb({
user: { user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }), findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
}, },
@@ -535,7 +808,7 @@ describe("vacation router", () => {
resource: { resource: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] }); 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 () => { it("only approves PENDING vacations from the requested set", async () => {
const db = { const db = createVacationDb({
user: { user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }), findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
}, },
@@ -565,7 +838,7 @@ describe("vacation router", () => {
resource: { resource: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] }); const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
@@ -581,7 +854,10 @@ describe("vacation router", () => {
describe("batchReject", () => { describe("batchReject", () => {
it("rejects multiple pending vacations with optional reason", async () => { it("rejects multiple pending vacations with optional reason", async () => {
const db = { const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: { vacation: {
findMany: vi.fn().mockResolvedValue([ findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" }, { id: "vac_1", resourceId: "res_1" },
@@ -591,7 +867,7 @@ describe("vacation router", () => {
resource: { resource: {
findUnique: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue(null),
}, },
}; });
const caller = createManagerCaller(db); const caller = createManagerCaller(db);
const result = await caller.batchReject({ const result = await caller.batchReject({
@@ -731,8 +1007,8 @@ describe("vacation router", () => {
const db = { const db = {
resource: { resource: {
findMany: vi.fn().mockResolvedValue([ findMany: vi.fn().mockResolvedValue([
{ id: "res_1" }, { id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
{ id: "res_2" }, { id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
]), ]),
}, },
user: { user: {
@@ -759,7 +1035,9 @@ describe("vacation router", () => {
it("skips already existing holidays", async () => { it("skips already existing holidays", async () => {
const db = { const db = {
resource: { resource: {
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]), findMany: vi.fn().mockResolvedValue([
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
]),
}, },
user: { user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }), findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
+48 -11
View File
@@ -1,6 +1,11 @@
import { listAssignmentBookings } from "@capakraken/application"; import { listAssignmentBookings } from "@capakraken/application";
import { rankResources } from "@capakraken/staffing"; 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"; import { createNotificationsForUsers } from "./create-notification.js";
/** /**
@@ -58,6 +63,11 @@ type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
chargeabilityTarget: number; chargeabilityTarget: number;
availability: unknown; availability: unknown;
valueScore: number | null; valueScore: number | null;
countryId: string | null;
federalState: string | null;
metroCityId: string | null;
country: { code: string | null } | null;
metroCity: { name: string | null } | null;
}>>; }>>;
}; };
notification: { notification: {
@@ -154,27 +164,54 @@ export async function generateAutoSuggestions(
endDate: demand.endDate, endDate: demand.endDate,
resourceIds: resources.map((r) => r.id), 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 // 5. Enrich resources with utilization data for the demand's date range
const enrichedResources = resources.map((resource) => { const enrichedResources = resources.map((resource) => {
const avail = resource.availability as const availability = resource.availability as unknown as WeekdayAvailability;
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number } const context = contexts.get(resource.id);
| null;
const totalAvailableHours = avail?.monday ?? 8;
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id); const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
const totalAvailableHours = calculateEffectiveAvailableHours({
const allocatedHoursPerDay = resourceBookings.reduce( availability,
(sum, b) => sum + b.hoursPerDay, 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, 0,
); );
const utilizationPercent = const utilizationPercent =
totalAvailableHours > 0 totalAvailableHours > 0
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100) ? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
: 0; : 0;
const wouldExceedCapacity = const wouldExceedCapacity = totalAvailableHours > 0
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours; ? allocatedHours + demand.hoursPerDay > totalAvailableHours
: demand.hoursPerDay > 0;
return { return {
id: resource.id, id: resource.id,
+58 -67
View File
@@ -1,14 +1,16 @@
import { import {
deriveResourceForecast, deriveResourceForecast,
getMonthRange, getMonthRange,
countWorkingDaysInOverlap,
calculateSAH,
type AssignmentSlice, type AssignmentSlice,
} from "@capakraken/engine"; } from "@capakraken/engine";
import type { SpainScheduleRule } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application"; import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { VacationStatus } from "@capakraken/db";
import { createNotificationsForUsers } from "./create-notification.js"; import { createNotificationsForUsers } from "./create-notification.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "./resource-capacity.js";
/** /**
* Minimal DB client type for chargeability alerts. * Minimal DB client type for chargeability alerts.
@@ -24,23 +26,19 @@ type DbClient = {
id: string; id: string;
displayName: string; displayName: string;
fte: number; fte: number;
availability: unknown;
countryId: string | null;
metroCityId: string | null;
federalState: string | null;
chargeabilityTarget: number; 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; managementLevelGroup: { targetPercentage: number | null } | null;
}> metroCity: { id?: string | null; name: string | 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;
}> }>
>; >;
}; };
@@ -105,9 +103,14 @@ export async function checkChargeabilityAlerts(
id: true, id: true,
displayName: true, displayName: true,
fte: true, fte: true,
availability: true,
countryId: true,
metroCityId: true,
federalState: true,
chargeabilityTarget: true, chargeabilityTarget: true,
country: { select: { dailyWorkingHours: true, scheduleRules: true } }, country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
managementLevelGroup: { select: { targetPercentage: true } }, managementLevelGroup: { select: { targetPercentage: true } },
metroCity: { select: { id: true, name: true } },
}, },
}); });
@@ -121,56 +124,32 @@ export async function checkChargeabilityAlerts(
endDate: monthEnd, endDate: monthEnd,
resourceIds, resourceIds,
}); });
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
// Fetch vacations for the current month db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
const vacations = await (db as DbClient).vacation.findMany({ resources.map((resource) => ({
where: { id: resource.id,
resourceId: { in: resourceIds }, availability: resource.availability as unknown as WeekdayAvailability,
status: VacationStatus.APPROVED, countryId: resource.countryId,
startDate: { lte: monthEnd }, countryCode: resource.country?.code,
endDate: { gte: monthStart }, federalState: resource.federalState,
}, metroCityId: resource.metroCityId,
select: { metroCityName: resource.metroCity?.name,
resourceId: true, })),
startDate: true, monthStart,
endDate: true, monthEnd,
type: true, );
isHalfDay: true,
},
});
// Compute chargeability per resource // Compute chargeability per resource
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = []; const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
for (const resource of resources) { for (const resource of resources) {
const dailyHours = resource.country?.dailyWorkingHours ?? 8; const availability = resource.availability as unknown as WeekdayAvailability;
const context = availabilityContexts.get(resource.id);
// Compute absence dates for SAH const availableHours = calculateEffectiveAvailableHours({
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id); availability,
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,
periodStart: monthStart, periodStart: monthStart,
periodEnd: monthEnd, periodEnd: monthEnd,
publicHolidays: [], context,
absenceDays: absenceDates,
}); });
// Build assignment slices // Build assignment slices
@@ -178,12 +157,24 @@ export async function checkChargeabilityAlerts(
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false), (b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
); );
const slices: AssignmentSlice[] = resourceBookings.map((b) => { const slices: AssignmentSlice[] = resourceBookings.flatMap((b) => {
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate); const totalChargeableHours = calculateEffectiveBookedHours({
availability,
startDate: b.startDate,
endDate: b.endDate,
hoursPerDay: b.hoursPerDay,
periodStart: monthStart,
periodEnd: monthEnd,
context,
});
if (totalChargeableHours <= 0) {
return [];
}
return { return {
hoursPerDay: b.hoursPerDay, hoursPerDay: b.hoursPerDay,
workingDays, workingDays: 0,
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
totalChargeableHours,
}; };
}); });
@@ -194,7 +185,7 @@ export async function checkChargeabilityAlerts(
fte: resource.fte, fte: resource.fte,
targetPercentage: targetPct, targetPercentage: targetPct,
assignments: slices, assignments: slices,
sah: sahResult.standardAvailableHours, sah: availableHours,
}); });
const chgPct = forecast.chg * 100; const chgPct = forecast.chg * 100;
+53 -21
View File
@@ -9,7 +9,7 @@
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId). * 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 { interface MinimalVacation {
resourceId: string; resourceId: string;
@@ -19,14 +19,20 @@ interface MinimalVacation {
interface AutoImportDb { interface AutoImportDb {
resource: { resource: {
findMany: (args: { findMany: (args: any) => any;
where: { isActive: boolean }; };
select: { id: string; federalState: string }; country?: {
}) => Promise<Array<{ id: string; federalState: string | null }>>; findUnique: (args: any) => any;
};
metroCity?: {
findUnique: (args: any) => any;
};
holidayCalendar?: {
findMany: (args: any) => any;
}; };
vacation: { vacation: {
findMany: (args: unknown) => Promise<MinimalVacation[]>; findMany: (args: any) => any;
createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>; createMany: (args: any) => any;
}; };
} }
@@ -42,34 +48,60 @@ export interface AutoImportResult {
* Returns the number of holiday vacation records created. * Returns the number of holiday vacation records created.
*/ */
export async function autoImportPublicHolidays( export async function autoImportPublicHolidays(
// eslint-disable-next-line @typescript-eslint/no-explicit-any db: AutoImportDb,
db: any,
year: number, year: number,
): Promise<AutoImportResult> { ): Promise<AutoImportResult> {
const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({ const resources = await db.resource.findMany({
where: { isActive: true }, 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) { if (resources.length === 0) {
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 }; return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
} }
// Group resources by federal state (null = federal-only holidays) const nextYearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const byState = new Map<string | null, string[]>(); const nextYearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const byHolidayProfile = new Map<string, typeof resources>();
for (const resource of resources) { for (const resource of resources) {
const state = resource.federalState ?? null; const profileKey = JSON.stringify({
const group = byState.get(state) ?? []; countryCode: resource.country?.code ?? null,
group.push(resource.id); federalState: resource.federalState ?? null,
byState.set(state, group); metroCityName: resource.metroCity?.name ?? null,
});
const group = byHolidayProfile.get(profileKey) ?? [];
group.push(resource);
byHolidayProfile.set(profileKey, group);
} }
let totalCreated = 0; let totalCreated = 0;
let totalSkipped = 0; let totalSkipped = 0;
for (const [state, resourceIds] of byState) { for (const [, groupedResources] of byHolidayProfile) {
const holidays = getPublicHolidays(year, state ?? undefined); 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; if (holidays.length === 0) continue;
const resourceIds = groupedResources.map((resource: { id: string }) => resource.id);
for (const holiday of holidays) { for (const holiday of holidays) {
const holidayDate = new Date(holiday.date); 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 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; totalSkipped += existingResourceIds.size;
if (newResourceIds.length === 0) continue; if (newResourceIds.length === 0) continue;
const records = newResourceIds.map((resourceId) => ({ const records = newResourceIds.map((resourceId: string) => ({
resourceId, resourceId,
type: "PUBLIC_HOLIDAY", type: "PUBLIC_HOLIDAY",
status: "APPROVED", 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()],
};
}
+13 -12
View File
@@ -3,23 +3,24 @@ import pino from "pino";
const isProduction = process.env["NODE_ENV"] === "production"; const isProduction = process.env["NODE_ENV"] === "production";
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info"; const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
const devDestination = pino.destination({ dest: 1, sync: true });
export const logger = pino({ export const logger = isProduction
level: LOG_LEVEL, ? pino({
base: { service: "capakraken-api" }, level: LOG_LEVEL,
...(isProduction base: { service: "capakraken-api" },
? {} })
: { : pino(
transport: { {
target: "pino/file", level: LOG_LEVEL,
options: { destination: 1 }, // stdout base: { service: "capakraken-api" },
},
formatters: { formatters: {
level(label: string) { level(label: string) {
return { level: label }; return { level: label };
}, },
}, },
}), },
}); devDestination,
);
export type Logger = typeof logger; 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;
}
+70 -38
View File
@@ -21,6 +21,7 @@ import {
FillDemandRequirementSchema, FillDemandRequirementSchema,
FillOpenDemandByAllocationSchema, FillOpenDemandByAllocationSchema,
PermissionKey, PermissionKey,
type WeekdayAvailability,
UpdateAssignmentSchema, UpdateAssignmentSchema,
UpdateAllocationSchema, UpdateAllocationSchema,
UpdateDemandRequirementSchema, UpdateDemandRequirementSchema,
@@ -34,6 +35,13 @@ import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { generateAutoSuggestions } from "../lib/auto-staffing.js"; import { generateAutoSuggestions } from "../lib/auto-staffing.js";
import { invalidateDashboardCache } from "../lib/cache.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 { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.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 }, where: { id: input.resourceId },
select: { select: {
id: true, displayName: true, eid: true, fte: true, 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" }); 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 // Get existing assignments in the date range
const existingAssignments = await ctx.db.assignment.findMany({ const existingAssignments = await ctx.db.assignment.findMany({
@@ -350,19 +372,29 @@ export const allocationRouter = createTRPCRouter({
orderBy: { startDate: "asc" }, orderBy: { startDate: "asc" },
}); });
// Get vacations in the date range const contexts = await loadResourceDailyAvailabilityContexts(
const vacations = await ctx.db.vacation.findMany({ ctx.db,
where: { [{
resourceId: input.resourceId, id: resource.id,
status: "APPROVED", availability,
startDate: { lte: input.endDate }, countryId: resource.countryId,
endDate: { gte: input.startDate }, countryCode: resource.country?.code,
}, federalState: resource.federalState,
select: { startDate: true, endDate: true, isHalfDay: true }, metroCityId: resource.metroCityId,
}); metroCityName: resource.metroCity?.name,
}],
input.startDate,
input.endDate,
);
const context = contexts.get(resource.id);
// Calculate day-by-day availability // Calculate day-by-day availability
let totalWorkingDays = 0; const totalWorkingDays = countEffectiveWorkingDays({
availability,
periodStart: input.startDate,
periodEnd: input.endDate,
context,
});
let availableDays = 0; let availableDays = 0;
let conflictDays = 0; let conflictDays = 0;
let partialDays = 0; let partialDays = 0;
@@ -372,36 +404,27 @@ export const allocationRouter = createTRPCRouter({
const d = new Date(input.startDate); const d = new Date(input.startDate);
const end = new Date(input.endDate); const end = new Date(input.endDate);
while (d <= end) { while (d <= end) {
const dow = d.getDay(); const effectiveDayCapacity = calculateEffectiveDayAvailability({
if (dow !== 0 && dow !== 6) { availability,
totalWorkingDays++; date: d,
context,
});
// Check vacation if (effectiveDayCapacity > 0) {
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;
});
if (isVacation) {
conflictDays++;
d.setDate(d.getDate() + 1);
continue;
}
// Sum existing hours on this day
let bookedHours = 0; let bookedHours = 0;
for (const a of existingAssignments) { for (const a of existingAssignments) {
const as2 = new Date(a.startDate); as2.setHours(0, 0, 0, 0); bookedHours += calculateEffectiveBookedHours({
const ae = new Date(a.endDate); ae.setHours(0, 0, 0, 0); availability,
const dc = new Date(d); dc.setHours(0, 0, 0, 0); startDate: a.startDate,
if (dc >= as2 && dc <= ae) { endDate: a.endDate,
bookedHours += a.hoursPerDay; 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) { if (remainingCapacity >= requestedHpd) {
availableDays++; availableDays++;
totalAvailableHours += requestedHpd; totalAvailableHours += requestedHpd;
@@ -416,6 +439,15 @@ export const allocationRouter = createTRPCRouter({
} }
const totalRequestedHours = totalWorkingDays * requestedHpd; 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 { return {
resource: { id: resource.id, name: resource.displayName, eid: resource.eid }, 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 { z } from "zod";
import { TRPCError } from "@trpc/server"; 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 { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.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 { checkPromptInjection } from "../lib/prompt-guard.js";
import { checkAiOutput } from "../lib/content-filter.js"; import { checkAiOutput } from "../lib/content-filter.js";
import { createAuditEntry } from "../lib/audit.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: Deine Fähigkeiten:
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten - 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 - Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen - Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten - 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. - 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) - 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 - 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 - 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: Datenmodell:
@@ -48,10 +55,12 @@ Datenmodell:
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED) - Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
- Chargeability = gebuchte/verfügbare Stunden × 100% - Chargeability = gebuchte/verfügbare Stunden × 100%
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED - 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 */ /** Map tool names to the permission required to use them */
const TOOL_PERMISSION_MAP: Record<string, string> = { const TOOL_PERMISSION_MAP: Record<string, string> = {
list_users: PermissionKey.MANAGE_USERS,
// Resource management // Resource management
update_resource: "manageResources", update_resource: "manageResources",
create_resource: "manageResources", create_resource: "manageResources",
@@ -89,7 +98,36 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
}; };
/** Tools that require cost visibility */ /** 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({ export const assistantRouter = createTRPCRouter({
chat: protectedProcedure chat: protectedProcedure
@@ -176,26 +214,12 @@ export const assistantRouter = createTRPCRouter({
} }
// 4. Filter tools based on granular permissions // 4. Filter tools based on granular permissions
const availableTools = TOOL_DEFINITIONS.filter((t) => { const availableTools = getAvailableAssistantTools(permissions);
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;
});
// 5. Function calling loop // 5. Function calling loop
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions }; const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
const collectedActions: ToolAction[] = []; const collectedActions: ToolAction[] = [];
let collectedInsights: AssistantInsight[] = [];
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -240,6 +264,11 @@ export const assistantRouter = createTRPCRouter({
toolCtx, toolCtx,
); );
const insight = buildAssistantInsight(toolCall.function.name, result.data);
if (insight) {
collectedInsights = mergeInsights(collectedInsights, insight);
}
// Collect any actions (e.g. navigation) // Collect any actions (e.g. navigation)
if (result.action) { if (result.action) {
collectedActions.push(result.action); collectedActions.push(result.action);
@@ -298,6 +327,7 @@ export const assistantRouter = createTRPCRouter({
return { return {
content: finalContent, content: finalContent,
role: "assistant" as const, role: "assistant" as const,
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}), ...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
}; };
} }
@@ -306,6 +336,7 @@ export const assistantRouter = createTRPCRouter({
return { return {
content: "I had to stop after too many tool calls. Please try a simpler question.", content: "I had to stop after too many tool calls. Please try a simpler question.",
role: "assistant" as const, role: "assistant" as const,
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}), ...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
}; };
}), }),
+54 -130
View File
@@ -5,19 +5,18 @@ import {
sumFte, sumFte,
getMonthRange, getMonthRange,
getMonthKeys, getMonthKeys,
countWorkingDaysInOverlap,
calculateSAH,
calculateAllocation,
DEFAULT_CALCULATION_RULES,
type AssignmentSlice, type AssignmentSlice,
} from "@capakraken/engine"; } from "@capakraken/engine";
import type { CalculationRule, AbsenceDay } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared";
import type { SpainScheduleRule } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application"; import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
import { VacationStatus } from "@capakraken/db";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js"; import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
export const chargeabilityReportRouter = createTRPCRouter({ export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure getReport: controllerProcedure
@@ -59,6 +58,10 @@ export const chargeabilityReportRouter = createTRPCRouter({
eid: true, eid: true,
displayName: true, displayName: true,
fte: true, fte: true,
availability: true,
countryId: true,
federalState: true,
metroCityId: true,
chargeabilityTarget: true, chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } }, country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } }, orgUnit: { select: { id: true, name: true } },
@@ -90,6 +93,20 @@ export const chargeabilityReportRouter = createTRPCRouter({
endDate: rangeEnd, endDate: rangeEnd,
resourceIds, 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 // Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))]; 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 // 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 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) // Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
const targetPct = resource.managementLevelGroup?.targetPercentage const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100); ?? (resource.chargeabilityTarget / 100);
const dailyHours = resource.country?.dailyWorkingHours ?? 8; const availability = resource.availability as unknown as WeekdayAvailability;
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null; 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 [y, m] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(y, m); const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
const availableHours = calculateEffectiveAvailableHours({
// Compute absence days for SAH availability,
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,
periodStart: monthStart, periodStart: monthStart,
periodEnd: monthEnd, periodEnd: monthEnd,
publicHolidays: [], // TODO: integrate public holidays from country context,
absenceDays: absenceDates,
}); });
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
// Build typed absence days for this resource in this month const totalChargeableHours = calculateEffectiveBookedHours({
const monthAbsenceDays: AbsenceDay[] = []; availability,
for (const v of resourceVacations) { startDate: a.startDate,
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime())); endDate: a.endDate,
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime())); hoursPerDay: a.hoursPerDay,
if (vStart > vEnd) continue; periodStart: monthStart,
const absCursor = new Date(vStart); periodEnd: monthEnd,
absCursor.setUTCHours(0, 0, 0, 0); context,
const absEndNorm = new Date(vEnd); });
absEndNorm.setUTCHours(0, 0, 0, 0); if (totalChargeableHours <= 0) {
const triggerType = v.type === "SICK" ? "SICK" as const return [];
: 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 return {
const slices: AssignmentSlice[] = []; hoursPerDay: a.hoursPerDay,
for (const a of resourceAssignments) { workingDays: 0,
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate); categoryCode: a.project.utilizationCategory?.code ?? "Chg",
if (workingDays <= 0) continue; totalChargeableHours,
};
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
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,
});
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
});
} else {
slices.push({
hoursPerDay: a.hoursPerDay,
workingDays,
categoryCode,
});
}
}
const forecast = deriveResourceForecast({ const forecast = deriveResourceForecast({
fte: resource.fte, fte: resource.fte,
targetPercentage: targetPct, targetPercentage: targetPct,
assignments: slices, assignments: slices,
sah: sahResult.standardAvailableHours, sah: availableHours,
}); });
return { return {
monthKey: key, monthKey: key,
sah: sahResult.standardAvailableHours, sah: availableHours,
...forecast, ...forecast,
}; };
}); }));
return { return {
id: resource.id, id: resource.id,
@@ -278,7 +202,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
targetPct, targetPct,
months, months,
}; };
}); }));
// Compute group totals per month // Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => { const groupTotals = monthKeys.map((key, monthIdx) => {
+205 -70
View File
@@ -4,18 +4,27 @@ import {
deriveResourceForecast, deriveResourceForecast,
computeBudgetStatus, computeBudgetStatus,
getMonthRange, getMonthRange,
countWorkingDaysInOverlap,
DEFAULT_CALCULATION_RULES, DEFAULT_CALCULATION_RULES,
summarizeEstimateDemandLines, summarizeEstimateDemandLines,
computeEvenSpread, computeEvenSpread,
distributeHoursToWeeks, distributeHoursToWeeks,
type AssignmentSlice, type AssignmentSlice,
} from "@capakraken/engine"; } 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 { VacationStatus } from "@capakraken/db";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js"; import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.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) ──────────────────── // ─── Graph Types (mirrored from client for API response) ────────────────────
@@ -62,6 +71,21 @@ function fmtNum(v: number, decimals = 1): string {
return v.toFixed(decimals); 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 ───────────────────────────────────────────────────────────────── // ─── Router ─────────────────────────────────────────────────────────────────
export const computationGraphRouter = createTRPCRouter({ export const computationGraphRouter = createTRPCRouter({
@@ -88,8 +112,12 @@ export const computationGraphRouter = createTRPCRouter({
fte: true, fte: true,
lcrCents: true, lcrCents: true,
chargeabilityTarget: true, chargeabilityTarget: true,
countryId: true,
federalState: true,
metroCityId: true,
availability: 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 } }, 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({ const vacations = await ctx.db.vacation.findMany({
where: { where: {
resourceId: input.resourceId, resourceId: input.resourceId,
@@ -143,45 +171,47 @@ export const computationGraphRouter = createTRPCRouter({
}, },
select: { startDate: true, endDate: true, type: true, isHalfDay: true }, select: { startDate: true, endDate: true, type: true, 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,
});
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;
// Build absence dates for SAH (ISO strings), separating public holidays const contexts = await loadResourceDailyAvailabilityContexts(
const publicHolidayStrings: string[] = []; ctx.db,
const absenceDateStrings: string[] = []; [{
const absenceDays: AbsenceDay[] = []; id: resource.id,
let halfDayCount = 0; availability: weeklyAvailability,
let vacationDayCount = 0; countryId: resource.countryId,
let sickDayCount = 0; countryCode: resource.country?.code,
let publicHolidayCount = 0; federalState: resource.federalState,
for (const v of vacations) { metroCityId: resource.metroCityId,
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime())); metroCityName: resource.metroCity?.name,
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime())); }],
if (vStart > vEnd) continue; monthStart,
const cursor = new Date(vStart); monthEnd,
cursor.setUTCHours(0, 0, 0, 0); );
const endNorm = new Date(vEnd); const availabilityContext = contexts.get(resource.id);
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 } : {}),
});
if (v.isHalfDay) halfDayCount++;
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
}
// ── 4. Load calculation rules ── // ── 4. Load calculation rules ──
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES; let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
@@ -197,7 +227,7 @@ export const computationGraphRouter = createTRPCRouter({
// table may not exist yet // table may not exist yet
} }
// ── 5. Calculate SAH ── // ── 5. Calculate SAH / effective capacity ──
const sahResult = calculateSAH({ const sahResult = calculateSAH({
dailyWorkingHours: dailyHours, dailyWorkingHours: dailyHours,
scheduleRules, scheduleRules,
@@ -207,6 +237,60 @@ export const computationGraphRouter = createTRPCRouter({
publicHolidays: publicHolidayStrings, publicHolidays: publicHolidayStrings,
absenceDays: absenceDateStrings, 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 ── // ── 6. Calculate allocations + chargeability slices ──
const slices: AssignmentSlice[] = []; const slices: AssignmentSlice[] = [];
@@ -217,9 +301,6 @@ export const computationGraphRouter = createTRPCRouter({
let hasRulesEffect = false; let hasRulesEffect = false;
for (const a of assignments) { 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 overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime())); const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const categoryCode = a.project.utilizationCategory?.code ?? "Chg"; const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
@@ -233,6 +314,7 @@ export const computationGraphRouter = createTRPCRouter({
absenceDays, absenceDays,
calculationRules: calcRules, calculationRules: calcRules,
}); });
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
totalAllocHours += calcResult.totalHours; totalAllocHours += calcResult.totalHours;
totalAllocCostCents += calcResult.totalCostCents; totalAllocCostCents += calcResult.totalCostCents;
@@ -247,7 +329,7 @@ export const computationGraphRouter = createTRPCRouter({
slices.push({ slices.push({
hoursPerDay: a.hoursPerDay, hoursPerDay: a.hoursPerDay,
workingDays, workingDays: calcResult.workingDays,
categoryCode, categoryCode,
...(calcResult.totalChargeableHours !== undefined ...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours } ? { totalChargeableHours: calcResult.totalChargeableHours }
@@ -260,7 +342,7 @@ export const computationGraphRouter = createTRPCRouter({
fte: resource.fte, fte: resource.fte,
targetPercentage: targetPct, targetPercentage: targetPct,
assignments: slices, assignments: slices,
sah: sahResult.standardAvailableHours, sah: effectiveAvailableHours,
}); });
// ── 8. Build budget status for first project with budget ── // ── 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 ? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
: 0; : 0;
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => { 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); }, 0);
// Format weekly availability for display // Format weekly availability for display
@@ -332,9 +425,10 @@ export const computationGraphRouter = createTRPCRouter({
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" "); : weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
// Derived utilization ratio // Derived utilization ratio
const utilizationPct = sahResult.standardAvailableHours > 0 const utilizationPct = effectiveAvailableHours > 0
? (totalAllocHours / sahResult.standardAvailableHours) * 100 ? (totalAllocHours / effectiveAvailableHours) * 100
: 0; : 0;
const chargeableHours = forecast.chg * effectiveAvailableHours;
// Has schedule rules (Spain variable hours)? // Has schedule rules (Spain variable hours)?
const hasScheduleRules = !!scheduleRules; const hasScheduleRules = !!scheduleRules;
@@ -342,6 +436,11 @@ export const computationGraphRouter = createTRPCRouter({
const nodes: GraphNode[] = [ const nodes: GraphNode[] = [
// INPUT // INPUT
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0), 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), n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
...(hasScheduleRules ? [ ...(hasScheduleRules ? [
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0), 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.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.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.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.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.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), n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
@@ -358,12 +457,15 @@ export const computationGraphRouter = createTRPCRouter({
// SAH // SAH
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1), 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.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.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1), n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1), n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"), n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
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.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"), 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 // ALLOCATION
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"), 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 // 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"), n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
...(forecast.bd > 0 ? [ ...(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 ? [ ...(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 ? [ ...(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 ? [ ...(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 ? [ ...(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.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"), 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[] = [ const links: GraphLink[] = [
// INPUT → SAH // 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.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 ? [ ...(hasScheduleRules ? [
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1), l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
] : []), ] : []),
@@ -422,14 +533,14 @@ export const computationGraphRouter = createTRPCRouter({
l("sah.weekendDays", "sah.grossWorkingDays", "", 1), l("sah.weekendDays", "sah.grossWorkingDays", "", 1),
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1), l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
l("input.absences", "sah.absenceDays", "∩ 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.publicHolidayDays", "sah.netWorkingDays", "", 1),
l("sah.absenceDays", "sah.netWorkingDays", "", 1), l("sah.absenceDays", "sah.netWorkingDays", "", 1),
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1), l("sah.baseHours", "sah.sah", "start from base capacity", 2),
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 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.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
l("sah.netWorkingDays", "sah.sah", "×", 2),
// INPUT → ALLOCATION // INPUT → ALLOCATION
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2), l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
@@ -489,6 +600,30 @@ export const computationGraphRouter = createTRPCRouter({
resourceEid: resource.eid, resourceEid: resource.eid,
month: input.month, month: input.month,
assignmentCount: assignments.length, 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 { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.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 */ /** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER]; const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
/** type EntitlementSnapshot = {
* Count calendar days between two dates (inclusive). id: string;
* Half-day vacations count as 0.5. entitledDays: number;
*/ carryoverDays: number;
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number { usedDays: number;
if (isHalfDay) return 0.5; pendingDays: number;
const ms = endDate.getTime() - startDate.getTime(); };
return Math.round(ms / 86_400_000) + 1;
}
/** /**
* Get or create an entitlement record, applying carryover from previous year if needed. * Get or create an entitlement record, applying carryover from previous year if needed.
@@ -61,6 +61,14 @@ async function getOrCreateEntitlement(
return entitlement; 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. * Recompute used/pending days from actual vacation records and update the cached values.
*/ */
@@ -69,14 +77,57 @@ async function syncEntitlement(
resourceId: string, resourceId: string,
year: number, year: number,
defaultDays: 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 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({ const vacations = await db.vacation.findMany({
where: { where: {
resourceId, resourceId,
type: { in: BALANCE_TYPES }, 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] }, status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
}, },
select: { startDate: true, endDate: true, status: true, isHalfDay: true }, select: { startDate: true, endDate: true, status: true, isHalfDay: true },
@@ -86,13 +137,22 @@ async function syncEntitlement(
let pendingDays = 0; let pendingDays = 0;
for (const v of vacations) { 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; if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days; else pendingDays += days;
} }
return db.vacationEntitlement.update({ return db.vacationEntitlement.update({
where: { id: entitlement.id }, where: { id: entitlementWithCarryover.id },
data: { usedDays, pendingDays }, data: { usedDays, pendingDays },
}); });
} }
@@ -134,17 +194,23 @@ export const entitlementRouter = createTRPCRouter({
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays); const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational) // Also count sick days (informational)
const sickVacations = await ctx.db.vacation.findMany({ const sickVacationsResult = await ctx.db.vacation.findMany({
where: { where: {
resourceId: input.resourceId, resourceId: input.resourceId,
type: VacationType.SICK, type: VacationType.SICK,
status: VacationStatus.APPROVED, 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 }, select: { startDate: true, endDate: true, isHalfDay: true },
}); });
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce( 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, 0,
); );
@@ -171,7 +237,7 @@ export const entitlementRouter = createTRPCRouter({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28; 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 { experienceMultiplierRouter } from "./experience-multiplier.js";
import { estimateRouter } from "./estimate.js"; import { estimateRouter } from "./estimate.js";
import { entitlementRouter } from "./entitlement.js"; import { entitlementRouter } from "./entitlement.js";
import { holidayCalendarRouter } from "./holiday-calendar.js";
import { importExportRouter } from "./import-export.js"; import { importExportRouter } from "./import-export.js";
import { insightsRouter } from "./insights.js"; import { insightsRouter } from "./insights.js";
import { managementLevelRouter } from "./management-level.js"; import { managementLevelRouter } from "./management-level.js";
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
insights: insightsRouter, insights: insightsRouter,
vacation: vacationRouter, vacation: vacationRouter,
entitlement: entitlementRouter, entitlement: entitlementRouter,
holidayCalendar: holidayCalendarRouter,
notification: notificationRouter, notification: notificationRouter,
settings: settingsRouter, settings: settingsRouter,
country: countryRouter, country: countryRouter,
+45 -7
View File
@@ -2,6 +2,7 @@ import {
countPlanningEntries, countPlanningEntries,
listAssignmentBookings, listAssignmentBookings,
} from "@capakraken/application"; } from "@capakraken/application";
import type { WeekdayAvailability } from "@capakraken/shared";
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared"; import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
@@ -17,6 +18,10 @@ import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../ge
import { invalidateDashboardCache } from "../lib/cache.js"; import { invalidateDashboardCache } from "../lib/cache.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js"; import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.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) 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({ const assignments = await ctx.db.assignment.findMany({
where: { projectId: input.projectId, status: { not: "CANCELLED" } }, 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 mapped: ShoringAssignment[] = assignments.map((a) => {
const start = new Date(a.startDate); const workingDays = a.hoursPerDay > 0
const end = new Date(a.endDate); ? calculateEffectiveBookedHours({
const diffMs = end.getTime() - start.getTime(); availability: a.resource.availability as unknown as WeekdayAvailability,
const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1); startDate: a.startDate,
const workingDays = Math.round(diffDays / 7 * 5); endDate: a.endDate,
hoursPerDay: a.hoursPerDay,
periodStart,
periodEnd,
context: contexts.get(a.resourceId ?? a.resource.id),
}) / a.hoursPerDay
: 0;
return { return {
resourceId: a.resourceId, resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null, countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay, 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 { createTRPCRouter, controllerProcedure } from "../trpc.js";
import {
calculateEffectiveAvailableHours,
calculateEffectiveBookedHours,
countEffectiveWorkingDays,
getAvailabilityHoursForDate,
loadResourceDailyAvailabilityContexts,
} from "../lib/resource-capacity.js";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod";
// ─── Column Definitions ────────────────────────────────────────────────────── // ─── Column Definitions ──────────────────────────────────────────────────────
@@ -30,6 +44,7 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "departed", label: "Departed", dataType: "boolean" }, { key: "departed", label: "Departed", dataType: "boolean" },
{ key: "postalCode", label: "Postal Code", dataType: "string" }, { key: "postalCode", label: "Postal Code", dataType: "string" },
{ key: "federalState", label: "Federal State", 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: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" }, { key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" }, { 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: "status", label: "Status", dataType: "string" },
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" }, { key: "winProbability", label: "Win Probability (%)", dataType: "number" },
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" }, { key: "budgetCents", label: "Budget (cents)", dataType: "number" },
{ key: "clientId", label: "Client ID", dataType: "string" },
{ key: "startDate", label: "Start Date", dataType: "date" }, { key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" }, { key: "endDate", label: "End Date", dataType: "date" },
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" }, { key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
@@ -61,10 +77,19 @@ const PROJECT_COLUMNS: ColumnDef[] = [
const ASSIGNMENT_COLUMNS: ColumnDef[] = [ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "id", label: "ID", dataType: "string" }, { 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.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
{ key: "resource.eid", label: "Resource EID", 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.name", label: "Project", dataType: "string", prismaPath: "project" },
{ key: "project.shortCode", label: "Project Code", 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: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" }, { key: "endDate", label: "End Date", dataType: "date" },
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" }, { key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
@@ -77,10 +102,55 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "updatedAt", label: "Updated At", dataType: "date" }, { 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[]> = { const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
resource: RESOURCE_COLUMNS, resource: RESOURCE_COLUMNS,
project: PROJECT_COLUMNS, project: PROJECT_COLUMNS,
assignment: ASSIGNMENT_COLUMNS, assignment: ASSIGNMENT_COLUMNS,
resource_month: RESOURCE_MONTH_COLUMNS,
}; };
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -89,6 +159,7 @@ const ENTITY_MAP = {
resource: "resource", resource: "resource",
project: "project", project: "project",
assignment: "assignment", assignment: "assignment",
resource_month: "resource_month",
} as const; } as const;
type EntityKey = keyof typeof ENTITY_MAP; type EntityKey = keyof typeof ENTITY_MAP;
@@ -110,6 +181,7 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
"id", "startDate", "endDate", "hoursPerDay", "percentage", "id", "startDate", "endDate", "hoursPerDay", "percentage",
"role", "dailyCostCents", "status", "createdAt", "updatedAt", "role", "dailyCostCents", "status", "createdAt", "updatedAt",
]), ]),
resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)),
}; };
function getValidScalarField(entity: EntityKey, field: string): string | null { 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 (!def) continue;
if (colKey.includes(".")) { if (colKey.includes(".")) {
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
const relationName = def.prismaPath ?? colKey.split(".")[0]!; const relationName = def.prismaPath ?? colKey.split(".")[0]!;
const fieldName = colKey.split(".").slice(1).join(".");
const existing = select[relationName]; const existing = select[relationName];
if (existing && typeof existing === "object" && existing !== null && "select" in existing) { const fieldSegments = colKey.split(".").slice(1);
(existing as { select: Record<string, boolean> }).select[fieldName] = true; const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
} else { ? (existing as { select: Record<string, unknown> }).select
select[relationName] = { select: { [fieldName]: true } }; : {};
} mergeSelectPath(relationSelect, fieldSegments);
select[relationName] = { select: relationSelect };
} else { } else {
select[colKey] = true; select[colKey] = true;
} }
@@ -149,6 +220,29 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
return select; 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. * Build a Prisma `where` from the filter array.
* Only scalar top-level fields are allowed for safety. * Only scalar top-level fields are allowed for safety.
@@ -246,6 +340,8 @@ function csvEscape(value: unknown): string {
// ─── Input Schema ─────────────────────────────────────────────────────────── // ─── Input Schema ───────────────────────────────────────────────────────────
const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]);
const FilterSchema = z.object({ const FilterSchema = z.object({
field: z.string().min(1), field: z.string().min(1),
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]), op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
@@ -253,24 +349,171 @@ const FilterSchema = z.object({
}); });
const ReportInputSchema = z.object({ const ReportInputSchema = z.object({
entity: z.enum(["resource", "project", "assignment"]), entity: reportEntitySchema,
columns: z.array(z.string()).min(1), columns: z.array(z.string()).min(1),
filters: z.array(FilterSchema).default([]), filters: z.array(FilterSchema).default([]),
groupBy: z.string().optional(), groupBy: z.string().optional(),
sortBy: z.string().optional(), sortBy: z.string().optional(),
sortDir: z.enum(["asc", "desc"]).default("asc"), 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), limit: z.number().int().min(1).max(5000).default(50),
offset: z.number().int().min(0).default(0), 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 ────────────────────────────────────────────────────────────────── // ─── Router ──────────────────────────────────────────────────────────────────
export const reportRouter = createTRPCRouter({ 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. * Return available columns for a given entity type.
*/ */
getAvailableColumns: controllerProcedure getAvailableColumns: controllerProcedure
.input(z.object({ entity: z.enum(["resource", "project", "assignment"]) })) .input(z.object({ entity: reportEntitySchema }))
.query(({ input }) => { .query(({ input }) => {
const columns = COLUMN_MAP[input.entity]; const columns = COLUMN_MAP[input.entity];
if (!columns) { if (!columns) {
@@ -285,40 +528,7 @@ export const reportRouter = createTRPCRouter({
getReportData: controllerProcedure getReportData: controllerProcedure
.input(ReportInputSchema) .input(ReportInputSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input; return executeReportQuery(ctx.db, 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 };
}), }),
/** /**
@@ -329,33 +539,12 @@ export const reportRouter = createTRPCRouter({
limit: z.number().int().min(1).max(50000).default(5000), limit: z.number().int().min(1).max(50000).default(5000),
})) }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { entity, columns, filters, sortBy, sortDir, limit } = input; const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
const rows = result.rows;
const select = buildSelect(entity, columns); const outputColumns = result.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")];
// Build CSV // Build CSV
const entityColumns = COLUMN_MAP[entity]; const entityColumns = COLUMN_MAP[input.entity];
const headerLabels = outputColumns.map((key) => { const headerLabels = outputColumns.map((key) => {
const def = entityColumns.find((c) => c.key === key); const def = entityColumns.find((c) => c.key === key);
return def?.label ?? 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. */ /** Resolve the Prisma model delegate from entity key. */
function getModelDelegate(db: any, entity: EntityKey) { function getModelDelegate(db: any, entity: EntityKey) {
switch (entity) { switch (entity) {
+239 -50
View File
@@ -7,7 +7,6 @@ import {
} from "@capakraken/application"; } from "@capakraken/application";
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared"; import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
import type { WeekdayAvailability } from "@capakraken/shared"; import type { WeekdayAvailability } from "@capakraken/shared";
import { computeChargeability } from "@capakraken/engine";
import { assertBlueprintDynamicFields } from "./blueprint-validation.js"; import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
import { import {
@@ -17,6 +16,12 @@ import {
getAnonymizationDirectory, getAnonymizationDirectory,
resolveResourceIdsByDisplayedEids, resolveResourceIdsByDisplayedEids,
} from "../lib/anonymization.js"; } 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. 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; 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({ export const resourceRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input( .input(
@@ -1056,10 +1105,14 @@ export const resourceRouter = createTRPCRouter({
portfolioUrl: true, portfolioUrl: true,
postalCode: true, postalCode: true,
federalState: true, federalState: true,
countryId: true,
metroCityId: true,
valueScore: true, valueScore: true,
valueScoreBreakdown: true, valueScoreBreakdown: true,
valueScoreUpdatedAt: true, valueScoreUpdatedAt: true,
userId: true, userId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
}, },
}); });
const bookings = await listAssignmentBookings(ctx.db, { const bookings = await listAssignmentBookings(ctx.db, {
@@ -1067,30 +1120,67 @@ export const resourceRouter = createTRPCRouter({
endDate: end, endDate: end,
resourceIds: resources.map((resource) => resource.id), 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); const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => { return resources.map((r) => {
const avail = r.availability as Record<string, number>; const availability = r.availability as unknown as WeekdayAvailability;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5; const context = contexts.get(r.id);
const periodDays = const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
(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(
(booking) => (booking) =>
booking.resourceId === r.id && booking.resourceId === r.id &&
(input.includeProposed || booking.status !== "PROPOSED"), (input.includeProposed || booking.status !== "PROPOSED"),
); );
for (const a of resourceBookings) { const availableHours = calculateEffectiveAvailableHours({
const days = availability,
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) / periodStart: start,
(1000 * 60 * 60 * 24) + periodEnd: end,
1; context,
bookedHours += a.hoursPerDay * days; });
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true; 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 = const utilizationPercent =
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0; availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
@@ -1125,6 +1215,11 @@ export const resourceRouter = createTRPCRouter({
chapter: true, chapter: true,
chargeabilityTarget: true, chargeabilityTarget: true,
availability: true, availability: true,
countryId: true,
federalState: true,
metroCityId: true,
country: { select: { code: true } },
metroCity: { select: { name: true } },
}, },
}); });
const bookings = await listAssignmentBookings(ctx.db, { const bookings = await listAssignmentBookings(ctx.db, {
@@ -1132,10 +1227,25 @@ export const resourceRouter = createTRPCRouter({
endDate: end, endDate: end,
resourceIds: resources.map((resource) => resource.id), 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); const directory = await getAnonymizationDirectory(ctx.db);
return resources.map((r) => { return resources.map((r) => {
const avail = r.availability as unknown as WeekdayAvailability; const avail = r.availability as unknown as WeekdayAvailability;
const context = contexts.get(r.id);
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id); const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
const actualAllocs = resourceBookings.filter((booking) => const actualAllocs = resourceBookings.filter((booking) =>
@@ -1146,8 +1256,42 @@ export const resourceRouter = createTRPCRouter({
isChargeabilityRelevantProject(booking.project, true), isChargeabilityRelevantProject(booking.project, true),
); );
const actual = computeChargeability(avail, actualAllocs, start, end); const availableHours = calculateEffectiveAvailableHours({
const expected = computeChargeability(avail, expectedAllocs, start, end); 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({ return anonymizeResource({
id: r.id, id: r.id,
@@ -1155,9 +1299,9 @@ export const resourceRouter = createTRPCRouter({
displayName: r.displayName, displayName: r.displayName,
chapter: r.chapter, chapter: r.chapter,
chargeabilityTarget: r.chargeabilityTarget, chargeabilityTarget: r.chargeabilityTarget,
actualChargeability: actual.chargeability, actualChargeability,
expectedChargeability: expected.chargeability, expectedChargeability,
availableHours: actual.availableHours, availableHours: Math.round(availableHours),
}, directory); }, directory);
}); });
}), }),
@@ -1208,7 +1352,10 @@ export const resourceRouter = createTRPCRouter({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const now = new Date(); 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 }; type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
@@ -1223,6 +1370,11 @@ export const resourceRouter = createTRPCRouter({
skills: true, skills: true,
availability: true, availability: true,
chargeabilityTarget: 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: { where: {
resourceId: { in: allResourceIds }, resourceId: { in: allResourceIds },
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] }, status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
endDate: { gte: now }, endDate: { gte: today },
startDate: { lte: thirtyDaysFromNow }, startDate: { lte: thirtyDaysFromNow },
}, },
select: { select: {
@@ -1242,41 +1394,78 @@ export const resourceRouter = createTRPCRouter({
hoursPerDay: true, 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 }>(); const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
for (const r of resources) { for (const r of resources) {
const avail = r.availability as Record<string, number>; const availability = r.availability as unknown as WeekdayAvailability;
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5; const context = contexts.get(r.id);
const resourceAssignments = assignments.filter((a) => a.resourceId === 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; let earliestAvailableDate: Date | null = null;
const checkDate = new Date(now); const checkDate = new Date(today);
for (let i = 0; i < 30; i++) { for (let i = 0; i < 30; i++) {
const day = checkDate.getDay(); const dayAvailableHours = calculateEffectiveDayAvailability({
if (day !== 0 && day !== 6) { availability,
let dayBooked = 0; date: checkDate,
for (const a of resourceAssignments) { context,
if (a.startDate <= checkDate && a.endDate >= checkDate) { });
dayBooked += a.hoursPerDay; if (dayAvailableHours > 0) {
} const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
} if (dayBookedHours < dayAvailableHours * 0.8) {
if (dayBooked < dailyAvailHours * 0.8) {
earliestAvailableDate = new Date(checkDate); earliestAvailableDate = new Date(checkDate);
break; break;
} }
} }
checkDate.setDate(checkDate.getDate() + 1); checkDate.setUTCDate(checkDate.getUTCDate() + 1);
} }
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate }); utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });

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