feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
+4
-2
@@ -1,6 +1,8 @@
|
||||
# Database
|
||||
DATABASE_URL=postgresql://planarchy:planarchy_dev@localhost:5433/planarchy
|
||||
DATABASE_URL_TEST=postgresql://planarchy:planarchy_test@localhost:5434/planarchy_test
|
||||
DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
|
||||
DATABASE_URL_TEST=postgresql://capakraken:capakraken_test@localhost:5434/capakraken_test
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS=false
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME=
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6380
|
||||
|
||||
@@ -218,6 +218,8 @@ jobs:
|
||||
--health-retries=5
|
||||
env:
|
||||
DATABASE_URL: postgresql://capakraken:capakraken_test@localhost:5432/capakraken_test
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test
|
||||
REDIS_URL: redis://localhost:6379
|
||||
PORT: 3100
|
||||
steps:
|
||||
|
||||
@@ -6,6 +6,7 @@ node_modules/
|
||||
|
||||
# Build outputs
|
||||
.next/
|
||||
.next-e2e/
|
||||
dist/
|
||||
build/
|
||||
.turbo/
|
||||
@@ -20,6 +21,7 @@ test-results/
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*.local
|
||||
*.e2e-backup
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN pnpm --filter @planarchy/db db:generate
|
||||
RUN pnpm --filter @capakraken/db db:generate
|
||||
|
||||
EXPOSE 3100
|
||||
|
||||
|
||||
+2
-2
@@ -39,12 +39,12 @@ COPY --from=deps /app/ ./
|
||||
COPY . .
|
||||
|
||||
# Generate Prisma client
|
||||
RUN pnpm --filter @planarchy/db db:generate
|
||||
RUN pnpm --filter @capakraken/db db:generate
|
||||
|
||||
# Build the Next.js application
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN pnpm --filter @planarchy/web build
|
||||
RUN pnpm --filter @capakraken/web build
|
||||
|
||||
# ============================================================
|
||||
# Stage 3: Production runtime
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
test.describe("Timeline", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
@@ -7,11 +15,7 @@ test.describe("Timeline", () => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
||||
});
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/timeline");
|
||||
});
|
||||
|
||||
@@ -87,8 +91,13 @@ test.describe("Timeline", () => {
|
||||
.first();
|
||||
const allocationPopoverField = page.getByText("Hours / day");
|
||||
|
||||
const resourceHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
|
||||
await resourceHoverTarget.hover({ position: { x: 120, y: 20 } });
|
||||
const resourceHoverTarget = page.getByTestId("timeline-resource-row-canvas").first();
|
||||
const resourceHoverBox = await resourceHoverTarget.boundingBox();
|
||||
expect(resourceHoverBox).not.toBeNull();
|
||||
if (!resourceHoverBox) {
|
||||
throw new Error("Expected a resource timeline row canvas to be available");
|
||||
}
|
||||
await page.mouse.move(resourceHoverBox.x + 120, resourceHoverBox.y + 20);
|
||||
await expect(heatmapTooltip).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -109,8 +118,19 @@ test.describe("Timeline", () => {
|
||||
await expect(page.getByText(/projects/)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const projectHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
|
||||
await projectHoverTarget.hover({ position: { x: 120, y: 20 } });
|
||||
const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first();
|
||||
const projectHoverBox = await projectHoverTarget.boundingBox();
|
||||
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
|
||||
const projectAllocationBox = await projectAllocation.boundingBox();
|
||||
expect(projectHoverBox).not.toBeNull();
|
||||
expect(projectAllocationBox).not.toBeNull();
|
||||
if (!projectHoverBox) {
|
||||
throw new Error("Expected a project timeline row canvas to be available");
|
||||
}
|
||||
if (!projectAllocationBox) {
|
||||
throw new Error("Expected a project allocation block to be available");
|
||||
}
|
||||
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
|
||||
await expect(heatmapTooltip).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -118,8 +138,48 @@ test.describe("Timeline", () => {
|
||||
})
|
||||
.toBe("rgba(3, 7, 18, 0.96)");
|
||||
|
||||
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
|
||||
await projectAllocation.click({ button: "right" });
|
||||
await expect(allocationPopoverField).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows resolved holiday overlays in the resource timeline and exposes the holiday name in the tooltip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/timeline?startDate=2026-04-01&days=14&eids=bruce.banner", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const holidayBlock = row.locator(
|
||||
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
||||
).first();
|
||||
await expect(holidayBlock).toBeVisible();
|
||||
|
||||
const rowBox = await row.boundingBox();
|
||||
const holidayBox = await holidayBlock.boundingBox();
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(holidayBox).not.toBeNull();
|
||||
|
||||
if (!rowBox || !holidayBox) {
|
||||
throw new Error("Expected timeline row and holiday block bounding boxes to be available");
|
||||
}
|
||||
|
||||
await row.hover({
|
||||
position: {
|
||||
x: holidayBox.x - rowBox.x + holidayBox.width / 2,
|
||||
y: holidayBox.y - rowBox.y + Math.min(holidayBox.height / 2, rowBox.height - 4),
|
||||
},
|
||||
});
|
||||
|
||||
const holidayTooltip = page
|
||||
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
||||
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
|
||||
.first();
|
||||
|
||||
await expect(holidayTooltip).toBeVisible();
|
||||
await expect(holidayTooltip).toContainText("Karfreitag");
|
||||
await expect(holidayTooltip).toContainText("3 April 2026");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
test.describe("Vacations", () => {
|
||||
test.describe("My Vacations (self-service)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
async function signInAsAdmin(page: Page) {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
}
|
||||
|
||||
async function fillDisplayDate(page: Page, label: RegExp, value: string) {
|
||||
const [year, month, day] = value.split("-");
|
||||
await page.getByLabel(label).fill(`${day}/${month}/${year}`);
|
||||
}
|
||||
|
||||
test.describe("Vacations", () => {
|
||||
test.describe("My Vacations (self-service)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/vacations/my");
|
||||
});
|
||||
|
||||
@@ -23,25 +32,19 @@ test.describe("Vacations", () => {
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("request vacation modal opens", async ({ page }) => {
|
||||
test("request vacation is blocked without linked resource", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
||||
await reqBtn.click();
|
||||
// Modal should show vacation form
|
||||
await expect(reqBtn).toBeDisabled();
|
||||
await expect(
|
||||
page.locator("text=Request Vacation").or(page.locator("text=Vacation Type")),
|
||||
page.getByText("Your account is not linked to a resource. Please contact an administrator."),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Vacation Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/auth/signin");
|
||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "admin123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/vacations");
|
||||
});
|
||||
|
||||
@@ -62,12 +65,59 @@ test.describe("Vacations", () => {
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("filter chips are visible on list tab", async ({ page }) => {
|
||||
test("filter controls are visible on list tab", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Status filter options should be visible
|
||||
const filters = page.getByRole("combobox");
|
||||
|
||||
await expect(filters).toHaveCount(3);
|
||||
await expect(filters.nth(0)).toHaveValue("ALL");
|
||||
await expect(filters.nth(1)).toHaveValue("ALL");
|
||||
await expect(filters.nth(2)).toHaveValue("");
|
||||
});
|
||||
|
||||
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => {
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByRole("button", { name: /request vacation/i }).click();
|
||||
|
||||
await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0);
|
||||
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
|
||||
await page.getByLabel(/^type/i).selectOption("ANNUAL");
|
||||
await fillDisplayDate(page, /start date/i, "2026-01-06");
|
||||
await fillDisplayDate(page, /end date/i, "2026-01-06");
|
||||
|
||||
await expect(page.getByTestId("vacation-preview-card")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
|
||||
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
|
||||
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
|
||||
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06");
|
||||
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
|
||||
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Admin Holiday Calendar", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await signInAsAdmin(page);
|
||||
await page.goto("/admin/vacations");
|
||||
});
|
||||
|
||||
test("seeded holiday calendars can be selected and previewed", async ({ page }) => {
|
||||
await expect(page.getByTestId("holiday-calendar-editor")).toBeVisible({ timeout: 10000 });
|
||||
const germanyCalendarRow = page
|
||||
.getByTestId(/holiday-calendar-row-/)
|
||||
.filter({ hasText: "Referenzfeiertage Deutschland 2026-2027" })
|
||||
.first();
|
||||
|
||||
await expect(germanyCalendarRow).toBeVisible({ timeout: 10000 });
|
||||
await germanyCalendarRow.click();
|
||||
|
||||
await expect(
|
||||
page.locator("button", { hasText: /All|Pending|Approved/i }).first(),
|
||||
page.getByRole("heading", { name: "Referenzfeiertage Deutschland 2026-2027" }),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await page.getByTestId("holiday-preview-year-input").fill("2026");
|
||||
await expect(page.getByTestId("holiday-preview-table")).toContainText("2026-01-01");
|
||||
await expect(page.getByTestId("holiday-preview-table")).toContainText("Neujahr");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
/// <reference path="./.next-e2e/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -2,7 +2,9 @@ import path from "path";
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
distDir: process.env.NEXT_DIST_DIR ?? ".next",
|
||||
output: "standalone",
|
||||
outputFileTracingRoot: path.resolve(__dirname, "../.."),
|
||||
devIndicators: false,
|
||||
experimental: {
|
||||
optimizePackageImports: ["recharts", "date-fns"],
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const e2ePort = process.env["PLAYWRIGHT_TEST_PORT"] ?? "3110";
|
||||
const e2eBaseUrl = process.env["PLAYWRIGHT_TEST_BASE_URL"] ?? `http://localhost:${e2ePort}`;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env["CI"],
|
||||
retries: process.env["CI"] ? 2 : 0,
|
||||
...(process.env["CI"] ? { workers: 1 } : {}),
|
||||
workers: 1,
|
||||
reporter: "html",
|
||||
use: {
|
||||
baseURL: "http://localhost:3100",
|
||||
baseURL: e2eBaseUrl,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [
|
||||
@@ -18,9 +21,9 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3100",
|
||||
reuseExistingServer: !process.env["CI"],
|
||||
timeout: 120000,
|
||||
command: "node ./e2e/test-server.mjs",
|
||||
url: e2eBaseUrl,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEditor.js";
|
||||
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
||||
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
||||
|
||||
@@ -8,10 +9,40 @@ export default function AdminVacationsPage() {
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage public holidays, entitlements, and year summaries</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
|
||||
</p>
|
||||
</div>
|
||||
<HolidayCalendarEditor />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
|
||||
</p>
|
||||
</div>
|
||||
<PublicHolidayBatch />
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
|
||||
</p>
|
||||
</div>
|
||||
<EntitlementManager />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { usePermissions } from "~/hooks/usePermissions.js";
|
||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||
|
||||
type ModalState =
|
||||
@@ -85,68 +86,22 @@ function FilterDropdown({
|
||||
tooltipContent?: ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
|
||||
|
||||
const updatePanelPosition = useCallback(() => {
|
||||
const trigger = dropdownRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
|
||||
const viewportPadding = 16;
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
|
||||
|
||||
setPanelPosition({
|
||||
top: rect.bottom + 8,
|
||||
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
|
||||
minWidth: rect.width,
|
||||
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
|
||||
open: isOpen,
|
||||
onClose: () => setIsOpen(false),
|
||||
matchTriggerWidth: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
updatePanelPosition();
|
||||
const rafId = window.requestAnimationFrame(updatePanelPosition);
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePanelPosition);
|
||||
window.addEventListener("scroll", updatePanelPosition, true);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", updatePanelPosition);
|
||||
window.removeEventListener("scroll", updatePanelPosition, true);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen, updatePanelPosition]);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<div ref={triggerRef} className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
onClick={() => {
|
||||
const nextOpen = !isOpen;
|
||||
handleOpenChange(nextOpen);
|
||||
setIsOpen(nextOpen);
|
||||
}}
|
||||
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
|
||||
>
|
||||
<span className="text-left">{label}</span>
|
||||
@@ -160,9 +115,9 @@ function FilterDropdown({
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: panelPosition.top,
|
||||
left: panelPosition.left,
|
||||
minWidth: panelPosition.minWidth,
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
minWidth: position.minWidth,
|
||||
}}
|
||||
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function GlobalError({
|
||||
@@ -11,7 +10,7 @@ export default function GlobalError({
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(error);
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
||||
|
||||
const PERMISSION_LABELS: Record<string, string> = {
|
||||
viewCosts: "View Costs",
|
||||
useAssistantAdvancedTools: "Assistant Advanced Tools",
|
||||
exportData: "Export Data",
|
||||
importData: "Import Data",
|
||||
approveVacations: "Approve Vacations",
|
||||
@@ -24,6 +25,7 @@ const PERMISSION_LABELS: Record<string, string> = {
|
||||
|
||||
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
|
||||
viewCosts: "Access to cost data, budget views, and financial reports",
|
||||
useAssistantAdvancedTools: "Unlocks advanced AI assistant workflows for complex cross-entity analyses",
|
||||
exportData: "Export data to Excel, CSV, or PDF formats",
|
||||
importData: "Import data from external sources (Dispo, Excel)",
|
||||
approveVacations: "Approve or reject vacation requests",
|
||||
@@ -97,6 +99,8 @@ export function SystemRolesClient() {
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore TS2589: tRPC infers union type too deeply for the role config update payload
|
||||
const updateMutation = trpc.systemRoleConfig.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.systemRoleConfig.list.invalidate();
|
||||
|
||||
@@ -15,6 +15,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
||||
|
||||
const PERMISSION_LABELS: Record<string, string> = {
|
||||
viewCosts: "View Costs",
|
||||
useAssistantAdvancedTools: "Assistant Advanced Tools",
|
||||
exportData: "Export Data",
|
||||
importData: "Import Data",
|
||||
approveVacations: "Approve Vacations",
|
||||
@@ -25,6 +26,7 @@ const PERMISSION_LABELS: Record<string, string> = {
|
||||
manageAllocations: "Manage Allocations",
|
||||
manageRoles: "Manage Roles",
|
||||
manageUsers: "Manage Users",
|
||||
viewScores: "View Scores",
|
||||
};
|
||||
|
||||
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
||||
|
||||
@@ -11,6 +11,50 @@ import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
|
||||
|
||||
type Dimension = "2d" | "3d";
|
||||
|
||||
interface ResourceHolidayMeta {
|
||||
date: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
calendarName: string | null;
|
||||
}
|
||||
|
||||
interface ResourceFactorMeta {
|
||||
weeklyAvailability?: Record<string, number>;
|
||||
baseWorkingDays?: number;
|
||||
effectiveWorkingDays?: number;
|
||||
baseAvailableHours?: number;
|
||||
effectiveAvailableHours?: number;
|
||||
publicHolidayCount?: number;
|
||||
publicHolidayWorkdayCount?: number;
|
||||
publicHolidayHoursDeduction?: number;
|
||||
absenceDayCount?: number;
|
||||
absenceHoursDeduction?: number;
|
||||
chargeableHours?: number;
|
||||
utilizationPct?: number;
|
||||
}
|
||||
|
||||
interface ResourceGraphMeta {
|
||||
resourceName?: string;
|
||||
resourceEid?: string;
|
||||
month?: string;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
resolvedHolidays?: ResourceHolidayMeta[];
|
||||
factors?: ResourceFactorMeta;
|
||||
}
|
||||
|
||||
function formatNumber(value: number | undefined, digits = 1): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return "—";
|
||||
}
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export default function ComputationGraphClient() {
|
||||
const state = useComputationGraphData();
|
||||
const [dimension, setDimension] = useState<Dimension>("2d");
|
||||
@@ -24,10 +68,34 @@ export default function ComputationGraphClient() {
|
||||
isLoading,
|
||||
activeDomains,
|
||||
graphData,
|
||||
rawData,
|
||||
highlightedNodes, setHighlightedNodes,
|
||||
domainFilter, toggleDomain,
|
||||
} = state;
|
||||
|
||||
const resourceMeta = viewMode === "resource"
|
||||
? (rawData?.meta as ResourceGraphMeta | undefined)
|
||||
: undefined;
|
||||
const resourceFactors = resourceMeta?.factors;
|
||||
const weeklyAvailabilityEntries: Array<[string, number | undefined]> = resourceFactors?.weeklyAvailability
|
||||
? [
|
||||
["Mo", resourceFactors.weeklyAvailability.monday],
|
||||
["Di", resourceFactors.weeklyAvailability.tuesday],
|
||||
["Mi", resourceFactors.weeklyAvailability.wednesday],
|
||||
["Do", resourceFactors.weeklyAvailability.thursday],
|
||||
["Fr", resourceFactors.weeklyAvailability.friday],
|
||||
["Sa", resourceFactors.weeklyAvailability.saturday],
|
||||
["So", resourceFactors.weeklyAvailability.sunday],
|
||||
]
|
||||
: [];
|
||||
const weeklyAvailability = resourceFactors?.weeklyAvailability
|
||||
? weeklyAvailabilityEntries
|
||||
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && entry[1] > 0)
|
||||
.map(([label, hours]) => `${label} ${formatNumber(hours, 1)}h`)
|
||||
.join(" · ")
|
||||
: "—";
|
||||
const topHolidays = resourceMeta?.resolvedHolidays?.slice(0, 6) ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
{/* ── Header Bar ── */}
|
||||
@@ -173,6 +241,102 @@ export default function ComputationGraphClient() {
|
||||
<ComputationGraph3D state={state} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewMode === "resource" && resourceMeta && (
|
||||
<aside className="w-[24rem] overflow-y-auto border-l border-zinc-200 bg-white/90 p-4 dark:border-zinc-700 dark:bg-zinc-950/90">
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Bezugsgroessen</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{resourceMeta.resourceName ?? "Resource"}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">{resourceMeta.resourceEid ?? "—"} · {resourceMeta.month ?? month}</div>
|
||||
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Land</div>
|
||||
<div>{resourceMeta.countryName ?? resourceMeta.countryCode ?? "—"}{resourceMeta.countryCode ? ` (${resourceMeta.countryCode})` : ""}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Bundesland / Region</div>
|
||||
<div>{resourceMeta.federalState ?? "—"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Ort / Metro</div>
|
||||
<div>{resourceMeta.metroCityName ?? "—"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Wochenverfuegbarkeit</div>
|
||||
<div>{weeklyAvailability}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Feiertagsbasis</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{resourceFactors?.publicHolidayCount ?? 0} Feiertage, {resourceFactors?.publicHolidayWorkdayCount ?? 0} wirksam
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{topHolidays.length > 0 ? topHolidays.map((holiday) => (
|
||||
<div
|
||||
key={`${holiday.date}-${holiday.name}`}
|
||||
className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
|
||||
>
|
||||
<div className="font-medium text-zinc-900 dark:text-zinc-100">{holiday.name}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{holiday.date} · {holiday.scope} · {holiday.calendarName ?? "Kalender"}
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800">
|
||||
Keine aufgeloesten Feiertage im gewaehlten Monat.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Herleitung</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="rounded-lg bg-white px-3 py-2 text-sm dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">SAH Formel</div>
|
||||
<div className="font-medium text-zinc-900 dark:text-zinc-100">
|
||||
{formatNumber(resourceFactors?.baseAvailableHours)}h - {formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h - {formatNumber(resourceFactors?.absenceHoursDeduction)}h = {formatNumber(resourceFactors?.effectiveAvailableHours)}h
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Basistage</div>
|
||||
<div>{formatNumber(resourceFactors?.baseWorkingDays, 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Effektive Tage</div>
|
||||
<div>{formatNumber(resourceFactors?.effectiveWorkingDays, 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Feiertagsabzug</div>
|
||||
<div>{formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Abwesenheitsabzug</div>
|
||||
<div>{formatNumber(resourceFactors?.absenceHoursDeduction)}h</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Chargeable Hours</div>
|
||||
<div>{formatNumber(resourceFactors?.chargeableHours)}h</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||
<div className="text-xs uppercase text-zinc-500">Auslastung</div>
|
||||
<div>{formatNumber(resourceFactors?.utilizationPct)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,12 +6,19 @@ import {
|
||||
RESOURCE_VIEW_DOMAINS,
|
||||
PROJECT_VIEW_DOMAINS,
|
||||
type Domain,
|
||||
type GraphLink,
|
||||
type GraphNode,
|
||||
} from "./domain-colors";
|
||||
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
|
||||
|
||||
export type ViewMode = "resource" | "project";
|
||||
|
||||
export interface ComputationGraphResponse {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ComputationGraphState {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (m: ViewMode) => void;
|
||||
@@ -26,6 +33,7 @@ export interface ComputationGraphState {
|
||||
isLoading: boolean;
|
||||
activeDomains: Domain[];
|
||||
graphData: ForceGraphData;
|
||||
rawData: ComputationGraphResponse | null;
|
||||
highlightedNodes: Set<string> | null;
|
||||
setHighlightedNodes: (s: Set<string> | null) => void;
|
||||
hoveredNode: PositionedNode | null;
|
||||
@@ -144,6 +152,7 @@ export function useComputationGraphData(): ComputationGraphState {
|
||||
isLoading,
|
||||
activeDomains,
|
||||
graphData,
|
||||
rawData: (rawData as ComputationGraphResponse | undefined) ?? null,
|
||||
highlightedNodes,
|
||||
setHighlightedNodes,
|
||||
hoveredNode,
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
interface AssistantInsightMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "good" | "warn" | "danger" | "info";
|
||||
}
|
||||
|
||||
interface AssistantInsightSection {
|
||||
title: string;
|
||||
metrics: AssistantInsightMetric[];
|
||||
}
|
||||
|
||||
interface AssistantInsight {
|
||||
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
metrics: AssistantInsightMetric[];
|
||||
sections?: AssistantInsightSection[];
|
||||
}
|
||||
|
||||
interface ChatMessageProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
insights?: AssistantInsight[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight inline markdown renderer — handles bold, italic, code,
|
||||
* bullet lists, and numbered lists without a full markdown library.
|
||||
*/
|
||||
function renderMarkdown(text: string) {
|
||||
const lines = text.split("\n");
|
||||
const elements: React.ReactNode[] = [];
|
||||
@@ -21,7 +38,7 @@ function renderMarkdown(text: string) {
|
||||
if (listItems.length > 0 && listType) {
|
||||
const Tag = listType;
|
||||
elements.push(
|
||||
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "list-disc pl-4 my-1 space-y-0.5" : "list-decimal pl-4 my-1 space-y-0.5"}>
|
||||
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "my-1 list-disc space-y-0.5 pl-4" : "my-1 list-decimal space-y-0.5 pl-4"}>
|
||||
{listItems}
|
||||
</Tag>,
|
||||
);
|
||||
@@ -31,7 +48,6 @@ function renderMarkdown(text: string) {
|
||||
};
|
||||
|
||||
for (const [i, line] of lines.entries()) {
|
||||
// Bullet list: "- item" or "* item"
|
||||
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
|
||||
if (bulletMatch?.[1]) {
|
||||
if (listType !== "ul") flushList();
|
||||
@@ -40,7 +56,6 @@ function renderMarkdown(text: string) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbered list: "1. item"
|
||||
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
|
||||
if (numMatch?.[1]) {
|
||||
if (listType !== "ol") flushList();
|
||||
@@ -49,54 +64,46 @@ function renderMarkdown(text: string) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a list item — flush any pending list
|
||||
flushList();
|
||||
|
||||
// Empty line → spacing
|
||||
if (line.trim() === "") {
|
||||
elements.push(<div key={`br-${i}`} className="h-2" />);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph
|
||||
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
|
||||
}
|
||||
|
||||
flushList();
|
||||
return elements;
|
||||
}
|
||||
|
||||
/** Parse inline formatting: **bold**, *italic*, `code` */
|
||||
function inlineFormat(text: string): React.ReactNode {
|
||||
// Split by inline patterns, preserving delimiters
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Regex: **bold**, *italic*, `code`
|
||||
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// Text before this match
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[2]) {
|
||||
// **bold**
|
||||
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
|
||||
} else if (match[3]) {
|
||||
// *italic*
|
||||
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
|
||||
} else if (match[4]) {
|
||||
// `code`
|
||||
parts.push(
|
||||
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
|
||||
{match[4]}
|
||||
</code>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Remaining text
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
@@ -104,7 +111,72 @@ function inlineFormat(text: string): React.ReactNode {
|
||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content }: ChatMessageProps) {
|
||||
function metricToneClasses(tone: AssistantInsightMetric["tone"] | undefined): string {
|
||||
switch (tone) {
|
||||
case "good":
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300";
|
||||
case "warn":
|
||||
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300";
|
||||
case "danger":
|
||||
return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300";
|
||||
case "info":
|
||||
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300";
|
||||
default:
|
||||
return "border-gray-200 bg-white text-gray-700 dark:border-slate-700 dark:bg-slate-900/60 dark:text-gray-200";
|
||||
}
|
||||
}
|
||||
|
||||
function InsightMetric({ metric }: { metric: AssistantInsightMetric }) {
|
||||
return (
|
||||
<div className={clsx("rounded-xl border px-2.5 py-2", metricToneClasses(metric.tone))}>
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.08em] opacity-70">{metric.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold leading-tight">{metric.value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InsightCard({ insight }: { insight: AssistantInsight }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/90 p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/85">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{insight.title}</div>
|
||||
{insight.subtitle && (
|
||||
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{insight.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{insight.kind.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{insight.metrics.map((metric, index) => (
|
||||
<InsightMetric key={`${insight.kind}-${metric.label}-${index}`} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{insight.sections && insight.sections.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{insight.sections.map((section, sectionIndex) => (
|
||||
<div key={`${insight.kind}-${section.title}-${sectionIndex}`} className="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 p-2.5 dark:border-slate-700 dark:bg-slate-800/60">
|
||||
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
|
||||
{section.title}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{section.metrics.map((metric, metricIndex) => (
|
||||
<InsightMetric key={`${section.title}-${metric.label}-${metricIndex}`} metric={metric} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content, insights }: ChatMessageProps) {
|
||||
const isUser = role === "user";
|
||||
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]);
|
||||
|
||||
@@ -121,12 +193,19 @@ export function ChatMessage({ role, content }: ChatMessageProps) {
|
||||
<span className="whitespace-pre-wrap break-words">{content}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 mb-1.5">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<span className="mb-1.5 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
AI Generated
|
||||
</span>
|
||||
{insights && insights.length > 0 && (
|
||||
<div className="mb-2 space-y-2">
|
||||
{insights.map((insight, index) => (
|
||||
<InsightCard key={`${insight.kind}-${insight.title}-${index}`} insight={insight} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-0.5 break-words">{rendered}</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,26 @@ function resolvePageContext(pathname: string): string {
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
insights?: AssistantInsight[];
|
||||
}
|
||||
|
||||
interface AssistantInsightMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "good" | "warn" | "danger" | "info";
|
||||
}
|
||||
|
||||
interface AssistantInsightSection {
|
||||
title: string;
|
||||
metrics: AssistantInsightMetric[];
|
||||
}
|
||||
|
||||
interface AssistantInsight {
|
||||
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
metrics: AssistantInsightMetric[];
|
||||
sections?: AssistantInsightSection[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "capakraken-chat-messages";
|
||||
@@ -47,7 +67,23 @@ function loadPersistedMessages(): Message[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return JSON.parse(raw) as Message[];
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.filter((item): item is Partial<Message> & { role: Message["role"]; content: string } => (
|
||||
typeof item === "object"
|
||||
&& item !== null
|
||||
&& (item.role === "user" || item.role === "assistant")
|
||||
&& typeof item.content === "string"
|
||||
))
|
||||
.map((item) => ({
|
||||
role: item.role,
|
||||
content: item.content,
|
||||
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore corrupt data */ }
|
||||
return [];
|
||||
}
|
||||
@@ -101,10 +137,23 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
|
||||
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
|
||||
});
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]);
|
||||
const typedReply = reply as {
|
||||
content: string;
|
||||
role: "assistant";
|
||||
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
|
||||
insights?: AssistantInsight[];
|
||||
};
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: typedReply.content,
|
||||
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
|
||||
},
|
||||
]);
|
||||
|
||||
// Handle actions from the AI (navigation, data invalidation)
|
||||
const actions = (reply as { actions?: Array<{ type: string; url?: string; scope?: string[] }> }).actions;
|
||||
const actions = typedReply.actions;
|
||||
if (actions) {
|
||||
for (const action of actions) {
|
||||
if (action.type === "navigate" && action.url) {
|
||||
@@ -230,7 +279,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage key={i} role={msg.role} content={msg.content} />
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
{...(msg.insights ? { insights: msg.insights } : {})}
|
||||
/>
|
||||
))}
|
||||
{isLoading && <TypingIndicator />}
|
||||
{error && (
|
||||
|
||||
@@ -158,6 +158,12 @@ export function DashboardClient() {
|
||||
<WidgetContainer
|
||||
title={widget.title ?? getWidget(widget.type).label}
|
||||
description={getWidget(widget.type).description}
|
||||
showDetails={widget.config.showDetails === true}
|
||||
onToggleDetails={() =>
|
||||
updateWidgetConfig(widget.id, {
|
||||
showDetails: widget.config.showDetails !== true,
|
||||
})
|
||||
}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
>
|
||||
{renderWidget(widget.type, widget.config, (update) =>
|
||||
|
||||
@@ -8,9 +8,19 @@ interface WidgetContainerProps {
|
||||
onRemove: () => void;
|
||||
children: React.ReactNode;
|
||||
isDragging?: boolean;
|
||||
showDetails?: boolean;
|
||||
onToggleDetails?: () => void;
|
||||
}
|
||||
|
||||
export function WidgetContainer({ title, description, onRemove, children, isDragging }: WidgetContainerProps) {
|
||||
export function WidgetContainer({
|
||||
title,
|
||||
description,
|
||||
onRemove,
|
||||
children,
|
||||
isDragging,
|
||||
showDetails = false,
|
||||
onToggleDetails,
|
||||
}: WidgetContainerProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
@@ -19,14 +29,12 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
|
||||
className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
|
||||
isDragging
|
||||
? "shadow-xl border-brand-400 dark:border-brand-500 scale-[1.01] ring-2 ring-brand-400/30"
|
||||
: "bg-white dark:bg-gray-900 border-gray-200/80 dark:border-gray-700/60 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600"
|
||||
: "border-gray-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95),rgba(255,255,255,0.98))] shadow-sm hover:shadow-md hover:border-gray-300 dark:border-gray-700/60 dark:bg-[linear-gradient(180deg,rgba(17,24,39,0.96),rgba(17,24,39,0.92))] dark:hover:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{/* Header — clean, no background separation */}
|
||||
<div className="flex items-center justify-between px-4 pt-3.5 pb-2 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle group">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3 shrink-0 widget-drag-handle group">
|
||||
<div className="min-w-0 flex-1 cursor-grab active:cursor-grabbing">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Drag grip dots */}
|
||||
<svg
|
||||
className="w-3.5 h-5 text-gray-300 dark:text-gray-600 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
viewBox="0 0 14 20"
|
||||
@@ -39,19 +47,46 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
|
||||
<circle cx="4" cy="16" r="1.5" />
|
||||
<circle cx="10" cy="16" r="1.5" />
|
||||
</svg>
|
||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{title}</span>
|
||||
<span className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</span>
|
||||
{showDetails ? (
|
||||
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-brand-700 dark:bg-brand-500/10 dark:text-brand-300">
|
||||
Details
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 truncate mt-0.5 ml-[22px]">{description}</p>
|
||||
<p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{onToggleDetails ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleDetails();
|
||||
}}
|
||||
className={`rounded-xl border px-3 py-1.5 text-[11px] font-semibold transition ${
|
||||
showDetails
|
||||
? "border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-500/30 dark:bg-brand-500/10 dark:text-brand-300"
|
||||
: "border-gray-200 bg-white/80 text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
}`}
|
||||
title={showDetails ? "Hide details" : "Show details"}
|
||||
>
|
||||
{showDetails ? "Details on" : "Details off"}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
className="ml-2 p-1.5 text-gray-300 dark:text-gray-600 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors shrink-0 opacity-0 group-hover:opacity-100"
|
||||
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500 dark:text-gray-600 dark:hover:bg-red-950/30 dark:hover:text-red-400"
|
||||
title="Remove widget"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -59,12 +94,11 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle separator */}
|
||||
<div className="mx-4 border-t border-gray-100 dark:border-gray-800" />
|
||||
<div className="mx-4 border-t border-gray-200/80 dark:border-gray-800" />
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
||||
<div className="flex-1 overflow-auto p-4 pt-3">{children}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,67 @@ function textColorClass(pct: number): string {
|
||||
return "text-green-700";
|
||||
}
|
||||
|
||||
type BudgetForecastLocation = {
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
activeAssignmentCount?: number;
|
||||
burnRateCents?: number;
|
||||
};
|
||||
|
||||
type BudgetForecastRow = {
|
||||
projectId?: string;
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
budgetCents: number;
|
||||
spentCents: number;
|
||||
remainingCents?: number;
|
||||
burnRate: number;
|
||||
estimatedExhaustionDate: string | null;
|
||||
pctUsed: number;
|
||||
activeAssignmentCount?: number;
|
||||
calendarLocations?: BudgetForecastLocation[];
|
||||
};
|
||||
|
||||
function formatCurrency(cents: number | undefined): string {
|
||||
if (cents === undefined) return "—";
|
||||
return `${(cents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €`;
|
||||
}
|
||||
|
||||
function formatLocation(location: BudgetForecastLocation): string {
|
||||
const parts = [
|
||||
location.countryCode ?? location.countryName ?? null,
|
||||
location.federalState ?? null,
|
||||
location.metroCityName ?? null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
helper,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
helper: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/40">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{value}</div>
|
||||
<div className="mt-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">{helper}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const showDetails = config.showDetails === true;
|
||||
const { clients } = useWidgetFilterOptions();
|
||||
|
||||
const filters = useMemo<WidgetFilter[]>(
|
||||
@@ -39,7 +99,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const clientId = (config.clientId as string) ?? "";
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = data ?? [];
|
||||
const all = (data ?? []) as BudgetForecastRow[];
|
||||
return all.filter((r) => {
|
||||
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
|
||||
if (clientId && r.clientId !== clientId) return false;
|
||||
@@ -47,6 +107,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
});
|
||||
}, [data, search, clientId]);
|
||||
|
||||
const totals = useMemo(() => rows.reduce((acc, row) => {
|
||||
acc.budgetCents += row.budgetCents;
|
||||
acc.spentCents += row.spentCents;
|
||||
acc.remainingCents += row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents);
|
||||
acc.burnRate += row.burnRate;
|
||||
acc.activeAssignmentCount += row.activeAssignmentCount ?? 0;
|
||||
return acc;
|
||||
}, {
|
||||
budgetCents: 0,
|
||||
spentCents: 0,
|
||||
remainingCents: 0,
|
||||
burnRate: 0,
|
||||
activeAssignmentCount: 0,
|
||||
}), [rows]);
|
||||
|
||||
if (isLoading && !data) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pt-1">
|
||||
@@ -75,6 +150,28 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||
<div className="mb-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<SummaryCard
|
||||
label="Projects"
|
||||
value={String(rows.length)}
|
||||
helper={`${totals.activeAssignmentCount} active assignments in scope`}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Budget"
|
||||
value={formatCurrency(totals.budgetCents)}
|
||||
helper={`${formatCurrency(totals.spentCents)} spent`}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Remaining"
|
||||
value={formatCurrency(totals.remainingCents)}
|
||||
helper={`${rows.filter((row) => row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Burn / Month"
|
||||
value={formatCurrency(totals.burnRate)}
|
||||
helper="Holiday- and absence-adjusted active burn"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||
@@ -86,7 +183,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
|
||||
Burn/mo <InfoTooltip content="Current-month burn rate based on active allocations, adjusted for regional holidays and approved absences where resource calendars are available." />
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
|
||||
@@ -96,11 +193,41 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[140px] truncate">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[260px] align-top">
|
||||
<div>
|
||||
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
|
||||
{row.projectName}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||
{row.clientName ?? "No client"}
|
||||
{!showDetails && row.calendarLocations && row.calendarLocations.length > 0
|
||||
? ` · ${formatLocation(row.calendarLocations[0]!)}`
|
||||
: ""}
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 space-y-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
|
||||
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
|
||||
<div>Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.calendarLocations && row.calendarLocations.length > 0 ? (
|
||||
row.calendarLocations.slice(0, 4).map((location) => (
|
||||
<span
|
||||
key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}
|
||||
className="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 dark:border-gray-700 dark:bg-gray-900/70"
|
||||
>
|
||||
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span>No active calendar basis in the current month</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -112,14 +239,37 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
{row.pctUsed}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{formatCurrency(row.spentCents)} / {formatCurrency(row.budgetCents)}
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
|
||||
{row.burnRate > 0
|
||||
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
|
||||
: "\u2014"}
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums align-top">
|
||||
<div>
|
||||
{row.burnRate > 0 ? formatCurrency(row.burnRate) : "\u2014"}
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
|
||||
{(row.calendarLocations ?? []).slice(0, 3).map((location) => (
|
||||
<div key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}>
|
||||
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums">
|
||||
{row.estimatedExhaustionDate ?? "\u2014"}
|
||||
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums align-top">
|
||||
<div>{row.estimatedExhaustionDate ?? "\u2014"}</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
at {formatCurrency(row.burnRate)} / month
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -36,7 +36,90 @@ type ChargeabilityRow = {
|
||||
chargeabilityTarget: number;
|
||||
actualChargeability: number;
|
||||
expectedChargeability: number;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
derivation?: {
|
||||
weeklyAvailabilityHours: number;
|
||||
baseWorkingDays: number;
|
||||
effectiveWorkingDayEquivalent: number;
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
publicHolidayCount: number;
|
||||
publicHolidayWorkdayCount: number;
|
||||
publicHolidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
actualBookedHours: number;
|
||||
expectedBookedHours: number;
|
||||
targetBookedHours: number;
|
||||
unassignedHours: number;
|
||||
};
|
||||
};
|
||||
|
||||
function formatHours(value: number | undefined): string {
|
||||
if (value === undefined) return "—";
|
||||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatDayEquivalent(value: number | undefined): string {
|
||||
if (value === undefined) return "—";
|
||||
return Number.isInteger(value) ? `${value}` : value.toFixed(1);
|
||||
}
|
||||
|
||||
function MetricPill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||||
<span className="text-gray-400 dark:text-gray-500">{label}</span>
|
||||
<span className="text-gray-700 dark:text-gray-200">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatLocation(row: ChargeabilityRow): string {
|
||||
const parts = [row.countryCode ?? row.countryName ?? null, row.federalState ?? null, row.metroCityName ?? null]
|
||||
.filter((part): part is string => Boolean(part));
|
||||
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||
}
|
||||
|
||||
function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) {
|
||||
const derivation = row.derivation;
|
||||
|
||||
if (!derivation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 space-y-1.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<MetricPill label="Loc" value={formatLocation(row)} />
|
||||
<MetricPill label="Week" value={formatHours(derivation.weeklyAvailabilityHours)} />
|
||||
<MetricPill label="Target" value={formatHours(derivation.targetBookedHours)} />
|
||||
</div>
|
||||
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
|
||||
<div>
|
||||
Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)}
|
||||
</div>
|
||||
<div>
|
||||
Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)})
|
||||
</div>
|
||||
<div>
|
||||
Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)}
|
||||
</div>
|
||||
<div>
|
||||
Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)})
|
||||
</div>
|
||||
<div>
|
||||
Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)}
|
||||
</div>
|
||||
<div>
|
||||
Free {formatHours(derivation.unassignedHours)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -74,7 +157,13 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
|
||||
}
|
||||
|
||||
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
|
||||
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
|
||||
const config = _config as {
|
||||
topN?: number;
|
||||
watchlistThreshold?: number;
|
||||
chapter?: string;
|
||||
includeProposed?: boolean;
|
||||
showDetails?: boolean;
|
||||
};
|
||||
const { chapters } = useWidgetFilterOptions();
|
||||
|
||||
const widgetFilters = useMemo<WidgetFilter[]>(
|
||||
@@ -86,6 +175,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
);
|
||||
|
||||
const includeProposed = !!config.includeProposed;
|
||||
const showDetails = !!config.showDetails;
|
||||
const chapterFilter = (config.chapter as string) ?? "";
|
||||
const [showDeparted, setShowDeparted] = useState(false);
|
||||
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
||||
@@ -266,7 +356,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||
Period: {month}
|
||||
<InfoTooltip
|
||||
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
|
||||
content="Chargeability is calculated for the current calendar month. Available hours are derived from each person's weekly schedule and reduced by regional public holidays plus approved absences. Watchlist threshold: 15 percentage points below target."
|
||||
width="w-72"
|
||||
/>
|
||||
</p>
|
||||
@@ -330,7 +420,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
|
||||
Top Chargeability
|
||||
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours / total available hours." />
|
||||
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours divided by holiday- and absence-adjusted available hours." />
|
||||
<span className="ml-1 font-normal normal-case text-gray-400">
|
||||
{visibleTop.length}/{top.length}
|
||||
</span>
|
||||
@@ -390,18 +480,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
{visibleTop.map((r, i) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
||||
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[120px]">
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
|
||||
<div className="truncate">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
</div>
|
||||
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
|
||||
<UtilizationBar percent={r.actualChargeability} />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400">
|
||||
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400 align-top">
|
||||
<div>
|
||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
<td className="px-2 py-1 text-right text-gray-400 align-top">
|
||||
<div>
|
||||
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -473,18 +578,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{visibleWatchlist.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[140px]">
|
||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
|
||||
<div className="truncate">
|
||||
<span title={r.displayName}>{r.displayName}</span>
|
||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||
</div>
|
||||
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
|
||||
<UtilizationBar percent={r.actualChargeability} />
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400">
|
||||
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400 align-top">
|
||||
<div>
|
||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right text-gray-400">
|
||||
<td className="px-2 py-1 text-right text-gray-400 align-top">
|
||||
<div>
|
||||
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,53 @@ import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
|
||||
type GroupBy = "project" | "person" | "chapter";
|
||||
|
||||
type DemandRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
allocatedHours: number;
|
||||
requiredFTEs: number;
|
||||
resourceCount: number;
|
||||
derivation?: {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
periodWorkingHoursBase: number;
|
||||
requiredHours: number | null;
|
||||
requiredFTEs: number;
|
||||
fillPct: number | null;
|
||||
demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE";
|
||||
calendarLocations: Array<{
|
||||
countryCode: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resourceCount: number;
|
||||
allocatedHours: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type DemandDerivation = NonNullable<DemandRow["derivation"]>;
|
||||
type DemandCalendarLocation = DemandDerivation["calendarLocations"][number];
|
||||
|
||||
function formatHours(value: number | null | undefined): string {
|
||||
if (value == null) return "—";
|
||||
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatLocation(location: DemandCalendarLocation): string {
|
||||
const parts = [location.countryCode, location.federalState, location.metroCityName]
|
||||
.filter((part): part is string => Boolean(part));
|
||||
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||
}
|
||||
|
||||
function formatDemandSource(source: DemandDerivation["demandSource"] | undefined): string {
|
||||
if (source === "DEMAND_REQUIREMENTS") return "Source: Demand requirements";
|
||||
if (source === "PROJECT_STAFFING_REQS") return "Source: Project staffing reqs";
|
||||
return "No demand basis";
|
||||
}
|
||||
|
||||
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const showDetails = config.showDetails === true;
|
||||
const groupBy = (config.groupBy as GroupBy) || "project";
|
||||
|
||||
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
|
||||
@@ -48,7 +94,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const rows = data ?? [];
|
||||
const rows = (data ?? []) as DemandRow[];
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const mult = sortDir === "asc" ? 1 : -1;
|
||||
@@ -144,37 +190,84 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sorted.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
|
||||
<td className="px-3 py-2 text-gray-900 max-w-[280px] align-top">
|
||||
<div className="font-medium truncate">
|
||||
{groupBy === "project" ? (
|
||||
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
|
||||
) : (
|
||||
row.name
|
||||
)}
|
||||
</div>
|
||||
{showDetails && groupBy === "project" && row.derivation ? (
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
|
||||
<div>
|
||||
{row.derivation.periodStart} to {row.derivation.periodEnd}
|
||||
</div>
|
||||
<div>
|
||||
{row.derivation.calendarLocations.length > 0
|
||||
? row.derivation.calendarLocations
|
||||
.slice(0, 2)
|
||||
.map((location) =>
|
||||
`${formatLocation(location)} (${formatHours(location.allocatedHours)})`,
|
||||
)
|
||||
.join(" · ")
|
||||
: "No location-based booking basis"}
|
||||
</div>
|
||||
{row.derivation.calendarLocations.length > 2 ? (
|
||||
<div>+ {row.derivation.calendarLocations.length - 2} more calendar contexts</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right align-top">
|
||||
<div className="text-gray-700">{row.allocatedHours}h</div>
|
||||
{showDetails && groupBy === "project" && row.derivation ? (
|
||||
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
|
||||
<div>{row.derivation.calendarLocations.length} calendar basis{row.derivation.calendarLocations.length === 1 ? "" : "es"}</div>
|
||||
<div>{row.resourceCount} resource{row.resourceCount === 1 ? "" : "s"} in scope</div>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
|
||||
{groupBy === "project" && (
|
||||
<td className="px-3 py-2 text-right text-gray-700">
|
||||
<td className="px-3 py-2 text-right align-top text-gray-700">
|
||||
{(() => {
|
||||
const ftes = row.requiredFTEs as unknown as number;
|
||||
if (ftes <= 0) return "—";
|
||||
const requiredHours = ftes * 22 * 3 * 8;
|
||||
const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100));
|
||||
const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3;
|
||||
const requiredHours = row.derivation?.requiredHours ?? null;
|
||||
const rawFillPct = row.derivation?.fillPct ?? null;
|
||||
const fillPct = Math.min(100, rawFillPct ?? 0);
|
||||
const isBelowTarget = rawFillPct !== null ? rawFillPct < 100 : false;
|
||||
const ringColor = isBelowTarget
|
||||
? "var(--color-red-500, #ef4444)"
|
||||
: "var(--color-green-500, #22c55e)";
|
||||
return (
|
||||
<div className="inline-flex flex-col items-end gap-1">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
|
||||
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
|
||||
{ftes} FTE
|
||||
</span>
|
||||
</span>
|
||||
{showDetails ? (
|
||||
<div className="space-y-0.5 text-[10px] leading-4 text-gray-500">
|
||||
<div>{formatHours(row.allocatedHours)} / {formatHours(requiredHours)}</div>
|
||||
<div>{rawFillPct == null ? "—" : `${rawFillPct}% coverage`} · {formatHours(row.derivation?.periodWorkingHoursBase)} per 1.0 FTE</div>
|
||||
<div>{formatDemandSource(row.derivation?.demandSource)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
|
||||
<td className="px-3 py-2 text-right align-top text-gray-500">
|
||||
<div>{row.resourceCount}</div>
|
||||
{showDetails && groupBy === "project" && row.derivation?.calendarLocations.length ? (
|
||||
<div className="mt-1 text-[10px] leading-4 text-gray-500">
|
||||
{row.derivation.calendarLocations.reduce((sum, location) => sum + location.resourceCount, 0)} resource entries across locations
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,55 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from "recharts";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const COLORS = [
|
||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
||||
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
|
||||
];
|
||||
type PeakTimesChartRow = {
|
||||
period: string;
|
||||
label: string;
|
||||
bookedHours: number;
|
||||
capacityHours: number;
|
||||
utilizationPct: number;
|
||||
remainingHours: number;
|
||||
overbookedHours: number;
|
||||
isCurrentPeriod: boolean;
|
||||
};
|
||||
|
||||
interface PeakTimesChartProps {
|
||||
chartData: Record<string, number | string>[];
|
||||
groups: string[];
|
||||
rows: PeakTimesChartRow[];
|
||||
selectedPeriod: string | null;
|
||||
onSelectedPeriodChange?: (period: string) => void;
|
||||
}
|
||||
|
||||
export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) {
|
||||
if (chartData.length === 0) {
|
||||
function formatHours(value: number): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function utilizationBarTone(utilizationPct: number): string {
|
||||
if (utilizationPct > 100) return "bg-red-500";
|
||||
if (utilizationPct > 75) return "bg-emerald-500";
|
||||
if (utilizationPct >= 50) return "bg-amber-400";
|
||||
return "bg-rose-400";
|
||||
}
|
||||
|
||||
function utilizationTextTone(utilizationPct: number): string {
|
||||
if (utilizationPct > 100) return "text-red-600 dark:text-red-300";
|
||||
if (utilizationPct > 75) return "text-emerald-600 dark:text-emerald-300";
|
||||
if (utilizationPct >= 50) return "text-amber-600 dark:text-amber-300";
|
||||
return "text-rose-600 dark:text-rose-300";
|
||||
}
|
||||
|
||||
export default function PeakTimesChart({
|
||||
rows,
|
||||
selectedPeriod,
|
||||
onSelectedPeriodChange,
|
||||
}: PeakTimesChartProps) {
|
||||
const [hoveredPeriod, setHoveredPeriod] = useState<string | null>(null);
|
||||
|
||||
const fallbackPeriod = selectedPeriod && rows.some((row) => row.period === selectedPeriod)
|
||||
? selectedPeriod
|
||||
: rows[0]?.period ?? null;
|
||||
const activePeriod = hoveredPeriod ?? fallbackPeriod;
|
||||
const activeRow = useMemo(
|
||||
() => rows.find((row) => row.period === activePeriod) ?? rows[0] ?? null,
|
||||
[activePeriod, rows],
|
||||
);
|
||||
const chartMaxPct = useMemo(() => {
|
||||
const maxUtilization = Math.max(100, ...rows.map((row) => row.utilizationPct));
|
||||
return Math.max(120, Math.ceil(maxUtilization / 20) * 20);
|
||||
}, [rows]);
|
||||
const tickValues = useMemo(() => {
|
||||
const base = [0, 50, 100];
|
||||
return chartMaxPct > 100 ? [...base, chartMaxPct] : base;
|
||||
}, [chartMaxPct]);
|
||||
const referenceLineBottom = (100 / chartMaxPct) * 100;
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
||||
No allocation data in selected period.
|
||||
<div className="flex h-full items-center justify-center rounded-[22px] border border-dashed border-slate-200 bg-slate-50/80 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
|
||||
No allocation data in the selected horizon.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
|
||||
<YAxis tick={{ fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<ReferenceLine
|
||||
{...({ dataKey: "capacity" } as any)}
|
||||
stroke="#ef4444"
|
||||
strokeDasharray="5 5"
|
||||
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
|
||||
/>
|
||||
{groups.map((g, i) => (
|
||||
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
|
||||
<div className="flex h-full min-h-[15rem] flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
Overall Utilization
|
||||
</div>
|
||||
|
||||
{activeRow ? (
|
||||
<div className="min-w-0 text-right">
|
||||
<div className={`truncate text-sm font-semibold ${utilizationTextTone(activeRow.utilizationPct)}`}>
|
||||
{activeRow.label} · {activeRow.utilizationPct}%
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{formatHours(activeRow.bookedHours)}h / {formatHours(activeRow.capacityHours)}h
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex min-h-[12rem] flex-1 gap-2">
|
||||
<div className="flex w-8 shrink-0 flex-col justify-between pb-6 text-right text-[9px] font-medium text-slate-400">
|
||||
{[...tickValues].reverse().map((tick) => (
|
||||
<span key={tick}>{tick}%</span>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<div className="pointer-events-none absolute inset-0 bottom-6">
|
||||
{[...tickValues].reverse().map((tick) => {
|
||||
const bottom = (tick / chartMaxPct) * 100;
|
||||
return (
|
||||
<div
|
||||
key={tick}
|
||||
className="absolute left-0 right-0 border-t border-dashed border-slate-200/80 dark:border-slate-700/50"
|
||||
style={{ bottom: `${bottom}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="absolute left-0 right-0 border-t border-slate-300/90 dark:border-slate-500/80"
|
||||
style={{ bottom: `${referenceLineBottom}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid h-full items-end gap-1.5 pb-6 sm:gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${rows.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{rows.map((row) => {
|
||||
const height = Math.min((row.utilizationPct / chartMaxPct) * 100, 100);
|
||||
const isActive = row.period === activePeriod;
|
||||
const isPinned = row.period === fallbackPeriod;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={row.period}
|
||||
type="button"
|
||||
className="group flex h-full min-w-0 flex-col items-center rounded-2xl px-1 text-left transition-colors"
|
||||
title={`${row.label}: ${row.utilizationPct}% utilization, ${formatHours(row.bookedHours)}h booked, ${formatHours(row.capacityHours)}h capacity, ${formatHours(row.remainingHours)}h free, ${formatHours(row.overbookedHours)}h overbooked`}
|
||||
onMouseEnter={() => setHoveredPeriod(row.period)}
|
||||
onMouseLeave={() => setHoveredPeriod((current) => (current === row.period ? null : current))}
|
||||
onClick={() => onSelectedPeriodChange?.(row.period)}
|
||||
style={{
|
||||
backgroundColor: isPinned
|
||||
? "rgba(14, 165, 233, 0.08)"
|
||||
: isActive
|
||||
? "rgba(148, 163, 184, 0.08)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<div className="relative flex min-h-0 flex-1 w-full items-end justify-center px-0.5">
|
||||
<div className="relative h-full w-full max-w-[34px] sm:max-w-[42px]">
|
||||
<div className="absolute inset-x-0 bottom-0 h-full rounded-t-xl bg-slate-100 dark:bg-slate-800/80" />
|
||||
<div
|
||||
className={`absolute inset-x-0 bottom-0 rounded-t-xl transition-all duration-150 ${utilizationBarTone(row.utilizationPct)} ${
|
||||
isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100"
|
||||
}`}
|
||||
style={{ height: `${Math.max(height, row.utilizationPct > 0 ? 6 : 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 min-w-0 shrink-0">
|
||||
<div className="truncate text-center text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
|
||||
{row.label}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
@@ -10,84 +11,249 @@ const PeakTimesChart = dynamic(
|
||||
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
|
||||
);
|
||||
|
||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const granularity = (config.granularity as "week" | "month") || "month";
|
||||
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project";
|
||||
type PeakDepartmentRow = {
|
||||
name: string;
|
||||
hours: number;
|
||||
capacityHours: number;
|
||||
remainingHours: number;
|
||||
overbookedHours: number;
|
||||
utilizationPct: number;
|
||||
};
|
||||
|
||||
type PeakPeriodRow = {
|
||||
period: string;
|
||||
label: string;
|
||||
bookedHours: number;
|
||||
capacityHours: number;
|
||||
remainingHours: number;
|
||||
overbookedHours: number;
|
||||
utilizationPct: number;
|
||||
isCurrentPeriod: boolean;
|
||||
groups: PeakDepartmentRow[];
|
||||
};
|
||||
|
||||
function formatHours(value: number): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatMonthLabel(periodStart: string | undefined, fallback: string): string {
|
||||
if (!periodStart) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const date = new Date(`${periodStart}T00:00:00.000Z`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
year: "2-digit",
|
||||
timeZone: "UTC",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function utilizationTone(utilizationPct: number): string {
|
||||
if (utilizationPct >= 100) return "bg-red-500";
|
||||
if (utilizationPct >= 85) return "bg-amber-400";
|
||||
return "bg-emerald-500";
|
||||
}
|
||||
|
||||
function utilizationTextTone(utilizationPct: number): string {
|
||||
if (utilizationPct >= 100) return "text-red-600 dark:text-red-300";
|
||||
if (utilizationPct >= 85) return "text-amber-600 dark:text-amber-300";
|
||||
return "text-emerald-600 dark:text-emerald-300";
|
||||
}
|
||||
|
||||
function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepartmentRow[] {
|
||||
if (rows.length <= limit) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const visibleRows = rows.slice(0, limit - 1);
|
||||
const hiddenRows = rows.slice(limit - 1);
|
||||
const hiddenHours = hiddenRows.reduce((sum, row) => sum + row.hours, 0);
|
||||
const hiddenCapacityHours = hiddenRows.reduce((sum, row) => sum + row.capacityHours, 0);
|
||||
const hiddenRemainingHours = hiddenRows.reduce((sum, row) => sum + row.remainingHours, 0);
|
||||
const hiddenOverbookedHours = hiddenRows.reduce((sum, row) => sum + row.overbookedHours, 0);
|
||||
|
||||
return [
|
||||
...visibleRows,
|
||||
{
|
||||
name: `Other (${hiddenRows.length})`,
|
||||
hours: hiddenHours,
|
||||
capacityHours: hiddenCapacityHours,
|
||||
remainingHours: hiddenRemainingHours,
|
||||
overbookedHours: hiddenOverbookedHours,
|
||||
utilizationPct:
|
||||
hiddenCapacityHours > 0 ? Math.round((hiddenHours / hiddenCapacityHours) * 100) : 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const now = new Date();
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString();
|
||||
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
|
||||
const endDate = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 12, 0, 23, 59, 59, 999),
|
||||
).toISOString();
|
||||
const currentPeriodKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||
const persistedPeriod = typeof config.activePeriod === "string" ? config.activePeriod : null;
|
||||
|
||||
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
|
||||
{ startDate, endDate, granularity, groupBy },
|
||||
{ startDate, endDate, granularity: "month", groupBy: "chapter" },
|
||||
{ staleTime: 120_000, placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
const periods = useMemo<PeakPeriodRow[]>(
|
||||
() =>
|
||||
(data ?? []).map((period) => {
|
||||
const derivation = period.derivation;
|
||||
const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours;
|
||||
const capacityHours = period.capacityHours ?? derivation.capacityHours ?? 0;
|
||||
const remainingHours =
|
||||
period.remainingHours ??
|
||||
derivation.remainingCapacityHours ??
|
||||
Math.max(capacityHours - bookedHours, 0);
|
||||
const overbookedHours =
|
||||
period.overbookedHours ??
|
||||
derivation.overbookedHours ??
|
||||
Math.max(bookedHours - capacityHours, 0);
|
||||
const utilizationPct =
|
||||
period.utilizationPct ??
|
||||
derivation.utilizationPct ??
|
||||
(capacityHours > 0 ? Math.round((bookedHours / capacityHours) * 100) : 0);
|
||||
|
||||
return {
|
||||
period: period.period,
|
||||
label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period),
|
||||
bookedHours,
|
||||
capacityHours,
|
||||
remainingHours,
|
||||
overbookedHours,
|
||||
utilizationPct,
|
||||
isCurrentPeriod: period.period === currentPeriodKey,
|
||||
groups: (period.groups ?? [])
|
||||
.map((group) => {
|
||||
const groupCapacityHours = group.capacityHours ?? 0;
|
||||
const groupRemainingHours =
|
||||
group.remainingHours ?? Math.max(groupCapacityHours - group.hours, 0);
|
||||
const groupOverbookedHours =
|
||||
group.overbookedHours ?? Math.max(group.hours - groupCapacityHours, 0);
|
||||
const groupUtilizationPct =
|
||||
group.utilizationPct ??
|
||||
(groupCapacityHours > 0 ? Math.round((group.hours / groupCapacityHours) * 100) : 0);
|
||||
|
||||
return {
|
||||
name: group.name,
|
||||
hours: group.hours,
|
||||
capacityHours: groupCapacityHours,
|
||||
remainingHours: groupRemainingHours,
|
||||
overbookedHours: groupOverbookedHours,
|
||||
utilizationPct: groupUtilizationPct,
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.utilizationPct - left.utilizationPct ||
|
||||
right.hours - left.hours ||
|
||||
left.name.localeCompare(right.name),
|
||||
),
|
||||
};
|
||||
}),
|
||||
[currentPeriodKey, data],
|
||||
);
|
||||
|
||||
const selectedPeriod =
|
||||
(persistedPeriod && periods.some((period) => period.period === persistedPeriod) ? persistedPeriod : null) ??
|
||||
(periods.some((period) => period.period === currentPeriodKey) ? currentPeriodKey : periods[0]?.period ?? null);
|
||||
|
||||
const selectedPeriodRow =
|
||||
periods.find((period) => period.period === selectedPeriod) ?? periods[0] ?? null;
|
||||
const currentPeriodRow =
|
||||
periods.find((period) => period.period === currentPeriodKey) ?? selectedPeriodRow;
|
||||
const peakPeriodRow = useMemo(
|
||||
() =>
|
||||
[...periods].sort(
|
||||
(left, right) =>
|
||||
right.utilizationPct - left.utilizationPct || right.bookedHours - left.bookedHours,
|
||||
)[0] ?? null,
|
||||
[periods],
|
||||
);
|
||||
const departmentRows = useMemo(
|
||||
() => aggregateDepartmentRows(selectedPeriodRow?.groups ?? []),
|
||||
[selectedPeriodRow],
|
||||
);
|
||||
|
||||
if (isLoading && periods.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full pt-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
|
||||
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
|
||||
</div>
|
||||
<div className="flex items-end gap-1 flex-1 px-2">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 shimmer-skeleton rounded-t"
|
||||
style={{ height: `${30 + Math.random() * 50}%` }}
|
||||
/>
|
||||
<div className="flex h-full flex-col gap-3 pt-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div key={index} className="h-14 rounded-2xl shimmer-skeleton" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 rounded-[22px] shimmer-skeleton" />
|
||||
<div className="h-32 rounded-[22px] shimmer-skeleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const periods = data ?? [];
|
||||
|
||||
// Collect all group names
|
||||
const allGroups = new Set<string>();
|
||||
for (const p of periods) {
|
||||
for (const g of p.groups) allGroups.add(g.name);
|
||||
}
|
||||
const groups = [...allGroups].slice(0, 10);
|
||||
|
||||
// Build recharts data
|
||||
const chartData = periods.map((p) => {
|
||||
const row: Record<string, number | string> = { period: p.period, capacity: p.capacityHours };
|
||||
for (const g of p.groups) {
|
||||
row[g.name] = g.hours;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Controls + info */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={granularity}
|
||||
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="month">Monthly</option>
|
||||
<option value="week">Weekly</option>
|
||||
</select>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
||||
>
|
||||
<option value="project">By Project</option>
|
||||
<option value="chapter">By Chapter</option>
|
||||
<option value="resource">By Resource</option>
|
||||
</select>
|
||||
<div className="flex h-full flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2">
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
Current
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-baseline justify-between gap-3">
|
||||
<span className={`text-base font-semibold ${utilizationTextTone(currentPeriodRow?.utilizationPct ?? 0)}`}>
|
||||
{currentPeriodRow?.utilizationPct ?? 0}%
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{currentPeriodRow?.label ?? "No data"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
Selected
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-baseline justify-between gap-3">
|
||||
<span className={`text-base font-semibold ${utilizationTextTone(selectedPeriodRow?.utilizationPct ?? 0)}`}>
|
||||
{selectedPeriodRow?.utilizationPct ?? 0}%
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{selectedPeriodRow?.label ?? "Hover or pin"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
Peak
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-baseline justify-between gap-3">
|
||||
<span className={`text-base font-semibold ${utilizationTextTone(peakPeriodRow?.utilizationPct ?? 0)}`}>
|
||||
{peakPeriodRow?.utilizationPct ?? 0}%
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{peakPeriodRow?.label ?? "No data"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InfoTooltip
|
||||
content={
|
||||
<span>
|
||||
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
|
||||
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
|
||||
Bars exceeding the capacity line indicate over-allocation risk.
|
||||
The top chart shows total booked load against effective capacity.<br />
|
||||
The current month is marked with a blue accent.<br />
|
||||
Hover any month to inspect details and click to pin the department breakdown.
|
||||
</span>
|
||||
}
|
||||
width="w-80"
|
||||
@@ -95,9 +261,72 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<PeakTimesChart chartData={chartData} groups={groups} />
|
||||
<div className="min-h-0 flex-1 lg:grid lg:grid-cols-[minmax(0,1.85fr)_minmax(18rem,0.95fr)] lg:gap-3">
|
||||
<div className="min-h-0">
|
||||
<PeakTimesChart
|
||||
rows={periods}
|
||||
selectedPeriod={selectedPeriod}
|
||||
onSelectedPeriodChange={(period) => onConfigChange?.({ activePeriod: period })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 min-h-0 lg:mt-0">
|
||||
<div className="flex h-full flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
Department Utilization
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{selectedPeriodRow?.label ?? "No active month"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}</div>
|
||||
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
|
||||
{departmentRows.length > 0 ? (
|
||||
departmentRows.map((group) => {
|
||||
const barWidth = Math.min(group.utilizationPct, 100);
|
||||
return (
|
||||
<div key={group.name} className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 truncate text-xs font-medium text-slate-700 dark:text-slate-200">
|
||||
{group.name}
|
||||
</div>
|
||||
<div className={`shrink-0 text-[11px] font-semibold ${utilizationTextTone(group.utilizationPct)}`}>
|
||||
{group.utilizationPct}%
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="relative h-2.5 overflow-visible rounded-full bg-slate-100 dark:bg-slate-800/80"
|
||||
title={`${group.name}: ${group.utilizationPct}% utilization, ${formatHours(group.hours)}h booked, ${formatHours(group.capacityHours)}h capacity, ${formatHours(group.remainingHours)}h free, ${formatHours(group.overbookedHours)}h overbooked`}
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full ${utilizationTone(group.utilizationPct)}`}
|
||||
style={{ width: `${barWidth}%` }}
|
||||
/>
|
||||
{group.overbookedHours > 0 ? (
|
||||
<div
|
||||
className="absolute right-0 top-0 h-full rounded-full bg-red-600/85"
|
||||
style={{ width: `${Math.min(22, Math.max(8, group.utilizationPct - 100))}%` }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 px-3 py-4 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
|
||||
No department data in the selected month.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
|
||||
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
|
||||
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
|
||||
function healthDot(value: number): string {
|
||||
if (value >= 70) return "bg-green-500";
|
||||
@@ -21,7 +22,55 @@ function scoreBadge(score: number): string {
|
||||
return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300";
|
||||
}
|
||||
|
||||
function formatShortDate(value?: string | Date | null): string {
|
||||
if (!value) {
|
||||
return "No end date";
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "No end date";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatTimeline(daysUntilEndDate?: number | null, timelineStatus?: string | null): string {
|
||||
if (timelineStatus === "UNSCHEDULED" || daysUntilEndDate == null) {
|
||||
return "No end date";
|
||||
}
|
||||
|
||||
if (daysUntilEndDate < 0) {
|
||||
return `${Math.abs(daysUntilEndDate)} days overdue`;
|
||||
}
|
||||
|
||||
if (daysUntilEndDate === 0) {
|
||||
return "Due today";
|
||||
}
|
||||
|
||||
return `${daysUntilEndDate} days left`;
|
||||
}
|
||||
|
||||
function formatLocation(location: {
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
}): string {
|
||||
const parts = [
|
||||
location.countryCode ?? location.countryName ?? null,
|
||||
location.federalState ?? null,
|
||||
location.metroCityName ?? null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||
}
|
||||
|
||||
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const showDetails = config.showDetails === true;
|
||||
const { clients } = useWidgetFilterOptions();
|
||||
|
||||
const filters = useMemo<WidgetFilter[]>(
|
||||
@@ -87,10 +136,10 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||
Project <InfoTooltip content="Active projects scored across three health dimensions" />
|
||||
Project <InfoTooltip content="Active projects scored across three health dimensions including visible budget, staffing, and timeline basis." />
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
|
||||
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demanded headcount), Timeline health (end date and remaining horizon)." />
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
|
||||
@@ -103,13 +152,44 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{rows.map((row) => (
|
||||
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[160px] truncate">
|
||||
<Link href={`/projects/${(row as any).id}`} className="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
||||
<td className="px-3 py-2 text-gray-900 dark:text-gray-100 max-w-[320px]">
|
||||
<Link href={`/projects/${(row as any).id}`} className="block hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
||||
<div className="truncate font-medium">
|
||||
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
|
||||
{row.projectName}
|
||||
</div>
|
||||
</Link>
|
||||
{showDetails ? (
|
||||
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
<div>
|
||||
Budget: {formatMoney(row.spentCents ?? 0)} spent
|
||||
{row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"}
|
||||
{row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""}
|
||||
</div>
|
||||
<div>
|
||||
Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC
|
||||
{typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""}
|
||||
{typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""}
|
||||
</div>
|
||||
<div>
|
||||
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
|
||||
</div>
|
||||
{(row.calendarLocations ?? []).length > 0 ? (
|
||||
<div>
|
||||
Calendar basis: {(row.calendarLocations ?? [])
|
||||
.slice(0, 2)
|
||||
.map((location) => `${formatLocation(location)} (${formatMoney(location.spentCents)} / ${location.assignmentCount} assign.)`)
|
||||
.join(" · ")}
|
||||
{(row.calendarLocations ?? []).length > 2
|
||||
? ` · +${(row.calendarLocations ?? []).length - 2} more`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col items-center justify-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
|
||||
@@ -124,6 +204,15 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
title={`Timeline: ${row.timelineHealth}%`}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center tabular-nums">
|
||||
B {row.budgetUtilizationPercent ?? 0}% used
|
||||
</div>
|
||||
{showDetails ? (
|
||||
<div className="text-center tabular-nums">
|
||||
S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<ShoringBadge projectId={(row as any).id} />
|
||||
|
||||
@@ -19,6 +19,8 @@ function StatCard({
|
||||
value,
|
||||
suffix,
|
||||
sub,
|
||||
details,
|
||||
showDetails = false,
|
||||
info,
|
||||
accentColor,
|
||||
delay = 0,
|
||||
@@ -28,6 +30,8 @@ function StatCard({
|
||||
value: number;
|
||||
suffix?: string;
|
||||
sub?: string;
|
||||
details?: string[];
|
||||
showDetails?: boolean;
|
||||
info?: React.ReactNode;
|
||||
accentColor?: "green" | "amber" | "red";
|
||||
delay?: number;
|
||||
@@ -66,13 +70,37 @@ function StatCard({
|
||||
</div>
|
||||
)}
|
||||
{sub && <p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{sub}</p>}
|
||||
{showDetails && details && details.length > 0 ? (
|
||||
<div className="mt-2 space-y-1 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
{details.map((detail) => (
|
||||
<p key={detail}>{detail}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
||||
function formatShortDate(value?: string | Date | null): string {
|
||||
if (!value) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function StatCardsWidget(props: Partial<WidgetProps> = {}) {
|
||||
const showDetails = props.config?.showDetails === true;
|
||||
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
placeholderData: (prev) => prev,
|
||||
@@ -104,21 +132,33 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
||||
<StatCard
|
||||
label="Total Resources"
|
||||
value={data.totalResources}
|
||||
sub={`${data.activeResources} active`}
|
||||
info="All resources in the system. Sub-line shows active resources only."
|
||||
sub={`${data.activeResources} active / ${data.inactiveResources ?? Math.max(data.totalResources - data.activeResources, 0)} inactive`}
|
||||
details={[
|
||||
"Basis: all resource master records",
|
||||
]}
|
||||
showDetails={showDetails}
|
||||
info="All resources in the system. Sub-line shows active versus inactive records."
|
||||
delay={0}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Projects"
|
||||
value={data.activeProjects}
|
||||
sub={`${data.totalProjects} total`}
|
||||
sub={`${data.totalProjects} total / ${data.inactiveProjects ?? Math.max(data.totalProjects - data.activeProjects, 0)} non-active`}
|
||||
details={[
|
||||
"Basis: project status on the dashboard snapshot",
|
||||
]}
|
||||
showDetails={showDetails}
|
||||
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
|
||||
delay={0.05}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Allocations"
|
||||
value={data.totalAllocations}
|
||||
sub={`${data.activeAllocations} not cancelled`}
|
||||
sub={`${data.activeAllocations} not cancelled / ${data.cancelledAllocations ?? Math.max(data.totalAllocations - data.activeAllocations, 0)} cancelled`}
|
||||
details={[
|
||||
"Basis: split allocation read model across explicit and legacy rows",
|
||||
]}
|
||||
showDetails={showDetails}
|
||||
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
|
||||
delay={0.1}
|
||||
/>
|
||||
@@ -127,7 +167,13 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
||||
value={budgetPct}
|
||||
suffix="%"
|
||||
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
|
||||
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
|
||||
details={[
|
||||
`Remaining: ${formatMoney(data.budgetBasis?.remainingBudgetCents ?? (data.budgetSummary.totalBudgetCents - data.budgetSummary.totalCostCents))}`,
|
||||
`Basis: ${data.budgetBasis?.trackedAssignmentCount ?? 0} non-cancelled assignments across ${data.budgetBasis?.budgetedProjects ?? 0} budgeted projects`,
|
||||
`Window: ${formatShortDate(data.budgetBasis?.windowStart)} - ${formatShortDate(data.budgetBasis?.windowEnd)}`,
|
||||
]}
|
||||
showDetails={showDetails}
|
||||
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost uses the effective allocation cost basis including holiday-adjusted working capacity where available."
|
||||
accentColor={budgetAccent}
|
||||
delay={0.15}
|
||||
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}
|
||||
|
||||
@@ -231,6 +231,7 @@ const adminNavEntries: AdminEntry[] = [
|
||||
],
|
||||
},
|
||||
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
|
||||
{ href: "/admin/vacations", label: "Vacations & Holidays", icon: <VacationIcon /> },
|
||||
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> },
|
||||
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
|
||||
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import type { Route } from "next";
|
||||
import { motion, useAnimationControls } from "framer-motion";
|
||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
function relativeTime(date: Date): string {
|
||||
@@ -28,12 +29,16 @@ type TabKey = "all" | "tasks" | "reminders";
|
||||
export function NotificationBell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const bellRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
const { data: session, status } = useSession();
|
||||
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
|
||||
const { panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
|
||||
open,
|
||||
onClose: () => setOpen(false),
|
||||
side: "right",
|
||||
crossAlign: "start",
|
||||
triggerRef: bellRef,
|
||||
});
|
||||
|
||||
const badgeControls = useAnimationControls();
|
||||
const prevUnreadRef = useRef<number | null>(null);
|
||||
@@ -96,39 +101,6 @@ export function NotificationBell() {
|
||||
},
|
||||
});
|
||||
|
||||
// Compute dropdown position when opening
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!bellRef.current) return;
|
||||
const rect = bellRef.current.getBoundingClientRect();
|
||||
const panelHeight = 440; // approximate max height
|
||||
let top = rect.top;
|
||||
// If it would overflow the bottom, flip upward
|
||||
if (top + panelHeight > window.innerHeight) {
|
||||
top = Math.max(8, window.innerHeight - panelHeight - 8);
|
||||
}
|
||||
setDropdownPos({ top, left: rect.right + 8 });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) updatePosition();
|
||||
}, [open, updatePosition]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
const target = e.target as Node;
|
||||
if (
|
||||
ref.current && !ref.current.contains(target) &&
|
||||
dropdownRef.current && !dropdownRef.current.contains(target)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
function handleMarkAllRead() {
|
||||
if (!isAuthenticated) return;
|
||||
markRead.mutate({});
|
||||
@@ -150,12 +122,18 @@ export function NotificationBell() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<div className="relative">
|
||||
{/* Bell button */}
|
||||
<button
|
||||
ref={bellRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={() => {
|
||||
setOpen((current) => {
|
||||
const nextOpen = !current;
|
||||
handleOpenChange(nextOpen);
|
||||
return nextOpen;
|
||||
});
|
||||
}}
|
||||
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
@@ -193,12 +171,12 @@ export function NotificationBell() {
|
||||
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
|
||||
{open && createPortal(
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
ref={panelRef}
|
||||
initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }}
|
||||
animate={{ opacity: 1, scaleY: 1, scaleX: 1 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden origin-top"
|
||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { clsx } from "clsx";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type EntityType = "resource" | "project" | "assignment";
|
||||
type EntityType = "resource" | "project" | "assignment" | "resource_month";
|
||||
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
|
||||
|
||||
interface FilterRow {
|
||||
@@ -17,10 +17,50 @@ interface FilterRow {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface AvailableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
dataType: "string" | "number" | "date" | "boolean";
|
||||
}
|
||||
|
||||
interface TemplateConfig {
|
||||
entity: EntityType;
|
||||
columns: string[];
|
||||
filters: Omit<FilterRow, "id">[];
|
||||
groupBy?: string;
|
||||
sortBy?: string;
|
||||
sortDir?: "asc" | "desc";
|
||||
periodMonth?: string;
|
||||
}
|
||||
|
||||
interface ReportTemplateSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
entity: EntityType;
|
||||
config: TemplateConfig;
|
||||
isShared: boolean;
|
||||
isOwner: boolean;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
|
||||
interface ReportBlueprint {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
entity: EntityType;
|
||||
columns: string[];
|
||||
groupBy?: string;
|
||||
sortBy?: string;
|
||||
sortDir?: "asc" | "desc";
|
||||
templateName: string;
|
||||
}
|
||||
|
||||
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
|
||||
{ value: "resource", label: "Resources" },
|
||||
{ value: "project", label: "Projects" },
|
||||
{ value: "assignment", label: "Assignments" },
|
||||
{ value: "resource_month", label: "Resource Months" },
|
||||
];
|
||||
|
||||
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
|
||||
@@ -36,10 +76,120 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
|
||||
"displayName",
|
||||
"eid",
|
||||
"chapter",
|
||||
"countryCode",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
"monthlyActualBookedHours",
|
||||
"monthlyExpectedBookedHours",
|
||||
"monthlyActualChargeabilityPct",
|
||||
"monthlyExpectedChargeabilityPct",
|
||||
"monthlyUnassignedHours",
|
||||
] as const;
|
||||
|
||||
const REPORT_BLUEPRINTS: ReportBlueprint[] = [
|
||||
{
|
||||
id: "resource-month-sah-transparency",
|
||||
label: "SAH transparency",
|
||||
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly SAH transparency",
|
||||
columns: [
|
||||
"displayName",
|
||||
"eid",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
],
|
||||
sortBy: "displayName",
|
||||
sortDir: "asc",
|
||||
},
|
||||
{
|
||||
id: "resource-month-chargeability-audit",
|
||||
label: "Chargeability audit",
|
||||
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly chargeability audit",
|
||||
columns: [
|
||||
"displayName",
|
||||
"eid",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlySahHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
"monthlyActualBookedHours",
|
||||
"monthlyExpectedBookedHours",
|
||||
"monthlyActualChargeabilityPct",
|
||||
"monthlyExpectedChargeabilityPct",
|
||||
"monthlyUnassignedHours",
|
||||
"lcrCents",
|
||||
"currency",
|
||||
],
|
||||
sortBy: "monthlyActualChargeabilityPct",
|
||||
sortDir: "desc",
|
||||
},
|
||||
{
|
||||
id: "resource-month-location-comparison",
|
||||
label: "Location comparison",
|
||||
description: "Compares holiday impact across country, state and city contexts for the same month.",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly holiday comparison by location",
|
||||
columns: [
|
||||
"displayName",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyActualChargeabilityPct",
|
||||
],
|
||||
groupBy: "federalState",
|
||||
sortBy: "monthlyPublicHolidayHoursDeduction",
|
||||
sortDir: "desc",
|
||||
},
|
||||
];
|
||||
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function getCurrentPeriodMonth(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReportBuilder() {
|
||||
@@ -50,6 +200,9 @@ export function ReportBuilder() {
|
||||
const [groupBy, setGroupBy] = useState<string>("");
|
||||
const [sortBy, setSortBy] = useState<string>("");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||
const [templateName, setTemplateName] = useState<string>("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [runQuery, setRunQuery] = useState(false);
|
||||
|
||||
@@ -59,7 +212,21 @@ export function ReportBuilder() {
|
||||
{ placeholderData: keepPreviousData },
|
||||
);
|
||||
|
||||
const availableColumns = columnsQuery.data ?? [];
|
||||
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
|
||||
const templatesQuery = trpc.report.listTemplates.useQuery();
|
||||
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
|
||||
onSuccess: async (result) => {
|
||||
setSelectedTemplateId(result.id);
|
||||
await templatesQuery.refetch();
|
||||
},
|
||||
});
|
||||
const deleteTemplateMutation = trpc.report.deleteTemplate.useMutation({
|
||||
onSuccess: async () => {
|
||||
setSelectedTemplateId("");
|
||||
setTemplateName("");
|
||||
await templatesQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
// Scalar columns (for filter/sort/group — only non-relation columns)
|
||||
const scalarColumns = useMemo(
|
||||
@@ -76,12 +243,13 @@ export function ReportBuilder() {
|
||||
filters: filters
|
||||
.filter((f) => f.field && f.value)
|
||||
.map(({ field, op, value }) => ({ field, op, value })),
|
||||
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(sortBy ? { sortBy, sortDir } : {}),
|
||||
limit: PAGE_SIZE,
|
||||
offset: page * PAGE_SIZE,
|
||||
};
|
||||
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
|
||||
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
|
||||
|
||||
// Fetch report data
|
||||
const reportQuery = trpc.report.getReportData.useQuery(
|
||||
@@ -99,6 +267,40 @@ export function ReportBuilder() {
|
||||
setFilters([]);
|
||||
setGroupBy("");
|
||||
setSortBy("");
|
||||
if (newEntity === "resource_month") {
|
||||
setPeriodMonth((current) => current || getCurrentPeriodMonth());
|
||||
}
|
||||
setRunQuery(false);
|
||||
setPage(0);
|
||||
}, []);
|
||||
|
||||
const applyTemplate = useCallback((template: ReportTemplateSummary) => {
|
||||
const config = template.config;
|
||||
setSelectedTemplateId(template.id);
|
||||
setTemplateName(template.name);
|
||||
setEntity(config.entity);
|
||||
setSelectedColumns(new Set(config.columns));
|
||||
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
|
||||
setGroupBy(config.groupBy ?? "");
|
||||
setSortBy(config.sortBy ?? "");
|
||||
setSortDir(config.sortDir ?? "asc");
|
||||
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
|
||||
setRunQuery(false);
|
||||
setPage(0);
|
||||
}, [templatesQuery.data]);
|
||||
|
||||
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
|
||||
setSelectedTemplateId("");
|
||||
setTemplateName(blueprint.templateName);
|
||||
setEntity(blueprint.entity);
|
||||
setSelectedColumns(new Set(blueprint.columns));
|
||||
setFilters([]);
|
||||
setGroupBy(blueprint.groupBy ?? "");
|
||||
setSortBy(blueprint.sortBy ?? "");
|
||||
setSortDir(blueprint.sortDir ?? "asc");
|
||||
if (blueprint.entity === "resource_month") {
|
||||
setPeriodMonth((current) => current || getCurrentPeriodMonth());
|
||||
}
|
||||
setRunQuery(false);
|
||||
setPage(0);
|
||||
}, []);
|
||||
@@ -163,6 +365,7 @@ export function ReportBuilder() {
|
||||
filters: filters
|
||||
.filter((f) => f.field && f.value)
|
||||
.map(({ field, op, value }) => ({ field, op, value })),
|
||||
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(sortBy ? { sortBy, sortDir } : {}),
|
||||
limit: 5000,
|
||||
@@ -179,7 +382,42 @@ export function ReportBuilder() {
|
||||
} catch {
|
||||
// Error handled by tRPC
|
||||
}
|
||||
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
|
||||
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
|
||||
|
||||
const handleSaveTemplate = useCallback(async () => {
|
||||
if (!templateName.trim() || selectedColumns.size === 0) return;
|
||||
|
||||
await saveTemplateMutation.mutateAsync({
|
||||
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
|
||||
name: templateName.trim(),
|
||||
config: {
|
||||
entity,
|
||||
columns: Array.from(selectedColumns),
|
||||
filters: filters
|
||||
.filter((filter) => filter.field && filter.value)
|
||||
.map(({ field, op, value }) => ({ field, op, value })),
|
||||
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||
...(groupBy ? { groupBy } : {}),
|
||||
...(sortBy ? { sortBy, sortDir } : {}),
|
||||
},
|
||||
});
|
||||
}, [
|
||||
entity,
|
||||
filters,
|
||||
groupBy,
|
||||
periodMonth,
|
||||
saveTemplateMutation,
|
||||
selectedColumns,
|
||||
selectedTemplateId,
|
||||
sortBy,
|
||||
sortDir,
|
||||
templateName,
|
||||
]);
|
||||
|
||||
const handleDeleteTemplate = useCallback(async () => {
|
||||
if (!selectedTemplateId) return;
|
||||
await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
|
||||
}, [deleteTemplateMutation, selectedTemplateId]);
|
||||
|
||||
// ─── Derived ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -188,6 +426,15 @@ export function ReportBuilder() {
|
||||
const outputColumns = reportQuery.data?.columns ?? [];
|
||||
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||
const isLoading = reportQuery.isFetching;
|
||||
const templates = templatesQuery.data ?? [];
|
||||
const resourceMonthBlueprints = useMemo(
|
||||
() => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity),
|
||||
[entity],
|
||||
);
|
||||
const recommendedColumnSet = useMemo(
|
||||
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
|
||||
[entity],
|
||||
);
|
||||
|
||||
// Column label lookup
|
||||
const columnLabelMap = useMemo(() => {
|
||||
@@ -212,6 +459,61 @@ export function ReportBuilder() {
|
||||
|
||||
{/* Config Panel */}
|
||||
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/60 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Template
|
||||
</label>
|
||||
<select
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => {
|
||||
const nextId = e.target.value;
|
||||
setSelectedTemplateId(nextId);
|
||||
const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
|
||||
if (template) {
|
||||
applyTemplate(template);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Unsaved view</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Template name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder="Monthly SAH by location"
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSaveTemplate()}
|
||||
disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
|
||||
className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteTemplate()}
|
||||
disabled={!selectedTemplateId || deleteTemplateMutation.isPending}
|
||||
className="self-end rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300 dark:hover:bg-slate-900"
|
||||
>
|
||||
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Entity Selector */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -234,6 +536,73 @@ export function ReportBuilder() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{entity === "resource_month" && (
|
||||
<div className="mt-4 space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200">
|
||||
Period month
|
||||
</label>
|
||||
<input
|
||||
type="month"
|
||||
value={periodMonth}
|
||||
onChange={(e) => setPeriodMonth(e.target.value)}
|
||||
className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
|
||||
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{resourceMonthBlueprints.map((blueprint) => (
|
||||
<button
|
||||
key={blueprint.id}
|
||||
type="button"
|
||||
onClick={() => applyBlueprint(blueprint)}
|
||||
className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
|
||||
>
|
||||
<div className="text-sm font-semibold text-emerald-950 dark:text-emerald-100">
|
||||
{blueprint.label}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-emerald-900/75 dark:text-emerald-200/75">
|
||||
{blueprint.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
|
||||
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
|
||||
Recommended transparency columns
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => (
|
||||
<button
|
||||
key={column}
|
||||
type="button"
|
||||
onClick={() => toggleColumn(column)}
|
||||
className={clsx(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition",
|
||||
selectedColumns.has(column)
|
||||
? "border-emerald-500 bg-emerald-500 text-white"
|
||||
: "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
|
||||
)}
|
||||
>
|
||||
{columnLabelMap.get(column) ?? column}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
|
||||
Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
|
||||
Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column Picker */}
|
||||
@@ -276,6 +645,11 @@ export function ReportBuilder() {
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
|
||||
{recommendedColumnSet.has(col.key) && (
|
||||
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
|
||||
Rec
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
|
||||
{col.dataType}
|
||||
</span>
|
||||
@@ -428,6 +802,7 @@ export function ReportBuilder() {
|
||||
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
{/* Results Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
|
||||
{!isLoading && (
|
||||
@@ -436,6 +811,10 @@ export function ReportBuilder() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleExport()}
|
||||
|
||||
@@ -209,17 +209,74 @@ interface SuggestionLike {
|
||||
resourceName: string;
|
||||
eid: string;
|
||||
score: number;
|
||||
valueScore?: number;
|
||||
scoreBreakdown: {
|
||||
skillScore: number;
|
||||
availabilityScore: number;
|
||||
costScore: number;
|
||||
utilizationScore: number;
|
||||
total?: number;
|
||||
};
|
||||
matchedSkills: string[];
|
||||
missingSkills: string[];
|
||||
availabilityConflicts: string[];
|
||||
estimatedDailyCostCents: number;
|
||||
currentUtilization: number;
|
||||
remainingHours?: number;
|
||||
remainingHoursPerDay?: number;
|
||||
baseAvailableHours?: number;
|
||||
effectiveAvailableHours?: number;
|
||||
holidayHoursDeduction?: number;
|
||||
location?: {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
label: string;
|
||||
};
|
||||
capacity?: {
|
||||
requestedHoursPerDay: number;
|
||||
requestedHoursTotal: number;
|
||||
baseWorkingDays: number;
|
||||
effectiveWorkingDays: number;
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
bookedHours: number;
|
||||
remainingHours: number;
|
||||
remainingHoursPerDay: number;
|
||||
holidayCount: number;
|
||||
holidayWorkdayCount: number;
|
||||
holidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
};
|
||||
conflicts?: {
|
||||
count: number;
|
||||
conflictDays: string[];
|
||||
details: Array<{
|
||||
date: string;
|
||||
baseHours: number;
|
||||
effectiveHours: number;
|
||||
allocatedHours: number;
|
||||
remainingHours: number;
|
||||
requestedHours: number;
|
||||
shortageHours: number;
|
||||
absenceFraction: number;
|
||||
isHoliday: boolean;
|
||||
}>;
|
||||
};
|
||||
ranking?: {
|
||||
rank: number;
|
||||
baseRank: number;
|
||||
tieBreakerApplied: boolean;
|
||||
tieBreakerReason: string | null;
|
||||
model: string;
|
||||
components: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
score: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SuggestionCardProps {
|
||||
@@ -231,10 +288,24 @@ interface SuggestionCardProps {
|
||||
}
|
||||
|
||||
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showAssignForm, setShowAssignForm] = useState(false);
|
||||
const locationLabel = suggestion.location?.label
|
||||
|| [suggestion.location?.countryCode, suggestion.location?.federalState, suggestion.location?.metroCityName]
|
||||
.filter(Boolean)
|
||||
.join(" / ")
|
||||
|| "No location";
|
||||
const capacity = suggestion.capacity;
|
||||
const conflicts = suggestion.conflicts;
|
||||
const conflictCount = conflicts?.count ?? suggestion.availabilityConflicts.length;
|
||||
const remainingHours = capacity?.remainingHours ?? suggestion.remainingHours ?? 0;
|
||||
const remainingHoursPerDay = capacity?.remainingHoursPerDay ?? suggestion.remainingHoursPerDay ?? 0;
|
||||
const baseAvailableHours = capacity?.baseAvailableHours ?? suggestion.baseAvailableHours ?? 0;
|
||||
const effectiveAvailableHours = capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0;
|
||||
const holidayHoursDeduction = capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0;
|
||||
|
||||
return (
|
||||
<div className="app-surface p-5">
|
||||
<div data-suggestion className="app-surface p-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
|
||||
@@ -243,15 +314,23 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
|
||||
<div className="text-sm text-gray-500">{suggestion.eid}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{locationLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDetails((prev) => !prev)}
|
||||
>
|
||||
{showDetails ? "Hide Details" : "Details"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
onClick={() => setShowAssignForm((prev) => !prev)}
|
||||
>
|
||||
{expanded ? "Cancel" : "Assign"}
|
||||
{showAssignForm ? "Close Assign" : "Assign"}
|
||||
</Button>
|
||||
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
|
||||
@@ -260,13 +339,6 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
|
||||
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
|
||||
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
|
||||
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{suggestion.matchedSkills.map((skill) => (
|
||||
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
|
||||
@@ -280,24 +352,144 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="Free / Workday"
|
||||
value={formatHours(remainingHoursPerDay)}
|
||||
tone={remainingHoursPerDay >= searchCriteria.hoursPerDay ? "good" : "warn"}
|
||||
helper={`${formatHours(remainingHours)} total in window`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Capacity"
|
||||
value={`${formatHours(effectiveAvailableHours)} effective`}
|
||||
helper={`${formatHours(baseAvailableHours)} base`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Holiday Deduction"
|
||||
value={holidayHoursDeduction > 0 ? formatHours(holidayHoursDeduction) : "0h"}
|
||||
tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
|
||||
helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Conflicts"
|
||||
value={String(conflictCount)}
|
||||
tone={conflictCount > 0 ? "warn" : "good"}
|
||||
helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
|
||||
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
|
||||
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
|
||||
{suggestion.availabilityConflicts.length > 0 && (
|
||||
{suggestion.valueScore != null && (
|
||||
<span>Value Score: {suggestion.valueScore}</span>
|
||||
)}
|
||||
{conflictCount > 0 && (
|
||||
<span className="font-medium text-amber-600 dark:text-amber-300">
|
||||
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
|
||||
{conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
{showDetails && (
|
||||
<div className="mt-5 space-y-4 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-950/40">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
|
||||
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
|
||||
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
|
||||
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_1fr]">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Capacity Basis</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<MetricLine label="Requested load" value={`${formatHours(capacity?.requestedHoursPerDay ?? searchCriteria.hoursPerDay)} / day`} />
|
||||
<MetricLine label="Requested total" value={formatHours(capacity?.requestedHoursTotal ?? 0)} />
|
||||
<MetricLine label="Base working days" value={String(capacity?.baseWorkingDays ?? 0)} />
|
||||
<MetricLine label="Effective working days" value={String(capacity?.effectiveWorkingDays ?? 0)} />
|
||||
<MetricLine label="Base available hours" value={formatHours(baseAvailableHours)} />
|
||||
<MetricLine label="Effective available hours" value={formatHours(effectiveAvailableHours)} />
|
||||
<MetricLine label="Booked hours" value={formatHours(capacity?.bookedHours ?? 0)} />
|
||||
<MetricLine label="Remaining hours" value={formatHours(remainingHours)} />
|
||||
<MetricLine label="Holiday deduction" value={formatHours(holidayHoursDeduction)} />
|
||||
<MetricLine label="Absence deduction" value={formatHours(capacity?.absenceHoursDeduction ?? 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Ranking Basis</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{(suggestion.ranking?.components ?? []).map((component) => (
|
||||
<MetricLine key={component.key} label={component.label} value={`${component.score}`} />
|
||||
))}
|
||||
{suggestion.ranking?.tieBreakerReason && (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{suggestion.ranking.tieBreakerReason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Location + Calendar</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<MetricLine label="Location" value={locationLabel} />
|
||||
<MetricLine label="Holiday dates" value={String(capacity?.holidayCount ?? 0)} />
|
||||
<MetricLine label="Holiday workdays" value={String(capacity?.holidayWorkdayCount ?? 0)} />
|
||||
<MetricLine label="Absence days" value={String(capacity?.absenceDayEquivalent ?? 0)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Conflict Check</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
|
||||
</div>
|
||||
</div>
|
||||
{conflictCount === 0 ? (
|
||||
<p className="mt-3 text-sm text-green-700 dark:text-green-300">
|
||||
No overloaded working days in the selected window.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{(conflicts?.details ?? []).slice(0, 6).map((item) => (
|
||||
<div key={item.date} className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium">{item.date}</span>
|
||||
<span>Short by {formatHours(item.shortageHours)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs">
|
||||
Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{conflictCount > 6 && (
|
||||
<p className="text-xs text-gray-500">
|
||||
+{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAssignForm && (
|
||||
<AssignForm
|
||||
resourceId={suggestion.resourceId}
|
||||
resourceName={suggestion.resourceName}
|
||||
searchCriteria={searchCriteria}
|
||||
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
|
||||
onError={onError}
|
||||
onCancel={() => setExpanded(false)}
|
||||
onCancel={() => setShowAssignForm(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -499,3 +691,45 @@ function ScoreBar({ label, value, tooltip }: { label: string; value: number; too
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatHours(value: number): string {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return `${rounded}h`;
|
||||
}
|
||||
|
||||
function MetricLine({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-gray-100 pb-2 text-sm last:border-b-0 last:pb-0 dark:border-gray-800">
|
||||
<span className="text-gray-500 dark:text-gray-400">{label}</span>
|
||||
<span className="text-right font-medium text-gray-900 dark:text-gray-100">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
helper,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
helper?: string;
|
||||
tone?: "neutral" | "good" | "warn";
|
||||
}) {
|
||||
const toneClass = tone === "good"
|
||||
? "border-green-200 bg-green-50/70 dark:border-green-900/40 dark:bg-green-950/20"
|
||||
: tone === "warn"
|
||||
? "border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20"
|
||||
: "border-gray-200 bg-gray-50/70 dark:border-gray-700 dark:bg-gray-900/40";
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border p-3 ${toneClass}`}>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">{value}</div>
|
||||
{helper && (
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{helper}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
@@ -28,9 +29,14 @@ export function AllocationPopover({
|
||||
anchorX,
|
||||
anchorY,
|
||||
}: AllocationPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
const { ref, style } = useViewportPopover({
|
||||
anchor: { kind: "point", x: anchorX, y: anchorY },
|
||||
width: 300,
|
||||
estimatedHeight: 360,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
||||
{ projectId },
|
||||
@@ -63,17 +69,6 @@ export function AllocationPopover({
|
||||
},
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
@@ -93,18 +88,9 @@ export function AllocationPopover({
|
||||
});
|
||||
}
|
||||
|
||||
// Position popover so it stays on screen
|
||||
const popoverStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.min(anchorX, window.innerWidth - 320),
|
||||
top: Math.min(anchorY + 8, window.innerHeight - 360),
|
||||
zIndex: 50,
|
||||
width: 300,
|
||||
};
|
||||
|
||||
if (isLoading || !allocation) {
|
||||
return (
|
||||
<div ref={ref} style={popoverStyle} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
|
||||
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
@@ -115,7 +101,7 @@ export function AllocationPopover({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={popoverStyle}
|
||||
style={style}
|
||||
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
||||
import { formatCents, formatDateLong } from "~/lib/format.js";
|
||||
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||
|
||||
interface DemandPopoverProps {
|
||||
demand: TimelineDemandEntry;
|
||||
@@ -21,17 +21,12 @@ export function DemandPopover({
|
||||
anchorX,
|
||||
anchorY,
|
||||
}: DemandPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
const { ref, style } = useViewportPopover({
|
||||
anchor: { kind: "point", x: anchorX, y: anchorY },
|
||||
width: 300,
|
||||
estimatedHeight: 340,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
|
||||
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
|
||||
@@ -41,18 +36,10 @@ export function DemandPopover({
|
||||
const totalHours = demand.hoursPerDay * days;
|
||||
const budgetCents = demand.dailyCostCents * days;
|
||||
|
||||
const popoverStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.min(anchorX, window.innerWidth - 320),
|
||||
top: Math.min(anchorY + 8, window.innerHeight - 340),
|
||||
zIndex: 50,
|
||||
width: 300,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={popoverStyle}
|
||||
style={style}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { AllocationStatus } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||
import { DateInput } from "~/components/ui/DateInput.js";
|
||||
|
||||
interface NewAllocationPopoverProps {
|
||||
@@ -36,7 +37,12 @@ export function NewAllocationPopover({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: NewAllocationPopoverProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { ref, style } = useViewportPopover({
|
||||
anchor: { kind: "point", x: anchorX - 10, y: anchorY },
|
||||
width: 320,
|
||||
estimatedHeight: 440,
|
||||
onClose,
|
||||
});
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -67,17 +73,6 @@ export function NewAllocationPopover({
|
||||
},
|
||||
});
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
function handleCreate() {
|
||||
if (!selectedProjectId) return;
|
||||
createMutation.mutate({
|
||||
@@ -93,13 +88,10 @@ export function NewAllocationPopover({
|
||||
|
||||
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
|
||||
|
||||
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
|
||||
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
|
||||
style={style}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { formatCents } from "~/lib/format.js";
|
||||
import type { SkillEntry } from "@capakraken/shared";
|
||||
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||
|
||||
interface ResourceHoverCardProps {
|
||||
resourceId: string;
|
||||
@@ -12,34 +12,20 @@ interface ResourceHoverCardProps {
|
||||
}
|
||||
|
||||
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: 0, top: 0 });
|
||||
const { ref, style } = useViewportPopover({
|
||||
anchor: { kind: "element", element: anchorEl },
|
||||
width: 280,
|
||||
estimatedHeight: 320,
|
||||
onClose,
|
||||
side: "right",
|
||||
ignoreElements: [anchorEl],
|
||||
});
|
||||
|
||||
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
|
||||
{ id: resourceId },
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
// Position relative to anchor element
|
||||
useEffect(() => {
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
setPos({
|
||||
left: rect.right + 8,
|
||||
top: Math.min(rect.top, window.innerHeight - 320),
|
||||
});
|
||||
}, [anchorEl]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [onClose, anchorEl]);
|
||||
|
||||
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
|
||||
const mainSkills = skills.filter((s) => s.isMainSkill);
|
||||
const topSkills = skills
|
||||
@@ -47,19 +33,11 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
|
||||
.sort((a, b) => b.proficiency - a.proficiency)
|
||||
.slice(0, 6);
|
||||
|
||||
const popoverStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.min(pos.left, window.innerWidth - 300),
|
||||
top: pos.top,
|
||||
zIndex: 50,
|
||||
width: 280,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-resource-hover-card="true"
|
||||
style={popoverStyle}
|
||||
style={style}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
||||
onMouseLeave={onClose}
|
||||
>
|
||||
|
||||
@@ -113,6 +113,16 @@ export type VacationEntry = {
|
||||
halfDayPart?: string | null;
|
||||
};
|
||||
|
||||
export type HolidayOverlayEntry = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
};
|
||||
|
||||
// ─── Context shape ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface TimelineContextValue {
|
||||
@@ -314,9 +324,43 @@ export function TimelineProvider({
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
|
||||
const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
{
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
||||
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
||||
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
||||
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
||||
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
||||
},
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
|
||||
const vacationsByResource = useMemo(() => {
|
||||
const map = new Map<string, VacationEntry[]>();
|
||||
for (const vacation of vacationEntries as VacationEntry[]) {
|
||||
const mergedEntries = [...(vacationEntries as VacationEntry[])];
|
||||
const existingKeys = new Set(
|
||||
mergedEntries.map((vacation) => {
|
||||
const start = new Date(vacation.startDate).toISOString().slice(0, 10);
|
||||
const end = new Date(vacation.endDate).toISOString().slice(0, 10);
|
||||
return `${vacation.resourceId}:${vacation.type}:${start}:${end}`;
|
||||
}),
|
||||
);
|
||||
|
||||
for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) {
|
||||
const start = new Date(holiday.startDate).toISOString().slice(0, 10);
|
||||
const end = new Date(holiday.endDate).toISOString().slice(0, 10);
|
||||
const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`;
|
||||
if (existingKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
existingKeys.add(key);
|
||||
mergedEntries.push(holiday as VacationEntry);
|
||||
}
|
||||
|
||||
for (const vacation of mergedEntries) {
|
||||
const existing = map.get(vacation.resourceId);
|
||||
if (existing) {
|
||||
existing.push(vacation);
|
||||
@@ -325,7 +369,7 @@ export function TimelineProvider({
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [vacationEntries]);
|
||||
}, [holidayOverlayEntries, vacationEntries]);
|
||||
|
||||
// When EID filter is active, explicitly fetch those resources.
|
||||
const { data: eidFilterData } = trpc.resource.list.useQuery(
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { useRef, useState, type RefObject } from "react";
|
||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
export interface TimelineFilters {
|
||||
@@ -159,55 +160,12 @@ export function TimelineFilter({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: TimelineFilterProps) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
const updatePanelPosition = useCallback(() => {
|
||||
const trigger = anchorRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const panelWidth = panelRef.current?.offsetWidth ?? 320;
|
||||
const viewportPadding = 16;
|
||||
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
|
||||
|
||||
setPanelPosition({
|
||||
top: rect.bottom + 8,
|
||||
left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
|
||||
const { panelRef, position } = useAnchoredOverlay<HTMLDivElement>({
|
||||
open: isOpen,
|
||||
onClose,
|
||||
align: "end",
|
||||
triggerRef: anchorRef,
|
||||
});
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
updatePanelPosition();
|
||||
const rafId = window.requestAnimationFrame(updatePanelPosition);
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePanelPosition);
|
||||
window.addEventListener("scroll", updatePanelPosition, true);
|
||||
window.addEventListener("mousedown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", updatePanelPosition);
|
||||
window.removeEventListener("scroll", updatePanelPosition, true);
|
||||
window.removeEventListener("mousedown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [anchorRef, isOpen, onClose, updatePanelPosition]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
@@ -221,7 +179,7 @@ export function TimelineFilter({
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{ position: "fixed", top: panelPosition.top, left: panelPosition.left }}
|
||||
style={{ position: "fixed", top: position.top, left: position.left }}
|
||||
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
|
||||
@@ -188,8 +188,10 @@ function TimelineProjectPanelInner({
|
||||
} | null>(null);
|
||||
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const demandTooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||
|
||||
const [heatmapHover, setHeatmapHover] = useState<{
|
||||
date: Date;
|
||||
@@ -206,6 +208,22 @@ function TimelineProjectPanelInner({
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
}>(null);
|
||||
const [demandHover, setDemandHover] = useState<null | {
|
||||
roleName: string;
|
||||
roleColor: string;
|
||||
projectName: string;
|
||||
projectShortCode?: string | null;
|
||||
requestedHeadcount: number;
|
||||
unfilledHeadcount: number;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
totalHours: number;
|
||||
percentage?: number;
|
||||
status?: string;
|
||||
totalCostCents?: number;
|
||||
dailyCostCents?: number;
|
||||
}>(null);
|
||||
|
||||
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
|
||||
const dateIndexByTime = new Map<number, number>();
|
||||
@@ -472,6 +490,7 @@ function TimelineProjectPanelInner({
|
||||
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
||||
vacationHoverRafRef.current = null;
|
||||
const date = xToDate(clientX, rect);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const time = date.getTime();
|
||||
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
||||
const hit =
|
||||
@@ -507,18 +526,58 @@ function TimelineProjectPanelInner({
|
||||
|
||||
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
||||
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
||||
const shouldClearDemand = demandHover !== null;
|
||||
|
||||
lastHeatmapDayRef.current = -1;
|
||||
lastHeatmapResourceRef.current = null;
|
||||
hoveredVacationKeyRef.current = null;
|
||||
|
||||
if (shouldClearHeatmap || shouldClearVacation) {
|
||||
if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
|
||||
startTransition(() => {
|
||||
if (shouldClearHeatmap) setHeatmapHover(null);
|
||||
if (shouldClearVacation) setVacationHover(null);
|
||||
if (shouldClearDemand) setDemandHover(null);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [demandHover]);
|
||||
|
||||
const handleDemandHoverMove = useCallback(
|
||||
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
|
||||
demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 };
|
||||
if (demandTooltipRef.current) {
|
||||
demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`;
|
||||
demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`;
|
||||
}
|
||||
|
||||
const startDate = new Date(demand.startDate);
|
||||
const endDate = new Date(demand.endDate);
|
||||
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
|
||||
|
||||
startTransition(() => {
|
||||
setDemandHover({
|
||||
roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand",
|
||||
roleColor: demand.roleEntity?.color ?? "#f59e0b",
|
||||
projectName: demand.project.name,
|
||||
projectShortCode: demand.project.shortCode,
|
||||
requestedHeadcount: demand.requestedHeadcount,
|
||||
unfilledHeadcount: demand.unfilledHeadcount,
|
||||
startDate: demand.startDate,
|
||||
endDate: demand.endDate,
|
||||
hoursPerDay: demand.hoursPerDay,
|
||||
totalHours: demand.hoursPerDay * days,
|
||||
percentage: demand.percentage,
|
||||
status: demand.status,
|
||||
...(demand.dailyCostCents > 0
|
||||
? {
|
||||
totalCostCents: demand.dailyCostCents * days,
|
||||
dailyCostCents: demand.dailyCostCents,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -672,6 +731,8 @@ function TimelineProjectPanelInner({
|
||||
onAllocMouseDown,
|
||||
onAllocTouchStart,
|
||||
onAllocationContextMenu,
|
||||
handleDemandHoverMove,
|
||||
clearHoverTooltips,
|
||||
multiSelectState,
|
||||
allocDragState,
|
||||
)
|
||||
@@ -699,6 +760,9 @@ function TimelineProjectPanelInner({
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="timeline-project-resource-row-canvas"
|
||||
data-project-id={row.project.id}
|
||||
data-resource-id={row.resource.id}
|
||||
className="relative overflow-hidden touch-none"
|
||||
style={{
|
||||
width: totalCanvasWidth,
|
||||
@@ -792,8 +856,11 @@ function TimelineProjectPanelInner({
|
||||
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
||||
vacationTooltipRef={vacationTooltipRef}
|
||||
vacationTooltipPos={vacationTooltipPosRef.current}
|
||||
demandTooltipRef={demandTooltipRef}
|
||||
demandTooltipPos={demandTooltipPosRef.current}
|
||||
heatmapHover={heatmapHover}
|
||||
vacationHover={vacationHover}
|
||||
demandHover={demandHover}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -852,6 +919,8 @@ function renderOpenDemandRow(
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
|
||||
onClearHoverTooltips: () => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
allocDragState: AllocDragState,
|
||||
) {
|
||||
@@ -889,6 +958,7 @@ function renderOpenDemandRow(
|
||||
<div
|
||||
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
|
||||
style={{ width: totalCanvasWidth, height: rowHeight }}
|
||||
onMouseLeave={onClearHoverTooltips}
|
||||
>
|
||||
{rowGridLines}
|
||||
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
|
||||
@@ -962,7 +1032,6 @@ function renderOpenDemandRow(
|
||||
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||
)}
|
||||
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} – ${formatDateLong(allocEnd)}`}
|
||||
style={{
|
||||
left: left + 2,
|
||||
width: width - 4,
|
||||
@@ -986,6 +1055,7 @@ function renderOpenDemandRow(
|
||||
e.clientY,
|
||||
);
|
||||
}}
|
||||
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
|
||||
>
|
||||
{/* Left resize handle */}
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { TimelineFilters } from "./TimelineFilter.js";
|
||||
|
||||
@@ -20,68 +21,22 @@ function TimelineFilterDropdown({
|
||||
tooltipContent?: ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
|
||||
|
||||
const updatePanelPosition = useCallback(() => {
|
||||
const trigger = dropdownRef.current;
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
|
||||
const viewportPadding = 16;
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
|
||||
|
||||
setPanelPosition({
|
||||
top: rect.bottom + 8,
|
||||
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
|
||||
minWidth: rect.width,
|
||||
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
|
||||
open: isOpen,
|
||||
onClose: () => setIsOpen(false),
|
||||
matchTriggerWidth: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
updatePanelPosition();
|
||||
const rafId = window.requestAnimationFrame(updatePanelPosition);
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", updatePanelPosition);
|
||||
window.addEventListener("scroll", updatePanelPosition, true);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", updatePanelPosition);
|
||||
window.removeEventListener("scroll", updatePanelPosition, true);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen, updatePanelPosition]);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<div ref={triggerRef} className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
onClick={() => {
|
||||
const nextOpen = !isOpen;
|
||||
handleOpenChange(nextOpen);
|
||||
setIsOpen(nextOpen);
|
||||
}}
|
||||
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
|
||||
>
|
||||
<span className="text-left">{label}</span>
|
||||
@@ -95,9 +50,9 @@ function TimelineFilterDropdown({
|
||||
ref={panelRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: panelPosition.top,
|
||||
left: panelPosition.left,
|
||||
minWidth: panelPosition.minWidth,
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
minWidth: position.minWidth,
|
||||
}}
|
||||
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
|
||||
>
|
||||
|
||||
@@ -359,6 +359,7 @@ function TimelineResourcePanelInner({
|
||||
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
||||
vacationHoverRafRef.current = null;
|
||||
const date = xToDate(clientX, rect);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const t = date.getTime();
|
||||
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
||||
const hit =
|
||||
@@ -494,6 +495,10 @@ function TimelineResourcePanelInner({
|
||||
|
||||
{/* Row canvas */}
|
||||
<div
|
||||
data-testid="timeline-resource-row-canvas"
|
||||
data-resource-id={resource.id}
|
||||
data-resource-eid={resource.eid}
|
||||
data-resource-name={resource.displayName}
|
||||
className="relative overflow-hidden touch-none"
|
||||
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
|
||||
onMouseDown={(e) => {
|
||||
@@ -542,7 +547,8 @@ function TimelineResourcePanelInner({
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
)}
|
||||
{renderVacationBlocks(
|
||||
{filters.showVacations &&
|
||||
renderVacationBlocks(
|
||||
vacationBlocksByResource.get(resource.id) ?? [],
|
||||
rowHeight,
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { formatCents, formatDateLong } from "~/lib/format.js";
|
||||
|
||||
function getVacationTitle(vacation: VacationHoverData): string {
|
||||
if (vacation.type === "PUBLIC_HOLIDAY" && vacation.note) {
|
||||
return vacation.note;
|
||||
}
|
||||
return vacation.type.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
export type HeatmapHoverData = {
|
||||
date: Date;
|
||||
@@ -30,6 +37,23 @@ export type VacationHoverData = {
|
||||
approvedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
export type DemandHoverData = {
|
||||
roleName: string;
|
||||
roleColor: string;
|
||||
projectName: string;
|
||||
projectShortCode?: string | null;
|
||||
requestedHeadcount: number;
|
||||
unfilledHeadcount: number;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
totalHours: number;
|
||||
percentage?: number;
|
||||
status?: string;
|
||||
totalCostCents?: number;
|
||||
dailyCostCents?: number;
|
||||
};
|
||||
|
||||
interface TimelineTooltipProps {
|
||||
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
heatmapTooltipPos: { left: number; top: number };
|
||||
@@ -37,6 +61,9 @@ interface TimelineTooltipProps {
|
||||
vacationTooltipPos: { left: number; top: number };
|
||||
heatmapHover: HeatmapHoverData | null;
|
||||
vacationHover: VacationHoverData | null;
|
||||
demandTooltipRef?: React.RefObject<HTMLDivElement | null>;
|
||||
demandTooltipPos?: { left: number; top: number };
|
||||
demandHover?: DemandHoverData | null;
|
||||
}
|
||||
|
||||
export function TimelineTooltip({
|
||||
@@ -46,7 +73,87 @@ export function TimelineTooltip({
|
||||
vacationTooltipPos,
|
||||
heatmapHover,
|
||||
vacationHover,
|
||||
demandTooltipRef,
|
||||
demandTooltipPos,
|
||||
demandHover,
|
||||
}: TimelineTooltipProps) {
|
||||
const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
|
||||
|
||||
if (demandHover && demandTooltipRef && demandTooltipPos) {
|
||||
return (
|
||||
<div
|
||||
ref={demandTooltipRef}
|
||||
style={{
|
||||
left: demandTooltipPos.left,
|
||||
top: demandTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: demandHover.roleColor }}
|
||||
/>
|
||||
<span className="truncate font-semibold">{demandHover.roleName}</span>
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{demandHover.projectShortCode ? `${demandHover.projectShortCode} · ` : ""}
|
||||
{demandHover.projectName}
|
||||
</div>
|
||||
</div>
|
||||
{demandHover.status ? (
|
||||
<span className="text-[10px] uppercase tracking-wide text-amber-300">
|
||||
{demandHover.status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
|
||||
<div>
|
||||
<div className="text-gray-500">Requested</div>
|
||||
<div className="font-medium text-gray-100">{demandHover.requestedHeadcount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Open</div>
|
||||
<div className="font-medium text-amber-300">{demandHover.unfilledHeadcount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Range</div>
|
||||
<div className="font-medium text-gray-100">
|
||||
{formatDateLong(demandHover.startDate)} to {formatDateLong(demandHover.endDate)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Load</div>
|
||||
<div className="font-medium text-gray-100">
|
||||
{demandHover.hoursPerDay}h/day · {demandHover.totalHours}h
|
||||
</div>
|
||||
</div>
|
||||
{typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? (
|
||||
<div>
|
||||
<div className="text-gray-500">Allocation</div>
|
||||
<div className="font-medium text-gray-100">{demandHover.percentage}%</div>
|
||||
</div>
|
||||
) : null}
|
||||
{typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
|
||||
<div>
|
||||
<div className="text-gray-500">Cost</div>
|
||||
<div className="font-medium text-gray-100">
|
||||
{formatCents(demandHover.totalCostCents)} EUR
|
||||
{typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
|
||||
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// When both are active, render a single merged tooltip using the heatmap position
|
||||
if (heatmapHover && vacationHover) {
|
||||
return (
|
||||
@@ -114,14 +221,12 @@ export function TimelineTooltip({
|
||||
<div className="mt-2 pt-2 border-t border-amber-700/40">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
|
||||
<span className="font-semibold text-amber-300">
|
||||
{vacationHover.type.replaceAll("_", " ")}
|
||||
</span>
|
||||
<span className="font-semibold text-amber-300">{vacationTitle}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note ? (
|
||||
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
|
||||
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -200,11 +305,11 @@ export function TimelineTooltip({
|
||||
}}
|
||||
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
|
||||
>
|
||||
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
|
||||
<div className="font-semibold">{vacationTitle}</div>
|
||||
<div className="mt-1 text-[11px] text-amber-100/90">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note ? (
|
||||
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
|
||||
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,11 @@ export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: num
|
||||
return (
|
||||
<div
|
||||
key={`vac-${v.id}`}
|
||||
data-testid="timeline-vacation-block"
|
||||
data-vacation-id={v.id}
|
||||
data-vacation-type={v.type}
|
||||
data-vacation-status={v.status}
|
||||
data-vacation-note={v.note ?? ""}
|
||||
className={clsx(
|
||||
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
|
||||
colorClass,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import type { ColumnDef } from "@capakraken/shared";
|
||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||
|
||||
interface ColumnTogglePanelProps {
|
||||
allColumns: ColumnDef[];
|
||||
@@ -17,18 +19,11 @@ export function ColumnTogglePanel({
|
||||
defaultKeys,
|
||||
}: ColumnTogglePanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
|
||||
open,
|
||||
onClose: () => setOpen(false),
|
||||
align: "end",
|
||||
});
|
||||
|
||||
const dragKey = useRef<string | null>(null);
|
||||
|
||||
@@ -59,11 +54,20 @@ export function ColumnTogglePanel({
|
||||
const builtins = allColumns.filter((c) => !c.isCustom);
|
||||
const customs = allColumns.filter((c) => c.isCustom);
|
||||
|
||||
const handleToggleOpen = useCallback(() => {
|
||||
setOpen((current) => {
|
||||
const nextOpen = !current;
|
||||
handleOpenChange(nextOpen);
|
||||
return nextOpen;
|
||||
});
|
||||
}, [handleOpenChange]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={panelRef}>
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onClick={handleToggleOpen}
|
||||
title="Toggle columns"
|
||||
className={`p-1.5 rounded-lg border text-sm transition-colors ${
|
||||
open
|
||||
@@ -80,10 +84,17 @@ export function ColumnTogglePanel({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-xl py-2">
|
||||
<div className="px-3 pb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed z-[9998] w-52 rounded-xl border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-900"
|
||||
style={{ top: position.top, left: position.left }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 pb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||
Columns
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
@@ -100,17 +111,24 @@ export function ColumnTogglePanel({
|
||||
<div
|
||||
key={col.key}
|
||||
draggable={col.hideable && isVisible}
|
||||
onDragStart={() => { dragKey.current = col.key; }}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${
|
||||
onDragStart={() => {
|
||||
dragKey.current = col.key;
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={() => {
|
||||
if (dragKey.current) reorder(dragKey.current, col.key);
|
||||
dragKey.current = null;
|
||||
}}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
!col.hideable ? "opacity-50" : "cursor-grab"
|
||||
}`}
|
||||
>
|
||||
{col.hideable && isVisible && (
|
||||
<span className="text-gray-300 text-xs select-none">⠿</span>
|
||||
<span className="select-none text-xs text-gray-300">⠿</span>
|
||||
)}
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<label className="flex flex-1 cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
@@ -118,7 +136,7 @@ export function ColumnTogglePanel({
|
||||
disabled={!col.hideable}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{col.label}</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200">{col.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
@@ -126,28 +144,37 @@ export function ColumnTogglePanel({
|
||||
|
||||
{customs.length > 0 && (
|
||||
<>
|
||||
<div className="my-1 border-t border-gray-100" />
|
||||
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p>
|
||||
<div className="my-1 border-t border-gray-100 dark:border-gray-800" />
|
||||
<p className="px-3 py-1 text-xs font-medium text-gray-400">Custom Fields</p>
|
||||
{customs.map((col) => {
|
||||
const isVisible = visibleKeys.includes(col.key);
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
draggable={isVisible}
|
||||
onDragStart={() => { dragKey.current = col.key; }}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab"
|
||||
onDragStart={() => {
|
||||
dragKey.current = col.key;
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={() => {
|
||||
if (dragKey.current) reorder(dragKey.current, col.key);
|
||||
dragKey.current = null;
|
||||
}}
|
||||
className="flex cursor-grab items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
{isVisible && <span className="text-gray-300 text-xs select-none">⠿</span>}
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
{isVisible && <span className="select-none text-xs text-gray-300">⠿</span>}
|
||||
<label className="flex flex-1 cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible}
|
||||
onChange={() => toggle(col.key)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{col.label}</span>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-200">
|
||||
{col.label}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
@@ -155,7 +182,8 @@ export function ColumnTogglePanel({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type ScopeType = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
type CalendarRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeType: ScopeType;
|
||||
stateCode: string | null;
|
||||
metroCityId: string | null;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
country: { id: string; code: string; name: string };
|
||||
metroCity: { id: string; name: string } | null;
|
||||
entries: Array<{
|
||||
id: string;
|
||||
date: string | Date;
|
||||
name: string;
|
||||
isRecurringAnnual: boolean;
|
||||
source: string | null;
|
||||
}>;
|
||||
_count?: { entries: number };
|
||||
};
|
||||
|
||||
type CountryRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
metroCities: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<ScopeType, string> = {
|
||||
COUNTRY: "Land",
|
||||
STATE: "Bundesland/Region",
|
||||
CITY: "Stadt",
|
||||
};
|
||||
|
||||
function formatDate(value: string | Date): string {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function HolidayCalendarEditor() {
|
||||
const utils = trpc.useUtils();
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string | null>(null);
|
||||
const [scopeType, setScopeType] = useState<ScopeType>("COUNTRY");
|
||||
const [countryId, setCountryId] = useState("");
|
||||
const [stateCode, setStateCode] = useState("");
|
||||
const [metroCityId, setMetroCityId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [priority, setPriority] = useState(0);
|
||||
const [entryDate, setEntryDate] = useState("");
|
||||
const [entryName, setEntryName] = useState("");
|
||||
const [entryRecurring, setEntryRecurring] = useState(false);
|
||||
const [entrySource, setEntrySource] = useState("");
|
||||
const [previewYear, setPreviewYear] = useState(new Date().getFullYear());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [calendarDraft, setCalendarDraft] = useState({
|
||||
name: "",
|
||||
priority: 0,
|
||||
stateCode: "",
|
||||
metroCityId: "",
|
||||
isActive: true,
|
||||
});
|
||||
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||
const [entryDraft, setEntryDraft] = useState({
|
||||
date: "",
|
||||
name: "",
|
||||
isRecurringAnnual: false,
|
||||
source: "",
|
||||
});
|
||||
|
||||
const { data: countries } = trpc.country.list.useQuery();
|
||||
const { data: calendars } = trpc.holidayCalendar.listCalendars.useQuery({ includeInactive: true });
|
||||
|
||||
const selectedCalendar = ((calendars ?? []) as unknown as CalendarRow[]).find((calendar) => calendar.id === selectedCalendarId) ?? null;
|
||||
|
||||
const selectedCountry = useMemo(() => {
|
||||
const rows = (countries ?? []) as unknown as CountryRow[];
|
||||
return rows.find((country) => country.id === countryId) ?? null;
|
||||
}, [countries, countryId]);
|
||||
|
||||
const selectedCalendarCountry = useMemo(() => {
|
||||
const rows = (countries ?? []) as unknown as CountryRow[];
|
||||
return rows.find((country) => country.id === selectedCalendar?.country.id) ?? null;
|
||||
}, [countries, selectedCalendar]);
|
||||
|
||||
const previewQuery = trpc.holidayCalendar.previewResolvedHolidays.useQuery(
|
||||
{
|
||||
countryId: selectedCalendar?.country.id ?? countryId,
|
||||
year: previewYear,
|
||||
...(selectedCalendar?.stateCode ? { stateCode: selectedCalendar.stateCode } : {}),
|
||||
...(selectedCalendar?.metroCityId ? { metroCityId: selectedCalendar.metroCityId } : {}),
|
||||
},
|
||||
{
|
||||
enabled: Boolean(selectedCalendar?.country.id ?? countryId),
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
utils.holidayCalendar.listCalendars.invalidate(),
|
||||
utils.holidayCalendar.getCalendarById.invalidate(),
|
||||
utils.holidayCalendar.previewResolvedHolidays.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
const createCalendar = trpc.holidayCalendar.createCalendar.useMutation({
|
||||
onSuccess: async (calendar) => {
|
||||
await invalidate();
|
||||
setSelectedCalendarId(calendar.id);
|
||||
setName("");
|
||||
setStateCode("");
|
||||
setMetroCityId("");
|
||||
setPriority(0);
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const updateCalendar = trpc.holidayCalendar.updateCalendar.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const deleteCalendar = trpc.holidayCalendar.deleteCalendar.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setSelectedCalendarId(null);
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const createEntry = trpc.holidayCalendar.createEntry.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setEntryDate("");
|
||||
setEntryName("");
|
||||
setEntryRecurring(false);
|
||||
setEntrySource("");
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const updateEntry = trpc.holidayCalendar.updateEntry.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setEditingEntryId(null);
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const deleteEntry = trpc.holidayCalendar.deleteEntry.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const countryRows = (countries ?? []) as unknown as CountryRow[];
|
||||
const calendarRows = (calendars ?? []) as unknown as CalendarRow[];
|
||||
const isCreateScopeValid = scopeType === "COUNTRY"
|
||||
? Boolean(countryId && name.trim())
|
||||
: scopeType === "STATE"
|
||||
? Boolean(countryId && name.trim() && stateCode.trim())
|
||||
: Boolean(countryId && name.trim() && metroCityId);
|
||||
const isCalendarDirty = selectedCalendar !== null && (
|
||||
calendarDraft.name !== selectedCalendar.name
|
||||
|| calendarDraft.priority !== selectedCalendar.priority
|
||||
|| calendarDraft.isActive !== selectedCalendar.isActive
|
||||
|| calendarDraft.stateCode !== (selectedCalendar.stateCode ?? "")
|
||||
|| calendarDraft.metroCityId !== (selectedCalendar.metroCityId ?? "")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCalendar) {
|
||||
setCalendarDraft({
|
||||
name: "",
|
||||
priority: 0,
|
||||
stateCode: "",
|
||||
metroCityId: "",
|
||||
isActive: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCalendarDraft({
|
||||
name: selectedCalendar.name,
|
||||
priority: selectedCalendar.priority,
|
||||
stateCode: selectedCalendar.stateCode ?? "",
|
||||
metroCityId: selectedCalendar.metroCityId ?? "",
|
||||
isActive: selectedCalendar.isActive,
|
||||
});
|
||||
setEditingEntryId(null);
|
||||
}, [selectedCalendar]);
|
||||
|
||||
function handleCreateCalendar(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!isCreateScopeValid) {
|
||||
setError("Bitte alle Pflichtfelder fuer den gewaehlten Scope ausfuellen.");
|
||||
return;
|
||||
}
|
||||
createCalendar.mutate({
|
||||
name: name.trim(),
|
||||
scopeType,
|
||||
countryId,
|
||||
...(scopeType === "STATE" && stateCode.trim() ? { stateCode: stateCode.trim().toUpperCase() } : {}),
|
||||
...(scopeType === "CITY" && metroCityId ? { metroCityId } : {}),
|
||||
priority,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddEntry(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedCalendarId) return;
|
||||
if (!entryDate || !entryName.trim()) {
|
||||
setError("Datum und Feiertagsname sind erforderlich.");
|
||||
return;
|
||||
}
|
||||
createEntry.mutate({
|
||||
holidayCalendarId: selectedCalendarId,
|
||||
date: new Date(`${entryDate}T00:00:00.000Z`),
|
||||
name: entryName.trim(),
|
||||
isRecurringAnnual: entryRecurring,
|
||||
...(entrySource.trim() ? { source: entrySource.trim() } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function resetCalendarDraft() {
|
||||
if (!selectedCalendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCalendarDraft({
|
||||
name: selectedCalendar.name,
|
||||
priority: selectedCalendar.priority,
|
||||
stateCode: selectedCalendar.stateCode ?? "",
|
||||
metroCityId: selectedCalendar.metroCityId ?? "",
|
||||
isActive: selectedCalendar.isActive,
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdateCalendar(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedCalendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const normalizedStateCode = calendarDraft.stateCode.trim().toUpperCase();
|
||||
if (selectedCalendar.scopeType === "STATE" && !normalizedStateCode) {
|
||||
setError("State-Kalender benoetigen einen Regionscode.");
|
||||
return;
|
||||
}
|
||||
if (selectedCalendar.scopeType === "CITY" && !calendarDraft.metroCityId) {
|
||||
setError("City-Kalender benoetigen eine Stadtzuordnung.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateCalendar.mutate({
|
||||
id: selectedCalendar.id,
|
||||
data: {
|
||||
name: calendarDraft.name.trim(),
|
||||
priority: calendarDraft.priority,
|
||||
isActive: calendarDraft.isActive,
|
||||
...(selectedCalendar.scopeType === "STATE" ? { stateCode: normalizedStateCode } : {}),
|
||||
...(selectedCalendar.scopeType === "CITY" ? { metroCityId: calendarDraft.metroCityId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function startEditingEntry(entry: CalendarRow["entries"][number]) {
|
||||
setEditingEntryId(entry.id);
|
||||
setEntryDraft({
|
||||
date: formatDate(entry.date),
|
||||
name: entry.name,
|
||||
isRecurringAnnual: entry.isRecurringAnnual,
|
||||
source: entry.source ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdateEntry(entryId: string) {
|
||||
if (!entryDraft.date || !entryDraft.name.trim()) {
|
||||
setError("Ein Feiertagseintrag braucht Datum und Name.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
updateEntry.mutate({
|
||||
id: entryId,
|
||||
data: {
|
||||
date: new Date(`${entryDraft.date}T00:00:00.000Z`),
|
||||
name: entryDraft.name.trim(),
|
||||
isRecurringAnnual: entryDraft.isRecurringAnnual,
|
||||
source: entryDraft.source.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteCalendar(calendar: CalendarRow) {
|
||||
if (deleteCalendar.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = globalThis.confirm(
|
||||
`Feiertagskalender "${calendar.name}" wirklich loeschen? Alle Eintraege gehen dabei verloren.`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
deleteCalendar.mutate({ id: calendar.id });
|
||||
}
|
||||
|
||||
function handleDeleteEntry(entry: CalendarRow["entries"][number]) {
|
||||
if (deleteEntry.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = globalThis.confirm(
|
||||
`Feiertag "${entry.name}" am ${formatDate(entry.date)} wirklich entfernen?`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
deleteEntry.mutate({ id: entry.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="holiday-calendar-editor"
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Holiday Calendar Editor</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Pflege Feiertagskalender pro Land, Bundesland/Region oder Stadt. Die Vorschau zeigt den effektiv aufgeloesten Kalender fuer den gewaelten Scope.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[1.1fr_1.4fr]">
|
||||
<form onSubmit={handleCreateCalendar} className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-name-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Bayern Feiertage"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Scope</span>
|
||||
<select
|
||||
data-testid="holiday-calendar-scope-select"
|
||||
value={scopeType}
|
||||
onChange={(e) => setScopeType(e.target.value as ScopeType)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{Object.entries(SCOPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Land</span>
|
||||
<select
|
||||
data-testid="holiday-calendar-country-select"
|
||||
value={countryId}
|
||||
onChange={(e) => {
|
||||
setCountryId(e.target.value);
|
||||
setMetroCityId("");
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">Land waehlen</option>
|
||||
{countryRows.map((country) => (
|
||||
<option key={country.id} value={country.id}>{country.name} ({country.code})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-priority-input"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{scopeType === "STATE" && (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-state-input"
|
||||
value={stateCode}
|
||||
onChange={(e) => setStateCode(e.target.value.toUpperCase())}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="BY"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{scopeType === "CITY" && (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
|
||||
<select
|
||||
data-testid="holiday-calendar-city-select"
|
||||
value={metroCityId}
|
||||
onChange={(e) => setMetroCityId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">Stadt waehlen</option>
|
||||
{(selectedCountry?.metroCities ?? []).map((city) => (
|
||||
<option key={city.id} value={city.id}>{city.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button
|
||||
data-testid="holiday-calendar-create-button"
|
||||
type="submit"
|
||||
disabled={createCalendar.isPending || !isCreateScopeValid}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{createCalendar.isPending ? "Speichert..." : "Kalender anlegen"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Kalender</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Scope</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Zuordnung</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Eintraege</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{calendarRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-sm text-gray-400">Noch keine Feiertagskalender vorhanden.</td>
|
||||
</tr>
|
||||
)}
|
||||
{calendarRows.map((calendar) => (
|
||||
<tr
|
||||
key={calendar.id}
|
||||
data-testid={`holiday-calendar-row-${calendar.id}`}
|
||||
className={`cursor-pointer border-t border-gray-200 dark:border-gray-700 ${selectedCalendarId === calendar.id ? "bg-brand-50 dark:bg-brand-950/20" : "hover:bg-gray-50 dark:hover:bg-gray-900/40"}`}
|
||||
onClick={() => setSelectedCalendarId(calendar.id)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{calendar.name}</div>
|
||||
<div className="text-xs text-gray-500">{calendar.country.name}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{SCOPE_LABELS[calendar.scopeType]}</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{calendar.scopeType === "COUNTRY" && calendar.country.code}
|
||||
{calendar.scopeType === "STATE" && calendar.stateCode}
|
||||
{calendar.scopeType === "CITY" && calendar.metroCity?.name}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-400">{calendar._count?.entries ?? calendar.entries.length}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedCalendar && (
|
||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{selectedCalendar.name}</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{SCOPE_LABELS[selectedCalendar.scopeType]} · {selectedCalendar.country.name}
|
||||
{selectedCalendar.stateCode ? ` · ${selectedCalendar.stateCode}` : ""}
|
||||
{selectedCalendar.metroCity?.name ? ` · ${selectedCalendar.metroCity.name}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
data-testid="holiday-calendar-toggle-active-button"
|
||||
type="button"
|
||||
onClick={() => updateCalendar.mutate({
|
||||
id: selectedCalendar.id,
|
||||
data: { isActive: !selectedCalendar.isActive },
|
||||
})}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
>
|
||||
{selectedCalendar.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||
</button>
|
||||
<button
|
||||
data-testid="holiday-calendar-delete-button"
|
||||
type="button"
|
||||
onClick={() => handleDeleteCalendar(selectedCalendar)}
|
||||
disabled={deleteCalendar.isPending}
|
||||
className="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950/30"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdateCalendar} className="grid gap-3 md:grid-cols-2 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Kalendername</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-draft-name-input"
|
||||
value={calendarDraft.name}
|
||||
onChange={(e) => setCalendarDraft((current) => ({ ...current, name: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-draft-priority-input"
|
||||
type="number"
|
||||
value={calendarDraft.priority}
|
||||
onChange={(e) => setCalendarDraft((current) => ({
|
||||
...current,
|
||||
priority: parseInt(e.target.value, 10) || 0,
|
||||
}))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{selectedCalendar.scopeType === "STATE" && (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-draft-state-input"
|
||||
value={calendarDraft.stateCode}
|
||||
onChange={(e) => setCalendarDraft((current) => ({
|
||||
...current,
|
||||
stateCode: e.target.value.toUpperCase(),
|
||||
}))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="BY"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedCalendar.scopeType === "CITY" && (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
|
||||
<select
|
||||
data-testid="holiday-calendar-draft-city-select"
|
||||
value={calendarDraft.metroCityId}
|
||||
onChange={(e) => setCalendarDraft((current) => ({
|
||||
...current,
|
||||
metroCityId: e.target.value,
|
||||
}))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">Stadt waehlen</option>
|
||||
{(selectedCalendarCountry?.metroCities ?? []).map((city) => (
|
||||
<option key={city.id} value={city.id}>{city.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={calendarDraft.isActive}
|
||||
onChange={(e) => setCalendarDraft((current) => ({
|
||||
...current,
|
||||
isActive: e.target.checked,
|
||||
}))}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Kalender aktiv
|
||||
</label>
|
||||
|
||||
<div className="flex items-end justify-end gap-2 md:col-span-2">
|
||||
<button
|
||||
data-testid="holiday-calendar-reset-button"
|
||||
type="button"
|
||||
onClick={resetCalendarDraft}
|
||||
disabled={!isCalendarDirty || updateCalendar.isPending}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
data-testid="holiday-calendar-save-button"
|
||||
type="submit"
|
||||
disabled={!isCalendarDirty || updateCalendar.isPending}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{updateCalendar.isPending ? "Speichert..." : "Kalender speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form onSubmit={handleAddEntry} className="grid gap-3 md:grid-cols-[1fr_1.25fr_1fr_auto]">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</span>
|
||||
<input
|
||||
data-testid="holiday-entry-date-input"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Feiertagsname</span>
|
||||
<input
|
||||
data-testid="holiday-entry-name-input"
|
||||
value={entryName}
|
||||
onChange={(e) => setEntryName(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Augsburger Friedensfest"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Quelle</span>
|
||||
<input
|
||||
data-testid="holiday-entry-source-input"
|
||||
value={entrySource}
|
||||
onChange={(e) => setEntrySource(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Kommunale Satzung"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
data-testid="holiday-entry-create-button"
|
||||
type="submit"
|
||||
disabled={createEntry.isPending || !entryDate || !entryName.trim()}
|
||||
className="self-end rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<input
|
||||
data-testid="holiday-entry-recurring-checkbox"
|
||||
type="checkbox"
|
||||
checked={entryRecurring}
|
||||
onChange={(e) => setEntryRecurring(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Jaehrlich wiederkehrend
|
||||
</label>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Typ</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedCalendar.entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-gray-400">Keine Eintraege vorhanden.</td>
|
||||
</tr>
|
||||
)}
|
||||
{selectedCalendar.entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-t border-gray-200 dark:border-gray-700">
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">
|
||||
{editingEntryId === entry.id ? (
|
||||
<input
|
||||
data-testid={`holiday-entry-edit-date-${entry.id}`}
|
||||
type="date"
|
||||
value={entryDraft.date}
|
||||
onChange={(e) => setEntryDraft((current) => ({ ...current, date: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
) : formatDate(entry.date)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">
|
||||
{editingEntryId === entry.id ? (
|
||||
<input
|
||||
data-testid={`holiday-entry-edit-name-${entry.id}`}
|
||||
value={entryDraft.name}
|
||||
onChange={(e) => setEntryDraft((current) => ({ ...current, name: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
) : entry.name}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{editingEntryId === entry.id ? (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
data-testid={`holiday-entry-edit-recurring-${entry.id}`}
|
||||
type="checkbox"
|
||||
checked={entryDraft.isRecurringAnnual}
|
||||
onChange={(e) => setEntryDraft((current) => ({
|
||||
...current,
|
||||
isRecurringAnnual: e.target.checked,
|
||||
}))}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Jaehrlich
|
||||
</label>
|
||||
) : entry.isRecurringAnnual ? "jaehrlich" : "fix"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{editingEntryId === entry.id ? (
|
||||
<input
|
||||
data-testid={`holiday-entry-edit-source-${entry.id}`}
|
||||
value={entryDraft.source}
|
||||
onChange={(e) => setEntryDraft((current) => ({ ...current, source: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Quelle"
|
||||
/>
|
||||
) : entry.source ?? "System/ohne Quelle"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-3">
|
||||
{editingEntryId === entry.id ? (
|
||||
<>
|
||||
<button
|
||||
data-testid={`holiday-entry-save-${entry.id}`}
|
||||
type="button"
|
||||
onClick={() => handleUpdateEntry(entry.id)}
|
||||
disabled={updateEntry.isPending || !entryDraft.date || !entryDraft.name.trim()}
|
||||
className="text-xs font-medium text-brand-600 hover:text-brand-700 disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
data-testid={`holiday-entry-cancel-${entry.id}`}
|
||||
type="button"
|
||||
onClick={() => setEditingEntryId(null)}
|
||||
disabled={updateEntry.isPending}
|
||||
className="text-xs font-medium text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
data-testid={`holiday-entry-edit-${entry.id}`}
|
||||
type="button"
|
||||
onClick={() => startEditingEntry(entry)}
|
||||
className="text-xs font-medium text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-testid={`holiday-entry-delete-${entry.id}`}
|
||||
type="button"
|
||||
onClick={() => handleDeleteEntry(entry)}
|
||||
disabled={deleteEntry.isPending}
|
||||
className="text-xs font-medium text-red-600 hover:text-red-700"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Vorschau</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Effektiv aufgeloeste Feiertage fuer den gewaehlten Scope.</p>
|
||||
</div>
|
||||
<input
|
||||
data-testid="holiday-preview-year-input"
|
||||
type="number"
|
||||
value={previewYear}
|
||||
onChange={(e) => setPreviewYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
|
||||
className="w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table data-testid="holiday-preview-table" className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(previewQuery.data ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-6 text-center text-sm text-gray-400">
|
||||
{previewQuery.isLoading ? "Laedt Vorschau..." : "Keine Feiertage fuer diese Auswahl vorhanden."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(previewQuery.data ?? []).map((entry) => (
|
||||
<tr key={`${entry.date}-${entry.name}`} className="border-t border-gray-200 dark:border-gray-700">
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{entry.date}</td>
|
||||
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">{entry.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{entry.calendarName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { VacationStatus, VacationType } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { VacationModal } from "./VacationModal.js";
|
||||
@@ -137,6 +138,13 @@ export function VacationClient() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Regional public holidays are maintained in{" "}
|
||||
<Link href="/admin/vacations" className="font-medium text-brand-700 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300">
|
||||
Holiday Calendars
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,6 +10,34 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
|
||||
|
||||
const VACATION_TYPES = Object.values(VacationType);
|
||||
const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter((type) => type !== VacationType.PUBLIC_HOLIDAY);
|
||||
|
||||
const HOLIDAY_SOURCE_LABELS = {
|
||||
CALENDAR: "Calendar",
|
||||
LEGACY_PUBLIC_HOLIDAY: "Legacy import",
|
||||
CALENDAR_AND_LEGACY: "Calendar + legacy",
|
||||
} as const;
|
||||
|
||||
type VacationPreviewData = {
|
||||
requestedDays: number;
|
||||
effectiveDays: number;
|
||||
deductedDays: number;
|
||||
publicHolidayDates: string[];
|
||||
holidayDetails: Array<{
|
||||
date: string;
|
||||
source: string;
|
||||
}>;
|
||||
holidayContext: {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
sources: {
|
||||
hasCalendarHolidays: boolean;
|
||||
hasLegacyPublicHolidayEntries: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
interface VacationModalProps {
|
||||
resourceId?: string;
|
||||
@@ -17,13 +45,34 @@ interface VacationModalProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function toDateInputValue(date: Date | string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
function toUtcInputDate(value: string): Date {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function buildHolidayBasisLabel(preview: VacationPreviewData): string[] {
|
||||
const parts = [];
|
||||
|
||||
if (preview.holidayContext.countryName || preview.holidayContext.countryCode) {
|
||||
parts.push(preview.holidayContext.countryName ?? preview.holidayContext.countryCode ?? "");
|
||||
}
|
||||
|
||||
if (preview.holidayContext.federalState) {
|
||||
parts.push(preview.holidayContext.federalState);
|
||||
}
|
||||
|
||||
if (preview.holidayContext.metroCityName) {
|
||||
parts.push(preview.holidayContext.metroCityName);
|
||||
}
|
||||
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function getHolidaySourceLabel(source: string): string {
|
||||
if (source in HOLIDAY_SOURCE_LABELS) {
|
||||
return HOLIDAY_SOURCE_LABELS[source as keyof typeof HOLIDAY_SOURCE_LABELS];
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
||||
@@ -70,6 +119,24 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
|
||||
);
|
||||
|
||||
const previewQuery = trpc.vacation.previewRequest.useQuery(
|
||||
{
|
||||
resourceId,
|
||||
type,
|
||||
startDate: toUtcInputDate(debouncedStart || "1970-01-01"),
|
||||
endDate: toUtcInputDate(debouncedEnd || "1970-01-01"),
|
||||
...(isHalfDay ? { isHalfDay: true } : {}),
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!resourceId
|
||||
&& !!debouncedStart
|
||||
&& !!debouncedEnd
|
||||
&& (!isHalfDay || debouncedStart === debouncedEnd),
|
||||
staleTime: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.vacation.create.useMutation({
|
||||
@@ -166,7 +233,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label htmlFor="vac-type" className={labelClass}>
|
||||
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · PUBLIC_HOLIDAY = national/regional holiday · OTHER = special leave." />
|
||||
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." />
|
||||
</label>
|
||||
<select
|
||||
id="vac-type"
|
||||
@@ -174,7 +241,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
onChange={(e) => setType(e.target.value as VacationType)}
|
||||
className={inputClass}
|
||||
>
|
||||
{VACATION_TYPES.map((t) => (
|
||||
{REQUESTABLE_VACATION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{VACATION_TYPE_LABELS[t]}
|
||||
</option>
|
||||
@@ -282,6 +349,81 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!resourceId && !!startDate && !!endDate && (
|
||||
<div
|
||||
data-testid="vacation-preview-card"
|
||||
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<strong>Leave preview</strong>
|
||||
{previewQuery.isLoading && (
|
||||
<span className="text-xs text-emerald-700">Calculating…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewQuery.data && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2 text-xs sm:text-sm">
|
||||
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||
<div className="text-emerald-700">Requested</div>
|
||||
<div data-testid="vacation-preview-requested-days" className="font-semibold">
|
||||
{previewQuery.data.requestedDays}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||
<div className="text-emerald-700">Effective</div>
|
||||
<div data-testid="vacation-preview-effective-days" className="font-semibold">
|
||||
{previewQuery.data.effectiveDays}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||
<div className="text-emerald-700">Deducted</div>
|
||||
<div data-testid="vacation-preview-deducted-days" className="font-semibold">
|
||||
{previewQuery.data.deductedDays}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{buildHolidayBasisLabel(previewQuery.data).length > 0 && (
|
||||
<div data-testid="vacation-preview-holiday-basis" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
|
||||
<span className="font-medium">Holiday basis:</span>{" "}
|
||||
{buildHolidayBasisLabel(previewQuery.data).join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays || previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
|
||||
<div data-testid="vacation-preview-holiday-sources" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
|
||||
<span className="font-medium">Sources:</span>{" "}
|
||||
{[
|
||||
previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null,
|
||||
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null,
|
||||
].filter(Boolean).join(" + ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewQuery.data.publicHolidayDates.length > 0 && (
|
||||
<div data-testid="vacation-preview-public-holidays" className="text-xs sm:text-sm">
|
||||
<span className="font-medium">Excluded public holidays:</span>{" "}
|
||||
{previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewQuery.data.requestedDays !== previewQuery.data.deductedDays && (
|
||||
<div className="text-xs sm:text-sm text-emerald-800">
|
||||
Public holidays in the selected range are excluded from deducted leave days.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewQuery.error && (
|
||||
<div className="mt-2 text-xs text-red-700">
|
||||
{previewQuery.error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
<div>
|
||||
<label htmlFor="vac-note" className={labelClass}>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
|
||||
import { createAuditEntry } from "@capakraken/api";
|
||||
import { createAuditEntry } from "@capakraken/api/lib/audit";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
@@ -27,9 +27,12 @@ const authConfig = {
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { email, password, totp } = parsed.data;
|
||||
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
|
||||
|
||||
// Rate limit: 5 login attempts per 15 minutes per email
|
||||
const rateLimitResult = authRateLimiter(email.toLowerCase());
|
||||
const rateLimitResult = isE2eTestMode
|
||||
? { allowed: true }
|
||||
: authRateLimiter(email.toLowerCase());
|
||||
if (!rateLimitResult.allowed) {
|
||||
// Audit failed login (rate limited)
|
||||
void createAuditEntry({
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
"next-env.d.ts",
|
||||
".next-e2e/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
name: capakraken-prod
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -66,4 +68,6 @@ services:
|
||||
|
||||
volumes:
|
||||
capakraken_prod_pgdata:
|
||||
name: capakraken_prod_pgdata
|
||||
capakraken_prod_redis:
|
||||
name: capakraken_prod_redis
|
||||
|
||||
+4
-1
@@ -1,3 +1,5 @@
|
||||
name: capakraken
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
@@ -69,7 +71,7 @@ services:
|
||||
postgres-test:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5434:5432"
|
||||
- "${POSTGRES_TEST_PORT:-5434}:5432"
|
||||
environment:
|
||||
POSTGRES_DB: capakraken_test
|
||||
POSTGRES_USER: capakraken
|
||||
@@ -81,3 +83,4 @@ services:
|
||||
|
||||
volumes:
|
||||
capakraken_pgdata:
|
||||
name: capakraken_pgdata
|
||||
|
||||
@@ -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
@@ -6,13 +6,14 @@
|
||||
"dev": "turbo dev",
|
||||
"build": "turbo build",
|
||||
"lint": "turbo lint",
|
||||
"test": "turbo test",
|
||||
"test": "turbo run test:unit",
|
||||
"test:unit": "turbo test:unit",
|
||||
"test:e2e": "turbo test:e2e",
|
||||
"db:push": "pnpm --filter @capakraken/db db:push",
|
||||
"db:migrate": "pnpm --filter @capakraken/db db:migrate",
|
||||
"db:seed": "pnpm --filter @capakraken/db db:seed",
|
||||
"db:studio": "pnpm --filter @capakraken/db db:studio",
|
||||
"db:doctor": "node ./scripts/db-doctor.mjs capakraken",
|
||||
"db:push": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:push",
|
||||
"db:migrate": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:migrate",
|
||||
"db:seed": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:seed",
|
||||
"db:studio": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:studio",
|
||||
"db:reset:dispo": "pnpm --filter @capakraken/db db:reset:dispo",
|
||||
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
|
||||
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"./router": "./src/router/index.ts",
|
||||
"./trpc": "./src/trpc.ts",
|
||||
"./sse": "./src/sse/event-bus.ts",
|
||||
"./lib/audit": "./src/lib/audit.ts",
|
||||
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
|
||||
"./lib/logger": "./src/lib/logger.ts",
|
||||
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { AllocationStatus, SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { allocationRouter } from "../router/allocation.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted } from "../sse/event-bus.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
vi.mock("../sse/event-bus.js", () => ({
|
||||
emitAllocationCreated: vi.fn(),
|
||||
emitAllocationDeleted: vi.fn(),
|
||||
emitAllocationUpdated: vi.fn(),
|
||||
emitNotificationCreated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/budget-alerts.js", () => ({
|
||||
@@ -18,6 +19,10 @@ vi.mock("../lib/cache.js", () => ({
|
||||
invalidateDashboardCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(allocationRouter);
|
||||
|
||||
function createManagerCaller(db: Record<string, unknown>) {
|
||||
@@ -35,7 +40,100 @@ function createManagerCaller(db: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
function createDemandWorkflowDb(overrides: Record<string, unknown> = {}) {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1", name: "Project One" }),
|
||||
},
|
||||
role: {
|
||||
findUnique: vi.fn().mockResolvedValue({ name: "FX Artist" }),
|
||||
},
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
|
||||
},
|
||||
notification: {
|
||||
create: vi.fn().mockImplementation(async ({ data }: { data: { userId: string } }) => ({
|
||||
id: `notif_${data.userId}`,
|
||||
})),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...db,
|
||||
...overrides,
|
||||
project: { ...db.project, ...(overrides.project as Record<string, unknown> | undefined) },
|
||||
role: { ...db.role, ...(overrides.role as Record<string, unknown> | undefined) },
|
||||
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||
notification: {
|
||||
...db.notification,
|
||||
...(overrides.notification as Record<string, unknown> | undefined),
|
||||
},
|
||||
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||
};
|
||||
}
|
||||
|
||||
describe("allocation entry resolution router", () => {
|
||||
it("excludes regional holidays from resource availability coverage", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "resource_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "E-001",
|
||||
fte: 1,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { dailyWorkingHours: 8, code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.checkResourceAvailability({
|
||||
resourceId: "resource_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
dailyCapacity: 8,
|
||||
totalWorkingDays: 1,
|
||||
availableDays: 0,
|
||||
partialDays: 0,
|
||||
conflictDays: 1,
|
||||
totalAvailableHours: 0,
|
||||
totalRequestedHours: 8,
|
||||
coveragePercent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
|
||||
const createdDemandRequirement = {
|
||||
id: "demand_1",
|
||||
@@ -187,6 +285,7 @@ describe("allocation entry resolution router", () => {
|
||||
|
||||
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
|
||||
vi.mocked(emitAllocationCreated).mockClear();
|
||||
vi.mocked(emitNotificationCreated).mockClear();
|
||||
|
||||
const createdDemandRequirement = {
|
||||
id: "demand_explicit_1",
|
||||
@@ -206,18 +305,14 @@ describe("allocation entry resolution router", () => {
|
||||
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
|
||||
};
|
||||
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
|
||||
},
|
||||
const db = createDemandWorkflowDb({
|
||||
demandRequirement: {
|
||||
create: vi.fn().mockResolvedValue(createdDemandRequirement),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}) as Record<string, unknown>;
|
||||
Object.assign(db, {
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.createDemandRequirement({
|
||||
@@ -247,6 +342,8 @@ describe("allocation entry resolution router", () => {
|
||||
projectId: "project_1",
|
||||
resourceId: null,
|
||||
});
|
||||
expect(db.notification.create).toHaveBeenCalledTimes(2);
|
||||
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
|
||||
@@ -730,4 +827,3 @@ describe("allocation entry resolution router", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAssistantInsight } from "../router/assistant-insights.js";
|
||||
|
||||
describe("assistant insights", () => {
|
||||
it("builds a transparent chargeability insight from holiday-aware payloads", () => {
|
||||
const insight = buildAssistantInsight("get_chargeability", {
|
||||
resource: "Bruce Banner",
|
||||
month: "2026-01",
|
||||
chargeability: "42.9%",
|
||||
chargeabilityPct: 42.9,
|
||||
targetPct: 80,
|
||||
availableHours: 168,
|
||||
bookedHours: 72,
|
||||
unassignedHours: 96,
|
||||
targetHours: 134.4,
|
||||
baseWorkingDays: 23,
|
||||
workingDays: 21,
|
||||
baseAvailableHours: 184,
|
||||
locationContext: { country: "Deutschland", federalState: "BY", metroCity: "Augsburg" },
|
||||
holidaySummary: { count: 2, workdayCount: 2, hoursDeduction: 16 },
|
||||
absenceSummary: { dayEquivalent: 0.5, hoursDeduction: 4 },
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "chargeability",
|
||||
title: "Bruce Banner · 2026-01",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Chargeability", value: "42.9%", tone: "warn" }),
|
||||
expect.objectContaining({ label: "Available", value: "168 h" }),
|
||||
expect.objectContaining({ label: "Target", value: "134.4 h" }),
|
||||
]),
|
||||
sections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Basis",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Location", value: "Augsburg, BY, Deutschland" }),
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: "Deductions",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Holiday deduction", value: "16 h" }),
|
||||
expect.objectContaining({ label: "Absence deduction", value: "4 h" }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a holiday comparison insight with regional scope counts", () => {
|
||||
const insight = buildAssistantInsight("list_holidays_by_region", {
|
||||
locationContext: { countryCode: "DE", federalState: "BY" },
|
||||
count: 14,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-12-31",
|
||||
summary: {
|
||||
byScope: [
|
||||
{ scope: "NATIONAL", count: 9 },
|
||||
{ scope: "STATE", count: 5 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "holiday_region",
|
||||
title: "BY, DE",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Resolved holidays", value: "14" }),
|
||||
]),
|
||||
sections: [
|
||||
expect.objectContaining({
|
||||
title: "Scopes",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "STATE", value: "5" }),
|
||||
]),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a best-resource insight from staffing recommendations", () => {
|
||||
const insight = buildAssistantInsight("find_best_project_resource", {
|
||||
project: { name: "Gelddruckmaschine", shortCode: "GDM" },
|
||||
period: { startDate: "2026-04-01", endDate: "2026-04-21", minHoursPerDay: 3, rankingMode: "lowest_lcr" },
|
||||
candidateCount: 4,
|
||||
bestMatch: {
|
||||
name: "Jane Doe",
|
||||
role: "TD",
|
||||
chapter: "Lighting",
|
||||
country: "Deutschland",
|
||||
federalState: "BY",
|
||||
metroCity: "Muenchen",
|
||||
lcr: "€85.00",
|
||||
remainingHours: 74,
|
||||
remainingHoursPerDay: 3.5,
|
||||
availableHours: 120,
|
||||
baseAvailableHours: 136,
|
||||
holidaySummary: { hoursDeduction: 8 },
|
||||
absenceSummary: { hoursDeduction: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(insight).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "resource_match",
|
||||
title: "GDM staffing",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Best match", value: "Jane Doe" }),
|
||||
expect.objectContaining({ label: "Remaining", value: "74 h", tone: "good" }),
|
||||
]),
|
||||
sections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Selection",
|
||||
metrics: expect.arrayContaining([
|
||||
expect.objectContaining({ label: "Location", value: "Muenchen, BY, Deutschland" }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { getAvailableAssistantTools } from "../router/assistant.js";
|
||||
|
||||
function getToolNames(permissions: PermissionKeyValue[]) {
|
||||
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
|
||||
}
|
||||
|
||||
describe("assistant router tool gating", () => {
|
||||
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
|
||||
const withAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
]);
|
||||
|
||||
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps user administration tools behind manageUsers", () => {
|
||||
const withoutManageUsers = getToolNames([]);
|
||||
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
|
||||
|
||||
expect(withoutManageUsers).not.toContain("list_users");
|
||||
expect(withManageUsers).toContain("list_users");
|
||||
});
|
||||
|
||||
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
||||
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
|
||||
|
||||
expect(names).not.toContain("find_best_project_resource");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PermissionKey } from "@capakraken/shared";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: PermissionKey[] = [],
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN",
|
||||
permissions: new Set(permissions),
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant advanced tools and scoping", () => {
|
||||
it("finds the best project resource with holiday-aware remaining capacity and LCR ranking", async () => {
|
||||
const assignmentFindMany = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: "res_carol",
|
||||
hoursPerDay: 2,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "PROPOSED",
|
||||
resource: {
|
||||
id: "res_carol",
|
||||
eid: "carol.danvers",
|
||||
displayName: "Carol Danvers",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 7664,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: "city_hamburg",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
areaRole: { name: "Artist" },
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: "res_steve",
|
||||
hoursPerDay: 4,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
resource: {
|
||||
id: "res_steve",
|
||||
eid: "steve.rogers",
|
||||
displayName: "Steve Rogers",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 13377,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_augsburg",
|
||||
country: { code: "DE", name: "Deutschland" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
areaRole: { name: "Artist" },
|
||||
},
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
resourceId: "res_carol",
|
||||
projectId: "project_lari",
|
||||
hoursPerDay: 2,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "PROPOSED",
|
||||
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||
},
|
||||
{
|
||||
resourceId: "res_steve",
|
||||
projectId: "project_lari",
|
||||
hoursPerDay: 4,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_lari",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "LARI",
|
||||
status: "ACTIVE",
|
||||
responsiblePerson: "Larissa Joos",
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: assignmentFindMany,
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_best_project_resource",
|
||||
JSON.stringify({
|
||||
projectIdentifier: "LARI",
|
||||
startDate: "2026-01-05",
|
||||
endDate: "2026-01-16",
|
||||
minHoursPerDay: 3,
|
||||
rankingMode: "lowest_lcr",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
project: { shortCode: string };
|
||||
candidateCount: number;
|
||||
bestMatch: {
|
||||
name: string;
|
||||
remainingHoursPerDay: number;
|
||||
lcrCents: number | null;
|
||||
federalState: string | null;
|
||||
metroCity: string | null;
|
||||
baseAvailableHours: number;
|
||||
holidaySummary: { count: number };
|
||||
};
|
||||
candidates: Array<{
|
||||
name: string;
|
||||
remainingHoursPerDay: number;
|
||||
workingDays: number;
|
||||
baseAvailableHours: number;
|
||||
holidaySummary: { count: number; hoursDeduction: number };
|
||||
capacityBreakdown: { holidayHoursDeduction: number };
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(parsed.project.shortCode).toBe("LARI");
|
||||
expect(parsed.candidateCount).toBe(2);
|
||||
expect(parsed.bestMatch).toEqual(
|
||||
expect.objectContaining({
|
||||
name: "Carol Danvers",
|
||||
remainingHoursPerDay: 6,
|
||||
lcrCents: 7664,
|
||||
federalState: "HH",
|
||||
metroCity: "Hamburg",
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 0 }),
|
||||
}),
|
||||
);
|
||||
expect(parsed.candidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Carol Danvers",
|
||||
remainingHoursPerDay: 6,
|
||||
workingDays: 10,
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 0, hoursDeduction: 0 }),
|
||||
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 0 }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Steve Rogers",
|
||||
remainingHoursPerDay: 4,
|
||||
workingDays: 9,
|
||||
baseAvailableHours: 80,
|
||||
holidaySummary: expect.objectContaining({ count: 1, hoursDeduction: 8 }),
|
||||
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 8 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires the dedicated advanced assistant permission for the high-level resource tool", async () => {
|
||||
const ctx = createToolContext({}, [PermissionKey.VIEW_COSTS]);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_best_project_resource",
|
||||
JSON.stringify({ projectIdentifier: "LARI" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes assistant notification listing to the current user", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([]);
|
||||
const ctx = createToolContext({
|
||||
notification: {
|
||||
findMany,
|
||||
},
|
||||
});
|
||||
|
||||
await executeTool("list_notifications", JSON.stringify({ unreadOnly: true }), ctx);
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
userId: "user_1",
|
||||
readAt: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects marking notifications that do not belong to the current user", async () => {
|
||||
const update = vi.fn();
|
||||
const ctx = createToolContext({
|
||||
notification: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
|
||||
update,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"mark_notification_read",
|
||||
JSON.stringify({ notificationId: "notif_1" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Access denied: this notification does not belong to you",
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires manageUsers before listing users through the assistant", async () => {
|
||||
const findMany = vi.fn();
|
||||
const ctx = createToolContext({
|
||||
user: {
|
||||
findMany,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining(PermissionKey.MANAGE_USERS),
|
||||
}),
|
||||
);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: string[] = [],
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole: "ADMIN",
|
||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant holiday tools", () => {
|
||||
it("lists regional holidays and distinguishes Bavaria from Hamburg", async () => {
|
||||
const ctx = createToolContext({});
|
||||
|
||||
const bavaria = await executeTool(
|
||||
"list_holidays_by_region",
|
||||
JSON.stringify({ countryCode: "DE", federalState: "BY", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
const hamburg = await executeTool(
|
||||
"list_holidays_by_region",
|
||||
JSON.stringify({ countryCode: "DE", federalState: "HH", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const bavariaResult = JSON.parse(bavaria.content) as {
|
||||
count: number;
|
||||
locationContext: { federalState: string | null };
|
||||
summary: { byScope: Array<{ scope: string; count: number }> };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
const hamburgResult = JSON.parse(hamburg.content) as {
|
||||
count: number;
|
||||
locationContext: { federalState: string | null };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
|
||||
expect(bavariaResult.count).toBeGreaterThan(hamburgResult.count);
|
||||
expect(bavariaResult.locationContext.federalState).toBe("BY");
|
||||
expect(bavariaResult.summary.byScope).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
|
||||
);
|
||||
expect(bavariaResult.holidays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]),
|
||||
);
|
||||
expect(hamburgResult.holidays).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves resource-specific holidays including city-local dates", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_resource_holidays",
|
||||
JSON.stringify({ identifier: "bruce.banner", year: 2026 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
resource: { eid: string; federalState: string | null; metroCity: string | null };
|
||||
summary: { byScope: Array<{ scope: string; count: number }> };
|
||||
holidays: Array<{ name: string; date: string }>;
|
||||
};
|
||||
|
||||
expect(parsed.resource).toEqual(
|
||||
expect.objectContaining({
|
||||
eid: "bruce.banner",
|
||||
federalState: "BY",
|
||||
metroCity: "Augsburg",
|
||||
}),
|
||||
);
|
||||
expect(parsed.holidays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Augsburger Friedensfest", date: "2026-08-08" }),
|
||||
]),
|
||||
);
|
||||
expect(parsed.summary.byScope).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "bruce.banner",
|
||||
fte: 1,
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_chargeability",
|
||||
JSON.stringify({ resourceId: "res_1", month: "2026-01" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
baseWorkingDays: number;
|
||||
baseAvailableHours: number;
|
||||
availableHours: number;
|
||||
bookedHours: number;
|
||||
workingDays: number;
|
||||
targetHours: number;
|
||||
unassignedHours: number;
|
||||
holidaySummary: { count: number; workdayCount: number; hoursDeduction: number };
|
||||
capacityBreakdown: { formula: string; holidayHoursDeduction: number; absenceHoursDeduction: number };
|
||||
locationContext: { federalState: string | null };
|
||||
allocations: Array<{ hours: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.bookedHours).toBe(8);
|
||||
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
|
||||
expect(parsed.baseWorkingDays).toBe(23);
|
||||
expect(parsed.baseAvailableHours).toBe(184);
|
||||
expect(parsed.availableHours).toBe(168);
|
||||
expect(parsed.workingDays).toBe(21);
|
||||
expect(parsed.targetHours).toBe(134.4);
|
||||
expect(parsed.unassignedHours).toBe(160);
|
||||
expect(parsed.locationContext.federalState).toBe("BY");
|
||||
expect(parsed.holidaySummary).toEqual(
|
||||
expect.objectContaining({
|
||||
count: 2,
|
||||
workdayCount: 2,
|
||||
hoursDeduction: 16,
|
||||
}),
|
||||
);
|
||||
expect(parsed.capacityBreakdown).toEqual(
|
||||
expect.objectContaining({
|
||||
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
|
||||
holidayHoursDeduction: 16,
|
||||
absenceHoursDeduction: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns holiday-aware budget forecast data from the dashboard use-case", async () => {
|
||||
const { getDashboardBudgetForecast } = await import("@capakraken/application");
|
||||
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||
{
|
||||
projectId: "project_1",
|
||||
projectName: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 60_000,
|
||||
burnRate: 5_000,
|
||||
pctUsed: 60,
|
||||
estimatedExhaustionDate: "2026-02-20",
|
||||
},
|
||||
]);
|
||||
|
||||
const ctx = createToolContext({}, ["viewCosts"]);
|
||||
const result = await executeTool("get_budget_forecast", "{}", ctx);
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
forecasts: Array<{
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
budgetCents: number;
|
||||
spentCents: number;
|
||||
remainingCents: number;
|
||||
projectedCents: number;
|
||||
burnRateCents: number;
|
||||
burnStatus: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(getDashboardBudgetForecast).toHaveBeenCalled();
|
||||
expect(parsed.forecasts).toEqual([
|
||||
expect.objectContaining({
|
||||
projectName: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 60_000,
|
||||
remainingCents: 40_000,
|
||||
projectedCents: 100_000,
|
||||
burnRateCents: 5_000,
|
||||
burnStatus: "on_track",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("checks resource availability with regional holidays excluded from capacity", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
project: { name: "Gamma", shortCode: "GAM" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"check_resource_availability",
|
||||
JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
workingDays: number;
|
||||
periodAvailableHours: number;
|
||||
periodBookedHours: number;
|
||||
periodRemainingHours: number;
|
||||
availableHoursPerDay: number;
|
||||
isFullyAvailable: boolean;
|
||||
};
|
||||
|
||||
expect(parsed.workingDays).toBe(1);
|
||||
expect(parsed.periodAvailableHours).toBe(8);
|
||||
expect(parsed.periodBookedHours).toBe(8);
|
||||
expect(parsed.periodRemainingHours).toBe(0);
|
||||
expect(parsed.availableHoursPerDay).toBe(0);
|
||||
expect(parsed.isFullyAvailable).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps scenario simulation flat when a proposed change falls on a local holiday", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
budgetCents: 500_000,
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
resourceId: "res_1",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
status: "CONFIRMED",
|
||||
resource: {
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
lcrCents: 100,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
lcrCents: 100,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", dailyWorkingHours: 8 },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db, ["manageAllocations"]);
|
||||
|
||||
const result = await executeTool(
|
||||
"simulate_scenario",
|
||||
JSON.stringify({
|
||||
projectId: "project_1",
|
||||
changes: [
|
||||
{
|
||||
resourceId: "res_1",
|
||||
startDate: "2026-01-06",
|
||||
endDate: "2026-01-06",
|
||||
hoursPerDay: 8,
|
||||
},
|
||||
],
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
baseline: { totalHours: number; totalCostCents: number };
|
||||
scenario: { totalHours: number; totalCostCents: number };
|
||||
delta: { hours: number; costCents: number };
|
||||
};
|
||||
|
||||
expect(parsed.baseline).toEqual(
|
||||
expect.objectContaining({
|
||||
totalHours: 8,
|
||||
totalCostCents: 800,
|
||||
}),
|
||||
);
|
||||
expect(parsed.scenario).toEqual(
|
||||
expect.objectContaining({
|
||||
totalHours: 8,
|
||||
totalCostCents: 800,
|
||||
}),
|
||||
);
|
||||
expect(parsed.delta).toEqual(
|
||||
expect.objectContaining({
|
||||
hours: 0,
|
||||
costCents: 0,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers resources without a local holiday in staffing suggestions", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
shortCode: "HP",
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
}),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
displayName: "Bavaria",
|
||||
eid: "BY-1",
|
||||
fte: 1,
|
||||
lcrCents: 10000,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
{
|
||||
id: "res_hh",
|
||||
displayName: "Hamburg",
|
||||
eid: "HH-1",
|
||||
fte: 1,
|
||||
lcrCents: 10000,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_staffing_suggestions",
|
||||
JSON.stringify({ projectId: "project_1", limit: 5 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
suggestions: Array<{ name: string; availableHours: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.suggestions).toHaveLength(1);
|
||||
expect(parsed.suggestions[0]).toEqual(
|
||||
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds capacity with local holidays respected", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_by",
|
||||
displayName: "Bavaria",
|
||||
eid: "BY-1",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
{
|
||||
id: "res_hh",
|
||||
displayName: "Hamburg",
|
||||
eid: "HH-1",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
areaRole: { name: "Consultant" },
|
||||
chapter: "CGI",
|
||||
assignments: [],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"find_capacity",
|
||||
JSON.stringify({ startDate: "2026-01-06", endDate: "2026-01-06", minHoursPerDay: 1 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
results: Array<{ name: string; availableHours: number; availableHoursPerDay: number }>;
|
||||
};
|
||||
|
||||
expect(parsed.results).toHaveLength(1);
|
||||
expect(parsed.results[0]).toEqual(
|
||||
expect.objectContaining({ name: "Hamburg", availableHours: 8, availableHoursPerDay: 8 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "project_1",
|
||||
name: "Holiday Project",
|
||||
shortCode: "HP",
|
||||
shoringThreshold: 55,
|
||||
onshoreCountryCode: "DE",
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
resourceId: "res_by",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
resource: {
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: "res_in",
|
||||
hoursPerDay: 8,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
resource: {
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_in",
|
||||
federalState: null,
|
||||
metroCityId: null,
|
||||
country: { code: "IN" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
const ctx = createToolContext(db);
|
||||
|
||||
const result = await executeTool(
|
||||
"get_shoring_ratio",
|
||||
JSON.stringify({ projectId: "project_1" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.content).toContain("0% onshore (DE), 100% offshore");
|
||||
expect(result.content).toContain("IN 100% (1 people)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||
return {
|
||||
...actual,
|
||||
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
|
||||
listAssignmentBookings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { checkChargeabilityAlerts } from "../lib/chargeability-alerts.js";
|
||||
|
||||
describe("chargeability alerts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("creates an alert when a regional holiday reduces booked hours below threshold", async () => {
|
||||
const notifications: Array<{ userId: string; title: string; body?: string }> = [];
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Bruce Banner",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
federalState: "BY",
|
||||
chargeabilityTarget: 21,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
managementLevelGroup: { targetPercentage: 0.21 },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||
notifications.push(data);
|
||||
return { id: `notification_${notifications.length}`, userId: data.userId };
|
||||
}),
|
||||
},
|
||||
user: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "manager_1" }]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Gamma",
|
||||
shortCode: "GAM",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "res_1", displayName: "Bruce Banner", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const alertCount = await checkChargeabilityAlerts(db);
|
||||
|
||||
expect(alertCount).toBe(1);
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]?.title).toContain("Bruce Banner");
|
||||
expect(notifications[0]?.body).toContain("gap: 16pp");
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,10 @@ describe("chargeability report router", () => {
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_es",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_es",
|
||||
@@ -143,6 +147,10 @@ describe("chargeability report router", () => {
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_es",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_es",
|
||||
@@ -204,4 +212,217 @@ describe("chargeability report router", () => {
|
||||
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
|
||||
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
|
||||
});
|
||||
|
||||
it("reduces SAH for German public holidays based on the calendar", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_de",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
orgUnit: { id: "org_1", name: "CGI" },
|
||||
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||
managementLevel: { id: "level_1", name: "L7" },
|
||||
metroCity: { id: "city_1", name: "Munich" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "project_full_month", utilizationCategory: { code: "Chg" } },
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_full_month",
|
||||
projectId: "project_full_month",
|
||||
resourceId: "resource_de",
|
||||
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
||||
hoursPerDay: 7,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_full_month",
|
||||
name: "Full Month Project",
|
||||
shortCode: "FMP",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_de", displayName: "Alice", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2026-01",
|
||||
endMonth: "2026-01",
|
||||
});
|
||||
|
||||
const month = report.resources[0]?.months[0];
|
||||
|
||||
expect(month).toBeDefined();
|
||||
expect(month?.sah).toBe(168);
|
||||
expect(month?.chg).toBeCloseTo(0.875, 5);
|
||||
});
|
||||
|
||||
it("applies city-specific public holidays to SAH", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_augsburg",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_1",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
orgUnit: { id: "org_1", name: "CGI" },
|
||||
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||
managementLevel: { id: "level_1", name: "L7" },
|
||||
metroCity: { id: "city_1", name: "Augsburg" },
|
||||
},
|
||||
{
|
||||
id: "resource_munich",
|
||||
eid: "E-002",
|
||||
displayName: "Bob",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_2",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
orgUnit: { id: "org_1", name: "CGI" },
|
||||
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||
managementLevel: { id: "level_1", name: "L7" },
|
||||
metroCity: { id: "city_2", name: "Munich" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2028-08",
|
||||
endMonth: "2028-08",
|
||||
});
|
||||
|
||||
const augsburg = report.resources.find((resource) => resource.city === "Augsburg");
|
||||
const munich = report.resources.find((resource) => resource.city === "Munich");
|
||||
|
||||
expect(augsburg?.months[0]?.sah).toBe((munich?.months[0]?.sah ?? 0) - 8);
|
||||
});
|
||||
|
||||
it("respects individual weekday availability when computing booked hours", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_pt",
|
||||
eid: "E-003",
|
||||
displayName: "Carla",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 0 },
|
||||
countryId: "country_de",
|
||||
federalState: null,
|
||||
metroCityId: "city_3",
|
||||
chargeabilityTarget: 80,
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
orgUnit: { id: "org_1", name: "CGI" },
|
||||
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||
managementLevel: { id: "level_1", name: "L7" },
|
||||
metroCity: { id: "city_3", name: "Berlin" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "project_week", utilizationCategory: { code: "Chg" } },
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_week",
|
||||
projectId: "project_week",
|
||||
resourceId: "resource_pt",
|
||||
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
||||
endDate: new Date("2026-03-06T00:00:00.000Z"),
|
||||
hoursPerDay: 4,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_week",
|
||||
name: "Week Project",
|
||||
shortCode: "WP",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_pt", displayName: "Carla", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const report = await caller.getReport({
|
||||
startMonth: "2026-03",
|
||||
endMonth: "2026-03",
|
||||
});
|
||||
|
||||
const month = report.resources[0]?.months[0];
|
||||
|
||||
expect(month).toBeDefined();
|
||||
expect(month?.chg).toBeCloseTo(16 / 144, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { computationGraphRouter } from "../router/computation-graph.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
const createCaller = createCallerFactory(computationGraphRouter);
|
||||
|
||||
type ResourceGraphMeta = {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resolvedHolidays: Array<{
|
||||
date: string;
|
||||
name: string;
|
||||
scope: "COUNTRY" | "STATE" | "CITY";
|
||||
calendarName: string | null;
|
||||
}>;
|
||||
factors: {
|
||||
baseAvailableHours: number;
|
||||
effectiveAvailableHours: number;
|
||||
publicHolidayCount: number;
|
||||
publicHolidayWorkdayCount: number;
|
||||
publicHolidayHoursDeduction: number;
|
||||
absenceDayCount: number;
|
||||
absenceHoursDeduction: number;
|
||||
};
|
||||
};
|
||||
|
||||
function createControllerCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||
expires: "2026-03-14T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_controller",
|
||||
systemRole: SystemRole.CONTROLLER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createDb(resourceFindImpl: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
resource: {
|
||||
findUniqueOrThrow: resourceFindImpl,
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
calculationRule: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildResource(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "resource_1",
|
||||
displayName: "Bruce Banner",
|
||||
eid: "bruce.banner",
|
||||
fte: 1,
|
||||
lcrCents: 5_000,
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
country: {
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
name: "Deutschland",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
},
|
||||
metroCity: null,
|
||||
managementLevelGroup: {
|
||||
id: "mlg_1",
|
||||
name: "Senior",
|
||||
targetPercentage: 0.8,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computation graph router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("exposes location context and city-local holidays in the resource graph", async () => {
|
||||
const db = createDb(vi.fn().mockResolvedValue(buildResource({
|
||||
id: "resource_augsburg",
|
||||
metroCityId: "city_augsburg",
|
||||
metroCity: { id: "city_augsburg", name: "Augsburg" },
|
||||
})));
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getResourceData({
|
||||
resourceId: "resource_augsburg",
|
||||
month: "2026-08",
|
||||
});
|
||||
const meta = result.meta as ResourceGraphMeta;
|
||||
const nodeIds = result.nodes.map((node) => node.id);
|
||||
const holidayExamples = result.nodes.find((node) => node.id === "input.holidayExamples");
|
||||
|
||||
expect(new Set(nodeIds).size).toBe(nodeIds.length);
|
||||
expect(nodeIds).toEqual(expect.arrayContaining([
|
||||
"input.country",
|
||||
"input.state",
|
||||
"input.city",
|
||||
"input.holidayContext",
|
||||
"input.holidayExamples",
|
||||
"sah.baseHours",
|
||||
"sah.publicHolidayHours",
|
||||
"sah.absenceHours",
|
||||
]));
|
||||
expect(meta).toMatchObject({
|
||||
countryCode: "DE",
|
||||
countryName: "Deutschland",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
});
|
||||
expect(meta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
date: "2026-08-08",
|
||||
name: "Augsburger Friedensfest",
|
||||
scope: "CITY",
|
||||
}),
|
||||
]));
|
||||
expect(meta.factors.publicHolidayCount).toBeGreaterThan(0);
|
||||
expect(meta.factors.publicHolidayWorkdayCount).toBe(0);
|
||||
expect(holidayExamples?.value).toEqual(expect.stringContaining("Augsburger Friedensfest"));
|
||||
});
|
||||
|
||||
it("derives different effective SAH values for Bavaria and Hamburg", async () => {
|
||||
const db = createDb(vi.fn()
|
||||
.mockResolvedValueOnce(buildResource({
|
||||
id: "resource_by",
|
||||
federalState: "BY",
|
||||
managementLevelGroup: null,
|
||||
}))
|
||||
.mockResolvedValueOnce(buildResource({
|
||||
id: "resource_hh",
|
||||
federalState: "HH",
|
||||
managementLevelGroup: null,
|
||||
})));
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const bavaria = await caller.getResourceData({
|
||||
resourceId: "resource_by",
|
||||
month: "2026-01",
|
||||
});
|
||||
const hamburg = await caller.getResourceData({
|
||||
resourceId: "resource_hh",
|
||||
month: "2026-01",
|
||||
});
|
||||
|
||||
const bavariaMeta = bavaria.meta as ResourceGraphMeta;
|
||||
const hamburgMeta = hamburg.meta as ResourceGraphMeta;
|
||||
|
||||
expect(bavariaMeta.federalState).toBe("BY");
|
||||
expect(hamburgMeta.federalState).toBe("HH");
|
||||
expect(bavariaMeta.factors.baseAvailableHours).toBe(176);
|
||||
expect(hamburgMeta.factors.baseAvailableHours).toBe(176);
|
||||
expect(bavariaMeta.factors.effectiveAvailableHours).toBe(160);
|
||||
expect(hamburgMeta.factors.effectiveAvailableHours).toBe(168);
|
||||
expect(bavariaMeta.factors.publicHolidayWorkdayCount).toBe(2);
|
||||
expect(hamburgMeta.factors.publicHolidayWorkdayCount).toBe(1);
|
||||
expect(bavariaMeta.factors.publicHolidayHoursDeduction).toBe(16);
|
||||
expect(hamburgMeta.factors.publicHolidayHoursDeduction).toBe(8);
|
||||
expect(bavariaMeta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
|
||||
]));
|
||||
expect(hamburgMeta.resolvedHolidays).not.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
getDashboardDemand: vi.fn(),
|
||||
getDashboardTopValueResources: vi.fn(),
|
||||
getDashboardChargeabilityOverview: vi.fn(),
|
||||
getDashboardBudgetForecast: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
getDashboardDemand,
|
||||
getDashboardTopValueResources,
|
||||
getDashboardChargeabilityOverview,
|
||||
getDashboardBudgetForecast,
|
||||
} from "@capakraken/application";
|
||||
import { dashboardRouter } from "../router/dashboard.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
@@ -302,4 +304,52 @@ describe("dashboard router", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBudgetForecast", () => {
|
||||
it("returns budget forecast rows with calendar location context", async () => {
|
||||
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||
{
|
||||
projectId: "project_1",
|
||||
projectName: "Alpha",
|
||||
shortCode: "ALPHA",
|
||||
clientId: "client_1",
|
||||
clientName: "Client One",
|
||||
budgetCents: 100_000,
|
||||
spentCents: 40_000,
|
||||
remainingCents: 60_000,
|
||||
burnRate: 10_000,
|
||||
estimatedExhaustionDate: "2026-06-30",
|
||||
pctUsed: 40,
|
||||
activeAssignmentCount: 2,
|
||||
calendarLocations: [
|
||||
{
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Munich",
|
||||
activeAssignmentCount: 2,
|
||||
burnRateCents: 10_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller({});
|
||||
const result = await caller.getBudgetForecast();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
projectName: "Alpha",
|
||||
activeAssignmentCount: 2,
|
||||
calendarLocations: [
|
||||
expect.objectContaining({
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
metroCityName: "Munich",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,7 @@ describe("effortRule.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -180,6 +181,7 @@ describe("effortRule.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -212,6 +214,7 @@ describe("effortRule.update", () => {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -236,6 +239,7 @@ describe("effortRule.update", () => {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -281,6 +285,7 @@ describe("effortRule.delete", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
delete: vi.fn().mockResolvedValue(existing),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -16,7 +16,17 @@ const createCaller = createCallerFactory(entitlementRouter);
|
||||
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
const withResourceOwnership = {
|
||||
resource: { findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }) },
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||
const select = args?.select ?? {};
|
||||
return {
|
||||
...(select.userId ? { userId: "user_1" } : {}),
|
||||
...(select.federalState ? { federalState: "BY" } : {}),
|
||||
...(select.country ? { country: { code: "DE" } } : {}),
|
||||
...(select.metroCity ? { metroCity: null } : {}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
...db,
|
||||
};
|
||||
return createCaller({
|
||||
@@ -80,6 +90,14 @@ function sampleEntitlement(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function mockEntitlementFindUniqueByYear(
|
||||
entitlementsByYear: Record<number, ReturnType<typeof sampleEntitlement> | null>,
|
||||
) {
|
||||
return vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
|
||||
entitlementsByYear[where.resourceId_year.year] ?? null
|
||||
));
|
||||
}
|
||||
|
||||
// ─── getBalance ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("entitlement.getBalance", () => {
|
||||
@@ -90,7 +108,7 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
@@ -129,10 +147,9 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null) // current year not found
|
||||
.mockResolvedValueOnce(prevEntitlement), // previous year found
|
||||
findUnique: mockEntitlementFindUniqueByYear({
|
||||
2025: prevEntitlement,
|
||||
}),
|
||||
create: vi.fn().mockResolvedValue(createdEntitlement),
|
||||
update: vi.fn().mockResolvedValue(createdEntitlement),
|
||||
},
|
||||
@@ -164,7 +181,7 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
@@ -185,12 +202,14 @@ describe("entitlement.getBalance", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
// Public holiday vacations for holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// First call: balance-type vacations (for syncEntitlement)
|
||||
.mockResolvedValueOnce([])
|
||||
// Second call: sick days
|
||||
@@ -209,19 +228,169 @@ describe("entitlement.getBalance", () => {
|
||||
|
||||
expect(result.sickDays).toBe(3);
|
||||
});
|
||||
|
||||
it("does not deduct city-specific public holidays from leave balance", async () => {
|
||||
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
}),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
|
||||
update: vi.fn().mockImplementation(async ({ data }) => ({
|
||||
...entitlement,
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
endDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
status: "APPROVED",
|
||||
isHalfDay: false,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
|
||||
|
||||
expect(result.usedDays).toBe(0);
|
||||
expect(result.remainingDays).toBe(30);
|
||||
});
|
||||
|
||||
it("recomputes carryover from the previous year when the next year already exists", async () => {
|
||||
const entitlements = new Map([
|
||||
[2025, sampleEntitlement({
|
||||
id: "ent_2025",
|
||||
year: 2025,
|
||||
entitledDays: 28,
|
||||
carryoverDays: 0,
|
||||
usedDays: 8,
|
||||
pendingDays: 0,
|
||||
})],
|
||||
[2026, sampleEntitlement({
|
||||
id: "ent_2026",
|
||||
year: 2026,
|
||||
entitledDays: 28,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
})],
|
||||
]);
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
|
||||
entitlements.get(where.resourceId_year.year) ?? null
|
||||
)),
|
||||
create: vi.fn(),
|
||||
update: vi.fn().mockImplementation(async ({ where, data }: {
|
||||
where: { id: string };
|
||||
data: Record<string, number>;
|
||||
}) => {
|
||||
const current = [...entitlements.values()].find((entry) => entry.id === where.id);
|
||||
if (!current) {
|
||||
throw new Error(`Unknown entitlement ${where.id}`);
|
||||
}
|
||||
const updated = { ...current, ...data };
|
||||
entitlements.set(updated.year, updated);
|
||||
return updated;
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi
|
||||
.fn()
|
||||
// 2025 holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// 2025 balance vacations
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: new Date("2025-06-10T00:00:00.000Z"),
|
||||
endDate: new Date("2025-06-17T00:00:00.000Z"),
|
||||
status: "APPROVED",
|
||||
isHalfDay: false,
|
||||
},
|
||||
])
|
||||
// 2026 holiday context
|
||||
.mockResolvedValueOnce([])
|
||||
// 2026 balance vacations
|
||||
.mockResolvedValueOnce([])
|
||||
// 2026 sick days
|
||||
.mockResolvedValueOnce([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
|
||||
|
||||
expect(result.carryoverDays).toBe(20);
|
||||
expect(result.entitledDays).toBe(48);
|
||||
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "ent_2026" },
|
||||
data: expect.objectContaining({
|
||||
carryoverDays: 20,
|
||||
entitledDays: 48,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("entitlement.get", () => {
|
||||
it("returns existing entitlement (manager role)", async () => {
|
||||
const entitlement = sampleEntitlement();
|
||||
const entitlement = sampleEntitlement({
|
||||
entitledDays: 30,
|
||||
carryoverDays: 0,
|
||||
usedDays: 0,
|
||||
pendingDays: 0,
|
||||
});
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 30 }),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, number> }) => ({
|
||||
...entitlement,
|
||||
...data,
|
||||
})),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -259,6 +428,7 @@ describe("entitlement.set", () => {
|
||||
update: vi.fn().mockResolvedValue(updated),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -286,6 +456,7 @@ describe("entitlement.set", () => {
|
||||
update: vi.fn(),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -324,6 +495,7 @@ describe("entitlement.bulkSet", () => {
|
||||
vacationEntitlement: {
|
||||
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
@@ -350,6 +522,7 @@ describe("entitlement.bulkSet", () => {
|
||||
vacationEntitlement: {
|
||||
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
@@ -396,10 +569,15 @@ describe("entitlement.getYearSummary", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
federalState: "BY",
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue(resources),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
|
||||
@@ -24,10 +24,12 @@ vi.mock("ioredis", () => {
|
||||
describe("event-bus debounce", () => {
|
||||
let received: SseEvent[];
|
||||
let unsubscribe: () => void;
|
||||
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
received = [];
|
||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
unsubscribe = eventBus.subscribe((event) => {
|
||||
received.push(event);
|
||||
});
|
||||
@@ -36,6 +38,7 @@ describe("event-bus debounce", () => {
|
||||
afterEach(() => {
|
||||
unsubscribe();
|
||||
cancelPendingEvents();
|
||||
consoleWarnSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ describe("experienceMultiplier.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -203,6 +204,7 @@ describe("experienceMultiplier.create", () => {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -235,6 +237,7 @@ describe("experienceMultiplier.update", () => {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -259,6 +262,7 @@ describe("experienceMultiplier.update", () => {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
@@ -308,6 +312,7 @@ describe("experienceMultiplier.delete", () => {
|
||||
findUnique: vi.fn().mockResolvedValue(existing),
|
||||
delete: vi.fn().mockResolvedValue(existing),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
import { holidayCalendarRouter } from "../router/holiday-calendar.js";
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(holidayCalendarRouter);
|
||||
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "user@example.com", name: "User", image: null },
|
||||
expires: "2026-12-31T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_1",
|
||||
systemRole: SystemRole.USER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createAdminCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "admin@example.com", name: "Admin", image: null },
|
||||
expires: "2026-12-31T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "admin_1",
|
||||
systemRole: SystemRole.ADMIN,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("holiday calendar router", () => {
|
||||
it("merges built-in and scoped custom holidays in preview", async () => {
|
||||
const db = {
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_city",
|
||||
name: "Augsburg lokal",
|
||||
scopeType: "CITY",
|
||||
priority: 10,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
date: new Date("2020-01-01T00:00:00.000Z"),
|
||||
name: "Augsburg Neujahr",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
{
|
||||
date: new Date("2020-08-08T00:00:00.000Z"),
|
||||
name: "Friedensfest lokal",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewResolvedHolidays({
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_augsburg",
|
||||
year: 2026,
|
||||
});
|
||||
|
||||
expect(db.holidayCalendar.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
countryId: "country_de",
|
||||
isActive: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
date: "2026-01-01",
|
||||
name: "Augsburg Neujahr",
|
||||
scopeType: "CITY",
|
||||
calendarName: "Augsburg lokal",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
date: "2026-08-08",
|
||||
name: "Friedensfest lokal",
|
||||
scopeType: "CITY",
|
||||
calendarName: "Augsburg lokal",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate calendar scopes on create", async () => {
|
||||
const db = {
|
||||
country: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" })
|
||||
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" }),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "existing_scope" }),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.createCalendar({
|
||||
name: "Deutschland Standard",
|
||||
scopeType: "COUNTRY",
|
||||
countryId: "country_de",
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
})).rejects.toThrow("A holiday calendar for this exact scope already exists");
|
||||
|
||||
expect(db.holidayCalendar.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects duplicate entry dates within the same calendar", async () => {
|
||||
const db = {
|
||||
holidayCalendar: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "cal_1", name: "Deutschland Standard" }),
|
||||
},
|
||||
holidayCalendarEntry: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }),
|
||||
create: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.createEntry({
|
||||
holidayCalendarId: "cal_1",
|
||||
date: new Date("2026-12-24T00:00:00.000Z"),
|
||||
name: "Heiligabend lokal",
|
||||
isRecurringAnnual: true,
|
||||
source: "manual",
|
||||
})).rejects.toThrow("A holiday entry for this calendar and date already exists");
|
||||
|
||||
expect(db.holidayCalendarEntry.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -187,6 +187,7 @@ describe("notification.create", () => {
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
findUnique: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
@@ -209,6 +210,7 @@ describe("notification.create", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(db.notification.findUnique).toHaveBeenCalledWith({ where: { id: "notif_1" } });
|
||||
});
|
||||
|
||||
it("creates a notification with optional fields", async () => {
|
||||
@@ -222,6 +224,7 @@ describe("notification.create", () => {
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
findUnique: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
|
||||
@@ -134,12 +134,14 @@ describe("project router", () => {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
shortCode: "PRJ-001",
|
||||
name: "Test Project",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
winProbability: 80,
|
||||
@@ -167,6 +169,7 @@ describe("project router", () => {
|
||||
caller.create({
|
||||
shortCode: "PRJ-001",
|
||||
name: "Duplicate",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
budgetCents: 100_00,
|
||||
@@ -189,6 +192,7 @@ describe("project router", () => {
|
||||
caller.create({
|
||||
shortCode: "PRJ-002",
|
||||
name: "Blocked",
|
||||
responsiblePerson: "Alice",
|
||||
orderType: OrderType.CHARGEABLE,
|
||||
allocationType: AllocationType.INT,
|
||||
budgetCents: 100_00,
|
||||
@@ -239,6 +243,64 @@ describe("project router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getShoringRatio", () => {
|
||||
it("excludes regional holidays from shoring weighting", async () => {
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "project_1",
|
||||
name: "Test Project",
|
||||
shoringThreshold: 55,
|
||||
onshoreCountryCode: "DE",
|
||||
}),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
resourceId: "res_de",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
resource: {
|
||||
id: "res_de",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||
country: { id: "country_de", code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a2",
|
||||
resourceId: "res_es",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
resource: {
|
||||
id: "res_es",
|
||||
countryId: "country_es",
|
||||
federalState: null,
|
||||
metroCityId: null,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||
country: { id: "country_es", code: "ES" },
|
||||
metroCity: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getShoringRatio({ projectId: "project_1" });
|
||||
|
||||
expect(result.totalHours).toBe(24);
|
||||
expect(result.onshoreRatio).toBe(33);
|
||||
expect(result.offshoreRatio).toBe(67);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("update", () => {
|
||||
@@ -294,6 +356,7 @@ describe("project router", () => {
|
||||
project: {
|
||||
update: vi.fn().mockResolvedValue(updated),
|
||||
},
|
||||
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@capakraken/application", () => ({
|
||||
isChargeabilityActualBooking: vi.fn(() => false),
|
||||
isChargeabilityRelevantProject: vi.fn(() => false),
|
||||
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/resource-capacity.js", () => ({
|
||||
calculateEffectiveAvailableHours: vi.fn(({ context }: { context?: unknown }) => (context ? 156 : 168)),
|
||||
calculateEffectiveBookedHours: vi.fn(() => 0),
|
||||
countEffectiveWorkingDays: vi.fn(({ context }: { context?: unknown }) => (context ? 19.5 : 21)),
|
||||
getAvailabilityHoursForDate: vi.fn(() => 8),
|
||||
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map([
|
||||
[
|
||||
"res_1",
|
||||
{
|
||||
holidayDates: new Set(["2026-04-10"]),
|
||||
vacationFractionsByDate: new Map([["2026-04-14", 0.5]]),
|
||||
},
|
||||
],
|
||||
])),
|
||||
}));
|
||||
|
||||
import { reportRouter } from "../router/report.js";
|
||||
import { createCallerFactory } from "../trpc.js";
|
||||
|
||||
const createCaller = createCallerFactory(reportRouter);
|
||||
|
||||
function createControllerCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||
expires: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_controller",
|
||||
systemRole: SystemRole.CONTROLLER,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("report router", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists the new resource month transparency columns", async () => {
|
||||
const caller = createControllerCaller({});
|
||||
const columns = await caller.getAvailableColumns({ entity: "resource_month" });
|
||||
|
||||
expect(columns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "monthlyPublicHolidayCount", label: "Holiday Dates" }),
|
||||
expect.objectContaining({ key: "monthlyTargetHours", label: "Target Hours" }),
|
||||
expect.objectContaining({ key: "monthlyUnassignedHours", label: "Unassigned Hours" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("exports resource month basis and computed columns in CSV", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "res_1",
|
||||
eid: "alice",
|
||||
displayName: "Alice",
|
||||
email: "alice@example.com",
|
||||
chapter: "VFX",
|
||||
resourceType: "EMPLOYEE",
|
||||
isActive: true,
|
||||
chgResponsibility: false,
|
||||
rolledOff: false,
|
||||
departed: false,
|
||||
lcrCents: 7500,
|
||||
ucrCents: 10000,
|
||||
currency: "EUR",
|
||||
fte: 1,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
chargeabilityTarget: 80,
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: null,
|
||||
orgUnit: { name: "Delivery" },
|
||||
managementLevelGroup: null,
|
||||
managementLevel: { name: "Senior" },
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.exportReport({
|
||||
entity: "resource_month",
|
||||
columns: [
|
||||
"displayName",
|
||||
"countryCode",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyTargetHours",
|
||||
"monthlyUnassignedHours",
|
||||
],
|
||||
filters: [],
|
||||
periodMonth: "2026-04",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(result.rowCount).toBe(1);
|
||||
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
|
||||
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,10 @@ describe("resource router", () => {
|
||||
valueScoreBreakdown: null,
|
||||
valueScoreUpdatedAt: null,
|
||||
userId: null,
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -158,6 +162,165 @@ describe("resource router", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates utilization with regional holidays removed from available hours", async () => {
|
||||
const resource = {
|
||||
id: "resource_1",
|
||||
eid: "E-001",
|
||||
displayName: "Alice",
|
||||
email: "alice@example.com",
|
||||
chapter: "CGI",
|
||||
lcrCents: 5000,
|
||||
ucrCents: 9000,
|
||||
currency: "EUR",
|
||||
chargeabilityTarget: 80,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
skills: [],
|
||||
dynamicFields: {},
|
||||
blueprintId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-03-01"),
|
||||
updatedAt: new Date("2026-03-01"),
|
||||
roleId: null,
|
||||
portfolioUrl: null,
|
||||
postalCode: null,
|
||||
federalState: "BY",
|
||||
countryId: "country_de",
|
||||
metroCityId: null,
|
||||
valueScore: null,
|
||||
valueScoreBreakdown: null,
|
||||
valueScoreUpdatedAt: null,
|
||||
userId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([resource]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_confirmed",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project 1",
|
||||
shortCode: "P1",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.listWithUtilization({
|
||||
startDate: "2026-01-05T00:00:00.000Z",
|
||||
endDate: "2026-01-06T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
bookingCount: 1,
|
||||
bookedHours: 8,
|
||||
availableHours: 8,
|
||||
utilizationPercent: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("shifts marketplace availability when a local holiday blocks today", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-06T10:00:00.000Z"));
|
||||
|
||||
try {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
displayName: "Bavaria Artist",
|
||||
eid: "E-BY",
|
||||
chapter: "CGI",
|
||||
skills: [{ skill: "Houdini", proficiency: 5 }],
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
{
|
||||
id: "resource_hh",
|
||||
displayName: "Hamburg Artist",
|
||||
eid: "E-HH",
|
||||
chapter: "CGI",
|
||||
skills: [{ skill: "Houdini", proficiency: 5 }],
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getSkillMarketplace({
|
||||
searchSkill: "houdini",
|
||||
availableOnly: true,
|
||||
});
|
||||
|
||||
const bavaria = result.searchResults.find((resource) => resource.id === "resource_by");
|
||||
const hamburg = result.searchResults.find((resource) => resource.id === "resource_hh");
|
||||
|
||||
expect(bavaria?.availableFrom).toBe("2026-01-07T00:00:00.000Z");
|
||||
expect(hamburg?.availableFrom).toBe("2026-01-06T00:00:00.000Z");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses a composite displayName/id cursor for stable pagination", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -314,6 +477,84 @@ describe("resource router", () => {
|
||||
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
|
||||
});
|
||||
|
||||
it("excludes regional public holidays from chargeability stats", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
eid: "E-BY",
|
||||
displayName: "Bavaria",
|
||||
chapter: "CGI",
|
||||
chargeabilityTarget: 80,
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
metroCity: { name: "Munich" },
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "assignment_holiday",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_by",
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project 1",
|
||||
shortCode: "P1",
|
||||
status: "ACTIVE",
|
||||
orderType: "CLIENT",
|
||||
dynamicFields: null,
|
||||
},
|
||||
resource: { id: "resource_by", displayName: "Bavaria", chapter: "CGI" },
|
||||
},
|
||||
]);
|
||||
|
||||
const RealDate = Date;
|
||||
class MockDate extends Date {
|
||||
constructor(...args: ConstructorParameters<typeof Date>) {
|
||||
if (args.length === 0) {
|
||||
super("2026-01-15T00:00:00.000Z");
|
||||
return;
|
||||
}
|
||||
super(...args);
|
||||
}
|
||||
static now() {
|
||||
return new RealDate("2026-01-15T00:00:00.000Z").getTime();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Date", MockDate);
|
||||
|
||||
try {
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getChargeabilityStats({});
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
actualChargeability: 0,
|
||||
expectedChargeability: 0,
|
||||
availableHours: 168,
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies country filters including explicit no-country toggle", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
|
||||
@@ -17,23 +17,6 @@ vi.mock("@capakraken/staffing", () => ({
|
||||
},
|
||||
})),
|
||||
),
|
||||
analyzeUtilization: vi.fn().mockReturnValue({
|
||||
resourceId: "res_1",
|
||||
displayName: "Alice",
|
||||
totalDays: 20,
|
||||
allocatedDays: 15,
|
||||
utilizationPercent: 75,
|
||||
chargeablePercent: 60,
|
||||
overallocatedDays: 0,
|
||||
dailyBreakdown: [],
|
||||
}),
|
||||
findCapacityWindows: vi.fn().mockReturnValue([
|
||||
{
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-10"),
|
||||
availableHoursPerDay: 6,
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@capakraken/application", () => ({
|
||||
@@ -76,6 +59,11 @@ function sampleResource(overrides: Record<string, unknown> = {}) {
|
||||
isActive: true,
|
||||
valueScore: 85,
|
||||
chapter: "VFX",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -105,6 +93,30 @@ describe("staffing.getSuggestions", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty("resourceId");
|
||||
expect(result[0]).toHaveProperty("score");
|
||||
expect(result[0]).toMatchObject({
|
||||
resourceName: "Alice",
|
||||
eid: "alice",
|
||||
location: {
|
||||
countryCode: "DE",
|
||||
federalState: "BY",
|
||||
},
|
||||
capacity: expect.objectContaining({
|
||||
requestedHoursPerDay: 8,
|
||||
baseAvailableHours: expect.any(Number),
|
||||
effectiveAvailableHours: expect.any(Number),
|
||||
remainingHoursPerDay: expect.any(Number),
|
||||
holidayHoursDeduction: expect.any(Number),
|
||||
}),
|
||||
conflicts: {
|
||||
count: expect.any(Number),
|
||||
conflictDays: expect.any(Array),
|
||||
details: expect.any(Array),
|
||||
},
|
||||
ranking: expect.objectContaining({
|
||||
rank: 1,
|
||||
components: expect.any(Array),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("filters resources by chapter when provided", async () => {
|
||||
@@ -175,6 +187,58 @@ describe("staffing.getSuggestions", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses value score as a transparent tiebreaker within two score points", async () => {
|
||||
const resources = [
|
||||
sampleResource({ id: "res_1", displayName: "Alice", eid: "alice", valueScore: 60 }),
|
||||
sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 95 }),
|
||||
];
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue(resources),
|
||||
},
|
||||
};
|
||||
|
||||
const { rankResources } = await import("@capakraken/staffing");
|
||||
vi.mocked(rankResources).mockImplementationOnce((input: { resources: Array<{ id: string }> }) => ([
|
||||
{
|
||||
resourceId: input.resources[0]!.id,
|
||||
score: 80,
|
||||
breakdown: {
|
||||
skillScore: 80,
|
||||
availabilityScore: 80,
|
||||
costScore: 80,
|
||||
utilizationScore: 80,
|
||||
},
|
||||
},
|
||||
{
|
||||
resourceId: input.resources[1]!.id,
|
||||
score: 79,
|
||||
breakdown: {
|
||||
skillScore: 79,
|
||||
availabilityScore: 79,
|
||||
costScore: 79,
|
||||
utilizationScore: 79,
|
||||
},
|
||||
},
|
||||
]));
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getSuggestions({
|
||||
requiredSkills: ["Compositing"],
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
hoursPerDay: 8,
|
||||
});
|
||||
|
||||
expect(result[0]?.resourceId).toBe("res_2");
|
||||
expect(result[0]?.ranking).toMatchObject({
|
||||
rank: 1,
|
||||
baseRank: 2,
|
||||
tieBreakerApplied: true,
|
||||
});
|
||||
expect(result[0]?.ranking.tieBreakerReason).toContain("value score");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── analyzeUtilization ──────────────────────────────────────────────────────
|
||||
@@ -186,6 +250,11 @@ describe("staffing.analyzeUtilization", () => {
|
||||
displayName: "Alice",
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -200,10 +269,56 @@ describe("staffing.analyzeUtilization", () => {
|
||||
endDate: new Date("2026-04-30"),
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("utilizationPercent");
|
||||
expect(result).toHaveProperty("currentChargeability");
|
||||
expect(result.resourceId).toBe("res_1");
|
||||
});
|
||||
|
||||
it("excludes Bavarian public holidays from chargeability analysis", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
chargeabilityTarget: 80,
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(resource),
|
||||
},
|
||||
};
|
||||
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
hoursPerDay: 8,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: { id: "project_1", name: "Chargeable", shortCode: "CHG", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.analyzeUtilization({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.currentChargeability).toBe(100);
|
||||
expect(result.overallocatedDays).toEqual([]);
|
||||
expect(result.underutilizedDays).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -230,6 +345,11 @@ describe("staffing.findCapacity", () => {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -244,8 +364,53 @@ describe("staffing.findCapacity", () => {
|
||||
endDate: new Date("2026-04-30"),
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0]).toHaveProperty("availableHoursPerDay");
|
||||
expect(result.every((window) => window.availableHoursPerDay > 0)).toBe(true);
|
||||
expect(result.reduce((sum, window) => sum + window.availableDays, 0)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("splits capacity windows around Bavarian public holidays", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(resource),
|
||||
},
|
||||
};
|
||||
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.findCapacity({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
minAvailableHoursPerDay: 4,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||
@@ -265,11 +430,16 @@ describe("staffing.findCapacity", () => {
|
||||
).rejects.toThrow("Resource not found");
|
||||
});
|
||||
|
||||
it("passes minAvailableHoursPerDay to engine", async () => {
|
||||
it("honors minAvailableHoursPerDay when computing holiday-aware windows", async () => {
|
||||
const resource = {
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
};
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -277,21 +447,30 @@ describe("staffing.findCapacity", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const { findCapacityWindows } = await import("@capakraken/staffing");
|
||||
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||
{
|
||||
id: "a1",
|
||||
projectId: "project_1",
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||
hoursPerDay: 3,
|
||||
dailyCostCents: 0,
|
||||
status: "CONFIRMED",
|
||||
project: { id: "project_1", name: "Project", shortCode: "PRJ", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
|
||||
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
|
||||
},
|
||||
]);
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await caller.findCapacity({
|
||||
const result = await caller.findCapacity({
|
||||
resourceId: "res_1",
|
||||
startDate: new Date("2026-04-01"),
|
||||
endDate: new Date("2026-04-30"),
|
||||
minAvailableHoursPerDay: 6,
|
||||
});
|
||||
|
||||
expect(findCapacityWindows).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.any(Date),
|
||||
expect.any(Date),
|
||||
6,
|
||||
);
|
||||
expect(result.every((window) => window.availableHoursPerDay >= 6)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,4 +290,83 @@ describe("timeline allocation entry resolution", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns resolved holiday overlays for assigned resources", async () => {
|
||||
const db = {
|
||||
demandRequirement: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "assignment_1",
|
||||
kind: "assignment",
|
||||
resourceId: "resource_by",
|
||||
projectId: "project_1",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-01-31"),
|
||||
hoursPerDay: 8,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
metadata: {},
|
||||
project: {
|
||||
id: "project_1",
|
||||
name: "Project One",
|
||||
shortCode: "PRJ",
|
||||
status: "ACTIVE",
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-03-31"),
|
||||
orderType: "CHARGEABLE",
|
||||
clientId: null,
|
||||
},
|
||||
resource: {
|
||||
id: "resource_by",
|
||||
displayName: "Alice",
|
||||
eid: "E-001",
|
||||
chapter: null,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "resource_by",
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: null,
|
||||
country: { code: "DE" },
|
||||
metroCity: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
project: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
country: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
metroCity: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const overlays = await caller.getHolidayOverlays({
|
||||
startDate: new Date("2026-01-01"),
|
||||
endDate: new Date("2026-01-31"),
|
||||
});
|
||||
|
||||
expect(overlays).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: "resource_by",
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
note: "Heilige Drei Könige",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,30 @@ vi.mock("../sse/event-bus.js", () => ({
|
||||
emitVacationUpdated: vi.fn(),
|
||||
emitVacationDeleted: vi.fn(),
|
||||
emitNotificationCreated: vi.fn(),
|
||||
emitTaskAssigned: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/email.js", () => ({
|
||||
sendEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/create-notification.js", () => ({
|
||||
createNotification: vi.fn().mockResolvedValue("notif_1"),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/vacation-conflicts.js", () => ({
|
||||
checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
|
||||
checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../lib/audit.js", () => ({
|
||||
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const createCaller = createCallerFactory(vacationRouter);
|
||||
|
||||
function createProtectedCaller(db: Record<string, unknown>) {
|
||||
@@ -91,6 +109,56 @@ const sampleVacation = {
|
||||
approvedBy: null,
|
||||
};
|
||||
|
||||
function createVacationDb(overrides: Record<string, unknown> = {}) {
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||
const select = args?.select ?? {};
|
||||
return {
|
||||
...(select.userId ? { userId: "user_1" } : {}),
|
||||
...(select.displayName ? { displayName: "Alice" } : {}),
|
||||
...(select.user ? { user: null } : {}),
|
||||
...(select.federalState ? { federalState: "BY" } : {}),
|
||||
...(select.country ? { country: { code: "DE", name: "Germany" } } : {}),
|
||||
...(select.metroCity ? { metroCity: null } : {}),
|
||||
};
|
||||
}),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(sampleVacation),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
notification: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...db,
|
||||
...overrides,
|
||||
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||
resource: { ...db.resource, ...(overrides.resource as Record<string, unknown> | undefined) },
|
||||
vacation: { ...db.vacation, ...(overrides.vacation as Record<string, unknown> | undefined) },
|
||||
notification: {
|
||||
...db.notification,
|
||||
...(overrides.notification as Record<string, unknown> | undefined),
|
||||
},
|
||||
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||
};
|
||||
}
|
||||
|
||||
describe("vacation router", () => {
|
||||
describe("list", () => {
|
||||
it("returns vacations with default filters", async () => {
|
||||
@@ -199,18 +267,11 @@ describe("vacation router", () => {
|
||||
status: VacationStatus.PENDING,
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -239,15 +300,14 @@ describe("vacation router", () => {
|
||||
approvedById: "mgr_1",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -269,17 +329,11 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("rejects overlapping vacation", async () => {
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
@@ -293,10 +347,10 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("rejects when end date is before start date", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: { findUnique: vi.fn() },
|
||||
vacation: { findFirst: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(
|
||||
@@ -316,18 +370,11 @@ describe("vacation router", () => {
|
||||
halfDayPart: "MORNING",
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.create({
|
||||
@@ -349,6 +396,235 @@ describe("vacation router", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects multi-day half-day vacations", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-02"),
|
||||
isHalfDay: true,
|
||||
halfDayPart: "MORNING",
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects half-day vacations without a half-day part", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
isHalfDay: true,
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects half-day parts on full-day vacations", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-01"),
|
||||
halfDayPart: "AFTERNOON",
|
||||
})).rejects.toThrow();
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects leave requests that only hit public holidays", async () => {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||
})).rejects.toThrow("does not deduct any vacation days");
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewRequest", () => {
|
||||
it("shows public holidays as non-deductible leave days", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Augsburg" },
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
endDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.requestedDays).toBe(1);
|
||||
expect(result.effectiveDays).toBe(0);
|
||||
expect(result.deductedDays).toBe(0);
|
||||
expect(result.publicHolidayDates).toContain("2028-08-08");
|
||||
expect(result.holidayContext).toEqual({
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Augsburg",
|
||||
sources: {
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
});
|
||||
expect(result.holidayDetails).toContainEqual({
|
||||
date: "2028-08-08",
|
||||
source: "CALENDAR",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses custom city holiday calendars for non-deductible leave days", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_muc",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
}),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_muc",
|
||||
name: "Muenchen lokal",
|
||||
scopeType: "CITY",
|
||||
priority: 10,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
date: new Date("2020-11-15T00:00:00.000Z"),
|
||||
name: "Lokaler Stadtfeiertag",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.requestedDays).toBe(1);
|
||||
expect(result.effectiveDays).toBe(0);
|
||||
expect(result.publicHolidayDates).toContain("2026-11-15");
|
||||
expect(result.holidayContext.countryName).toBe("Germany");
|
||||
expect(result.holidayContext.metroCityName).toBe("Muenchen");
|
||||
expect(db.holidayCalendar.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks legacy public holiday entries as a separate preview source", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
federalState: "HH",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.publicHolidayDates).toContain("2026-05-01");
|
||||
expect(result.holidayContext.sources).toEqual({
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: true,
|
||||
});
|
||||
expect(result.holidayDetails).toContainEqual({
|
||||
date: "2026-05-01",
|
||||
source: "CALENDAR_AND_LEGACY",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects multi-day half-day previews", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.previewRequest({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01"),
|
||||
endDate: new Date("2026-06-02"),
|
||||
isHalfDay: true,
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("create manual public holiday handling", () => {
|
||||
it("rejects manual public holiday creation requests", async () => {
|
||||
const db = createVacationDb();
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
})).rejects.toThrow("Public holidays must be managed via Holiday Calendars or the legacy holiday import");
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("approve", () => {
|
||||
@@ -359,7 +635,7 @@ describe("vacation router", () => {
|
||||
approvedById: "mgr_1",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
@@ -370,7 +646,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.approve({ id: "vac_1" });
|
||||
@@ -388,25 +664,25 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for missing vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
|
||||
});
|
||||
|
||||
it("rejects approving an already APPROVED vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
|
||||
@@ -429,7 +705,7 @@ describe("vacation router", () => {
|
||||
rejectionReason: "Team conflict",
|
||||
};
|
||||
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
@@ -437,7 +713,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
|
||||
@@ -454,14 +730,14 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws when rejecting non-PENDING vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.APPROVED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
|
||||
@@ -477,15 +753,12 @@ describe("vacation router", () => {
|
||||
status: VacationStatus.CANCELLED,
|
||||
};
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||
},
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.cancel({ id: "vac_1" });
|
||||
@@ -494,25 +767,25 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND for missing vacation", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
|
||||
});
|
||||
|
||||
it("throws when already cancelled", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
status: VacationStatus.CANCELLED,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
|
||||
@@ -521,7 +794,7 @@ describe("vacation router", () => {
|
||||
|
||||
describe("batchApprove", () => {
|
||||
it("approves multiple pending vacations", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
@@ -535,7 +808,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
|
||||
@@ -552,7 +825,7 @@ describe("vacation router", () => {
|
||||
});
|
||||
|
||||
it("only approves PENDING vacations from the requested set", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
@@ -565,7 +838,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
|
||||
@@ -581,7 +854,10 @@ describe("vacation router", () => {
|
||||
|
||||
describe("batchReject", () => {
|
||||
it("rejects multiple pending vacations with optional reason", async () => {
|
||||
const db = {
|
||||
const db = createVacationDb({
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "vac_1", resourceId: "res_1" },
|
||||
@@ -591,7 +867,7 @@ describe("vacation router", () => {
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.batchReject({
|
||||
@@ -731,8 +1007,8 @@ describe("vacation router", () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "res_1" },
|
||||
{ id: "res_2" },
|
||||
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
{ id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
]),
|
||||
},
|
||||
user: {
|
||||
@@ -759,7 +1035,9 @@ describe("vacation router", () => {
|
||||
it("skips already existing holidays", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||
]),
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { rankResources } from "@capakraken/staffing";
|
||||
import type { SkillEntry } from "@capakraken/shared";
|
||||
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "./resource-capacity.js";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
|
||||
/**
|
||||
@@ -58,6 +63,11 @@ type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
|
||||
chargeabilityTarget: number;
|
||||
availability: unknown;
|
||||
valueScore: number | null;
|
||||
countryId: string | null;
|
||||
federalState: string | null;
|
||||
metroCityId: string | null;
|
||||
country: { code: string | null } | null;
|
||||
metroCity: { name: string | null } | null;
|
||||
}>>;
|
||||
};
|
||||
notification: {
|
||||
@@ -154,27 +164,54 @@ export async function generateAutoSuggestions(
|
||||
endDate: demand.endDate,
|
||||
resourceIds: resources.map((r) => r.id),
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
demand.startDate,
|
||||
demand.endDate,
|
||||
);
|
||||
|
||||
// 5. Enrich resources with utilization data for the demand's date range
|
||||
const enrichedResources = resources.map((resource) => {
|
||||
const avail = resource.availability as
|
||||
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
|
||||
| null;
|
||||
const totalAvailableHours = avail?.monday ?? 8;
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
|
||||
|
||||
const allocatedHoursPerDay = resourceBookings.reduce(
|
||||
(sum, b) => sum + b.hoursPerDay,
|
||||
const totalAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: demand.startDate,
|
||||
periodEnd: demand.endDate,
|
||||
context,
|
||||
});
|
||||
const allocatedHours = resourceBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: demand.startDate,
|
||||
periodEnd: demand.endDate,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
const utilizationPercent =
|
||||
totalAvailableHours > 0
|
||||
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
|
||||
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
|
||||
: 0;
|
||||
|
||||
const wouldExceedCapacity =
|
||||
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
|
||||
const wouldExceedCapacity = totalAvailableHours > 0
|
||||
? allocatedHours + demand.hoursPerDay > totalAvailableHours
|
||||
: demand.hoursPerDay > 0;
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {
|
||||
deriveResourceForecast,
|
||||
getMonthRange,
|
||||
countWorkingDaysInOverlap,
|
||||
calculateSAH,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { SpainScheduleRule } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "./resource-capacity.js";
|
||||
|
||||
/**
|
||||
* Minimal DB client type for chargeability alerts.
|
||||
@@ -24,23 +26,19 @@ type DbClient = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
fte: number;
|
||||
availability: unknown;
|
||||
countryId: string | null;
|
||||
metroCityId: string | null;
|
||||
federalState: string | null;
|
||||
chargeabilityTarget: number;
|
||||
country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null;
|
||||
country: {
|
||||
id?: string | null;
|
||||
code: string | null;
|
||||
dailyWorkingHours: number | null;
|
||||
scheduleRules: unknown;
|
||||
} | null;
|
||||
managementLevelGroup: { targetPercentage: number | null } | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, unknown>;
|
||||
}) => Promise<
|
||||
Array<{
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
metroCity: { id?: string | null; name: string | null } | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
@@ -105,9 +103,14 @@ export async function checkChargeabilityAlerts(
|
||||
id: true,
|
||||
displayName: true,
|
||||
fte: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
federalState: true,
|
||||
chargeabilityTarget: true,
|
||||
country: { select: { dailyWorkingHours: true, scheduleRules: true } },
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
managementLevelGroup: { select: { targetPercentage: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,56 +124,32 @@ export async function checkChargeabilityAlerts(
|
||||
endDate: monthEnd,
|
||||
resourceIds,
|
||||
});
|
||||
|
||||
// Fetch vacations for the current month
|
||||
const vacations = await (db as DbClient).vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: monthEnd },
|
||||
endDate: { gte: monthStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
});
|
||||
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
|
||||
// Compute chargeability per resource
|
||||
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
|
||||
|
||||
for (const resource of resources) {
|
||||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
||||
|
||||
// Compute absence dates for SAH
|
||||
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
|
||||
const absenceDates: string[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const cursor = new Date(vStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(vEnd);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
while (cursor <= endNorm) {
|
||||
absenceDates.push(cursor.toISOString().slice(0, 10));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null;
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
fte: resource.fte,
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = availabilityContexts.get(resource.id);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
publicHolidays: [],
|
||||
absenceDays: absenceDates,
|
||||
context,
|
||||
});
|
||||
|
||||
// Build assignment slices
|
||||
@@ -178,12 +157,24 @@ export async function checkChargeabilityAlerts(
|
||||
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
|
||||
);
|
||||
|
||||
const slices: AssignmentSlice[] = resourceBookings.map((b) => {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate);
|
||||
const slices: AssignmentSlice[] = resourceBookings.flatMap((b) => {
|
||||
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
hoursPerDay: b.hoursPerDay,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context,
|
||||
});
|
||||
if (totalChargeableHours <= 0) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
hoursPerDay: b.hoursPerDay,
|
||||
workingDays,
|
||||
workingDays: 0,
|
||||
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
|
||||
totalChargeableHours,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -194,7 +185,7 @@ export async function checkChargeabilityAlerts(
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
});
|
||||
|
||||
const chgPct = forecast.chg * 100;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
|
||||
*/
|
||||
|
||||
import { getPublicHolidays } from "@capakraken/shared";
|
||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "./holiday-availability.js";
|
||||
|
||||
interface MinimalVacation {
|
||||
resourceId: string;
|
||||
@@ -19,14 +19,20 @@ interface MinimalVacation {
|
||||
|
||||
interface AutoImportDb {
|
||||
resource: {
|
||||
findMany: (args: {
|
||||
where: { isActive: boolean };
|
||||
select: { id: string; federalState: string };
|
||||
}) => Promise<Array<{ id: string; federalState: string | null }>>;
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
country?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
metroCity?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
holidayCalendar?: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: unknown) => Promise<MinimalVacation[]>;
|
||||
createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>;
|
||||
findMany: (args: any) => any;
|
||||
createMany: (args: any) => any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,34 +48,60 @@ export interface AutoImportResult {
|
||||
* Returns the number of holiday vacation records created.
|
||||
*/
|
||||
export async function autoImportPublicHolidays(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: any,
|
||||
db: AutoImportDb,
|
||||
year: number,
|
||||
): Promise<AutoImportResult> {
|
||||
const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, federalState: true },
|
||||
select: {
|
||||
id: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (resources.length === 0) {
|
||||
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
|
||||
}
|
||||
|
||||
// Group resources by federal state (null = federal-only holidays)
|
||||
const byState = new Map<string | null, string[]>();
|
||||
const nextYearStart = new Date(`${year}-01-01T00:00:00.000Z`);
|
||||
const nextYearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
|
||||
const byHolidayProfile = new Map<string, typeof resources>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const state = resource.federalState ?? null;
|
||||
const group = byState.get(state) ?? [];
|
||||
group.push(resource.id);
|
||||
byState.set(state, group);
|
||||
const profileKey = JSON.stringify({
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
});
|
||||
const group = byHolidayProfile.get(profileKey) ?? [];
|
||||
group.push(resource);
|
||||
byHolidayProfile.set(profileKey, group);
|
||||
}
|
||||
|
||||
let totalCreated = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const [state, resourceIds] of byState) {
|
||||
const holidays = getPublicHolidays(year, state ?? undefined);
|
||||
for (const [, groupedResources] of byHolidayProfile) {
|
||||
const sample = groupedResources[0];
|
||||
if (!sample) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
|
||||
periodStart: nextYearStart,
|
||||
periodEnd: nextYearEnd,
|
||||
countryId: sample.countryId,
|
||||
countryCode: sample.country?.code ?? null,
|
||||
federalState: sample.federalState,
|
||||
metroCityId: sample.metroCityId,
|
||||
metroCityName: sample.metroCity?.name ?? null,
|
||||
});
|
||||
if (holidays.length === 0) continue;
|
||||
const resourceIds = groupedResources.map((resource: { id: string }) => resource.id);
|
||||
|
||||
for (const holiday of holidays) {
|
||||
const holidayDate = new Date(holiday.date);
|
||||
@@ -86,13 +118,13 @@ export async function autoImportPublicHolidays(
|
||||
});
|
||||
|
||||
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
|
||||
const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id));
|
||||
const newResourceIds = resourceIds.filter((id: string) => !existingResourceIds.has(id));
|
||||
|
||||
totalSkipped += existingResourceIds.size;
|
||||
|
||||
if (newResourceIds.length === 0) continue;
|
||||
|
||||
const records = newResourceIds.map((resourceId) => ({
|
||||
const records = newResourceIds.map((resourceId: string) => ({
|
||||
resourceId,
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
status: "APPROVED",
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
|
||||
|
||||
type VacationLike = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
type HolidayAvailabilityInput = {
|
||||
vacations: VacationLike[];
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
resolvedHolidayStrings?: string[] | undefined;
|
||||
};
|
||||
|
||||
type HolidayAvailabilityResult = {
|
||||
absenceDateStrings: string[];
|
||||
publicHolidayStrings: string[];
|
||||
absenceDays: AbsenceDay[];
|
||||
};
|
||||
|
||||
export type CalendarHoliday = {
|
||||
date: string;
|
||||
name: string;
|
||||
scope: "COUNTRY" | "STATE" | "CITY";
|
||||
};
|
||||
|
||||
type CalendarScope = CalendarHoliday["scope"];
|
||||
|
||||
type HolidayCalendarEntryRecord = {
|
||||
date: Date;
|
||||
name: string;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
type HolidayCalendarRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeType: CalendarScope;
|
||||
priority: number;
|
||||
createdAt?: Date;
|
||||
entries: HolidayCalendarEntryRecord[];
|
||||
};
|
||||
|
||||
type HolidayResolverDb = {
|
||||
[key: string]: unknown;
|
||||
country?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
metroCity?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
holidayCalendar?: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
};
|
||||
|
||||
type ResolvedHoliday = CalendarHoliday & {
|
||||
calendarName: string;
|
||||
priority: number;
|
||||
sourceType: "BUILTIN" | "CUSTOM";
|
||||
};
|
||||
|
||||
export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
|
||||
return db as HolidayResolverDb;
|
||||
}
|
||||
|
||||
export function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
type CityHolidayRule = {
|
||||
countryCode: string;
|
||||
cityName: string;
|
||||
resolveDates: (year: number) => string[];
|
||||
};
|
||||
|
||||
const CITY_HOLIDAY_RULES: CityHolidayRule[] = [
|
||||
{
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
resolveDates: (year) => [`${year}-08-08`],
|
||||
},
|
||||
];
|
||||
|
||||
const SCOPE_WEIGHT: Record<CalendarScope, number> = {
|
||||
COUNTRY: 1,
|
||||
STATE: 2,
|
||||
CITY: 3,
|
||||
};
|
||||
|
||||
function normalizeCityName(cityName?: string | null): string | null {
|
||||
const normalized = cityName?.trim().toLowerCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStateCode(stateCode?: string | null): string | null {
|
||||
const normalized = stateCode?.trim().toUpperCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function resolveCalendarEntries(
|
||||
calendars: HolidayCalendarRecord[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): ResolvedHoliday[] {
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
const startIso = toIsoDate(periodStart);
|
||||
const endIso = toIsoDate(periodEnd);
|
||||
const resolved = new Map<string, ResolvedHoliday>();
|
||||
|
||||
for (const calendar of calendars) {
|
||||
for (const entry of calendar.entries) {
|
||||
const baseDate = new Date(entry.date);
|
||||
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
const effectiveDate = entry.isRecurringAnnual
|
||||
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
||||
: baseDate;
|
||||
const key = toIsoDate(effectiveDate);
|
||||
|
||||
if (key < startIso || key > endIso) {
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate: ResolvedHoliday = {
|
||||
date: key,
|
||||
name: entry.name,
|
||||
scope: calendar.scopeType,
|
||||
calendarName: calendar.name,
|
||||
priority: calendar.priority,
|
||||
sourceType: "CUSTOM",
|
||||
};
|
||||
const existing = resolved.get(key);
|
||||
|
||||
if (
|
||||
!existing
|
||||
|| SCOPE_WEIGHT[candidate.scope] > SCOPE_WEIGHT[existing.scope]
|
||||
|| (
|
||||
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& candidate.priority > existing.priority
|
||||
)
|
||||
|| (
|
||||
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& candidate.priority === existing.priority
|
||||
&& existing.sourceType === "BUILTIN"
|
||||
)
|
||||
) {
|
||||
resolved.set(key, candidate);
|
||||
}
|
||||
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...resolved.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
function mergeResolvedHolidays(
|
||||
builtInHolidays: CalendarHoliday[],
|
||||
customHolidays: ResolvedHoliday[],
|
||||
): ResolvedHoliday[] {
|
||||
const merged = new Map<string, ResolvedHoliday>();
|
||||
|
||||
for (const holiday of builtInHolidays) {
|
||||
merged.set(holiday.date, {
|
||||
...holiday,
|
||||
calendarName: "System",
|
||||
priority: Number.MIN_SAFE_INTEGER,
|
||||
sourceType: "BUILTIN",
|
||||
});
|
||||
}
|
||||
|
||||
for (const holiday of customHolidays) {
|
||||
const existing = merged.get(holiday.date);
|
||||
if (
|
||||
!existing
|
||||
|| SCOPE_WEIGHT[holiday.scope] > SCOPE_WEIGHT[existing.scope]
|
||||
|| (
|
||||
SCOPE_WEIGHT[holiday.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& holiday.priority >= existing.priority
|
||||
)
|
||||
) {
|
||||
merged.set(holiday.date, holiday);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
async function loadScopedHolidayCalendars(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
countryId?: string | null | undefined;
|
||||
stateCode?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
},
|
||||
): Promise<HolidayCalendarRecord[]> {
|
||||
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stateCode = normalizeStateCode(input.stateCode);
|
||||
const metroCityId = input.metroCityId?.trim() || null;
|
||||
|
||||
return db.holidayCalendar.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
countryId: input.countryId,
|
||||
OR: [
|
||||
{ scopeType: "COUNTRY" },
|
||||
...(stateCode ? [{ scopeType: "STATE" as const, stateCode }] : []),
|
||||
...(metroCityId ? [{ scopeType: "CITY" as const, metroCityId }] : []),
|
||||
],
|
||||
},
|
||||
include: { entries: true },
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export function getCalendarHolidayStrings(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
countryCode?: string | null,
|
||||
federalState?: string | null,
|
||||
metroCityName?: string | null,
|
||||
): string[] {
|
||||
return getCalendarHolidays(
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode,
|
||||
federalState,
|
||||
metroCityName,
|
||||
).map((holiday) => holiday.date);
|
||||
}
|
||||
|
||||
export function getCalendarHolidays(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
countryCode?: string | null,
|
||||
federalState?: string | null,
|
||||
metroCityName?: string | null,
|
||||
): CalendarHoliday[] {
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
const holidays = new Map<string, CalendarHoliday>();
|
||||
|
||||
if (countryCode === "DE") {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holiday of getPublicHolidays(year, federalState ?? undefined)) {
|
||||
if (holiday.date >= toIsoDate(periodStart) && holiday.date <= toIsoDate(periodEnd)) {
|
||||
holidays.set(holiday.date, {
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scope: holiday.federal ? "COUNTRY" : "STATE",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCityName = normalizeCityName(metroCityName);
|
||||
if (countryCode && normalizedCityName) {
|
||||
for (const rule of CITY_HOLIDAY_RULES) {
|
||||
if (
|
||||
rule.countryCode === countryCode
|
||||
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||
) {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holidayDate of rule.resolveDates(year)) {
|
||||
if (holidayDate >= toIsoDate(periodStart) && holidayDate <= toIsoDate(periodEnd)) {
|
||||
holidays.set(holidayDate, {
|
||||
date: holidayDate,
|
||||
name: "Augsburger Friedensfest",
|
||||
scope: "CITY",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...holidays.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
export async function getResolvedCalendarHolidays(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId?: string | null | undefined;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
},
|
||||
): Promise<ResolvedHoliday[]> {
|
||||
let countryCode = input.countryCode ?? null;
|
||||
if (!countryCode && input.countryId && typeof db.country?.findUnique === "function") {
|
||||
const country = await db.country.findUnique({
|
||||
where: { id: input.countryId },
|
||||
select: { code: true },
|
||||
});
|
||||
countryCode = country?.code ?? null;
|
||||
}
|
||||
|
||||
let metroCityName = input.metroCityName ?? null;
|
||||
if (!metroCityName && input.metroCityId && typeof db.metroCity?.findUnique === "function") {
|
||||
const metroCity = await db.metroCity.findUnique({
|
||||
where: { id: input.metroCityId },
|
||||
select: { name: true },
|
||||
});
|
||||
metroCityName = metroCity?.name ?? null;
|
||||
}
|
||||
|
||||
const builtIn = getCalendarHolidays(
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
countryCode,
|
||||
input.federalState,
|
||||
metroCityName,
|
||||
);
|
||||
const calendars = await loadScopedHolidayCalendars(db, {
|
||||
countryId: input.countryId,
|
||||
stateCode: input.federalState,
|
||||
metroCityId: input.metroCityId,
|
||||
});
|
||||
const custom = resolveCalendarEntries(calendars, input.periodStart, input.periodEnd);
|
||||
|
||||
return mergeResolvedHolidays(builtIn, custom);
|
||||
}
|
||||
|
||||
export async function getResolvedCalendarHolidayStrings(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId?: string | null | undefined;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
const holidays = await getResolvedCalendarHolidays(db, input);
|
||||
return holidays.map((holiday) => holiday.date);
|
||||
}
|
||||
|
||||
export function collectHolidayAvailability(
|
||||
input: HolidayAvailabilityInput,
|
||||
): HolidayAvailabilityResult {
|
||||
const periodStartIso = toIsoDate(input.periodStart);
|
||||
const periodEndIso = toIsoDate(input.periodEnd);
|
||||
const publicHolidaySet = new Set(
|
||||
input.resolvedHolidayStrings
|
||||
? input.resolvedHolidayStrings.filter((date) => date >= periodStartIso && date <= periodEndIso)
|
||||
: getCalendarHolidayStrings(
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
input.countryCode,
|
||||
input.federalState,
|
||||
input.metroCityName,
|
||||
),
|
||||
);
|
||||
const absenceDateSet = new Set<string>();
|
||||
const absenceDayMap = new Map<string, AbsenceDay>();
|
||||
|
||||
for (const isoDate of publicHolidaySet) {
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(`${isoDate}T00:00:00.000Z`),
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
});
|
||||
}
|
||||
|
||||
for (const vacation of input.vacations) {
|
||||
if (vacation.type !== "PUBLIC_HOLIDAY") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapStart = new Date(
|
||||
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
publicHolidaySet.add(isoDate);
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(cursor),
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const vacation of input.vacations) {
|
||||
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapStart = new Date(
|
||||
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const triggerType = vacation.type === "SICK" ? "SICK" : "VACATION";
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
if (!publicHolidaySet.has(isoDate)) {
|
||||
absenceDateSet.add(isoDate);
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(cursor),
|
||||
type: triggerType,
|
||||
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
absenceDateStrings: [...absenceDateSet].sort(),
|
||||
publicHolidayStrings: [...publicHolidaySet].sort(),
|
||||
absenceDays: [...absenceDayMap.values()],
|
||||
};
|
||||
}
|
||||
@@ -3,23 +3,24 @@ import pino from "pino";
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
|
||||
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
|
||||
const devDestination = pino.destination({ dest: 1, sync: true });
|
||||
|
||||
export const logger = pino({
|
||||
export const logger = isProduction
|
||||
? pino({
|
||||
level: LOG_LEVEL,
|
||||
base: { service: "capakraken-api" },
|
||||
})
|
||||
: pino(
|
||||
{
|
||||
level: LOG_LEVEL,
|
||||
base: { service: "capakraken-api" },
|
||||
...(isProduction
|
||||
? {}
|
||||
: {
|
||||
transport: {
|
||||
target: "pino/file",
|
||||
options: { destination: 1 }, // stdout
|
||||
},
|
||||
formatters: {
|
||||
level(label: string) {
|
||||
return { level: label };
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
devDestination,
|
||||
);
|
||||
|
||||
export type Logger = typeof logger;
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FillDemandRequirementSchema,
|
||||
FillOpenDemandByAllocationSchema,
|
||||
PermissionKey,
|
||||
type WeekdayAvailability,
|
||||
UpdateAssignmentSchema,
|
||||
UpdateAllocationSchema,
|
||||
UpdateDemandRequirementSchema,
|
||||
@@ -34,6 +35,13 @@ import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
|
||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
calculateEffectiveDayAvailability,
|
||||
countEffectiveWorkingDays,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||
|
||||
@@ -328,12 +336,26 @@ export const allocationRouter = createTRPCRouter({
|
||||
where: { id: input.resourceId },
|
||||
select: {
|
||||
id: true, displayName: true, eid: true, fte: true,
|
||||
country: { select: { dailyWorkingHours: true } },
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { dailyWorkingHours: true, code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||
|
||||
const dailyCapacity = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
|
||||
const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
|
||||
const availability = (resource.availability as WeekdayAvailability | null) ?? {
|
||||
monday: fallbackDailyHours,
|
||||
tuesday: fallbackDailyHours,
|
||||
wednesday: fallbackDailyHours,
|
||||
thursday: fallbackDailyHours,
|
||||
friday: fallbackDailyHours,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
};
|
||||
|
||||
// Get existing assignments in the date range
|
||||
const existingAssignments = await ctx.db.assignment.findMany({
|
||||
@@ -350,19 +372,29 @@ export const allocationRouter = createTRPCRouter({
|
||||
orderBy: { startDate: "asc" },
|
||||
});
|
||||
|
||||
// Get vacations in the date range
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
status: "APPROVED",
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
},
|
||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
[{
|
||||
id: resource.id,
|
||||
availability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}],
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const context = contexts.get(resource.id);
|
||||
|
||||
// Calculate day-by-day availability
|
||||
let totalWorkingDays = 0;
|
||||
const totalWorkingDays = countEffectiveWorkingDays({
|
||||
availability,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context,
|
||||
});
|
||||
let availableDays = 0;
|
||||
let conflictDays = 0;
|
||||
let partialDays = 0;
|
||||
@@ -372,36 +404,27 @@ export const allocationRouter = createTRPCRouter({
|
||||
const d = new Date(input.startDate);
|
||||
const end = new Date(input.endDate);
|
||||
while (d <= end) {
|
||||
const dow = d.getDay();
|
||||
if (dow !== 0 && dow !== 6) {
|
||||
totalWorkingDays++;
|
||||
|
||||
// Check vacation
|
||||
const isVacation = vacations.some((v) => {
|
||||
const vs = new Date(v.startDate); vs.setHours(0, 0, 0, 0);
|
||||
const ve = new Date(v.endDate); ve.setHours(0, 0, 0, 0);
|
||||
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
|
||||
return dc >= vs && dc <= ve;
|
||||
const effectiveDayCapacity = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date: d,
|
||||
context,
|
||||
});
|
||||
|
||||
if (isVacation) {
|
||||
conflictDays++;
|
||||
d.setDate(d.getDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sum existing hours on this day
|
||||
if (effectiveDayCapacity > 0) {
|
||||
let bookedHours = 0;
|
||||
for (const a of existingAssignments) {
|
||||
const as2 = new Date(a.startDate); as2.setHours(0, 0, 0, 0);
|
||||
const ae = new Date(a.endDate); ae.setHours(0, 0, 0, 0);
|
||||
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
|
||||
if (dc >= as2 && dc <= ae) {
|
||||
bookedHours += a.hoursPerDay;
|
||||
}
|
||||
bookedHours += calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart: d,
|
||||
periodEnd: d,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const remainingCapacity = Math.max(0, dailyCapacity - bookedHours);
|
||||
const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours);
|
||||
if (remainingCapacity >= requestedHpd) {
|
||||
availableDays++;
|
||||
totalAvailableHours += requestedHpd;
|
||||
@@ -416,6 +439,15 @@ export const allocationRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const totalRequestedHours = totalWorkingDays * requestedHpd;
|
||||
const totalPeriodCapacity = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: input.startDate,
|
||||
periodEnd: input.endDate,
|
||||
context,
|
||||
});
|
||||
const dailyCapacity = totalWorkingDays > 0
|
||||
? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10
|
||||
: 0;
|
||||
|
||||
return {
|
||||
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
export interface AssistantInsightMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "good" | "warn" | "danger" | "info";
|
||||
}
|
||||
|
||||
export interface AssistantInsightSection {
|
||||
title: string;
|
||||
metrics: AssistantInsightMetric[];
|
||||
}
|
||||
|
||||
export interface AssistantInsight {
|
||||
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
metrics: AssistantInsightMetric[];
|
||||
sections?: AssistantInsightSection[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function formatHours(value: unknown): string | null {
|
||||
const num = asNumber(value);
|
||||
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} h`;
|
||||
}
|
||||
|
||||
function formatDays(value: unknown): string | null {
|
||||
const num = asNumber(value);
|
||||
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} d`;
|
||||
}
|
||||
|
||||
function pushMetric(
|
||||
metrics: AssistantInsightMetric[],
|
||||
label: string,
|
||||
value: string | null,
|
||||
tone?: AssistantInsightMetric["tone"],
|
||||
) {
|
||||
if (!value) return;
|
||||
metrics.push({ label, value, ...(tone ? { tone } : {}) });
|
||||
}
|
||||
|
||||
function createLocationLabel(locationContext: Record<string, unknown> | undefined): string | null {
|
||||
if (!locationContext) return null;
|
||||
const parts = [
|
||||
asString(locationContext.metroCity),
|
||||
asString(locationContext.federalState),
|
||||
asString(locationContext.country),
|
||||
asString(locationContext.countryCode),
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
function buildChargeabilityInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||
const resource = asString(data.resource);
|
||||
const month = asString(data.month);
|
||||
if (!resource || !month) return null;
|
||||
|
||||
const holidaySummary = isRecord(data.holidaySummary) ? data.holidaySummary : undefined;
|
||||
const absenceSummary = isRecord(data.absenceSummary) ? data.absenceSummary : undefined;
|
||||
const capacityBreakdown = isRecord(data.capacityBreakdown) ? data.capacityBreakdown : undefined;
|
||||
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
|
||||
const chargeabilityPct = asNumber(data.chargeabilityPct);
|
||||
const targetPct = asNumber(data.targetPct);
|
||||
|
||||
const metrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(metrics, "Chargeability", asString(data.chargeability), chargeabilityPct == null || targetPct == null
|
||||
? "info"
|
||||
: chargeabilityPct >= targetPct ? "good" : "warn");
|
||||
pushMetric(metrics, "Available", formatHours(data.availableHours));
|
||||
pushMetric(metrics, "Booked", formatHours(data.bookedHours));
|
||||
pushMetric(metrics, "Unassigned", formatHours(data.unassignedHours));
|
||||
pushMetric(metrics, "Target", formatHours(data.targetHours));
|
||||
pushMetric(metrics, "Holidays", formatDays(holidaySummary?.workdayCount ?? holidaySummary?.count));
|
||||
|
||||
const sections: AssistantInsightSection[] = [];
|
||||
|
||||
const basisMetrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(basisMetrics, "Location", createLocationLabel(locationContext), "info");
|
||||
pushMetric(basisMetrics, "Base working days", formatDays(data.baseWorkingDays));
|
||||
pushMetric(basisMetrics, "Effective working days", formatDays(data.workingDays));
|
||||
pushMetric(basisMetrics, "Base capacity", formatHours(data.baseAvailableHours));
|
||||
if (basisMetrics.length > 0) {
|
||||
sections.push({ title: "Basis", metrics: basisMetrics });
|
||||
}
|
||||
|
||||
const deductionMetrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(deductionMetrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction ?? capacityBreakdown?.holidayHoursDeduction), "warn");
|
||||
pushMetric(deductionMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
|
||||
pushMetric(deductionMetrics, "Absence days", formatDays(absenceSummary?.dayEquivalent));
|
||||
if (deductionMetrics.length > 0) {
|
||||
sections.push({ title: "Deductions", metrics: deductionMetrics });
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "chargeability",
|
||||
title: `${resource} · ${month}`,
|
||||
subtitle: "Holiday-aware monthly capacity",
|
||||
metrics,
|
||||
...(sections.length > 0 ? { sections } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHolidayRegionInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
|
||||
const periodStart = asString(data.periodStart);
|
||||
const periodEnd = asString(data.periodEnd);
|
||||
|
||||
const metrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(metrics, "Region", createLocationLabel(locationContext), "info");
|
||||
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
|
||||
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
|
||||
|
||||
const summary = isRecord(data.summary) ? data.summary : undefined;
|
||||
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
|
||||
const scopeMetrics = scopeItems
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const scope = asString(item.scope);
|
||||
const count = asNumber(item.count);
|
||||
if (!scope || count == null) return null;
|
||||
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
|
||||
})
|
||||
.filter((item): item is AssistantInsightMetric => item !== null);
|
||||
|
||||
return {
|
||||
kind: "holiday_region",
|
||||
title: createLocationLabel(locationContext) ?? "Regional holidays",
|
||||
subtitle: "Resolved public holiday set",
|
||||
metrics,
|
||||
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildResourceHolidayInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||
const resource = isRecord(data.resource) ? data.resource : undefined;
|
||||
const summary = isRecord(data.summary) ? data.summary : undefined;
|
||||
const periodStart = asString(data.periodStart);
|
||||
const periodEnd = asString(data.periodEnd);
|
||||
|
||||
const metrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(metrics, "Employee", asString(resource?.name) ?? asString(resource?.eid));
|
||||
pushMetric(metrics, "Location", createLocationLabel(resource), "info");
|
||||
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
|
||||
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
|
||||
|
||||
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
|
||||
const scopeMetrics = scopeItems
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const scope = asString(item.scope);
|
||||
const count = asNumber(item.count);
|
||||
if (!scope || count == null) return null;
|
||||
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
|
||||
})
|
||||
.filter((item): item is AssistantInsightMetric => item !== null);
|
||||
|
||||
return {
|
||||
kind: "resource_holidays",
|
||||
title: `${asString(resource?.name) ?? "Resource"} holidays`,
|
||||
subtitle: "Location-specific holiday resolution",
|
||||
metrics,
|
||||
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildResourceMatchInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||
const project = isRecord(data.project) ? data.project : undefined;
|
||||
const period = isRecord(data.period) ? data.period : undefined;
|
||||
const bestMatch = isRecord(data.bestMatch) ? data.bestMatch : undefined;
|
||||
if (!project || !period || !bestMatch) return null;
|
||||
|
||||
const remainingHours = asNumber(bestMatch.remainingHours);
|
||||
const remainingHoursPerDay = asNumber(bestMatch.remainingHoursPerDay);
|
||||
const lcr = asString(bestMatch.lcr);
|
||||
const holidaySummary = isRecord(bestMatch.holidaySummary) ? bestMatch.holidaySummary : undefined;
|
||||
const absenceSummary = isRecord(bestMatch.absenceSummary) ? bestMatch.absenceSummary : undefined;
|
||||
const capacityBreakdown = isRecord(bestMatch.capacityBreakdown) ? bestMatch.capacityBreakdown : undefined;
|
||||
|
||||
const metrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(metrics, "Best match", asString(bestMatch.name) ?? asString(bestMatch.eid), "good");
|
||||
pushMetric(metrics, "Project", asString(project.name) ?? asString(project.shortCode));
|
||||
pushMetric(metrics, "Remaining", formatHours(remainingHours), remainingHours != null && remainingHours > 0 ? "good" : "warn");
|
||||
pushMetric(metrics, "Per workday", formatHours(remainingHoursPerDay));
|
||||
pushMetric(metrics, "LCR", lcr);
|
||||
pushMetric(metrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction), "warn");
|
||||
|
||||
const sections: AssistantInsightSection[] = [];
|
||||
|
||||
const profileMetrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(profileMetrics, "Role", asString(bestMatch.role));
|
||||
pushMetric(profileMetrics, "Chapter", asString(bestMatch.chapter));
|
||||
pushMetric(profileMetrics, "Location", createLocationLabel(bestMatch), "info");
|
||||
pushMetric(profileMetrics, "Candidate pool", asNumber(data.candidateCount)?.toString() ?? null);
|
||||
if (profileMetrics.length > 0) {
|
||||
sections.push({ title: "Selection", metrics: profileMetrics });
|
||||
}
|
||||
|
||||
const basisMetrics: AssistantInsightMetric[] = [];
|
||||
pushMetric(basisMetrics, "Window", asString(period.startDate) && asString(period.endDate) ? `${asString(period.startDate)} to ${asString(period.endDate)}` : null);
|
||||
pushMetric(basisMetrics, "Ranking", asString(period.rankingMode));
|
||||
pushMetric(basisMetrics, "Min/day", formatHours(period.minHoursPerDay));
|
||||
pushMetric(basisMetrics, "Base capacity", formatHours(capacityBreakdown?.baseAvailableHours ?? bestMatch.baseAvailableHours));
|
||||
pushMetric(basisMetrics, "Effective capacity", formatHours(bestMatch.availableHours));
|
||||
pushMetric(basisMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
|
||||
if (basisMetrics.length > 0) {
|
||||
sections.push({ title: "Capacity basis", metrics: basisMetrics });
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "resource_match",
|
||||
title: `${asString(project.shortCode) ?? asString(project.name) ?? "Project"} staffing`,
|
||||
subtitle: "Holiday-aware best-fit resource",
|
||||
metrics,
|
||||
...(sections.length > 0 ? { sections } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssistantInsight(toolName: string, data: unknown): AssistantInsight | null {
|
||||
if (!isRecord(data)) return null;
|
||||
|
||||
switch (toolName) {
|
||||
case "get_chargeability":
|
||||
return buildChargeabilityInsight(data);
|
||||
case "find_best_project_resource":
|
||||
return buildResourceMatchInsight(data);
|
||||
case "list_holidays_by_region":
|
||||
return buildHolidayRegionInsight(data);
|
||||
case "get_resource_holidays":
|
||||
return buildResourceHolidayInsight(data);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,11 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
|
||||
import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||
import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
|
||||
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
|
||||
import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
|
||||
import { checkPromptInjection } from "../lib/prompt-guard.js";
|
||||
import { checkAiOutput } from "../lib/content-filter.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
@@ -20,7 +21,7 @@ const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-A
|
||||
|
||||
Deine Fähigkeiten:
|
||||
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
|
||||
- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
|
||||
- Chargeability-Analysen, Urlaubsübersichten, Feiertagskalender nach Land/Bundesland/Stadt, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
|
||||
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
|
||||
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
|
||||
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
|
||||
@@ -40,6 +41,12 @@ Wichtige Regeln:
|
||||
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
|
||||
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
|
||||
- Fasse Ergebnisse kompakt zusammen — keine unnötigen Wiederholungen der Tool-Ergebnisse
|
||||
- Wenn Feiertage, SAH, Chargeability, Verfügbarkeit oder Ressourcenauswahl relevant sind, erkläre IMMER transparent:
|
||||
1. Standortkontext (Land/Bundesland/Stadt falls relevant)
|
||||
2. Feiertagsbasis bzw. Feiertagsanzahl
|
||||
3. Abzüge durch Feiertage/Abwesenheiten
|
||||
4. resultierende verfügbare Stunden / Zielstunden / Restkapazität
|
||||
- Wenn strukturierte UI-Karten vorhanden sind, wiederhole dort gezeigte Zahlen NICHT vollständig im Freitext. Gib nur die Kernaussage und die wichtigste Begründung an.
|
||||
- Wenn eine Suche keine Treffer ergibt, versuche einzelne Wörter aus der Anfrage als Suchbegriffe. Die Tools unterstützen automatisch wort-basierte Fuzzy-Suche — zeige dem User die Vorschläge wenn welche gefunden werden
|
||||
|
||||
Datenmodell:
|
||||
@@ -48,10 +55,12 @@ Datenmodell:
|
||||
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
|
||||
- Chargeability = gebuchte/verfügbare Stunden × 100%
|
||||
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
|
||||
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
|
||||
`;
|
||||
|
||||
/** Map tool names to the permission required to use them */
|
||||
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
list_users: PermissionKey.MANAGE_USERS,
|
||||
// Resource management
|
||||
update_resource: "manageResources",
|
||||
create_resource: "manageResources",
|
||||
@@ -89,7 +98,36 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Tools that require cost visibility */
|
||||
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail"]);
|
||||
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail", "find_best_project_resource"]);
|
||||
|
||||
export function getAvailableAssistantTools(permissions: Set<PermissionKey>) {
|
||||
return TOOL_DEFINITIONS.filter((tool) => {
|
||||
const toolName = tool.function.name;
|
||||
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
||||
|
||||
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
|
||||
return false;
|
||||
}
|
||||
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
|
||||
return false;
|
||||
}
|
||||
if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
|
||||
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
|
||||
if (duplicateIndex >= 0) {
|
||||
const copy = [...existing];
|
||||
copy[duplicateIndex] = next;
|
||||
return copy;
|
||||
}
|
||||
return [...existing, next].slice(-6);
|
||||
}
|
||||
|
||||
export const assistantRouter = createTRPCRouter({
|
||||
chat: protectedProcedure
|
||||
@@ -176,26 +214,12 @@ export const assistantRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// 4. Filter tools based on granular permissions
|
||||
const availableTools = TOOL_DEFINITIONS.filter((t) => {
|
||||
const toolName = t.function.name;
|
||||
|
||||
// Check write permission
|
||||
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
||||
if (requiredPerm && !permissions.has(requiredPerm as import("@capakraken/shared").PermissionKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide cost/budget tools if user lacks viewCosts
|
||||
if (COST_TOOLS.has(toolName) && !permissions.has("viewCosts" as import("@capakraken/shared").PermissionKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const availableTools = getAvailableAssistantTools(permissions);
|
||||
|
||||
// 5. Function calling loop
|
||||
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
|
||||
const collectedActions: ToolAction[] = [];
|
||||
let collectedInsights: AssistantInsight[] = [];
|
||||
|
||||
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -240,6 +264,11 @@ export const assistantRouter = createTRPCRouter({
|
||||
toolCtx,
|
||||
);
|
||||
|
||||
const insight = buildAssistantInsight(toolCall.function.name, result.data);
|
||||
if (insight) {
|
||||
collectedInsights = mergeInsights(collectedInsights, insight);
|
||||
}
|
||||
|
||||
// Collect any actions (e.g. navigation)
|
||||
if (result.action) {
|
||||
collectedActions.push(result.action);
|
||||
@@ -298,6 +327,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
return {
|
||||
content: finalContent,
|
||||
role: "assistant" as const,
|
||||
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
||||
};
|
||||
}
|
||||
@@ -306,6 +336,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
return {
|
||||
content: "I had to stop after too many tool calls. Please try a simpler question.",
|
||||
role: "assistant" as const,
|
||||
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -5,19 +5,18 @@ import {
|
||||
sumFte,
|
||||
getMonthRange,
|
||||
getMonthKeys,
|
||||
countWorkingDaysInOverlap,
|
||||
calculateSAH,
|
||||
calculateAllocation,
|
||||
DEFAULT_CALCULATION_RULES,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
|
||||
import type { SpainScheduleRule } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
export const chargeabilityReportRouter = createTRPCRouter({
|
||||
getReport: controllerProcedure
|
||||
@@ -59,6 +58,10 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
eid: true,
|
||||
displayName: true,
|
||||
fte: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
chargeabilityTarget: true,
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
orgUnit: { select: { id: true, name: true } },
|
||||
@@ -90,6 +93,20 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
endDate: rangeEnd,
|
||||
resourceIds,
|
||||
});
|
||||
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
);
|
||||
|
||||
// Enrich with utilization category — fetch project util categories in bulk
|
||||
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
|
||||
@@ -118,152 +135,59 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
},
|
||||
}));
|
||||
|
||||
// Fetch vacations/absences in the range (including type for rules engine)
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: rangeEnd },
|
||||
endDate: { gte: rangeStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Load calculation rules for chargeability adjustments
|
||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||||
try {
|
||||
const dbRules = await ctx.db.calculationRule.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [{ priority: "desc" }],
|
||||
});
|
||||
if (dbRules.length > 0) {
|
||||
calcRules = dbRules as unknown as CalculationRule[];
|
||||
}
|
||||
} catch {
|
||||
// table may not exist yet
|
||||
}
|
||||
|
||||
// Build per-resource, per-month forecasts
|
||||
const resourceRows = resources.map((resource) => {
|
||||
const resourceRows = await Promise.all(resources.map(async (resource) => {
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
|
||||
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
|
||||
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
|
||||
const targetPct = resource.managementLevelGroup?.targetPercentage
|
||||
?? (resource.chargeabilityTarget / 100);
|
||||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
||||
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = availabilityContexts.get(resource.id);
|
||||
|
||||
const months = monthKeys.map((key) => {
|
||||
const months = await Promise.all(monthKeys.map(async (key) => {
|
||||
const [y, m] = key.split("-").map(Number) as [number, number];
|
||||
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
|
||||
|
||||
// Compute absence days for SAH
|
||||
const absenceDates: string[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const cursor = new Date(vStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(vEnd);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
while (cursor <= endNorm) {
|
||||
absenceDates.push(cursor.toISOString().slice(0, 10));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate SAH for this resource+month
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
fte: resource.fte,
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
publicHolidays: [], // TODO: integrate public holidays from country
|
||||
absenceDays: absenceDates,
|
||||
context,
|
||||
});
|
||||
|
||||
// Build typed absence days for this resource in this month
|
||||
const monthAbsenceDays: AbsenceDay[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const absCursor = new Date(vStart);
|
||||
absCursor.setUTCHours(0, 0, 0, 0);
|
||||
const absEndNorm = new Date(vEnd);
|
||||
absEndNorm.setUTCHours(0, 0, 0, 0);
|
||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
||||
: "VACATION" as const;
|
||||
while (absCursor <= absEndNorm) {
|
||||
monthAbsenceDays.push({
|
||||
date: new Date(absCursor),
|
||||
type: triggerType,
|
||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Build assignment slices for this month, using rules to compute chargeable hours
|
||||
const slices: AssignmentSlice[] = [];
|
||||
for (const a of resourceAssignments) {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
if (workingDays <= 0) continue;
|
||||
|
||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||||
|
||||
// If there are absences and rules, compute rules-adjusted chargeable hours
|
||||
if (monthAbsenceDays.length > 0) {
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
|
||||
const calcResult = calculateAllocation({
|
||||
lcrCents: 0, // we only need hours, not costs
|
||||
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
|
||||
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: overlapStart,
|
||||
endDate: overlapEnd,
|
||||
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
|
||||
absenceDays: monthAbsenceDays,
|
||||
calculationRules: calcRules,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context,
|
||||
});
|
||||
if (totalChargeableHours <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
slices.push({
|
||||
return {
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
|
||||
workingDays: 0,
|
||||
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
|
||||
totalChargeableHours,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
categoryCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const forecast = deriveResourceForecast({
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
});
|
||||
|
||||
return {
|
||||
monthKey: key,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
...forecast,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
@@ -278,7 +202,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
||||
targetPct,
|
||||
months,
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
// Compute group totals per month
|
||||
const groupTotals = monthKeys.map((key, monthIdx) => {
|
||||
|
||||
@@ -4,18 +4,27 @@ import {
|
||||
deriveResourceForecast,
|
||||
computeBudgetStatus,
|
||||
getMonthRange,
|
||||
countWorkingDaysInOverlap,
|
||||
DEFAULT_CALCULATION_RULES,
|
||||
summarizeEstimateDemandLines,
|
||||
computeEvenSpread,
|
||||
distributeHoursToWeeks,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import { fmtEur } from "../lib/format-utils.js";
|
||||
import {
|
||||
asHolidayResolverDb,
|
||||
collectHolidayAvailability,
|
||||
getResolvedCalendarHolidays,
|
||||
} from "../lib/holiday-availability.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
countEffectiveWorkingDays,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
// ─── Graph Types (mirrored from client for API response) ────────────────────
|
||||
|
||||
@@ -62,6 +71,21 @@ function fmtNum(v: number, decimals = 1): string {
|
||||
return v.toFixed(decimals);
|
||||
}
|
||||
|
||||
function getAvailabilityHoursForDate(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
|
||||
return availability[dayKey] ?? 0;
|
||||
}
|
||||
|
||||
function sumAvailabilityHoursForDates(
|
||||
availability: WeekdayAvailability,
|
||||
dates: Date[],
|
||||
): number {
|
||||
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
|
||||
}
|
||||
|
||||
// ─── Router ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const computationGraphRouter = createTRPCRouter({
|
||||
@@ -88,8 +112,12 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
fte: true,
|
||||
lcrCents: true,
|
||||
chargeabilityTarget: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
availability: true,
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
||||
},
|
||||
});
|
||||
@@ -133,7 +161,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
// ── 3. Load absences ──
|
||||
// ── 3. Load absences + holiday context ──
|
||||
const vacations = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
@@ -143,45 +171,47 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
},
|
||||
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
||||
});
|
||||
|
||||
// Build absence dates for SAH (ISO strings), separating public holidays
|
||||
const publicHolidayStrings: string[] = [];
|
||||
const absenceDateStrings: string[] = [];
|
||||
const absenceDays: AbsenceDay[] = [];
|
||||
let halfDayCount = 0;
|
||||
let vacationDayCount = 0;
|
||||
let sickDayCount = 0;
|
||||
let publicHolidayCount = 0;
|
||||
for (const v of vacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const cursor = new Date(vStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(vEnd);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
||||
: "VACATION" as const;
|
||||
while (cursor <= endNorm) {
|
||||
const isoDate = cursor.toISOString().slice(0, 10);
|
||||
if (triggerType === "PUBLIC_HOLIDAY") {
|
||||
publicHolidayStrings.push(isoDate);
|
||||
publicHolidayCount++;
|
||||
} else {
|
||||
absenceDateStrings.push(isoDate);
|
||||
if (triggerType === "VACATION") vacationDayCount++;
|
||||
if (triggerType === "SICK") sickDayCount++;
|
||||
}
|
||||
absenceDays.push({
|
||||
date: new Date(cursor),
|
||||
type: triggerType,
|
||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||||
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
});
|
||||
if (v.isHalfDay) halfDayCount++;
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
const holidayAvailability = collectHolidayAvailability({
|
||||
vacations,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
|
||||
});
|
||||
const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
|
||||
const absenceDateStrings = holidayAvailability.absenceDateStrings;
|
||||
const absenceDays = holidayAvailability.absenceDays;
|
||||
const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
|
||||
const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
|
||||
const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
|
||||
const publicHolidayCount = resolvedHolidays.length;
|
||||
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
[{
|
||||
id: resource.id,
|
||||
availability: weeklyAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
}],
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
const availabilityContext = contexts.get(resource.id);
|
||||
|
||||
// ── 4. Load calculation rules ──
|
||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||||
@@ -197,7 +227,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
// table may not exist yet
|
||||
}
|
||||
|
||||
// ── 5. Calculate SAH ──
|
||||
// ── 5. Calculate SAH / effective capacity ──
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
@@ -207,6 +237,60 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
publicHolidays: publicHolidayStrings,
|
||||
absenceDays: absenceDateStrings,
|
||||
});
|
||||
const baseWorkingDays = countEffectiveWorkingDays({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveWorkingDays = countEffectiveWorkingDays({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: availabilityContext,
|
||||
});
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability: weeklyAvailability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context: availabilityContext,
|
||||
});
|
||||
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
|
||||
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
|
||||
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
|
||||
), 0);
|
||||
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
|
||||
weeklyAvailability,
|
||||
publicHolidayDates,
|
||||
);
|
||||
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
|
||||
if (absence.type === "PUBLIC_HOLIDAY") {
|
||||
return sum;
|
||||
}
|
||||
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
|
||||
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
|
||||
}, 0);
|
||||
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
|
||||
? effectiveAvailableHours / effectiveWorkingDays
|
||||
: 0;
|
||||
const holidayScopeSummary = [
|
||||
resource.country?.code ?? "—",
|
||||
resource.federalState ?? "—",
|
||||
resource.metroCity?.name ?? "—",
|
||||
].join(" / ");
|
||||
const holidayExamples = resolvedHolidays.length > 0
|
||||
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
|
||||
: "none";
|
||||
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
|
||||
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
|
||||
// ── 6. Calculate allocations + chargeability slices ──
|
||||
const slices: AssignmentSlice[] = [];
|
||||
@@ -217,9 +301,6 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
let hasRulesEffect = false;
|
||||
|
||||
for (const a of assignments) {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
if (workingDays <= 0) continue;
|
||||
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||||
@@ -233,6 +314,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
absenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
|
||||
|
||||
totalAllocHours += calcResult.totalHours;
|
||||
totalAllocCostCents += calcResult.totalCostCents;
|
||||
@@ -247,7 +329,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
|
||||
slices.push({
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays,
|
||||
workingDays: calcResult.workingDays,
|
||||
categoryCode,
|
||||
...(calcResult.totalChargeableHours !== undefined
|
||||
? { totalChargeableHours: calcResult.totalChargeableHours }
|
||||
@@ -260,7 +342,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: effectiveAvailableHours,
|
||||
});
|
||||
|
||||
// ── 8. Build budget status for first project with budget ──
|
||||
@@ -319,7 +401,18 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
|
||||
: 0;
|
||||
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
|
||||
return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||
const calcResult = calculateAllocation({
|
||||
lcrCents: resource.lcrCents,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
startDate: overlapStart,
|
||||
endDate: overlapEnd,
|
||||
availability: weeklyAvailability,
|
||||
absenceDays,
|
||||
calculationRules: calcRules,
|
||||
});
|
||||
return sum + calcResult.workingDays;
|
||||
}, 0);
|
||||
|
||||
// Format weekly availability for display
|
||||
@@ -332,9 +425,10 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
|
||||
|
||||
// Derived utilization ratio
|
||||
const utilizationPct = sahResult.standardAvailableHours > 0
|
||||
? (totalAllocHours / sahResult.standardAvailableHours) * 100
|
||||
const utilizationPct = effectiveAvailableHours > 0
|
||||
? (totalAllocHours / effectiveAvailableHours) * 100
|
||||
: 0;
|
||||
const chargeableHours = forecast.chg * effectiveAvailableHours;
|
||||
|
||||
// Has schedule rules (Spain variable hours)?
|
||||
const hasScheduleRules = !!scheduleRules;
|
||||
@@ -342,6 +436,11 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
const nodes: GraphNode[] = [
|
||||
// INPUT
|
||||
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
|
||||
n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
|
||||
n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
|
||||
n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
|
||||
n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
|
||||
n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0),
|
||||
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
|
||||
...(hasScheduleRules ? [
|
||||
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
|
||||
@@ -350,7 +449,7 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
|
||||
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
|
||||
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
|
||||
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0),
|
||||
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0),
|
||||
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
|
||||
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
|
||||
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
|
||||
@@ -358,12 +457,15 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
// SAH
|
||||
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
|
||||
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
|
||||
n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
|
||||
n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
|
||||
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
|
||||
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"),
|
||||
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"),
|
||||
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
|
||||
n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
|
||||
n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
|
||||
n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
|
||||
n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
|
||||
n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
|
||||
n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
|
||||
n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
|
||||
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
|
||||
n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),
|
||||
|
||||
// ALLOCATION
|
||||
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
|
||||
@@ -387,24 +489,24 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
] : []),
|
||||
|
||||
// CHARGEABILITY — full breakdown from deriveResourceForecast
|
||||
n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
|
||||
n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
|
||||
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
|
||||
...(forecast.bd > 0 ? [
|
||||
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"),
|
||||
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.mdi > 0 ? [
|
||||
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"),
|
||||
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.mo > 0 ? [
|
||||
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"),
|
||||
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.pdr > 0 ? [
|
||||
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"),
|
||||
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"),
|
||||
] : []),
|
||||
...(forecast.absence > 0 ? [
|
||||
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"),
|
||||
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"),
|
||||
] : []),
|
||||
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
|
||||
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
|
||||
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
|
||||
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"),
|
||||
|
||||
@@ -414,7 +516,16 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
|
||||
const links: GraphLink[] = [
|
||||
// INPUT → SAH
|
||||
l("input.country", "input.holidayContext", "holiday base", 1),
|
||||
l("input.state", "input.holidayContext", "regional scope", 1),
|
||||
l("input.city", "input.holidayContext", "local scope", 1),
|
||||
l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1),
|
||||
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
|
||||
l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2),
|
||||
l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2),
|
||||
l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
|
||||
l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
|
||||
l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
|
||||
...(hasScheduleRules ? [
|
||||
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
|
||||
] : []),
|
||||
@@ -422,14 +533,14 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
l("sah.weekendDays", "sah.grossWorkingDays", "−", 1),
|
||||
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
|
||||
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
|
||||
l("sah.grossWorkingDays", "sah.netWorkingDays", "−", 2),
|
||||
l("sah.grossWorkingDays", "sah.netWorkingDays", "− holiday/absence days", 2),
|
||||
l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1),
|
||||
l("sah.absenceDays", "sah.netWorkingDays", "−", 1),
|
||||
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
|
||||
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
|
||||
l("sah.baseHours", "sah.sah", "start from base capacity", 2),
|
||||
l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2),
|
||||
l("sah.absenceHours", "sah.sah", "− absence hours", 2),
|
||||
l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1),
|
||||
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
|
||||
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
|
||||
l("sah.netWorkingDays", "sah.sah", "×", 2),
|
||||
|
||||
// INPUT → ALLOCATION
|
||||
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
|
||||
@@ -489,6 +600,30 @@ export const computationGraphRouter = createTRPCRouter({
|
||||
resourceEid: resource.eid,
|
||||
month: input.month,
|
||||
assignmentCount: assignments.length,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
resolvedHolidays: resolvedHolidays.map((holiday) => ({
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scope: holiday.scope,
|
||||
calendarName: holiday.calendarName,
|
||||
})),
|
||||
factors: {
|
||||
weeklyAvailability,
|
||||
baseWorkingDays,
|
||||
effectiveWorkingDays,
|
||||
baseAvailableHours,
|
||||
effectiveAvailableHours,
|
||||
publicHolidayCount,
|
||||
publicHolidayWorkdayCount,
|
||||
publicHolidayHoursDeduction,
|
||||
absenceDayCount: absenceDateStrings.length,
|
||||
absenceHoursDeduction,
|
||||
chargeableHours,
|
||||
utilizationPct,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -9,19 +9,19 @@ import { z } from "zod";
|
||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
|
||||
/**
|
||||
* Count calendar days between two dates (inclusive).
|
||||
* Half-day vacations count as 0.5.
|
||||
*/
|
||||
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
|
||||
if (isHalfDay) return 0.5;
|
||||
const ms = endDate.getTime() - startDate.getTime();
|
||||
return Math.round(ms / 86_400_000) + 1;
|
||||
}
|
||||
type EntitlementSnapshot = {
|
||||
id: string;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get or create an entitlement record, applying carryover from previous year if needed.
|
||||
@@ -61,6 +61,14 @@ async function getOrCreateEntitlement(
|
||||
return entitlement;
|
||||
}
|
||||
|
||||
function calculateCarryoverDays(entitlement: {
|
||||
entitledDays: number;
|
||||
usedDays: number;
|
||||
pendingDays: number;
|
||||
}): number {
|
||||
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute used/pending days from actual vacation records and update the cached values.
|
||||
*/
|
||||
@@ -69,14 +77,57 @@ async function syncEntitlement(
|
||||
resourceId: string,
|
||||
year: number,
|
||||
defaultDays: number,
|
||||
) {
|
||||
visitedYears: Set<number> = new Set(),
|
||||
): Promise<EntitlementSnapshot> {
|
||||
if (visitedYears.has(year)) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: `Detected recursive entitlement sync for year ${year}`,
|
||||
});
|
||||
}
|
||||
visitedYears.add(year);
|
||||
|
||||
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
|
||||
where: { resourceId_year: { resourceId, year: year - 1 } },
|
||||
});
|
||||
|
||||
if (previousYearEntitlement) {
|
||||
previousYearEntitlement = await syncEntitlement(
|
||||
db,
|
||||
resourceId,
|
||||
year - 1,
|
||||
defaultDays,
|
||||
visitedYears,
|
||||
);
|
||||
}
|
||||
|
||||
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
|
||||
const carryoverDays = previousYearEntitlement
|
||||
? calculateCarryoverDays(previousYearEntitlement)
|
||||
: 0;
|
||||
const expectedEntitledDays = defaultDays + carryoverDays;
|
||||
const entitlementWithCarryover = (
|
||||
entitlement.carryoverDays !== carryoverDays
|
||||
|| entitlement.entitledDays !== expectedEntitledDays
|
||||
)
|
||||
? await db.vacationEntitlement.update({
|
||||
where: { id: entitlement.id },
|
||||
data: {
|
||||
carryoverDays,
|
||||
entitledDays: expectedEntitledDays,
|
||||
},
|
||||
})
|
||||
: entitlement;
|
||||
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
|
||||
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
|
||||
const holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
|
||||
|
||||
const vacations = await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId,
|
||||
type: { in: BALANCE_TYPES },
|
||||
startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
|
||||
startDate: { lte: yearEnd },
|
||||
endDate: { gte: yearStart },
|
||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||
},
|
||||
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
|
||||
@@ -86,13 +137,22 @@ async function syncEntitlement(
|
||||
let pendingDays = 0;
|
||||
|
||||
for (const v of vacations) {
|
||||
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
|
||||
const days = countVacationChargeableDays({
|
||||
vacation: v,
|
||||
periodStart: yearStart,
|
||||
periodEnd: yearEnd,
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
});
|
||||
if (v.status === VacationStatus.APPROVED) usedDays += days;
|
||||
else pendingDays += days;
|
||||
}
|
||||
|
||||
return db.vacationEntitlement.update({
|
||||
where: { id: entitlement.id },
|
||||
where: { id: entitlementWithCarryover.id },
|
||||
data: { usedDays, pendingDays },
|
||||
});
|
||||
}
|
||||
@@ -134,17 +194,23 @@ export const entitlementRouter = createTRPCRouter({
|
||||
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
|
||||
// Also count sick days (informational)
|
||||
const sickVacations = await ctx.db.vacation.findMany({
|
||||
const sickVacationsResult = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: input.resourceId,
|
||||
type: VacationType.SICK,
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
|
||||
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
|
||||
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
|
||||
},
|
||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
||||
});
|
||||
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
|
||||
const sickDays = sickVacations.reduce(
|
||||
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
|
||||
(sum, v) => sum + countCalendarDaysInPeriod(
|
||||
v,
|
||||
new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -171,7 +237,7 @@ export const entitlementRouter = createTRPCRouter({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||
return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}),
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { effortRuleRouter } from "./effort-rule.js";
|
||||
import { experienceMultiplierRouter } from "./experience-multiplier.js";
|
||||
import { estimateRouter } from "./estimate.js";
|
||||
import { entitlementRouter } from "./entitlement.js";
|
||||
import { holidayCalendarRouter } from "./holiday-calendar.js";
|
||||
import { importExportRouter } from "./import-export.js";
|
||||
import { insightsRouter } from "./insights.js";
|
||||
import { managementLevelRouter } from "./management-level.js";
|
||||
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
|
||||
insights: insightsRouter,
|
||||
vacation: vacationRouter,
|
||||
entitlement: entitlementRouter,
|
||||
holidayCalendar: holidayCalendarRouter,
|
||||
notification: notificationRouter,
|
||||
settings: settingsRouter,
|
||||
country: countryRouter,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
countPlanningEntries,
|
||||
listAssignmentBookings,
|
||||
} from "@capakraken/application";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -17,6 +18,10 @@ import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../ge
|
||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { validateImageDataUrl } from "../lib/image-validation.js";
|
||||
import {
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
|
||||
|
||||
@@ -127,20 +132,53 @@ export const projectRouter = createTRPCRouter({
|
||||
|
||||
const assignments = await ctx.db.assignment.findMany({
|
||||
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
|
||||
include: { resource: { include: { country: { select: { code: true } } } } },
|
||||
include: {
|
||||
resource: {
|
||||
include: {
|
||||
country: { select: { id: true, code: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const periodStart = assignments.length > 0
|
||||
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
||||
: new Date();
|
||||
const periodEnd = assignments.length > 0
|
||||
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
||||
: new Date();
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
assignments.map((assignment) => ({
|
||||
id: assignment.resource.id,
|
||||
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: assignment.resource.country?.id ?? assignment.resource.countryId,
|
||||
countryCode: assignment.resource.country?.code,
|
||||
federalState: assignment.resource.federalState,
|
||||
metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId,
|
||||
metroCityName: assignment.resource.metroCity?.name,
|
||||
})),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
);
|
||||
|
||||
const mapped: ShoringAssignment[] = assignments.map((a) => {
|
||||
const start = new Date(a.startDate);
|
||||
const end = new Date(a.endDate);
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1);
|
||||
const workingDays = Math.round(diffDays / 7 * 5);
|
||||
const workingDays = a.hoursPerDay > 0
|
||||
? calculateEffectiveBookedHours({
|
||||
availability: a.resource.availability as unknown as WeekdayAvailability,
|
||||
startDate: a.startDate,
|
||||
endDate: a.endDate,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: contexts.get(a.resourceId ?? a.resource.id),
|
||||
}) / a.hoursPerDay
|
||||
: 0;
|
||||
return {
|
||||
resourceId: a.resourceId,
|
||||
countryCode: a.resource.country?.code ?? null,
|
||||
hoursPerDay: a.hoursPerDay,
|
||||
workingDays: Math.max(1, workingDays),
|
||||
workingDays: Math.max(0, workingDays),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@capakraken/db";
|
||||
import {
|
||||
isChargeabilityActualBooking,
|
||||
isChargeabilityRelevantProject,
|
||||
listAssignmentBookings,
|
||||
} from "@capakraken/application";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
countEffectiveWorkingDays,
|
||||
getAvailabilityHoursForDate,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
// ─── Column Definitions ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,6 +44,7 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
|
||||
{ key: "departed", label: "Departed", dataType: "boolean" },
|
||||
{ key: "postalCode", label: "Postal Code", dataType: "string" },
|
||||
{ key: "federalState", label: "Federal State", dataType: "string" },
|
||||
{ key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" },
|
||||
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
|
||||
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
|
||||
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
|
||||
@@ -49,6 +64,7 @@ const PROJECT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "status", label: "Status", dataType: "string" },
|
||||
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
|
||||
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
|
||||
{ key: "clientId", label: "Client ID", dataType: "string" },
|
||||
{ key: "startDate", label: "Start Date", dataType: "date" },
|
||||
{ key: "endDate", label: "End Date", dataType: "date" },
|
||||
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
|
||||
@@ -61,10 +77,19 @@ const PROJECT_COLUMNS: ColumnDef[] = [
|
||||
|
||||
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "id", label: "ID", dataType: "string" },
|
||||
{ key: "resourceId", label: "Resource ID", dataType: "string" },
|
||||
{ key: "projectId", label: "Project ID", dataType: "string" },
|
||||
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" },
|
||||
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
|
||||
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
|
||||
{ key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" },
|
||||
{ key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" },
|
||||
{ key: "startDate", label: "Start Date", dataType: "date" },
|
||||
{ key: "endDate", label: "End Date", dataType: "date" },
|
||||
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
|
||||
@@ -77,10 +102,55 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
|
||||
{ key: "updatedAt", label: "Updated At", dataType: "date" },
|
||||
];
|
||||
|
||||
const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [
|
||||
{ key: "id", label: "Row ID", dataType: "string" },
|
||||
{ key: "resourceId", label: "Resource ID", dataType: "string" },
|
||||
{ key: "monthKey", label: "Month", dataType: "string" },
|
||||
{ key: "periodStart", label: "Period Start", dataType: "date" },
|
||||
{ key: "periodEnd", label: "Period End", dataType: "date" },
|
||||
{ key: "eid", label: "Employee ID", dataType: "string" },
|
||||
{ key: "displayName", label: "Name", dataType: "string" },
|
||||
{ key: "email", label: "Email", dataType: "string" },
|
||||
{ key: "chapter", label: "Chapter", dataType: "string" },
|
||||
{ key: "resourceType", label: "Resource Type", dataType: "string" },
|
||||
{ key: "isActive", label: "Active", dataType: "boolean" },
|
||||
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
|
||||
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
|
||||
{ key: "departed", label: "Departed", dataType: "boolean" },
|
||||
{ key: "countryCode", label: "Country Code", dataType: "string" },
|
||||
{ key: "countryName", label: "Country", dataType: "string" },
|
||||
{ key: "federalState", label: "Federal State", dataType: "string" },
|
||||
{ key: "metroCityName", label: "Metro City", dataType: "string" },
|
||||
{ key: "orgUnitName", label: "Org Unit", dataType: "string" },
|
||||
{ key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" },
|
||||
{ key: "managementLevelName", label: "Mgmt Level", dataType: "string" },
|
||||
{ key: "fte", label: "FTE", dataType: "number" },
|
||||
{ key: "lcrCents", label: "LCR (cents)", dataType: "number" },
|
||||
{ key: "ucrCents", label: "UCR (cents)", dataType: "number" },
|
||||
{ key: "currency", label: "Currency", dataType: "string" },
|
||||
{ key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" },
|
||||
{ key: "monthlyTargetHours", label: "Target Hours", dataType: "number" },
|
||||
{ key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" },
|
||||
{ key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" },
|
||||
{ key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" },
|
||||
{ key: "monthlySahHours", label: "SAH", dataType: "number" },
|
||||
{ key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" },
|
||||
{ key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" },
|
||||
{ key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" },
|
||||
{ key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" },
|
||||
{ key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" },
|
||||
{ key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" },
|
||||
{ key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" },
|
||||
{ key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" },
|
||||
{ key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" },
|
||||
{ key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" },
|
||||
];
|
||||
|
||||
const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
|
||||
resource: RESOURCE_COLUMNS,
|
||||
project: PROJECT_COLUMNS,
|
||||
assignment: ASSIGNMENT_COLUMNS,
|
||||
resource_month: RESOURCE_MONTH_COLUMNS,
|
||||
};
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -89,6 +159,7 @@ const ENTITY_MAP = {
|
||||
resource: "resource",
|
||||
project: "project",
|
||||
assignment: "assignment",
|
||||
resource_month: "resource_month",
|
||||
} as const;
|
||||
|
||||
type EntityKey = keyof typeof ENTITY_MAP;
|
||||
@@ -110,6 +181,7 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
|
||||
"id", "startDate", "endDate", "hoursPerDay", "percentage",
|
||||
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
|
||||
]),
|
||||
resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)),
|
||||
};
|
||||
|
||||
function getValidScalarField(entity: EntityKey, field: string): string | null {
|
||||
@@ -132,15 +204,14 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
|
||||
if (!def) continue;
|
||||
|
||||
if (colKey.includes(".")) {
|
||||
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
|
||||
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
|
||||
const fieldName = colKey.split(".").slice(1).join(".");
|
||||
const existing = select[relationName];
|
||||
if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
|
||||
(existing as { select: Record<string, boolean> }).select[fieldName] = true;
|
||||
} else {
|
||||
select[relationName] = { select: { [fieldName]: true } };
|
||||
}
|
||||
const fieldSegments = colKey.split(".").slice(1);
|
||||
const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
|
||||
? (existing as { select: Record<string, unknown> }).select
|
||||
: {};
|
||||
mergeSelectPath(relationSelect, fieldSegments);
|
||||
select[relationName] = { select: relationSelect };
|
||||
} else {
|
||||
select[colKey] = true;
|
||||
}
|
||||
@@ -149,6 +220,29 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
|
||||
return select;
|
||||
}
|
||||
|
||||
function mergeSelectPath(
|
||||
target: Record<string, unknown>,
|
||||
segments: string[],
|
||||
): void {
|
||||
const [head, ...tail] = segments;
|
||||
if (!head) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tail.length === 0) {
|
||||
target[head] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = target[head];
|
||||
const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
|
||||
? (existing as { select: Record<string, unknown> }).select
|
||||
: {};
|
||||
|
||||
mergeSelectPath(nestedSelect, tail);
|
||||
target[head] = { select: nestedSelect };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Prisma `where` from the filter array.
|
||||
* Only scalar top-level fields are allowed for safety.
|
||||
@@ -246,6 +340,8 @@ function csvEscape(value: unknown): string {
|
||||
|
||||
// ─── Input Schema ───────────────────────────────────────────────────────────
|
||||
|
||||
const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]);
|
||||
|
||||
const FilterSchema = z.object({
|
||||
field: z.string().min(1),
|
||||
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
|
||||
@@ -253,24 +349,171 @@ const FilterSchema = z.object({
|
||||
});
|
||||
|
||||
const ReportInputSchema = z.object({
|
||||
entity: z.enum(["resource", "project", "assignment"]),
|
||||
entity: reportEntitySchema,
|
||||
columns: z.array(z.string()).min(1),
|
||||
filters: z.array(FilterSchema).default([]),
|
||||
groupBy: z.string().optional(),
|
||||
sortBy: z.string().optional(),
|
||||
sortDir: z.enum(["asc", "desc"]).default("asc"),
|
||||
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
limit: z.number().int().min(1).max(5000).default(50),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
});
|
||||
|
||||
const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true });
|
||||
|
||||
const ReportTemplateEntity = {
|
||||
RESOURCE: "RESOURCE",
|
||||
PROJECT: "PROJECT",
|
||||
ASSIGNMENT: "ASSIGNMENT",
|
||||
RESOURCE_MONTH: "RESOURCE_MONTH",
|
||||
} as const;
|
||||
|
||||
type ReportTemplateEntity = (typeof ReportTemplateEntity)[keyof typeof ReportTemplateEntity];
|
||||
|
||||
type ReportTemplateRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
entity: ReportTemplateEntity;
|
||||
config: unknown;
|
||||
isShared: boolean;
|
||||
ownerId: string;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function getReportTemplateDelegate(db: unknown) {
|
||||
return (db as {
|
||||
reportTemplate: {
|
||||
findMany: (args: unknown) => Promise<ReportTemplateRecord[]>;
|
||||
findUnique: (args: unknown) => Promise<{ ownerId: string } | null>;
|
||||
update: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
|
||||
upsert: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
|
||||
delete: (args: unknown) => Promise<unknown>;
|
||||
};
|
||||
}).reportTemplate;
|
||||
}
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const reportRouter = createTRPCRouter({
|
||||
listTemplates: controllerProcedure.query(async ({ ctx }) => {
|
||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||
const templates = await reportTemplate.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: ctx.dbUser!.id },
|
||||
{ isShared: true },
|
||||
],
|
||||
},
|
||||
orderBy: [{ name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
entity: true,
|
||||
config: true,
|
||||
isShared: true,
|
||||
ownerId: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return templates.map((template: ReportTemplateRecord) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
entity: fromTemplateEntity(template.entity),
|
||||
config: ReportTemplateConfigSchema.parse(template.config),
|
||||
isShared: template.isShared,
|
||||
isOwner: template.ownerId === ctx.dbUser!.id,
|
||||
updatedAt: template.updatedAt,
|
||||
}));
|
||||
}),
|
||||
|
||||
saveTemplate: controllerProcedure
|
||||
.input(z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(500).optional(),
|
||||
isShared: z.boolean().default(false),
|
||||
config: ReportTemplateConfigSchema,
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||
const payload = input.config as unknown as Prisma.InputJsonValue;
|
||||
const entity = toTemplateEntity(input.config.entity);
|
||||
|
||||
if (input.id) {
|
||||
const existing = await reportTemplate.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
|
||||
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
|
||||
}
|
||||
|
||||
return reportTemplate.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
entity,
|
||||
config: payload,
|
||||
isShared: input.isShared,
|
||||
},
|
||||
select: { id: true, updatedAt: true },
|
||||
});
|
||||
}
|
||||
|
||||
return reportTemplate.upsert({
|
||||
where: {
|
||||
ownerId_name: {
|
||||
ownerId: ctx.dbUser!.id,
|
||||
name: input.name,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
description: input.description,
|
||||
entity,
|
||||
config: payload,
|
||||
isShared: input.isShared,
|
||||
},
|
||||
create: {
|
||||
ownerId: ctx.dbUser!.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
entity,
|
||||
config: payload,
|
||||
isShared: input.isShared,
|
||||
},
|
||||
select: { id: true, updatedAt: true },
|
||||
});
|
||||
}),
|
||||
|
||||
deleteTemplate: controllerProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||
const existing = await reportTemplate.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { ownerId: true },
|
||||
});
|
||||
|
||||
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" });
|
||||
}
|
||||
|
||||
await reportTemplate.delete({ where: { id: input.id } });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
/**
|
||||
* Return available columns for a given entity type.
|
||||
*/
|
||||
getAvailableColumns: controllerProcedure
|
||||
.input(z.object({ entity: z.enum(["resource", "project", "assignment"]) }))
|
||||
.input(z.object({ entity: reportEntitySchema }))
|
||||
.query(({ input }) => {
|
||||
const columns = COLUMN_MAP[input.entity];
|
||||
if (!columns) {
|
||||
@@ -285,40 +528,7 @@ export const reportRouter = createTRPCRouter({
|
||||
getReportData: controllerProcedure
|
||||
.input(ReportInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
|
||||
|
||||
const select = buildSelect(entity, columns);
|
||||
const where = buildWhere(entity, filters);
|
||||
|
||||
// Build orderBy (only scalar fields)
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(ctx.db, entity);
|
||||
|
||||
const [rawRows, totalCount] = await Promise.all([
|
||||
(modelDelegate as any).findMany({
|
||||
select,
|
||||
where,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
(modelDelegate as any).count({ where }),
|
||||
]);
|
||||
|
||||
// Flatten nested relations into dot-notation keys
|
||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||
|
||||
// Ensure column order matches request (plus id)
|
||||
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
|
||||
|
||||
return { rows, columns: outputColumns, totalCount };
|
||||
return executeReportQuery(ctx.db, input);
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -329,33 +539,12 @@ export const reportRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(50000).default(5000),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { entity, columns, filters, sortBy, sortDir, limit } = input;
|
||||
|
||||
const select = buildSelect(entity, columns);
|
||||
const where = buildWhere(entity, filters);
|
||||
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(ctx.db, entity);
|
||||
|
||||
const rawRows = await (modelDelegate as any).findMany({
|
||||
select,
|
||||
where,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
|
||||
const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
|
||||
const rows = result.rows;
|
||||
const outputColumns = result.columns;
|
||||
|
||||
// Build CSV
|
||||
const entityColumns = COLUMN_MAP[entity];
|
||||
const entityColumns = COLUMN_MAP[input.entity];
|
||||
const headerLabels = outputColumns.map((key) => {
|
||||
const def = entityColumns.find((c) => c.key === key);
|
||||
return def?.label ?? key;
|
||||
@@ -372,6 +561,385 @@ export const reportRouter = createTRPCRouter({
|
||||
}),
|
||||
});
|
||||
|
||||
type ReportInput = z.infer<typeof ReportInputSchema>;
|
||||
type FilterInput = z.infer<typeof FilterSchema>;
|
||||
|
||||
async function executeReportQuery(
|
||||
db: any,
|
||||
input: ReportInput,
|
||||
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
||||
if (input.entity === "resource_month") {
|
||||
return executeResourceMonthReport(db, input);
|
||||
}
|
||||
|
||||
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
|
||||
const select = buildSelect(entity, columns);
|
||||
const where = buildWhere(entity, filters);
|
||||
|
||||
let orderBy: Record<string, string> | undefined;
|
||||
if (sortBy) {
|
||||
const validField = getValidScalarField(entity, sortBy);
|
||||
if (validField) {
|
||||
orderBy = { [validField]: sortDir };
|
||||
}
|
||||
}
|
||||
|
||||
const modelDelegate = getModelDelegate(db, entity);
|
||||
const [rawRows, totalCount] = await Promise.all([
|
||||
(modelDelegate as any).findMany({
|
||||
select,
|
||||
where,
|
||||
...(orderBy ? { orderBy } : {}),
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
(modelDelegate as any).count({ where }),
|
||||
]);
|
||||
|
||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||
const outputColumns = ["id", ...columns.filter((column) => column !== "id")];
|
||||
|
||||
return {
|
||||
rows: rows.map((row) => pickColumns(row, outputColumns)),
|
||||
columns: outputColumns,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeResourceMonthReport(
|
||||
db: any,
|
||||
input: ReportInput,
|
||||
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
||||
const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7);
|
||||
const [year, month] = periodMonth.split("-").map(Number) as [number, number];
|
||||
const periodStart = new Date(Date.UTC(year, month - 1, 1));
|
||||
const periodEnd = new Date(Date.UTC(year, month, 0));
|
||||
|
||||
const resources = await db.resource.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
displayName: true,
|
||||
email: true,
|
||||
chapter: true,
|
||||
resourceType: true,
|
||||
isActive: true,
|
||||
chgResponsibility: true,
|
||||
rolledOff: true,
|
||||
departed: true,
|
||||
lcrCents: true,
|
||||
ucrCents: true,
|
||||
currency: true,
|
||||
fte: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
orgUnit: { select: { name: true } },
|
||||
managementLevelGroup: { select: { name: true, targetPercentage: true } },
|
||||
managementLevel: { select: { name: true } },
|
||||
},
|
||||
orderBy: { displayName: "asc" },
|
||||
});
|
||||
|
||||
const resourceIds = resources.map((resource: any) => resource.id);
|
||||
const [bookings, contexts] = await Promise.all([
|
||||
resourceIds.length > 0
|
||||
? listAssignmentBookings(db, {
|
||||
startDate: periodStart,
|
||||
endDate: periodEnd,
|
||||
resourceIds,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
loadResourceDailyAvailabilityContexts(
|
||||
db,
|
||||
resources.map((resource: any) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
),
|
||||
]);
|
||||
|
||||
const rows = resources.map((resource: any) => {
|
||||
const availability = resource.availability as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||
const baseWorkingDays = countEffectiveWorkingDays({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const effectiveWorkingDays = countEffectiveWorkingDays({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context: undefined,
|
||||
});
|
||||
const sahHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
});
|
||||
|
||||
const holidayDates = [...(context?.holidayDates ?? new Set<string>())];
|
||||
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
|
||||
count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
|
||||
), 0);
|
||||
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
|
||||
sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`))
|
||||
), 0);
|
||||
|
||||
let absenceDayEquivalent = 0;
|
||||
let absenceHoursDeduction = 0;
|
||||
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
|
||||
const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`));
|
||||
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
|
||||
continue;
|
||||
}
|
||||
absenceDayEquivalent += fraction;
|
||||
absenceHoursDeduction += dayHours * fraction;
|
||||
}
|
||||
|
||||
const actualBookedHours = resourceBookings
|
||||
.filter((booking) => isChargeabilityActualBooking(booking, false))
|
||||
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
}), 0);
|
||||
const expectedBookedHours = resourceBookings
|
||||
.filter((booking) => isChargeabilityRelevantProject(booking.project, true))
|
||||
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
context,
|
||||
}), 0);
|
||||
|
||||
const targetPct = resource.managementLevelGroup?.targetPercentage != null
|
||||
? resource.managementLevelGroup.targetPercentage * 100
|
||||
: resource.chargeabilityTarget;
|
||||
|
||||
return {
|
||||
id: `${resource.id}:${periodMonth}`,
|
||||
resourceId: resource.id,
|
||||
monthKey: periodMonth,
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
eid: resource.eid,
|
||||
displayName: resource.displayName,
|
||||
email: resource.email,
|
||||
chapter: resource.chapter,
|
||||
resourceType: resource.resourceType,
|
||||
isActive: resource.isActive,
|
||||
chgResponsibility: resource.chgResponsibility,
|
||||
rolledOff: resource.rolledOff,
|
||||
departed: resource.departed,
|
||||
countryCode: resource.country?.code ?? null,
|
||||
countryName: resource.country?.name ?? null,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
orgUnitName: resource.orgUnit?.name ?? null,
|
||||
managementLevelGroupName: resource.managementLevelGroup?.name ?? null,
|
||||
managementLevelName: resource.managementLevel?.name ?? null,
|
||||
fte: roundMetric(resource.fte),
|
||||
lcrCents: resource.lcrCents,
|
||||
ucrCents: resource.ucrCents,
|
||||
currency: resource.currency,
|
||||
monthlyChargeabilityTargetPct: roundMetric(targetPct),
|
||||
monthlyTargetHours: roundMetric((sahHours * targetPct) / 100),
|
||||
monthlyBaseWorkingDays: roundMetric(baseWorkingDays),
|
||||
monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays),
|
||||
monthlyBaseAvailableHours: roundMetric(baseAvailableHours),
|
||||
monthlySahHours: roundMetric(sahHours),
|
||||
monthlyPublicHolidayCount: holidayDates.length,
|
||||
monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount,
|
||||
monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction),
|
||||
monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent),
|
||||
monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction),
|
||||
monthlyActualBookedHours: roundMetric(actualBookedHours),
|
||||
monthlyExpectedBookedHours: roundMetric(expectedBookedHours),
|
||||
monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0),
|
||||
monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0),
|
||||
monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)),
|
||||
};
|
||||
});
|
||||
|
||||
const filteredRows = rows.filter((row: Record<string, unknown>) => input.filters.every((filter) => matchesInMemoryFilter(
|
||||
row,
|
||||
filter,
|
||||
RESOURCE_MONTH_COLUMNS,
|
||||
)));
|
||||
const sortedRows = sortInMemoryRows(filteredRows, input.sortBy, input.sortDir, RESOURCE_MONTH_COLUMNS);
|
||||
const totalCount = sortedRows.length;
|
||||
const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit);
|
||||
const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")];
|
||||
|
||||
return {
|
||||
rows: pagedRows.map((row) => pickColumns(row, outputColumns)),
|
||||
columns: outputColumns,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFilterValue(def: ColumnDef | undefined, value: string): unknown {
|
||||
if (!def) return value;
|
||||
if (def.dataType === "number") {
|
||||
const parsed = Number(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
if (def.dataType === "boolean") {
|
||||
return value === "true";
|
||||
}
|
||||
if (def.dataType === "date") {
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function matchesInMemoryFilter(
|
||||
row: Record<string, unknown>,
|
||||
filter: FilterInput,
|
||||
columns: ColumnDef[],
|
||||
): boolean {
|
||||
const def = columns.find((column) => column.key === filter.field);
|
||||
if (!def) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rowValueRaw = row[filter.field];
|
||||
const rowValue = def.dataType === "date" && typeof rowValueRaw === "string"
|
||||
? new Date(rowValueRaw).getTime()
|
||||
: rowValueRaw;
|
||||
const parsedFilterValue = parseFilterValue(def, filter.value);
|
||||
|
||||
if (parsedFilterValue === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (filter.op) {
|
||||
case "eq":
|
||||
return rowValue === parsedFilterValue;
|
||||
case "neq":
|
||||
return rowValue !== parsedFilterValue;
|
||||
case "gt":
|
||||
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue > parsedFilterValue;
|
||||
case "lt":
|
||||
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue < parsedFilterValue;
|
||||
case "gte":
|
||||
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue >= parsedFilterValue;
|
||||
case "lte":
|
||||
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue <= parsedFilterValue;
|
||||
case "contains":
|
||||
return typeof rowValue === "string" && rowValue.toLowerCase().includes(filter.value.toLowerCase());
|
||||
case "in":
|
||||
return filter.value.split(",").map((value) => value.trim()).includes(String(rowValue ?? ""));
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function sortInMemoryRows(
|
||||
rows: Record<string, unknown>[],
|
||||
sortBy: string | undefined,
|
||||
sortDir: "asc" | "desc",
|
||||
columns: ColumnDef[],
|
||||
): Record<string, unknown>[] {
|
||||
if (!sortBy) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const def = columns.find((column) => column.key === sortBy);
|
||||
if (!def) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const direction = sortDir === "asc" ? 1 : -1;
|
||||
return [...rows].sort((left, right) => {
|
||||
const leftValue = left[sortBy];
|
||||
const rightValue = right[sortBy];
|
||||
|
||||
if (leftValue == null && rightValue == null) return 0;
|
||||
if (leftValue == null) return 1;
|
||||
if (rightValue == null) return -1;
|
||||
|
||||
if (def.dataType === "number") {
|
||||
return direction * (Number(leftValue) - Number(rightValue));
|
||||
}
|
||||
if (def.dataType === "boolean") {
|
||||
return direction * (Number(Boolean(leftValue)) - Number(Boolean(rightValue)));
|
||||
}
|
||||
if (def.dataType === "date") {
|
||||
return direction * (new Date(String(leftValue)).getTime() - new Date(String(rightValue)).getTime());
|
||||
}
|
||||
return direction * String(leftValue).localeCompare(String(rightValue), "de");
|
||||
});
|
||||
}
|
||||
|
||||
function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
||||
return Object.fromEntries(columns.map((column) => [column, row[column]]));
|
||||
}
|
||||
|
||||
function roundMetric(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
|
||||
switch (entity) {
|
||||
case "resource":
|
||||
return ReportTemplateEntity.RESOURCE;
|
||||
case "project":
|
||||
return ReportTemplateEntity.PROJECT;
|
||||
case "assignment":
|
||||
return ReportTemplateEntity.ASSIGNMENT;
|
||||
case "resource_month":
|
||||
return ReportTemplateEntity.RESOURCE_MONTH;
|
||||
default:
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
||||
}
|
||||
}
|
||||
|
||||
function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey {
|
||||
switch (entity) {
|
||||
case ReportTemplateEntity.RESOURCE:
|
||||
return "resource";
|
||||
case ReportTemplateEntity.PROJECT:
|
||||
return "project";
|
||||
case ReportTemplateEntity.ASSIGNMENT:
|
||||
return "assignment";
|
||||
case ReportTemplateEntity.RESOURCE_MONTH:
|
||||
return "resource_month";
|
||||
default:
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the Prisma model delegate from entity key. */
|
||||
function getModelDelegate(db: any, entity: EntityKey) {
|
||||
switch (entity) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "@capakraken/application";
|
||||
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { computeChargeability } from "@capakraken/engine";
|
||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||
import {
|
||||
@@ -17,6 +16,12 @@ import {
|
||||
getAnonymizationDirectory,
|
||||
resolveResourceIdsByDisplayedEids,
|
||||
} from "../lib/anonymization.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
calculateEffectiveDayAvailability,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "../lib/resource-capacity.js";
|
||||
|
||||
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
||||
|
||||
@@ -46,6 +51,50 @@ function parseResourceCursor(cursor: string | undefined): { displayName: string;
|
||||
return null;
|
||||
}
|
||||
|
||||
type BookingForCapacity = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
};
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildDailyBookedHoursMap(
|
||||
bookings: BookingForCapacity[],
|
||||
availability: WeekdayAvailability,
|
||||
context: Parameters<typeof calculateEffectiveBookedHours>[0]["context"],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Map<string, number> {
|
||||
const dailyBookedHours = new Map<string, number>();
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const bookedHours = bookings.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: cursor,
|
||||
periodEnd: cursor,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
dailyBookedHours.set(isoDate, bookedHours);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return dailyBookedHours;
|
||||
}
|
||||
|
||||
export const resourceRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
@@ -1056,10 +1105,14 @@ export const resourceRouter = createTRPCRouter({
|
||||
portfolioUrl: true,
|
||||
postalCode: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
valueScore: true,
|
||||
valueScoreBreakdown: true,
|
||||
valueScoreUpdatedAt: true,
|
||||
userId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
@@ -1067,30 +1120,67 @@ export const resourceRouter = createTRPCRouter({
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const bookingsByResourceId = new Map<string, typeof bookings>();
|
||||
for (const booking of bookings) {
|
||||
if (!booking.resourceId) {
|
||||
continue;
|
||||
}
|
||||
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
|
||||
items.push(booking);
|
||||
bookingsByResourceId.set(booking.resourceId, items);
|
||||
}
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const periodDays =
|
||||
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
|
||||
const availableHours = dailyAvailHours * periodDays * (5 / 7);
|
||||
|
||||
let bookedHours = 0;
|
||||
let isOverbooked = false;
|
||||
const resourceBookings = bookings.filter(
|
||||
const availability = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
|
||||
(booking) =>
|
||||
booking.resourceId === r.id &&
|
||||
(input.includeProposed || booking.status !== "PROPOSED"),
|
||||
);
|
||||
for (const a of resourceBookings) {
|
||||
const days =
|
||||
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
|
||||
(1000 * 60 * 60 * 24) +
|
||||
1;
|
||||
bookedHours += a.hoursPerDay * days;
|
||||
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
|
||||
}
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const bookedHours = resourceBookings.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
|
||||
const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
|
||||
const date = new Date(`${isoDate}T00:00:00.000Z`);
|
||||
const dayCapacity = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date,
|
||||
context,
|
||||
});
|
||||
return dayCapacity > 0 && hours > dayCapacity;
|
||||
});
|
||||
|
||||
const utilizationPercent =
|
||||
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
||||
@@ -1125,6 +1215,11 @@ export const resourceRouter = createTRPCRouter({
|
||||
chapter: true,
|
||||
chargeabilityTarget: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const bookings = await listAssignmentBookings(ctx.db, {
|
||||
@@ -1132,10 +1227,25 @@ export const resourceRouter = createTRPCRouter({
|
||||
endDate: end,
|
||||
resourceIds: resources.map((resource) => resource.id),
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
start,
|
||||
end,
|
||||
);
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
|
||||
return resources.map((r) => {
|
||||
const avail = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
||||
|
||||
const actualAllocs = resourceBookings.filter((booking) =>
|
||||
@@ -1146,8 +1256,42 @@ export const resourceRouter = createTRPCRouter({
|
||||
isChargeabilityRelevantProject(booking.project, true),
|
||||
);
|
||||
|
||||
const actual = computeChargeability(avail, actualAllocs, start, end);
|
||||
const expected = computeChargeability(avail, expectedAllocs, start, end);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability: avail,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
});
|
||||
const actualBookedHours = actualAllocs.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability: avail,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const expectedBookedHours = expectedAllocs.reduce(
|
||||
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||
availability: avail,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: start,
|
||||
periodEnd: end,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const actualChargeability = availableHours > 0
|
||||
? Math.round((actualBookedHours / availableHours) * 100)
|
||||
: 0;
|
||||
const expectedChargeability = availableHours > 0
|
||||
? Math.round((expectedBookedHours / availableHours) * 100)
|
||||
: 0;
|
||||
|
||||
return anonymizeResource({
|
||||
id: r.id,
|
||||
@@ -1155,9 +1299,9 @@ export const resourceRouter = createTRPCRouter({
|
||||
displayName: r.displayName,
|
||||
chapter: r.chapter,
|
||||
chargeabilityTarget: r.chargeabilityTarget,
|
||||
actualChargeability: actual.chargeability,
|
||||
expectedChargeability: expected.chargeability,
|
||||
availableHours: actual.availableHours,
|
||||
actualChargeability,
|
||||
expectedChargeability,
|
||||
availableHours: Math.round(availableHours),
|
||||
}, directory);
|
||||
});
|
||||
}),
|
||||
@@ -1208,7 +1352,10 @@ export const resourceRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const now = new Date();
|
||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
const today = new Date(now);
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const thirtyDaysFromNow = new Date(today);
|
||||
thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);
|
||||
|
||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||
|
||||
@@ -1223,6 +1370,11 @@ export const resourceRouter = createTRPCRouter({
|
||||
skills: true,
|
||||
availability: true,
|
||||
chargeabilityTarget: true,
|
||||
countryId: true,
|
||||
federalState: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1232,7 +1384,7 @@ export const resourceRouter = createTRPCRouter({
|
||||
where: {
|
||||
resourceId: { in: allResourceIds },
|
||||
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
|
||||
endDate: { gte: now },
|
||||
endDate: { gte: today },
|
||||
startDate: { lte: thirtyDaysFromNow },
|
||||
},
|
||||
select: {
|
||||
@@ -1242,41 +1394,78 @@ export const resourceRouter = createTRPCRouter({
|
||||
hoursPerDay: true,
|
||||
},
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
ctx.db,
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
today,
|
||||
thirtyDaysFromNow,
|
||||
);
|
||||
const assignmentsByResourceId = new Map<string, typeof assignments>();
|
||||
for (const assignment of assignments) {
|
||||
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
|
||||
items.push(assignment);
|
||||
assignmentsByResourceId.set(assignment.resourceId, items);
|
||||
}
|
||||
|
||||
// Build utilization map (simple: booked hours per day / available hours per day)
|
||||
// Build utilization map with holiday-aware daily capacity over the next 30 days.
|
||||
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||
for (const r of resources) {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
||||
const resourceAssignments = assignments.filter((a) => a.resourceId === r.id);
|
||||
const availability = r.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(r.id);
|
||||
const resourceAssignments = assignmentsByResourceId.get(r.id) ?? [];
|
||||
const todayAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: today,
|
||||
periodEnd: today,
|
||||
context,
|
||||
});
|
||||
const todayBookedHours = resourceAssignments.reduce(
|
||||
(sum, assignment) => sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
periodStart: today,
|
||||
periodEnd: today,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
const utilizationPercent = todayAvailableHours > 0
|
||||
? Math.round((todayBookedHours / todayAvailableHours) * 100)
|
||||
: 0;
|
||||
const dailyBookedHours = buildDailyBookedHoursMap(
|
||||
resourceAssignments,
|
||||
availability,
|
||||
context,
|
||||
today,
|
||||
thirtyDaysFromNow,
|
||||
);
|
||||
|
||||
// Current daily booked hours (assignments overlapping today)
|
||||
let todayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= now && a.endDate >= now) {
|
||||
todayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0;
|
||||
|
||||
// Find earliest date when resource has capacity (within 30 days)
|
||||
let earliestAvailableDate: Date | null = null;
|
||||
const checkDate = new Date(now);
|
||||
const checkDate = new Date(today);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const day = checkDate.getDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
let dayBooked = 0;
|
||||
for (const a of resourceAssignments) {
|
||||
if (a.startDate <= checkDate && a.endDate >= checkDate) {
|
||||
dayBooked += a.hoursPerDay;
|
||||
}
|
||||
}
|
||||
if (dayBooked < dailyAvailHours * 0.8) {
|
||||
const dayAvailableHours = calculateEffectiveDayAvailability({
|
||||
availability,
|
||||
date: checkDate,
|
||||
context,
|
||||
});
|
||||
if (dayAvailableHours > 0) {
|
||||
const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
|
||||
if (dayBookedHours < dayAvailableHours * 0.8) {
|
||||
earliestAvailableDate = new Date(checkDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
checkDate.setDate(checkDate.getDate() + 1);
|
||||
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user