feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
+4
-2
@@ -1,6 +1,8 @@
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://planarchy:planarchy_dev@localhost:5433/planarchy
|
DATABASE_URL=postgresql://capakraken:capakraken_dev@localhost:5433/capakraken
|
||||||
DATABASE_URL_TEST=postgresql://planarchy:planarchy_test@localhost:5434/planarchy_test
|
DATABASE_URL_TEST=postgresql://capakraken:capakraken_test@localhost:5434/capakraken_test
|
||||||
|
ALLOW_DESTRUCTIVE_DB_TOOLS=false
|
||||||
|
CONFIRM_DESTRUCTIVE_DB_NAME=
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://localhost:6380
|
REDIS_URL=redis://localhost:6380
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ jobs:
|
|||||||
--health-retries=5
|
--health-retries=5
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql://capakraken:capakraken_test@localhost:5432/capakraken_test
|
DATABASE_URL: postgresql://capakraken:capakraken_test@localhost:5432/capakraken_test
|
||||||
|
ALLOW_DESTRUCTIVE_DB_TOOLS: "true"
|
||||||
|
CONFIRM_DESTRUCTIVE_DB_NAME: capakraken_test
|
||||||
REDIS_URL: redis://localhost:6379
|
REDIS_URL: redis://localhost:6379
|
||||||
PORT: 3100
|
PORT: 3100
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ node_modules/
|
|||||||
|
|
||||||
# Build outputs
|
# Build outputs
|
||||||
.next/
|
.next/
|
||||||
|
.next-e2e/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.turbo/
|
.turbo/
|
||||||
@@ -20,6 +21,7 @@ test-results/
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
*.e2e-backup
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN pnpm --filter @planarchy/db db:generate
|
RUN pnpm --filter @capakraken/db db:generate
|
||||||
|
|
||||||
EXPOSE 3100
|
EXPOSE 3100
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -39,12 +39,12 @@ COPY --from=deps /app/ ./
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Generate Prisma client
|
# Generate Prisma client
|
||||||
RUN pnpm --filter @planarchy/db db:generate
|
RUN pnpm --filter @capakraken/db db:generate
|
||||||
|
|
||||||
# Build the Next.js application
|
# Build the Next.js application
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN pnpm --filter @planarchy/web build
|
RUN pnpm --filter @capakraken/web build
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 3: Production runtime
|
# Stage 3: Production runtime
|
||||||
|
|||||||
@@ -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("Timeline", () => {
|
||||||
test.describe.configure({ mode: "serial" });
|
test.describe.configure({ mode: "serial" });
|
||||||
@@ -7,11 +15,7 @@ test.describe("Timeline", () => {
|
|||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
localStorage.setItem("capakraken_theme", JSON.stringify({ mode: "dark" }));
|
||||||
});
|
});
|
||||||
await page.goto("/auth/signin");
|
await signInAsAdmin(page);
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
|
||||||
await page.fill('input[type="password"]', "admin123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
|
||||||
await page.goto("/timeline");
|
await page.goto("/timeline");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,8 +91,13 @@ test.describe("Timeline", () => {
|
|||||||
.first();
|
.first();
|
||||||
const allocationPopoverField = page.getByText("Hours / day");
|
const allocationPopoverField = page.getByText("Hours / day");
|
||||||
|
|
||||||
const resourceHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
|
const resourceHoverTarget = page.getByTestId("timeline-resource-row-canvas").first();
|
||||||
await resourceHoverTarget.hover({ position: { x: 120, y: 20 } });
|
const resourceHoverBox = await resourceHoverTarget.boundingBox();
|
||||||
|
expect(resourceHoverBox).not.toBeNull();
|
||||||
|
if (!resourceHoverBox) {
|
||||||
|
throw new Error("Expected a resource timeline row canvas to be available");
|
||||||
|
}
|
||||||
|
await page.mouse.move(resourceHoverBox.x + 120, resourceHoverBox.y + 20);
|
||||||
await expect(heatmapTooltip).toBeVisible();
|
await expect(heatmapTooltip).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -109,8 +118,19 @@ test.describe("Timeline", () => {
|
|||||||
await expect(page.getByText(/projects/)).toBeVisible();
|
await expect(page.getByText(/projects/)).toBeVisible();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
const projectHoverTarget = page.locator(".relative.overflow-hidden.touch-none").first();
|
const projectHoverTarget = page.getByTestId("timeline-project-resource-row-canvas").first();
|
||||||
await projectHoverTarget.hover({ position: { x: 120, y: 20 } });
|
const projectHoverBox = await projectHoverTarget.boundingBox();
|
||||||
|
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
|
||||||
|
const projectAllocationBox = await projectAllocation.boundingBox();
|
||||||
|
expect(projectHoverBox).not.toBeNull();
|
||||||
|
expect(projectAllocationBox).not.toBeNull();
|
||||||
|
if (!projectHoverBox) {
|
||||||
|
throw new Error("Expected a project timeline row canvas to be available");
|
||||||
|
}
|
||||||
|
if (!projectAllocationBox) {
|
||||||
|
throw new Error("Expected a project allocation block to be available");
|
||||||
|
}
|
||||||
|
await page.mouse.move(projectAllocationBox.x + (projectAllocationBox.width / 2), projectHoverBox.y + 20);
|
||||||
await expect(heatmapTooltip).toBeVisible();
|
await expect(heatmapTooltip).toBeVisible();
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -118,8 +138,48 @@ test.describe("Timeline", () => {
|
|||||||
})
|
})
|
||||||
.toBe("rgba(3, 7, 18, 0.96)");
|
.toBe("rgba(3, 7, 18, 0.96)");
|
||||||
|
|
||||||
const projectAllocation = page.locator("div[style*='top: 2px'][style*='bottom: 2px']").nth(1);
|
|
||||||
await projectAllocation.click({ button: "right" });
|
await projectAllocation.click({ button: "right" });
|
||||||
await expect(allocationPopoverField).toBeVisible();
|
await expect(allocationPopoverField).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows resolved holiday overlays in the resource timeline and exposes the holiday name in the tooltip", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/timeline?startDate=2026-04-01&days=14&eids=bruce.banner", {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = page.locator('[data-testid="timeline-resource-row-canvas"][data-resource-eid="bruce.banner"]').first();
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
const holidayBlock = row.locator(
|
||||||
|
'[data-testid="timeline-vacation-block"][data-vacation-type="PUBLIC_HOLIDAY"][data-vacation-note="Karfreitag"]',
|
||||||
|
).first();
|
||||||
|
await expect(holidayBlock).toBeVisible();
|
||||||
|
|
||||||
|
const rowBox = await row.boundingBox();
|
||||||
|
const holidayBox = await holidayBlock.boundingBox();
|
||||||
|
expect(rowBox).not.toBeNull();
|
||||||
|
expect(holidayBox).not.toBeNull();
|
||||||
|
|
||||||
|
if (!rowBox || !holidayBox) {
|
||||||
|
throw new Error("Expected timeline row and holiday block bounding boxes to be available");
|
||||||
|
}
|
||||||
|
|
||||||
|
await row.hover({
|
||||||
|
position: {
|
||||||
|
x: holidayBox.x - rowBox.x + holidayBox.width / 2,
|
||||||
|
y: holidayBox.y - rowBox.y + Math.min(holidayBox.height / 2, rowBox.height - 4),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const holidayTooltip = page
|
||||||
|
.locator("div.fixed.pointer-events-none.rounded-xl.border.border-amber-700\\/50")
|
||||||
|
.or(page.locator("div.fixed.pointer-events-none.rounded-xl").filter({ hasText: "Karfreitag" }))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(holidayTooltip).toBeVisible();
|
||||||
|
await expect(holidayTooltip).toContainText("Karfreitag");
|
||||||
|
await expect(holidayTooltip).toContainText("3 April 2026");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
async function signInAsAdmin(page: Page) {
|
||||||
|
await page.goto("/auth/signin");
|
||||||
|
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
||||||
|
await page.fill('input[type="password"]', "admin123");
|
||||||
|
await page.click('button[type="submit"]');
|
||||||
|
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillDisplayDate(page: Page, label: RegExp, value: string) {
|
||||||
|
const [year, month, day] = value.split("-");
|
||||||
|
await page.getByLabel(label).fill(`${day}/${month}/${year}`);
|
||||||
|
}
|
||||||
|
|
||||||
test.describe("Vacations", () => {
|
test.describe("Vacations", () => {
|
||||||
test.describe("My Vacations (self-service)", () => {
|
test.describe("My Vacations (self-service)", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await signInAsAdmin(page);
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
|
||||||
await page.fill('input[type="password"]', "admin123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
|
||||||
await page.goto("/vacations/my");
|
await page.goto("/vacations/my");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -23,25 +32,19 @@ test.describe("Vacations", () => {
|
|||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("request vacation modal opens", async ({ page }) => {
|
test("request vacation is blocked without linked resource", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
const reqBtn = page.locator("button", { hasText: /Request Vacation/i });
|
||||||
await reqBtn.click();
|
await expect(reqBtn).toBeDisabled();
|
||||||
// Modal should show vacation form
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("text=Request Vacation").or(page.locator("text=Vacation Type")),
|
page.getByText("Your account is not linked to a resource. Please contact an administrator."),
|
||||||
).toBeVisible({ timeout: 5000 });
|
).toBeVisible({ timeout: 5000 });
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Vacation Management", () => {
|
test.describe("Vacation Management", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await page.goto("/auth/signin");
|
await signInAsAdmin(page);
|
||||||
await page.fill('input[type="email"]', "admin@capakraken.dev");
|
|
||||||
await page.fill('input[type="password"]', "admin123");
|
|
||||||
await page.click('button[type="submit"]');
|
|
||||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
|
||||||
await page.goto("/vacations");
|
await page.goto("/vacations");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,12 +65,59 @@ test.describe("Vacations", () => {
|
|||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("filter chips are visible on list tab", async ({ page }) => {
|
test("filter controls are visible on list tab", async ({ page }) => {
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
// Status filter options should be visible
|
const filters = page.getByRole("combobox");
|
||||||
|
|
||||||
|
await expect(filters).toHaveCount(3);
|
||||||
|
await expect(filters.nth(0)).toHaveValue("ALL");
|
||||||
|
await expect(filters.nth(1)).toHaveValue("ALL");
|
||||||
|
await expect(filters.nth(2)).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vacation request preview excludes regional public holidays from deducted days", async ({ page }) => {
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await page.getByRole("button", { name: /request vacation/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByLabel(/^type/i).locator("option", { hasText: /Public Holiday/i })).toHaveCount(0);
|
||||||
|
await page.getByLabel(/resource/i).selectOption({ label: "Bruce Banner (bruce.banner)" });
|
||||||
|
await page.getByLabel(/^type/i).selectOption("ANNUAL");
|
||||||
|
await fillDisplayDate(page, /start date/i, "2026-01-06");
|
||||||
|
await fillDisplayDate(page, /end date/i, "2026-01-06");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("vacation-preview-card")).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByTestId("vacation-preview-requested-days")).toHaveText("1");
|
||||||
|
await expect(page.getByTestId("vacation-preview-effective-days")).toHaveText("0");
|
||||||
|
await expect(page.getByTestId("vacation-preview-deducted-days")).toHaveText("0");
|
||||||
|
await expect(page.getByTestId("vacation-preview-public-holidays")).toContainText("2026-01-06");
|
||||||
|
await expect(page.getByTestId("vacation-preview-holiday-basis")).toContainText("Germany");
|
||||||
|
await expect(page.getByTestId("vacation-preview-holiday-sources")).toContainText("Holiday Calendar");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Admin Holiday Calendar", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await signInAsAdmin(page);
|
||||||
|
await page.goto("/admin/vacations");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("seeded holiday calendars can be selected and previewed", async ({ page }) => {
|
||||||
|
await expect(page.getByTestId("holiday-calendar-editor")).toBeVisible({ timeout: 10000 });
|
||||||
|
const germanyCalendarRow = page
|
||||||
|
.getByTestId(/holiday-calendar-row-/)
|
||||||
|
.filter({ hasText: "Referenzfeiertage Deutschland 2026-2027" })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(germanyCalendarRow).toBeVisible({ timeout: 10000 });
|
||||||
|
await germanyCalendarRow.click();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator("button", { hasText: /All|Pending|Approved/i }).first(),
|
page.getByRole("heading", { name: "Referenzfeiertage Deutschland 2026-2027" }),
|
||||||
).toBeVisible({ timeout: 10000 });
|
).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
await page.getByTestId("holiday-preview-year-input").fill("2026");
|
||||||
|
await expect(page.getByTestId("holiday-preview-table")).toContainText("2026-01-01");
|
||||||
|
await expect(page.getByTestId("holiday-preview-table")).toContainText("Neujahr");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference path="./.next/types/routes.d.ts" />
|
/// <reference path="./.next-e2e/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import path from "path";
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
distDir: process.env.NEXT_DIST_DIR ?? ".next",
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
outputFileTracingRoot: path.resolve(__dirname, "../.."),
|
||||||
devIndicators: false,
|
devIndicators: false,
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["recharts", "date-fns"],
|
optimizePackageImports: ["recharts", "date-fns"],
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { defineConfig, devices } from "@playwright/test";
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const e2ePort = process.env["PLAYWRIGHT_TEST_PORT"] ?? "3110";
|
||||||
|
const e2eBaseUrl = process.env["PLAYWRIGHT_TEST_BASE_URL"] ?? `http://localhost:${e2ePort}`;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
fullyParallel: true,
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env["CI"],
|
forbidOnly: !!process.env["CI"],
|
||||||
retries: process.env["CI"] ? 2 : 0,
|
retries: process.env["CI"] ? 2 : 0,
|
||||||
...(process.env["CI"] ? { workers: 1 } : {}),
|
workers: 1,
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
use: {
|
use: {
|
||||||
baseURL: "http://localhost:3100",
|
baseURL: e2eBaseUrl,
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
},
|
},
|
||||||
projects: [
|
projects: [
|
||||||
@@ -18,9 +21,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
webServer: {
|
webServer: {
|
||||||
command: "pnpm dev",
|
command: "node ./e2e/test-server.mjs",
|
||||||
url: "http://localhost:3100",
|
url: e2eBaseUrl,
|
||||||
reuseExistingServer: !process.env["CI"],
|
reuseExistingServer: false,
|
||||||
timeout: 120000,
|
timeout: 180000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEditor.js";
|
||||||
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
|
||||||
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
|
||||||
|
|
||||||
@@ -8,10 +9,40 @@ export default function AdminVacationsPage() {
|
|||||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Manage public holidays, entitlements, and year summaries</p>
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<PublicHolidayBatch />
|
|
||||||
<EntitlementManager />
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<HolidayCalendarEditor />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PublicHolidayBatch />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<EntitlementManager />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { usePermissions } from "~/hooks/usePermissions.js";
|
|||||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||||
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
import { useRowOrder } from "~/hooks/useRowOrder.js";
|
||||||
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
|
||||||
|
|
||||||
type ModalState =
|
type ModalState =
|
||||||
@@ -85,68 +86,22 @@ function FilterDropdown({
|
|||||||
tooltipContent?: ReactNode;
|
tooltipContent?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
open: isOpen,
|
||||||
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
|
onClose: () => setIsOpen(false),
|
||||||
|
matchTriggerWidth: true,
|
||||||
const updatePanelPosition = useCallback(() => {
|
});
|
||||||
const trigger = dropdownRef.current;
|
|
||||||
if (!trigger) return;
|
|
||||||
|
|
||||||
const rect = trigger.getBoundingClientRect();
|
|
||||||
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
|
|
||||||
const viewportPadding = 16;
|
|
||||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
|
|
||||||
|
|
||||||
setPanelPosition({
|
|
||||||
top: rect.bottom + 8,
|
|
||||||
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
|
|
||||||
minWidth: rect.width,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handlePointerDown(event: MouseEvent) {
|
|
||||||
const target = event.target as Node;
|
|
||||||
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handlePointerDown);
|
|
||||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
updatePanelPosition();
|
|
||||||
const rafId = window.requestAnimationFrame(updatePanelPosition);
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", updatePanelPosition);
|
|
||||||
window.addEventListener("scroll", updatePanelPosition, true);
|
|
||||||
window.addEventListener("keydown", handleEscape);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(rafId);
|
|
||||||
window.removeEventListener("resize", updatePanelPosition);
|
|
||||||
window.removeEventListener("scroll", updatePanelPosition, true);
|
|
||||||
window.removeEventListener("keydown", handleEscape);
|
|
||||||
};
|
|
||||||
}, [isOpen, updatePanelPosition]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={dropdownRef} className="relative">
|
<div ref={triggerRef} className="relative">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
onClick={() => {
|
||||||
|
const nextOpen = !isOpen;
|
||||||
|
handleOpenChange(nextOpen);
|
||||||
|
setIsOpen(nextOpen);
|
||||||
|
}}
|
||||||
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
|
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
|
||||||
>
|
>
|
||||||
<span className="text-left">{label}</span>
|
<span className="text-left">{label}</span>
|
||||||
@@ -160,9 +115,9 @@ function FilterDropdown({
|
|||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: panelPosition.top,
|
top: position.top,
|
||||||
left: panelPosition.left,
|
left: position.left,
|
||||||
minWidth: panelPosition.minWidth,
|
minWidth: position.minWidth,
|
||||||
}}
|
}}
|
||||||
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
|
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function GlobalError({
|
export default function GlobalError({
|
||||||
@@ -11,7 +10,7 @@ export default function GlobalError({
|
|||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Sentry.captureException(error);
|
console.error(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
|||||||
|
|
||||||
const PERMISSION_LABELS: Record<string, string> = {
|
const PERMISSION_LABELS: Record<string, string> = {
|
||||||
viewCosts: "View Costs",
|
viewCosts: "View Costs",
|
||||||
|
useAssistantAdvancedTools: "Assistant Advanced Tools",
|
||||||
exportData: "Export Data",
|
exportData: "Export Data",
|
||||||
importData: "Import Data",
|
importData: "Import Data",
|
||||||
approveVacations: "Approve Vacations",
|
approveVacations: "Approve Vacations",
|
||||||
@@ -24,6 +25,7 @@ const PERMISSION_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
|
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
|
||||||
viewCosts: "Access to cost data, budget views, and financial reports",
|
viewCosts: "Access to cost data, budget views, and financial reports",
|
||||||
|
useAssistantAdvancedTools: "Unlocks advanced AI assistant workflows for complex cross-entity analyses",
|
||||||
exportData: "Export data to Excel, CSV, or PDF formats",
|
exportData: "Export data to Excel, CSV, or PDF formats",
|
||||||
importData: "Import data from external sources (Dispo, Excel)",
|
importData: "Import data from external sources (Dispo, Excel)",
|
||||||
approveVacations: "Approve or reject vacation requests",
|
approveVacations: "Approve or reject vacation requests",
|
||||||
@@ -97,6 +99,8 @@ export function SystemRolesClient() {
|
|||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore TS2589: tRPC infers union type too deeply for the role config update payload
|
||||||
const updateMutation = trpc.systemRoleConfig.update.useMutation({
|
const updateMutation = trpc.systemRoleConfig.update.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await utils.systemRoleConfig.list.invalidate();
|
await utils.systemRoleConfig.list.invalidate();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
|
|||||||
|
|
||||||
const PERMISSION_LABELS: Record<string, string> = {
|
const PERMISSION_LABELS: Record<string, string> = {
|
||||||
viewCosts: "View Costs",
|
viewCosts: "View Costs",
|
||||||
|
useAssistantAdvancedTools: "Assistant Advanced Tools",
|
||||||
exportData: "Export Data",
|
exportData: "Export Data",
|
||||||
importData: "Import Data",
|
importData: "Import Data",
|
||||||
approveVacations: "Approve Vacations",
|
approveVacations: "Approve Vacations",
|
||||||
@@ -25,6 +26,7 @@ const PERMISSION_LABELS: Record<string, string> = {
|
|||||||
manageAllocations: "Manage Allocations",
|
manageAllocations: "Manage Allocations",
|
||||||
manageRoles: "Manage Roles",
|
manageRoles: "Manage Roles",
|
||||||
manageUsers: "Manage Users",
|
manageUsers: "Manage Users",
|
||||||
|
viewScores: "View Scores",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
||||||
|
|||||||
@@ -11,6 +11,50 @@ import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
|
|||||||
|
|
||||||
type Dimension = "2d" | "3d";
|
type Dimension = "2d" | "3d";
|
||||||
|
|
||||||
|
interface ResourceHolidayMeta {
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
scope: string;
|
||||||
|
calendarName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceFactorMeta {
|
||||||
|
weeklyAvailability?: Record<string, number>;
|
||||||
|
baseWorkingDays?: number;
|
||||||
|
effectiveWorkingDays?: number;
|
||||||
|
baseAvailableHours?: number;
|
||||||
|
effectiveAvailableHours?: number;
|
||||||
|
publicHolidayCount?: number;
|
||||||
|
publicHolidayWorkdayCount?: number;
|
||||||
|
publicHolidayHoursDeduction?: number;
|
||||||
|
absenceDayCount?: number;
|
||||||
|
absenceHoursDeduction?: number;
|
||||||
|
chargeableHours?: number;
|
||||||
|
utilizationPct?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceGraphMeta {
|
||||||
|
resourceName?: string;
|
||||||
|
resourceEid?: string;
|
||||||
|
month?: string;
|
||||||
|
countryCode?: string | null;
|
||||||
|
countryName?: string | null;
|
||||||
|
federalState?: string | null;
|
||||||
|
metroCityName?: string | null;
|
||||||
|
resolvedHolidays?: ResourceHolidayMeta[];
|
||||||
|
factors?: ResourceFactorMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number | undefined, digits = 1): string {
|
||||||
|
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("de-DE", {
|
||||||
|
minimumFractionDigits: digits,
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ComputationGraphClient() {
|
export default function ComputationGraphClient() {
|
||||||
const state = useComputationGraphData();
|
const state = useComputationGraphData();
|
||||||
const [dimension, setDimension] = useState<Dimension>("2d");
|
const [dimension, setDimension] = useState<Dimension>("2d");
|
||||||
@@ -24,10 +68,34 @@ export default function ComputationGraphClient() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
activeDomains,
|
activeDomains,
|
||||||
graphData,
|
graphData,
|
||||||
|
rawData,
|
||||||
highlightedNodes, setHighlightedNodes,
|
highlightedNodes, setHighlightedNodes,
|
||||||
domainFilter, toggleDomain,
|
domainFilter, toggleDomain,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
|
const resourceMeta = viewMode === "resource"
|
||||||
|
? (rawData?.meta as ResourceGraphMeta | undefined)
|
||||||
|
: undefined;
|
||||||
|
const resourceFactors = resourceMeta?.factors;
|
||||||
|
const weeklyAvailabilityEntries: Array<[string, number | undefined]> = resourceFactors?.weeklyAvailability
|
||||||
|
? [
|
||||||
|
["Mo", resourceFactors.weeklyAvailability.monday],
|
||||||
|
["Di", resourceFactors.weeklyAvailability.tuesday],
|
||||||
|
["Mi", resourceFactors.weeklyAvailability.wednesday],
|
||||||
|
["Do", resourceFactors.weeklyAvailability.thursday],
|
||||||
|
["Fr", resourceFactors.weeklyAvailability.friday],
|
||||||
|
["Sa", resourceFactors.weeklyAvailability.saturday],
|
||||||
|
["So", resourceFactors.weeklyAvailability.sunday],
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const weeklyAvailability = resourceFactors?.weeklyAvailability
|
||||||
|
? weeklyAvailabilityEntries
|
||||||
|
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && entry[1] > 0)
|
||||||
|
.map(([label, hours]) => `${label} ${formatNumber(hours, 1)}h`)
|
||||||
|
.join(" · ")
|
||||||
|
: "—";
|
||||||
|
const topHolidays = resourceMeta?.resolvedHolidays?.slice(0, 6) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||||
{/* ── Header Bar ── */}
|
{/* ── Header Bar ── */}
|
||||||
@@ -173,6 +241,102 @@ export default function ComputationGraphClient() {
|
|||||||
<ComputationGraph3D state={state} />
|
<ComputationGraph3D state={state} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{viewMode === "resource" && resourceMeta && (
|
||||||
|
<aside className="w-[24rem] overflow-y-auto border-l border-zinc-200 bg-white/90 p-4 dark:border-zinc-700 dark:bg-zinc-950/90">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Bezugsgroessen</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{resourceMeta.resourceName ?? "Resource"}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500">{resourceMeta.resourceEid ?? "—"} · {resourceMeta.month ?? month}</div>
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-zinc-700 dark:text-zinc-300">
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Land</div>
|
||||||
|
<div>{resourceMeta.countryName ?? resourceMeta.countryCode ?? "—"}{resourceMeta.countryCode ? ` (${resourceMeta.countryCode})` : ""}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Bundesland / Region</div>
|
||||||
|
<div>{resourceMeta.federalState ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Ort / Metro</div>
|
||||||
|
<div>{resourceMeta.metroCityName ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Wochenverfuegbarkeit</div>
|
||||||
|
<div>{weeklyAvailability}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Feiertagsbasis</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{resourceFactors?.publicHolidayCount ?? 0} Feiertage, {resourceFactors?.publicHolidayWorkdayCount ?? 0} wirksam
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{topHolidays.length > 0 ? topHolidays.map((holiday) => (
|
||||||
|
<div
|
||||||
|
key={`${holiday.date}-${holiday.name}`}
|
||||||
|
className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-zinc-900 dark:text-zinc-100">{holiday.name}</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{holiday.date} · {holiday.scope} · {holiday.calendarName ?? "Kalender"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-lg border border-dashed border-zinc-200 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800">
|
||||||
|
Keine aufgeloesten Feiertage im gewaehlten Monat.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Herleitung</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 text-sm dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">SAH Formel</div>
|
||||||
|
<div className="font-medium text-zinc-900 dark:text-zinc-100">
|
||||||
|
{formatNumber(resourceFactors?.baseAvailableHours)}h - {formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h - {formatNumber(resourceFactors?.absenceHoursDeduction)}h = {formatNumber(resourceFactors?.effectiveAvailableHours)}h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Basistage</div>
|
||||||
|
<div>{formatNumber(resourceFactors?.baseWorkingDays, 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Effektive Tage</div>
|
||||||
|
<div>{formatNumber(resourceFactors?.effectiveWorkingDays, 0)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Feiertagsabzug</div>
|
||||||
|
<div>{formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Abwesenheitsabzug</div>
|
||||||
|
<div>{formatNumber(resourceFactors?.absenceHoursDeduction)}h</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Chargeable Hours</div>
|
||||||
|
<div>{formatNumber(resourceFactors?.chargeableHours)}h</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
|
||||||
|
<div className="text-xs uppercase text-zinc-500">Auslastung</div>
|
||||||
|
<div>{formatNumber(resourceFactors?.utilizationPct)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,12 +6,19 @@ import {
|
|||||||
RESOURCE_VIEW_DOMAINS,
|
RESOURCE_VIEW_DOMAINS,
|
||||||
PROJECT_VIEW_DOMAINS,
|
PROJECT_VIEW_DOMAINS,
|
||||||
type Domain,
|
type Domain,
|
||||||
|
type GraphLink,
|
||||||
type GraphNode,
|
type GraphNode,
|
||||||
} from "./domain-colors";
|
} from "./domain-colors";
|
||||||
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
|
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
|
||||||
|
|
||||||
export type ViewMode = "resource" | "project";
|
export type ViewMode = "resource" | "project";
|
||||||
|
|
||||||
|
export interface ComputationGraphResponse {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
links: GraphLink[];
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ComputationGraphState {
|
export interface ComputationGraphState {
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
setViewMode: (m: ViewMode) => void;
|
setViewMode: (m: ViewMode) => void;
|
||||||
@@ -26,6 +33,7 @@ export interface ComputationGraphState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
activeDomains: Domain[];
|
activeDomains: Domain[];
|
||||||
graphData: ForceGraphData;
|
graphData: ForceGraphData;
|
||||||
|
rawData: ComputationGraphResponse | null;
|
||||||
highlightedNodes: Set<string> | null;
|
highlightedNodes: Set<string> | null;
|
||||||
setHighlightedNodes: (s: Set<string> | null) => void;
|
setHighlightedNodes: (s: Set<string> | null) => void;
|
||||||
hoveredNode: PositionedNode | null;
|
hoveredNode: PositionedNode | null;
|
||||||
@@ -144,6 +152,7 @@ export function useComputationGraphData(): ComputationGraphState {
|
|||||||
isLoading,
|
isLoading,
|
||||||
activeDomains,
|
activeDomains,
|
||||||
graphData,
|
graphData,
|
||||||
|
rawData: (rawData as ComputationGraphResponse | undefined) ?? null,
|
||||||
highlightedNodes,
|
highlightedNodes,
|
||||||
setHighlightedNodes,
|
setHighlightedNodes,
|
||||||
hoveredNode,
|
hoveredNode,
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
|
||||||
|
interface AssistantInsightMetric {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "neutral" | "good" | "warn" | "danger" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantInsightSection {
|
||||||
|
title: string;
|
||||||
|
metrics: AssistantInsightMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantInsight {
|
||||||
|
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
metrics: AssistantInsightMetric[];
|
||||||
|
sections?: AssistantInsightSection[];
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
insights?: AssistantInsight[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight inline markdown renderer — handles bold, italic, code,
|
|
||||||
* bullet lists, and numbered lists without a full markdown library.
|
|
||||||
*/
|
|
||||||
function renderMarkdown(text: string) {
|
function renderMarkdown(text: string) {
|
||||||
const lines = text.split("\n");
|
const lines = text.split("\n");
|
||||||
const elements: React.ReactNode[] = [];
|
const elements: React.ReactNode[] = [];
|
||||||
@@ -21,7 +38,7 @@ function renderMarkdown(text: string) {
|
|||||||
if (listItems.length > 0 && listType) {
|
if (listItems.length > 0 && listType) {
|
||||||
const Tag = listType;
|
const Tag = listType;
|
||||||
elements.push(
|
elements.push(
|
||||||
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "list-disc pl-4 my-1 space-y-0.5" : "list-decimal pl-4 my-1 space-y-0.5"}>
|
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "my-1 list-disc space-y-0.5 pl-4" : "my-1 list-decimal space-y-0.5 pl-4"}>
|
||||||
{listItems}
|
{listItems}
|
||||||
</Tag>,
|
</Tag>,
|
||||||
);
|
);
|
||||||
@@ -31,7 +48,6 @@ function renderMarkdown(text: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for (const [i, line] of lines.entries()) {
|
for (const [i, line] of lines.entries()) {
|
||||||
// Bullet list: "- item" or "* item"
|
|
||||||
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
|
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
|
||||||
if (bulletMatch?.[1]) {
|
if (bulletMatch?.[1]) {
|
||||||
if (listType !== "ul") flushList();
|
if (listType !== "ul") flushList();
|
||||||
@@ -40,7 +56,6 @@ function renderMarkdown(text: string) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Numbered list: "1. item"
|
|
||||||
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
|
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
|
||||||
if (numMatch?.[1]) {
|
if (numMatch?.[1]) {
|
||||||
if (listType !== "ol") flushList();
|
if (listType !== "ol") flushList();
|
||||||
@@ -49,54 +64,46 @@ function renderMarkdown(text: string) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not a list item — flush any pending list
|
|
||||||
flushList();
|
flushList();
|
||||||
|
|
||||||
// Empty line → spacing
|
|
||||||
if (line.trim() === "") {
|
if (line.trim() === "") {
|
||||||
elements.push(<div key={`br-${i}`} className="h-2" />);
|
elements.push(<div key={`br-${i}`} className="h-2" />);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular paragraph
|
|
||||||
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
|
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
|
||||||
}
|
}
|
||||||
|
|
||||||
flushList();
|
flushList();
|
||||||
return elements;
|
return elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse inline formatting: **bold**, *italic*, `code` */
|
|
||||||
function inlineFormat(text: string): React.ReactNode {
|
function inlineFormat(text: string): React.ReactNode {
|
||||||
// Split by inline patterns, preserving delimiters
|
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: React.ReactNode[] = [];
|
||||||
// Regex: **bold**, *italic*, `code`
|
|
||||||
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
|
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
|
||||||
let lastIndex = 0;
|
let lastIndex = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = regex.exec(text)) !== null) {
|
while ((match = regex.exec(text)) !== null) {
|
||||||
// Text before this match
|
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) {
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (match[2]) {
|
if (match[2]) {
|
||||||
// **bold**
|
|
||||||
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
|
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
|
||||||
} else if (match[3]) {
|
} else if (match[3]) {
|
||||||
// *italic*
|
|
||||||
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
|
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
|
||||||
} else if (match[4]) {
|
} else if (match[4]) {
|
||||||
// `code`
|
|
||||||
parts.push(
|
parts.push(
|
||||||
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
|
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
|
||||||
{match[4]}
|
{match[4]}
|
||||||
</code>,
|
</code>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remaining text
|
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) {
|
||||||
parts.push(text.slice(lastIndex));
|
parts.push(text.slice(lastIndex));
|
||||||
}
|
}
|
||||||
@@ -104,7 +111,72 @@ function inlineFormat(text: string): React.ReactNode {
|
|||||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessage({ role, content }: ChatMessageProps) {
|
function metricToneClasses(tone: AssistantInsightMetric["tone"] | undefined): string {
|
||||||
|
switch (tone) {
|
||||||
|
case "good":
|
||||||
|
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300";
|
||||||
|
case "warn":
|
||||||
|
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300";
|
||||||
|
case "danger":
|
||||||
|
return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300";
|
||||||
|
case "info":
|
||||||
|
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300";
|
||||||
|
default:
|
||||||
|
return "border-gray-200 bg-white text-gray-700 dark:border-slate-700 dark:bg-slate-900/60 dark:text-gray-200";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsightMetric({ metric }: { metric: AssistantInsightMetric }) {
|
||||||
|
return (
|
||||||
|
<div className={clsx("rounded-xl border px-2.5 py-2", metricToneClasses(metric.tone))}>
|
||||||
|
<div className="text-[10px] font-medium uppercase tracking-[0.08em] opacity-70">{metric.label}</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold leading-tight">{metric.value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InsightCard({ insight }: { insight: AssistantInsight }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/90 p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/85">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{insight.title}</div>
|
||||||
|
{insight.subtitle && (
|
||||||
|
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{insight.subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
{insight.kind.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
|
{insight.metrics.map((metric, index) => (
|
||||||
|
<InsightMetric key={`${insight.kind}-${metric.label}-${index}`} metric={metric} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{insight.sections && insight.sections.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{insight.sections.map((section, sectionIndex) => (
|
||||||
|
<div key={`${insight.kind}-${section.title}-${sectionIndex}`} className="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 p-2.5 dark:border-slate-700 dark:bg-slate-800/60">
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
|
||||||
|
{section.title}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{section.metrics.map((metric, metricIndex) => (
|
||||||
|
<InsightMetric key={`${section.title}-${metric.label}-${metricIndex}`} metric={metric} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessage({ role, content, insights }: ChatMessageProps) {
|
||||||
const isUser = role === "user";
|
const isUser = role === "user";
|
||||||
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]);
|
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]);
|
||||||
|
|
||||||
@@ -121,12 +193,19 @@ export function ChatMessage({ role, content }: ChatMessageProps) {
|
|||||||
<span className="whitespace-pre-wrap break-words">{content}</span>
|
<span className="whitespace-pre-wrap break-words">{content}</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 mb-1.5">
|
<span className="mb-1.5 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
|
||||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
</svg>
|
</svg>
|
||||||
AI Generated
|
AI Generated
|
||||||
</span>
|
</span>
|
||||||
|
{insights && insights.length > 0 && (
|
||||||
|
<div className="mb-2 space-y-2">
|
||||||
|
{insights.map((insight, index) => (
|
||||||
|
<InsightCard key={`${insight.kind}-${insight.title}-${index}`} insight={insight} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-0.5 break-words">{rendered}</div>
|
<div className="space-y-0.5 break-words">{rendered}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ function resolvePageContext(pathname: string): string {
|
|||||||
interface Message {
|
interface Message {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
|
insights?: AssistantInsight[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantInsightMetric {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "neutral" | "good" | "warn" | "danger" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantInsightSection {
|
||||||
|
title: string;
|
||||||
|
metrics: AssistantInsightMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantInsight {
|
||||||
|
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
metrics: AssistantInsightMetric[];
|
||||||
|
sections?: AssistantInsightSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "capakraken-chat-messages";
|
const STORAGE_KEY = "capakraken-chat-messages";
|
||||||
@@ -47,7 +67,23 @@ function loadPersistedMessages(): Message[] {
|
|||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||||
if (raw) return JSON.parse(raw) as Message[];
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.filter((item): item is Partial<Message> & { role: Message["role"]; content: string } => (
|
||||||
|
typeof item === "object"
|
||||||
|
&& item !== null
|
||||||
|
&& (item.role === "user" || item.role === "assistant")
|
||||||
|
&& typeof item.content === "string"
|
||||||
|
))
|
||||||
|
.map((item) => ({
|
||||||
|
role: item.role,
|
||||||
|
content: item.content,
|
||||||
|
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch { /* ignore corrupt data */ }
|
} catch { /* ignore corrupt data */ }
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -101,10 +137,23 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
|||||||
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
|
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
|
||||||
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
|
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
|
||||||
});
|
});
|
||||||
setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]);
|
const typedReply = reply as {
|
||||||
|
content: string;
|
||||||
|
role: "assistant";
|
||||||
|
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
|
||||||
|
insights?: AssistantInsight[];
|
||||||
|
};
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: typedReply.content,
|
||||||
|
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
// Handle actions from the AI (navigation, data invalidation)
|
// Handle actions from the AI (navigation, data invalidation)
|
||||||
const actions = (reply as { actions?: Array<{ type: string; url?: string; scope?: string[] }> }).actions;
|
const actions = typedReply.actions;
|
||||||
if (actions) {
|
if (actions) {
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
if (action.type === "navigate" && action.url) {
|
if (action.type === "navigate" && action.url) {
|
||||||
@@ -230,7 +279,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<ChatMessage key={i} role={msg.role} content={msg.content} />
|
<ChatMessage
|
||||||
|
key={i}
|
||||||
|
role={msg.role}
|
||||||
|
content={msg.content}
|
||||||
|
{...(msg.insights ? { insights: msg.insights } : {})}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{isLoading && <TypingIndicator />}
|
{isLoading && <TypingIndicator />}
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -158,6 +158,12 @@ export function DashboardClient() {
|
|||||||
<WidgetContainer
|
<WidgetContainer
|
||||||
title={widget.title ?? getWidget(widget.type).label}
|
title={widget.title ?? getWidget(widget.type).label}
|
||||||
description={getWidget(widget.type).description}
|
description={getWidget(widget.type).description}
|
||||||
|
showDetails={widget.config.showDetails === true}
|
||||||
|
onToggleDetails={() =>
|
||||||
|
updateWidgetConfig(widget.id, {
|
||||||
|
showDetails: widget.config.showDetails !== true,
|
||||||
|
})
|
||||||
|
}
|
||||||
onRemove={() => removeWidget(widget.id)}
|
onRemove={() => removeWidget(widget.id)}
|
||||||
>
|
>
|
||||||
{renderWidget(widget.type, widget.config, (update) =>
|
{renderWidget(widget.type, widget.config, (update) =>
|
||||||
|
|||||||
@@ -8,9 +8,19 @@ interface WidgetContainerProps {
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
|
showDetails?: boolean;
|
||||||
|
onToggleDetails?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WidgetContainer({ title, description, onRemove, children, isDragging }: WidgetContainerProps) {
|
export function WidgetContainer({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onRemove,
|
||||||
|
children,
|
||||||
|
isDragging,
|
||||||
|
showDetails = false,
|
||||||
|
onToggleDetails,
|
||||||
|
}: WidgetContainerProps) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 16 }}
|
initial={{ opacity: 0, y: 16 }}
|
||||||
@@ -19,14 +29,12 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
|
|||||||
className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
|
className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
|
||||||
isDragging
|
isDragging
|
||||||
? "shadow-xl border-brand-400 dark:border-brand-500 scale-[1.01] ring-2 ring-brand-400/30"
|
? "shadow-xl border-brand-400 dark:border-brand-500 scale-[1.01] ring-2 ring-brand-400/30"
|
||||||
: "bg-white dark:bg-gray-900 border-gray-200/80 dark:border-gray-700/60 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600"
|
: "border-gray-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95),rgba(255,255,255,0.98))] shadow-sm hover:shadow-md hover:border-gray-300 dark:border-gray-700/60 dark:bg-[linear-gradient(180deg,rgba(17,24,39,0.96),rgba(17,24,39,0.92))] dark:hover:border-gray-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header — clean, no background separation */}
|
<div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3 shrink-0 widget-drag-handle group">
|
||||||
<div className="flex items-center justify-between px-4 pt-3.5 pb-2 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle group">
|
<div className="min-w-0 flex-1 cursor-grab active:cursor-grabbing">
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Drag grip dots */}
|
|
||||||
<svg
|
<svg
|
||||||
className="w-3.5 h-5 text-gray-300 dark:text-gray-600 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="w-3.5 h-5 text-gray-300 dark:text-gray-600 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
viewBox="0 0 14 20"
|
viewBox="0 0 14 20"
|
||||||
@@ -39,32 +47,58 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
|
|||||||
<circle cx="4" cy="16" r="1.5" />
|
<circle cx="4" cy="16" r="1.5" />
|
||||||
<circle cx="10" cy="16" r="1.5" />
|
<circle cx="10" cy="16" r="1.5" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{title}</span>
|
<span className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{showDetails ? (
|
||||||
|
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-brand-700 dark:bg-brand-500/10 dark:text-brand-300">
|
||||||
|
Details
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-[11px] text-gray-400 dark:text-gray-500 truncate mt-0.5 ml-[22px]">{description}</p>
|
<p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
type="button"
|
{onToggleDetails ? (
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation();
|
type="button"
|
||||||
onRemove();
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
className="ml-2 p-1.5 text-gray-300 dark:text-gray-600 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors shrink-0 opacity-0 group-hover:opacity-100"
|
onToggleDetails();
|
||||||
title="Remove widget"
|
}}
|
||||||
>
|
className={`rounded-xl border px-3 py-1.5 text-[11px] font-semibold transition ${
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
showDetails
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
? "border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-500/30 dark:bg-brand-500/10 dark:text-brand-300"
|
||||||
</svg>
|
: "border-gray-200 bg-white/80 text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
</button>
|
}`}
|
||||||
|
title={showDetails ? "Hide details" : "Show details"}
|
||||||
|
>
|
||||||
|
{showDetails ? "Details on" : "Details off"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500 dark:text-gray-600 dark:hover:bg-red-950/30 dark:hover:text-red-400"
|
||||||
|
title="Remove widget"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subtle separator */}
|
<div className="mx-4 border-t border-gray-200/80 dark:border-gray-800" />
|
||||||
<div className="mx-4 border-t border-gray-100 dark:border-gray-800" />
|
|
||||||
|
|
||||||
{/* Body */}
|
<div className="flex-1 overflow-auto p-4 pt-3">{children}</div>
|
||||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,67 @@ function textColorClass(pct: number): string {
|
|||||||
return "text-green-700";
|
return "text-green-700";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BudgetForecastLocation = {
|
||||||
|
countryCode?: string | null;
|
||||||
|
countryName?: string | null;
|
||||||
|
federalState?: string | null;
|
||||||
|
metroCityName?: string | null;
|
||||||
|
activeAssignmentCount?: number;
|
||||||
|
burnRateCents?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BudgetForecastRow = {
|
||||||
|
projectId?: string;
|
||||||
|
projectName: string;
|
||||||
|
shortCode: string;
|
||||||
|
clientId: string | null;
|
||||||
|
clientName: string | null;
|
||||||
|
budgetCents: number;
|
||||||
|
spentCents: number;
|
||||||
|
remainingCents?: number;
|
||||||
|
burnRate: number;
|
||||||
|
estimatedExhaustionDate: string | null;
|
||||||
|
pctUsed: number;
|
||||||
|
activeAssignmentCount?: number;
|
||||||
|
calendarLocations?: BudgetForecastLocation[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatCurrency(cents: number | undefined): string {
|
||||||
|
if (cents === undefined) return "—";
|
||||||
|
return `${(cents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocation(location: BudgetForecastLocation): string {
|
||||||
|
const parts = [
|
||||||
|
location.countryCode ?? location.countryName ?? null,
|
||||||
|
location.federalState ?? null,
|
||||||
|
location.metroCityName ?? null,
|
||||||
|
].filter((part): part is string => Boolean(part));
|
||||||
|
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
helper,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
helper: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/40">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{value}</div>
|
||||||
|
<div className="mt-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">{helper}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||||
|
const showDetails = config.showDetails === true;
|
||||||
const { clients } = useWidgetFilterOptions();
|
const { clients } = useWidgetFilterOptions();
|
||||||
|
|
||||||
const filters = useMemo<WidgetFilter[]>(
|
const filters = useMemo<WidgetFilter[]>(
|
||||||
@@ -39,7 +99,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
const clientId = (config.clientId as string) ?? "";
|
const clientId = (config.clientId as string) ?? "";
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const all = data ?? [];
|
const all = (data ?? []) as BudgetForecastRow[];
|
||||||
return all.filter((r) => {
|
return all.filter((r) => {
|
||||||
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
|
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
|
||||||
if (clientId && r.clientId !== clientId) return false;
|
if (clientId && r.clientId !== clientId) return false;
|
||||||
@@ -47,6 +107,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
});
|
});
|
||||||
}, [data, search, clientId]);
|
}, [data, search, clientId]);
|
||||||
|
|
||||||
|
const totals = useMemo(() => rows.reduce((acc, row) => {
|
||||||
|
acc.budgetCents += row.budgetCents;
|
||||||
|
acc.spentCents += row.spentCents;
|
||||||
|
acc.remainingCents += row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents);
|
||||||
|
acc.burnRate += row.burnRate;
|
||||||
|
acc.activeAssignmentCount += row.activeAssignmentCount ?? 0;
|
||||||
|
return acc;
|
||||||
|
}, {
|
||||||
|
budgetCents: 0,
|
||||||
|
spentCents: 0,
|
||||||
|
remainingCents: 0,
|
||||||
|
burnRate: 0,
|
||||||
|
activeAssignmentCount: 0,
|
||||||
|
}), [rows]);
|
||||||
|
|
||||||
if (isLoading && !data) {
|
if (isLoading && !data) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
<div className="flex flex-col gap-1 pt-1">
|
||||||
@@ -75,6 +150,28 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
|
||||||
|
<div className="mb-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<SummaryCard
|
||||||
|
label="Projects"
|
||||||
|
value={String(rows.length)}
|
||||||
|
helper={`${totals.activeAssignmentCount} active assignments in scope`}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Budget"
|
||||||
|
value={formatCurrency(totals.budgetCents)}
|
||||||
|
helper={`${formatCurrency(totals.spentCents)} spent`}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Remaining"
|
||||||
|
value={formatCurrency(totals.remainingCents)}
|
||||||
|
helper={`${rows.filter((row) => row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Burn / Month"
|
||||||
|
value={formatCurrency(totals.burnRate)}
|
||||||
|
helper="Holiday- and absence-adjusted active burn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="overflow-auto flex-1">
|
<div className="overflow-auto flex-1">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
@@ -86,7 +183,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
|
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
|
Burn/mo <InfoTooltip content="Current-month burn rate based on active allocations, adjusted for regional holidays and approved absences where resource calendars are available." />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
|
||||||
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
|
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
|
||||||
@@ -96,11 +193,41 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[140px] truncate">
|
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[260px] align-top">
|
||||||
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
|
<div>
|
||||||
{row.projectName}
|
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
|
||||||
|
{row.projectName}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
{row.clientName ?? "No client"}
|
||||||
|
{!showDetails && row.calendarLocations && row.calendarLocations.length > 0
|
||||||
|
? ` · ${formatLocation(row.calendarLocations[0]!)}`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="mt-1 space-y-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
|
||||||
|
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
|
||||||
|
<div>Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{row.calendarLocations && row.calendarLocations.length > 0 ? (
|
||||||
|
row.calendarLocations.slice(0, 4).map((location) => (
|
||||||
|
<span
|
||||||
|
key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}
|
||||||
|
className="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 dark:border-gray-700 dark:bg-gray-900/70"
|
||||||
|
>
|
||||||
|
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span>No active calendar basis in the current month</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2 align-top">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -112,14 +239,37 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
{row.pctUsed}%
|
{row.pctUsed}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
{formatCurrency(row.spentCents)} / {formatCurrency(row.budgetCents)}
|
||||||
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
|
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums align-top">
|
||||||
{row.burnRate > 0
|
<div>
|
||||||
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
|
{row.burnRate > 0 ? formatCurrency(row.burnRate) : "\u2014"}
|
||||||
: "\u2014"}
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
|
||||||
|
{(row.calendarLocations ?? []).slice(0, 3).map((location) => (
|
||||||
|
<div key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}>
|
||||||
|
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums">
|
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums align-top">
|
||||||
{row.estimatedExhaustionDate ?? "\u2014"}
|
<div>{row.estimatedExhaustionDate ?? "\u2014"}</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
at {formatCurrency(row.burnRate)} / month
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -36,8 +36,91 @@ type ChargeabilityRow = {
|
|||||||
chargeabilityTarget: number;
|
chargeabilityTarget: number;
|
||||||
actualChargeability: number;
|
actualChargeability: number;
|
||||||
expectedChargeability: number;
|
expectedChargeability: number;
|
||||||
|
countryCode?: string | null;
|
||||||
|
countryName?: string | null;
|
||||||
|
federalState?: string | null;
|
||||||
|
metroCityName?: string | null;
|
||||||
|
derivation?: {
|
||||||
|
weeklyAvailabilityHours: number;
|
||||||
|
baseWorkingDays: number;
|
||||||
|
effectiveWorkingDayEquivalent: number;
|
||||||
|
baseAvailableHours: number;
|
||||||
|
effectiveAvailableHours: number;
|
||||||
|
publicHolidayCount: number;
|
||||||
|
publicHolidayWorkdayCount: number;
|
||||||
|
publicHolidayHoursDeduction: number;
|
||||||
|
absenceDayEquivalent: number;
|
||||||
|
absenceHoursDeduction: number;
|
||||||
|
actualBookedHours: number;
|
||||||
|
expectedBookedHours: number;
|
||||||
|
targetBookedHours: number;
|
||||||
|
unassignedHours: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatHours(value: number | undefined): string {
|
||||||
|
if (value === undefined) return "—";
|
||||||
|
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayEquivalent(value: number | undefined): string {
|
||||||
|
if (value === undefined) return "—";
|
||||||
|
return Number.isInteger(value) ? `${value}` : value.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricPill({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">{label}</span>
|
||||||
|
<span className="text-gray-700 dark:text-gray-200">{value}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocation(row: ChargeabilityRow): string {
|
||||||
|
const parts = [row.countryCode ?? row.countryName ?? null, row.federalState ?? null, row.metroCityName ?? null]
|
||||||
|
.filter((part): part is string => Boolean(part));
|
||||||
|
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) {
|
||||||
|
const derivation = row.derivation;
|
||||||
|
|
||||||
|
if (!derivation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1.5 space-y-1.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<MetricPill label="Loc" value={formatLocation(row)} />
|
||||||
|
<MetricPill label="Week" value={formatHours(derivation.weeklyAvailabilityHours)} />
|
||||||
|
<MetricPill label="Target" value={formatHours(derivation.targetBookedHours)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)})
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)})
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Free {formatHours(derivation.unassignedHours)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
|
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -74,7 +157,13 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
|
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
|
||||||
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
|
const config = _config as {
|
||||||
|
topN?: number;
|
||||||
|
watchlistThreshold?: number;
|
||||||
|
chapter?: string;
|
||||||
|
includeProposed?: boolean;
|
||||||
|
showDetails?: boolean;
|
||||||
|
};
|
||||||
const { chapters } = useWidgetFilterOptions();
|
const { chapters } = useWidgetFilterOptions();
|
||||||
|
|
||||||
const widgetFilters = useMemo<WidgetFilter[]>(
|
const widgetFilters = useMemo<WidgetFilter[]>(
|
||||||
@@ -86,6 +175,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
|||||||
);
|
);
|
||||||
|
|
||||||
const includeProposed = !!config.includeProposed;
|
const includeProposed = !!config.includeProposed;
|
||||||
|
const showDetails = !!config.showDetails;
|
||||||
const chapterFilter = (config.chapter as string) ?? "";
|
const chapterFilter = (config.chapter as string) ?? "";
|
||||||
const [showDeparted, setShowDeparted] = useState(false);
|
const [showDeparted, setShowDeparted] = useState(false);
|
||||||
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
|
||||||
@@ -266,7 +356,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
|||||||
<p className="text-xs text-gray-400 flex items-center gap-1">
|
<p className="text-xs text-gray-400 flex items-center gap-1">
|
||||||
Period: {month}
|
Period: {month}
|
||||||
<InfoTooltip
|
<InfoTooltip
|
||||||
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
|
content="Chargeability is calculated for the current calendar month. Available hours are derived from each person's weekly schedule and reduced by regional public holidays plus approved absences. Watchlist threshold: 15 percentage points below target."
|
||||||
width="w-72"
|
width="w-72"
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
@@ -330,7 +420,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
|||||||
>
|
>
|
||||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
|
||||||
Top Chargeability
|
Top Chargeability
|
||||||
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours / total available hours." />
|
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours divided by holiday- and absence-adjusted available hours." />
|
||||||
<span className="ml-1 font-normal normal-case text-gray-400">
|
<span className="ml-1 font-normal normal-case text-gray-400">
|
||||||
{visibleTop.length}/{top.length}
|
{visibleTop.length}/{top.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -390,18 +480,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
|||||||
{visibleTop.map((r, i) => (
|
{visibleTop.map((r, i) => (
|
||||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
||||||
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
|
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
|
||||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[120px]">
|
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<span title={r.displayName}>{r.displayName}</span>
|
<span title={r.displayName}>{r.displayName}</span>
|
||||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
|
||||||
<UtilizationBar percent={r.actualChargeability} />
|
<UtilizationBar percent={r.actualChargeability} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400">
|
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400 align-top">
|
||||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
<div>
|
||||||
|
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||||
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 text-right text-gray-400">
|
<td className="px-2 py-1 text-right text-gray-400 align-top">
|
||||||
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
|
<div>
|
||||||
|
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
|
||||||
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
{formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -473,18 +578,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
|
|||||||
<tbody className="divide-y divide-gray-50">
|
<tbody className="divide-y divide-gray-50">
|
||||||
{visibleWatchlist.map((r) => (
|
{visibleWatchlist.map((r) => (
|
||||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
|
||||||
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[140px]">
|
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<span title={r.displayName}>{r.displayName}</span>
|
<span title={r.displayName}>{r.displayName}</span>
|
||||||
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
|
||||||
<UtilizationBar percent={r.actualChargeability} />
|
<UtilizationBar percent={r.actualChargeability} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400">
|
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400 align-top">
|
||||||
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
<div>
|
||||||
|
<AnimatedNumber value={r.actualChargeability} suffix="%" />
|
||||||
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 text-right text-gray-400">
|
<td className="px-2 py-1 text-right text-gray-400 align-top">
|
||||||
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
|
<div>
|
||||||
|
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
|
||||||
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,7 +8,53 @@ import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
|||||||
|
|
||||||
type GroupBy = "project" | "person" | "chapter";
|
type GroupBy = "project" | "person" | "chapter";
|
||||||
|
|
||||||
|
type DemandRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortCode: string;
|
||||||
|
allocatedHours: number;
|
||||||
|
requiredFTEs: number;
|
||||||
|
resourceCount: number;
|
||||||
|
derivation?: {
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
periodWorkingHoursBase: number;
|
||||||
|
requiredHours: number | null;
|
||||||
|
requiredFTEs: number;
|
||||||
|
fillPct: number | null;
|
||||||
|
demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE";
|
||||||
|
calendarLocations: Array<{
|
||||||
|
countryCode: string | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCityName: string | null;
|
||||||
|
resourceCount: number;
|
||||||
|
allocatedHours: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type DemandDerivation = NonNullable<DemandRow["derivation"]>;
|
||||||
|
type DemandCalendarLocation = DemandDerivation["calendarLocations"][number];
|
||||||
|
|
||||||
|
function formatHours(value: number | null | undefined): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocation(location: DemandCalendarLocation): string {
|
||||||
|
const parts = [location.countryCode, location.federalState, location.metroCityName]
|
||||||
|
.filter((part): part is string => Boolean(part));
|
||||||
|
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDemandSource(source: DemandDerivation["demandSource"] | undefined): string {
|
||||||
|
if (source === "DEMAND_REQUIREMENTS") return "Source: Demand requirements";
|
||||||
|
if (source === "PROJECT_STAFFING_REQS") return "Source: Project staffing reqs";
|
||||||
|
return "No demand basis";
|
||||||
|
}
|
||||||
|
|
||||||
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||||
|
const showDetails = config.showDetails === true;
|
||||||
const groupBy = (config.groupBy as GroupBy) || "project";
|
const groupBy = (config.groupBy as GroupBy) || "project";
|
||||||
|
|
||||||
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
|
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
|
||||||
@@ -48,7 +94,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = data ?? [];
|
const rows = (data ?? []) as DemandRow[];
|
||||||
|
|
||||||
const sorted = [...rows].sort((a, b) => {
|
const sorted = [...rows].sort((a, b) => {
|
||||||
const mult = sortDir === "asc" ? 1 : -1;
|
const mult = sortDir === "asc" ? 1 : -1;
|
||||||
@@ -144,37 +190,84 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
{sorted.map((row) => (
|
{sorted.map((row) => (
|
||||||
<tr key={row.id} className="hover:bg-gray-50">
|
<tr key={row.id} className="hover:bg-gray-50">
|
||||||
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
|
<td className="px-3 py-2 text-gray-900 max-w-[280px] align-top">
|
||||||
{groupBy === "project" ? (
|
<div className="font-medium truncate">
|
||||||
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
|
{groupBy === "project" ? (
|
||||||
) : (
|
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
|
||||||
row.name
|
) : (
|
||||||
)}
|
row.name
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showDetails && groupBy === "project" && row.derivation ? (
|
||||||
|
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
|
||||||
|
<div>
|
||||||
|
{row.derivation.periodStart} to {row.derivation.periodEnd}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{row.derivation.calendarLocations.length > 0
|
||||||
|
? row.derivation.calendarLocations
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((location) =>
|
||||||
|
`${formatLocation(location)} (${formatHours(location.allocatedHours)})`,
|
||||||
|
)
|
||||||
|
.join(" · ")
|
||||||
|
: "No location-based booking basis"}
|
||||||
|
</div>
|
||||||
|
{row.derivation.calendarLocations.length > 2 ? (
|
||||||
|
<div>+ {row.derivation.calendarLocations.length - 2} more calendar contexts</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right align-top">
|
||||||
|
<div className="text-gray-700">{row.allocatedHours}h</div>
|
||||||
|
{showDetails && groupBy === "project" && row.derivation ? (
|
||||||
|
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
|
||||||
|
<div>{row.derivation.calendarLocations.length} calendar basis{row.derivation.calendarLocations.length === 1 ? "" : "es"}</div>
|
||||||
|
<div>{row.resourceCount} resource{row.resourceCount === 1 ? "" : "s"} in scope</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
|
|
||||||
{groupBy === "project" && (
|
{groupBy === "project" && (
|
||||||
<td className="px-3 py-2 text-right text-gray-700">
|
<td className="px-3 py-2 text-right align-top text-gray-700">
|
||||||
{(() => {
|
{(() => {
|
||||||
const ftes = row.requiredFTEs as unknown as number;
|
const ftes = row.requiredFTEs as unknown as number;
|
||||||
if (ftes <= 0) return "—";
|
if (ftes <= 0) return "—";
|
||||||
const requiredHours = ftes * 22 * 3 * 8;
|
const requiredHours = row.derivation?.requiredHours ?? null;
|
||||||
const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100));
|
const rawFillPct = row.derivation?.fillPct ?? null;
|
||||||
const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3;
|
const fillPct = Math.min(100, rawFillPct ?? 0);
|
||||||
|
const isBelowTarget = rawFillPct !== null ? rawFillPct < 100 : false;
|
||||||
const ringColor = isBelowTarget
|
const ringColor = isBelowTarget
|
||||||
? "var(--color-red-500, #ef4444)"
|
? "var(--color-red-500, #ef4444)"
|
||||||
: "var(--color-green-500, #22c55e)";
|
: "var(--color-green-500, #22c55e)";
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<div className="inline-flex flex-col items-end gap-1">
|
||||||
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
|
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
|
||||||
{ftes} FTE
|
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
|
||||||
|
{ftes} FTE
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
{showDetails ? (
|
||||||
|
<div className="space-y-0.5 text-[10px] leading-4 text-gray-500">
|
||||||
|
<div>{formatHours(row.allocatedHours)} / {formatHours(requiredHours)}</div>
|
||||||
|
<div>{rawFillPct == null ? "—" : `${rawFillPct}% coverage`} · {formatHours(row.derivation?.periodWorkingHoursBase)} per 1.0 FTE</div>
|
||||||
|
<div>{formatDemandSource(row.derivation?.demandSource)}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
|
<td className="px-3 py-2 text-right align-top text-gray-500">
|
||||||
|
<div>{row.resourceCount}</div>
|
||||||
|
{showDetails && groupBy === "project" && row.derivation?.calendarLocations.length ? (
|
||||||
|
<div className="mt-1 text-[10px] leading-4 text-gray-500">
|
||||||
|
{row.derivation.calendarLocations.reduce((sum, location) => sum + location.resourceCount, 0)} resource entries across locations
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,55 +1,172 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useMemo, useState } from "react";
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ReferenceLine,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
|
|
||||||
const COLORS = [
|
type PeakTimesChartRow = {
|
||||||
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
|
period: string;
|
||||||
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
|
label: string;
|
||||||
];
|
bookedHours: number;
|
||||||
|
capacityHours: number;
|
||||||
|
utilizationPct: number;
|
||||||
|
remainingHours: number;
|
||||||
|
overbookedHours: number;
|
||||||
|
isCurrentPeriod: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface PeakTimesChartProps {
|
interface PeakTimesChartProps {
|
||||||
chartData: Record<string, number | string>[];
|
rows: PeakTimesChartRow[];
|
||||||
groups: string[];
|
selectedPeriod: string | null;
|
||||||
|
onSelectedPeriodChange?: (period: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) {
|
function formatHours(value: number): string {
|
||||||
if (chartData.length === 0) {
|
return new Intl.NumberFormat("de-DE", {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utilizationBarTone(utilizationPct: number): string {
|
||||||
|
if (utilizationPct > 100) return "bg-red-500";
|
||||||
|
if (utilizationPct > 75) return "bg-emerald-500";
|
||||||
|
if (utilizationPct >= 50) return "bg-amber-400";
|
||||||
|
return "bg-rose-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function utilizationTextTone(utilizationPct: number): string {
|
||||||
|
if (utilizationPct > 100) return "text-red-600 dark:text-red-300";
|
||||||
|
if (utilizationPct > 75) return "text-emerald-600 dark:text-emerald-300";
|
||||||
|
if (utilizationPct >= 50) return "text-amber-600 dark:text-amber-300";
|
||||||
|
return "text-rose-600 dark:text-rose-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PeakTimesChart({
|
||||||
|
rows,
|
||||||
|
selectedPeriod,
|
||||||
|
onSelectedPeriodChange,
|
||||||
|
}: PeakTimesChartProps) {
|
||||||
|
const [hoveredPeriod, setHoveredPeriod] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fallbackPeriod = selectedPeriod && rows.some((row) => row.period === selectedPeriod)
|
||||||
|
? selectedPeriod
|
||||||
|
: rows[0]?.period ?? null;
|
||||||
|
const activePeriod = hoveredPeriod ?? fallbackPeriod;
|
||||||
|
const activeRow = useMemo(
|
||||||
|
() => rows.find((row) => row.period === activePeriod) ?? rows[0] ?? null,
|
||||||
|
[activePeriod, rows],
|
||||||
|
);
|
||||||
|
const chartMaxPct = useMemo(() => {
|
||||||
|
const maxUtilization = Math.max(100, ...rows.map((row) => row.utilizationPct));
|
||||||
|
return Math.max(120, Math.ceil(maxUtilization / 20) * 20);
|
||||||
|
}, [rows]);
|
||||||
|
const tickValues = useMemo(() => {
|
||||||
|
const base = [0, 50, 100];
|
||||||
|
return chartMaxPct > 100 ? [...base, chartMaxPct] : base;
|
||||||
|
}, [chartMaxPct]);
|
||||||
|
const referenceLineBottom = (100 / chartMaxPct) * 100;
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-gray-400">
|
<div className="flex h-full items-center justify-center rounded-[22px] border border-dashed border-slate-200 bg-slate-50/80 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
|
||||||
No allocation data in selected period.
|
No allocation data in the selected horizon.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<div className="flex h-full min-h-[15rem] flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
|
||||||
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||||
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
|
Overall Utilization
|
||||||
<YAxis tick={{ fontSize: 10 }} />
|
</div>
|
||||||
<Tooltip contentStyle={{ fontSize: 11 }} />
|
|
||||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
{activeRow ? (
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
<div className="min-w-0 text-right">
|
||||||
<ReferenceLine
|
<div className={`truncate text-sm font-semibold ${utilizationTextTone(activeRow.utilizationPct)}`}>
|
||||||
{...({ dataKey: "capacity" } as any)}
|
{activeRow.label} · {activeRow.utilizationPct}%
|
||||||
stroke="#ef4444"
|
</div>
|
||||||
strokeDasharray="5 5"
|
<div className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
|
{formatHours(activeRow.bookedHours)}h / {formatHours(activeRow.capacityHours)}h
|
||||||
/>
|
</div>
|
||||||
{groups.map((g, i) => (
|
</div>
|
||||||
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
|
) : null}
|
||||||
))}
|
</div>
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
<div className="mt-3 flex min-h-[12rem] flex-1 gap-2">
|
||||||
|
<div className="flex w-8 shrink-0 flex-col justify-between pb-6 text-right text-[9px] font-medium text-slate-400">
|
||||||
|
{[...tickValues].reverse().map((tick) => (
|
||||||
|
<span key={tick}>{tick}%</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<div className="pointer-events-none absolute inset-0 bottom-6">
|
||||||
|
{[...tickValues].reverse().map((tick) => {
|
||||||
|
const bottom = (tick / chartMaxPct) * 100;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tick}
|
||||||
|
className="absolute left-0 right-0 border-t border-dashed border-slate-200/80 dark:border-slate-700/50"
|
||||||
|
style={{ bottom: `${bottom}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 border-t border-slate-300/90 dark:border-slate-500/80"
|
||||||
|
style={{ bottom: `${referenceLineBottom}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="grid h-full items-end gap-1.5 pb-6 sm:gap-2"
|
||||||
|
style={{ gridTemplateColumns: `repeat(${rows.length}, minmax(0, 1fr))` }}
|
||||||
|
>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const height = Math.min((row.utilizationPct / chartMaxPct) * 100, 100);
|
||||||
|
const isActive = row.period === activePeriod;
|
||||||
|
const isPinned = row.period === fallbackPeriod;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={row.period}
|
||||||
|
type="button"
|
||||||
|
className="group flex h-full min-w-0 flex-col items-center rounded-2xl px-1 text-left transition-colors"
|
||||||
|
title={`${row.label}: ${row.utilizationPct}% utilization, ${formatHours(row.bookedHours)}h booked, ${formatHours(row.capacityHours)}h capacity, ${formatHours(row.remainingHours)}h free, ${formatHours(row.overbookedHours)}h overbooked`}
|
||||||
|
onMouseEnter={() => setHoveredPeriod(row.period)}
|
||||||
|
onMouseLeave={() => setHoveredPeriod((current) => (current === row.period ? null : current))}
|
||||||
|
onClick={() => onSelectedPeriodChange?.(row.period)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isPinned
|
||||||
|
? "rgba(14, 165, 233, 0.08)"
|
||||||
|
: isActive
|
||||||
|
? "rgba(148, 163, 184, 0.08)"
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative flex min-h-0 flex-1 w-full items-end justify-center px-0.5">
|
||||||
|
<div className="relative h-full w-full max-w-[34px] sm:max-w-[42px]">
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-full rounded-t-xl bg-slate-100 dark:bg-slate-800/80" />
|
||||||
|
<div
|
||||||
|
className={`absolute inset-x-0 bottom-0 rounded-t-xl transition-all duration-150 ${utilizationBarTone(row.utilizationPct)} ${
|
||||||
|
isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
style={{ height: `${Math.max(height, row.utilizationPct > 0 ? 6 : 0)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 min-w-0 shrink-0">
|
||||||
|
<div className="truncate text-center text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
|
||||||
|
{row.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
@@ -10,84 +11,249 @@ const PeakTimesChart = dynamic(
|
|||||||
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
|
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
|
||||||
);
|
);
|
||||||
|
|
||||||
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
type PeakDepartmentRow = {
|
||||||
const granularity = (config.granularity as "week" | "month") || "month";
|
name: string;
|
||||||
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project";
|
hours: number;
|
||||||
|
capacityHours: number;
|
||||||
|
remainingHours: number;
|
||||||
|
overbookedHours: number;
|
||||||
|
utilizationPct: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PeakPeriodRow = {
|
||||||
|
period: string;
|
||||||
|
label: string;
|
||||||
|
bookedHours: number;
|
||||||
|
capacityHours: number;
|
||||||
|
remainingHours: number;
|
||||||
|
overbookedHours: number;
|
||||||
|
utilizationPct: number;
|
||||||
|
isCurrentPeriod: boolean;
|
||||||
|
groups: PeakDepartmentRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatHours(value: number): string {
|
||||||
|
return new Intl.NumberFormat("de-DE", {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthLabel(periodStart: string | undefined, fallback: string): string {
|
||||||
|
if (!periodStart) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(`${periodStart}T00:00:00.000Z`);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
timeZone: "UTC",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function utilizationTone(utilizationPct: number): string {
|
||||||
|
if (utilizationPct >= 100) return "bg-red-500";
|
||||||
|
if (utilizationPct >= 85) return "bg-amber-400";
|
||||||
|
return "bg-emerald-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
function utilizationTextTone(utilizationPct: number): string {
|
||||||
|
if (utilizationPct >= 100) return "text-red-600 dark:text-red-300";
|
||||||
|
if (utilizationPct >= 85) return "text-amber-600 dark:text-amber-300";
|
||||||
|
return "text-emerald-600 dark:text-emerald-300";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepartmentRow[] {
|
||||||
|
if (rows.length <= limit) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleRows = rows.slice(0, limit - 1);
|
||||||
|
const hiddenRows = rows.slice(limit - 1);
|
||||||
|
const hiddenHours = hiddenRows.reduce((sum, row) => sum + row.hours, 0);
|
||||||
|
const hiddenCapacityHours = hiddenRows.reduce((sum, row) => sum + row.capacityHours, 0);
|
||||||
|
const hiddenRemainingHours = hiddenRows.reduce((sum, row) => sum + row.remainingHours, 0);
|
||||||
|
const hiddenOverbookedHours = hiddenRows.reduce((sum, row) => sum + row.overbookedHours, 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...visibleRows,
|
||||||
|
{
|
||||||
|
name: `Other (${hiddenRows.length})`,
|
||||||
|
hours: hiddenHours,
|
||||||
|
capacityHours: hiddenCapacityHours,
|
||||||
|
remainingHours: hiddenRemainingHours,
|
||||||
|
overbookedHours: hiddenOverbookedHours,
|
||||||
|
utilizationPct:
|
||||||
|
hiddenCapacityHours > 0 ? Math.round((hiddenHours / hiddenCapacityHours) * 100) : 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString();
|
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
|
||||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString();
|
const endDate = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 12, 0, 23, 59, 59, 999),
|
||||||
|
).toISOString();
|
||||||
|
const currentPeriodKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const persistedPeriod = typeof config.activePeriod === "string" ? config.activePeriod : null;
|
||||||
|
|
||||||
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
|
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
|
||||||
{ startDate, endDate, granularity, groupBy },
|
{ startDate, endDate, granularity: "month", groupBy: "chapter" },
|
||||||
{ staleTime: 120_000, placeholderData: (prev) => prev },
|
{ staleTime: 120_000, placeholderData: (prev) => prev },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
const periods = useMemo<PeakPeriodRow[]>(
|
||||||
|
() =>
|
||||||
|
(data ?? []).map((period) => {
|
||||||
|
const derivation = period.derivation;
|
||||||
|
const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours;
|
||||||
|
const capacityHours = period.capacityHours ?? derivation.capacityHours ?? 0;
|
||||||
|
const remainingHours =
|
||||||
|
period.remainingHours ??
|
||||||
|
derivation.remainingCapacityHours ??
|
||||||
|
Math.max(capacityHours - bookedHours, 0);
|
||||||
|
const overbookedHours =
|
||||||
|
period.overbookedHours ??
|
||||||
|
derivation.overbookedHours ??
|
||||||
|
Math.max(bookedHours - capacityHours, 0);
|
||||||
|
const utilizationPct =
|
||||||
|
period.utilizationPct ??
|
||||||
|
derivation.utilizationPct ??
|
||||||
|
(capacityHours > 0 ? Math.round((bookedHours / capacityHours) * 100) : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: period.period,
|
||||||
|
label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period),
|
||||||
|
bookedHours,
|
||||||
|
capacityHours,
|
||||||
|
remainingHours,
|
||||||
|
overbookedHours,
|
||||||
|
utilizationPct,
|
||||||
|
isCurrentPeriod: period.period === currentPeriodKey,
|
||||||
|
groups: (period.groups ?? [])
|
||||||
|
.map((group) => {
|
||||||
|
const groupCapacityHours = group.capacityHours ?? 0;
|
||||||
|
const groupRemainingHours =
|
||||||
|
group.remainingHours ?? Math.max(groupCapacityHours - group.hours, 0);
|
||||||
|
const groupOverbookedHours =
|
||||||
|
group.overbookedHours ?? Math.max(group.hours - groupCapacityHours, 0);
|
||||||
|
const groupUtilizationPct =
|
||||||
|
group.utilizationPct ??
|
||||||
|
(groupCapacityHours > 0 ? Math.round((group.hours / groupCapacityHours) * 100) : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: group.name,
|
||||||
|
hours: group.hours,
|
||||||
|
capacityHours: groupCapacityHours,
|
||||||
|
remainingHours: groupRemainingHours,
|
||||||
|
overbookedHours: groupOverbookedHours,
|
||||||
|
utilizationPct: groupUtilizationPct,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.utilizationPct - left.utilizationPct ||
|
||||||
|
right.hours - left.hours ||
|
||||||
|
left.name.localeCompare(right.name),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
[currentPeriodKey, data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedPeriod =
|
||||||
|
(persistedPeriod && periods.some((period) => period.period === persistedPeriod) ? persistedPeriod : null) ??
|
||||||
|
(periods.some((period) => period.period === currentPeriodKey) ? currentPeriodKey : periods[0]?.period ?? null);
|
||||||
|
|
||||||
|
const selectedPeriodRow =
|
||||||
|
periods.find((period) => period.period === selectedPeriod) ?? periods[0] ?? null;
|
||||||
|
const currentPeriodRow =
|
||||||
|
periods.find((period) => period.period === currentPeriodKey) ?? selectedPeriodRow;
|
||||||
|
const peakPeriodRow = useMemo(
|
||||||
|
() =>
|
||||||
|
[...periods].sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.utilizationPct - left.utilizationPct || right.bookedHours - left.bookedHours,
|
||||||
|
)[0] ?? null,
|
||||||
|
[periods],
|
||||||
|
);
|
||||||
|
const departmentRows = useMemo(
|
||||||
|
() => aggregateDepartmentRows(selectedPeriodRow?.groups ?? []),
|
||||||
|
[selectedPeriodRow],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading && periods.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 h-full pt-2">
|
<div className="flex h-full flex-col gap-3 pt-2">
|
||||||
<div className="flex gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
|
{[...Array(3)].map((_, index) => (
|
||||||
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
|
<div key={index} className="h-14 rounded-2xl shimmer-skeleton" />
|
||||||
</div>
|
|
||||||
<div className="flex items-end gap-1 flex-1 px-2">
|
|
||||||
{[...Array(12)].map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="flex-1 shimmer-skeleton rounded-t"
|
|
||||||
style={{ height: `${30 + Math.random() * 50}%` }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 rounded-[22px] shimmer-skeleton" />
|
||||||
|
<div className="h-32 rounded-[22px] shimmer-skeleton" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const periods = data ?? [];
|
|
||||||
|
|
||||||
// Collect all group names
|
|
||||||
const allGroups = new Set<string>();
|
|
||||||
for (const p of periods) {
|
|
||||||
for (const g of p.groups) allGroups.add(g.name);
|
|
||||||
}
|
|
||||||
const groups = [...allGroups].slice(0, 10);
|
|
||||||
|
|
||||||
// Build recharts data
|
|
||||||
const chartData = periods.map((p) => {
|
|
||||||
const row: Record<string, number | string> = { period: p.period, capacity: p.capacityHours };
|
|
||||||
for (const g of p.groups) {
|
|
||||||
row[g.name] = g.hours;
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full gap-3">
|
<div className="flex h-full flex-col gap-2 overflow-hidden">
|
||||||
{/* Controls + info */}
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex gap-2 items-center">
|
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2">
|
||||||
<select
|
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
|
||||||
value={granularity}
|
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||||
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
|
Current
|
||||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
</div>
|
||||||
>
|
<div className="mt-0.5 flex items-baseline justify-between gap-3">
|
||||||
<option value="month">Monthly</option>
|
<span className={`text-base font-semibold ${utilizationTextTone(currentPeriodRow?.utilizationPct ?? 0)}`}>
|
||||||
<option value="week">Weekly</option>
|
{currentPeriodRow?.utilizationPct ?? 0}%
|
||||||
</select>
|
</span>
|
||||||
<select
|
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
value={groupBy}
|
{currentPeriodRow?.label ?? "No data"}
|
||||||
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
|
</span>
|
||||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
|
</div>
|
||||||
>
|
</div>
|
||||||
<option value="project">By Project</option>
|
|
||||||
<option value="chapter">By Chapter</option>
|
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
|
||||||
<option value="resource">By Resource</option>
|
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||||
</select>
|
Selected
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-baseline justify-between gap-3">
|
||||||
|
<span className={`text-base font-semibold ${utilizationTextTone(selectedPeriodRow?.utilizationPct ?? 0)}`}>
|
||||||
|
{selectedPeriodRow?.utilizationPct ?? 0}%
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
|
{selectedPeriodRow?.label ?? "Hover or pin"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||||
|
Peak
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-baseline justify-between gap-3">
|
||||||
|
<span className={`text-base font-semibold ${utilizationTextTone(peakPeriodRow?.utilizationPct ?? 0)}`}>
|
||||||
|
{peakPeriodRow?.utilizationPct ?? 0}%
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
|
{peakPeriodRow?.label ?? "No data"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<InfoTooltip
|
<InfoTooltip
|
||||||
content={
|
content={
|
||||||
<span>
|
<span>
|
||||||
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
|
The top chart shows total booked load against effective capacity.<br />
|
||||||
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
|
The current month is marked with a blue accent.<br />
|
||||||
Bars exceeding the capacity line indicate over-allocation risk.
|
Hover any month to inspect details and click to pin the department breakdown.
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
width="w-80"
|
width="w-80"
|
||||||
@@ -95,9 +261,72 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart */}
|
<div className="min-h-0 flex-1 lg:grid lg:grid-cols-[minmax(0,1.85fr)_minmax(18rem,0.95fr)] lg:gap-3">
|
||||||
<div className="flex-1 min-h-0">
|
<div className="min-h-0">
|
||||||
<PeakTimesChart chartData={chartData} groups={groups} />
|
<PeakTimesChart
|
||||||
|
rows={periods}
|
||||||
|
selectedPeriod={selectedPeriod}
|
||||||
|
onSelectedPeriodChange={(period) => onConfigChange?.({ activePeriod: period })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 min-h-0 lg:mt-0">
|
||||||
|
<div className="flex h-full flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||||
|
Department Utilization
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{selectedPeriodRow?.label ?? "No active month"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
|
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}</div>
|
||||||
|
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
|
||||||
|
{departmentRows.length > 0 ? (
|
||||||
|
departmentRows.map((group) => {
|
||||||
|
const barWidth = Math.min(group.utilizationPct, 100);
|
||||||
|
return (
|
||||||
|
<div key={group.name} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 truncate text-xs font-medium text-slate-700 dark:text-slate-200">
|
||||||
|
{group.name}
|
||||||
|
</div>
|
||||||
|
<div className={`shrink-0 text-[11px] font-semibold ${utilizationTextTone(group.utilizationPct)}`}>
|
||||||
|
{group.utilizationPct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative h-2.5 overflow-visible rounded-full bg-slate-100 dark:bg-slate-800/80"
|
||||||
|
title={`${group.name}: ${group.utilizationPct}% utilization, ${formatHours(group.hours)}h booked, ${formatHours(group.capacityHours)}h capacity, ${formatHours(group.remainingHours)}h free, ${formatHours(group.overbookedHours)}h overbooked`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${utilizationTone(group.utilizationPct)}`}
|
||||||
|
style={{ width: `${barWidth}%` }}
|
||||||
|
/>
|
||||||
|
{group.overbookedHours > 0 ? (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 h-full rounded-full bg-red-600/85"
|
||||||
|
style={{ width: `${Math.min(22, Math.max(8, group.utilizationPct - 100))}%` }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 px-3 py-4 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
|
||||||
|
No department data in the selected month.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|||||||
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
|
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
|
||||||
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
|
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
|
||||||
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
|
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
|
|
||||||
function healthDot(value: number): string {
|
function healthDot(value: number): string {
|
||||||
if (value >= 70) return "bg-green-500";
|
if (value >= 70) return "bg-green-500";
|
||||||
@@ -21,7 +22,55 @@ function scoreBadge(score: number): string {
|
|||||||
return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300";
|
return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatShortDate(value?: string | Date | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return "No end date";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "No end date";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeline(daysUntilEndDate?: number | null, timelineStatus?: string | null): string {
|
||||||
|
if (timelineStatus === "UNSCHEDULED" || daysUntilEndDate == null) {
|
||||||
|
return "No end date";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilEndDate < 0) {
|
||||||
|
return `${Math.abs(daysUntilEndDate)} days overdue`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilEndDate === 0) {
|
||||||
|
return "Due today";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${daysUntilEndDate} days left`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocation(location: {
|
||||||
|
countryCode?: string | null;
|
||||||
|
countryName?: string | null;
|
||||||
|
federalState?: string | null;
|
||||||
|
metroCityName?: string | null;
|
||||||
|
}): string {
|
||||||
|
const parts = [
|
||||||
|
location.countryCode ?? location.countryName ?? null,
|
||||||
|
location.federalState ?? null,
|
||||||
|
location.metroCityName ?? null,
|
||||||
|
].filter((part): part is string => Boolean(part));
|
||||||
|
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||||
|
const showDetails = config.showDetails === true;
|
||||||
const { clients } = useWidgetFilterOptions();
|
const { clients } = useWidgetFilterOptions();
|
||||||
|
|
||||||
const filters = useMemo<WidgetFilter[]>(
|
const filters = useMemo<WidgetFilter[]>(
|
||||||
@@ -87,10 +136,10 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
|
||||||
Project <InfoTooltip content="Active projects scored across three health dimensions" />
|
Project <InfoTooltip content="Active projects scored across three health dimensions including visible budget, staffing, and timeline basis." />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||||
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
|
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demanded headcount), Timeline health (end date and remaining horizon)." />
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
|
||||||
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
|
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
|
||||||
@@ -103,26 +152,66 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
|||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
|
||||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[160px] truncate">
|
<td className="px-3 py-2 text-gray-900 dark:text-gray-100 max-w-[320px]">
|
||||||
<Link href={`/projects/${(row as any).id}`} className="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
<Link href={`/projects/${(row as any).id}`} className="block hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
||||||
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
|
<div className="truncate font-medium">
|
||||||
{row.projectName}
|
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
|
||||||
|
{row.projectName}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<div>
|
||||||
|
Budget: {formatMoney(row.spentCents ?? 0)} spent
|
||||||
|
{row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"}
|
||||||
|
{row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC
|
||||||
|
{typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""}
|
||||||
|
{typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
|
||||||
|
</div>
|
||||||
|
{(row.calendarLocations ?? []).length > 0 ? (
|
||||||
|
<div>
|
||||||
|
Calendar basis: {(row.calendarLocations ?? [])
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((location) => `${formatLocation(location)} (${formatMoney(location.spentCents)} / ${location.assignmentCount} assign.)`)
|
||||||
|
.join(" · ")}
|
||||||
|
{(row.calendarLocations ?? []).length > 2
|
||||||
|
? ` · +${(row.calendarLocations ?? []).length - 2} more`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex flex-col items-center justify-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
<span
|
<div className="flex items-center justify-center gap-2">
|
||||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
|
<span
|
||||||
title={`Budget: ${row.budgetHealth}%`}
|
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
|
||||||
/>
|
title={`Budget: ${row.budgetHealth}%`}
|
||||||
<span
|
/>
|
||||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
|
<span
|
||||||
title={`Staffing: ${row.staffingHealth}%`}
|
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
|
||||||
/>
|
title={`Staffing: ${row.staffingHealth}%`}
|
||||||
<span
|
/>
|
||||||
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
|
<span
|
||||||
title={`Timeline: ${row.timelineHealth}%`}
|
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
|
||||||
/>
|
title={`Timeline: ${row.timelineHealth}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-center tabular-nums">
|
||||||
|
B {row.budgetUtilizationPercent ?? 0}% used
|
||||||
|
</div>
|
||||||
|
{showDetails ? (
|
||||||
|
<div className="text-center tabular-nums">
|
||||||
|
S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ function StatCard({
|
|||||||
value,
|
value,
|
||||||
suffix,
|
suffix,
|
||||||
sub,
|
sub,
|
||||||
|
details,
|
||||||
|
showDetails = false,
|
||||||
info,
|
info,
|
||||||
accentColor,
|
accentColor,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
@@ -28,6 +30,8 @@ function StatCard({
|
|||||||
value: number;
|
value: number;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
|
details?: string[];
|
||||||
|
showDetails?: boolean;
|
||||||
info?: React.ReactNode;
|
info?: React.ReactNode;
|
||||||
accentColor?: "green" | "amber" | "red";
|
accentColor?: "green" | "amber" | "red";
|
||||||
delay?: number;
|
delay?: number;
|
||||||
@@ -66,13 +70,37 @@ function StatCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sub && <p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{sub}</p>}
|
{sub && <p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{sub}</p>}
|
||||||
|
{showDetails && details && details.length > 0 ? (
|
||||||
|
<div className="mt-2 space-y-1 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||||
|
{details.map((detail) => (
|
||||||
|
<p key={detail}>{detail}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
function formatShortDate(value?: string | Date | null): string {
|
||||||
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
if (!value) {
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return "n/a";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCardsWidget(props: Partial<WidgetProps> = {}) {
|
||||||
|
const showDetails = props.config?.showDetails === true;
|
||||||
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
|
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
@@ -104,21 +132,33 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
|||||||
<StatCard
|
<StatCard
|
||||||
label="Total Resources"
|
label="Total Resources"
|
||||||
value={data.totalResources}
|
value={data.totalResources}
|
||||||
sub={`${data.activeResources} active`}
|
sub={`${data.activeResources} active / ${data.inactiveResources ?? Math.max(data.totalResources - data.activeResources, 0)} inactive`}
|
||||||
info="All resources in the system. Sub-line shows active resources only."
|
details={[
|
||||||
|
"Basis: all resource master records",
|
||||||
|
]}
|
||||||
|
showDetails={showDetails}
|
||||||
|
info="All resources in the system. Sub-line shows active versus inactive records."
|
||||||
delay={0}
|
delay={0}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Active Projects"
|
label="Active Projects"
|
||||||
value={data.activeProjects}
|
value={data.activeProjects}
|
||||||
sub={`${data.totalProjects} total`}
|
sub={`${data.totalProjects} total / ${data.inactiveProjects ?? Math.max(data.totalProjects - data.activeProjects, 0)} non-active`}
|
||||||
|
details={[
|
||||||
|
"Basis: project status on the dashboard snapshot",
|
||||||
|
]}
|
||||||
|
showDetails={showDetails}
|
||||||
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
|
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
|
||||||
delay={0.05}
|
delay={0.05}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Allocations"
|
label="Total Allocations"
|
||||||
value={data.totalAllocations}
|
value={data.totalAllocations}
|
||||||
sub={`${data.activeAllocations} not cancelled`}
|
sub={`${data.activeAllocations} not cancelled / ${data.cancelledAllocations ?? Math.max(data.totalAllocations - data.activeAllocations, 0)} cancelled`}
|
||||||
|
details={[
|
||||||
|
"Basis: split allocation read model across explicit and legacy rows",
|
||||||
|
]}
|
||||||
|
showDetails={showDetails}
|
||||||
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
|
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
|
||||||
delay={0.1}
|
delay={0.1}
|
||||||
/>
|
/>
|
||||||
@@ -127,7 +167,13 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
|
|||||||
value={budgetPct}
|
value={budgetPct}
|
||||||
suffix="%"
|
suffix="%"
|
||||||
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
|
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
|
||||||
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
|
details={[
|
||||||
|
`Remaining: ${formatMoney(data.budgetBasis?.remainingBudgetCents ?? (data.budgetSummary.totalBudgetCents - data.budgetSummary.totalCostCents))}`,
|
||||||
|
`Basis: ${data.budgetBasis?.trackedAssignmentCount ?? 0} non-cancelled assignments across ${data.budgetBasis?.budgetedProjects ?? 0} budgeted projects`,
|
||||||
|
`Window: ${formatShortDate(data.budgetBasis?.windowStart)} - ${formatShortDate(data.budgetBasis?.windowEnd)}`,
|
||||||
|
]}
|
||||||
|
showDetails={showDetails}
|
||||||
|
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost uses the effective allocation cost basis including holiday-adjusted working capacity where available."
|
||||||
accentColor={budgetAccent}
|
accentColor={budgetAccent}
|
||||||
delay={0.15}
|
delay={0.15}
|
||||||
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}
|
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ const adminNavEntries: AdminEntry[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
|
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
|
||||||
|
{ href: "/admin/vacations", label: "Vacations & Holidays", icon: <VacationIcon /> },
|
||||||
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> },
|
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> },
|
||||||
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
|
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
|
||||||
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
|
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { Route } from "next";
|
import type { Route } from "next";
|
||||||
import { motion, useAnimationControls } from "framer-motion";
|
import { motion, useAnimationControls } from "framer-motion";
|
||||||
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
function relativeTime(date: Date): string {
|
function relativeTime(date: Date): string {
|
||||||
@@ -28,12 +29,16 @@ type TabKey = "all" | "tasks" | "reminders";
|
|||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const bellRef = useRef<HTMLButtonElement>(null);
|
const bellRef = useRef<HTMLButtonElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
|
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
|
||||||
|
const { panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
|
||||||
|
open,
|
||||||
|
onClose: () => setOpen(false),
|
||||||
|
side: "right",
|
||||||
|
crossAlign: "start",
|
||||||
|
triggerRef: bellRef,
|
||||||
|
});
|
||||||
|
|
||||||
const badgeControls = useAnimationControls();
|
const badgeControls = useAnimationControls();
|
||||||
const prevUnreadRef = useRef<number | null>(null);
|
const prevUnreadRef = useRef<number | null>(null);
|
||||||
@@ -96,39 +101,6 @@ export function NotificationBell() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Compute dropdown position when opening
|
|
||||||
const updatePosition = useCallback(() => {
|
|
||||||
if (!bellRef.current) return;
|
|
||||||
const rect = bellRef.current.getBoundingClientRect();
|
|
||||||
const panelHeight = 440; // approximate max height
|
|
||||||
let top = rect.top;
|
|
||||||
// If it would overflow the bottom, flip upward
|
|
||||||
if (top + panelHeight > window.innerHeight) {
|
|
||||||
top = Math.max(8, window.innerHeight - panelHeight - 8);
|
|
||||||
}
|
|
||||||
setDropdownPos({ top, left: rect.right + 8 });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) updatePosition();
|
|
||||||
}, [open, updatePosition]);
|
|
||||||
|
|
||||||
// Close dropdown on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
function handleClick(e: MouseEvent) {
|
|
||||||
const target = e.target as Node;
|
|
||||||
if (
|
|
||||||
ref.current && !ref.current.contains(target) &&
|
|
||||||
dropdownRef.current && !dropdownRef.current.contains(target)
|
|
||||||
) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
function handleMarkAllRead() {
|
function handleMarkAllRead() {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
markRead.mutate({});
|
markRead.mutate({});
|
||||||
@@ -150,12 +122,18 @@ export function NotificationBell() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div className="relative">
|
||||||
{/* Bell button */}
|
{/* Bell button */}
|
||||||
<button
|
<button
|
||||||
ref={bellRef}
|
ref={bellRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => {
|
||||||
|
setOpen((current) => {
|
||||||
|
const nextOpen = !current;
|
||||||
|
handleOpenChange(nextOpen);
|
||||||
|
return nextOpen;
|
||||||
|
});
|
||||||
|
}}
|
||||||
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
>
|
>
|
||||||
@@ -193,12 +171,12 @@ export function NotificationBell() {
|
|||||||
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
|
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
|
||||||
{open && createPortal(
|
{open && createPortal(
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={dropdownRef}
|
ref={panelRef}
|
||||||
initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }}
|
initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }}
|
||||||
animate={{ opacity: 1, scaleY: 1, scaleX: 1 }}
|
animate={{ opacity: 1, scaleY: 1, scaleX: 1 }}
|
||||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||||
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden origin-top"
|
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden origin-top"
|
||||||
style={{ top: dropdownPos.top, left: dropdownPos.left }}
|
style={{ top: position.top, left: position.left }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { clsx } from "clsx";
|
|||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type EntityType = "resource" | "project" | "assignment";
|
type EntityType = "resource" | "project" | "assignment" | "resource_month";
|
||||||
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
|
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
|
||||||
|
|
||||||
interface FilterRow {
|
interface FilterRow {
|
||||||
@@ -17,10 +17,50 @@ interface FilterRow {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AvailableColumn {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
dataType: "string" | "number" | "date" | "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateConfig {
|
||||||
|
entity: EntityType;
|
||||||
|
columns: string[];
|
||||||
|
filters: Omit<FilterRow, "id">[];
|
||||||
|
groupBy?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDir?: "asc" | "desc";
|
||||||
|
periodMonth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportTemplateSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
entity: EntityType;
|
||||||
|
config: TemplateConfig;
|
||||||
|
isShared: boolean;
|
||||||
|
isOwner: boolean;
|
||||||
|
updatedAt: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportBlueprint {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
entity: EntityType;
|
||||||
|
columns: string[];
|
||||||
|
groupBy?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDir?: "asc" | "desc";
|
||||||
|
templateName: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
|
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
|
||||||
{ value: "resource", label: "Resources" },
|
{ value: "resource", label: "Resources" },
|
||||||
{ value: "project", label: "Projects" },
|
{ value: "project", label: "Projects" },
|
||||||
{ value: "assignment", label: "Assignments" },
|
{ value: "assignment", label: "Assignments" },
|
||||||
|
{ value: "resource_month", label: "Resource Months" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
|
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
|
||||||
@@ -36,10 +76,120 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
|
|||||||
|
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
|
||||||
|
"displayName",
|
||||||
|
"eid",
|
||||||
|
"chapter",
|
||||||
|
"countryCode",
|
||||||
|
"countryName",
|
||||||
|
"federalState",
|
||||||
|
"metroCityName",
|
||||||
|
"monthlyBaseWorkingDays",
|
||||||
|
"monthlyEffectiveWorkingDays",
|
||||||
|
"monthlyBaseAvailableHours",
|
||||||
|
"monthlyPublicHolidayWorkdayCount",
|
||||||
|
"monthlyPublicHolidayHoursDeduction",
|
||||||
|
"monthlyAbsenceDayEquivalent",
|
||||||
|
"monthlyAbsenceHoursDeduction",
|
||||||
|
"monthlySahHours",
|
||||||
|
"monthlyChargeabilityTargetPct",
|
||||||
|
"monthlyTargetHours",
|
||||||
|
"monthlyActualBookedHours",
|
||||||
|
"monthlyExpectedBookedHours",
|
||||||
|
"monthlyActualChargeabilityPct",
|
||||||
|
"monthlyExpectedChargeabilityPct",
|
||||||
|
"monthlyUnassignedHours",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const REPORT_BLUEPRINTS: ReportBlueprint[] = [
|
||||||
|
{
|
||||||
|
id: "resource-month-sah-transparency",
|
||||||
|
label: "SAH transparency",
|
||||||
|
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
|
||||||
|
entity: "resource_month",
|
||||||
|
templateName: "Monthly SAH transparency",
|
||||||
|
columns: [
|
||||||
|
"displayName",
|
||||||
|
"eid",
|
||||||
|
"chapter",
|
||||||
|
"countryName",
|
||||||
|
"federalState",
|
||||||
|
"metroCityName",
|
||||||
|
"monthlyBaseWorkingDays",
|
||||||
|
"monthlyEffectiveWorkingDays",
|
||||||
|
"monthlyBaseAvailableHours",
|
||||||
|
"monthlyPublicHolidayWorkdayCount",
|
||||||
|
"monthlyPublicHolidayHoursDeduction",
|
||||||
|
"monthlyAbsenceDayEquivalent",
|
||||||
|
"monthlyAbsenceHoursDeduction",
|
||||||
|
"monthlySahHours",
|
||||||
|
"monthlyChargeabilityTargetPct",
|
||||||
|
"monthlyTargetHours",
|
||||||
|
],
|
||||||
|
sortBy: "displayName",
|
||||||
|
sortDir: "asc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resource-month-chargeability-audit",
|
||||||
|
label: "Chargeability audit",
|
||||||
|
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
|
||||||
|
entity: "resource_month",
|
||||||
|
templateName: "Monthly chargeability audit",
|
||||||
|
columns: [
|
||||||
|
"displayName",
|
||||||
|
"eid",
|
||||||
|
"chapter",
|
||||||
|
"countryName",
|
||||||
|
"federalState",
|
||||||
|
"metroCityName",
|
||||||
|
"monthlySahHours",
|
||||||
|
"monthlyChargeabilityTargetPct",
|
||||||
|
"monthlyTargetHours",
|
||||||
|
"monthlyActualBookedHours",
|
||||||
|
"monthlyExpectedBookedHours",
|
||||||
|
"monthlyActualChargeabilityPct",
|
||||||
|
"monthlyExpectedChargeabilityPct",
|
||||||
|
"monthlyUnassignedHours",
|
||||||
|
"lcrCents",
|
||||||
|
"currency",
|
||||||
|
],
|
||||||
|
sortBy: "monthlyActualChargeabilityPct",
|
||||||
|
sortDir: "desc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resource-month-location-comparison",
|
||||||
|
label: "Location comparison",
|
||||||
|
description: "Compares holiday impact across country, state and city contexts for the same month.",
|
||||||
|
entity: "resource_month",
|
||||||
|
templateName: "Monthly holiday comparison by location",
|
||||||
|
columns: [
|
||||||
|
"displayName",
|
||||||
|
"chapter",
|
||||||
|
"countryName",
|
||||||
|
"federalState",
|
||||||
|
"metroCityName",
|
||||||
|
"monthlyBaseWorkingDays",
|
||||||
|
"monthlyPublicHolidayWorkdayCount",
|
||||||
|
"monthlyPublicHolidayHoursDeduction",
|
||||||
|
"monthlyAbsenceHoursDeduction",
|
||||||
|
"monthlySahHours",
|
||||||
|
"monthlyActualChargeabilityPct",
|
||||||
|
],
|
||||||
|
groupBy: "federalState",
|
||||||
|
sortBy: "monthlyPublicHolidayHoursDeduction",
|
||||||
|
sortDir: "desc",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
return Math.random().toString(36).slice(2, 10);
|
return Math.random().toString(36).slice(2, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentPeriodMonth(): string {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Component ──────────────────────────────────────────────────────────────
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ReportBuilder() {
|
export function ReportBuilder() {
|
||||||
@@ -50,6 +200,9 @@ export function ReportBuilder() {
|
|||||||
const [groupBy, setGroupBy] = useState<string>("");
|
const [groupBy, setGroupBy] = useState<string>("");
|
||||||
const [sortBy, setSortBy] = useState<string>("");
|
const [sortBy, setSortBy] = useState<string>("");
|
||||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||||
|
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||||
|
const [templateName, setTemplateName] = useState<string>("");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [runQuery, setRunQuery] = useState(false);
|
const [runQuery, setRunQuery] = useState(false);
|
||||||
|
|
||||||
@@ -59,7 +212,21 @@ export function ReportBuilder() {
|
|||||||
{ placeholderData: keepPreviousData },
|
{ placeholderData: keepPreviousData },
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableColumns = columnsQuery.data ?? [];
|
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
|
||||||
|
const templatesQuery = trpc.report.listTemplates.useQuery();
|
||||||
|
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
setSelectedTemplateId(result.id);
|
||||||
|
await templatesQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const deleteTemplateMutation = trpc.report.deleteTemplate.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
setSelectedTemplateId("");
|
||||||
|
setTemplateName("");
|
||||||
|
await templatesQuery.refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Scalar columns (for filter/sort/group — only non-relation columns)
|
// Scalar columns (for filter/sort/group — only non-relation columns)
|
||||||
const scalarColumns = useMemo(
|
const scalarColumns = useMemo(
|
||||||
@@ -76,12 +243,13 @@ export function ReportBuilder() {
|
|||||||
filters: filters
|
filters: filters
|
||||||
.filter((f) => f.field && f.value)
|
.filter((f) => f.field && f.value)
|
||||||
.map(({ field, op, value }) => ({ field, op, value })),
|
.map(({ field, op, value }) => ({ field, op, value })),
|
||||||
|
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||||
...(groupBy ? { groupBy } : {}),
|
...(groupBy ? { groupBy } : {}),
|
||||||
...(sortBy ? { sortBy, sortDir } : {}),
|
...(sortBy ? { sortBy, sortDir } : {}),
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: page * PAGE_SIZE,
|
offset: page * PAGE_SIZE,
|
||||||
};
|
};
|
||||||
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
|
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
|
||||||
|
|
||||||
// Fetch report data
|
// Fetch report data
|
||||||
const reportQuery = trpc.report.getReportData.useQuery(
|
const reportQuery = trpc.report.getReportData.useQuery(
|
||||||
@@ -99,6 +267,40 @@ export function ReportBuilder() {
|
|||||||
setFilters([]);
|
setFilters([]);
|
||||||
setGroupBy("");
|
setGroupBy("");
|
||||||
setSortBy("");
|
setSortBy("");
|
||||||
|
if (newEntity === "resource_month") {
|
||||||
|
setPeriodMonth((current) => current || getCurrentPeriodMonth());
|
||||||
|
}
|
||||||
|
setRunQuery(false);
|
||||||
|
setPage(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyTemplate = useCallback((template: ReportTemplateSummary) => {
|
||||||
|
const config = template.config;
|
||||||
|
setSelectedTemplateId(template.id);
|
||||||
|
setTemplateName(template.name);
|
||||||
|
setEntity(config.entity);
|
||||||
|
setSelectedColumns(new Set(config.columns));
|
||||||
|
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
|
||||||
|
setGroupBy(config.groupBy ?? "");
|
||||||
|
setSortBy(config.sortBy ?? "");
|
||||||
|
setSortDir(config.sortDir ?? "asc");
|
||||||
|
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
|
||||||
|
setRunQuery(false);
|
||||||
|
setPage(0);
|
||||||
|
}, [templatesQuery.data]);
|
||||||
|
|
||||||
|
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
|
||||||
|
setSelectedTemplateId("");
|
||||||
|
setTemplateName(blueprint.templateName);
|
||||||
|
setEntity(blueprint.entity);
|
||||||
|
setSelectedColumns(new Set(blueprint.columns));
|
||||||
|
setFilters([]);
|
||||||
|
setGroupBy(blueprint.groupBy ?? "");
|
||||||
|
setSortBy(blueprint.sortBy ?? "");
|
||||||
|
setSortDir(blueprint.sortDir ?? "asc");
|
||||||
|
if (blueprint.entity === "resource_month") {
|
||||||
|
setPeriodMonth((current) => current || getCurrentPeriodMonth());
|
||||||
|
}
|
||||||
setRunQuery(false);
|
setRunQuery(false);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -163,6 +365,7 @@ export function ReportBuilder() {
|
|||||||
filters: filters
|
filters: filters
|
||||||
.filter((f) => f.field && f.value)
|
.filter((f) => f.field && f.value)
|
||||||
.map(({ field, op, value }) => ({ field, op, value })),
|
.map(({ field, op, value }) => ({ field, op, value })),
|
||||||
|
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||||
...(groupBy ? { groupBy } : {}),
|
...(groupBy ? { groupBy } : {}),
|
||||||
...(sortBy ? { sortBy, sortDir } : {}),
|
...(sortBy ? { sortBy, sortDir } : {}),
|
||||||
limit: 5000,
|
limit: 5000,
|
||||||
@@ -179,7 +382,42 @@ export function ReportBuilder() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Error handled by tRPC
|
// Error handled by tRPC
|
||||||
}
|
}
|
||||||
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
|
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
|
||||||
|
|
||||||
|
const handleSaveTemplate = useCallback(async () => {
|
||||||
|
if (!templateName.trim() || selectedColumns.size === 0) return;
|
||||||
|
|
||||||
|
await saveTemplateMutation.mutateAsync({
|
||||||
|
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
|
||||||
|
name: templateName.trim(),
|
||||||
|
config: {
|
||||||
|
entity,
|
||||||
|
columns: Array.from(selectedColumns),
|
||||||
|
filters: filters
|
||||||
|
.filter((filter) => filter.field && filter.value)
|
||||||
|
.map(({ field, op, value }) => ({ field, op, value })),
|
||||||
|
...(entity === "resource_month" ? { periodMonth } : {}),
|
||||||
|
...(groupBy ? { groupBy } : {}),
|
||||||
|
...(sortBy ? { sortBy, sortDir } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
entity,
|
||||||
|
filters,
|
||||||
|
groupBy,
|
||||||
|
periodMonth,
|
||||||
|
saveTemplateMutation,
|
||||||
|
selectedColumns,
|
||||||
|
selectedTemplateId,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
|
templateName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDeleteTemplate = useCallback(async () => {
|
||||||
|
if (!selectedTemplateId) return;
|
||||||
|
await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
|
||||||
|
}, [deleteTemplateMutation, selectedTemplateId]);
|
||||||
|
|
||||||
// ─── Derived ──────────────────────────────────────────────────────────
|
// ─── Derived ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -188,6 +426,15 @@ export function ReportBuilder() {
|
|||||||
const outputColumns = reportQuery.data?.columns ?? [];
|
const outputColumns = reportQuery.data?.columns ?? [];
|
||||||
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||||
const isLoading = reportQuery.isFetching;
|
const isLoading = reportQuery.isFetching;
|
||||||
|
const templates = templatesQuery.data ?? [];
|
||||||
|
const resourceMonthBlueprints = useMemo(
|
||||||
|
() => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity),
|
||||||
|
[entity],
|
||||||
|
);
|
||||||
|
const recommendedColumnSet = useMemo(
|
||||||
|
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
|
||||||
|
[entity],
|
||||||
|
);
|
||||||
|
|
||||||
// Column label lookup
|
// Column label lookup
|
||||||
const columnLabelMap = useMemo(() => {
|
const columnLabelMap = useMemo(() => {
|
||||||
@@ -212,6 +459,61 @@ export function ReportBuilder() {
|
|||||||
|
|
||||||
{/* Config Panel */}
|
{/* Config Panel */}
|
||||||
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||||
|
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/60 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Template
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedTemplateId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const nextId = e.target.value;
|
||||||
|
setSelectedTemplateId(nextId);
|
||||||
|
const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
|
||||||
|
if (template) {
|
||||||
|
applyTemplate(template);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
<option value="">Unsaved view</option>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.id} value={template.id}>
|
||||||
|
{template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Template name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={templateName}
|
||||||
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
|
placeholder="Monthly SAH by location"
|
||||||
|
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSaveTemplate()}
|
||||||
|
disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
|
||||||
|
className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeleteTemplate()}
|
||||||
|
disabled={!selectedTemplateId || deleteTemplateMutation.isPending}
|
||||||
|
className="self-end rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300 dark:hover:bg-slate-900"
|
||||||
|
>
|
||||||
|
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Entity Selector */}
|
{/* Entity Selector */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
@@ -234,6 +536,73 @@ export function ReportBuilder() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{entity === "resource_month" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200">
|
||||||
|
Period month
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={periodMonth}
|
||||||
|
onChange={(e) => setPeriodMonth(e.target.value)}
|
||||||
|
className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
|
||||||
|
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-3">
|
||||||
|
{resourceMonthBlueprints.map((blueprint) => (
|
||||||
|
<button
|
||||||
|
key={blueprint.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyBlueprint(blueprint)}
|
||||||
|
className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold text-emerald-950 dark:text-emerald-100">
|
||||||
|
{blueprint.label}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-emerald-900/75 dark:text-emerald-200/75">
|
||||||
|
{blueprint.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
|
||||||
|
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
|
||||||
|
Recommended transparency columns
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => (
|
||||||
|
<button
|
||||||
|
key={column}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleColumn(column)}
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full border px-3 py-1 text-xs font-medium transition",
|
||||||
|
selectedColumns.has(column)
|
||||||
|
? "border-emerald-500 bg-emerald-500 text-white"
|
||||||
|
: "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columnLabelMap.get(column) ?? column}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
|
||||||
|
Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
|
||||||
|
Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Picker */}
|
{/* Column Picker */}
|
||||||
@@ -276,6 +645,11 @@ export function ReportBuilder() {
|
|||||||
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
|
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
|
||||||
/>
|
/>
|
||||||
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
|
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
|
||||||
|
{recommendedColumnSet.has(col.key) && (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
|
||||||
|
Rec
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
|
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
|
||||||
{col.dataType}
|
{col.dataType}
|
||||||
</span>
|
</span>
|
||||||
@@ -428,13 +802,18 @@ export function ReportBuilder() {
|
|||||||
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||||
{/* Results Header */}
|
{/* Results Header */}
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
|
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-1">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
|
<div className="flex items-center gap-3">
|
||||||
{!isLoading && (
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
|
||||||
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
|
{!isLoading && (
|
||||||
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
|
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
|
||||||
</span>
|
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -209,17 +209,74 @@ interface SuggestionLike {
|
|||||||
resourceName: string;
|
resourceName: string;
|
||||||
eid: string;
|
eid: string;
|
||||||
score: number;
|
score: number;
|
||||||
|
valueScore?: number;
|
||||||
scoreBreakdown: {
|
scoreBreakdown: {
|
||||||
skillScore: number;
|
skillScore: number;
|
||||||
availabilityScore: number;
|
availabilityScore: number;
|
||||||
costScore: number;
|
costScore: number;
|
||||||
utilizationScore: number;
|
utilizationScore: number;
|
||||||
|
total?: number;
|
||||||
};
|
};
|
||||||
matchedSkills: string[];
|
matchedSkills: string[];
|
||||||
missingSkills: string[];
|
missingSkills: string[];
|
||||||
availabilityConflicts: string[];
|
availabilityConflicts: string[];
|
||||||
estimatedDailyCostCents: number;
|
estimatedDailyCostCents: number;
|
||||||
currentUtilization: number;
|
currentUtilization: number;
|
||||||
|
remainingHours?: number;
|
||||||
|
remainingHoursPerDay?: number;
|
||||||
|
baseAvailableHours?: number;
|
||||||
|
effectiveAvailableHours?: number;
|
||||||
|
holidayHoursDeduction?: number;
|
||||||
|
location?: {
|
||||||
|
countryCode: string | null;
|
||||||
|
countryName: string | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCityName: string | null;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
capacity?: {
|
||||||
|
requestedHoursPerDay: number;
|
||||||
|
requestedHoursTotal: number;
|
||||||
|
baseWorkingDays: number;
|
||||||
|
effectiveWorkingDays: number;
|
||||||
|
baseAvailableHours: number;
|
||||||
|
effectiveAvailableHours: number;
|
||||||
|
bookedHours: number;
|
||||||
|
remainingHours: number;
|
||||||
|
remainingHoursPerDay: number;
|
||||||
|
holidayCount: number;
|
||||||
|
holidayWorkdayCount: number;
|
||||||
|
holidayHoursDeduction: number;
|
||||||
|
absenceDayEquivalent: number;
|
||||||
|
absenceHoursDeduction: number;
|
||||||
|
};
|
||||||
|
conflicts?: {
|
||||||
|
count: number;
|
||||||
|
conflictDays: string[];
|
||||||
|
details: Array<{
|
||||||
|
date: string;
|
||||||
|
baseHours: number;
|
||||||
|
effectiveHours: number;
|
||||||
|
allocatedHours: number;
|
||||||
|
remainingHours: number;
|
||||||
|
requestedHours: number;
|
||||||
|
shortageHours: number;
|
||||||
|
absenceFraction: number;
|
||||||
|
isHoliday: boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
ranking?: {
|
||||||
|
rank: number;
|
||||||
|
baseRank: number;
|
||||||
|
tieBreakerApplied: boolean;
|
||||||
|
tieBreakerReason: string | null;
|
||||||
|
model: string;
|
||||||
|
components: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
score: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SuggestionCardProps {
|
interface SuggestionCardProps {
|
||||||
@@ -231,10 +288,24 @@ interface SuggestionCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) {
|
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [showDetails, setShowDetails] = useState(false);
|
||||||
|
const [showAssignForm, setShowAssignForm] = useState(false);
|
||||||
|
const locationLabel = suggestion.location?.label
|
||||||
|
|| [suggestion.location?.countryCode, suggestion.location?.federalState, suggestion.location?.metroCityName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ")
|
||||||
|
|| "No location";
|
||||||
|
const capacity = suggestion.capacity;
|
||||||
|
const conflicts = suggestion.conflicts;
|
||||||
|
const conflictCount = conflicts?.count ?? suggestion.availabilityConflicts.length;
|
||||||
|
const remainingHours = capacity?.remainingHours ?? suggestion.remainingHours ?? 0;
|
||||||
|
const remainingHoursPerDay = capacity?.remainingHoursPerDay ?? suggestion.remainingHoursPerDay ?? 0;
|
||||||
|
const baseAvailableHours = capacity?.baseAvailableHours ?? suggestion.baseAvailableHours ?? 0;
|
||||||
|
const effectiveAvailableHours = capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0;
|
||||||
|
const holidayHoursDeduction = capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-surface p-5">
|
<div data-suggestion className="app-surface p-5">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
|
||||||
@@ -243,15 +314,23 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
|||||||
<div>
|
<div>
|
||||||
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
|
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
|
||||||
<div className="text-sm text-gray-500">{suggestion.eid}</div>
|
<div className="text-sm text-gray-500">{suggestion.eid}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">{locationLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDetails((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showDetails ? "Hide Details" : "Details"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setExpanded((prev) => !prev)}
|
onClick={() => setShowAssignForm((prev) => !prev)}
|
||||||
>
|
>
|
||||||
{expanded ? "Cancel" : "Assign"}
|
{showAssignForm ? "Close Assign" : "Assign"}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
|
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
|
||||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
|
||||||
@@ -260,13 +339,6 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
|
|
||||||
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
|
|
||||||
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
|
|
||||||
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{suggestion.matchedSkills.map((skill) => (
|
{suggestion.matchedSkills.map((skill) => (
|
||||||
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
|
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
|
||||||
@@ -280,24 +352,144 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label="Free / Workday"
|
||||||
|
value={formatHours(remainingHoursPerDay)}
|
||||||
|
tone={remainingHoursPerDay >= searchCriteria.hoursPerDay ? "good" : "warn"}
|
||||||
|
helper={`${formatHours(remainingHours)} total in window`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Capacity"
|
||||||
|
value={`${formatHours(effectiveAvailableHours)} effective`}
|
||||||
|
helper={`${formatHours(baseAvailableHours)} base`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Holiday Deduction"
|
||||||
|
value={holidayHoursDeduction > 0 ? formatHours(holidayHoursDeduction) : "0h"}
|
||||||
|
tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
|
||||||
|
helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Conflicts"
|
||||||
|
value={String(conflictCount)}
|
||||||
|
tone={conflictCount > 0 ? "warn" : "good"}
|
||||||
|
helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
|
||||||
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
|
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
|
||||||
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
|
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
|
||||||
{suggestion.availabilityConflicts.length > 0 && (
|
{suggestion.valueScore != null && (
|
||||||
|
<span>Value Score: {suggestion.valueScore}</span>
|
||||||
|
)}
|
||||||
|
{conflictCount > 0 && (
|
||||||
<span className="font-medium text-amber-600 dark:text-amber-300">
|
<span className="font-medium text-amber-600 dark:text-amber-300">
|
||||||
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
|
{conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{showDetails && (
|
||||||
|
<div className="mt-5 space-y-4 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-950/40">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
|
||||||
|
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
|
||||||
|
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
|
||||||
|
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.15fr_1fr]">
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Capacity Basis</div>
|
||||||
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
|
<MetricLine label="Requested load" value={`${formatHours(capacity?.requestedHoursPerDay ?? searchCriteria.hoursPerDay)} / day`} />
|
||||||
|
<MetricLine label="Requested total" value={formatHours(capacity?.requestedHoursTotal ?? 0)} />
|
||||||
|
<MetricLine label="Base working days" value={String(capacity?.baseWorkingDays ?? 0)} />
|
||||||
|
<MetricLine label="Effective working days" value={String(capacity?.effectiveWorkingDays ?? 0)} />
|
||||||
|
<MetricLine label="Base available hours" value={formatHours(baseAvailableHours)} />
|
||||||
|
<MetricLine label="Effective available hours" value={formatHours(effectiveAvailableHours)} />
|
||||||
|
<MetricLine label="Booked hours" value={formatHours(capacity?.bookedHours ?? 0)} />
|
||||||
|
<MetricLine label="Remaining hours" value={formatHours(remainingHours)} />
|
||||||
|
<MetricLine label="Holiday deduction" value={formatHours(holidayHoursDeduction)} />
|
||||||
|
<MetricLine label="Absence deduction" value={formatHours(capacity?.absenceHoursDeduction ?? 0)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Ranking Basis</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{(suggestion.ranking?.components ?? []).map((component) => (
|
||||||
|
<MetricLine key={component.key} label={component.label} value={`${component.score}`} />
|
||||||
|
))}
|
||||||
|
{suggestion.ranking?.tieBreakerReason && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
{suggestion.ranking.tieBreakerReason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Location + Calendar</div>
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<MetricLine label="Location" value={locationLabel} />
|
||||||
|
<MetricLine label="Holiday dates" value={String(capacity?.holidayCount ?? 0)} />
|
||||||
|
<MetricLine label="Holiday workdays" value={String(capacity?.holidayWorkdayCount ?? 0)} />
|
||||||
|
<MetricLine label="Absence days" value={String(capacity?.absenceDayEquivalent ?? 0)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Conflict Check</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{conflictCount === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-green-700 dark:text-green-300">
|
||||||
|
No overloaded working days in the selected window.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{(conflicts?.details ?? []).slice(0, 6).map((item) => (
|
||||||
|
<div key={item.date} className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="font-medium">{item.date}</span>
|
||||||
|
<span>Short by {formatHours(item.shortageHours)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{conflictCount > 6 && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
+{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAssignForm && (
|
||||||
<AssignForm
|
<AssignForm
|
||||||
resourceId={suggestion.resourceId}
|
resourceId={suggestion.resourceId}
|
||||||
resourceName={suggestion.resourceName}
|
resourceName={suggestion.resourceName}
|
||||||
searchCriteria={searchCriteria}
|
searchCriteria={searchCriteria}
|
||||||
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
|
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
onCancel={() => setExpanded(false)}
|
onCancel={() => setShowAssignForm(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -499,3 +691,45 @@ function ScoreBar({ label, value, tooltip }: { label: string; value: number; too
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatHours(value: number): string {
|
||||||
|
const rounded = Math.round(value * 10) / 10;
|
||||||
|
return `${rounded}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricLine({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-gray-100 pb-2 text-sm last:border-b-0 last:pb-0 dark:border-gray-800">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">{label}</span>
|
||||||
|
<span className="text-right font-medium text-gray-900 dark:text-gray-100">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
helper,
|
||||||
|
tone = "neutral",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
helper?: string;
|
||||||
|
tone?: "neutral" | "good" | "warn";
|
||||||
|
}) {
|
||||||
|
const toneClass = tone === "good"
|
||||||
|
? "border-green-200 bg-green-50/70 dark:border-green-900/40 dark:bg-green-950/20"
|
||||||
|
: tone === "warn"
|
||||||
|
? "border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20"
|
||||||
|
: "border-gray-200 bg-gray-50/70 dark:border-gray-700 dark:bg-gray-900/40";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border p-3 ${toneClass}`}>
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">{value}</div>
|
||||||
|
{helper && (
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{helper}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
|
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||||
|
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
import { DateInput } from "~/components/ui/DateInput.js";
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
|
|
||||||
@@ -28,9 +29,14 @@ export function AllocationPopover({
|
|||||||
anchorX,
|
anchorX,
|
||||||
anchorY,
|
anchorY,
|
||||||
}: AllocationPopoverProps) {
|
}: AllocationPopoverProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const invalidateTimeline = useInvalidateTimeline();
|
const invalidateTimeline = useInvalidateTimeline();
|
||||||
|
const { ref, style } = useViewportPopover({
|
||||||
|
anchor: { kind: "point", x: anchorX, y: anchorY },
|
||||||
|
width: 300,
|
||||||
|
estimatedHeight: 360,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
||||||
{ projectId },
|
{ projectId },
|
||||||
@@ -63,17 +69,6 @@ export function AllocationPopover({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClick(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
function toDateInput(d: Date): string {
|
function toDateInput(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
@@ -93,18 +88,9 @@ export function AllocationPopover({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Position popover so it stays on screen
|
|
||||||
const popoverStyle: React.CSSProperties = {
|
|
||||||
position: "fixed",
|
|
||||||
left: Math.min(anchorX, window.innerWidth - 320),
|
|
||||||
top: Math.min(anchorY + 8, window.innerHeight - 360),
|
|
||||||
zIndex: 50,
|
|
||||||
width: 300,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading || !allocation) {
|
if (isLoading || !allocation) {
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={popoverStyle} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
|
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
|
||||||
Loading...
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -115,7 +101,7 @@ export function AllocationPopover({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={popoverStyle}
|
style={style}
|
||||||
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
|
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
import type { TimelineDemandEntry } from "./TimelineContext.js";
|
||||||
import { formatCents, formatDateLong } from "~/lib/format.js";
|
import { formatCents, formatDateLong } from "~/lib/format.js";
|
||||||
|
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||||
|
|
||||||
interface DemandPopoverProps {
|
interface DemandPopoverProps {
|
||||||
demand: TimelineDemandEntry;
|
demand: TimelineDemandEntry;
|
||||||
@@ -21,17 +21,12 @@ export function DemandPopover({
|
|||||||
anchorX,
|
anchorX,
|
||||||
anchorY,
|
anchorY,
|
||||||
}: DemandPopoverProps) {
|
}: DemandPopoverProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const { ref, style } = useViewportPopover({
|
||||||
|
anchor: { kind: "point", x: anchorX, y: anchorY },
|
||||||
useEffect(() => {
|
width: 300,
|
||||||
function handleClick(e: MouseEvent) {
|
estimatedHeight: 340,
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
onClose,
|
||||||
onClose();
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
|
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
|
||||||
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
|
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
|
||||||
@@ -41,18 +36,10 @@ export function DemandPopover({
|
|||||||
const totalHours = demand.hoursPerDay * days;
|
const totalHours = demand.hoursPerDay * days;
|
||||||
const budgetCents = demand.dailyCostCents * days;
|
const budgetCents = demand.dailyCostCents * days;
|
||||||
|
|
||||||
const popoverStyle: React.CSSProperties = {
|
|
||||||
position: "fixed",
|
|
||||||
left: Math.min(anchorX, window.innerWidth - 320),
|
|
||||||
top: Math.min(anchorY + 8, window.innerHeight - 340),
|
|
||||||
zIndex: 50,
|
|
||||||
width: 300,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={popoverStyle}
|
style={style}
|
||||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { AllocationStatus } from "@capakraken/shared";
|
import { AllocationStatus } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||||
|
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||||
import { DateInput } from "~/components/ui/DateInput.js";
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
|
|
||||||
interface NewAllocationPopoverProps {
|
interface NewAllocationPopoverProps {
|
||||||
@@ -36,7 +37,12 @@ export function NewAllocationPopover({
|
|||||||
onClose,
|
onClose,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: NewAllocationPopoverProps) {
|
}: NewAllocationPopoverProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const { ref, style } = useViewportPopover({
|
||||||
|
anchor: { kind: "point", x: anchorX - 10, y: anchorY },
|
||||||
|
width: 320,
|
||||||
|
estimatedHeight: 440,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
const invalidateTimeline = useInvalidateTimeline();
|
const invalidateTimeline = useInvalidateTimeline();
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -67,17 +73,6 @@ export function NewAllocationPopover({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClick(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
if (!selectedProjectId) return;
|
if (!selectedProjectId) return;
|
||||||
createMutation.mutate({
|
createMutation.mutate({
|
||||||
@@ -93,13 +88,10 @@ export function NewAllocationPopover({
|
|||||||
|
|
||||||
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
|
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
|
||||||
|
|
||||||
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
|
|
||||||
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
|
style={style}
|
||||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { formatCents } from "~/lib/format.js";
|
import { formatCents } from "~/lib/format.js";
|
||||||
import type { SkillEntry } from "@capakraken/shared";
|
import type { SkillEntry } from "@capakraken/shared";
|
||||||
|
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||||
|
|
||||||
interface ResourceHoverCardProps {
|
interface ResourceHoverCardProps {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@@ -12,34 +12,20 @@ interface ResourceHoverCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
|
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const { ref, style } = useViewportPopover({
|
||||||
const [pos, setPos] = useState({ left: 0, top: 0 });
|
anchor: { kind: "element", element: anchorEl },
|
||||||
|
width: 280,
|
||||||
|
estimatedHeight: 320,
|
||||||
|
onClose,
|
||||||
|
side: "right",
|
||||||
|
ignoreElements: [anchorEl],
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
|
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
|
||||||
{ id: resourceId },
|
{ id: resourceId },
|
||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Position relative to anchor element
|
|
||||||
useEffect(() => {
|
|
||||||
const rect = anchorEl.getBoundingClientRect();
|
|
||||||
setPos({
|
|
||||||
left: rect.right + 8,
|
|
||||||
top: Math.min(rect.top, window.innerHeight - 320),
|
|
||||||
});
|
|
||||||
}, [anchorEl]);
|
|
||||||
|
|
||||||
// Close on outside click
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClick(e: MouseEvent) {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, [onClose, anchorEl]);
|
|
||||||
|
|
||||||
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
|
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
|
||||||
const mainSkills = skills.filter((s) => s.isMainSkill);
|
const mainSkills = skills.filter((s) => s.isMainSkill);
|
||||||
const topSkills = skills
|
const topSkills = skills
|
||||||
@@ -47,19 +33,11 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
|
|||||||
.sort((a, b) => b.proficiency - a.proficiency)
|
.sort((a, b) => b.proficiency - a.proficiency)
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
|
|
||||||
const popoverStyle: React.CSSProperties = {
|
|
||||||
position: "fixed",
|
|
||||||
left: Math.min(pos.left, window.innerWidth - 300),
|
|
||||||
top: pos.top,
|
|
||||||
zIndex: 50,
|
|
||||||
width: 280,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
data-resource-hover-card="true"
|
data-resource-hover-card="true"
|
||||||
style={popoverStyle}
|
style={style}
|
||||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
|
||||||
onMouseLeave={onClose}
|
onMouseLeave={onClose}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -113,6 +113,16 @@ export type VacationEntry = {
|
|||||||
halfDayPart?: string | null;
|
halfDayPart?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HolidayOverlayEntry = {
|
||||||
|
id: string;
|
||||||
|
resourceId: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
startDate: Date | string;
|
||||||
|
endDate: Date | string;
|
||||||
|
note?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Context shape ──────────────────────────────────────────────────────────
|
// ─── Context shape ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TimelineContextValue {
|
export interface TimelineContextValue {
|
||||||
@@ -314,9 +324,43 @@ export function TimelineProvider({
|
|||||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
|
||||||
|
{
|
||||||
|
startDate: viewStart,
|
||||||
|
endDate: viewEnd,
|
||||||
|
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
|
||||||
|
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
|
||||||
|
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
|
||||||
|
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
|
||||||
|
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
|
||||||
|
},
|
||||||
|
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||||
|
);
|
||||||
|
|
||||||
const vacationsByResource = useMemo(() => {
|
const vacationsByResource = useMemo(() => {
|
||||||
const map = new Map<string, VacationEntry[]>();
|
const map = new Map<string, VacationEntry[]>();
|
||||||
for (const vacation of vacationEntries as VacationEntry[]) {
|
const mergedEntries = [...(vacationEntries as VacationEntry[])];
|
||||||
|
const existingKeys = new Set(
|
||||||
|
mergedEntries.map((vacation) => {
|
||||||
|
const start = new Date(vacation.startDate).toISOString().slice(0, 10);
|
||||||
|
const end = new Date(vacation.endDate).toISOString().slice(0, 10);
|
||||||
|
return `${vacation.resourceId}:${vacation.type}:${start}:${end}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) {
|
||||||
|
const start = new Date(holiday.startDate).toISOString().slice(0, 10);
|
||||||
|
const end = new Date(holiday.endDate).toISOString().slice(0, 10);
|
||||||
|
const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`;
|
||||||
|
if (existingKeys.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingKeys.add(key);
|
||||||
|
mergedEntries.push(holiday as VacationEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const vacation of mergedEntries) {
|
||||||
const existing = map.get(vacation.resourceId);
|
const existing = map.get(vacation.resourceId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.push(vacation);
|
existing.push(vacation);
|
||||||
@@ -325,7 +369,7 @@ export function TimelineProvider({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [vacationEntries]);
|
}, [holidayOverlayEntries, vacationEntries]);
|
||||||
|
|
||||||
// When EID filter is active, explicitly fetch those resources.
|
// When EID filter is active, explicitly fetch those resources.
|
||||||
const { data: eidFilterData } = trpc.resource.list.useQuery(
|
const { data: eidFilterData } = trpc.resource.list.useQuery(
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
import { useRef, useState, type RefObject } from "react";
|
||||||
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
export interface TimelineFilters {
|
export interface TimelineFilters {
|
||||||
@@ -159,55 +160,12 @@ export function TimelineFilter({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
}: TimelineFilterProps) {
|
}: TimelineFilterProps) {
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
const { panelRef, position } = useAnchoredOverlay<HTMLDivElement>({
|
||||||
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 });
|
open: isOpen,
|
||||||
|
onClose,
|
||||||
const updatePanelPosition = useCallback(() => {
|
align: "end",
|
||||||
const trigger = anchorRef.current;
|
triggerRef: anchorRef,
|
||||||
if (!trigger) return;
|
});
|
||||||
|
|
||||||
const rect = trigger.getBoundingClientRect();
|
|
||||||
const panelWidth = panelRef.current?.offsetWidth ?? 320;
|
|
||||||
const viewportPadding = 16;
|
|
||||||
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
|
|
||||||
|
|
||||||
setPanelPosition({
|
|
||||||
top: rect.bottom + 8,
|
|
||||||
left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
|
|
||||||
});
|
|
||||||
}, [anchorRef]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
updatePanelPosition();
|
|
||||||
const rafId = window.requestAnimationFrame(updatePanelPosition);
|
|
||||||
const handlePointerDown = (event: MouseEvent) => {
|
|
||||||
const target = event.target as Node;
|
|
||||||
if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", updatePanelPosition);
|
|
||||||
window.addEventListener("scroll", updatePanelPosition, true);
|
|
||||||
window.addEventListener("mousedown", handlePointerDown);
|
|
||||||
window.addEventListener("keydown", handleEscape);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(rafId);
|
|
||||||
window.removeEventListener("resize", updatePanelPosition);
|
|
||||||
window.removeEventListener("scroll", updatePanelPosition, true);
|
|
||||||
window.removeEventListener("mousedown", handlePointerDown);
|
|
||||||
window.removeEventListener("keydown", handleEscape);
|
|
||||||
};
|
|
||||||
}, [anchorRef, isOpen, onClose, updatePanelPosition]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
@@ -221,7 +179,7 @@ export function TimelineFilter({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
style={{ position: "fixed", top: panelPosition.top, left: panelPosition.left }}
|
style={{ position: "fixed", top: position.top, left: position.left }}
|
||||||
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
|
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
|||||||
@@ -188,8 +188,10 @@ function TimelineProjectPanelInner({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
|
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
|
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const demandTooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
|
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||||
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
|
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||||
|
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||||
|
|
||||||
const [heatmapHover, setHeatmapHover] = useState<{
|
const [heatmapHover, setHeatmapHover] = useState<{
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -206,6 +208,22 @@ function TimelineProjectPanelInner({
|
|||||||
approvedBy?: { name?: string | null; email: string } | null;
|
approvedBy?: { name?: string | null; email: string } | null;
|
||||||
approvedAt?: Date | string | null;
|
approvedAt?: Date | string | null;
|
||||||
}>(null);
|
}>(null);
|
||||||
|
const [demandHover, setDemandHover] = useState<null | {
|
||||||
|
roleName: string;
|
||||||
|
roleColor: string;
|
||||||
|
projectName: string;
|
||||||
|
projectShortCode?: string | null;
|
||||||
|
requestedHeadcount: number;
|
||||||
|
unfilledHeadcount: number;
|
||||||
|
startDate: Date | string;
|
||||||
|
endDate: Date | string;
|
||||||
|
hoursPerDay: number;
|
||||||
|
totalHours: number;
|
||||||
|
percentage?: number;
|
||||||
|
status?: string;
|
||||||
|
totalCostCents?: number;
|
||||||
|
dailyCostCents?: number;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
|
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
|
||||||
const dateIndexByTime = new Map<number, number>();
|
const dateIndexByTime = new Map<number, number>();
|
||||||
@@ -472,6 +490,7 @@ function TimelineProjectPanelInner({
|
|||||||
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
||||||
vacationHoverRafRef.current = null;
|
vacationHoverRafRef.current = null;
|
||||||
const date = xToDate(clientX, rect);
|
const date = xToDate(clientX, rect);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
const time = date.getTime();
|
const time = date.getTime();
|
||||||
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
||||||
const hit =
|
const hit =
|
||||||
@@ -507,18 +526,58 @@ function TimelineProjectPanelInner({
|
|||||||
|
|
||||||
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
||||||
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
||||||
|
const shouldClearDemand = demandHover !== null;
|
||||||
|
|
||||||
lastHeatmapDayRef.current = -1;
|
lastHeatmapDayRef.current = -1;
|
||||||
lastHeatmapResourceRef.current = null;
|
lastHeatmapResourceRef.current = null;
|
||||||
hoveredVacationKeyRef.current = null;
|
hoveredVacationKeyRef.current = null;
|
||||||
|
|
||||||
if (shouldClearHeatmap || shouldClearVacation) {
|
if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
if (shouldClearHeatmap) setHeatmapHover(null);
|
if (shouldClearHeatmap) setHeatmapHover(null);
|
||||||
if (shouldClearVacation) setVacationHover(null);
|
if (shouldClearVacation) setVacationHover(null);
|
||||||
|
if (shouldClearDemand) setDemandHover(null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, []);
|
}, [demandHover]);
|
||||||
|
|
||||||
|
const handleDemandHoverMove = useCallback(
|
||||||
|
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
|
||||||
|
demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 };
|
||||||
|
if (demandTooltipRef.current) {
|
||||||
|
demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`;
|
||||||
|
demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date(demand.startDate);
|
||||||
|
const endDate = new Date(demand.endDate);
|
||||||
|
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
setDemandHover({
|
||||||
|
roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand",
|
||||||
|
roleColor: demand.roleEntity?.color ?? "#f59e0b",
|
||||||
|
projectName: demand.project.name,
|
||||||
|
projectShortCode: demand.project.shortCode,
|
||||||
|
requestedHeadcount: demand.requestedHeadcount,
|
||||||
|
unfilledHeadcount: demand.unfilledHeadcount,
|
||||||
|
startDate: demand.startDate,
|
||||||
|
endDate: demand.endDate,
|
||||||
|
hoursPerDay: demand.hoursPerDay,
|
||||||
|
totalHours: demand.hoursPerDay * days,
|
||||||
|
percentage: demand.percentage,
|
||||||
|
status: demand.status,
|
||||||
|
...(demand.dailyCostCents > 0
|
||||||
|
? {
|
||||||
|
totalCostCents: demand.dailyCostCents * days,
|
||||||
|
dailyCostCents: demand.dailyCostCents,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -672,6 +731,8 @@ function TimelineProjectPanelInner({
|
|||||||
onAllocMouseDown,
|
onAllocMouseDown,
|
||||||
onAllocTouchStart,
|
onAllocTouchStart,
|
||||||
onAllocationContextMenu,
|
onAllocationContextMenu,
|
||||||
|
handleDemandHoverMove,
|
||||||
|
clearHoverTooltips,
|
||||||
multiSelectState,
|
multiSelectState,
|
||||||
allocDragState,
|
allocDragState,
|
||||||
)
|
)
|
||||||
@@ -699,6 +760,9 @@ function TimelineProjectPanelInner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
data-testid="timeline-project-resource-row-canvas"
|
||||||
|
data-project-id={row.project.id}
|
||||||
|
data-resource-id={row.resource.id}
|
||||||
className="relative overflow-hidden touch-none"
|
className="relative overflow-hidden touch-none"
|
||||||
style={{
|
style={{
|
||||||
width: totalCanvasWidth,
|
width: totalCanvasWidth,
|
||||||
@@ -792,8 +856,11 @@ function TimelineProjectPanelInner({
|
|||||||
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
||||||
vacationTooltipRef={vacationTooltipRef}
|
vacationTooltipRef={vacationTooltipRef}
|
||||||
vacationTooltipPos={vacationTooltipPosRef.current}
|
vacationTooltipPos={vacationTooltipPosRef.current}
|
||||||
|
demandTooltipRef={demandTooltipRef}
|
||||||
|
demandTooltipPos={demandTooltipPosRef.current}
|
||||||
heatmapHover={heatmapHover}
|
heatmapHover={heatmapHover}
|
||||||
vacationHover={vacationHover}
|
vacationHover={vacationHover}
|
||||||
|
demandHover={demandHover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -852,6 +919,8 @@ function renderOpenDemandRow(
|
|||||||
anchorX: number,
|
anchorX: number,
|
||||||
anchorY: number,
|
anchorY: number,
|
||||||
) => void,
|
) => void,
|
||||||
|
onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
|
||||||
|
onClearHoverTooltips: () => void,
|
||||||
multiSelectState: MultiSelectState,
|
multiSelectState: MultiSelectState,
|
||||||
allocDragState: AllocDragState,
|
allocDragState: AllocDragState,
|
||||||
) {
|
) {
|
||||||
@@ -889,6 +958,7 @@ function renderOpenDemandRow(
|
|||||||
<div
|
<div
|
||||||
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
|
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
|
||||||
style={{ width: totalCanvasWidth, height: rowHeight }}
|
style={{ width: totalCanvasWidth, height: rowHeight }}
|
||||||
|
onMouseLeave={onClearHoverTooltips}
|
||||||
>
|
>
|
||||||
{rowGridLines}
|
{rowGridLines}
|
||||||
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
|
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
|
||||||
@@ -962,7 +1032,6 @@ function renderOpenDemandRow(
|
|||||||
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
|
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
|
||||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||||
)}
|
)}
|
||||||
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} – ${formatDateLong(allocEnd)}`}
|
|
||||||
style={{
|
style={{
|
||||||
left: left + 2,
|
left: left + 2,
|
||||||
width: width - 4,
|
width: width - 4,
|
||||||
@@ -986,6 +1055,7 @@ function renderOpenDemandRow(
|
|||||||
e.clientY,
|
e.clientY,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
|
||||||
>
|
>
|
||||||
{/* Left resize handle */}
|
{/* Left resize handle */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { TimelineFilters } from "./TimelineFilter.js";
|
import type { TimelineFilters } from "./TimelineFilter.js";
|
||||||
|
|
||||||
@@ -20,68 +21,22 @@ function TimelineFilterDropdown({
|
|||||||
tooltipContent?: ReactNode;
|
tooltipContent?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
|
||||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
open: isOpen,
|
||||||
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
|
onClose: () => setIsOpen(false),
|
||||||
|
matchTriggerWidth: true,
|
||||||
const updatePanelPosition = useCallback(() => {
|
});
|
||||||
const trigger = dropdownRef.current;
|
|
||||||
if (!trigger) return;
|
|
||||||
|
|
||||||
const rect = trigger.getBoundingClientRect();
|
|
||||||
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
|
|
||||||
const viewportPadding = 16;
|
|
||||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
|
|
||||||
|
|
||||||
setPanelPosition({
|
|
||||||
top: rect.bottom + 8,
|
|
||||||
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
|
|
||||||
minWidth: rect.width,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handlePointerDown(event: MouseEvent) {
|
|
||||||
const target = event.target as Node;
|
|
||||||
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handlePointerDown);
|
|
||||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
updatePanelPosition();
|
|
||||||
const rafId = window.requestAnimationFrame(updatePanelPosition);
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", updatePanelPosition);
|
|
||||||
window.addEventListener("scroll", updatePanelPosition, true);
|
|
||||||
window.addEventListener("keydown", handleEscape);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.cancelAnimationFrame(rafId);
|
|
||||||
window.removeEventListener("resize", updatePanelPosition);
|
|
||||||
window.removeEventListener("scroll", updatePanelPosition, true);
|
|
||||||
window.removeEventListener("keydown", handleEscape);
|
|
||||||
};
|
|
||||||
}, [isOpen, updatePanelPosition]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={dropdownRef} className="relative">
|
<div ref={triggerRef} className="relative">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen((current) => !current)}
|
onClick={() => {
|
||||||
|
const nextOpen = !isOpen;
|
||||||
|
handleOpenChange(nextOpen);
|
||||||
|
setIsOpen(nextOpen);
|
||||||
|
}}
|
||||||
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
|
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
|
||||||
>
|
>
|
||||||
<span className="text-left">{label}</span>
|
<span className="text-left">{label}</span>
|
||||||
@@ -95,9 +50,9 @@ function TimelineFilterDropdown({
|
|||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
top: panelPosition.top,
|
top: position.top,
|
||||||
left: panelPosition.left,
|
left: position.left,
|
||||||
minWidth: panelPosition.minWidth,
|
minWidth: position.minWidth,
|
||||||
}}
|
}}
|
||||||
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
|
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ function TimelineResourcePanelInner({
|
|||||||
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
||||||
vacationHoverRafRef.current = null;
|
vacationHoverRafRef.current = null;
|
||||||
const date = xToDate(clientX, rect);
|
const date = xToDate(clientX, rect);
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
const t = date.getTime();
|
const t = date.getTime();
|
||||||
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
||||||
const hit =
|
const hit =
|
||||||
@@ -494,6 +495,10 @@ function TimelineResourcePanelInner({
|
|||||||
|
|
||||||
{/* Row canvas */}
|
{/* Row canvas */}
|
||||||
<div
|
<div
|
||||||
|
data-testid="timeline-resource-row-canvas"
|
||||||
|
data-resource-id={resource.id}
|
||||||
|
data-resource-eid={resource.eid}
|
||||||
|
data-resource-name={resource.displayName}
|
||||||
className="relative overflow-hidden touch-none"
|
className="relative overflow-hidden touch-none"
|
||||||
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
|
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
@@ -542,10 +547,11 @@ function TimelineResourcePanelInner({
|
|||||||
onAllocationContextMenu,
|
onAllocationContextMenu,
|
||||||
multiSelectState,
|
multiSelectState,
|
||||||
)}
|
)}
|
||||||
{renderVacationBlocks(
|
{filters.showVacations &&
|
||||||
vacationBlocksByResource.get(resource.id) ?? [],
|
renderVacationBlocks(
|
||||||
rowHeight,
|
vacationBlocksByResource.get(resource.id) ?? [],
|
||||||
)}
|
rowHeight,
|
||||||
|
)}
|
||||||
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
|
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
|
||||||
{displayMode === "heatmap" &&
|
{displayMode === "heatmap" &&
|
||||||
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
|
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formatDateLong } from "~/lib/format.js";
|
import { formatCents, formatDateLong } from "~/lib/format.js";
|
||||||
|
|
||||||
|
function getVacationTitle(vacation: VacationHoverData): string {
|
||||||
|
if (vacation.type === "PUBLIC_HOLIDAY" && vacation.note) {
|
||||||
|
return vacation.note;
|
||||||
|
}
|
||||||
|
return vacation.type.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
export type HeatmapHoverData = {
|
export type HeatmapHoverData = {
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -30,6 +37,23 @@ export type VacationHoverData = {
|
|||||||
approvedAt?: Date | string | null;
|
approvedAt?: Date | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DemandHoverData = {
|
||||||
|
roleName: string;
|
||||||
|
roleColor: string;
|
||||||
|
projectName: string;
|
||||||
|
projectShortCode?: string | null;
|
||||||
|
requestedHeadcount: number;
|
||||||
|
unfilledHeadcount: number;
|
||||||
|
startDate: Date | string;
|
||||||
|
endDate: Date | string;
|
||||||
|
hoursPerDay: number;
|
||||||
|
totalHours: number;
|
||||||
|
percentage?: number;
|
||||||
|
status?: string;
|
||||||
|
totalCostCents?: number;
|
||||||
|
dailyCostCents?: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface TimelineTooltipProps {
|
interface TimelineTooltipProps {
|
||||||
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||||
heatmapTooltipPos: { left: number; top: number };
|
heatmapTooltipPos: { left: number; top: number };
|
||||||
@@ -37,6 +61,9 @@ interface TimelineTooltipProps {
|
|||||||
vacationTooltipPos: { left: number; top: number };
|
vacationTooltipPos: { left: number; top: number };
|
||||||
heatmapHover: HeatmapHoverData | null;
|
heatmapHover: HeatmapHoverData | null;
|
||||||
vacationHover: VacationHoverData | null;
|
vacationHover: VacationHoverData | null;
|
||||||
|
demandTooltipRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
|
demandTooltipPos?: { left: number; top: number };
|
||||||
|
demandHover?: DemandHoverData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TimelineTooltip({
|
export function TimelineTooltip({
|
||||||
@@ -46,7 +73,87 @@ export function TimelineTooltip({
|
|||||||
vacationTooltipPos,
|
vacationTooltipPos,
|
||||||
heatmapHover,
|
heatmapHover,
|
||||||
vacationHover,
|
vacationHover,
|
||||||
|
demandTooltipRef,
|
||||||
|
demandTooltipPos,
|
||||||
|
demandHover,
|
||||||
}: TimelineTooltipProps) {
|
}: TimelineTooltipProps) {
|
||||||
|
const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
|
||||||
|
|
||||||
|
if (demandHover && demandTooltipRef && demandTooltipPos) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={demandTooltipRef}
|
||||||
|
style={{
|
||||||
|
left: demandTooltipPos.left,
|
||||||
|
top: demandTooltipPos.top,
|
||||||
|
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||||
|
}}
|
||||||
|
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: demandHover.roleColor }}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-semibold">{demandHover.roleName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[11px] text-gray-400">
|
||||||
|
{demandHover.projectShortCode ? `${demandHover.projectShortCode} · ` : ""}
|
||||||
|
{demandHover.projectName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{demandHover.status ? (
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-amber-300">
|
||||||
|
{demandHover.status}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Requested</div>
|
||||||
|
<div className="font-medium text-gray-100">{demandHover.requestedHeadcount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Open</div>
|
||||||
|
<div className="font-medium text-amber-300">{demandHover.unfilledHeadcount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Range</div>
|
||||||
|
<div className="font-medium text-gray-100">
|
||||||
|
{formatDateLong(demandHover.startDate)} to {formatDateLong(demandHover.endDate)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Load</div>
|
||||||
|
<div className="font-medium text-gray-100">
|
||||||
|
{demandHover.hoursPerDay}h/day · {demandHover.totalHours}h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Allocation</div>
|
||||||
|
<div className="font-medium text-gray-100">{demandHover.percentage}%</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-500">Cost</div>
|
||||||
|
<div className="font-medium text-gray-100">
|
||||||
|
{formatCents(demandHover.totalCostCents)} EUR
|
||||||
|
{typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
|
||||||
|
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// When both are active, render a single merged tooltip using the heatmap position
|
// When both are active, render a single merged tooltip using the heatmap position
|
||||||
if (heatmapHover && vacationHover) {
|
if (heatmapHover && vacationHover) {
|
||||||
return (
|
return (
|
||||||
@@ -114,14 +221,12 @@ export function TimelineTooltip({
|
|||||||
<div className="mt-2 pt-2 border-t border-amber-700/40">
|
<div className="mt-2 pt-2 border-t border-amber-700/40">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
|
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
|
||||||
<span className="font-semibold text-amber-300">
|
<span className="font-semibold text-amber-300">{vacationTitle}</span>
|
||||||
{vacationHover.type.replaceAll("_", " ")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
||||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||||
</div>
|
</div>
|
||||||
{vacationHover.note ? (
|
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
|
||||||
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
|
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -200,11 +305,11 @@ export function TimelineTooltip({
|
|||||||
}}
|
}}
|
||||||
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
|
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
|
||||||
>
|
>
|
||||||
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
|
<div className="font-semibold">{vacationTitle}</div>
|
||||||
<div className="mt-1 text-[11px] text-amber-100/90">
|
<div className="mt-1 text-[11px] text-amber-100/90">
|
||||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||||
</div>
|
</div>
|
||||||
{vacationHover.note ? (
|
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
|
||||||
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: num
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`vac-${v.id}`}
|
key={`vac-${v.id}`}
|
||||||
|
data-testid="timeline-vacation-block"
|
||||||
|
data-vacation-id={v.id}
|
||||||
|
data-vacation-type={v.type}
|
||||||
|
data-vacation-status={v.status}
|
||||||
|
data-vacation-note={v.note ?? ""}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
|
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
|
||||||
colorClass,
|
colorClass,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
import type { ColumnDef } from "@capakraken/shared";
|
import type { ColumnDef } from "@capakraken/shared";
|
||||||
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
|
|
||||||
interface ColumnTogglePanelProps {
|
interface ColumnTogglePanelProps {
|
||||||
allColumns: ColumnDef[];
|
allColumns: ColumnDef[];
|
||||||
@@ -17,18 +19,11 @@ export function ColumnTogglePanel({
|
|||||||
defaultKeys,
|
defaultKeys,
|
||||||
}: ColumnTogglePanelProps) {
|
}: ColumnTogglePanelProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
|
||||||
|
open,
|
||||||
useEffect(() => {
|
onClose: () => setOpen(false),
|
||||||
if (!open) return;
|
align: "end",
|
||||||
function handleClick(e: MouseEvent) {
|
});
|
||||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const dragKey = useRef<string | null>(null);
|
const dragKey = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -59,11 +54,20 @@ export function ColumnTogglePanel({
|
|||||||
const builtins = allColumns.filter((c) => !c.isCustom);
|
const builtins = allColumns.filter((c) => !c.isCustom);
|
||||||
const customs = allColumns.filter((c) => c.isCustom);
|
const customs = allColumns.filter((c) => c.isCustom);
|
||||||
|
|
||||||
|
const handleToggleOpen = useCallback(() => {
|
||||||
|
setOpen((current) => {
|
||||||
|
const nextOpen = !current;
|
||||||
|
handleOpenChange(nextOpen);
|
||||||
|
return nextOpen;
|
||||||
|
});
|
||||||
|
}, [handleOpenChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={panelRef}>
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={handleToggleOpen}
|
||||||
title="Toggle columns"
|
title="Toggle columns"
|
||||||
className={`p-1.5 rounded-lg border text-sm transition-colors ${
|
className={`p-1.5 rounded-lg border text-sm transition-colors ${
|
||||||
open
|
open
|
||||||
@@ -80,83 +84,107 @@ export function ColumnTogglePanel({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open &&
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-xl py-2">
|
createPortal(
|
||||||
<div className="px-3 pb-1 flex items-center justify-between">
|
<div
|
||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span>
|
ref={panelRef}
|
||||||
<button
|
className="fixed z-[9998] w-52 rounded-xl border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-900"
|
||||||
type="button"
|
style={{ top: position.top, left: position.left }}
|
||||||
onClick={reset}
|
>
|
||||||
className="text-xs text-brand-600 hover:text-brand-800"
|
<div className="flex items-center justify-between px-3 pb-1">
|
||||||
>
|
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
Reset
|
Columns
|
||||||
</button>
|
</span>
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reset}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-800"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="max-h-72 overflow-y-auto">
|
<div className="max-h-72 overflow-y-auto">
|
||||||
{builtins.map((col) => {
|
{builtins.map((col) => {
|
||||||
const isVisible = visibleKeys.includes(col.key);
|
const isVisible = visibleKeys.includes(col.key);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={col.key}
|
key={col.key}
|
||||||
draggable={col.hideable && isVisible}
|
draggable={col.hideable && isVisible}
|
||||||
onDragStart={() => { dragKey.current = col.key; }}
|
onDragStart={() => {
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
dragKey.current = col.key;
|
||||||
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
|
}}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${
|
onDragOver={(event) => {
|
||||||
!col.hideable ? "opacity-50" : "cursor-grab"
|
event.preventDefault();
|
||||||
}`}
|
}}
|
||||||
>
|
onDrop={() => {
|
||||||
{col.hideable && isVisible && (
|
if (dragKey.current) reorder(dragKey.current, col.key);
|
||||||
<span className="text-gray-300 text-xs select-none">⠿</span>
|
dragKey.current = null;
|
||||||
)}
|
}}
|
||||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||||
<input
|
!col.hideable ? "opacity-50" : "cursor-grab"
|
||||||
type="checkbox"
|
}`}
|
||||||
checked={isVisible}
|
>
|
||||||
onChange={() => toggle(col.key)}
|
{col.hideable && isVisible && (
|
||||||
disabled={!col.hideable}
|
<span className="select-none text-xs text-gray-300">⠿</span>
|
||||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
)}
|
||||||
/>
|
<label className="flex flex-1 cursor-pointer items-center gap-2">
|
||||||
<span className="text-sm text-gray-700">{col.label}</span>
|
<input
|
||||||
</label>
|
type="checkbox"
|
||||||
</div>
|
checked={isVisible}
|
||||||
);
|
onChange={() => toggle(col.key)}
|
||||||
})}
|
disabled={!col.hideable}
|
||||||
|
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-200">{col.label}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{customs.length > 0 && (
|
{customs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-gray-100" />
|
<div className="my-1 border-t border-gray-100 dark:border-gray-800" />
|
||||||
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p>
|
<p className="px-3 py-1 text-xs font-medium text-gray-400">Custom Fields</p>
|
||||||
{customs.map((col) => {
|
{customs.map((col) => {
|
||||||
const isVisible = visibleKeys.includes(col.key);
|
const isVisible = visibleKeys.includes(col.key);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={col.key}
|
key={col.key}
|
||||||
draggable={isVisible}
|
draggable={isVisible}
|
||||||
onDragStart={() => { dragKey.current = col.key; }}
|
onDragStart={() => {
|
||||||
onDragOver={(e) => { e.preventDefault(); }}
|
dragKey.current = col.key;
|
||||||
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
|
}}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab"
|
onDragOver={(event) => {
|
||||||
>
|
event.preventDefault();
|
||||||
{isVisible && <span className="text-gray-300 text-xs select-none">⠿</span>}
|
}}
|
||||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
onDrop={() => {
|
||||||
<input
|
if (dragKey.current) reorder(dragKey.current, col.key);
|
||||||
type="checkbox"
|
dragKey.current = null;
|
||||||
checked={isVisible}
|
}}
|
||||||
onChange={() => toggle(col.key)}
|
className="flex cursor-grab items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
>
|
||||||
/>
|
{isVisible && <span className="select-none text-xs text-gray-300">⠿</span>}
|
||||||
<span className="text-sm text-gray-700">{col.label}</span>
|
<label className="flex flex-1 cursor-pointer items-center gap-2">
|
||||||
</label>
|
<input
|
||||||
</div>
|
type="checkbox"
|
||||||
);
|
checked={isVisible}
|
||||||
})}
|
onChange={() => toggle(col.key)}
|
||||||
</>
|
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||||
)}
|
/>
|
||||||
</div>
|
<span className="text-sm text-gray-700 dark:text-gray-200">
|
||||||
</div>
|
{col.label}
|
||||||
)}
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,865 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
|
type ScopeType = "COUNTRY" | "STATE" | "CITY";
|
||||||
|
|
||||||
|
type CalendarRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopeType: ScopeType;
|
||||||
|
stateCode: string | null;
|
||||||
|
metroCityId: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
priority: number;
|
||||||
|
country: { id: string; code: string; name: string };
|
||||||
|
metroCity: { id: string; name: string } | null;
|
||||||
|
entries: Array<{
|
||||||
|
id: string;
|
||||||
|
date: string | Date;
|
||||||
|
name: string;
|
||||||
|
isRecurringAnnual: boolean;
|
||||||
|
source: string | null;
|
||||||
|
}>;
|
||||||
|
_count?: { entries: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type CountryRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
metroCities: { id: string; name: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SCOPE_LABELS: Record<ScopeType, string> = {
|
||||||
|
COUNTRY: "Land",
|
||||||
|
STATE: "Bundesland/Region",
|
||||||
|
CITY: "Stadt",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDate(value: string | Date): string {
|
||||||
|
return new Date(value).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HolidayCalendarEditor() {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
const [selectedCalendarId, setSelectedCalendarId] = useState<string | null>(null);
|
||||||
|
const [scopeType, setScopeType] = useState<ScopeType>("COUNTRY");
|
||||||
|
const [countryId, setCountryId] = useState("");
|
||||||
|
const [stateCode, setStateCode] = useState("");
|
||||||
|
const [metroCityId, setMetroCityId] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [priority, setPriority] = useState(0);
|
||||||
|
const [entryDate, setEntryDate] = useState("");
|
||||||
|
const [entryName, setEntryName] = useState("");
|
||||||
|
const [entryRecurring, setEntryRecurring] = useState(false);
|
||||||
|
const [entrySource, setEntrySource] = useState("");
|
||||||
|
const [previewYear, setPreviewYear] = useState(new Date().getFullYear());
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [calendarDraft, setCalendarDraft] = useState({
|
||||||
|
name: "",
|
||||||
|
priority: 0,
|
||||||
|
stateCode: "",
|
||||||
|
metroCityId: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||||
|
const [entryDraft, setEntryDraft] = useState({
|
||||||
|
date: "",
|
||||||
|
name: "",
|
||||||
|
isRecurringAnnual: false,
|
||||||
|
source: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: countries } = trpc.country.list.useQuery();
|
||||||
|
const { data: calendars } = trpc.holidayCalendar.listCalendars.useQuery({ includeInactive: true });
|
||||||
|
|
||||||
|
const selectedCalendar = ((calendars ?? []) as unknown as CalendarRow[]).find((calendar) => calendar.id === selectedCalendarId) ?? null;
|
||||||
|
|
||||||
|
const selectedCountry = useMemo(() => {
|
||||||
|
const rows = (countries ?? []) as unknown as CountryRow[];
|
||||||
|
return rows.find((country) => country.id === countryId) ?? null;
|
||||||
|
}, [countries, countryId]);
|
||||||
|
|
||||||
|
const selectedCalendarCountry = useMemo(() => {
|
||||||
|
const rows = (countries ?? []) as unknown as CountryRow[];
|
||||||
|
return rows.find((country) => country.id === selectedCalendar?.country.id) ?? null;
|
||||||
|
}, [countries, selectedCalendar]);
|
||||||
|
|
||||||
|
const previewQuery = trpc.holidayCalendar.previewResolvedHolidays.useQuery(
|
||||||
|
{
|
||||||
|
countryId: selectedCalendar?.country.id ?? countryId,
|
||||||
|
year: previewYear,
|
||||||
|
...(selectedCalendar?.stateCode ? { stateCode: selectedCalendar.stateCode } : {}),
|
||||||
|
...(selectedCalendar?.metroCityId ? { metroCityId: selectedCalendar.metroCityId } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: Boolean(selectedCalendar?.country.id ?? countryId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidate = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
utils.holidayCalendar.listCalendars.invalidate(),
|
||||||
|
utils.holidayCalendar.getCalendarById.invalidate(),
|
||||||
|
utils.holidayCalendar.previewResolvedHolidays.invalidate(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCalendar = trpc.holidayCalendar.createCalendar.useMutation({
|
||||||
|
onSuccess: async (calendar) => {
|
||||||
|
await invalidate();
|
||||||
|
setSelectedCalendarId(calendar.id);
|
||||||
|
setName("");
|
||||||
|
setStateCode("");
|
||||||
|
setMetroCityId("");
|
||||||
|
setPriority(0);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError) => setError(mutationError.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCalendar = trpc.holidayCalendar.updateCalendar.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError) => setError(mutationError.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCalendar = trpc.holidayCalendar.deleteCalendar.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
setSelectedCalendarId(null);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError) => setError(mutationError.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEntry = trpc.holidayCalendar.createEntry.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
setEntryDate("");
|
||||||
|
setEntryName("");
|
||||||
|
setEntryRecurring(false);
|
||||||
|
setEntrySource("");
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError) => setError(mutationError.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateEntry = trpc.holidayCalendar.updateEntry.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
setEditingEntryId(null);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError) => setError(mutationError.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteEntry = trpc.holidayCalendar.deleteEntry.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidate();
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (mutationError) => setError(mutationError.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const countryRows = (countries ?? []) as unknown as CountryRow[];
|
||||||
|
const calendarRows = (calendars ?? []) as unknown as CalendarRow[];
|
||||||
|
const isCreateScopeValid = scopeType === "COUNTRY"
|
||||||
|
? Boolean(countryId && name.trim())
|
||||||
|
: scopeType === "STATE"
|
||||||
|
? Boolean(countryId && name.trim() && stateCode.trim())
|
||||||
|
: Boolean(countryId && name.trim() && metroCityId);
|
||||||
|
const isCalendarDirty = selectedCalendar !== null && (
|
||||||
|
calendarDraft.name !== selectedCalendar.name
|
||||||
|
|| calendarDraft.priority !== selectedCalendar.priority
|
||||||
|
|| calendarDraft.isActive !== selectedCalendar.isActive
|
||||||
|
|| calendarDraft.stateCode !== (selectedCalendar.stateCode ?? "")
|
||||||
|
|| calendarDraft.metroCityId !== (selectedCalendar.metroCityId ?? "")
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCalendar) {
|
||||||
|
setCalendarDraft({
|
||||||
|
name: "",
|
||||||
|
priority: 0,
|
||||||
|
stateCode: "",
|
||||||
|
metroCityId: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCalendarDraft({
|
||||||
|
name: selectedCalendar.name,
|
||||||
|
priority: selectedCalendar.priority,
|
||||||
|
stateCode: selectedCalendar.stateCode ?? "",
|
||||||
|
metroCityId: selectedCalendar.metroCityId ?? "",
|
||||||
|
isActive: selectedCalendar.isActive,
|
||||||
|
});
|
||||||
|
setEditingEntryId(null);
|
||||||
|
}, [selectedCalendar]);
|
||||||
|
|
||||||
|
function handleCreateCalendar(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!isCreateScopeValid) {
|
||||||
|
setError("Bitte alle Pflichtfelder fuer den gewaehlten Scope ausfuellen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createCalendar.mutate({
|
||||||
|
name: name.trim(),
|
||||||
|
scopeType,
|
||||||
|
countryId,
|
||||||
|
...(scopeType === "STATE" && stateCode.trim() ? { stateCode: stateCode.trim().toUpperCase() } : {}),
|
||||||
|
...(scopeType === "CITY" && metroCityId ? { metroCityId } : {}),
|
||||||
|
priority,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddEntry(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedCalendarId) return;
|
||||||
|
if (!entryDate || !entryName.trim()) {
|
||||||
|
setError("Datum und Feiertagsname sind erforderlich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createEntry.mutate({
|
||||||
|
holidayCalendarId: selectedCalendarId,
|
||||||
|
date: new Date(`${entryDate}T00:00:00.000Z`),
|
||||||
|
name: entryName.trim(),
|
||||||
|
isRecurringAnnual: entryRecurring,
|
||||||
|
...(entrySource.trim() ? { source: entrySource.trim() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCalendarDraft() {
|
||||||
|
if (!selectedCalendar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCalendarDraft({
|
||||||
|
name: selectedCalendar.name,
|
||||||
|
priority: selectedCalendar.priority,
|
||||||
|
stateCode: selectedCalendar.stateCode ?? "",
|
||||||
|
metroCityId: selectedCalendar.metroCityId ?? "",
|
||||||
|
isActive: selectedCalendar.isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateCalendar(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!selectedCalendar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
const normalizedStateCode = calendarDraft.stateCode.trim().toUpperCase();
|
||||||
|
if (selectedCalendar.scopeType === "STATE" && !normalizedStateCode) {
|
||||||
|
setError("State-Kalender benoetigen einen Regionscode.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedCalendar.scopeType === "CITY" && !calendarDraft.metroCityId) {
|
||||||
|
setError("City-Kalender benoetigen eine Stadtzuordnung.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCalendar.mutate({
|
||||||
|
id: selectedCalendar.id,
|
||||||
|
data: {
|
||||||
|
name: calendarDraft.name.trim(),
|
||||||
|
priority: calendarDraft.priority,
|
||||||
|
isActive: calendarDraft.isActive,
|
||||||
|
...(selectedCalendar.scopeType === "STATE" ? { stateCode: normalizedStateCode } : {}),
|
||||||
|
...(selectedCalendar.scopeType === "CITY" ? { metroCityId: calendarDraft.metroCityId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditingEntry(entry: CalendarRow["entries"][number]) {
|
||||||
|
setEditingEntryId(entry.id);
|
||||||
|
setEntryDraft({
|
||||||
|
date: formatDate(entry.date),
|
||||||
|
name: entry.name,
|
||||||
|
isRecurringAnnual: entry.isRecurringAnnual,
|
||||||
|
source: entry.source ?? "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateEntry(entryId: string) {
|
||||||
|
if (!entryDraft.date || !entryDraft.name.trim()) {
|
||||||
|
setError("Ein Feiertagseintrag braucht Datum und Name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
updateEntry.mutate({
|
||||||
|
id: entryId,
|
||||||
|
data: {
|
||||||
|
date: new Date(`${entryDraft.date}T00:00:00.000Z`),
|
||||||
|
name: entryDraft.name.trim(),
|
||||||
|
isRecurringAnnual: entryDraft.isRecurringAnnual,
|
||||||
|
source: entryDraft.source.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteCalendar(calendar: CalendarRow) {
|
||||||
|
if (deleteCalendar.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = globalThis.confirm(
|
||||||
|
`Feiertagskalender "${calendar.name}" wirklich loeschen? Alle Eintraege gehen dabei verloren.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
deleteCalendar.mutate({ id: calendar.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteEntry(entry: CalendarRow["entries"][number]) {
|
||||||
|
if (deleteEntry.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = globalThis.confirm(
|
||||||
|
`Feiertag "${entry.name}" am ${formatDate(entry.date)} wirklich entfernen?`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
deleteEntry.mutate({ id: entry.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="holiday-calendar-editor"
|
||||||
|
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Holiday Calendar Editor</h3>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Pflege Feiertagskalender pro Land, Bundesland/Region oder Stadt. Die Vorschau zeigt den effektiv aufgeloesten Kalender fuer den gewaelten Scope.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[1.1fr_1.4fr]">
|
||||||
|
<form onSubmit={handleCreateCalendar} className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-calendar-name-input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="Bayern Feiertage"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Scope</span>
|
||||||
|
<select
|
||||||
|
data-testid="holiday-calendar-scope-select"
|
||||||
|
value={scopeType}
|
||||||
|
onChange={(e) => setScopeType(e.target.value as ScopeType)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
>
|
||||||
|
{Object.entries(SCOPE_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Land</span>
|
||||||
|
<select
|
||||||
|
data-testid="holiday-calendar-country-select"
|
||||||
|
value={countryId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCountryId(e.target.value);
|
||||||
|
setMetroCityId("");
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Land waehlen</option>
|
||||||
|
{countryRows.map((country) => (
|
||||||
|
<option key={country.id} value={country.id}>{country.name} ({country.code})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-calendar-priority-input"
|
||||||
|
type="number"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(parseInt(e.target.value, 10) || 0)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scopeType === "STATE" && (
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-calendar-state-input"
|
||||||
|
value={stateCode}
|
||||||
|
onChange={(e) => setStateCode(e.target.value.toUpperCase())}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="BY"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{scopeType === "CITY" && (
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
|
||||||
|
<select
|
||||||
|
data-testid="holiday-calendar-city-select"
|
||||||
|
value={metroCityId}
|
||||||
|
onChange={(e) => setMetroCityId(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Stadt waehlen</option>
|
||||||
|
{(selectedCountry?.metroCities ?? []).map((city) => (
|
||||||
|
<option key={city.id} value={city.id}>{city.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-testid="holiday-calendar-create-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={createCalendar.isPending || !isCreateScopeValid}
|
||||||
|
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createCalendar.isPending ? "Speichert..." : "Kalender anlegen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Kalender</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Scope</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Zuordnung</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Eintraege</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{calendarRows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-3 py-6 text-center text-sm text-gray-400">Noch keine Feiertagskalender vorhanden.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{calendarRows.map((calendar) => (
|
||||||
|
<tr
|
||||||
|
key={calendar.id}
|
||||||
|
data-testid={`holiday-calendar-row-${calendar.id}`}
|
||||||
|
className={`cursor-pointer border-t border-gray-200 dark:border-gray-700 ${selectedCalendarId === calendar.id ? "bg-brand-50 dark:bg-brand-950/20" : "hover:bg-gray-50 dark:hover:bg-gray-900/40"}`}
|
||||||
|
onClick={() => setSelectedCalendarId(calendar.id)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100">{calendar.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{calendar.country.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{SCOPE_LABELS[calendar.scopeType]}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||||
|
{calendar.scopeType === "COUNTRY" && calendar.country.code}
|
||||||
|
{calendar.scopeType === "STATE" && calendar.stateCode}
|
||||||
|
{calendar.scopeType === "CITY" && calendar.metroCity?.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-400">{calendar._count?.entries ?? calendar.entries.length}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedCalendar && (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<div className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{selectedCalendar.name}</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{SCOPE_LABELS[selectedCalendar.scopeType]} · {selectedCalendar.country.name}
|
||||||
|
{selectedCalendar.stateCode ? ` · ${selectedCalendar.stateCode}` : ""}
|
||||||
|
{selectedCalendar.metroCity?.name ? ` · ${selectedCalendar.metroCity.name}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
data-testid="holiday-calendar-toggle-active-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateCalendar.mutate({
|
||||||
|
id: selectedCalendar.id,
|
||||||
|
data: { isActive: !selectedCalendar.isActive },
|
||||||
|
})}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||||
|
>
|
||||||
|
{selectedCalendar.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="holiday-calendar-delete-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteCalendar(selectedCalendar)}
|
||||||
|
disabled={deleteCalendar.isPending}
|
||||||
|
className="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950/30"
|
||||||
|
>
|
||||||
|
Loeschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleUpdateCalendar} className="grid gap-3 md:grid-cols-2 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Kalendername</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-calendar-draft-name-input"
|
||||||
|
value={calendarDraft.name}
|
||||||
|
onChange={(e) => setCalendarDraft((current) => ({ ...current, name: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-calendar-draft-priority-input"
|
||||||
|
type="number"
|
||||||
|
value={calendarDraft.priority}
|
||||||
|
onChange={(e) => setCalendarDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
priority: parseInt(e.target.value, 10) || 0,
|
||||||
|
}))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{selectedCalendar.scopeType === "STATE" && (
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-calendar-draft-state-input"
|
||||||
|
value={calendarDraft.stateCode}
|
||||||
|
onChange={(e) => setCalendarDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
stateCode: e.target.value.toUpperCase(),
|
||||||
|
}))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="BY"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCalendar.scopeType === "CITY" && (
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
|
||||||
|
<select
|
||||||
|
data-testid="holiday-calendar-draft-city-select"
|
||||||
|
value={calendarDraft.metroCityId}
|
||||||
|
onChange={(e) => setCalendarDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
metroCityId: e.target.value,
|
||||||
|
}))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Stadt waehlen</option>
|
||||||
|
{(selectedCalendarCountry?.metroCities ?? []).map((city) => (
|
||||||
|
<option key={city.id} value={city.id}>{city.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={calendarDraft.isActive}
|
||||||
|
onChange={(e) => setCalendarDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
isActive: e.target.checked,
|
||||||
|
}))}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
Kalender aktiv
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-end justify-end gap-2 md:col-span-2">
|
||||||
|
<button
|
||||||
|
data-testid="holiday-calendar-reset-button"
|
||||||
|
type="button"
|
||||||
|
onClick={resetCalendarDraft}
|
||||||
|
disabled={!isCalendarDirty || updateCalendar.isPending}
|
||||||
|
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||||
|
>
|
||||||
|
Zuruecksetzen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid="holiday-calendar-save-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={!isCalendarDirty || updateCalendar.isPending}
|
||||||
|
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateCalendar.isPending ? "Speichert..." : "Kalender speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddEntry} className="grid gap-3 md:grid-cols-[1fr_1.25fr_1fr_auto]">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-entry-date-input"
|
||||||
|
type="date"
|
||||||
|
value={entryDate}
|
||||||
|
onChange={(e) => setEntryDate(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Feiertagsname</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-entry-name-input"
|
||||||
|
value={entryName}
|
||||||
|
onChange={(e) => setEntryName(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="Augsburger Friedensfest"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Quelle</span>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-entry-source-input"
|
||||||
|
value={entrySource}
|
||||||
|
onChange={(e) => setEntrySource(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="Kommunale Satzung"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
data-testid="holiday-entry-create-button"
|
||||||
|
type="submit"
|
||||||
|
disabled={createEntry.isPending || !entryDate || !entryName.trim()}
|
||||||
|
className="self-end rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
data-testid="holiday-entry-recurring-checkbox"
|
||||||
|
type="checkbox"
|
||||||
|
checked={entryRecurring}
|
||||||
|
onChange={(e) => setEntryRecurring(e.target.checked)}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
Jaehrlich wiederkehrend
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Typ</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{selectedCalendar.entries.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-6 text-center text-sm text-gray-400">Keine Eintraege vorhanden.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{selectedCalendar.entries.map((entry) => (
|
||||||
|
<tr key={entry.id} className="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">
|
||||||
|
{editingEntryId === entry.id ? (
|
||||||
|
<input
|
||||||
|
data-testid={`holiday-entry-edit-date-${entry.id}`}
|
||||||
|
type="date"
|
||||||
|
value={entryDraft.date}
|
||||||
|
onChange={(e) => setEntryDraft((current) => ({ ...current, date: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
) : formatDate(entry.date)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">
|
||||||
|
{editingEntryId === entry.id ? (
|
||||||
|
<input
|
||||||
|
data-testid={`holiday-entry-edit-name-${entry.id}`}
|
||||||
|
value={entryDraft.name}
|
||||||
|
onChange={(e) => setEntryDraft((current) => ({ ...current, name: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
) : entry.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||||
|
{editingEntryId === entry.id ? (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
data-testid={`holiday-entry-edit-recurring-${entry.id}`}
|
||||||
|
type="checkbox"
|
||||||
|
checked={entryDraft.isRecurringAnnual}
|
||||||
|
onChange={(e) => setEntryDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
isRecurringAnnual: e.target.checked,
|
||||||
|
}))}
|
||||||
|
className="rounded border-gray-300 dark:border-gray-600"
|
||||||
|
/>
|
||||||
|
Jaehrlich
|
||||||
|
</label>
|
||||||
|
) : entry.isRecurringAnnual ? "jaehrlich" : "fix"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||||
|
{editingEntryId === entry.id ? (
|
||||||
|
<input
|
||||||
|
data-testid={`holiday-entry-edit-source-${entry.id}`}
|
||||||
|
value={entryDraft.source}
|
||||||
|
onChange={(e) => setEntryDraft((current) => ({ ...current, source: e.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="Quelle"
|
||||||
|
/>
|
||||||
|
) : entry.source ?? "System/ohne Quelle"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
{editingEntryId === entry.id ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
data-testid={`holiday-entry-save-${entry.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateEntry(entry.id)}
|
||||||
|
disabled={updateEntry.isPending || !entryDraft.date || !entryDraft.name.trim()}
|
||||||
|
className="text-xs font-medium text-brand-600 hover:text-brand-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
data-testid={`holiday-entry-cancel-${entry.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingEntryId(null)}
|
||||||
|
disabled={updateEntry.isPending}
|
||||||
|
className="text-xs font-medium text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
data-testid={`holiday-entry-edit-${entry.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => startEditingEntry(entry)}
|
||||||
|
className="text-xs font-medium text-brand-600 hover:text-brand-700"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
data-testid={`holiday-entry-delete-${entry.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteEntry(entry)}
|
||||||
|
disabled={deleteEntry.isPending}
|
||||||
|
className="text-xs font-medium text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Vorschau</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Effektiv aufgeloeste Feiertage fuer den gewaehlten Scope.</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
data-testid="holiday-preview-year-input"
|
||||||
|
type="number"
|
||||||
|
value={previewYear}
|
||||||
|
onChange={(e) => setPreviewYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
|
||||||
|
className="w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<table data-testid="holiday-preview-table" className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(previewQuery.data ?? []).length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="px-3 py-6 text-center text-sm text-gray-400">
|
||||||
|
{previewQuery.isLoading ? "Laedt Vorschau..." : "Keine Feiertage fuer diese Auswahl vorhanden."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{(previewQuery.data ?? []).map((entry) => (
|
||||||
|
<tr key={`${entry.date}-${entry.name}`} className="border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{entry.date}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">{entry.name}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{entry.calendarName}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { VacationStatus, VacationType } from "@capakraken/shared";
|
import { VacationStatus, VacationType } from "@capakraken/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import { VacationModal } from "./VacationModal.js";
|
import { VacationModal } from "./VacationModal.js";
|
||||||
@@ -137,6 +138,13 @@ export function VacationClient() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
|
||||||
|
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Regional public holidays are maintained in{" "}
|
||||||
|
<Link href="/admin/vacations" className="font-medium text-brand-700 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300">
|
||||||
|
Holiday Calendars
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -10,6 +10,34 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
|||||||
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
|
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
|
||||||
|
|
||||||
const VACATION_TYPES = Object.values(VacationType);
|
const VACATION_TYPES = Object.values(VacationType);
|
||||||
|
const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter((type) => type !== VacationType.PUBLIC_HOLIDAY);
|
||||||
|
|
||||||
|
const HOLIDAY_SOURCE_LABELS = {
|
||||||
|
CALENDAR: "Calendar",
|
||||||
|
LEGACY_PUBLIC_HOLIDAY: "Legacy import",
|
||||||
|
CALENDAR_AND_LEGACY: "Calendar + legacy",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type VacationPreviewData = {
|
||||||
|
requestedDays: number;
|
||||||
|
effectiveDays: number;
|
||||||
|
deductedDays: number;
|
||||||
|
publicHolidayDates: string[];
|
||||||
|
holidayDetails: Array<{
|
||||||
|
date: string;
|
||||||
|
source: string;
|
||||||
|
}>;
|
||||||
|
holidayContext: {
|
||||||
|
countryCode: string | null;
|
||||||
|
countryName: string | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCityName: string | null;
|
||||||
|
sources: {
|
||||||
|
hasCalendarHolidays: boolean;
|
||||||
|
hasLegacyPublicHolidayEntries: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface VacationModalProps {
|
interface VacationModalProps {
|
||||||
resourceId?: string;
|
resourceId?: string;
|
||||||
@@ -17,13 +45,34 @@ interface VacationModalProps {
|
|||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDateInputValue(date: Date | string | null | undefined): string {
|
function toUtcInputDate(value: string): Date {
|
||||||
if (!date) return "";
|
return new Date(`${value}T00:00:00.000Z`);
|
||||||
const d = typeof date === "string" ? new Date(date) : date;
|
}
|
||||||
const y = d.getFullYear();
|
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
function buildHolidayBasisLabel(preview: VacationPreviewData): string[] {
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
const parts = [];
|
||||||
return `${y}-${m}-${day}`;
|
|
||||||
|
if (preview.holidayContext.countryName || preview.holidayContext.countryCode) {
|
||||||
|
parts.push(preview.holidayContext.countryName ?? preview.holidayContext.countryCode ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.holidayContext.federalState) {
|
||||||
|
parts.push(preview.holidayContext.federalState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.holidayContext.metroCityName) {
|
||||||
|
parts.push(preview.holidayContext.metroCityName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHolidaySourceLabel(source: string): string {
|
||||||
|
if (source in HOLIDAY_SOURCE_LABELS) {
|
||||||
|
return HOLIDAY_SOURCE_LABELS[source as keyof typeof HOLIDAY_SOURCE_LABELS];
|
||||||
|
}
|
||||||
|
|
||||||
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
||||||
@@ -70,6 +119,24 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
|
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const previewQuery = trpc.vacation.previewRequest.useQuery(
|
||||||
|
{
|
||||||
|
resourceId,
|
||||||
|
type,
|
||||||
|
startDate: toUtcInputDate(debouncedStart || "1970-01-01"),
|
||||||
|
endDate: toUtcInputDate(debouncedEnd || "1970-01-01"),
|
||||||
|
...(isHalfDay ? { isHalfDay: true } : {}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled:
|
||||||
|
!!resourceId
|
||||||
|
&& !!debouncedStart
|
||||||
|
&& !!debouncedEnd
|
||||||
|
&& (!isHalfDay || debouncedStart === debouncedEnd),
|
||||||
|
staleTime: 10_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const createMutation = trpc.vacation.create.useMutation({
|
const createMutation = trpc.vacation.create.useMutation({
|
||||||
@@ -166,7 +233,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
{/* Type */}
|
{/* Type */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="vac-type" className={labelClass}>
|
<label htmlFor="vac-type" className={labelClass}>
|
||||||
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · PUBLIC_HOLIDAY = national/regional holiday · OTHER = special leave." />
|
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." />
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="vac-type"
|
id="vac-type"
|
||||||
@@ -174,7 +241,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
onChange={(e) => setType(e.target.value as VacationType)}
|
onChange={(e) => setType(e.target.value as VacationType)}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
{VACATION_TYPES.map((t) => (
|
{REQUESTABLE_VACATION_TYPES.map((t) => (
|
||||||
<option key={t} value={t}>
|
<option key={t} value={t}>
|
||||||
{VACATION_TYPE_LABELS[t]}
|
{VACATION_TYPE_LABELS[t]}
|
||||||
</option>
|
</option>
|
||||||
@@ -282,6 +349,81 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!!resourceId && !!startDate && !!endDate && (
|
||||||
|
<div
|
||||||
|
data-testid="vacation-preview-card"
|
||||||
|
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<strong>Leave preview</strong>
|
||||||
|
{previewQuery.isLoading && (
|
||||||
|
<span className="text-xs text-emerald-700">Calculating…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewQuery.data && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs sm:text-sm">
|
||||||
|
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||||
|
<div className="text-emerald-700">Requested</div>
|
||||||
|
<div data-testid="vacation-preview-requested-days" className="font-semibold">
|
||||||
|
{previewQuery.data.requestedDays}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||||
|
<div className="text-emerald-700">Effective</div>
|
||||||
|
<div data-testid="vacation-preview-effective-days" className="font-semibold">
|
||||||
|
{previewQuery.data.effectiveDays}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||||
|
<div className="text-emerald-700">Deducted</div>
|
||||||
|
<div data-testid="vacation-preview-deducted-days" className="font-semibold">
|
||||||
|
{previewQuery.data.deductedDays}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{buildHolidayBasisLabel(previewQuery.data).length > 0 && (
|
||||||
|
<div data-testid="vacation-preview-holiday-basis" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
|
||||||
|
<span className="font-medium">Holiday basis:</span>{" "}
|
||||||
|
{buildHolidayBasisLabel(previewQuery.data).join(" / ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays || previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
|
||||||
|
<div data-testid="vacation-preview-holiday-sources" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
|
||||||
|
<span className="font-medium">Sources:</span>{" "}
|
||||||
|
{[
|
||||||
|
previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null,
|
||||||
|
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null,
|
||||||
|
].filter(Boolean).join(" + ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewQuery.data.publicHolidayDates.length > 0 && (
|
||||||
|
<div data-testid="vacation-preview-public-holidays" className="text-xs sm:text-sm">
|
||||||
|
<span className="font-medium">Excluded public holidays:</span>{" "}
|
||||||
|
{previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewQuery.data.requestedDays !== previewQuery.data.deductedDays && (
|
||||||
|
<div className="text-xs sm:text-sm text-emerald-800">
|
||||||
|
Public holidays in the selected range are excluded from deducted leave days.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewQuery.error && (
|
||||||
|
<div className="mt-2 text-xs text-red-700">
|
||||||
|
{previewQuery.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Note */}
|
{/* Note */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="vac-note" className={labelClass}>
|
<label htmlFor="vac-note" className={labelClass}>
|
||||||
|
|||||||
@@ -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 { prisma } from "@capakraken/db";
|
||||||
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
|
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
|
||||||
import { createAuditEntry } from "@capakraken/api";
|
import { createAuditEntry } from "@capakraken/api/lib/audit";
|
||||||
import { logger } from "@capakraken/api/lib/logger";
|
import { logger } from "@capakraken/api/lib/logger";
|
||||||
import NextAuth, { type NextAuthConfig } from "next-auth";
|
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
@@ -27,9 +27,12 @@ const authConfig = {
|
|||||||
if (!parsed.success) return null;
|
if (!parsed.success) return null;
|
||||||
|
|
||||||
const { email, password, totp } = parsed.data;
|
const { email, password, totp } = parsed.data;
|
||||||
|
const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
|
||||||
|
|
||||||
// Rate limit: 5 login attempts per 15 minutes per email
|
// Rate limit: 5 login attempts per 15 minutes per email
|
||||||
const rateLimitResult = authRateLimiter(email.toLowerCase());
|
const rateLimitResult = isE2eTestMode
|
||||||
|
? { allowed: true }
|
||||||
|
: authRateLimiter(email.toLowerCase());
|
||||||
if (!rateLimitResult.allowed) {
|
if (!rateLimitResult.allowed) {
|
||||||
// Audit failed login (rate limited)
|
// Audit failed login (rate limited)
|
||||||
void createAuditEntry({
|
void createAuditEntry({
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
"isolatedModules": true
|
"isolatedModules": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts"
|
".next/types/**/*.ts",
|
||||||
|
"next-env.d.ts",
|
||||||
|
".next-e2e/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules"
|
"node_modules"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
name: capakraken-prod
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -66,4 +68,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
capakraken_prod_pgdata:
|
capakraken_prod_pgdata:
|
||||||
|
name: capakraken_prod_pgdata
|
||||||
capakraken_prod_redis:
|
capakraken_prod_redis:
|
||||||
|
name: capakraken_prod_redis
|
||||||
|
|||||||
+4
-1
@@ -1,3 +1,5 @@
|
|||||||
|
name: capakraken
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -69,7 +71,7 @@ services:
|
|||||||
postgres-test:
|
postgres-test:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
ports:
|
ports:
|
||||||
- "5434:5432"
|
- "${POSTGRES_TEST_PORT:-5434}:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: capakraken_test
|
POSTGRES_DB: capakraken_test
|
||||||
POSTGRES_USER: capakraken
|
POSTGRES_USER: capakraken
|
||||||
@@ -81,3 +83,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
capakraken_pgdata:
|
capakraken_pgdata:
|
||||||
|
name: capakraken_pgdata
|
||||||
|
|||||||
@@ -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",
|
"dev": "turbo dev",
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"test": "turbo test",
|
"test": "turbo run test:unit",
|
||||||
"test:unit": "turbo test:unit",
|
"test:unit": "turbo test:unit",
|
||||||
"test:e2e": "turbo test:e2e",
|
"test:e2e": "turbo test:e2e",
|
||||||
"db:push": "pnpm --filter @capakraken/db db:push",
|
"db:doctor": "node ./scripts/db-doctor.mjs capakraken",
|
||||||
"db:migrate": "pnpm --filter @capakraken/db db:migrate",
|
"db:push": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:push",
|
||||||
"db:seed": "pnpm --filter @capakraken/db db:seed",
|
"db:migrate": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:migrate",
|
||||||
"db:studio": "pnpm --filter @capakraken/db db:studio",
|
"db:seed": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:seed",
|
||||||
|
"db:studio": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:studio",
|
||||||
"db:reset:dispo": "pnpm --filter @capakraken/db db:reset:dispo",
|
"db:reset:dispo": "pnpm --filter @capakraken/db db:reset:dispo",
|
||||||
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
|
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
|
||||||
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
|
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"./router": "./src/router/index.ts",
|
"./router": "./src/router/index.ts",
|
||||||
"./trpc": "./src/trpc.ts",
|
"./trpc": "./src/trpc.ts",
|
||||||
"./sse": "./src/sse/event-bus.ts",
|
"./sse": "./src/sse/event-bus.ts",
|
||||||
|
"./lib/audit": "./src/lib/audit.ts",
|
||||||
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
|
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
|
||||||
"./lib/logger": "./src/lib/logger.ts",
|
"./lib/logger": "./src/lib/logger.ts",
|
||||||
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { AllocationStatus, SystemRole } from "@capakraken/shared";
|
import { AllocationStatus, SystemRole } from "@capakraken/shared";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { allocationRouter } from "../router/allocation.js";
|
import { allocationRouter } from "../router/allocation.js";
|
||||||
import { emitAllocationCreated, emitAllocationDeleted } from "../sse/event-bus.js";
|
import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
|
||||||
import { createCallerFactory } from "../trpc.js";
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
vi.mock("../sse/event-bus.js", () => ({
|
vi.mock("../sse/event-bus.js", () => ({
|
||||||
emitAllocationCreated: vi.fn(),
|
emitAllocationCreated: vi.fn(),
|
||||||
emitAllocationDeleted: vi.fn(),
|
emitAllocationDeleted: vi.fn(),
|
||||||
emitAllocationUpdated: vi.fn(),
|
emitAllocationUpdated: vi.fn(),
|
||||||
|
emitNotificationCreated: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../lib/budget-alerts.js", () => ({
|
vi.mock("../lib/budget-alerts.js", () => ({
|
||||||
@@ -18,6 +19,10 @@ vi.mock("../lib/cache.js", () => ({
|
|||||||
invalidateDashboardCache: vi.fn(),
|
invalidateDashboardCache: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||||
|
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
const createCaller = createCallerFactory(allocationRouter);
|
const createCaller = createCallerFactory(allocationRouter);
|
||||||
|
|
||||||
function createManagerCaller(db: Record<string, unknown>) {
|
function createManagerCaller(db: Record<string, unknown>) {
|
||||||
@@ -35,7 +40,100 @@ function createManagerCaller(db: Record<string, unknown>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDemandWorkflowDb(overrides: Record<string, unknown> = {}) {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "project_1", name: "Project One" }),
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ name: "FX Artist" }),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
create: vi.fn().mockImplementation(async ({ data }: { data: { userId: string } }) => ({
|
||||||
|
id: `notif_${data.userId}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...db,
|
||||||
|
...overrides,
|
||||||
|
project: { ...db.project, ...(overrides.project as Record<string, unknown> | undefined) },
|
||||||
|
role: { ...db.role, ...(overrides.role as Record<string, unknown> | undefined) },
|
||||||
|
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||||
|
notification: {
|
||||||
|
...db.notification,
|
||||||
|
...(overrides.notification as Record<string, unknown> | undefined),
|
||||||
|
},
|
||||||
|
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("allocation entry resolution router", () => {
|
describe("allocation entry resolution router", () => {
|
||||||
|
it("excludes regional holidays from resource availability coverage", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "resource_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
eid: "E-001",
|
||||||
|
fte: 1,
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
},
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { dailyWorkingHours: 8, code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_1",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
status: AllocationStatus.CONFIRMED,
|
||||||
|
project: { name: "Gamma", shortCode: "GAM" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const result = await caller.checkResourceAvailability({
|
||||||
|
resourceId: "resource_1",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
dailyCapacity: 8,
|
||||||
|
totalWorkingDays: 1,
|
||||||
|
availableDays: 0,
|
||||||
|
partialDays: 0,
|
||||||
|
conflictDays: 1,
|
||||||
|
totalAvailableHours: 0,
|
||||||
|
totalRequestedHours: 8,
|
||||||
|
coveragePercent: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
|
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
|
||||||
const createdDemandRequirement = {
|
const createdDemandRequirement = {
|
||||||
id: "demand_1",
|
id: "demand_1",
|
||||||
@@ -187,6 +285,7 @@ describe("allocation entry resolution router", () => {
|
|||||||
|
|
||||||
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
|
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
|
||||||
vi.mocked(emitAllocationCreated).mockClear();
|
vi.mocked(emitAllocationCreated).mockClear();
|
||||||
|
vi.mocked(emitNotificationCreated).mockClear();
|
||||||
|
|
||||||
const createdDemandRequirement = {
|
const createdDemandRequirement = {
|
||||||
id: "demand_explicit_1",
|
id: "demand_explicit_1",
|
||||||
@@ -206,18 +305,14 @@ describe("allocation entry resolution router", () => {
|
|||||||
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
|
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = {
|
const db = createDemandWorkflowDb({
|
||||||
project: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
|
|
||||||
},
|
|
||||||
demandRequirement: {
|
demandRequirement: {
|
||||||
create: vi.fn().mockResolvedValue(createdDemandRequirement),
|
create: vi.fn().mockResolvedValue(createdDemandRequirement),
|
||||||
},
|
},
|
||||||
auditLog: {
|
}) as Record<string, unknown>;
|
||||||
create: vi.fn().mockResolvedValue({}),
|
Object.assign(db, {
|
||||||
},
|
|
||||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.createDemandRequirement({
|
const result = await caller.createDemandRequirement({
|
||||||
@@ -247,6 +342,8 @@ describe("allocation entry resolution router", () => {
|
|||||||
projectId: "project_1",
|
projectId: "project_1",
|
||||||
resourceId: null,
|
resourceId: null,
|
||||||
});
|
});
|
||||||
|
expect(db.notification.create).toHaveBeenCalledTimes(2);
|
||||||
|
expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
|
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
|
||||||
@@ -730,4 +827,3 @@ describe("allocation entry resolution router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildAssistantInsight } from "../router/assistant-insights.js";
|
||||||
|
|
||||||
|
describe("assistant insights", () => {
|
||||||
|
it("builds a transparent chargeability insight from holiday-aware payloads", () => {
|
||||||
|
const insight = buildAssistantInsight("get_chargeability", {
|
||||||
|
resource: "Bruce Banner",
|
||||||
|
month: "2026-01",
|
||||||
|
chargeability: "42.9%",
|
||||||
|
chargeabilityPct: 42.9,
|
||||||
|
targetPct: 80,
|
||||||
|
availableHours: 168,
|
||||||
|
bookedHours: 72,
|
||||||
|
unassignedHours: 96,
|
||||||
|
targetHours: 134.4,
|
||||||
|
baseWorkingDays: 23,
|
||||||
|
workingDays: 21,
|
||||||
|
baseAvailableHours: 184,
|
||||||
|
locationContext: { country: "Deutschland", federalState: "BY", metroCity: "Augsburg" },
|
||||||
|
holidaySummary: { count: 2, workdayCount: 2, hoursDeduction: 16 },
|
||||||
|
absenceSummary: { dayEquivalent: 0.5, hoursDeduction: 4 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insight).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "chargeability",
|
||||||
|
title: "Bruce Banner · 2026-01",
|
||||||
|
metrics: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ label: "Chargeability", value: "42.9%", tone: "warn" }),
|
||||||
|
expect.objectContaining({ label: "Available", value: "168 h" }),
|
||||||
|
expect.objectContaining({ label: "Target", value: "134.4 h" }),
|
||||||
|
]),
|
||||||
|
sections: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Basis",
|
||||||
|
metrics: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ label: "Location", value: "Augsburg, BY, Deutschland" }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Deductions",
|
||||||
|
metrics: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ label: "Holiday deduction", value: "16 h" }),
|
||||||
|
expect.objectContaining({ label: "Absence deduction", value: "4 h" }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a holiday comparison insight with regional scope counts", () => {
|
||||||
|
const insight = buildAssistantInsight("list_holidays_by_region", {
|
||||||
|
locationContext: { countryCode: "DE", federalState: "BY" },
|
||||||
|
count: 14,
|
||||||
|
periodStart: "2026-01-01",
|
||||||
|
periodEnd: "2026-12-31",
|
||||||
|
summary: {
|
||||||
|
byScope: [
|
||||||
|
{ scope: "NATIONAL", count: 9 },
|
||||||
|
{ scope: "STATE", count: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insight).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "holiday_region",
|
||||||
|
title: "BY, DE",
|
||||||
|
metrics: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ label: "Resolved holidays", value: "14" }),
|
||||||
|
]),
|
||||||
|
sections: [
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Scopes",
|
||||||
|
metrics: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ label: "STATE", value: "5" }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a best-resource insight from staffing recommendations", () => {
|
||||||
|
const insight = buildAssistantInsight("find_best_project_resource", {
|
||||||
|
project: { name: "Gelddruckmaschine", shortCode: "GDM" },
|
||||||
|
period: { startDate: "2026-04-01", endDate: "2026-04-21", minHoursPerDay: 3, rankingMode: "lowest_lcr" },
|
||||||
|
candidateCount: 4,
|
||||||
|
bestMatch: {
|
||||||
|
name: "Jane Doe",
|
||||||
|
role: "TD",
|
||||||
|
chapter: "Lighting",
|
||||||
|
country: "Deutschland",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCity: "Muenchen",
|
||||||
|
lcr: "€85.00",
|
||||||
|
remainingHours: 74,
|
||||||
|
remainingHoursPerDay: 3.5,
|
||||||
|
availableHours: 120,
|
||||||
|
baseAvailableHours: 136,
|
||||||
|
holidaySummary: { hoursDeduction: 8 },
|
||||||
|
absenceSummary: { hoursDeduction: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(insight).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "resource_match",
|
||||||
|
title: "GDM staffing",
|
||||||
|
metrics: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ label: "Best match", value: "Jane Doe" }),
|
||||||
|
expect.objectContaining({ label: "Remaining", value: "74 h", tone: "good" }),
|
||||||
|
]),
|
||||||
|
sections: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Selection",
|
||||||
|
metrics: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ label: "Location", value: "Muenchen, BY, Deutschland" }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||||
|
import { getAvailableAssistantTools } from "../router/assistant.js";
|
||||||
|
|
||||||
|
function getToolNames(permissions: PermissionKeyValue[]) {
|
||||||
|
return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assistant router tool gating", () => {
|
||||||
|
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||||
|
const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
|
||||||
|
const withAdvanced = getToolNames([
|
||||||
|
PermissionKey.VIEW_COSTS,
|
||||||
|
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
||||||
|
expect(withAdvanced).toContain("find_best_project_resource");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps user administration tools behind manageUsers", () => {
|
||||||
|
const withoutManageUsers = getToolNames([]);
|
||||||
|
const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
|
||||||
|
|
||||||
|
expect(withoutManageUsers).not.toContain("list_users");
|
||||||
|
expect(withManageUsers).toContain("list_users");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
|
||||||
|
const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
|
||||||
|
|
||||||
|
expect(names).not.toContain("find_best_project_resource");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { PermissionKey } from "@capakraken/shared";
|
||||||
|
|
||||||
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||||
|
getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||||
|
|
||||||
|
function createToolContext(
|
||||||
|
db: Record<string, unknown>,
|
||||||
|
permissions: PermissionKey[] = [],
|
||||||
|
): ToolContext {
|
||||||
|
return {
|
||||||
|
db: db as ToolContext["db"],
|
||||||
|
userId: "user_1",
|
||||||
|
userRole: "ADMIN",
|
||||||
|
permissions: new Set(permissions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assistant advanced tools and scoping", () => {
|
||||||
|
it("finds the best project resource with holiday-aware remaining capacity and LCR ranking", async () => {
|
||||||
|
const assignmentFindMany = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
resourceId: "res_carol",
|
||||||
|
hoursPerDay: 2,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "PROPOSED",
|
||||||
|
resource: {
|
||||||
|
id: "res_carol",
|
||||||
|
eid: "carol.danvers",
|
||||||
|
displayName: "Carol Danvers",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcrCents: 7664,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "HH",
|
||||||
|
metroCityId: "city_hamburg",
|
||||||
|
country: { code: "DE", name: "Deutschland" },
|
||||||
|
metroCity: { name: "Hamburg" },
|
||||||
|
areaRole: { name: "Artist" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: "res_steve",
|
||||||
|
hoursPerDay: 4,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
resource: {
|
||||||
|
id: "res_steve",
|
||||||
|
eid: "steve.rogers",
|
||||||
|
displayName: "Steve Rogers",
|
||||||
|
chapter: "Delivery",
|
||||||
|
lcrCents: 13377,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: "city_augsburg",
|
||||||
|
country: { code: "DE", name: "Deutschland" },
|
||||||
|
metroCity: { name: "Augsburg" },
|
||||||
|
areaRole: { name: "Artist" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
resourceId: "res_carol",
|
||||||
|
projectId: "project_lari",
|
||||||
|
hoursPerDay: 2,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "PROPOSED",
|
||||||
|
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: "res_steve",
|
||||||
|
projectId: "project_lari",
|
||||||
|
hoursPerDay: 4,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-16T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { name: "Gelddruckmaschine", shortCode: "LARI" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ctx = createToolContext(
|
||||||
|
{
|
||||||
|
project: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "project_lari",
|
||||||
|
name: "Gelddruckmaschine",
|
||||||
|
shortCode: "LARI",
|
||||||
|
status: "ACTIVE",
|
||||||
|
responsiblePerson: "Larissa Joos",
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: assignmentFindMany,
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"find_best_project_resource",
|
||||||
|
JSON.stringify({
|
||||||
|
projectIdentifier: "LARI",
|
||||||
|
startDate: "2026-01-05",
|
||||||
|
endDate: "2026-01-16",
|
||||||
|
minHoursPerDay: 3,
|
||||||
|
rankingMode: "lowest_lcr",
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
project: { shortCode: string };
|
||||||
|
candidateCount: number;
|
||||||
|
bestMatch: {
|
||||||
|
name: string;
|
||||||
|
remainingHoursPerDay: number;
|
||||||
|
lcrCents: number | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCity: string | null;
|
||||||
|
baseAvailableHours: number;
|
||||||
|
holidaySummary: { count: number };
|
||||||
|
};
|
||||||
|
candidates: Array<{
|
||||||
|
name: string;
|
||||||
|
remainingHoursPerDay: number;
|
||||||
|
workingDays: number;
|
||||||
|
baseAvailableHours: number;
|
||||||
|
holidaySummary: { count: number; hoursDeduction: number };
|
||||||
|
capacityBreakdown: { holidayHoursDeduction: number };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.project.shortCode).toBe("LARI");
|
||||||
|
expect(parsed.candidateCount).toBe(2);
|
||||||
|
expect(parsed.bestMatch).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Carol Danvers",
|
||||||
|
remainingHoursPerDay: 6,
|
||||||
|
lcrCents: 7664,
|
||||||
|
federalState: "HH",
|
||||||
|
metroCity: "Hamburg",
|
||||||
|
baseAvailableHours: 80,
|
||||||
|
holidaySummary: expect.objectContaining({ count: 0 }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.candidates).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Carol Danvers",
|
||||||
|
remainingHoursPerDay: 6,
|
||||||
|
workingDays: 10,
|
||||||
|
baseAvailableHours: 80,
|
||||||
|
holidaySummary: expect.objectContaining({ count: 0, hoursDeduction: 0 }),
|
||||||
|
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 0 }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Steve Rogers",
|
||||||
|
remainingHoursPerDay: 4,
|
||||||
|
workingDays: 9,
|
||||||
|
baseAvailableHours: 80,
|
||||||
|
holidaySummary: expect.objectContaining({ count: 1, hoursDeduction: 8 }),
|
||||||
|
capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 8 }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the dedicated advanced assistant permission for the high-level resource tool", async () => {
|
||||||
|
const ctx = createToolContext({}, [PermissionKey.VIEW_COSTS]);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"find_best_project_resource",
|
||||||
|
JSON.stringify({ projectIdentifier: "LARI" }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.stringContaining(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes assistant notification listing to the current user", async () => {
|
||||||
|
const findMany = vi.fn().mockResolvedValue([]);
|
||||||
|
const ctx = createToolContext({
|
||||||
|
notification: {
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeTool("list_notifications", JSON.stringify({ unreadOnly: true }), ctx);
|
||||||
|
|
||||||
|
expect(findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
userId: "user_1",
|
||||||
|
readAt: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects marking notifications that do not belong to the current user", async () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
const ctx = createToolContext({
|
||||||
|
notification: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
|
||||||
|
update,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"mark_notification_read",
|
||||||
|
JSON.stringify({ notificationId: "notif_1" }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(JSON.parse(result.content)).toEqual({
|
||||||
|
error: "Access denied: this notification does not belong to you",
|
||||||
|
});
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires manageUsers before listing users through the assistant", async () => {
|
||||||
|
const findMany = vi.fn();
|
||||||
|
const ctx = createToolContext({
|
||||||
|
user: {
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
|
||||||
|
|
||||||
|
expect(JSON.parse(result.content)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.stringContaining(PermissionKey.MANAGE_USERS),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(findMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||||
|
|
||||||
|
function createToolContext(
|
||||||
|
db: Record<string, unknown>,
|
||||||
|
permissions: string[] = [],
|
||||||
|
): ToolContext {
|
||||||
|
return {
|
||||||
|
db: db as ToolContext["db"],
|
||||||
|
userId: "user_1",
|
||||||
|
userRole: "ADMIN",
|
||||||
|
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("assistant holiday tools", () => {
|
||||||
|
it("lists regional holidays and distinguishes Bavaria from Hamburg", async () => {
|
||||||
|
const ctx = createToolContext({});
|
||||||
|
|
||||||
|
const bavaria = await executeTool(
|
||||||
|
"list_holidays_by_region",
|
||||||
|
JSON.stringify({ countryCode: "DE", federalState: "BY", year: 2026 }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const hamburg = await executeTool(
|
||||||
|
"list_holidays_by_region",
|
||||||
|
JSON.stringify({ countryCode: "DE", federalState: "HH", year: 2026 }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bavariaResult = JSON.parse(bavaria.content) as {
|
||||||
|
count: number;
|
||||||
|
locationContext: { federalState: string | null };
|
||||||
|
summary: { byScope: Array<{ scope: string; count: number }> };
|
||||||
|
holidays: Array<{ name: string; date: string }>;
|
||||||
|
};
|
||||||
|
const hamburgResult = JSON.parse(hamburg.content) as {
|
||||||
|
count: number;
|
||||||
|
locationContext: { federalState: string | null };
|
||||||
|
holidays: Array<{ name: string; date: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(bavariaResult.count).toBeGreaterThan(hamburgResult.count);
|
||||||
|
expect(bavariaResult.locationContext.federalState).toBe("BY");
|
||||||
|
expect(bavariaResult.summary.byScope).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
|
||||||
|
);
|
||||||
|
expect(bavariaResult.holidays).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(hamburgResult.holidays).not.toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves resource-specific holidays including city-local dates", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(null)
|
||||||
|
.mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"get_resource_holidays",
|
||||||
|
JSON.stringify({ identifier: "bruce.banner", year: 2026 }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
resource: { eid: string; federalState: string | null; metroCity: string | null };
|
||||||
|
summary: { byScope: Array<{ scope: string; count: number }> };
|
||||||
|
holidays: Array<{ name: string; date: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.resource).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
eid: "bruce.banner",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCity: "Augsburg",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.holidays).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: "Augsburger Friedensfest", date: "2026-08-08" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(parsed.summary.byScope).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
eid: "bruce.banner",
|
||||||
|
fte: 1,
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE", dailyWorkingHours: 8 },
|
||||||
|
metroCity: null,
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { name: "Gamma", shortCode: "GAM" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"get_chargeability",
|
||||||
|
JSON.stringify({ resourceId: "res_1", month: "2026-01" }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
baseWorkingDays: number;
|
||||||
|
baseAvailableHours: number;
|
||||||
|
availableHours: number;
|
||||||
|
bookedHours: number;
|
||||||
|
workingDays: number;
|
||||||
|
targetHours: number;
|
||||||
|
unassignedHours: number;
|
||||||
|
holidaySummary: { count: number; workdayCount: number; hoursDeduction: number };
|
||||||
|
capacityBreakdown: { formula: string; holidayHoursDeduction: number; absenceHoursDeduction: number };
|
||||||
|
locationContext: { federalState: string | null };
|
||||||
|
allocations: Array<{ hours: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.bookedHours).toBe(8);
|
||||||
|
expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
|
||||||
|
expect(parsed.baseWorkingDays).toBe(23);
|
||||||
|
expect(parsed.baseAvailableHours).toBe(184);
|
||||||
|
expect(parsed.availableHours).toBe(168);
|
||||||
|
expect(parsed.workingDays).toBe(21);
|
||||||
|
expect(parsed.targetHours).toBe(134.4);
|
||||||
|
expect(parsed.unassignedHours).toBe(160);
|
||||||
|
expect(parsed.locationContext.federalState).toBe("BY");
|
||||||
|
expect(parsed.holidaySummary).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
count: 2,
|
||||||
|
workdayCount: 2,
|
||||||
|
hoursDeduction: 16,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.capacityBreakdown).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
|
||||||
|
holidayHoursDeduction: 16,
|
||||||
|
absenceHoursDeduction: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns holiday-aware budget forecast data from the dashboard use-case", async () => {
|
||||||
|
const { getDashboardBudgetForecast } = await import("@capakraken/application");
|
||||||
|
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
projectName: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
spentCents: 60_000,
|
||||||
|
burnRate: 5_000,
|
||||||
|
pctUsed: 60,
|
||||||
|
estimatedExhaustionDate: "2026-02-20",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ctx = createToolContext({}, ["viewCosts"]);
|
||||||
|
const result = await executeTool("get_budget_forecast", "{}", ctx);
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
forecasts: Array<{
|
||||||
|
projectName: string;
|
||||||
|
shortCode: string;
|
||||||
|
budgetCents: number;
|
||||||
|
spentCents: number;
|
||||||
|
remainingCents: number;
|
||||||
|
projectedCents: number;
|
||||||
|
burnRateCents: number;
|
||||||
|
burnStatus: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getDashboardBudgetForecast).toHaveBeenCalled();
|
||||||
|
expect(parsed.forecasts).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
projectName: "Gelddruckmaschine",
|
||||||
|
shortCode: "GDM",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
spentCents: 60_000,
|
||||||
|
remainingCents: 40_000,
|
||||||
|
projectedCents: 100_000,
|
||||||
|
burnRateCents: 5_000,
|
||||||
|
burnStatus: "on_track",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks resource availability with regional holidays excluded from capacity", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { name: "Gamma", shortCode: "GAM" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"check_resource_availability",
|
||||||
|
JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
workingDays: number;
|
||||||
|
periodAvailableHours: number;
|
||||||
|
periodBookedHours: number;
|
||||||
|
periodRemainingHours: number;
|
||||||
|
availableHoursPerDay: number;
|
||||||
|
isFullyAvailable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.workingDays).toBe(1);
|
||||||
|
expect(parsed.periodAvailableHours).toBe(8);
|
||||||
|
expect(parsed.periodBookedHours).toBe(8);
|
||||||
|
expect(parsed.periodRemainingHours).toBe(0);
|
||||||
|
expect(parsed.availableHoursPerDay).toBe(0);
|
||||||
|
expect(parsed.isFullyAvailable).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps scenario simulation flat when a proposed change falls on a local holiday", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "project_1",
|
||||||
|
name: "Holiday Project",
|
||||||
|
budgetCents: 500_000,
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_1",
|
||||||
|
resourceId: "res_1",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
status: "CONFIRMED",
|
||||||
|
resource: {
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
lcrCents: 100,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE", dailyWorkingHours: 8 },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
lcrCents: 100,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE", dailyWorkingHours: 8 },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db, ["manageAllocations"]);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"simulate_scenario",
|
||||||
|
JSON.stringify({
|
||||||
|
projectId: "project_1",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: "2026-01-06",
|
||||||
|
endDate: "2026-01-06",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
baseline: { totalHours: number; totalCostCents: number };
|
||||||
|
scenario: { totalHours: number; totalCostCents: number };
|
||||||
|
delta: { hours: number; costCents: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.baseline).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
totalHours: 8,
|
||||||
|
totalCostCents: 800,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.scenario).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
totalHours: 8,
|
||||||
|
totalCostCents: 800,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed.delta).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
hours: 0,
|
||||||
|
costCents: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers resources without a local holiday in staffing suggestions", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({
|
||||||
|
id: "project_1",
|
||||||
|
name: "Holiday Project",
|
||||||
|
shortCode: "HP",
|
||||||
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_by",
|
||||||
|
displayName: "Bavaria",
|
||||||
|
eid: "BY-1",
|
||||||
|
fte: 1,
|
||||||
|
lcrCents: 10000,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
areaRole: { name: "Consultant" },
|
||||||
|
chapter: "CGI",
|
||||||
|
assignments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "res_hh",
|
||||||
|
displayName: "Hamburg",
|
||||||
|
eid: "HH-1",
|
||||||
|
fte: 1,
|
||||||
|
lcrCents: 10000,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "HH",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
areaRole: { name: "Consultant" },
|
||||||
|
chapter: "CGI",
|
||||||
|
assignments: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"get_staffing_suggestions",
|
||||||
|
JSON.stringify({ projectId: "project_1", limit: 5 }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
suggestions: Array<{ name: string; availableHours: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.suggestions).toHaveLength(1);
|
||||||
|
expect(parsed.suggestions[0]).toEqual(
|
||||||
|
expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds capacity with local holidays respected", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_by",
|
||||||
|
displayName: "Bavaria",
|
||||||
|
eid: "BY-1",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
areaRole: { name: "Consultant" },
|
||||||
|
chapter: "CGI",
|
||||||
|
assignments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "res_hh",
|
||||||
|
displayName: "Hamburg",
|
||||||
|
eid: "HH-1",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "HH",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
areaRole: { name: "Consultant" },
|
||||||
|
chapter: "CGI",
|
||||||
|
assignments: [],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"find_capacity",
|
||||||
|
JSON.stringify({ startDate: "2026-01-06", endDate: "2026-01-06", minHoursPerDay: 1 }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(result.content) as {
|
||||||
|
results: Array<{ name: string; availableHours: number; availableHoursPerDay: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.results).toHaveLength(1);
|
||||||
|
expect(parsed.results[0]).toEqual(
|
||||||
|
expect.objectContaining({ name: "Hamburg", availableHours: 8, availableHoursPerDay: 8 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: "project_1",
|
||||||
|
name: "Holiday Project",
|
||||||
|
shortCode: "HP",
|
||||||
|
shoringThreshold: 55,
|
||||||
|
onshoreCountryCode: "DE",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
resourceId: "res_by",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
resource: {
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: "res_in",
|
||||||
|
hoursPerDay: 8,
|
||||||
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
resource: {
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_in",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "IN" },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const ctx = createToolContext(db);
|
||||||
|
|
||||||
|
const result = await executeTool(
|
||||||
|
"get_shoring_ratio",
|
||||||
|
JSON.stringify({ projectId: "project_1" }),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.content).toContain("0% onshore (DE), 100% offshore");
|
||||||
|
expect(result.content).toContain("IN 100% (1 people)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@capakraken/application", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@capakraken/application")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
|
||||||
|
listAssignmentBookings: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { listAssignmentBookings } from "@capakraken/application";
|
||||||
|
import { checkChargeabilityAlerts } from "../lib/chargeability-alerts.js";
|
||||||
|
|
||||||
|
describe("chargeability alerts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an alert when a regional holiday reduces booked hours below threshold", async () => {
|
||||||
|
const notifications: Array<{ userId: string; title: string; body?: string }> = [];
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: null,
|
||||||
|
federalState: "BY",
|
||||||
|
chargeabilityTarget: 21,
|
||||||
|
country: {
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
},
|
||||||
|
managementLevelGroup: { targetPercentage: 0.21 },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockImplementation(async ({ data }) => {
|
||||||
|
notifications.push(data);
|
||||||
|
return { id: `notification_${notifications.length}`, userId: data.userId };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "manager_1" }]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_1",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Gamma",
|
||||||
|
shortCode: "GAM",
|
||||||
|
status: "ACTIVE",
|
||||||
|
orderType: "CLIENT",
|
||||||
|
dynamicFields: null,
|
||||||
|
},
|
||||||
|
resource: { id: "res_1", displayName: "Bruce Banner", chapter: "CGI" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alertCount = await checkChargeabilityAlerts(db);
|
||||||
|
|
||||||
|
expect(alertCount).toBe(1);
|
||||||
|
expect(notifications).toHaveLength(1);
|
||||||
|
expect(notifications[0]?.title).toContain("Bruce Banner");
|
||||||
|
expect(notifications[0]?.body).toContain("gap: 16pp");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,6 +45,10 @@ describe("chargeability report router", () => {
|
|||||||
eid: "E-001",
|
eid: "E-001",
|
||||||
displayName: "Alice",
|
displayName: "Alice",
|
||||||
fte: 1,
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_es",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: "city_1",
|
||||||
chargeabilityTarget: 80,
|
chargeabilityTarget: 80,
|
||||||
country: {
|
country: {
|
||||||
id: "country_es",
|
id: "country_es",
|
||||||
@@ -143,6 +147,10 @@ describe("chargeability report router", () => {
|
|||||||
eid: "E-001",
|
eid: "E-001",
|
||||||
displayName: "Alice",
|
displayName: "Alice",
|
||||||
fte: 1,
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_es",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: "city_1",
|
||||||
chargeabilityTarget: 80,
|
chargeabilityTarget: 80,
|
||||||
country: {
|
country: {
|
||||||
id: "country_es",
|
id: "country_es",
|
||||||
@@ -204,4 +212,217 @@ describe("chargeability report router", () => {
|
|||||||
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
|
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
|
||||||
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
|
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reduces SAH for German public holidays based on the calendar", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "resource_de",
|
||||||
|
eid: "E-001",
|
||||||
|
displayName: "Alice",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: "city_1",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
country: {
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
},
|
||||||
|
orgUnit: { id: "org_1", name: "CGI" },
|
||||||
|
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||||
|
managementLevel: { id: "level_1", name: "L7" },
|
||||||
|
metroCity: { id: "city_1", name: "Munich" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "project_full_month", utilizationCategory: { code: "Chg" } },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_full_month",
|
||||||
|
projectId: "project_full_month",
|
||||||
|
resourceId: "resource_de",
|
||||||
|
startDate: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-31T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 7,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: {
|
||||||
|
id: "project_full_month",
|
||||||
|
name: "Full Month Project",
|
||||||
|
shortCode: "FMP",
|
||||||
|
status: "ACTIVE",
|
||||||
|
orderType: "CLIENT",
|
||||||
|
dynamicFields: null,
|
||||||
|
},
|
||||||
|
resource: { id: "resource_de", displayName: "Alice", chapter: "CGI" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const report = await caller.getReport({
|
||||||
|
startMonth: "2026-01",
|
||||||
|
endMonth: "2026-01",
|
||||||
|
});
|
||||||
|
|
||||||
|
const month = report.resources[0]?.months[0];
|
||||||
|
|
||||||
|
expect(month).toBeDefined();
|
||||||
|
expect(month?.sah).toBe(168);
|
||||||
|
expect(month?.chg).toBeCloseTo(0.875, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies city-specific public holidays to SAH", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "resource_augsburg",
|
||||||
|
eid: "E-001",
|
||||||
|
displayName: "Alice",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: "city_1",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
country: {
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
},
|
||||||
|
orgUnit: { id: "org_1", name: "CGI" },
|
||||||
|
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||||
|
managementLevel: { id: "level_1", name: "L7" },
|
||||||
|
metroCity: { id: "city_1", name: "Augsburg" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resource_munich",
|
||||||
|
eid: "E-002",
|
||||||
|
displayName: "Bob",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: "city_2",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
country: {
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
},
|
||||||
|
orgUnit: { id: "org_1", name: "CGI" },
|
||||||
|
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||||
|
managementLevel: { id: "level_1", name: "L7" },
|
||||||
|
metroCity: { id: "city_2", name: "Munich" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const report = await caller.getReport({
|
||||||
|
startMonth: "2028-08",
|
||||||
|
endMonth: "2028-08",
|
||||||
|
});
|
||||||
|
|
||||||
|
const augsburg = report.resources.find((resource) => resource.city === "Augsburg");
|
||||||
|
const munich = report.resources.find((resource) => resource.city === "Munich");
|
||||||
|
|
||||||
|
expect(augsburg?.months[0]?.sah).toBe((munich?.months[0]?.sah ?? 0) - 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects individual weekday availability when computing booked hours", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "resource_pt",
|
||||||
|
eid: "E-003",
|
||||||
|
displayName: "Carla",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 0 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: "city_3",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
country: {
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
},
|
||||||
|
orgUnit: { id: "org_1", name: "CGI" },
|
||||||
|
managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
|
||||||
|
managementLevel: { id: "level_1", name: "L7" },
|
||||||
|
metroCity: { id: "city_3", name: "Berlin" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "project_week", utilizationCategory: { code: "Chg" } },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_week",
|
||||||
|
projectId: "project_week",
|
||||||
|
resourceId: "resource_pt",
|
||||||
|
startDate: new Date("2026-03-02T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-03-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 4,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: {
|
||||||
|
id: "project_week",
|
||||||
|
name: "Week Project",
|
||||||
|
shortCode: "WP",
|
||||||
|
status: "ACTIVE",
|
||||||
|
orderType: "CLIENT",
|
||||||
|
dynamicFields: null,
|
||||||
|
},
|
||||||
|
resource: { id: "resource_pt", displayName: "Carla", chapter: "CGI" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const report = await caller.getReport({
|
||||||
|
startMonth: "2026-03",
|
||||||
|
endMonth: "2026-03",
|
||||||
|
});
|
||||||
|
|
||||||
|
const month = report.resources[0]?.months[0];
|
||||||
|
|
||||||
|
expect(month).toBeDefined();
|
||||||
|
expect(month?.chg).toBeCloseTo(16 / 144, 5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { computationGraphRouter } from "../router/computation-graph.js";
|
||||||
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(computationGraphRouter);
|
||||||
|
|
||||||
|
type ResourceGraphMeta = {
|
||||||
|
countryCode: string | null;
|
||||||
|
countryName: string | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCityName: string | null;
|
||||||
|
resolvedHolidays: Array<{
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
scope: "COUNTRY" | "STATE" | "CITY";
|
||||||
|
calendarName: string | null;
|
||||||
|
}>;
|
||||||
|
factors: {
|
||||||
|
baseAvailableHours: number;
|
||||||
|
effectiveAvailableHours: number;
|
||||||
|
publicHolidayCount: number;
|
||||||
|
publicHolidayWorkdayCount: number;
|
||||||
|
publicHolidayHoursDeduction: number;
|
||||||
|
absenceDayCount: number;
|
||||||
|
absenceHoursDeduction: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function createControllerCaller(db: Record<string, unknown>) {
|
||||||
|
return createCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||||
|
expires: "2026-03-14T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_controller",
|
||||||
|
systemRole: SystemRole.CONTROLLER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDb(resourceFindImpl: ReturnType<typeof vi.fn>) {
|
||||||
|
return {
|
||||||
|
resource: {
|
||||||
|
findUniqueOrThrow: resourceFindImpl,
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
calculationRule: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResource(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "resource_1",
|
||||||
|
displayName: "Bruce Banner",
|
||||||
|
eid: "bruce.banner",
|
||||||
|
fte: 1,
|
||||||
|
lcrCents: 5_000,
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
},
|
||||||
|
country: {
|
||||||
|
id: "country_de",
|
||||||
|
code: "DE",
|
||||||
|
name: "Deutschland",
|
||||||
|
dailyWorkingHours: 8,
|
||||||
|
scheduleRules: null,
|
||||||
|
},
|
||||||
|
metroCity: null,
|
||||||
|
managementLevelGroup: {
|
||||||
|
id: "mlg_1",
|
||||||
|
name: "Senior",
|
||||||
|
targetPercentage: 0.8,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("computation graph router", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes location context and city-local holidays in the resource graph", async () => {
|
||||||
|
const db = createDb(vi.fn().mockResolvedValue(buildResource({
|
||||||
|
id: "resource_augsburg",
|
||||||
|
metroCityId: "city_augsburg",
|
||||||
|
metroCity: { id: "city_augsburg", name: "Augsburg" },
|
||||||
|
})));
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getResourceData({
|
||||||
|
resourceId: "resource_augsburg",
|
||||||
|
month: "2026-08",
|
||||||
|
});
|
||||||
|
const meta = result.meta as ResourceGraphMeta;
|
||||||
|
const nodeIds = result.nodes.map((node) => node.id);
|
||||||
|
const holidayExamples = result.nodes.find((node) => node.id === "input.holidayExamples");
|
||||||
|
|
||||||
|
expect(new Set(nodeIds).size).toBe(nodeIds.length);
|
||||||
|
expect(nodeIds).toEqual(expect.arrayContaining([
|
||||||
|
"input.country",
|
||||||
|
"input.state",
|
||||||
|
"input.city",
|
||||||
|
"input.holidayContext",
|
||||||
|
"input.holidayExamples",
|
||||||
|
"sah.baseHours",
|
||||||
|
"sah.publicHolidayHours",
|
||||||
|
"sah.absenceHours",
|
||||||
|
]));
|
||||||
|
expect(meta).toMatchObject({
|
||||||
|
countryCode: "DE",
|
||||||
|
countryName: "Deutschland",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Augsburg",
|
||||||
|
});
|
||||||
|
expect(meta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
date: "2026-08-08",
|
||||||
|
name: "Augsburger Friedensfest",
|
||||||
|
scope: "CITY",
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
expect(meta.factors.publicHolidayCount).toBeGreaterThan(0);
|
||||||
|
expect(meta.factors.publicHolidayWorkdayCount).toBe(0);
|
||||||
|
expect(holidayExamples?.value).toEqual(expect.stringContaining("Augsburger Friedensfest"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives different effective SAH values for Bavaria and Hamburg", async () => {
|
||||||
|
const db = createDb(vi.fn()
|
||||||
|
.mockResolvedValueOnce(buildResource({
|
||||||
|
id: "resource_by",
|
||||||
|
federalState: "BY",
|
||||||
|
managementLevelGroup: null,
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce(buildResource({
|
||||||
|
id: "resource_hh",
|
||||||
|
federalState: "HH",
|
||||||
|
managementLevelGroup: null,
|
||||||
|
})));
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const bavaria = await caller.getResourceData({
|
||||||
|
resourceId: "resource_by",
|
||||||
|
month: "2026-01",
|
||||||
|
});
|
||||||
|
const hamburg = await caller.getResourceData({
|
||||||
|
resourceId: "resource_hh",
|
||||||
|
month: "2026-01",
|
||||||
|
});
|
||||||
|
|
||||||
|
const bavariaMeta = bavaria.meta as ResourceGraphMeta;
|
||||||
|
const hamburgMeta = hamburg.meta as ResourceGraphMeta;
|
||||||
|
|
||||||
|
expect(bavariaMeta.federalState).toBe("BY");
|
||||||
|
expect(hamburgMeta.federalState).toBe("HH");
|
||||||
|
expect(bavariaMeta.factors.baseAvailableHours).toBe(176);
|
||||||
|
expect(hamburgMeta.factors.baseAvailableHours).toBe(176);
|
||||||
|
expect(bavariaMeta.factors.effectiveAvailableHours).toBe(160);
|
||||||
|
expect(hamburgMeta.factors.effectiveAvailableHours).toBe(168);
|
||||||
|
expect(bavariaMeta.factors.publicHolidayWorkdayCount).toBe(2);
|
||||||
|
expect(hamburgMeta.factors.publicHolidayWorkdayCount).toBe(1);
|
||||||
|
expect(bavariaMeta.factors.publicHolidayHoursDeduction).toBe(16);
|
||||||
|
expect(hamburgMeta.factors.publicHolidayHoursDeduction).toBe(8);
|
||||||
|
expect(bavariaMeta.resolvedHolidays).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
|
||||||
|
]));
|
||||||
|
expect(hamburgMeta.resolvedHolidays).not.toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
|||||||
getDashboardDemand: vi.fn(),
|
getDashboardDemand: vi.fn(),
|
||||||
getDashboardTopValueResources: vi.fn(),
|
getDashboardTopValueResources: vi.fn(),
|
||||||
getDashboardChargeabilityOverview: vi.fn(),
|
getDashboardChargeabilityOverview: vi.fn(),
|
||||||
|
getDashboardBudgetForecast: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
getDashboardDemand,
|
getDashboardDemand,
|
||||||
getDashboardTopValueResources,
|
getDashboardTopValueResources,
|
||||||
getDashboardChargeabilityOverview,
|
getDashboardChargeabilityOverview,
|
||||||
|
getDashboardBudgetForecast,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import { dashboardRouter } from "../router/dashboard.js";
|
import { dashboardRouter } from "../router/dashboard.js";
|
||||||
import { createCallerFactory } from "../trpc.js";
|
import { createCallerFactory } from "../trpc.js";
|
||||||
@@ -302,4 +304,52 @@ describe("dashboard router", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getBudgetForecast", () => {
|
||||||
|
it("returns budget forecast rows with calendar location context", async () => {
|
||||||
|
vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
|
||||||
|
{
|
||||||
|
projectId: "project_1",
|
||||||
|
projectName: "Alpha",
|
||||||
|
shortCode: "ALPHA",
|
||||||
|
clientId: "client_1",
|
||||||
|
clientName: "Client One",
|
||||||
|
budgetCents: 100_000,
|
||||||
|
spentCents: 40_000,
|
||||||
|
remainingCents: 60_000,
|
||||||
|
burnRate: 10_000,
|
||||||
|
estimatedExhaustionDate: "2026-06-30",
|
||||||
|
pctUsed: 40,
|
||||||
|
activeAssignmentCount: 2,
|
||||||
|
calendarLocations: [
|
||||||
|
{
|
||||||
|
countryCode: "DE",
|
||||||
|
countryName: "Germany",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
activeAssignmentCount: 2,
|
||||||
|
burnRateCents: 10_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createProtectedCaller({});
|
||||||
|
const result = await caller.getBudgetForecast();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
projectName: "Alpha",
|
||||||
|
activeAssignmentCount: 2,
|
||||||
|
calendarLocations: [
|
||||||
|
expect.objectContaining({
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Munich",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ describe("effortRule.create", () => {
|
|||||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
create: vi.fn().mockResolvedValue(created),
|
create: vi.fn().mockResolvedValue(created),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -180,6 +181,7 @@ describe("effortRule.create", () => {
|
|||||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
create: vi.fn().mockResolvedValue(created),
|
create: vi.fn().mockResolvedValue(created),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -212,6 +214,7 @@ describe("effortRule.update", () => {
|
|||||||
deleteMany: vi.fn(),
|
deleteMany: vi.fn(),
|
||||||
createMany: vi.fn(),
|
createMany: vi.fn(),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -236,6 +239,7 @@ describe("effortRule.update", () => {
|
|||||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -281,6 +285,7 @@ describe("effortRule.delete", () => {
|
|||||||
findUnique: vi.fn().mockResolvedValue(existing),
|
findUnique: vi.fn().mockResolvedValue(existing),
|
||||||
delete: vi.fn().mockResolvedValue(existing),
|
delete: vi.fn().mockResolvedValue(existing),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
|
|||||||
@@ -16,7 +16,17 @@ const createCaller = createCallerFactory(entitlementRouter);
|
|||||||
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
|
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
|
||||||
function createProtectedCaller(db: Record<string, unknown>) {
|
function createProtectedCaller(db: Record<string, unknown>) {
|
||||||
const withResourceOwnership = {
|
const withResourceOwnership = {
|
||||||
resource: { findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }) },
|
resource: {
|
||||||
|
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||||
|
const select = args?.select ?? {};
|
||||||
|
return {
|
||||||
|
...(select.userId ? { userId: "user_1" } : {}),
|
||||||
|
...(select.federalState ? { federalState: "BY" } : {}),
|
||||||
|
...(select.country ? { country: { code: "DE" } } : {}),
|
||||||
|
...(select.metroCity ? { metroCity: null } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
...db,
|
...db,
|
||||||
};
|
};
|
||||||
return createCaller({
|
return createCaller({
|
||||||
@@ -80,6 +90,14 @@ function sampleEntitlement(overrides: Record<string, unknown> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockEntitlementFindUniqueByYear(
|
||||||
|
entitlementsByYear: Record<number, ReturnType<typeof sampleEntitlement> | null>,
|
||||||
|
) {
|
||||||
|
return vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
|
||||||
|
entitlementsByYear[where.resourceId_year.year] ?? null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── getBalance ──────────────────────────────────────────────────────────────
|
// ─── getBalance ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("entitlement.getBalance", () => {
|
describe("entitlement.getBalance", () => {
|
||||||
@@ -90,7 +108,7 @@ describe("entitlement.getBalance", () => {
|
|||||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||||
},
|
},
|
||||||
vacationEntitlement: {
|
vacationEntitlement: {
|
||||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||||
update: vi.fn().mockResolvedValue(entitlement),
|
update: vi.fn().mockResolvedValue(entitlement),
|
||||||
},
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
@@ -129,10 +147,9 @@ describe("entitlement.getBalance", () => {
|
|||||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||||
},
|
},
|
||||||
vacationEntitlement: {
|
vacationEntitlement: {
|
||||||
findUnique: vi
|
findUnique: mockEntitlementFindUniqueByYear({
|
||||||
.fn()
|
2025: prevEntitlement,
|
||||||
.mockResolvedValueOnce(null) // current year not found
|
}),
|
||||||
.mockResolvedValueOnce(prevEntitlement), // previous year found
|
|
||||||
create: vi.fn().mockResolvedValue(createdEntitlement),
|
create: vi.fn().mockResolvedValue(createdEntitlement),
|
||||||
update: vi.fn().mockResolvedValue(createdEntitlement),
|
update: vi.fn().mockResolvedValue(createdEntitlement),
|
||||||
},
|
},
|
||||||
@@ -164,7 +181,7 @@ describe("entitlement.getBalance", () => {
|
|||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
vacationEntitlement: {
|
vacationEntitlement: {
|
||||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||||
update: vi.fn().mockResolvedValue(entitlement),
|
update: vi.fn().mockResolvedValue(entitlement),
|
||||||
},
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
@@ -185,12 +202,14 @@ describe("entitlement.getBalance", () => {
|
|||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
vacationEntitlement: {
|
vacationEntitlement: {
|
||||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||||
update: vi.fn().mockResolvedValue(entitlement),
|
update: vi.fn().mockResolvedValue(entitlement),
|
||||||
},
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
findMany: vi
|
findMany: vi
|
||||||
.fn()
|
.fn()
|
||||||
|
// Public holiday vacations for holiday context
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
// First call: balance-type vacations (for syncEntitlement)
|
// First call: balance-type vacations (for syncEntitlement)
|
||||||
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
||||||
// Second call: sick days
|
// Second call: sick days
|
||||||
@@ -209,19 +228,169 @@ describe("entitlement.getBalance", () => {
|
|||||||
|
|
||||||
expect(result.sickDays).toBe(3);
|
expect(result.sickDays).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not deduct city-specific public holidays from leave balance", async () => {
|
||||||
|
const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
|
||||||
|
const db = {
|
||||||
|
systemSettings: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
userId: "user_1",
|
||||||
|
federalState: "BY",
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: { name: "Augsburg" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
vacationEntitlement: {
|
||||||
|
findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
|
||||||
|
update: vi.fn().mockImplementation(async ({ data }) => ({
|
||||||
|
...entitlement,
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
startDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||||
|
status: "APPROVED",
|
||||||
|
isHalfDay: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
|
||||||
|
|
||||||
|
expect(result.usedDays).toBe(0);
|
||||||
|
expect(result.remainingDays).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recomputes carryover from the previous year when the next year already exists", async () => {
|
||||||
|
const entitlements = new Map([
|
||||||
|
[2025, sampleEntitlement({
|
||||||
|
id: "ent_2025",
|
||||||
|
year: 2025,
|
||||||
|
entitledDays: 28,
|
||||||
|
carryoverDays: 0,
|
||||||
|
usedDays: 8,
|
||||||
|
pendingDays: 0,
|
||||||
|
})],
|
||||||
|
[2026, sampleEntitlement({
|
||||||
|
id: "ent_2026",
|
||||||
|
year: 2026,
|
||||||
|
entitledDays: 28,
|
||||||
|
carryoverDays: 0,
|
||||||
|
usedDays: 0,
|
||||||
|
pendingDays: 0,
|
||||||
|
})],
|
||||||
|
]);
|
||||||
|
const db = {
|
||||||
|
systemSettings: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
userId: "user_1",
|
||||||
|
federalState: "BY",
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
vacationEntitlement: {
|
||||||
|
findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
|
||||||
|
entitlements.get(where.resourceId_year.year) ?? null
|
||||||
|
)),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn().mockImplementation(async ({ where, data }: {
|
||||||
|
where: { id: string };
|
||||||
|
data: Record<string, number>;
|
||||||
|
}) => {
|
||||||
|
const current = [...entitlements.values()].find((entry) => entry.id === where.id);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error(`Unknown entitlement ${where.id}`);
|
||||||
|
}
|
||||||
|
const updated = { ...current, ...data };
|
||||||
|
entitlements.set(updated.year, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
// 2025 holiday context
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
// 2025 balance vacations
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
startDate: new Date("2025-06-10T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2025-06-17T00:00:00.000Z"),
|
||||||
|
status: "APPROVED",
|
||||||
|
isHalfDay: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
// 2026 holiday context
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
// 2026 balance vacations
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
// 2026 sick days
|
||||||
|
.mockResolvedValueOnce([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
|
||||||
|
|
||||||
|
expect(result.carryoverDays).toBe(20);
|
||||||
|
expect(result.entitledDays).toBe(48);
|
||||||
|
expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: "ent_2026" },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
carryoverDays: 20,
|
||||||
|
entitledDays: 48,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── get ─────────────────────────────────────────────────────────────────────
|
// ─── get ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("entitlement.get", () => {
|
describe("entitlement.get", () => {
|
||||||
it("returns existing entitlement (manager role)", async () => {
|
it("returns existing entitlement (manager role)", async () => {
|
||||||
const entitlement = sampleEntitlement();
|
const entitlement = sampleEntitlement({
|
||||||
|
entitledDays: 30,
|
||||||
|
carryoverDays: 0,
|
||||||
|
usedDays: 0,
|
||||||
|
pendingDays: 0,
|
||||||
|
});
|
||||||
const db = {
|
const db = {
|
||||||
systemSettings: {
|
systemSettings: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 30 }),
|
||||||
},
|
},
|
||||||
vacationEntitlement: {
|
vacationEntitlement: {
|
||||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||||
|
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, number> }) => ({
|
||||||
|
...entitlement,
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -259,6 +428,7 @@ describe("entitlement.set", () => {
|
|||||||
update: vi.fn().mockResolvedValue(updated),
|
update: vi.fn().mockResolvedValue(updated),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -286,6 +456,7 @@ describe("entitlement.set", () => {
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
create: vi.fn().mockResolvedValue(created),
|
create: vi.fn().mockResolvedValue(created),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -324,6 +495,7 @@ describe("entitlement.bulkSet", () => {
|
|||||||
vacationEntitlement: {
|
vacationEntitlement: {
|
||||||
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createAdminCaller(db);
|
const caller = createAdminCaller(db);
|
||||||
@@ -350,6 +522,7 @@ describe("entitlement.bulkSet", () => {
|
|||||||
vacationEntitlement: {
|
vacationEntitlement: {
|
||||||
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createAdminCaller(db);
|
const caller = createAdminCaller(db);
|
||||||
@@ -396,10 +569,15 @@ describe("entitlement.getYearSummary", () => {
|
|||||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
federalState: "BY",
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
}),
|
||||||
findMany: vi.fn().mockResolvedValue(resources),
|
findMany: vi.fn().mockResolvedValue(resources),
|
||||||
},
|
},
|
||||||
vacationEntitlement: {
|
vacationEntitlement: {
|
||||||
findUnique: vi.fn().mockResolvedValue(entitlement),
|
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||||
update: vi.fn().mockResolvedValue(entitlement),
|
update: vi.fn().mockResolvedValue(entitlement),
|
||||||
},
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ vi.mock("ioredis", () => {
|
|||||||
describe("event-bus debounce", () => {
|
describe("event-bus debounce", () => {
|
||||||
let received: SseEvent[];
|
let received: SseEvent[];
|
||||||
let unsubscribe: () => void;
|
let unsubscribe: () => void;
|
||||||
|
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
received = [];
|
received = [];
|
||||||
|
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
unsubscribe = eventBus.subscribe((event) => {
|
unsubscribe = eventBus.subscribe((event) => {
|
||||||
received.push(event);
|
received.push(event);
|
||||||
});
|
});
|
||||||
@@ -36,6 +38,7 @@ describe("event-bus debounce", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
cancelPendingEvents();
|
cancelPendingEvents();
|
||||||
|
consoleWarnSpy.mockRestore();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ describe("experienceMultiplier.create", () => {
|
|||||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
create: vi.fn().mockResolvedValue(created),
|
create: vi.fn().mockResolvedValue(created),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -203,6 +204,7 @@ describe("experienceMultiplier.create", () => {
|
|||||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
create: vi.fn().mockResolvedValue(created),
|
create: vi.fn().mockResolvedValue(created),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -235,6 +237,7 @@ describe("experienceMultiplier.update", () => {
|
|||||||
deleteMany: vi.fn(),
|
deleteMany: vi.fn(),
|
||||||
createMany: vi.fn(),
|
createMany: vi.fn(),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -259,6 +262,7 @@ describe("experienceMultiplier.update", () => {
|
|||||||
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
createMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
@@ -308,6 +312,7 @@ describe("experienceMultiplier.delete", () => {
|
|||||||
findUnique: vi.fn().mockResolvedValue(existing),
|
findUnique: vi.fn().mockResolvedValue(existing),
|
||||||
delete: vi.fn().mockResolvedValue(existing),
|
delete: vi.fn().mockResolvedValue(existing),
|
||||||
},
|
},
|
||||||
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
import { holidayCalendarRouter } from "../router/holiday-calendar.js";
|
||||||
|
|
||||||
|
vi.mock("../lib/audit.js", () => ({
|
||||||
|
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(holidayCalendarRouter);
|
||||||
|
|
||||||
|
function createProtectedCaller(db: Record<string, unknown>) {
|
||||||
|
return createCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "user@example.com", name: "User", image: null },
|
||||||
|
expires: "2026-12-31T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_1",
|
||||||
|
systemRole: SystemRole.USER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAdminCaller(db: Record<string, unknown>) {
|
||||||
|
return createCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "admin@example.com", name: "Admin", image: null },
|
||||||
|
expires: "2026-12-31T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "admin_1",
|
||||||
|
systemRole: SystemRole.ADMIN,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("holiday calendar router", () => {
|
||||||
|
it("merges built-in and scoped custom holidays in preview", async () => {
|
||||||
|
const db = {
|
||||||
|
country: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
|
||||||
|
},
|
||||||
|
metroCity: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "cal_city",
|
||||||
|
name: "Augsburg lokal",
|
||||||
|
scopeType: "CITY",
|
||||||
|
priority: 10,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
date: new Date("2020-01-01T00:00:00.000Z"),
|
||||||
|
name: "Augsburg Neujahr",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: new Date("2020-08-08T00:00:00.000Z"),
|
||||||
|
name: "Friedensfest lokal",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.previewResolvedHolidays({
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: "city_augsburg",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.holidayCalendar.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
countryId: "country_de",
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
date: "2026-01-01",
|
||||||
|
name: "Augsburg Neujahr",
|
||||||
|
scopeType: "CITY",
|
||||||
|
calendarName: "Augsburg lokal",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
date: "2026-08-08",
|
||||||
|
name: "Friedensfest lokal",
|
||||||
|
scopeType: "CITY",
|
||||||
|
calendarName: "Augsburg lokal",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate calendar scopes on create", async () => {
|
||||||
|
const db = {
|
||||||
|
country: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" })
|
||||||
|
.mockResolvedValueOnce({ id: "country_de", name: "Deutschland" }),
|
||||||
|
},
|
||||||
|
metroCity: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "existing_scope" }),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createAdminCaller(db);
|
||||||
|
|
||||||
|
await expect(caller.createCalendar({
|
||||||
|
name: "Deutschland Standard",
|
||||||
|
scopeType: "COUNTRY",
|
||||||
|
countryId: "country_de",
|
||||||
|
priority: 0,
|
||||||
|
isActive: true,
|
||||||
|
})).rejects.toThrow("A holiday calendar for this exact scope already exists");
|
||||||
|
|
||||||
|
expect(db.holidayCalendar.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate entry dates within the same calendar", async () => {
|
||||||
|
const db = {
|
||||||
|
holidayCalendar: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "cal_1", name: "Deutschland Standard" }),
|
||||||
|
},
|
||||||
|
holidayCalendarEntry: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createAdminCaller(db);
|
||||||
|
|
||||||
|
await expect(caller.createEntry({
|
||||||
|
holidayCalendarId: "cal_1",
|
||||||
|
date: new Date("2026-12-24T00:00:00.000Z"),
|
||||||
|
name: "Heiligabend lokal",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
source: "manual",
|
||||||
|
})).rejects.toThrow("A holiday entry for this calendar and date already exists");
|
||||||
|
|
||||||
|
expect(db.holidayCalendarEntry.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -187,6 +187,7 @@ describe("notification.create", () => {
|
|||||||
{
|
{
|
||||||
notification: {
|
notification: {
|
||||||
create: vi.fn().mockResolvedValue(created),
|
create: vi.fn().mockResolvedValue(created),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(created),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"user_mgr",
|
"user_mgr",
|
||||||
@@ -209,6 +210,7 @@ describe("notification.create", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(db.notification.findUnique).toHaveBeenCalledWith({ where: { id: "notif_1" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a notification with optional fields", async () => {
|
it("creates a notification with optional fields", async () => {
|
||||||
@@ -222,6 +224,7 @@ describe("notification.create", () => {
|
|||||||
{
|
{
|
||||||
notification: {
|
notification: {
|
||||||
create: vi.fn().mockResolvedValue(created),
|
create: vi.fn().mockResolvedValue(created),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(created),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"user_mgr",
|
"user_mgr",
|
||||||
|
|||||||
@@ -134,12 +134,14 @@ describe("project router", () => {
|
|||||||
create: vi.fn().mockResolvedValue(created),
|
create: vi.fn().mockResolvedValue(created),
|
||||||
},
|
},
|
||||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||||
|
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.create({
|
const result = await caller.create({
|
||||||
shortCode: "PRJ-001",
|
shortCode: "PRJ-001",
|
||||||
name: "Test Project",
|
name: "Test Project",
|
||||||
|
responsiblePerson: "Alice",
|
||||||
orderType: OrderType.CHARGEABLE,
|
orderType: OrderType.CHARGEABLE,
|
||||||
allocationType: AllocationType.INT,
|
allocationType: AllocationType.INT,
|
||||||
winProbability: 80,
|
winProbability: 80,
|
||||||
@@ -167,6 +169,7 @@ describe("project router", () => {
|
|||||||
caller.create({
|
caller.create({
|
||||||
shortCode: "PRJ-001",
|
shortCode: "PRJ-001",
|
||||||
name: "Duplicate",
|
name: "Duplicate",
|
||||||
|
responsiblePerson: "Alice",
|
||||||
orderType: OrderType.CHARGEABLE,
|
orderType: OrderType.CHARGEABLE,
|
||||||
allocationType: AllocationType.INT,
|
allocationType: AllocationType.INT,
|
||||||
budgetCents: 100_00,
|
budgetCents: 100_00,
|
||||||
@@ -189,6 +192,7 @@ describe("project router", () => {
|
|||||||
caller.create({
|
caller.create({
|
||||||
shortCode: "PRJ-002",
|
shortCode: "PRJ-002",
|
||||||
name: "Blocked",
|
name: "Blocked",
|
||||||
|
responsiblePerson: "Alice",
|
||||||
orderType: OrderType.CHARGEABLE,
|
orderType: OrderType.CHARGEABLE,
|
||||||
allocationType: AllocationType.INT,
|
allocationType: AllocationType.INT,
|
||||||
budgetCents: 100_00,
|
budgetCents: 100_00,
|
||||||
@@ -239,6 +243,64 @@ describe("project router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getShoringRatio", () => {
|
||||||
|
it("excludes regional holidays from shoring weighting", async () => {
|
||||||
|
const db = {
|
||||||
|
project: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: "project_1",
|
||||||
|
name: "Test Project",
|
||||||
|
shoringThreshold: 55,
|
||||||
|
onshoreCountryCode: "DE",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
resourceId: "res_de",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
resource: {
|
||||||
|
id: "res_de",
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||||
|
country: { id: "country_de", code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a2",
|
||||||
|
resourceId: "res_es",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
resource: {
|
||||||
|
id: "res_es",
|
||||||
|
countryId: "country_es",
|
||||||
|
federalState: null,
|
||||||
|
metroCityId: null,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
|
||||||
|
country: { id: "country_es", code: "ES" },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getShoringRatio({ projectId: "project_1" });
|
||||||
|
|
||||||
|
expect(result.totalHours).toBe(24);
|
||||||
|
expect(result.onshoreRatio).toBe(33);
|
||||||
|
expect(result.offshoreRatio).toBe(67);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── update ───────────────────────────────────────────────────────────────
|
// ─── update ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
@@ -294,6 +356,7 @@ describe("project router", () => {
|
|||||||
project: {
|
project: {
|
||||||
update: vi.fn().mockResolvedValue(updated),
|
update: vi.fn().mockResolvedValue(updated),
|
||||||
},
|
},
|
||||||
|
webhook: { findMany: vi.fn().mockResolvedValue([]) },
|
||||||
};
|
};
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { SystemRole } from "@capakraken/shared";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@capakraken/application", () => ({
|
||||||
|
isChargeabilityActualBooking: vi.fn(() => false),
|
||||||
|
isChargeabilityRelevantProject: vi.fn(() => false),
|
||||||
|
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/resource-capacity.js", () => ({
|
||||||
|
calculateEffectiveAvailableHours: vi.fn(({ context }: { context?: unknown }) => (context ? 156 : 168)),
|
||||||
|
calculateEffectiveBookedHours: vi.fn(() => 0),
|
||||||
|
countEffectiveWorkingDays: vi.fn(({ context }: { context?: unknown }) => (context ? 19.5 : 21)),
|
||||||
|
getAvailabilityHoursForDate: vi.fn(() => 8),
|
||||||
|
loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map([
|
||||||
|
[
|
||||||
|
"res_1",
|
||||||
|
{
|
||||||
|
holidayDates: new Set(["2026-04-10"]),
|
||||||
|
vacationFractionsByDate: new Map([["2026-04-14", 0.5]]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { reportRouter } from "../router/report.js";
|
||||||
|
import { createCallerFactory } from "../trpc.js";
|
||||||
|
|
||||||
|
const createCaller = createCallerFactory(reportRouter);
|
||||||
|
|
||||||
|
function createControllerCaller(db: Record<string, unknown>) {
|
||||||
|
return createCaller({
|
||||||
|
session: {
|
||||||
|
user: { email: "controller@example.com", name: "Controller", image: null },
|
||||||
|
expires: "2099-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
db: db as never,
|
||||||
|
dbUser: {
|
||||||
|
id: "user_controller",
|
||||||
|
systemRole: SystemRole.CONTROLLER,
|
||||||
|
permissionOverrides: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("report router", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the new resource month transparency columns", async () => {
|
||||||
|
const caller = createControllerCaller({});
|
||||||
|
const columns = await caller.getAvailableColumns({ entity: "resource_month" });
|
||||||
|
|
||||||
|
expect(columns).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ key: "monthlyPublicHolidayCount", label: "Holiday Dates" }),
|
||||||
|
expect.objectContaining({ key: "monthlyTargetHours", label: "Target Hours" }),
|
||||||
|
expect.objectContaining({ key: "monthlyUnassignedHours", label: "Unassigned Hours" }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports resource month basis and computed columns in CSV", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "res_1",
|
||||||
|
eid: "alice",
|
||||||
|
displayName: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
chapter: "VFX",
|
||||||
|
resourceType: "EMPLOYEE",
|
||||||
|
isActive: true,
|
||||||
|
chgResponsibility: false,
|
||||||
|
rolledOff: false,
|
||||||
|
departed: false,
|
||||||
|
lcrCents: 7500,
|
||||||
|
ucrCents: 10000,
|
||||||
|
currency: "EUR",
|
||||||
|
fte: 1,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
federalState: "BY",
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE", name: "Germany" },
|
||||||
|
metroCity: null,
|
||||||
|
orgUnit: { name: "Delivery" },
|
||||||
|
managementLevelGroup: null,
|
||||||
|
managementLevel: { name: "Senior" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.exportReport({
|
||||||
|
entity: "resource_month",
|
||||||
|
columns: [
|
||||||
|
"displayName",
|
||||||
|
"countryCode",
|
||||||
|
"monthlyPublicHolidayCount",
|
||||||
|
"monthlyPublicHolidayHoursDeduction",
|
||||||
|
"monthlyAbsenceHoursDeduction",
|
||||||
|
"monthlySahHours",
|
||||||
|
"monthlyTargetHours",
|
||||||
|
"monthlyUnassignedHours",
|
||||||
|
],
|
||||||
|
filters: [],
|
||||||
|
periodMonth: "2026-04",
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.rowCount).toBe(1);
|
||||||
|
expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
|
||||||
|
expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,6 +86,10 @@ describe("resource router", () => {
|
|||||||
valueScoreBreakdown: null,
|
valueScoreBreakdown: null,
|
||||||
valueScoreUpdatedAt: null,
|
valueScoreUpdatedAt: null,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
};
|
};
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
@@ -158,6 +162,165 @@ describe("resource router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calculates utilization with regional holidays removed from available hours", async () => {
|
||||||
|
const resource = {
|
||||||
|
id: "resource_1",
|
||||||
|
eid: "E-001",
|
||||||
|
displayName: "Alice",
|
||||||
|
email: "alice@example.com",
|
||||||
|
chapter: "CGI",
|
||||||
|
lcrCents: 5000,
|
||||||
|
ucrCents: 9000,
|
||||||
|
currency: "EUR",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
},
|
||||||
|
skills: [],
|
||||||
|
dynamicFields: {},
|
||||||
|
blueprintId: null,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date("2026-03-01"),
|
||||||
|
updatedAt: new Date("2026-03-01"),
|
||||||
|
roleId: null,
|
||||||
|
portfolioUrl: null,
|
||||||
|
postalCode: null,
|
||||||
|
federalState: "BY",
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: null,
|
||||||
|
valueScore: null,
|
||||||
|
valueScoreBreakdown: null,
|
||||||
|
valueScoreUpdatedAt: null,
|
||||||
|
userId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([resource]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_confirmed",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "resource_1",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Project 1",
|
||||||
|
shortCode: "P1",
|
||||||
|
status: "ACTIVE",
|
||||||
|
orderType: "CLIENT",
|
||||||
|
dynamicFields: null,
|
||||||
|
},
|
||||||
|
resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.listWithUtilization({
|
||||||
|
startDate: "2026-01-05T00:00:00.000Z",
|
||||||
|
endDate: "2026-01-06T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
bookingCount: 1,
|
||||||
|
bookedHours: 8,
|
||||||
|
availableHours: 8,
|
||||||
|
utilizationPercent: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shifts marketplace availability when a local holiday blocks today", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-01-06T10:00:00.000Z"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "resource_by",
|
||||||
|
displayName: "Bavaria Artist",
|
||||||
|
eid: "E-BY",
|
||||||
|
chapter: "CGI",
|
||||||
|
skills: [{ skill: "Houdini", proficiency: 5 }],
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
},
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "resource_hh",
|
||||||
|
displayName: "Hamburg Artist",
|
||||||
|
eid: "E-HH",
|
||||||
|
chapter: "CGI",
|
||||||
|
skills: [{ skill: "Houdini", proficiency: 5 }],
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
},
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "HH",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getSkillMarketplace({
|
||||||
|
searchSkill: "houdini",
|
||||||
|
availableOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bavaria = result.searchResults.find((resource) => resource.id === "resource_by");
|
||||||
|
const hamburg = result.searchResults.find((resource) => resource.id === "resource_hh");
|
||||||
|
|
||||||
|
expect(bavaria?.availableFrom).toBe("2026-01-07T00:00:00.000Z");
|
||||||
|
expect(hamburg?.availableFrom).toBe("2026-01-06T00:00:00.000Z");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("uses a composite displayName/id cursor for stable pagination", async () => {
|
it("uses a composite displayName/id cursor for stable pagination", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
@@ -314,6 +477,84 @@ describe("resource router", () => {
|
|||||||
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
|
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("excludes regional public holidays from chargeability stats", async () => {
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "resource_by",
|
||||||
|
eid: "E-BY",
|
||||||
|
displayName: "Bavaria",
|
||||||
|
chapter: "CGI",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: "city_munich",
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: { name: "Munich" },
|
||||||
|
availability: {
|
||||||
|
monday: 8,
|
||||||
|
tuesday: 8,
|
||||||
|
wednesday: 8,
|
||||||
|
thursday: 8,
|
||||||
|
friday: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_holiday",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "resource_by",
|
||||||
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Project 1",
|
||||||
|
shortCode: "P1",
|
||||||
|
status: "ACTIVE",
|
||||||
|
orderType: "CLIENT",
|
||||||
|
dynamicFields: null,
|
||||||
|
},
|
||||||
|
resource: { id: "resource_by", displayName: "Bavaria", chapter: "CGI" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const RealDate = Date;
|
||||||
|
class MockDate extends Date {
|
||||||
|
constructor(...args: ConstructorParameters<typeof Date>) {
|
||||||
|
if (args.length === 0) {
|
||||||
|
super("2026-01-15T00:00:00.000Z");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super(...args);
|
||||||
|
}
|
||||||
|
static now() {
|
||||||
|
return new RealDate("2026-01-15T00:00:00.000Z").getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal("Date", MockDate);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const caller = createControllerCaller(db);
|
||||||
|
const result = await caller.getChargeabilityStats({});
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
actualChargeability: 0,
|
||||||
|
expectedChargeability: 0,
|
||||||
|
availableHours: 168,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("applies country filters including explicit no-country toggle", async () => {
|
it("applies country filters including explicit no-country toggle", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
|
|||||||
@@ -17,23 +17,6 @@ vi.mock("@capakraken/staffing", () => ({
|
|||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
analyzeUtilization: vi.fn().mockReturnValue({
|
|
||||||
resourceId: "res_1",
|
|
||||||
displayName: "Alice",
|
|
||||||
totalDays: 20,
|
|
||||||
allocatedDays: 15,
|
|
||||||
utilizationPercent: 75,
|
|
||||||
chargeablePercent: 60,
|
|
||||||
overallocatedDays: 0,
|
|
||||||
dailyBreakdown: [],
|
|
||||||
}),
|
|
||||||
findCapacityWindows: vi.fn().mockReturnValue([
|
|
||||||
{
|
|
||||||
startDate: new Date("2026-04-01"),
|
|
||||||
endDate: new Date("2026-04-10"),
|
|
||||||
availableHoursPerDay: 6,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@capakraken/application", () => ({
|
vi.mock("@capakraken/application", () => ({
|
||||||
@@ -76,6 +59,11 @@ function sampleResource(overrides: Record<string, unknown> = {}) {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
valueScore: 85,
|
valueScore: 85,
|
||||||
chapter: "VFX",
|
chapter: "VFX",
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -105,6 +93,30 @@ describe("staffing.getSuggestions", () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
expect(result[0]).toHaveProperty("resourceId");
|
expect(result[0]).toHaveProperty("resourceId");
|
||||||
expect(result[0]).toHaveProperty("score");
|
expect(result[0]).toHaveProperty("score");
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
resourceName: "Alice",
|
||||||
|
eid: "alice",
|
||||||
|
location: {
|
||||||
|
countryCode: "DE",
|
||||||
|
federalState: "BY",
|
||||||
|
},
|
||||||
|
capacity: expect.objectContaining({
|
||||||
|
requestedHoursPerDay: 8,
|
||||||
|
baseAvailableHours: expect.any(Number),
|
||||||
|
effectiveAvailableHours: expect.any(Number),
|
||||||
|
remainingHoursPerDay: expect.any(Number),
|
||||||
|
holidayHoursDeduction: expect.any(Number),
|
||||||
|
}),
|
||||||
|
conflicts: {
|
||||||
|
count: expect.any(Number),
|
||||||
|
conflictDays: expect.any(Array),
|
||||||
|
details: expect.any(Array),
|
||||||
|
},
|
||||||
|
ranking: expect.objectContaining({
|
||||||
|
rank: 1,
|
||||||
|
components: expect.any(Array),
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters resources by chapter when provided", async () => {
|
it("filters resources by chapter when provided", async () => {
|
||||||
@@ -175,6 +187,58 @@ describe("staffing.getSuggestions", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses value score as a transparent tiebreaker within two score points", async () => {
|
||||||
|
const resources = [
|
||||||
|
sampleResource({ id: "res_1", displayName: "Alice", eid: "alice", valueScore: 60 }),
|
||||||
|
sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 95 }),
|
||||||
|
];
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue(resources),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rankResources } = await import("@capakraken/staffing");
|
||||||
|
vi.mocked(rankResources).mockImplementationOnce((input: { resources: Array<{ id: string }> }) => ([
|
||||||
|
{
|
||||||
|
resourceId: input.resources[0]!.id,
|
||||||
|
score: 80,
|
||||||
|
breakdown: {
|
||||||
|
skillScore: 80,
|
||||||
|
availabilityScore: 80,
|
||||||
|
costScore: 80,
|
||||||
|
utilizationScore: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceId: input.resources[1]!.id,
|
||||||
|
score: 79,
|
||||||
|
breakdown: {
|
||||||
|
skillScore: 79,
|
||||||
|
availabilityScore: 79,
|
||||||
|
costScore: 79,
|
||||||
|
utilizationScore: 79,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]));
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.getSuggestions({
|
||||||
|
requiredSkills: ["Compositing"],
|
||||||
|
startDate: new Date("2026-04-01"),
|
||||||
|
endDate: new Date("2026-04-30"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[0]?.resourceId).toBe("res_2");
|
||||||
|
expect(result[0]?.ranking).toMatchObject({
|
||||||
|
rank: 1,
|
||||||
|
baseRank: 2,
|
||||||
|
tieBreakerApplied: true,
|
||||||
|
});
|
||||||
|
expect(result[0]?.ranking.tieBreakerReason).toContain("value score");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── analyzeUtilization ──────────────────────────────────────────────────────
|
// ─── analyzeUtilization ──────────────────────────────────────────────────────
|
||||||
@@ -186,6 +250,11 @@ describe("staffing.analyzeUtilization", () => {
|
|||||||
displayName: "Alice",
|
displayName: "Alice",
|
||||||
chargeabilityTarget: 80,
|
chargeabilityTarget: 80,
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
};
|
};
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
@@ -200,10 +269,56 @@ describe("staffing.analyzeUtilization", () => {
|
|||||||
endDate: new Date("2026-04-30"),
|
endDate: new Date("2026-04-30"),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toHaveProperty("utilizationPercent");
|
expect(result).toHaveProperty("currentChargeability");
|
||||||
expect(result.resourceId).toBe("res_1");
|
expect(result.resourceId).toBe("res_1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("excludes Bavarian public holidays from chargeability analysis", async () => {
|
||||||
|
const resource = {
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Alice",
|
||||||
|
chargeabilityTarget: 80,
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(resource),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { id: "project_1", name: "Chargeable", shortCode: "CHG", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
|
||||||
|
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.analyzeUtilization({
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.currentChargeability).toBe(100);
|
||||||
|
expect(result.overallocatedDays).toEqual([]);
|
||||||
|
expect(result.underutilizedDays).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
@@ -230,6 +345,11 @@ describe("staffing.findCapacity", () => {
|
|||||||
id: "res_1",
|
id: "res_1",
|
||||||
displayName: "Alice",
|
displayName: "Alice",
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
};
|
};
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
@@ -244,8 +364,53 @@ describe("staffing.findCapacity", () => {
|
|||||||
endDate: new Date("2026-04-30"),
|
endDate: new Date("2026-04-30"),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
expect(result[0]).toHaveProperty("availableHoursPerDay");
|
expect(result[0]).toHaveProperty("availableHoursPerDay");
|
||||||
|
expect(result.every((window) => window.availableHoursPerDay > 0)).toBe(true);
|
||||||
|
expect(result.reduce((sum, window) => sum + window.availableDays, 0)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits capacity windows around Bavarian public holidays", async () => {
|
||||||
|
const resource = {
|
||||||
|
id: "res_1",
|
||||||
|
displayName: "Alice",
|
||||||
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
};
|
||||||
|
const db = {
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(resource),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.findCapacity({
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||||
|
minAvailableHoursPerDay: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
startDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-05T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result[1]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
startDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-07T00:00:00.000Z"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws NOT_FOUND when resource does not exist", async () => {
|
it("throws NOT_FOUND when resource does not exist", async () => {
|
||||||
@@ -265,11 +430,16 @@ describe("staffing.findCapacity", () => {
|
|||||||
).rejects.toThrow("Resource not found");
|
).rejects.toThrow("Resource not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes minAvailableHoursPerDay to engine", async () => {
|
it("honors minAvailableHoursPerDay when computing holiday-aware windows", async () => {
|
||||||
const resource = {
|
const resource = {
|
||||||
id: "res_1",
|
id: "res_1",
|
||||||
displayName: "Alice",
|
displayName: "Alice",
|
||||||
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
};
|
};
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
@@ -277,21 +447,30 @@ describe("staffing.findCapacity", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const { findCapacityWindows } = await import("@capakraken/staffing");
|
const { listAssignmentBookings } = await import("@capakraken/application");
|
||||||
|
vi.mocked(listAssignmentBookings).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
projectId: "project_1",
|
||||||
|
resourceId: "res_1",
|
||||||
|
startDate: new Date("2026-04-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-04-30T00:00:00.000Z"),
|
||||||
|
hoursPerDay: 3,
|
||||||
|
dailyCostCents: 0,
|
||||||
|
status: "CONFIRMED",
|
||||||
|
project: { id: "project_1", name: "Project", shortCode: "PRJ", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
|
||||||
|
resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createProtectedCaller(db);
|
||||||
await caller.findCapacity({
|
const result = await caller.findCapacity({
|
||||||
resourceId: "res_1",
|
resourceId: "res_1",
|
||||||
startDate: new Date("2026-04-01"),
|
startDate: new Date("2026-04-01"),
|
||||||
endDate: new Date("2026-04-30"),
|
endDate: new Date("2026-04-30"),
|
||||||
minAvailableHoursPerDay: 6,
|
minAvailableHoursPerDay: 6,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(findCapacityWindows).toHaveBeenCalledWith(
|
expect(result.every((window) => window.availableHoursPerDay >= 6)).toBe(true);
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.any(Date),
|
|
||||||
expect.any(Date),
|
|
||||||
6,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -290,4 +290,83 @@ describe("timeline allocation entry resolution", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns resolved holiday overlays for assigned resources", async () => {
|
||||||
|
const db = {
|
||||||
|
demandRequirement: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "assignment_1",
|
||||||
|
kind: "assignment",
|
||||||
|
resourceId: "resource_by",
|
||||||
|
projectId: "project_1",
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-01-31"),
|
||||||
|
hoursPerDay: 8,
|
||||||
|
status: AllocationStatus.CONFIRMED,
|
||||||
|
metadata: {},
|
||||||
|
project: {
|
||||||
|
id: "project_1",
|
||||||
|
name: "Project One",
|
||||||
|
shortCode: "PRJ",
|
||||||
|
status: "ACTIVE",
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-03-31"),
|
||||||
|
orderType: "CHARGEABLE",
|
||||||
|
clientId: null,
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
id: "resource_by",
|
||||||
|
displayName: "Alice",
|
||||||
|
eid: "E-001",
|
||||||
|
chapter: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "resource_by",
|
||||||
|
countryId: "country_de",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityId: null,
|
||||||
|
country: { code: "DE" },
|
||||||
|
metroCity: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
country: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
metroCity: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
const overlays = await caller.getHolidayOverlays({
|
||||||
|
startDate: new Date("2026-01-01"),
|
||||||
|
endDate: new Date("2026-01-31"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(overlays).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
resourceId: "resource_by",
|
||||||
|
type: "PUBLIC_HOLIDAY",
|
||||||
|
note: "Heilige Drei Könige",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,12 +9,30 @@ vi.mock("../sse/event-bus.js", () => ({
|
|||||||
emitVacationUpdated: vi.fn(),
|
emitVacationUpdated: vi.fn(),
|
||||||
emitVacationDeleted: vi.fn(),
|
emitVacationDeleted: vi.fn(),
|
||||||
emitNotificationCreated: vi.fn(),
|
emitNotificationCreated: vi.fn(),
|
||||||
|
emitTaskAssigned: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../lib/email.js", () => ({
|
vi.mock("../lib/email.js", () => ({
|
||||||
sendEmail: vi.fn(),
|
sendEmail: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/create-notification.js", () => ({
|
||||||
|
createNotification: vi.fn().mockResolvedValue("notif_1"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/vacation-conflicts.js", () => ({
|
||||||
|
checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
|
||||||
|
checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/webhook-dispatcher.js", () => ({
|
||||||
|
dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/audit.js", () => ({
|
||||||
|
createAuditEntry: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
const createCaller = createCallerFactory(vacationRouter);
|
const createCaller = createCallerFactory(vacationRouter);
|
||||||
|
|
||||||
function createProtectedCaller(db: Record<string, unknown>) {
|
function createProtectedCaller(db: Record<string, unknown>) {
|
||||||
@@ -91,6 +109,56 @@ const sampleVacation = {
|
|||||||
approvedBy: null,
|
approvedBy: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createVacationDb(overrides: Record<string, unknown> = {}) {
|
||||||
|
const db = {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
||||||
|
findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockImplementation(async (args?: { select?: Record<string, unknown> }) => {
|
||||||
|
const select = args?.select ?? {};
|
||||||
|
return {
|
||||||
|
...(select.userId ? { userId: "user_1" } : {}),
|
||||||
|
...(select.displayName ? { displayName: "Alice" } : {}),
|
||||||
|
...(select.user ? { user: null } : {}),
|
||||||
|
...(select.federalState ? { federalState: "BY" } : {}),
|
||||||
|
...(select.country ? { country: { code: "DE", name: "Germany" } } : {}),
|
||||||
|
...(select.metroCity ? { metroCity: null } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue(sampleVacation),
|
||||||
|
update: vi.fn().mockResolvedValue(sampleVacation),
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
},
|
||||||
|
auditLog: {
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...db,
|
||||||
|
...overrides,
|
||||||
|
user: { ...db.user, ...(overrides.user as Record<string, unknown> | undefined) },
|
||||||
|
resource: { ...db.resource, ...(overrides.resource as Record<string, unknown> | undefined) },
|
||||||
|
vacation: { ...db.vacation, ...(overrides.vacation as Record<string, unknown> | undefined) },
|
||||||
|
notification: {
|
||||||
|
...db.notification,
|
||||||
|
...(overrides.notification as Record<string, unknown> | undefined),
|
||||||
|
},
|
||||||
|
auditLog: { ...db.auditLog, ...(overrides.auditLog as Record<string, unknown> | undefined) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("vacation router", () => {
|
describe("vacation router", () => {
|
||||||
describe("list", () => {
|
describe("list", () => {
|
||||||
it("returns vacations with default filters", async () => {
|
it("returns vacations with default filters", async () => {
|
||||||
@@ -199,18 +267,11 @@ describe("vacation router", () => {
|
|||||||
status: VacationStatus.PENDING,
|
status: VacationStatus.PENDING,
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
user: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
|
||||||
},
|
|
||||||
resource: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
|
||||||
},
|
|
||||||
vacation: {
|
vacation: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
|
||||||
create: vi.fn().mockResolvedValue(createdVacation),
|
create: vi.fn().mockResolvedValue(createdVacation),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createProtectedCaller(db);
|
||||||
const result = await caller.create({
|
const result = await caller.create({
|
||||||
@@ -239,15 +300,14 @@ describe("vacation router", () => {
|
|||||||
approvedById: "mgr_1",
|
approvedById: "mgr_1",
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
|
||||||
},
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
|
||||||
create: vi.fn().mockResolvedValue(createdVacation),
|
create: vi.fn().mockResolvedValue(createdVacation),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.create({
|
const result = await caller.create({
|
||||||
@@ -269,17 +329,11 @@ describe("vacation router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects overlapping vacation", async () => {
|
it("rejects overlapping vacation", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
user: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
|
||||||
},
|
|
||||||
resource: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
|
||||||
},
|
|
||||||
vacation: {
|
vacation: {
|
||||||
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
|
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createProtectedCaller(db);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -293,10 +347,10 @@ describe("vacation router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when end date is before start date", async () => {
|
it("rejects when end date is before start date", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
user: { findUnique: vi.fn() },
|
user: { findUnique: vi.fn() },
|
||||||
vacation: { findFirst: vi.fn() },
|
vacation: { findFirst: vi.fn() },
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createProtectedCaller(db);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -316,18 +370,11 @@ describe("vacation router", () => {
|
|||||||
halfDayPart: "MORNING",
|
halfDayPart: "MORNING",
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
user: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
|
||||||
},
|
|
||||||
resource: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
|
|
||||||
},
|
|
||||||
vacation: {
|
vacation: {
|
||||||
findFirst: vi.fn().mockResolvedValue(null),
|
|
||||||
create: vi.fn().mockResolvedValue(createdVacation),
|
create: vi.fn().mockResolvedValue(createdVacation),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createProtectedCaller(db);
|
||||||
const result = await caller.create({
|
const result = await caller.create({
|
||||||
@@ -349,6 +396,235 @@ describe("vacation router", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects multi-day half-day vacations", async () => {
|
||||||
|
const db = createVacationDb();
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
|
||||||
|
await expect(caller.create({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: new Date("2026-06-01"),
|
||||||
|
endDate: new Date("2026-06-02"),
|
||||||
|
isHalfDay: true,
|
||||||
|
halfDayPart: "MORNING",
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects half-day vacations without a half-day part", async () => {
|
||||||
|
const db = createVacationDb();
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
|
||||||
|
await expect(caller.create({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: new Date("2026-06-01"),
|
||||||
|
endDate: new Date("2026-06-01"),
|
||||||
|
isHalfDay: true,
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects half-day parts on full-day vacations", async () => {
|
||||||
|
const db = createVacationDb();
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
|
||||||
|
await expect(caller.create({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: new Date("2026-06-01"),
|
||||||
|
endDate: new Date("2026-06-01"),
|
||||||
|
halfDayPart: "AFTERNOON",
|
||||||
|
})).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects leave requests that only hit public holidays", async () => {
|
||||||
|
const db = createVacationDb({
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
|
||||||
|
await expect(caller.create({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-01-06T00:00:00.000Z"),
|
||||||
|
})).rejects.toThrow("does not deduct any vacation days");
|
||||||
|
|
||||||
|
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("previewRequest", () => {
|
||||||
|
it("shows public holidays as non-deductible leave days", async () => {
|
||||||
|
const db = createVacationDb({
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
userId: "user_1",
|
||||||
|
federalState: "BY",
|
||||||
|
country: { code: "DE", name: "Germany" },
|
||||||
|
metroCity: { name: "Augsburg" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.previewRequest({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2028-08-08T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.requestedDays).toBe(1);
|
||||||
|
expect(result.effectiveDays).toBe(0);
|
||||||
|
expect(result.deductedDays).toBe(0);
|
||||||
|
expect(result.publicHolidayDates).toContain("2028-08-08");
|
||||||
|
expect(result.holidayContext).toEqual({
|
||||||
|
countryCode: "DE",
|
||||||
|
countryName: "Germany",
|
||||||
|
federalState: "BY",
|
||||||
|
metroCityName: "Augsburg",
|
||||||
|
sources: {
|
||||||
|
hasCalendarHolidays: true,
|
||||||
|
hasLegacyPublicHolidayEntries: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.holidayDetails).toContainEqual({
|
||||||
|
date: "2028-08-08",
|
||||||
|
source: "CALENDAR",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses custom city holiday calendars for non-deductible leave days", async () => {
|
||||||
|
const db = createVacationDb({
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
userId: "user_1",
|
||||||
|
countryId: "country_de",
|
||||||
|
metroCityId: "city_muc",
|
||||||
|
federalState: "BY",
|
||||||
|
country: { code: "DE", name: "Germany" },
|
||||||
|
metroCity: { name: "Muenchen" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
holidayCalendar: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "cal_muc",
|
||||||
|
name: "Muenchen lokal",
|
||||||
|
scopeType: "CITY",
|
||||||
|
priority: 10,
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
date: new Date("2020-11-15T00:00:00.000Z"),
|
||||||
|
name: "Lokaler Stadtfeiertag",
|
||||||
|
isRecurringAnnual: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.previewRequest({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.requestedDays).toBe(1);
|
||||||
|
expect(result.effectiveDays).toBe(0);
|
||||||
|
expect(result.publicHolidayDates).toContain("2026-11-15");
|
||||||
|
expect(result.holidayContext.countryName).toBe("Germany");
|
||||||
|
expect(result.holidayContext.metroCityName).toBe("Muenchen");
|
||||||
|
expect(db.holidayCalendar.findMany).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks legacy public holiday entries as a separate preview source", async () => {
|
||||||
|
const db = createVacationDb({
|
||||||
|
resource: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
userId: "user_1",
|
||||||
|
federalState: "HH",
|
||||||
|
country: { code: "DE", name: "Germany" },
|
||||||
|
metroCity: { name: "Hamburg" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
vacation: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
const result = await caller.previewRequest({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.publicHolidayDates).toContain("2026-05-01");
|
||||||
|
expect(result.holidayContext.sources).toEqual({
|
||||||
|
hasCalendarHolidays: true,
|
||||||
|
hasLegacyPublicHolidayEntries: true,
|
||||||
|
});
|
||||||
|
expect(result.holidayDetails).toContainEqual({
|
||||||
|
date: "2026-05-01",
|
||||||
|
source: "CALENDAR_AND_LEGACY",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects multi-day half-day previews", async () => {
|
||||||
|
const db = createVacationDb();
|
||||||
|
const caller = createProtectedCaller(db);
|
||||||
|
|
||||||
|
await expect(caller.previewRequest({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.ANNUAL,
|
||||||
|
startDate: new Date("2026-06-01"),
|
||||||
|
endDate: new Date("2026-06-02"),
|
||||||
|
isHalfDay: true,
|
||||||
|
})).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create manual public holiday handling", () => {
|
||||||
|
it("rejects manual public holiday creation requests", async () => {
|
||||||
|
const db = createVacationDb();
|
||||||
|
const caller = createManagerCaller(db);
|
||||||
|
|
||||||
|
await expect(caller.create({
|
||||||
|
resourceId: "res_1",
|
||||||
|
type: VacationType.PUBLIC_HOLIDAY,
|
||||||
|
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
endDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
})).rejects.toThrow("Public holidays must be managed via Holiday Calendars or the legacy holiday import");
|
||||||
|
|
||||||
|
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("approve", () => {
|
describe("approve", () => {
|
||||||
@@ -359,7 +635,7 @@ describe("vacation router", () => {
|
|||||||
approvedById: "mgr_1",
|
approvedById: "mgr_1",
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||||
@@ -370,7 +646,7 @@ describe("vacation router", () => {
|
|||||||
resource: {
|
resource: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.approve({ id: "vac_1" });
|
const result = await caller.approve({ id: "vac_1" });
|
||||||
@@ -388,25 +664,25 @@ describe("vacation router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws NOT_FOUND for missing vacation", async () => {
|
it("throws NOT_FOUND for missing vacation", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
|
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects approving an already APPROVED vacation", async () => {
|
it("rejects approving an already APPROVED vacation", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
...sampleVacation,
|
...sampleVacation,
|
||||||
status: VacationStatus.APPROVED,
|
status: VacationStatus.APPROVED,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
|
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
|
||||||
@@ -429,7 +705,7 @@ describe("vacation router", () => {
|
|||||||
rejectionReason: "Team conflict",
|
rejectionReason: "Team conflict",
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||||
@@ -437,7 +713,7 @@ describe("vacation router", () => {
|
|||||||
resource: {
|
resource: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
|
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
|
||||||
@@ -454,14 +730,14 @@ describe("vacation router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws when rejecting non-PENDING vacation", async () => {
|
it("throws when rejecting non-PENDING vacation", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
...sampleVacation,
|
...sampleVacation,
|
||||||
status: VacationStatus.APPROVED,
|
status: VacationStatus.APPROVED,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
|
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
|
||||||
@@ -477,15 +753,12 @@ describe("vacation router", () => {
|
|||||||
status: VacationStatus.CANCELLED,
|
status: VacationStatus.CANCELLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
user: {
|
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
|
|
||||||
},
|
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
findUnique: vi.fn().mockResolvedValue(sampleVacation),
|
||||||
update: vi.fn().mockResolvedValue(updatedVacation),
|
update: vi.fn().mockResolvedValue(updatedVacation),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createProtectedCaller(db);
|
||||||
const result = await caller.cancel({ id: "vac_1" });
|
const result = await caller.cancel({ id: "vac_1" });
|
||||||
@@ -494,25 +767,25 @@ describe("vacation router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws NOT_FOUND for missing vacation", async () => {
|
it("throws NOT_FOUND for missing vacation", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createProtectedCaller(db);
|
||||||
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
|
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when already cancelled", async () => {
|
it("throws when already cancelled", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
vacation: {
|
vacation: {
|
||||||
findUnique: vi.fn().mockResolvedValue({
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
...sampleVacation,
|
...sampleVacation,
|
||||||
status: VacationStatus.CANCELLED,
|
status: VacationStatus.CANCELLED,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createProtectedCaller(db);
|
const caller = createProtectedCaller(db);
|
||||||
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
|
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
|
||||||
@@ -521,7 +794,7 @@ describe("vacation router", () => {
|
|||||||
|
|
||||||
describe("batchApprove", () => {
|
describe("batchApprove", () => {
|
||||||
it("approves multiple pending vacations", async () => {
|
it("approves multiple pending vacations", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||||
},
|
},
|
||||||
@@ -535,7 +808,7 @@ describe("vacation router", () => {
|
|||||||
resource: {
|
resource: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
|
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
|
||||||
@@ -552,7 +825,7 @@ describe("vacation router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("only approves PENDING vacations from the requested set", async () => {
|
it("only approves PENDING vacations from the requested set", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||||
},
|
},
|
||||||
@@ -565,7 +838,7 @@ describe("vacation router", () => {
|
|||||||
resource: {
|
resource: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
|
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
|
||||||
@@ -581,7 +854,10 @@ describe("vacation router", () => {
|
|||||||
|
|
||||||
describe("batchReject", () => {
|
describe("batchReject", () => {
|
||||||
it("rejects multiple pending vacations with optional reason", async () => {
|
it("rejects multiple pending vacations with optional reason", async () => {
|
||||||
const db = {
|
const db = createVacationDb({
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||||
|
},
|
||||||
vacation: {
|
vacation: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{ id: "vac_1", resourceId: "res_1" },
|
{ id: "vac_1", resourceId: "res_1" },
|
||||||
@@ -591,7 +867,7 @@ describe("vacation router", () => {
|
|||||||
resource: {
|
resource: {
|
||||||
findUnique: vi.fn().mockResolvedValue(null),
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
const caller = createManagerCaller(db);
|
const caller = createManagerCaller(db);
|
||||||
const result = await caller.batchReject({
|
const result = await caller.batchReject({
|
||||||
@@ -731,8 +1007,8 @@ describe("vacation router", () => {
|
|||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
findMany: vi.fn().mockResolvedValue([
|
findMany: vi.fn().mockResolvedValue([
|
||||||
{ id: "res_1" },
|
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||||
{ id: "res_2" },
|
{ id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
@@ -759,7 +1035,9 @@ describe("vacation router", () => {
|
|||||||
it("skips already existing holidays", async () => {
|
it("skips already existing holidays", async () => {
|
||||||
const db = {
|
const db = {
|
||||||
resource: {
|
resource: {
|
||||||
findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{ id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
|
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { listAssignmentBookings } from "@capakraken/application";
|
import { listAssignmentBookings } from "@capakraken/application";
|
||||||
import { rankResources } from "@capakraken/staffing";
|
import { rankResources } from "@capakraken/staffing";
|
||||||
import type { SkillEntry } from "@capakraken/shared";
|
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "./resource-capacity.js";
|
||||||
import { createNotificationsForUsers } from "./create-notification.js";
|
import { createNotificationsForUsers } from "./create-notification.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +63,11 @@ type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
|
|||||||
chargeabilityTarget: number;
|
chargeabilityTarget: number;
|
||||||
availability: unknown;
|
availability: unknown;
|
||||||
valueScore: number | null;
|
valueScore: number | null;
|
||||||
|
countryId: string | null;
|
||||||
|
federalState: string | null;
|
||||||
|
metroCityId: string | null;
|
||||||
|
country: { code: string | null } | null;
|
||||||
|
metroCity: { name: string | null } | null;
|
||||||
}>>;
|
}>>;
|
||||||
};
|
};
|
||||||
notification: {
|
notification: {
|
||||||
@@ -154,27 +164,54 @@ export async function generateAutoSuggestions(
|
|||||||
endDate: demand.endDate,
|
endDate: demand.endDate,
|
||||||
resourceIds: resources.map((r) => r.id),
|
resourceIds: resources.map((r) => r.id),
|
||||||
});
|
});
|
||||||
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||||
|
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
|
||||||
|
resources.map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
availability: resource.availability as unknown as WeekdayAvailability,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
})),
|
||||||
|
demand.startDate,
|
||||||
|
demand.endDate,
|
||||||
|
);
|
||||||
|
|
||||||
// 5. Enrich resources with utilization data for the demand's date range
|
// 5. Enrich resources with utilization data for the demand's date range
|
||||||
const enrichedResources = resources.map((resource) => {
|
const enrichedResources = resources.map((resource) => {
|
||||||
const avail = resource.availability as
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
|
const context = contexts.get(resource.id);
|
||||||
| null;
|
|
||||||
const totalAvailableHours = avail?.monday ?? 8;
|
|
||||||
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
|
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
|
||||||
|
const totalAvailableHours = calculateEffectiveAvailableHours({
|
||||||
const allocatedHoursPerDay = resourceBookings.reduce(
|
availability,
|
||||||
(sum, b) => sum + b.hoursPerDay,
|
periodStart: demand.startDate,
|
||||||
|
periodEnd: demand.endDate,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const allocatedHours = resourceBookings.reduce(
|
||||||
|
(sum, booking) =>
|
||||||
|
sum + calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
periodStart: demand.startDate,
|
||||||
|
periodEnd: demand.endDate,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const utilizationPercent =
|
const utilizationPercent =
|
||||||
totalAvailableHours > 0
|
totalAvailableHours > 0
|
||||||
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
|
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const wouldExceedCapacity =
|
const wouldExceedCapacity = totalAvailableHours > 0
|
||||||
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
|
? allocatedHours + demand.hoursPerDay > totalAvailableHours
|
||||||
|
: demand.hoursPerDay > 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: resource.id,
|
id: resource.id,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
deriveResourceForecast,
|
deriveResourceForecast,
|
||||||
getMonthRange,
|
getMonthRange,
|
||||||
countWorkingDaysInOverlap,
|
|
||||||
calculateSAH,
|
|
||||||
type AssignmentSlice,
|
type AssignmentSlice,
|
||||||
} from "@capakraken/engine";
|
} from "@capakraken/engine";
|
||||||
import type { SpainScheduleRule } from "@capakraken/shared";
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||||
import { VacationStatus } from "@capakraken/db";
|
|
||||||
import { createNotificationsForUsers } from "./create-notification.js";
|
import { createNotificationsForUsers } from "./create-notification.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "./resource-capacity.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal DB client type for chargeability alerts.
|
* Minimal DB client type for chargeability alerts.
|
||||||
@@ -24,23 +26,19 @@ type DbClient = {
|
|||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
fte: number;
|
fte: number;
|
||||||
|
availability: unknown;
|
||||||
|
countryId: string | null;
|
||||||
|
metroCityId: string | null;
|
||||||
|
federalState: string | null;
|
||||||
chargeabilityTarget: number;
|
chargeabilityTarget: number;
|
||||||
country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null;
|
country: {
|
||||||
|
id?: string | null;
|
||||||
|
code: string | null;
|
||||||
|
dailyWorkingHours: number | null;
|
||||||
|
scheduleRules: unknown;
|
||||||
|
} | null;
|
||||||
managementLevelGroup: { targetPercentage: number | null } | null;
|
managementLevelGroup: { targetPercentage: number | null } | null;
|
||||||
}>
|
metroCity: { id?: string | null; name: string | null } | null;
|
||||||
>;
|
|
||||||
};
|
|
||||||
vacation: {
|
|
||||||
findMany: (args: {
|
|
||||||
where: Record<string, unknown>;
|
|
||||||
select: Record<string, unknown>;
|
|
||||||
}) => Promise<
|
|
||||||
Array<{
|
|
||||||
resourceId: string;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
type: string;
|
|
||||||
isHalfDay: boolean;
|
|
||||||
}>
|
}>
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
@@ -105,9 +103,14 @@ export async function checkChargeabilityAlerts(
|
|||||||
id: true,
|
id: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
fte: true,
|
fte: true,
|
||||||
|
availability: true,
|
||||||
|
countryId: true,
|
||||||
|
metroCityId: true,
|
||||||
|
federalState: true,
|
||||||
chargeabilityTarget: true,
|
chargeabilityTarget: true,
|
||||||
country: { select: { dailyWorkingHours: true, scheduleRules: true } },
|
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||||
managementLevelGroup: { select: { targetPercentage: true } },
|
managementLevelGroup: { select: { targetPercentage: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,56 +124,32 @@ export async function checkChargeabilityAlerts(
|
|||||||
endDate: monthEnd,
|
endDate: monthEnd,
|
||||||
resourceIds,
|
resourceIds,
|
||||||
});
|
});
|
||||||
|
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||||
// Fetch vacations for the current month
|
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
|
||||||
const vacations = await (db as DbClient).vacation.findMany({
|
resources.map((resource) => ({
|
||||||
where: {
|
id: resource.id,
|
||||||
resourceId: { in: resourceIds },
|
availability: resource.availability as unknown as WeekdayAvailability,
|
||||||
status: VacationStatus.APPROVED,
|
countryId: resource.countryId,
|
||||||
startDate: { lte: monthEnd },
|
countryCode: resource.country?.code,
|
||||||
endDate: { gte: monthStart },
|
federalState: resource.federalState,
|
||||||
},
|
metroCityId: resource.metroCityId,
|
||||||
select: {
|
metroCityName: resource.metroCity?.name,
|
||||||
resourceId: true,
|
})),
|
||||||
startDate: true,
|
monthStart,
|
||||||
endDate: true,
|
monthEnd,
|
||||||
type: true,
|
);
|
||||||
isHalfDay: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Compute chargeability per resource
|
// Compute chargeability per resource
|
||||||
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
|
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
|
||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
|
const context = availabilityContexts.get(resource.id);
|
||||||
// Compute absence dates for SAH
|
const availableHours = calculateEffectiveAvailableHours({
|
||||||
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
|
availability,
|
||||||
const absenceDates: string[] = [];
|
|
||||||
for (const v of resourceVacations) {
|
|
||||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
|
||||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
|
||||||
if (vStart > vEnd) continue;
|
|
||||||
const cursor = new Date(vStart);
|
|
||||||
cursor.setUTCHours(0, 0, 0, 0);
|
|
||||||
const endNorm = new Date(vEnd);
|
|
||||||
endNorm.setUTCHours(0, 0, 0, 0);
|
|
||||||
while (cursor <= endNorm) {
|
|
||||||
absenceDates.push(cursor.toISOString().slice(0, 10));
|
|
||||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null;
|
|
||||||
const sahResult = calculateSAH({
|
|
||||||
dailyWorkingHours: dailyHours,
|
|
||||||
scheduleRules,
|
|
||||||
fte: resource.fte,
|
|
||||||
periodStart: monthStart,
|
periodStart: monthStart,
|
||||||
periodEnd: monthEnd,
|
periodEnd: monthEnd,
|
||||||
publicHolidays: [],
|
context,
|
||||||
absenceDays: absenceDates,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build assignment slices
|
// Build assignment slices
|
||||||
@@ -178,12 +157,24 @@ export async function checkChargeabilityAlerts(
|
|||||||
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
|
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
|
||||||
);
|
);
|
||||||
|
|
||||||
const slices: AssignmentSlice[] = resourceBookings.map((b) => {
|
const slices: AssignmentSlice[] = resourceBookings.flatMap((b) => {
|
||||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate);
|
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: b.startDate,
|
||||||
|
endDate: b.endDate,
|
||||||
|
hoursPerDay: b.hoursPerDay,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
if (totalChargeableHours <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
hoursPerDay: b.hoursPerDay,
|
hoursPerDay: b.hoursPerDay,
|
||||||
workingDays,
|
workingDays: 0,
|
||||||
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
|
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
|
||||||
|
totalChargeableHours,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,7 +185,7 @@ export async function checkChargeabilityAlerts(
|
|||||||
fte: resource.fte,
|
fte: resource.fte,
|
||||||
targetPercentage: targetPct,
|
targetPercentage: targetPct,
|
||||||
assignments: slices,
|
assignments: slices,
|
||||||
sah: sahResult.standardAvailableHours,
|
sah: availableHours,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chgPct = forecast.chg * 100;
|
const chgPct = forecast.chg * 100;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
|
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getPublicHolidays } from "@capakraken/shared";
|
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "./holiday-availability.js";
|
||||||
|
|
||||||
interface MinimalVacation {
|
interface MinimalVacation {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
@@ -19,14 +19,20 @@ interface MinimalVacation {
|
|||||||
|
|
||||||
interface AutoImportDb {
|
interface AutoImportDb {
|
||||||
resource: {
|
resource: {
|
||||||
findMany: (args: {
|
findMany: (args: any) => any;
|
||||||
where: { isActive: boolean };
|
};
|
||||||
select: { id: string; federalState: string };
|
country?: {
|
||||||
}) => Promise<Array<{ id: string; federalState: string | null }>>;
|
findUnique: (args: any) => any;
|
||||||
|
};
|
||||||
|
metroCity?: {
|
||||||
|
findUnique: (args: any) => any;
|
||||||
|
};
|
||||||
|
holidayCalendar?: {
|
||||||
|
findMany: (args: any) => any;
|
||||||
};
|
};
|
||||||
vacation: {
|
vacation: {
|
||||||
findMany: (args: unknown) => Promise<MinimalVacation[]>;
|
findMany: (args: any) => any;
|
||||||
createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>;
|
createMany: (args: any) => any;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,34 +48,60 @@ export interface AutoImportResult {
|
|||||||
* Returns the number of holiday vacation records created.
|
* Returns the number of holiday vacation records created.
|
||||||
*/
|
*/
|
||||||
export async function autoImportPublicHolidays(
|
export async function autoImportPublicHolidays(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
db: AutoImportDb,
|
||||||
db: any,
|
|
||||||
year: number,
|
year: number,
|
||||||
): Promise<AutoImportResult> {
|
): Promise<AutoImportResult> {
|
||||||
const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({
|
const resources = await db.resource.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
select: { id: true, federalState: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
federalState: true,
|
||||||
|
countryId: true,
|
||||||
|
metroCityId: true,
|
||||||
|
country: { select: { code: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resources.length === 0) {
|
if (resources.length === 0) {
|
||||||
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
|
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group resources by federal state (null = federal-only holidays)
|
const nextYearStart = new Date(`${year}-01-01T00:00:00.000Z`);
|
||||||
const byState = new Map<string | null, string[]>();
|
const nextYearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
|
||||||
|
const byHolidayProfile = new Map<string, typeof resources>();
|
||||||
|
|
||||||
for (const resource of resources) {
|
for (const resource of resources) {
|
||||||
const state = resource.federalState ?? null;
|
const profileKey = JSON.stringify({
|
||||||
const group = byState.get(state) ?? [];
|
countryCode: resource.country?.code ?? null,
|
||||||
group.push(resource.id);
|
federalState: resource.federalState ?? null,
|
||||||
byState.set(state, group);
|
metroCityName: resource.metroCity?.name ?? null,
|
||||||
|
});
|
||||||
|
const group = byHolidayProfile.get(profileKey) ?? [];
|
||||||
|
group.push(resource);
|
||||||
|
byHolidayProfile.set(profileKey, group);
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalCreated = 0;
|
let totalCreated = 0;
|
||||||
let totalSkipped = 0;
|
let totalSkipped = 0;
|
||||||
|
|
||||||
for (const [state, resourceIds] of byState) {
|
for (const [, groupedResources] of byHolidayProfile) {
|
||||||
const holidays = getPublicHolidays(year, state ?? undefined);
|
const sample = groupedResources[0];
|
||||||
|
if (!sample) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
|
||||||
|
periodStart: nextYearStart,
|
||||||
|
periodEnd: nextYearEnd,
|
||||||
|
countryId: sample.countryId,
|
||||||
|
countryCode: sample.country?.code ?? null,
|
||||||
|
federalState: sample.federalState,
|
||||||
|
metroCityId: sample.metroCityId,
|
||||||
|
metroCityName: sample.metroCity?.name ?? null,
|
||||||
|
});
|
||||||
if (holidays.length === 0) continue;
|
if (holidays.length === 0) continue;
|
||||||
|
const resourceIds = groupedResources.map((resource: { id: string }) => resource.id);
|
||||||
|
|
||||||
for (const holiday of holidays) {
|
for (const holiday of holidays) {
|
||||||
const holidayDate = new Date(holiday.date);
|
const holidayDate = new Date(holiday.date);
|
||||||
@@ -86,13 +118,13 @@ export async function autoImportPublicHolidays(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
|
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
|
||||||
const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id));
|
const newResourceIds = resourceIds.filter((id: string) => !existingResourceIds.has(id));
|
||||||
|
|
||||||
totalSkipped += existingResourceIds.size;
|
totalSkipped += existingResourceIds.size;
|
||||||
|
|
||||||
if (newResourceIds.length === 0) continue;
|
if (newResourceIds.length === 0) continue;
|
||||||
|
|
||||||
const records = newResourceIds.map((resourceId) => ({
|
const records = newResourceIds.map((resourceId: string) => ({
|
||||||
resourceId,
|
resourceId,
|
||||||
type: "PUBLIC_HOLIDAY",
|
type: "PUBLIC_HOLIDAY",
|
||||||
status: "APPROVED",
|
status: "APPROVED",
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
|
||||||
|
|
||||||
|
type VacationLike = {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
type: string;
|
||||||
|
isHalfDay: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HolidayAvailabilityInput = {
|
||||||
|
vacations: VacationLike[];
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
countryCode?: string | null | undefined;
|
||||||
|
federalState?: string | null | undefined;
|
||||||
|
metroCityName?: string | null | undefined;
|
||||||
|
resolvedHolidayStrings?: string[] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HolidayAvailabilityResult = {
|
||||||
|
absenceDateStrings: string[];
|
||||||
|
publicHolidayStrings: string[];
|
||||||
|
absenceDays: AbsenceDay[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarHoliday = {
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
scope: "COUNTRY" | "STATE" | "CITY";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CalendarScope = CalendarHoliday["scope"];
|
||||||
|
|
||||||
|
type HolidayCalendarEntryRecord = {
|
||||||
|
date: Date;
|
||||||
|
name: string;
|
||||||
|
isRecurringAnnual: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HolidayCalendarRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
scopeType: CalendarScope;
|
||||||
|
priority: number;
|
||||||
|
createdAt?: Date;
|
||||||
|
entries: HolidayCalendarEntryRecord[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type HolidayResolverDb = {
|
||||||
|
[key: string]: unknown;
|
||||||
|
country?: {
|
||||||
|
findUnique: (args: any) => any;
|
||||||
|
};
|
||||||
|
metroCity?: {
|
||||||
|
findUnique: (args: any) => any;
|
||||||
|
};
|
||||||
|
holidayCalendar?: {
|
||||||
|
findMany: (args: any) => any;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResolvedHoliday = CalendarHoliday & {
|
||||||
|
calendarName: string;
|
||||||
|
priority: number;
|
||||||
|
sourceType: "BUILTIN" | "CUSTOM";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
|
||||||
|
return db as HolidayResolverDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toIsoDate(value: Date): string {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
type CityHolidayRule = {
|
||||||
|
countryCode: string;
|
||||||
|
cityName: string;
|
||||||
|
resolveDates: (year: number) => string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CITY_HOLIDAY_RULES: CityHolidayRule[] = [
|
||||||
|
{
|
||||||
|
countryCode: "DE",
|
||||||
|
cityName: "Augsburg",
|
||||||
|
resolveDates: (year) => [`${year}-08-08`],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCOPE_WEIGHT: Record<CalendarScope, number> = {
|
||||||
|
COUNTRY: 1,
|
||||||
|
STATE: 2,
|
||||||
|
CITY: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCityName(cityName?: string | null): string | null {
|
||||||
|
const normalized = cityName?.trim().toLowerCase();
|
||||||
|
return normalized && normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStateCode(stateCode?: string | null): string | null {
|
||||||
|
const normalized = stateCode?.trim().toUpperCase();
|
||||||
|
return normalized && normalized.length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCalendarEntries(
|
||||||
|
calendars: HolidayCalendarRecord[],
|
||||||
|
periodStart: Date,
|
||||||
|
periodEnd: Date,
|
||||||
|
): ResolvedHoliday[] {
|
||||||
|
const startYear = periodStart.getUTCFullYear();
|
||||||
|
const endYear = periodEnd.getUTCFullYear();
|
||||||
|
const startIso = toIsoDate(periodStart);
|
||||||
|
const endIso = toIsoDate(periodEnd);
|
||||||
|
const resolved = new Map<string, ResolvedHoliday>();
|
||||||
|
|
||||||
|
for (const calendar of calendars) {
|
||||||
|
for (const entry of calendar.entries) {
|
||||||
|
const baseDate = new Date(entry.date);
|
||||||
|
|
||||||
|
for (let year = startYear; year <= endYear; year += 1) {
|
||||||
|
const effectiveDate = entry.isRecurringAnnual
|
||||||
|
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
||||||
|
: baseDate;
|
||||||
|
const key = toIsoDate(effectiveDate);
|
||||||
|
|
||||||
|
if (key < startIso || key > endIso) {
|
||||||
|
if (!entry.isRecurringAnnual) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate: ResolvedHoliday = {
|
||||||
|
date: key,
|
||||||
|
name: entry.name,
|
||||||
|
scope: calendar.scopeType,
|
||||||
|
calendarName: calendar.name,
|
||||||
|
priority: calendar.priority,
|
||||||
|
sourceType: "CUSTOM",
|
||||||
|
};
|
||||||
|
const existing = resolved.get(key);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!existing
|
||||||
|
|| SCOPE_WEIGHT[candidate.scope] > SCOPE_WEIGHT[existing.scope]
|
||||||
|
|| (
|
||||||
|
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
|
||||||
|
&& candidate.priority > existing.priority
|
||||||
|
)
|
||||||
|
|| (
|
||||||
|
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
|
||||||
|
&& candidate.priority === existing.priority
|
||||||
|
&& existing.sourceType === "BUILTIN"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
resolved.set(key, candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isRecurringAnnual) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...resolved.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeResolvedHolidays(
|
||||||
|
builtInHolidays: CalendarHoliday[],
|
||||||
|
customHolidays: ResolvedHoliday[],
|
||||||
|
): ResolvedHoliday[] {
|
||||||
|
const merged = new Map<string, ResolvedHoliday>();
|
||||||
|
|
||||||
|
for (const holiday of builtInHolidays) {
|
||||||
|
merged.set(holiday.date, {
|
||||||
|
...holiday,
|
||||||
|
calendarName: "System",
|
||||||
|
priority: Number.MIN_SAFE_INTEGER,
|
||||||
|
sourceType: "BUILTIN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const holiday of customHolidays) {
|
||||||
|
const existing = merged.get(holiday.date);
|
||||||
|
if (
|
||||||
|
!existing
|
||||||
|
|| SCOPE_WEIGHT[holiday.scope] > SCOPE_WEIGHT[existing.scope]
|
||||||
|
|| (
|
||||||
|
SCOPE_WEIGHT[holiday.scope] === SCOPE_WEIGHT[existing.scope]
|
||||||
|
&& holiday.priority >= existing.priority
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
merged.set(holiday.date, holiday);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScopedHolidayCalendars(
|
||||||
|
db: HolidayResolverDb,
|
||||||
|
input: {
|
||||||
|
countryId?: string | null | undefined;
|
||||||
|
stateCode?: string | null | undefined;
|
||||||
|
metroCityId?: string | null | undefined;
|
||||||
|
},
|
||||||
|
): Promise<HolidayCalendarRecord[]> {
|
||||||
|
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateCode = normalizeStateCode(input.stateCode);
|
||||||
|
const metroCityId = input.metroCityId?.trim() || null;
|
||||||
|
|
||||||
|
return db.holidayCalendar.findMany({
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
countryId: input.countryId,
|
||||||
|
OR: [
|
||||||
|
{ scopeType: "COUNTRY" },
|
||||||
|
...(stateCode ? [{ scopeType: "STATE" as const, stateCode }] : []),
|
||||||
|
...(metroCityId ? [{ scopeType: "CITY" as const, metroCityId }] : []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: { entries: true },
|
||||||
|
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalendarHolidayStrings(
|
||||||
|
periodStart: Date,
|
||||||
|
periodEnd: Date,
|
||||||
|
countryCode?: string | null,
|
||||||
|
federalState?: string | null,
|
||||||
|
metroCityName?: string | null,
|
||||||
|
): string[] {
|
||||||
|
return getCalendarHolidays(
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
countryCode,
|
||||||
|
federalState,
|
||||||
|
metroCityName,
|
||||||
|
).map((holiday) => holiday.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCalendarHolidays(
|
||||||
|
periodStart: Date,
|
||||||
|
periodEnd: Date,
|
||||||
|
countryCode?: string | null,
|
||||||
|
federalState?: string | null,
|
||||||
|
metroCityName?: string | null,
|
||||||
|
): CalendarHoliday[] {
|
||||||
|
const startYear = periodStart.getUTCFullYear();
|
||||||
|
const endYear = periodEnd.getUTCFullYear();
|
||||||
|
const holidays = new Map<string, CalendarHoliday>();
|
||||||
|
|
||||||
|
if (countryCode === "DE") {
|
||||||
|
for (let year = startYear; year <= endYear; year += 1) {
|
||||||
|
for (const holiday of getPublicHolidays(year, federalState ?? undefined)) {
|
||||||
|
if (holiday.date >= toIsoDate(periodStart) && holiday.date <= toIsoDate(periodEnd)) {
|
||||||
|
holidays.set(holiday.date, {
|
||||||
|
date: holiday.date,
|
||||||
|
name: holiday.name,
|
||||||
|
scope: holiday.federal ? "COUNTRY" : "STATE",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCityName = normalizeCityName(metroCityName);
|
||||||
|
if (countryCode && normalizedCityName) {
|
||||||
|
for (const rule of CITY_HOLIDAY_RULES) {
|
||||||
|
if (
|
||||||
|
rule.countryCode === countryCode
|
||||||
|
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||||
|
) {
|
||||||
|
for (let year = startYear; year <= endYear; year += 1) {
|
||||||
|
for (const holidayDate of rule.resolveDates(year)) {
|
||||||
|
if (holidayDate >= toIsoDate(periodStart) && holidayDate <= toIsoDate(periodEnd)) {
|
||||||
|
holidays.set(holidayDate, {
|
||||||
|
date: holidayDate,
|
||||||
|
name: "Augsburger Friedensfest",
|
||||||
|
scope: "CITY",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...holidays.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResolvedCalendarHolidays(
|
||||||
|
db: HolidayResolverDb,
|
||||||
|
input: {
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
countryId?: string | null | undefined;
|
||||||
|
countryCode?: string | null | undefined;
|
||||||
|
federalState?: string | null | undefined;
|
||||||
|
metroCityId?: string | null | undefined;
|
||||||
|
metroCityName?: string | null | undefined;
|
||||||
|
},
|
||||||
|
): Promise<ResolvedHoliday[]> {
|
||||||
|
let countryCode = input.countryCode ?? null;
|
||||||
|
if (!countryCode && input.countryId && typeof db.country?.findUnique === "function") {
|
||||||
|
const country = await db.country.findUnique({
|
||||||
|
where: { id: input.countryId },
|
||||||
|
select: { code: true },
|
||||||
|
});
|
||||||
|
countryCode = country?.code ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let metroCityName = input.metroCityName ?? null;
|
||||||
|
if (!metroCityName && input.metroCityId && typeof db.metroCity?.findUnique === "function") {
|
||||||
|
const metroCity = await db.metroCity.findUnique({
|
||||||
|
where: { id: input.metroCityId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
metroCityName = metroCity?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtIn = getCalendarHolidays(
|
||||||
|
input.periodStart,
|
||||||
|
input.periodEnd,
|
||||||
|
countryCode,
|
||||||
|
input.federalState,
|
||||||
|
metroCityName,
|
||||||
|
);
|
||||||
|
const calendars = await loadScopedHolidayCalendars(db, {
|
||||||
|
countryId: input.countryId,
|
||||||
|
stateCode: input.federalState,
|
||||||
|
metroCityId: input.metroCityId,
|
||||||
|
});
|
||||||
|
const custom = resolveCalendarEntries(calendars, input.periodStart, input.periodEnd);
|
||||||
|
|
||||||
|
return mergeResolvedHolidays(builtIn, custom);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResolvedCalendarHolidayStrings(
|
||||||
|
db: HolidayResolverDb,
|
||||||
|
input: {
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
countryId?: string | null | undefined;
|
||||||
|
countryCode?: string | null | undefined;
|
||||||
|
federalState?: string | null | undefined;
|
||||||
|
metroCityId?: string | null | undefined;
|
||||||
|
metroCityName?: string | null | undefined;
|
||||||
|
},
|
||||||
|
): Promise<string[]> {
|
||||||
|
const holidays = await getResolvedCalendarHolidays(db, input);
|
||||||
|
return holidays.map((holiday) => holiday.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectHolidayAvailability(
|
||||||
|
input: HolidayAvailabilityInput,
|
||||||
|
): HolidayAvailabilityResult {
|
||||||
|
const periodStartIso = toIsoDate(input.periodStart);
|
||||||
|
const periodEndIso = toIsoDate(input.periodEnd);
|
||||||
|
const publicHolidaySet = new Set(
|
||||||
|
input.resolvedHolidayStrings
|
||||||
|
? input.resolvedHolidayStrings.filter((date) => date >= periodStartIso && date <= periodEndIso)
|
||||||
|
: getCalendarHolidayStrings(
|
||||||
|
input.periodStart,
|
||||||
|
input.periodEnd,
|
||||||
|
input.countryCode,
|
||||||
|
input.federalState,
|
||||||
|
input.metroCityName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const absenceDateSet = new Set<string>();
|
||||||
|
const absenceDayMap = new Map<string, AbsenceDay>();
|
||||||
|
|
||||||
|
for (const isoDate of publicHolidaySet) {
|
||||||
|
absenceDayMap.set(isoDate, {
|
||||||
|
date: new Date(`${isoDate}T00:00:00.000Z`),
|
||||||
|
type: "PUBLIC_HOLIDAY",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const vacation of input.vacations) {
|
||||||
|
if (vacation.type !== "PUBLIC_HOLIDAY") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlapStart = new Date(
|
||||||
|
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
|
||||||
|
);
|
||||||
|
const overlapEnd = new Date(
|
||||||
|
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlapStart > overlapEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = new Date(overlapStart);
|
||||||
|
cursor.setUTCHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(overlapEnd);
|
||||||
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
while (cursor <= end) {
|
||||||
|
const isoDate = toIsoDate(cursor);
|
||||||
|
publicHolidaySet.add(isoDate);
|
||||||
|
absenceDayMap.set(isoDate, {
|
||||||
|
date: new Date(cursor),
|
||||||
|
type: "PUBLIC_HOLIDAY",
|
||||||
|
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const vacation of input.vacations) {
|
||||||
|
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlapStart = new Date(
|
||||||
|
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
|
||||||
|
);
|
||||||
|
const overlapEnd = new Date(
|
||||||
|
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlapStart > overlapEnd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = new Date(overlapStart);
|
||||||
|
cursor.setUTCHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(overlapEnd);
|
||||||
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const triggerType = vacation.type === "SICK" ? "SICK" : "VACATION";
|
||||||
|
|
||||||
|
while (cursor <= end) {
|
||||||
|
const isoDate = toIsoDate(cursor);
|
||||||
|
if (!publicHolidaySet.has(isoDate)) {
|
||||||
|
absenceDateSet.add(isoDate);
|
||||||
|
absenceDayMap.set(isoDate, {
|
||||||
|
date: new Date(cursor),
|
||||||
|
type: triggerType,
|
||||||
|
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
absenceDateStrings: [...absenceDateSet].sort(),
|
||||||
|
publicHolidayStrings: [...publicHolidaySet].sort(),
|
||||||
|
absenceDays: [...absenceDayMap.values()],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,23 +3,24 @@ import pino from "pino";
|
|||||||
const isProduction = process.env["NODE_ENV"] === "production";
|
const isProduction = process.env["NODE_ENV"] === "production";
|
||||||
|
|
||||||
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
|
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
|
||||||
|
const devDestination = pino.destination({ dest: 1, sync: true });
|
||||||
|
|
||||||
export const logger = pino({
|
export const logger = isProduction
|
||||||
level: LOG_LEVEL,
|
? pino({
|
||||||
base: { service: "capakraken-api" },
|
level: LOG_LEVEL,
|
||||||
...(isProduction
|
base: { service: "capakraken-api" },
|
||||||
? {}
|
})
|
||||||
: {
|
: pino(
|
||||||
transport: {
|
{
|
||||||
target: "pino/file",
|
level: LOG_LEVEL,
|
||||||
options: { destination: 1 }, // stdout
|
base: { service: "capakraken-api" },
|
||||||
},
|
|
||||||
formatters: {
|
formatters: {
|
||||||
level(label: string) {
|
level(label: string) {
|
||||||
return { level: label };
|
return { level: label };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
});
|
devDestination,
|
||||||
|
);
|
||||||
|
|
||||||
export type Logger = typeof logger;
|
export type Logger = typeof logger;
|
||||||
|
|||||||
@@ -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,
|
FillDemandRequirementSchema,
|
||||||
FillOpenDemandByAllocationSchema,
|
FillOpenDemandByAllocationSchema,
|
||||||
PermissionKey,
|
PermissionKey,
|
||||||
|
type WeekdayAvailability,
|
||||||
UpdateAssignmentSchema,
|
UpdateAssignmentSchema,
|
||||||
UpdateAllocationSchema,
|
UpdateAllocationSchema,
|
||||||
UpdateDemandRequirementSchema,
|
UpdateDemandRequirementSchema,
|
||||||
@@ -34,6 +35,13 @@ import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
|||||||
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
||||||
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
|
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
|
||||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
calculateEffectiveDayAvailability,
|
||||||
|
countEffectiveWorkingDays,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
|
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
|
|
||||||
@@ -328,12 +336,26 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
where: { id: input.resourceId },
|
where: { id: input.resourceId },
|
||||||
select: {
|
select: {
|
||||||
id: true, displayName: true, eid: true, fte: true,
|
id: true, displayName: true, eid: true, fte: true,
|
||||||
country: { select: { dailyWorkingHours: true } },
|
availability: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
|
country: { select: { dailyWorkingHours: true, code: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||||
|
|
||||||
const dailyCapacity = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
|
const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
|
||||||
|
const availability = (resource.availability as WeekdayAvailability | null) ?? {
|
||||||
|
monday: fallbackDailyHours,
|
||||||
|
tuesday: fallbackDailyHours,
|
||||||
|
wednesday: fallbackDailyHours,
|
||||||
|
thursday: fallbackDailyHours,
|
||||||
|
friday: fallbackDailyHours,
|
||||||
|
saturday: 0,
|
||||||
|
sunday: 0,
|
||||||
|
};
|
||||||
|
|
||||||
// Get existing assignments in the date range
|
// Get existing assignments in the date range
|
||||||
const existingAssignments = await ctx.db.assignment.findMany({
|
const existingAssignments = await ctx.db.assignment.findMany({
|
||||||
@@ -350,19 +372,29 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
orderBy: { startDate: "asc" },
|
orderBy: { startDate: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get vacations in the date range
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||||
const vacations = await ctx.db.vacation.findMany({
|
ctx.db,
|
||||||
where: {
|
[{
|
||||||
resourceId: input.resourceId,
|
id: resource.id,
|
||||||
status: "APPROVED",
|
availability,
|
||||||
startDate: { lte: input.endDate },
|
countryId: resource.countryId,
|
||||||
endDate: { gte: input.startDate },
|
countryCode: resource.country?.code,
|
||||||
},
|
federalState: resource.federalState,
|
||||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
metroCityId: resource.metroCityId,
|
||||||
});
|
metroCityName: resource.metroCity?.name,
|
||||||
|
}],
|
||||||
|
input.startDate,
|
||||||
|
input.endDate,
|
||||||
|
);
|
||||||
|
const context = contexts.get(resource.id);
|
||||||
|
|
||||||
// Calculate day-by-day availability
|
// Calculate day-by-day availability
|
||||||
let totalWorkingDays = 0;
|
const totalWorkingDays = countEffectiveWorkingDays({
|
||||||
|
availability,
|
||||||
|
periodStart: input.startDate,
|
||||||
|
periodEnd: input.endDate,
|
||||||
|
context,
|
||||||
|
});
|
||||||
let availableDays = 0;
|
let availableDays = 0;
|
||||||
let conflictDays = 0;
|
let conflictDays = 0;
|
||||||
let partialDays = 0;
|
let partialDays = 0;
|
||||||
@@ -372,36 +404,27 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
const d = new Date(input.startDate);
|
const d = new Date(input.startDate);
|
||||||
const end = new Date(input.endDate);
|
const end = new Date(input.endDate);
|
||||||
while (d <= end) {
|
while (d <= end) {
|
||||||
const dow = d.getDay();
|
const effectiveDayCapacity = calculateEffectiveDayAvailability({
|
||||||
if (dow !== 0 && dow !== 6) {
|
availability,
|
||||||
totalWorkingDays++;
|
date: d,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
// Check vacation
|
if (effectiveDayCapacity > 0) {
|
||||||
const isVacation = vacations.some((v) => {
|
|
||||||
const vs = new Date(v.startDate); vs.setHours(0, 0, 0, 0);
|
|
||||||
const ve = new Date(v.endDate); ve.setHours(0, 0, 0, 0);
|
|
||||||
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
|
|
||||||
return dc >= vs && dc <= ve;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isVacation) {
|
|
||||||
conflictDays++;
|
|
||||||
d.setDate(d.getDate() + 1);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sum existing hours on this day
|
|
||||||
let bookedHours = 0;
|
let bookedHours = 0;
|
||||||
for (const a of existingAssignments) {
|
for (const a of existingAssignments) {
|
||||||
const as2 = new Date(a.startDate); as2.setHours(0, 0, 0, 0);
|
bookedHours += calculateEffectiveBookedHours({
|
||||||
const ae = new Date(a.endDate); ae.setHours(0, 0, 0, 0);
|
availability,
|
||||||
const dc = new Date(d); dc.setHours(0, 0, 0, 0);
|
startDate: a.startDate,
|
||||||
if (dc >= as2 && dc <= ae) {
|
endDate: a.endDate,
|
||||||
bookedHours += a.hoursPerDay;
|
hoursPerDay: a.hoursPerDay,
|
||||||
}
|
periodStart: d,
|
||||||
|
periodEnd: d,
|
||||||
|
context,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const remainingCapacity = Math.max(0, dailyCapacity - bookedHours);
|
const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours);
|
||||||
if (remainingCapacity >= requestedHpd) {
|
if (remainingCapacity >= requestedHpd) {
|
||||||
availableDays++;
|
availableDays++;
|
||||||
totalAvailableHours += requestedHpd;
|
totalAvailableHours += requestedHpd;
|
||||||
@@ -416,6 +439,15 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const totalRequestedHours = totalWorkingDays * requestedHpd;
|
const totalRequestedHours = totalWorkingDays * requestedHpd;
|
||||||
|
const totalPeriodCapacity = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart: input.startDate,
|
||||||
|
periodEnd: input.endDate,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const dailyCapacity = totalWorkingDays > 0
|
||||||
|
? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
|
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
export interface AssistantInsightMetric {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
tone?: "neutral" | "good" | "warn" | "danger" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantInsightSection {
|
||||||
|
title: string;
|
||||||
|
metrics: AssistantInsightMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantInsight {
|
||||||
|
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
metrics: AssistantInsightMetric[];
|
||||||
|
sections?: AssistantInsightSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim() ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asNumber(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHours(value: unknown): string | null {
|
||||||
|
const num = asNumber(value);
|
||||||
|
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDays(value: unknown): string | null {
|
||||||
|
const num = asNumber(value);
|
||||||
|
return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushMetric(
|
||||||
|
metrics: AssistantInsightMetric[],
|
||||||
|
label: string,
|
||||||
|
value: string | null,
|
||||||
|
tone?: AssistantInsightMetric["tone"],
|
||||||
|
) {
|
||||||
|
if (!value) return;
|
||||||
|
metrics.push({ label, value, ...(tone ? { tone } : {}) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocationLabel(locationContext: Record<string, unknown> | undefined): string | null {
|
||||||
|
if (!locationContext) return null;
|
||||||
|
const parts = [
|
||||||
|
asString(locationContext.metroCity),
|
||||||
|
asString(locationContext.federalState),
|
||||||
|
asString(locationContext.country),
|
||||||
|
asString(locationContext.countryCode),
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts.join(", ") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChargeabilityInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||||
|
const resource = asString(data.resource);
|
||||||
|
const month = asString(data.month);
|
||||||
|
if (!resource || !month) return null;
|
||||||
|
|
||||||
|
const holidaySummary = isRecord(data.holidaySummary) ? data.holidaySummary : undefined;
|
||||||
|
const absenceSummary = isRecord(data.absenceSummary) ? data.absenceSummary : undefined;
|
||||||
|
const capacityBreakdown = isRecord(data.capacityBreakdown) ? data.capacityBreakdown : undefined;
|
||||||
|
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
|
||||||
|
const chargeabilityPct = asNumber(data.chargeabilityPct);
|
||||||
|
const targetPct = asNumber(data.targetPct);
|
||||||
|
|
||||||
|
const metrics: AssistantInsightMetric[] = [];
|
||||||
|
pushMetric(metrics, "Chargeability", asString(data.chargeability), chargeabilityPct == null || targetPct == null
|
||||||
|
? "info"
|
||||||
|
: chargeabilityPct >= targetPct ? "good" : "warn");
|
||||||
|
pushMetric(metrics, "Available", formatHours(data.availableHours));
|
||||||
|
pushMetric(metrics, "Booked", formatHours(data.bookedHours));
|
||||||
|
pushMetric(metrics, "Unassigned", formatHours(data.unassignedHours));
|
||||||
|
pushMetric(metrics, "Target", formatHours(data.targetHours));
|
||||||
|
pushMetric(metrics, "Holidays", formatDays(holidaySummary?.workdayCount ?? holidaySummary?.count));
|
||||||
|
|
||||||
|
const sections: AssistantInsightSection[] = [];
|
||||||
|
|
||||||
|
const basisMetrics: AssistantInsightMetric[] = [];
|
||||||
|
pushMetric(basisMetrics, "Location", createLocationLabel(locationContext), "info");
|
||||||
|
pushMetric(basisMetrics, "Base working days", formatDays(data.baseWorkingDays));
|
||||||
|
pushMetric(basisMetrics, "Effective working days", formatDays(data.workingDays));
|
||||||
|
pushMetric(basisMetrics, "Base capacity", formatHours(data.baseAvailableHours));
|
||||||
|
if (basisMetrics.length > 0) {
|
||||||
|
sections.push({ title: "Basis", metrics: basisMetrics });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deductionMetrics: AssistantInsightMetric[] = [];
|
||||||
|
pushMetric(deductionMetrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction ?? capacityBreakdown?.holidayHoursDeduction), "warn");
|
||||||
|
pushMetric(deductionMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
|
||||||
|
pushMetric(deductionMetrics, "Absence days", formatDays(absenceSummary?.dayEquivalent));
|
||||||
|
if (deductionMetrics.length > 0) {
|
||||||
|
sections.push({ title: "Deductions", metrics: deductionMetrics });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "chargeability",
|
||||||
|
title: `${resource} · ${month}`,
|
||||||
|
subtitle: "Holiday-aware monthly capacity",
|
||||||
|
metrics,
|
||||||
|
...(sections.length > 0 ? { sections } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHolidayRegionInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||||
|
const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
|
||||||
|
const periodStart = asString(data.periodStart);
|
||||||
|
const periodEnd = asString(data.periodEnd);
|
||||||
|
|
||||||
|
const metrics: AssistantInsightMetric[] = [];
|
||||||
|
pushMetric(metrics, "Region", createLocationLabel(locationContext), "info");
|
||||||
|
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
|
||||||
|
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
|
||||||
|
|
||||||
|
const summary = isRecord(data.summary) ? data.summary : undefined;
|
||||||
|
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
|
||||||
|
const scopeMetrics = scopeItems
|
||||||
|
.map((item) => {
|
||||||
|
if (!isRecord(item)) return null;
|
||||||
|
const scope = asString(item.scope);
|
||||||
|
const count = asNumber(item.count);
|
||||||
|
if (!scope || count == null) return null;
|
||||||
|
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
|
||||||
|
})
|
||||||
|
.filter((item): item is AssistantInsightMetric => item !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "holiday_region",
|
||||||
|
title: createLocationLabel(locationContext) ?? "Regional holidays",
|
||||||
|
subtitle: "Resolved public holiday set",
|
||||||
|
metrics,
|
||||||
|
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResourceHolidayInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||||
|
const resource = isRecord(data.resource) ? data.resource : undefined;
|
||||||
|
const summary = isRecord(data.summary) ? data.summary : undefined;
|
||||||
|
const periodStart = asString(data.periodStart);
|
||||||
|
const periodEnd = asString(data.periodEnd);
|
||||||
|
|
||||||
|
const metrics: AssistantInsightMetric[] = [];
|
||||||
|
pushMetric(metrics, "Employee", asString(resource?.name) ?? asString(resource?.eid));
|
||||||
|
pushMetric(metrics, "Location", createLocationLabel(resource), "info");
|
||||||
|
pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
|
||||||
|
pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
|
||||||
|
|
||||||
|
const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
|
||||||
|
const scopeMetrics = scopeItems
|
||||||
|
.map((item) => {
|
||||||
|
if (!isRecord(item)) return null;
|
||||||
|
const scope = asString(item.scope);
|
||||||
|
const count = asNumber(item.count);
|
||||||
|
if (!scope || count == null) return null;
|
||||||
|
return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
|
||||||
|
})
|
||||||
|
.filter((item): item is AssistantInsightMetric => item !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "resource_holidays",
|
||||||
|
title: `${asString(resource?.name) ?? "Resource"} holidays`,
|
||||||
|
subtitle: "Location-specific holiday resolution",
|
||||||
|
metrics,
|
||||||
|
...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResourceMatchInsight(data: Record<string, unknown>): AssistantInsight | null {
|
||||||
|
const project = isRecord(data.project) ? data.project : undefined;
|
||||||
|
const period = isRecord(data.period) ? data.period : undefined;
|
||||||
|
const bestMatch = isRecord(data.bestMatch) ? data.bestMatch : undefined;
|
||||||
|
if (!project || !period || !bestMatch) return null;
|
||||||
|
|
||||||
|
const remainingHours = asNumber(bestMatch.remainingHours);
|
||||||
|
const remainingHoursPerDay = asNumber(bestMatch.remainingHoursPerDay);
|
||||||
|
const lcr = asString(bestMatch.lcr);
|
||||||
|
const holidaySummary = isRecord(bestMatch.holidaySummary) ? bestMatch.holidaySummary : undefined;
|
||||||
|
const absenceSummary = isRecord(bestMatch.absenceSummary) ? bestMatch.absenceSummary : undefined;
|
||||||
|
const capacityBreakdown = isRecord(bestMatch.capacityBreakdown) ? bestMatch.capacityBreakdown : undefined;
|
||||||
|
|
||||||
|
const metrics: AssistantInsightMetric[] = [];
|
||||||
|
pushMetric(metrics, "Best match", asString(bestMatch.name) ?? asString(bestMatch.eid), "good");
|
||||||
|
pushMetric(metrics, "Project", asString(project.name) ?? asString(project.shortCode));
|
||||||
|
pushMetric(metrics, "Remaining", formatHours(remainingHours), remainingHours != null && remainingHours > 0 ? "good" : "warn");
|
||||||
|
pushMetric(metrics, "Per workday", formatHours(remainingHoursPerDay));
|
||||||
|
pushMetric(metrics, "LCR", lcr);
|
||||||
|
pushMetric(metrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction), "warn");
|
||||||
|
|
||||||
|
const sections: AssistantInsightSection[] = [];
|
||||||
|
|
||||||
|
const profileMetrics: AssistantInsightMetric[] = [];
|
||||||
|
pushMetric(profileMetrics, "Role", asString(bestMatch.role));
|
||||||
|
pushMetric(profileMetrics, "Chapter", asString(bestMatch.chapter));
|
||||||
|
pushMetric(profileMetrics, "Location", createLocationLabel(bestMatch), "info");
|
||||||
|
pushMetric(profileMetrics, "Candidate pool", asNumber(data.candidateCount)?.toString() ?? null);
|
||||||
|
if (profileMetrics.length > 0) {
|
||||||
|
sections.push({ title: "Selection", metrics: profileMetrics });
|
||||||
|
}
|
||||||
|
|
||||||
|
const basisMetrics: AssistantInsightMetric[] = [];
|
||||||
|
pushMetric(basisMetrics, "Window", asString(period.startDate) && asString(period.endDate) ? `${asString(period.startDate)} to ${asString(period.endDate)}` : null);
|
||||||
|
pushMetric(basisMetrics, "Ranking", asString(period.rankingMode));
|
||||||
|
pushMetric(basisMetrics, "Min/day", formatHours(period.minHoursPerDay));
|
||||||
|
pushMetric(basisMetrics, "Base capacity", formatHours(capacityBreakdown?.baseAvailableHours ?? bestMatch.baseAvailableHours));
|
||||||
|
pushMetric(basisMetrics, "Effective capacity", formatHours(bestMatch.availableHours));
|
||||||
|
pushMetric(basisMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
|
||||||
|
if (basisMetrics.length > 0) {
|
||||||
|
sections.push({ title: "Capacity basis", metrics: basisMetrics });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "resource_match",
|
||||||
|
title: `${asString(project.shortCode) ?? asString(project.name) ?? "Project"} staffing`,
|
||||||
|
subtitle: "Holiday-aware best-fit resource",
|
||||||
|
metrics,
|
||||||
|
...(sections.length > 0 ? { sections } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssistantInsight(toolName: string, data: unknown): AssistantInsight | null {
|
||||||
|
if (!isRecord(data)) return null;
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case "get_chargeability":
|
||||||
|
return buildChargeabilityInsight(data);
|
||||||
|
case "find_best_project_resource":
|
||||||
|
return buildResourceMatchInsight(data);
|
||||||
|
case "list_holidays_by_region":
|
||||||
|
return buildHolidayRegionInsight(data);
|
||||||
|
case "get_resource_holidays":
|
||||||
|
return buildResourceHolidayInsight(data);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,11 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
|
import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
|
||||||
import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
|
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
|
||||||
|
import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
|
||||||
import { checkPromptInjection } from "../lib/prompt-guard.js";
|
import { checkPromptInjection } from "../lib/prompt-guard.js";
|
||||||
import { checkAiOutput } from "../lib/content-filter.js";
|
import { checkAiOutput } from "../lib/content-filter.js";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
@@ -20,7 +21,7 @@ const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-A
|
|||||||
|
|
||||||
Deine Fähigkeiten:
|
Deine Fähigkeiten:
|
||||||
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
|
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
|
||||||
- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
|
- Chargeability-Analysen, Urlaubsübersichten, Feiertagskalender nach Land/Bundesland/Stadt, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
|
||||||
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
|
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
|
||||||
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
|
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
|
||||||
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
|
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
|
||||||
@@ -40,6 +41,12 @@ Wichtige Regeln:
|
|||||||
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
|
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
|
||||||
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
|
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
|
||||||
- Fasse Ergebnisse kompakt zusammen — keine unnötigen Wiederholungen der Tool-Ergebnisse
|
- Fasse Ergebnisse kompakt zusammen — keine unnötigen Wiederholungen der Tool-Ergebnisse
|
||||||
|
- Wenn Feiertage, SAH, Chargeability, Verfügbarkeit oder Ressourcenauswahl relevant sind, erkläre IMMER transparent:
|
||||||
|
1. Standortkontext (Land/Bundesland/Stadt falls relevant)
|
||||||
|
2. Feiertagsbasis bzw. Feiertagsanzahl
|
||||||
|
3. Abzüge durch Feiertage/Abwesenheiten
|
||||||
|
4. resultierende verfügbare Stunden / Zielstunden / Restkapazität
|
||||||
|
- Wenn strukturierte UI-Karten vorhanden sind, wiederhole dort gezeigte Zahlen NICHT vollständig im Freitext. Gib nur die Kernaussage und die wichtigste Begründung an.
|
||||||
- Wenn eine Suche keine Treffer ergibt, versuche einzelne Wörter aus der Anfrage als Suchbegriffe. Die Tools unterstützen automatisch wort-basierte Fuzzy-Suche — zeige dem User die Vorschläge wenn welche gefunden werden
|
- Wenn eine Suche keine Treffer ergibt, versuche einzelne Wörter aus der Anfrage als Suchbegriffe. Die Tools unterstützen automatisch wort-basierte Fuzzy-Suche — zeige dem User die Vorschläge wenn welche gefunden werden
|
||||||
|
|
||||||
Datenmodell:
|
Datenmodell:
|
||||||
@@ -48,10 +55,12 @@ Datenmodell:
|
|||||||
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
|
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
|
||||||
- Chargeability = gebuchte/verfügbare Stunden × 100%
|
- Chargeability = gebuchte/verfügbare Stunden × 100%
|
||||||
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
|
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
|
||||||
|
- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/** Map tool names to the permission required to use them */
|
/** Map tool names to the permission required to use them */
|
||||||
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||||
|
list_users: PermissionKey.MANAGE_USERS,
|
||||||
// Resource management
|
// Resource management
|
||||||
update_resource: "manageResources",
|
update_resource: "manageResources",
|
||||||
create_resource: "manageResources",
|
create_resource: "manageResources",
|
||||||
@@ -89,7 +98,36 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Tools that require cost visibility */
|
/** Tools that require cost visibility */
|
||||||
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail"]);
|
const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail", "find_best_project_resource"]);
|
||||||
|
|
||||||
|
export function getAvailableAssistantTools(permissions: Set<PermissionKey>) {
|
||||||
|
return TOOL_DEFINITIONS.filter((tool) => {
|
||||||
|
const toolName = tool.function.name;
|
||||||
|
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
||||||
|
|
||||||
|
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
|
||||||
|
const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
|
||||||
|
if (duplicateIndex >= 0) {
|
||||||
|
const copy = [...existing];
|
||||||
|
copy[duplicateIndex] = next;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
return [...existing, next].slice(-6);
|
||||||
|
}
|
||||||
|
|
||||||
export const assistantRouter = createTRPCRouter({
|
export const assistantRouter = createTRPCRouter({
|
||||||
chat: protectedProcedure
|
chat: protectedProcedure
|
||||||
@@ -176,26 +214,12 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Filter tools based on granular permissions
|
// 4. Filter tools based on granular permissions
|
||||||
const availableTools = TOOL_DEFINITIONS.filter((t) => {
|
const availableTools = getAvailableAssistantTools(permissions);
|
||||||
const toolName = t.function.name;
|
|
||||||
|
|
||||||
// Check write permission
|
|
||||||
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
|
||||||
if (requiredPerm && !permissions.has(requiredPerm as import("@capakraken/shared").PermissionKey)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide cost/budget tools if user lacks viewCosts
|
|
||||||
if (COST_TOOLS.has(toolName) && !permissions.has("viewCosts" as import("@capakraken/shared").PermissionKey)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Function calling loop
|
// 5. Function calling loop
|
||||||
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
|
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
|
||||||
const collectedActions: ToolAction[] = [];
|
const collectedActions: ToolAction[] = [];
|
||||||
|
let collectedInsights: AssistantInsight[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -240,6 +264,11 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
toolCtx,
|
toolCtx,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const insight = buildAssistantInsight(toolCall.function.name, result.data);
|
||||||
|
if (insight) {
|
||||||
|
collectedInsights = mergeInsights(collectedInsights, insight);
|
||||||
|
}
|
||||||
|
|
||||||
// Collect any actions (e.g. navigation)
|
// Collect any actions (e.g. navigation)
|
||||||
if (result.action) {
|
if (result.action) {
|
||||||
collectedActions.push(result.action);
|
collectedActions.push(result.action);
|
||||||
@@ -298,6 +327,7 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
content: finalContent,
|
content: finalContent,
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
|
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
||||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -306,6 +336,7 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
content: "I had to stop after too many tool calls. Please try a simpler question.",
|
content: "I had to stop after too many tool calls. Please try a simpler question.",
|
||||||
role: "assistant" as const,
|
role: "assistant" as const,
|
||||||
|
...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
|
||||||
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,19 +5,18 @@ import {
|
|||||||
sumFte,
|
sumFte,
|
||||||
getMonthRange,
|
getMonthRange,
|
||||||
getMonthKeys,
|
getMonthKeys,
|
||||||
countWorkingDaysInOverlap,
|
|
||||||
calculateSAH,
|
|
||||||
calculateAllocation,
|
|
||||||
DEFAULT_CALCULATION_RULES,
|
|
||||||
type AssignmentSlice,
|
type AssignmentSlice,
|
||||||
} from "@capakraken/engine";
|
} from "@capakraken/engine";
|
||||||
import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
import type { SpainScheduleRule } from "@capakraken/shared";
|
|
||||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||||
import { VacationStatus } from "@capakraken/db";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||||
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
|
||||||
export const chargeabilityReportRouter = createTRPCRouter({
|
export const chargeabilityReportRouter = createTRPCRouter({
|
||||||
getReport: controllerProcedure
|
getReport: controllerProcedure
|
||||||
@@ -59,6 +58,10 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
|||||||
eid: true,
|
eid: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
fte: true,
|
fte: true,
|
||||||
|
availability: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
chargeabilityTarget: true,
|
chargeabilityTarget: true,
|
||||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||||
orgUnit: { select: { id: true, name: true } },
|
orgUnit: { select: { id: true, name: true } },
|
||||||
@@ -90,6 +93,20 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
|||||||
endDate: rangeEnd,
|
endDate: rangeEnd,
|
||||||
resourceIds,
|
resourceIds,
|
||||||
});
|
});
|
||||||
|
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||||
|
ctx.db,
|
||||||
|
resources.map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
availability: resource.availability as unknown as WeekdayAvailability,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
})),
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
);
|
||||||
|
|
||||||
// Enrich with utilization category — fetch project util categories in bulk
|
// Enrich with utilization category — fetch project util categories in bulk
|
||||||
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
|
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
|
||||||
@@ -118,152 +135,59 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Fetch vacations/absences in the range (including type for rules engine)
|
|
||||||
const vacations = await ctx.db.vacation.findMany({
|
|
||||||
where: {
|
|
||||||
resourceId: { in: resourceIds },
|
|
||||||
status: VacationStatus.APPROVED,
|
|
||||||
startDate: { lte: rangeEnd },
|
|
||||||
endDate: { gte: rangeStart },
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
resourceId: true,
|
|
||||||
startDate: true,
|
|
||||||
endDate: true,
|
|
||||||
type: true,
|
|
||||||
isHalfDay: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load calculation rules for chargeability adjustments
|
|
||||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
|
||||||
try {
|
|
||||||
const dbRules = await ctx.db.calculationRule.findMany({
|
|
||||||
where: { isActive: true },
|
|
||||||
orderBy: [{ priority: "desc" }],
|
|
||||||
});
|
|
||||||
if (dbRules.length > 0) {
|
|
||||||
calcRules = dbRules as unknown as CalculationRule[];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// table may not exist yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build per-resource, per-month forecasts
|
// Build per-resource, per-month forecasts
|
||||||
const resourceRows = resources.map((resource) => {
|
const resourceRows = await Promise.all(resources.map(async (resource) => {
|
||||||
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
|
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
|
||||||
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
|
|
||||||
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
|
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
|
||||||
const targetPct = resource.managementLevelGroup?.targetPercentage
|
const targetPct = resource.managementLevelGroup?.targetPercentage
|
||||||
?? (resource.chargeabilityTarget / 100);
|
?? (resource.chargeabilityTarget / 100);
|
||||||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||||
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
|
const context = availabilityContexts.get(resource.id);
|
||||||
|
|
||||||
const months = monthKeys.map((key) => {
|
const months = await Promise.all(monthKeys.map(async (key) => {
|
||||||
const [y, m] = key.split("-").map(Number) as [number, number];
|
const [y, m] = key.split("-").map(Number) as [number, number];
|
||||||
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
|
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
|
||||||
|
const availableHours = calculateEffectiveAvailableHours({
|
||||||
// Compute absence days for SAH
|
availability,
|
||||||
const absenceDates: string[] = [];
|
|
||||||
for (const v of resourceVacations) {
|
|
||||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
|
||||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
|
||||||
if (vStart > vEnd) continue;
|
|
||||||
const cursor = new Date(vStart);
|
|
||||||
cursor.setUTCHours(0, 0, 0, 0);
|
|
||||||
const endNorm = new Date(vEnd);
|
|
||||||
endNorm.setUTCHours(0, 0, 0, 0);
|
|
||||||
while (cursor <= endNorm) {
|
|
||||||
absenceDates.push(cursor.toISOString().slice(0, 10));
|
|
||||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate SAH for this resource+month
|
|
||||||
const sahResult = calculateSAH({
|
|
||||||
dailyWorkingHours: dailyHours,
|
|
||||||
scheduleRules,
|
|
||||||
fte: resource.fte,
|
|
||||||
periodStart: monthStart,
|
periodStart: monthStart,
|
||||||
periodEnd: monthEnd,
|
periodEnd: monthEnd,
|
||||||
publicHolidays: [], // TODO: integrate public holidays from country
|
context,
|
||||||
absenceDays: absenceDates,
|
|
||||||
});
|
});
|
||||||
|
const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
|
||||||
// Build typed absence days for this resource in this month
|
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||||
const monthAbsenceDays: AbsenceDay[] = [];
|
availability,
|
||||||
for (const v of resourceVacations) {
|
startDate: a.startDate,
|
||||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
endDate: a.endDate,
|
||||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
hoursPerDay: a.hoursPerDay,
|
||||||
if (vStart > vEnd) continue;
|
periodStart: monthStart,
|
||||||
const absCursor = new Date(vStart);
|
periodEnd: monthEnd,
|
||||||
absCursor.setUTCHours(0, 0, 0, 0);
|
context,
|
||||||
const absEndNorm = new Date(vEnd);
|
});
|
||||||
absEndNorm.setUTCHours(0, 0, 0, 0);
|
if (totalChargeableHours <= 0) {
|
||||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
return [];
|
||||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
|
||||||
: "VACATION" as const;
|
|
||||||
while (absCursor <= absEndNorm) {
|
|
||||||
monthAbsenceDays.push({
|
|
||||||
date: new Date(absCursor),
|
|
||||||
type: triggerType,
|
|
||||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
|
||||||
});
|
|
||||||
absCursor.setUTCDate(absCursor.getUTCDate() + 1);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Build assignment slices for this month, using rules to compute chargeable hours
|
return {
|
||||||
const slices: AssignmentSlice[] = [];
|
hoursPerDay: a.hoursPerDay,
|
||||||
for (const a of resourceAssignments) {
|
workingDays: 0,
|
||||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
categoryCode: a.project.utilizationCategory?.code ?? "Chg",
|
||||||
if (workingDays <= 0) continue;
|
totalChargeableHours,
|
||||||
|
};
|
||||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
});
|
||||||
|
|
||||||
// If there are absences and rules, compute rules-adjusted chargeable hours
|
|
||||||
if (monthAbsenceDays.length > 0) {
|
|
||||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
|
||||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
|
||||||
|
|
||||||
const calcResult = calculateAllocation({
|
|
||||||
lcrCents: 0, // we only need hours, not costs
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
startDate: overlapStart,
|
|
||||||
endDate: overlapEnd,
|
|
||||||
availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
|
|
||||||
absenceDays: monthAbsenceDays,
|
|
||||||
calculationRules: calcRules,
|
|
||||||
});
|
|
||||||
|
|
||||||
slices.push({
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
workingDays,
|
|
||||||
categoryCode,
|
|
||||||
...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
slices.push({
|
|
||||||
hoursPerDay: a.hoursPerDay,
|
|
||||||
workingDays,
|
|
||||||
categoryCode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const forecast = deriveResourceForecast({
|
const forecast = deriveResourceForecast({
|
||||||
fte: resource.fte,
|
fte: resource.fte,
|
||||||
targetPercentage: targetPct,
|
targetPercentage: targetPct,
|
||||||
assignments: slices,
|
assignments: slices,
|
||||||
sah: sahResult.standardAvailableHours,
|
sah: availableHours,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
monthKey: key,
|
monthKey: key,
|
||||||
sah: sahResult.standardAvailableHours,
|
sah: availableHours,
|
||||||
...forecast,
|
...forecast,
|
||||||
};
|
};
|
||||||
});
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: resource.id,
|
id: resource.id,
|
||||||
@@ -278,7 +202,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
|
|||||||
targetPct,
|
targetPct,
|
||||||
months,
|
months,
|
||||||
};
|
};
|
||||||
});
|
}));
|
||||||
|
|
||||||
// Compute group totals per month
|
// Compute group totals per month
|
||||||
const groupTotals = monthKeys.map((key, monthIdx) => {
|
const groupTotals = monthKeys.map((key, monthIdx) => {
|
||||||
|
|||||||
@@ -4,18 +4,27 @@ import {
|
|||||||
deriveResourceForecast,
|
deriveResourceForecast,
|
||||||
computeBudgetStatus,
|
computeBudgetStatus,
|
||||||
getMonthRange,
|
getMonthRange,
|
||||||
countWorkingDaysInOverlap,
|
|
||||||
DEFAULT_CALCULATION_RULES,
|
DEFAULT_CALCULATION_RULES,
|
||||||
summarizeEstimateDemandLines,
|
summarizeEstimateDemandLines,
|
||||||
computeEvenSpread,
|
computeEvenSpread,
|
||||||
distributeHoursToWeeks,
|
distributeHoursToWeeks,
|
||||||
type AssignmentSlice,
|
type AssignmentSlice,
|
||||||
} from "@capakraken/engine";
|
} from "@capakraken/engine";
|
||||||
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
|
||||||
import { VacationStatus } from "@capakraken/db";
|
import { VacationStatus } from "@capakraken/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||||
import { fmtEur } from "../lib/format-utils.js";
|
import { fmtEur } from "../lib/format-utils.js";
|
||||||
|
import {
|
||||||
|
asHolidayResolverDb,
|
||||||
|
collectHolidayAvailability,
|
||||||
|
getResolvedCalendarHolidays,
|
||||||
|
} from "../lib/holiday-availability.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
countEffectiveWorkingDays,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
|
||||||
// ─── Graph Types (mirrored from client for API response) ────────────────────
|
// ─── Graph Types (mirrored from client for API response) ────────────────────
|
||||||
|
|
||||||
@@ -62,6 +71,21 @@ function fmtNum(v: number, decimals = 1): string {
|
|||||||
return v.toFixed(decimals);
|
return v.toFixed(decimals);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAvailabilityHoursForDate(
|
||||||
|
availability: WeekdayAvailability,
|
||||||
|
date: Date,
|
||||||
|
): number {
|
||||||
|
const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
|
||||||
|
return availability[dayKey] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumAvailabilityHoursForDates(
|
||||||
|
availability: WeekdayAvailability,
|
||||||
|
dates: Date[],
|
||||||
|
): number {
|
||||||
|
return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Router ─────────────────────────────────────────────────────────────────
|
// ─── Router ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const computationGraphRouter = createTRPCRouter({
|
export const computationGraphRouter = createTRPCRouter({
|
||||||
@@ -88,8 +112,12 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
fte: true,
|
fte: true,
|
||||||
lcrCents: true,
|
lcrCents: true,
|
||||||
chargeabilityTarget: true,
|
chargeabilityTarget: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
availability: true,
|
availability: true,
|
||||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -133,7 +161,7 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 3. Load absences ──
|
// ── 3. Load absences + holiday context ──
|
||||||
const vacations = await ctx.db.vacation.findMany({
|
const vacations = await ctx.db.vacation.findMany({
|
||||||
where: {
|
where: {
|
||||||
resourceId: input.resourceId,
|
resourceId: input.resourceId,
|
||||||
@@ -143,45 +171,47 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
||||||
});
|
});
|
||||||
|
const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
});
|
||||||
|
const holidayAvailability = collectHolidayAvailability({
|
||||||
|
vacations,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
|
||||||
|
});
|
||||||
|
const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
|
||||||
|
const absenceDateStrings = holidayAvailability.absenceDateStrings;
|
||||||
|
const absenceDays = holidayAvailability.absenceDays;
|
||||||
|
const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
|
||||||
|
const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
|
||||||
|
const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
|
||||||
|
const publicHolidayCount = resolvedHolidays.length;
|
||||||
|
|
||||||
// Build absence dates for SAH (ISO strings), separating public holidays
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||||
const publicHolidayStrings: string[] = [];
|
ctx.db,
|
||||||
const absenceDateStrings: string[] = [];
|
[{
|
||||||
const absenceDays: AbsenceDay[] = [];
|
id: resource.id,
|
||||||
let halfDayCount = 0;
|
availability: weeklyAvailability,
|
||||||
let vacationDayCount = 0;
|
countryId: resource.countryId,
|
||||||
let sickDayCount = 0;
|
countryCode: resource.country?.code,
|
||||||
let publicHolidayCount = 0;
|
federalState: resource.federalState,
|
||||||
for (const v of vacations) {
|
metroCityId: resource.metroCityId,
|
||||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
metroCityName: resource.metroCity?.name,
|
||||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
}],
|
||||||
if (vStart > vEnd) continue;
|
monthStart,
|
||||||
const cursor = new Date(vStart);
|
monthEnd,
|
||||||
cursor.setUTCHours(0, 0, 0, 0);
|
);
|
||||||
const endNorm = new Date(vEnd);
|
const availabilityContext = contexts.get(resource.id);
|
||||||
endNorm.setUTCHours(0, 0, 0, 0);
|
|
||||||
const triggerType = v.type === "SICK" ? "SICK" as const
|
|
||||||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
|
||||||
: "VACATION" as const;
|
|
||||||
while (cursor <= endNorm) {
|
|
||||||
const isoDate = cursor.toISOString().slice(0, 10);
|
|
||||||
if (triggerType === "PUBLIC_HOLIDAY") {
|
|
||||||
publicHolidayStrings.push(isoDate);
|
|
||||||
publicHolidayCount++;
|
|
||||||
} else {
|
|
||||||
absenceDateStrings.push(isoDate);
|
|
||||||
if (triggerType === "VACATION") vacationDayCount++;
|
|
||||||
if (triggerType === "SICK") sickDayCount++;
|
|
||||||
}
|
|
||||||
absenceDays.push({
|
|
||||||
date: new Date(cursor),
|
|
||||||
type: triggerType,
|
|
||||||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
|
||||||
});
|
|
||||||
if (v.isHalfDay) halfDayCount++;
|
|
||||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 4. Load calculation rules ──
|
// ── 4. Load calculation rules ──
|
||||||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||||||
@@ -197,7 +227,7 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
// table may not exist yet
|
// table may not exist yet
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 5. Calculate SAH ──
|
// ── 5. Calculate SAH / effective capacity ──
|
||||||
const sahResult = calculateSAH({
|
const sahResult = calculateSAH({
|
||||||
dailyWorkingHours: dailyHours,
|
dailyWorkingHours: dailyHours,
|
||||||
scheduleRules,
|
scheduleRules,
|
||||||
@@ -207,6 +237,60 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
publicHolidays: publicHolidayStrings,
|
publicHolidays: publicHolidayStrings,
|
||||||
absenceDays: absenceDateStrings,
|
absenceDays: absenceDateStrings,
|
||||||
});
|
});
|
||||||
|
const baseWorkingDays = countEffectiveWorkingDays({
|
||||||
|
availability: weeklyAvailability,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context: undefined,
|
||||||
|
});
|
||||||
|
const effectiveWorkingDays = countEffectiveWorkingDays({
|
||||||
|
availability: weeklyAvailability,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context: availabilityContext,
|
||||||
|
});
|
||||||
|
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability: weeklyAvailability,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context: undefined,
|
||||||
|
});
|
||||||
|
const effectiveAvailableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability: weeklyAvailability,
|
||||||
|
periodStart: monthStart,
|
||||||
|
periodEnd: monthEnd,
|
||||||
|
context: availabilityContext,
|
||||||
|
});
|
||||||
|
const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
|
||||||
|
const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
|
||||||
|
count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
|
||||||
|
), 0);
|
||||||
|
const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
|
||||||
|
weeklyAvailability,
|
||||||
|
publicHolidayDates,
|
||||||
|
);
|
||||||
|
const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
|
||||||
|
if (absence.type === "PUBLIC_HOLIDAY") {
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
|
||||||
|
return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
|
||||||
|
}, 0);
|
||||||
|
const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
|
||||||
|
? effectiveAvailableHours / effectiveWorkingDays
|
||||||
|
: 0;
|
||||||
|
const holidayScopeSummary = [
|
||||||
|
resource.country?.code ?? "—",
|
||||||
|
resource.federalState ?? "—",
|
||||||
|
resource.metroCity?.name ?? "—",
|
||||||
|
].join(" / ");
|
||||||
|
const holidayExamples = resolvedHolidays.length > 0
|
||||||
|
? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
|
||||||
|
: "none";
|
||||||
|
const holidayScopeBreakdown = resolvedHolidays.reduce<Record<string, number>>((counts, holiday) => {
|
||||||
|
counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
|
||||||
|
return counts;
|
||||||
|
}, {});
|
||||||
|
|
||||||
// ── 6. Calculate allocations + chargeability slices ──
|
// ── 6. Calculate allocations + chargeability slices ──
|
||||||
const slices: AssignmentSlice[] = [];
|
const slices: AssignmentSlice[] = [];
|
||||||
@@ -217,9 +301,6 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
let hasRulesEffect = false;
|
let hasRulesEffect = false;
|
||||||
|
|
||||||
for (const a of assignments) {
|
for (const a of assignments) {
|
||||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
|
||||||
if (workingDays <= 0) continue;
|
|
||||||
|
|
||||||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||||||
@@ -233,6 +314,7 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
absenceDays,
|
absenceDays,
|
||||||
calculationRules: calcRules,
|
calculationRules: calcRules,
|
||||||
});
|
});
|
||||||
|
if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
|
||||||
|
|
||||||
totalAllocHours += calcResult.totalHours;
|
totalAllocHours += calcResult.totalHours;
|
||||||
totalAllocCostCents += calcResult.totalCostCents;
|
totalAllocCostCents += calcResult.totalCostCents;
|
||||||
@@ -247,7 +329,7 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
|
|
||||||
slices.push({
|
slices.push({
|
||||||
hoursPerDay: a.hoursPerDay,
|
hoursPerDay: a.hoursPerDay,
|
||||||
workingDays,
|
workingDays: calcResult.workingDays,
|
||||||
categoryCode,
|
categoryCode,
|
||||||
...(calcResult.totalChargeableHours !== undefined
|
...(calcResult.totalChargeableHours !== undefined
|
||||||
? { totalChargeableHours: calcResult.totalChargeableHours }
|
? { totalChargeableHours: calcResult.totalChargeableHours }
|
||||||
@@ -260,7 +342,7 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
fte: resource.fte,
|
fte: resource.fte,
|
||||||
targetPercentage: targetPct,
|
targetPercentage: targetPct,
|
||||||
assignments: slices,
|
assignments: slices,
|
||||||
sah: sahResult.standardAvailableHours,
|
sah: effectiveAvailableHours,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 8. Build budget status for first project with budget ──
|
// ── 8. Build budget status for first project with budget ──
|
||||||
@@ -319,7 +401,18 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
|
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
|
||||||
: 0;
|
: 0;
|
||||||
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
|
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
|
||||||
return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||||||
|
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||||||
|
const calcResult = calculateAllocation({
|
||||||
|
lcrCents: resource.lcrCents,
|
||||||
|
hoursPerDay: a.hoursPerDay,
|
||||||
|
startDate: overlapStart,
|
||||||
|
endDate: overlapEnd,
|
||||||
|
availability: weeklyAvailability,
|
||||||
|
absenceDays,
|
||||||
|
calculationRules: calcRules,
|
||||||
|
});
|
||||||
|
return sum + calcResult.workingDays;
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
// Format weekly availability for display
|
// Format weekly availability for display
|
||||||
@@ -332,9 +425,10 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
|
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
|
||||||
|
|
||||||
// Derived utilization ratio
|
// Derived utilization ratio
|
||||||
const utilizationPct = sahResult.standardAvailableHours > 0
|
const utilizationPct = effectiveAvailableHours > 0
|
||||||
? (totalAllocHours / sahResult.standardAvailableHours) * 100
|
? (totalAllocHours / effectiveAvailableHours) * 100
|
||||||
: 0;
|
: 0;
|
||||||
|
const chargeableHours = forecast.chg * effectiveAvailableHours;
|
||||||
|
|
||||||
// Has schedule rules (Spain variable hours)?
|
// Has schedule rules (Spain variable hours)?
|
||||||
const hasScheduleRules = !!scheduleRules;
|
const hasScheduleRules = !!scheduleRules;
|
||||||
@@ -342,6 +436,11 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
const nodes: GraphNode[] = [
|
const nodes: GraphNode[] = [
|
||||||
// INPUT
|
// INPUT
|
||||||
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
|
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
|
||||||
|
n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
|
||||||
|
n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
|
||||||
|
n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
|
||||||
|
n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
|
||||||
|
n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0),
|
||||||
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
|
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
|
||||||
...(hasScheduleRules ? [
|
...(hasScheduleRules ? [
|
||||||
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
|
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
|
||||||
@@ -350,7 +449,7 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
|
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
|
||||||
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
|
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
|
||||||
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
|
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
|
||||||
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0),
|
n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0),
|
||||||
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
|
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
|
||||||
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
|
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
|
||||||
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
|
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
|
||||||
@@ -358,12 +457,15 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
// SAH
|
// SAH
|
||||||
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
|
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
|
||||||
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
|
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
|
||||||
n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
|
n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
|
||||||
n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
|
n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
|
||||||
n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
|
n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
|
||||||
n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"),
|
n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
|
||||||
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"),
|
n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
|
||||||
n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
|
n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
|
||||||
|
n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
|
||||||
|
n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
|
||||||
|
n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),
|
||||||
|
|
||||||
// ALLOCATION
|
// ALLOCATION
|
||||||
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
|
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
|
||||||
@@ -387,24 +489,24 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
] : []),
|
] : []),
|
||||||
|
|
||||||
// CHARGEABILITY — full breakdown from deriveResourceForecast
|
// CHARGEABILITY — full breakdown from deriveResourceForecast
|
||||||
n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
|
n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
|
||||||
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
|
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
|
||||||
...(forecast.bd > 0 ? [
|
...(forecast.bd > 0 ? [
|
||||||
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"),
|
n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"),
|
||||||
] : []),
|
] : []),
|
||||||
...(forecast.mdi > 0 ? [
|
...(forecast.mdi > 0 ? [
|
||||||
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"),
|
n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"),
|
||||||
] : []),
|
] : []),
|
||||||
...(forecast.mo > 0 ? [
|
...(forecast.mo > 0 ? [
|
||||||
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"),
|
n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"),
|
||||||
] : []),
|
] : []),
|
||||||
...(forecast.pdr > 0 ? [
|
...(forecast.pdr > 0 ? [
|
||||||
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"),
|
n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"),
|
||||||
] : []),
|
] : []),
|
||||||
...(forecast.absence > 0 ? [
|
...(forecast.absence > 0 ? [
|
||||||
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"),
|
n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"),
|
||||||
] : []),
|
] : []),
|
||||||
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
|
n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
|
||||||
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
|
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
|
||||||
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"),
|
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"),
|
||||||
|
|
||||||
@@ -414,7 +516,16 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const links: GraphLink[] = [
|
const links: GraphLink[] = [
|
||||||
// INPUT → SAH
|
// INPUT → SAH
|
||||||
|
l("input.country", "input.holidayContext", "holiday base", 1),
|
||||||
|
l("input.state", "input.holidayContext", "regional scope", 1),
|
||||||
|
l("input.city", "input.holidayContext", "local scope", 1),
|
||||||
|
l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1),
|
||||||
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
|
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
|
||||||
|
l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2),
|
||||||
|
l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2),
|
||||||
|
l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
|
||||||
|
l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
|
||||||
|
l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
|
||||||
...(hasScheduleRules ? [
|
...(hasScheduleRules ? [
|
||||||
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
|
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
|
||||||
] : []),
|
] : []),
|
||||||
@@ -422,14 +533,14 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
l("sah.weekendDays", "sah.grossWorkingDays", "−", 1),
|
l("sah.weekendDays", "sah.grossWorkingDays", "−", 1),
|
||||||
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
|
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
|
||||||
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
|
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
|
||||||
l("sah.grossWorkingDays", "sah.netWorkingDays", "−", 2),
|
l("sah.grossWorkingDays", "sah.netWorkingDays", "− holiday/absence days", 2),
|
||||||
l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1),
|
l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1),
|
||||||
l("sah.absenceDays", "sah.netWorkingDays", "−", 1),
|
l("sah.absenceDays", "sah.netWorkingDays", "−", 1),
|
||||||
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
|
l("sah.baseHours", "sah.sah", "start from base capacity", 2),
|
||||||
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
|
l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2),
|
||||||
|
l("sah.absenceHours", "sah.sah", "− absence hours", 2),
|
||||||
|
l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1),
|
||||||
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
|
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
|
||||||
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
|
|
||||||
l("sah.netWorkingDays", "sah.sah", "×", 2),
|
|
||||||
|
|
||||||
// INPUT → ALLOCATION
|
// INPUT → ALLOCATION
|
||||||
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
|
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
|
||||||
@@ -489,6 +600,30 @@ export const computationGraphRouter = createTRPCRouter({
|
|||||||
resourceEid: resource.eid,
|
resourceEid: resource.eid,
|
||||||
month: input.month,
|
month: input.month,
|
||||||
assignmentCount: assignments.length,
|
assignmentCount: assignments.length,
|
||||||
|
countryCode: resource.country?.code ?? null,
|
||||||
|
countryName: resource.country?.name ?? null,
|
||||||
|
federalState: resource.federalState ?? null,
|
||||||
|
metroCityName: resource.metroCity?.name ?? null,
|
||||||
|
resolvedHolidays: resolvedHolidays.map((holiday) => ({
|
||||||
|
date: holiday.date,
|
||||||
|
name: holiday.name,
|
||||||
|
scope: holiday.scope,
|
||||||
|
calendarName: holiday.calendarName,
|
||||||
|
})),
|
||||||
|
factors: {
|
||||||
|
weeklyAvailability,
|
||||||
|
baseWorkingDays,
|
||||||
|
effectiveWorkingDays,
|
||||||
|
baseAvailableHours,
|
||||||
|
effectiveAvailableHours,
|
||||||
|
publicHolidayCount,
|
||||||
|
publicHolidayWorkdayCount,
|
||||||
|
publicHolidayHoursDeduction,
|
||||||
|
absenceDayCount: absenceDateStrings.length,
|
||||||
|
absenceHoursDeduction,
|
||||||
|
chargeableHours,
|
||||||
|
utilizationPct,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -9,19 +9,19 @@ import { z } from "zod";
|
|||||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||||
import { createAuditEntry } from "../lib/audit.js";
|
import { createAuditEntry } from "../lib/audit.js";
|
||||||
|
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||||
|
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||||
|
|
||||||
/** Types that consume from annual leave balance */
|
/** Types that consume from annual leave balance */
|
||||||
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
||||||
|
|
||||||
/**
|
type EntitlementSnapshot = {
|
||||||
* Count calendar days between two dates (inclusive).
|
id: string;
|
||||||
* Half-day vacations count as 0.5.
|
entitledDays: number;
|
||||||
*/
|
carryoverDays: number;
|
||||||
function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
|
usedDays: number;
|
||||||
if (isHalfDay) return 0.5;
|
pendingDays: number;
|
||||||
const ms = endDate.getTime() - startDate.getTime();
|
};
|
||||||
return Math.round(ms / 86_400_000) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create an entitlement record, applying carryover from previous year if needed.
|
* Get or create an entitlement record, applying carryover from previous year if needed.
|
||||||
@@ -61,6 +61,14 @@ async function getOrCreateEntitlement(
|
|||||||
return entitlement;
|
return entitlement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function calculateCarryoverDays(entitlement: {
|
||||||
|
entitledDays: number;
|
||||||
|
usedDays: number;
|
||||||
|
pendingDays: number;
|
||||||
|
}): number {
|
||||||
|
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recompute used/pending days from actual vacation records and update the cached values.
|
* Recompute used/pending days from actual vacation records and update the cached values.
|
||||||
*/
|
*/
|
||||||
@@ -69,14 +77,57 @@ async function syncEntitlement(
|
|||||||
resourceId: string,
|
resourceId: string,
|
||||||
year: number,
|
year: number,
|
||||||
defaultDays: number,
|
defaultDays: number,
|
||||||
) {
|
visitedYears: Set<number> = new Set(),
|
||||||
|
): Promise<EntitlementSnapshot> {
|
||||||
|
if (visitedYears.has(year)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: `Detected recursive entitlement sync for year ${year}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visitedYears.add(year);
|
||||||
|
|
||||||
|
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
|
||||||
|
where: { resourceId_year: { resourceId, year: year - 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (previousYearEntitlement) {
|
||||||
|
previousYearEntitlement = await syncEntitlement(
|
||||||
|
db,
|
||||||
|
resourceId,
|
||||||
|
year - 1,
|
||||||
|
defaultDays,
|
||||||
|
visitedYears,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
|
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
|
||||||
|
const carryoverDays = previousYearEntitlement
|
||||||
|
? calculateCarryoverDays(previousYearEntitlement)
|
||||||
|
: 0;
|
||||||
|
const expectedEntitledDays = defaultDays + carryoverDays;
|
||||||
|
const entitlementWithCarryover = (
|
||||||
|
entitlement.carryoverDays !== carryoverDays
|
||||||
|
|| entitlement.entitledDays !== expectedEntitledDays
|
||||||
|
)
|
||||||
|
? await db.vacationEntitlement.update({
|
||||||
|
where: { id: entitlement.id },
|
||||||
|
data: {
|
||||||
|
carryoverDays,
|
||||||
|
entitledDays: expectedEntitledDays,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: entitlement;
|
||||||
|
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
|
||||||
|
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
|
||||||
|
const holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
|
||||||
|
|
||||||
const vacations = await db.vacation.findMany({
|
const vacations = await db.vacation.findMany({
|
||||||
where: {
|
where: {
|
||||||
resourceId,
|
resourceId,
|
||||||
type: { in: BALANCE_TYPES },
|
type: { in: BALANCE_TYPES },
|
||||||
startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
|
startDate: { lte: yearEnd },
|
||||||
|
endDate: { gte: yearStart },
|
||||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||||
},
|
},
|
||||||
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
|
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
|
||||||
@@ -86,13 +137,22 @@ async function syncEntitlement(
|
|||||||
let pendingDays = 0;
|
let pendingDays = 0;
|
||||||
|
|
||||||
for (const v of vacations) {
|
for (const v of vacations) {
|
||||||
const days = countDays(v.startDate, v.endDate, v.isHalfDay);
|
const days = countVacationChargeableDays({
|
||||||
|
vacation: v,
|
||||||
|
periodStart: yearStart,
|
||||||
|
periodEnd: yearEnd,
|
||||||
|
countryCode: holidayContext.countryCode,
|
||||||
|
federalState: holidayContext.federalState,
|
||||||
|
metroCityName: holidayContext.metroCityName,
|
||||||
|
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||||
|
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||||
|
});
|
||||||
if (v.status === VacationStatus.APPROVED) usedDays += days;
|
if (v.status === VacationStatus.APPROVED) usedDays += days;
|
||||||
else pendingDays += days;
|
else pendingDays += days;
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.vacationEntitlement.update({
|
return db.vacationEntitlement.update({
|
||||||
where: { id: entitlement.id },
|
where: { id: entitlementWithCarryover.id },
|
||||||
data: { usedDays, pendingDays },
|
data: { usedDays, pendingDays },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -134,17 +194,23 @@ export const entitlementRouter = createTRPCRouter({
|
|||||||
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||||
|
|
||||||
// Also count sick days (informational)
|
// Also count sick days (informational)
|
||||||
const sickVacations = await ctx.db.vacation.findMany({
|
const sickVacationsResult = await ctx.db.vacation.findMany({
|
||||||
where: {
|
where: {
|
||||||
resourceId: input.resourceId,
|
resourceId: input.resourceId,
|
||||||
type: VacationType.SICK,
|
type: VacationType.SICK,
|
||||||
status: VacationStatus.APPROVED,
|
status: VacationStatus.APPROVED,
|
||||||
startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
|
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
|
||||||
|
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
|
||||||
},
|
},
|
||||||
select: { startDate: true, endDate: true, isHalfDay: true },
|
select: { startDate: true, endDate: true, isHalfDay: true },
|
||||||
});
|
});
|
||||||
|
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
|
||||||
const sickDays = sickVacations.reduce(
|
const sickDays = sickVacations.reduce(
|
||||||
(sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
|
(sum, v) => sum + countCalendarDaysInPeriod(
|
||||||
|
v,
|
||||||
|
new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||||
|
new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||||
|
),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -171,7 +237,7 @@ export const entitlementRouter = createTRPCRouter({
|
|||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
|
||||||
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
||||||
return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { experienceMultiplierRouter } from "./experience-multiplier.js";
|
||||||
import { estimateRouter } from "./estimate.js";
|
import { estimateRouter } from "./estimate.js";
|
||||||
import { entitlementRouter } from "./entitlement.js";
|
import { entitlementRouter } from "./entitlement.js";
|
||||||
|
import { holidayCalendarRouter } from "./holiday-calendar.js";
|
||||||
import { importExportRouter } from "./import-export.js";
|
import { importExportRouter } from "./import-export.js";
|
||||||
import { insightsRouter } from "./insights.js";
|
import { insightsRouter } from "./insights.js";
|
||||||
import { managementLevelRouter } from "./management-level.js";
|
import { managementLevelRouter } from "./management-level.js";
|
||||||
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
insights: insightsRouter,
|
insights: insightsRouter,
|
||||||
vacation: vacationRouter,
|
vacation: vacationRouter,
|
||||||
entitlement: entitlementRouter,
|
entitlement: entitlementRouter,
|
||||||
|
holidayCalendar: holidayCalendarRouter,
|
||||||
notification: notificationRouter,
|
notification: notificationRouter,
|
||||||
settings: settingsRouter,
|
settings: settingsRouter,
|
||||||
country: countryRouter,
|
country: countryRouter,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
countPlanningEntries,
|
countPlanningEntries,
|
||||||
listAssignmentBookings,
|
listAssignmentBookings,
|
||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
|
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -17,6 +18,10 @@ import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../ge
|
|||||||
import { invalidateDashboardCache } from "../lib/cache.js";
|
import { invalidateDashboardCache } from "../lib/cache.js";
|
||||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||||
import { validateImageDataUrl } from "../lib/image-validation.js";
|
import { validateImageDataUrl } from "../lib/image-validation.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
|
||||||
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
|
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
|
||||||
|
|
||||||
@@ -127,20 +132,53 @@ export const projectRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const assignments = await ctx.db.assignment.findMany({
|
const assignments = await ctx.db.assignment.findMany({
|
||||||
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
|
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
|
||||||
include: { resource: { include: { country: { select: { code: true } } } } },
|
include: {
|
||||||
|
resource: {
|
||||||
|
include: {
|
||||||
|
country: { select: { id: true, code: true } },
|
||||||
|
metroCity: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
const periodStart = assignments.length > 0
|
||||||
|
? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
|
||||||
|
: new Date();
|
||||||
|
const periodEnd = assignments.length > 0
|
||||||
|
? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
|
||||||
|
: new Date();
|
||||||
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||||
|
ctx.db,
|
||||||
|
assignments.map((assignment) => ({
|
||||||
|
id: assignment.resource.id,
|
||||||
|
availability: assignment.resource.availability as unknown as WeekdayAvailability,
|
||||||
|
countryId: assignment.resource.country?.id ?? assignment.resource.countryId,
|
||||||
|
countryCode: assignment.resource.country?.code,
|
||||||
|
federalState: assignment.resource.federalState,
|
||||||
|
metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId,
|
||||||
|
metroCityName: assignment.resource.metroCity?.name,
|
||||||
|
})),
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
);
|
||||||
|
|
||||||
const mapped: ShoringAssignment[] = assignments.map((a) => {
|
const mapped: ShoringAssignment[] = assignments.map((a) => {
|
||||||
const start = new Date(a.startDate);
|
const workingDays = a.hoursPerDay > 0
|
||||||
const end = new Date(a.endDate);
|
? calculateEffectiveBookedHours({
|
||||||
const diffMs = end.getTime() - start.getTime();
|
availability: a.resource.availability as unknown as WeekdayAvailability,
|
||||||
const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1);
|
startDate: a.startDate,
|
||||||
const workingDays = Math.round(diffDays / 7 * 5);
|
endDate: a.endDate,
|
||||||
|
hoursPerDay: a.hoursPerDay,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
context: contexts.get(a.resourceId ?? a.resource.id),
|
||||||
|
}) / a.hoursPerDay
|
||||||
|
: 0;
|
||||||
return {
|
return {
|
||||||
resourceId: a.resourceId,
|
resourceId: a.resourceId,
|
||||||
countryCode: a.resource.country?.code ?? null,
|
countryCode: a.resource.country?.code ?? null,
|
||||||
hoursPerDay: a.hoursPerDay,
|
hoursPerDay: a.hoursPerDay,
|
||||||
workingDays: Math.max(1, workingDays),
|
workingDays: Math.max(0, workingDays),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
import { z } from "zod";
|
import { Prisma } from "@capakraken/db";
|
||||||
|
import {
|
||||||
|
isChargeabilityActualBooking,
|
||||||
|
isChargeabilityRelevantProject,
|
||||||
|
listAssignmentBookings,
|
||||||
|
} from "@capakraken/application";
|
||||||
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
countEffectiveWorkingDays,
|
||||||
|
getAvailabilityHoursForDate,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
// ─── Column Definitions ──────────────────────────────────────────────────────
|
// ─── Column Definitions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -30,6 +44,7 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
|
|||||||
{ key: "departed", label: "Departed", dataType: "boolean" },
|
{ key: "departed", label: "Departed", dataType: "boolean" },
|
||||||
{ key: "postalCode", label: "Postal Code", dataType: "string" },
|
{ key: "postalCode", label: "Postal Code", dataType: "string" },
|
||||||
{ key: "federalState", label: "Federal State", dataType: "string" },
|
{ key: "federalState", label: "Federal State", dataType: "string" },
|
||||||
|
{ key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" },
|
||||||
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
|
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
|
||||||
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
|
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
|
||||||
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
|
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
|
||||||
@@ -49,6 +64,7 @@ const PROJECT_COLUMNS: ColumnDef[] = [
|
|||||||
{ key: "status", label: "Status", dataType: "string" },
|
{ key: "status", label: "Status", dataType: "string" },
|
||||||
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
|
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
|
||||||
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
|
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
|
||||||
|
{ key: "clientId", label: "Client ID", dataType: "string" },
|
||||||
{ key: "startDate", label: "Start Date", dataType: "date" },
|
{ key: "startDate", label: "Start Date", dataType: "date" },
|
||||||
{ key: "endDate", label: "End Date", dataType: "date" },
|
{ key: "endDate", label: "End Date", dataType: "date" },
|
||||||
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
|
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
|
||||||
@@ -61,10 +77,19 @@ const PROJECT_COLUMNS: ColumnDef[] = [
|
|||||||
|
|
||||||
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
|
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
|
||||||
{ key: "id", label: "ID", dataType: "string" },
|
{ key: "id", label: "ID", dataType: "string" },
|
||||||
|
{ key: "resourceId", label: "Resource ID", dataType: "string" },
|
||||||
|
{ key: "projectId", label: "Project ID", dataType: "string" },
|
||||||
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
|
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
|
||||||
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
|
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
|
||||||
|
{ key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" },
|
||||||
|
{ key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" },
|
||||||
|
{ key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" },
|
||||||
|
{ key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" },
|
||||||
|
{ key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" },
|
||||||
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
|
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
|
||||||
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
|
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
|
||||||
|
{ key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" },
|
||||||
|
{ key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" },
|
||||||
{ key: "startDate", label: "Start Date", dataType: "date" },
|
{ key: "startDate", label: "Start Date", dataType: "date" },
|
||||||
{ key: "endDate", label: "End Date", dataType: "date" },
|
{ key: "endDate", label: "End Date", dataType: "date" },
|
||||||
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
|
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
|
||||||
@@ -77,10 +102,55 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
|
|||||||
{ key: "updatedAt", label: "Updated At", dataType: "date" },
|
{ key: "updatedAt", label: "Updated At", dataType: "date" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [
|
||||||
|
{ key: "id", label: "Row ID", dataType: "string" },
|
||||||
|
{ key: "resourceId", label: "Resource ID", dataType: "string" },
|
||||||
|
{ key: "monthKey", label: "Month", dataType: "string" },
|
||||||
|
{ key: "periodStart", label: "Period Start", dataType: "date" },
|
||||||
|
{ key: "periodEnd", label: "Period End", dataType: "date" },
|
||||||
|
{ key: "eid", label: "Employee ID", dataType: "string" },
|
||||||
|
{ key: "displayName", label: "Name", dataType: "string" },
|
||||||
|
{ key: "email", label: "Email", dataType: "string" },
|
||||||
|
{ key: "chapter", label: "Chapter", dataType: "string" },
|
||||||
|
{ key: "resourceType", label: "Resource Type", dataType: "string" },
|
||||||
|
{ key: "isActive", label: "Active", dataType: "boolean" },
|
||||||
|
{ key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
|
||||||
|
{ key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
|
||||||
|
{ key: "departed", label: "Departed", dataType: "boolean" },
|
||||||
|
{ key: "countryCode", label: "Country Code", dataType: "string" },
|
||||||
|
{ key: "countryName", label: "Country", dataType: "string" },
|
||||||
|
{ key: "federalState", label: "Federal State", dataType: "string" },
|
||||||
|
{ key: "metroCityName", label: "Metro City", dataType: "string" },
|
||||||
|
{ key: "orgUnitName", label: "Org Unit", dataType: "string" },
|
||||||
|
{ key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" },
|
||||||
|
{ key: "managementLevelName", label: "Mgmt Level", dataType: "string" },
|
||||||
|
{ key: "fte", label: "FTE", dataType: "number" },
|
||||||
|
{ key: "lcrCents", label: "LCR (cents)", dataType: "number" },
|
||||||
|
{ key: "ucrCents", label: "UCR (cents)", dataType: "number" },
|
||||||
|
{ key: "currency", label: "Currency", dataType: "string" },
|
||||||
|
{ key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" },
|
||||||
|
{ key: "monthlyTargetHours", label: "Target Hours", dataType: "number" },
|
||||||
|
{ key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" },
|
||||||
|
{ key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" },
|
||||||
|
{ key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" },
|
||||||
|
{ key: "monthlySahHours", label: "SAH", dataType: "number" },
|
||||||
|
{ key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" },
|
||||||
|
{ key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" },
|
||||||
|
{ key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" },
|
||||||
|
{ key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" },
|
||||||
|
{ key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" },
|
||||||
|
{ key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" },
|
||||||
|
{ key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" },
|
||||||
|
{ key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" },
|
||||||
|
{ key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" },
|
||||||
|
{ key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" },
|
||||||
|
];
|
||||||
|
|
||||||
const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
|
const COLUMN_MAP: Record<EntityKey, ColumnDef[]> = {
|
||||||
resource: RESOURCE_COLUMNS,
|
resource: RESOURCE_COLUMNS,
|
||||||
project: PROJECT_COLUMNS,
|
project: PROJECT_COLUMNS,
|
||||||
assignment: ASSIGNMENT_COLUMNS,
|
assignment: ASSIGNMENT_COLUMNS,
|
||||||
|
resource_month: RESOURCE_MONTH_COLUMNS,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -89,6 +159,7 @@ const ENTITY_MAP = {
|
|||||||
resource: "resource",
|
resource: "resource",
|
||||||
project: "project",
|
project: "project",
|
||||||
assignment: "assignment",
|
assignment: "assignment",
|
||||||
|
resource_month: "resource_month",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type EntityKey = keyof typeof ENTITY_MAP;
|
type EntityKey = keyof typeof ENTITY_MAP;
|
||||||
@@ -110,6 +181,7 @@ const ALLOWED_SCALAR_FIELDS: Record<EntityKey, Set<string>> = {
|
|||||||
"id", "startDate", "endDate", "hoursPerDay", "percentage",
|
"id", "startDate", "endDate", "hoursPerDay", "percentage",
|
||||||
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
|
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
|
||||||
]),
|
]),
|
||||||
|
resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)),
|
||||||
};
|
};
|
||||||
|
|
||||||
function getValidScalarField(entity: EntityKey, field: string): string | null {
|
function getValidScalarField(entity: EntityKey, field: string): string | null {
|
||||||
@@ -132,15 +204,14 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
|
|||||||
if (!def) continue;
|
if (!def) continue;
|
||||||
|
|
||||||
if (colKey.includes(".")) {
|
if (colKey.includes(".")) {
|
||||||
// Relation column, e.g. "country.name" => select: { country: { select: { name: true } } }
|
|
||||||
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
|
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
|
||||||
const fieldName = colKey.split(".").slice(1).join(".");
|
|
||||||
const existing = select[relationName];
|
const existing = select[relationName];
|
||||||
if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
|
const fieldSegments = colKey.split(".").slice(1);
|
||||||
(existing as { select: Record<string, boolean> }).select[fieldName] = true;
|
const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
|
||||||
} else {
|
? (existing as { select: Record<string, unknown> }).select
|
||||||
select[relationName] = { select: { [fieldName]: true } };
|
: {};
|
||||||
}
|
mergeSelectPath(relationSelect, fieldSegments);
|
||||||
|
select[relationName] = { select: relationSelect };
|
||||||
} else {
|
} else {
|
||||||
select[colKey] = true;
|
select[colKey] = true;
|
||||||
}
|
}
|
||||||
@@ -149,6 +220,29 @@ function buildSelect(entity: EntityKey, columns: string[]): Record<string, unkno
|
|||||||
return select;
|
return select;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeSelectPath(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
segments: string[],
|
||||||
|
): void {
|
||||||
|
const [head, ...tail] = segments;
|
||||||
|
if (!head) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tail.length === 0) {
|
||||||
|
target[head] = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = target[head];
|
||||||
|
const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
|
||||||
|
? (existing as { select: Record<string, unknown> }).select
|
||||||
|
: {};
|
||||||
|
|
||||||
|
mergeSelectPath(nestedSelect, tail);
|
||||||
|
target[head] = { select: nestedSelect };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Prisma `where` from the filter array.
|
* Build a Prisma `where` from the filter array.
|
||||||
* Only scalar top-level fields are allowed for safety.
|
* Only scalar top-level fields are allowed for safety.
|
||||||
@@ -246,6 +340,8 @@ function csvEscape(value: unknown): string {
|
|||||||
|
|
||||||
// ─── Input Schema ───────────────────────────────────────────────────────────
|
// ─── Input Schema ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]);
|
||||||
|
|
||||||
const FilterSchema = z.object({
|
const FilterSchema = z.object({
|
||||||
field: z.string().min(1),
|
field: z.string().min(1),
|
||||||
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
|
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
|
||||||
@@ -253,24 +349,171 @@ const FilterSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ReportInputSchema = z.object({
|
const ReportInputSchema = z.object({
|
||||||
entity: z.enum(["resource", "project", "assignment"]),
|
entity: reportEntitySchema,
|
||||||
columns: z.array(z.string()).min(1),
|
columns: z.array(z.string()).min(1),
|
||||||
filters: z.array(FilterSchema).default([]),
|
filters: z.array(FilterSchema).default([]),
|
||||||
groupBy: z.string().optional(),
|
groupBy: z.string().optional(),
|
||||||
sortBy: z.string().optional(),
|
sortBy: z.string().optional(),
|
||||||
sortDir: z.enum(["asc", "desc"]).default("asc"),
|
sortDir: z.enum(["asc", "desc"]).default("asc"),
|
||||||
|
periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||||
limit: z.number().int().min(1).max(5000).default(50),
|
limit: z.number().int().min(1).max(5000).default(50),
|
||||||
offset: z.number().int().min(0).default(0),
|
offset: z.number().int().min(0).default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true });
|
||||||
|
|
||||||
|
const ReportTemplateEntity = {
|
||||||
|
RESOURCE: "RESOURCE",
|
||||||
|
PROJECT: "PROJECT",
|
||||||
|
ASSIGNMENT: "ASSIGNMENT",
|
||||||
|
RESOURCE_MONTH: "RESOURCE_MONTH",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ReportTemplateEntity = (typeof ReportTemplateEntity)[keyof typeof ReportTemplateEntity];
|
||||||
|
|
||||||
|
type ReportTemplateRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
entity: ReportTemplateEntity;
|
||||||
|
config: unknown;
|
||||||
|
isShared: boolean;
|
||||||
|
ownerId: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getReportTemplateDelegate(db: unknown) {
|
||||||
|
return (db as {
|
||||||
|
reportTemplate: {
|
||||||
|
findMany: (args: unknown) => Promise<ReportTemplateRecord[]>;
|
||||||
|
findUnique: (args: unknown) => Promise<{ ownerId: string } | null>;
|
||||||
|
update: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
|
||||||
|
upsert: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
|
||||||
|
delete: (args: unknown) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
}).reportTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const reportRouter = createTRPCRouter({
|
export const reportRouter = createTRPCRouter({
|
||||||
|
listTemplates: controllerProcedure.query(async ({ ctx }) => {
|
||||||
|
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||||
|
const templates = await reportTemplate.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ ownerId: ctx.dbUser!.id },
|
||||||
|
{ isShared: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ name: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
entity: true,
|
||||||
|
config: true,
|
||||||
|
isShared: true,
|
||||||
|
ownerId: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return templates.map((template: ReportTemplateRecord) => ({
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
entity: fromTemplateEntity(template.entity),
|
||||||
|
config: ReportTemplateConfigSchema.parse(template.config),
|
||||||
|
isShared: template.isShared,
|
||||||
|
isOwner: template.ownerId === ctx.dbUser!.id,
|
||||||
|
updatedAt: template.updatedAt,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
|
||||||
|
saveTemplate: controllerProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
description: z.string().trim().max(500).optional(),
|
||||||
|
isShared: z.boolean().default(false),
|
||||||
|
config: ReportTemplateConfigSchema,
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||||
|
const payload = input.config as unknown as Prisma.InputJsonValue;
|
||||||
|
const entity = toTemplateEntity(input.config.entity);
|
||||||
|
|
||||||
|
if (input.id) {
|
||||||
|
const existing = await reportTemplate.findUnique({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reportTemplate.update({
|
||||||
|
where: { id: input.id },
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
entity,
|
||||||
|
config: payload,
|
||||||
|
isShared: input.isShared,
|
||||||
|
},
|
||||||
|
select: { id: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return reportTemplate.upsert({
|
||||||
|
where: {
|
||||||
|
ownerId_name: {
|
||||||
|
ownerId: ctx.dbUser!.id,
|
||||||
|
name: input.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
description: input.description,
|
||||||
|
entity,
|
||||||
|
config: payload,
|
||||||
|
isShared: input.isShared,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
ownerId: ctx.dbUser!.id,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
entity,
|
||||||
|
config: payload,
|
||||||
|
isShared: input.isShared,
|
||||||
|
},
|
||||||
|
select: { id: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteTemplate: controllerProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const reportTemplate = getReportTemplateDelegate(ctx.db);
|
||||||
|
const existing = await reportTemplate.findUnique({
|
||||||
|
where: { id: input.id },
|
||||||
|
select: { ownerId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing || existing.ownerId !== ctx.dbUser!.id) {
|
||||||
|
throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await reportTemplate.delete({ where: { id: input.id } });
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return available columns for a given entity type.
|
* Return available columns for a given entity type.
|
||||||
*/
|
*/
|
||||||
getAvailableColumns: controllerProcedure
|
getAvailableColumns: controllerProcedure
|
||||||
.input(z.object({ entity: z.enum(["resource", "project", "assignment"]) }))
|
.input(z.object({ entity: reportEntitySchema }))
|
||||||
.query(({ input }) => {
|
.query(({ input }) => {
|
||||||
const columns = COLUMN_MAP[input.entity];
|
const columns = COLUMN_MAP[input.entity];
|
||||||
if (!columns) {
|
if (!columns) {
|
||||||
@@ -285,40 +528,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
getReportData: controllerProcedure
|
getReportData: controllerProcedure
|
||||||
.input(ReportInputSchema)
|
.input(ReportInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
|
return executeReportQuery(ctx.db, input);
|
||||||
|
|
||||||
const select = buildSelect(entity, columns);
|
|
||||||
const where = buildWhere(entity, filters);
|
|
||||||
|
|
||||||
// Build orderBy (only scalar fields)
|
|
||||||
let orderBy: Record<string, string> | undefined;
|
|
||||||
if (sortBy) {
|
|
||||||
const validField = getValidScalarField(entity, sortBy);
|
|
||||||
if (validField) {
|
|
||||||
orderBy = { [validField]: sortDir };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelDelegate = getModelDelegate(ctx.db, entity);
|
|
||||||
|
|
||||||
const [rawRows, totalCount] = await Promise.all([
|
|
||||||
(modelDelegate as any).findMany({
|
|
||||||
select,
|
|
||||||
where,
|
|
||||||
...(orderBy ? { orderBy } : {}),
|
|
||||||
take: limit,
|
|
||||||
skip: offset,
|
|
||||||
}),
|
|
||||||
(modelDelegate as any).count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Flatten nested relations into dot-notation keys
|
|
||||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
|
||||||
|
|
||||||
// Ensure column order matches request (plus id)
|
|
||||||
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
|
|
||||||
|
|
||||||
return { rows, columns: outputColumns, totalCount };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -329,33 +539,12 @@ export const reportRouter = createTRPCRouter({
|
|||||||
limit: z.number().int().min(1).max(50000).default(5000),
|
limit: z.number().int().min(1).max(50000).default(5000),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { entity, columns, filters, sortBy, sortDir, limit } = input;
|
const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
|
||||||
|
const rows = result.rows;
|
||||||
const select = buildSelect(entity, columns);
|
const outputColumns = result.columns;
|
||||||
const where = buildWhere(entity, filters);
|
|
||||||
|
|
||||||
let orderBy: Record<string, string> | undefined;
|
|
||||||
if (sortBy) {
|
|
||||||
const validField = getValidScalarField(entity, sortBy);
|
|
||||||
if (validField) {
|
|
||||||
orderBy = { [validField]: sortDir };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelDelegate = getModelDelegate(ctx.db, entity);
|
|
||||||
|
|
||||||
const rawRows = await (modelDelegate as any).findMany({
|
|
||||||
select,
|
|
||||||
where,
|
|
||||||
...(orderBy ? { orderBy } : {}),
|
|
||||||
take: limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
|
||||||
const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
|
|
||||||
|
|
||||||
// Build CSV
|
// Build CSV
|
||||||
const entityColumns = COLUMN_MAP[entity];
|
const entityColumns = COLUMN_MAP[input.entity];
|
||||||
const headerLabels = outputColumns.map((key) => {
|
const headerLabels = outputColumns.map((key) => {
|
||||||
const def = entityColumns.find((c) => c.key === key);
|
const def = entityColumns.find((c) => c.key === key);
|
||||||
return def?.label ?? key;
|
return def?.label ?? key;
|
||||||
@@ -372,6 +561,385 @@ export const reportRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ReportInput = z.infer<typeof ReportInputSchema>;
|
||||||
|
type FilterInput = z.infer<typeof FilterSchema>;
|
||||||
|
|
||||||
|
async function executeReportQuery(
|
||||||
|
db: any,
|
||||||
|
input: ReportInput,
|
||||||
|
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
||||||
|
if (input.entity === "resource_month") {
|
||||||
|
return executeResourceMonthReport(db, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
|
||||||
|
const select = buildSelect(entity, columns);
|
||||||
|
const where = buildWhere(entity, filters);
|
||||||
|
|
||||||
|
let orderBy: Record<string, string> | undefined;
|
||||||
|
if (sortBy) {
|
||||||
|
const validField = getValidScalarField(entity, sortBy);
|
||||||
|
if (validField) {
|
||||||
|
orderBy = { [validField]: sortDir };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelDelegate = getModelDelegate(db, entity);
|
||||||
|
const [rawRows, totalCount] = await Promise.all([
|
||||||
|
(modelDelegate as any).findMany({
|
||||||
|
select,
|
||||||
|
where,
|
||||||
|
...(orderBy ? { orderBy } : {}),
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
}),
|
||||||
|
(modelDelegate as any).count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = (rawRows as Record<string, unknown>[]).map((row) => flattenRow(row));
|
||||||
|
const outputColumns = ["id", ...columns.filter((column) => column !== "id")];
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows.map((row) => pickColumns(row, outputColumns)),
|
||||||
|
columns: outputColumns,
|
||||||
|
totalCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeResourceMonthReport(
|
||||||
|
db: any,
|
||||||
|
input: ReportInput,
|
||||||
|
): Promise<{ rows: Record<string, unknown>[]; columns: string[]; totalCount: number }> {
|
||||||
|
const periodMonth = input.periodMonth ?? new Date().toISOString().slice(0, 7);
|
||||||
|
const [year, month] = periodMonth.split("-").map(Number) as [number, number];
|
||||||
|
const periodStart = new Date(Date.UTC(year, month - 1, 1));
|
||||||
|
const periodEnd = new Date(Date.UTC(year, month, 0));
|
||||||
|
|
||||||
|
const resources = await db.resource.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eid: true,
|
||||||
|
displayName: true,
|
||||||
|
email: true,
|
||||||
|
chapter: true,
|
||||||
|
resourceType: true,
|
||||||
|
isActive: true,
|
||||||
|
chgResponsibility: true,
|
||||||
|
rolledOff: true,
|
||||||
|
departed: true,
|
||||||
|
lcrCents: true,
|
||||||
|
ucrCents: true,
|
||||||
|
currency: true,
|
||||||
|
fte: true,
|
||||||
|
availability: true,
|
||||||
|
chargeabilityTarget: true,
|
||||||
|
federalState: true,
|
||||||
|
countryId: true,
|
||||||
|
metroCityId: true,
|
||||||
|
country: { select: { code: true, name: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
|
orgUnit: { select: { name: true } },
|
||||||
|
managementLevelGroup: { select: { name: true, targetPercentage: true } },
|
||||||
|
managementLevel: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { displayName: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const resourceIds = resources.map((resource: any) => resource.id);
|
||||||
|
const [bookings, contexts] = await Promise.all([
|
||||||
|
resourceIds.length > 0
|
||||||
|
? listAssignmentBookings(db, {
|
||||||
|
startDate: periodStart,
|
||||||
|
endDate: periodEnd,
|
||||||
|
resourceIds,
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
loadResourceDailyAvailabilityContexts(
|
||||||
|
db,
|
||||||
|
resources.map((resource: any) => ({
|
||||||
|
id: resource.id,
|
||||||
|
availability: resource.availability as WeekdayAvailability,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
})),
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows = resources.map((resource: any) => {
|
||||||
|
const availability = resource.availability as WeekdayAvailability;
|
||||||
|
const context = contexts.get(resource.id);
|
||||||
|
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
||||||
|
const baseWorkingDays = countEffectiveWorkingDays({
|
||||||
|
availability,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
context: undefined,
|
||||||
|
});
|
||||||
|
const effectiveWorkingDays = countEffectiveWorkingDays({
|
||||||
|
availability,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const baseAvailableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
context: undefined,
|
||||||
|
});
|
||||||
|
const sahHours = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
|
const holidayDates = [...(context?.holidayDates ?? new Set<string>())];
|
||||||
|
const publicHolidayWorkdayCount = holidayDates.reduce((count, isoDate) => (
|
||||||
|
count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
|
||||||
|
), 0);
|
||||||
|
const publicHolidayHoursDeduction = holidayDates.reduce((sum, isoDate) => (
|
||||||
|
sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`))
|
||||||
|
), 0);
|
||||||
|
|
||||||
|
let absenceDayEquivalent = 0;
|
||||||
|
let absenceHoursDeduction = 0;
|
||||||
|
for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
|
||||||
|
const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`));
|
||||||
|
if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
absenceDayEquivalent += fraction;
|
||||||
|
absenceHoursDeduction += dayHours * fraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualBookedHours = resourceBookings
|
||||||
|
.filter((booking) => isChargeabilityActualBooking(booking, false))
|
||||||
|
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
context,
|
||||||
|
}), 0);
|
||||||
|
const expectedBookedHours = resourceBookings
|
||||||
|
.filter((booking) => isChargeabilityRelevantProject(booking.project, true))
|
||||||
|
.reduce((sum, booking) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
context,
|
||||||
|
}), 0);
|
||||||
|
|
||||||
|
const targetPct = resource.managementLevelGroup?.targetPercentage != null
|
||||||
|
? resource.managementLevelGroup.targetPercentage * 100
|
||||||
|
: resource.chargeabilityTarget;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${resource.id}:${periodMonth}`,
|
||||||
|
resourceId: resource.id,
|
||||||
|
monthKey: periodMonth,
|
||||||
|
periodStart: periodStart.toISOString(),
|
||||||
|
periodEnd: periodEnd.toISOString(),
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
email: resource.email,
|
||||||
|
chapter: resource.chapter,
|
||||||
|
resourceType: resource.resourceType,
|
||||||
|
isActive: resource.isActive,
|
||||||
|
chgResponsibility: resource.chgResponsibility,
|
||||||
|
rolledOff: resource.rolledOff,
|
||||||
|
departed: resource.departed,
|
||||||
|
countryCode: resource.country?.code ?? null,
|
||||||
|
countryName: resource.country?.name ?? null,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityName: resource.metroCity?.name ?? null,
|
||||||
|
orgUnitName: resource.orgUnit?.name ?? null,
|
||||||
|
managementLevelGroupName: resource.managementLevelGroup?.name ?? null,
|
||||||
|
managementLevelName: resource.managementLevel?.name ?? null,
|
||||||
|
fte: roundMetric(resource.fte),
|
||||||
|
lcrCents: resource.lcrCents,
|
||||||
|
ucrCents: resource.ucrCents,
|
||||||
|
currency: resource.currency,
|
||||||
|
monthlyChargeabilityTargetPct: roundMetric(targetPct),
|
||||||
|
monthlyTargetHours: roundMetric((sahHours * targetPct) / 100),
|
||||||
|
monthlyBaseWorkingDays: roundMetric(baseWorkingDays),
|
||||||
|
monthlyEffectiveWorkingDays: roundMetric(effectiveWorkingDays),
|
||||||
|
monthlyBaseAvailableHours: roundMetric(baseAvailableHours),
|
||||||
|
monthlySahHours: roundMetric(sahHours),
|
||||||
|
monthlyPublicHolidayCount: holidayDates.length,
|
||||||
|
monthlyPublicHolidayWorkdayCount: publicHolidayWorkdayCount,
|
||||||
|
monthlyPublicHolidayHoursDeduction: roundMetric(publicHolidayHoursDeduction),
|
||||||
|
monthlyAbsenceDayEquivalent: roundMetric(absenceDayEquivalent),
|
||||||
|
monthlyAbsenceHoursDeduction: roundMetric(absenceHoursDeduction),
|
||||||
|
monthlyActualBookedHours: roundMetric(actualBookedHours),
|
||||||
|
monthlyExpectedBookedHours: roundMetric(expectedBookedHours),
|
||||||
|
monthlyActualChargeabilityPct: roundMetric(sahHours > 0 ? (actualBookedHours / sahHours) * 100 : 0),
|
||||||
|
monthlyExpectedChargeabilityPct: roundMetric(sahHours > 0 ? (expectedBookedHours / sahHours) * 100 : 0),
|
||||||
|
monthlyUnassignedHours: roundMetric(Math.max(0, sahHours - actualBookedHours)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredRows = rows.filter((row: Record<string, unknown>) => input.filters.every((filter) => matchesInMemoryFilter(
|
||||||
|
row,
|
||||||
|
filter,
|
||||||
|
RESOURCE_MONTH_COLUMNS,
|
||||||
|
)));
|
||||||
|
const sortedRows = sortInMemoryRows(filteredRows, input.sortBy, input.sortDir, RESOURCE_MONTH_COLUMNS);
|
||||||
|
const totalCount = sortedRows.length;
|
||||||
|
const pagedRows = sortedRows.slice(input.offset, input.offset + input.limit);
|
||||||
|
const outputColumns = ["id", ...input.columns.filter((column) => column !== "id")];
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: pagedRows.map((row) => pickColumns(row, outputColumns)),
|
||||||
|
columns: outputColumns,
|
||||||
|
totalCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFilterValue(def: ColumnDef | undefined, value: string): unknown {
|
||||||
|
if (!def) return value;
|
||||||
|
if (def.dataType === "number") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
|
}
|
||||||
|
if (def.dataType === "boolean") {
|
||||||
|
return value === "true";
|
||||||
|
}
|
||||||
|
if (def.dataType === "date") {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesInMemoryFilter(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
filter: FilterInput,
|
||||||
|
columns: ColumnDef[],
|
||||||
|
): boolean {
|
||||||
|
const def = columns.find((column) => column.key === filter.field);
|
||||||
|
if (!def) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowValueRaw = row[filter.field];
|
||||||
|
const rowValue = def.dataType === "date" && typeof rowValueRaw === "string"
|
||||||
|
? new Date(rowValueRaw).getTime()
|
||||||
|
: rowValueRaw;
|
||||||
|
const parsedFilterValue = parseFilterValue(def, filter.value);
|
||||||
|
|
||||||
|
if (parsedFilterValue === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (filter.op) {
|
||||||
|
case "eq":
|
||||||
|
return rowValue === parsedFilterValue;
|
||||||
|
case "neq":
|
||||||
|
return rowValue !== parsedFilterValue;
|
||||||
|
case "gt":
|
||||||
|
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue > parsedFilterValue;
|
||||||
|
case "lt":
|
||||||
|
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue < parsedFilterValue;
|
||||||
|
case "gte":
|
||||||
|
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue >= parsedFilterValue;
|
||||||
|
case "lte":
|
||||||
|
return typeof rowValue === "number" && typeof parsedFilterValue === "number" && rowValue <= parsedFilterValue;
|
||||||
|
case "contains":
|
||||||
|
return typeof rowValue === "string" && rowValue.toLowerCase().includes(filter.value.toLowerCase());
|
||||||
|
case "in":
|
||||||
|
return filter.value.split(",").map((value) => value.trim()).includes(String(rowValue ?? ""));
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortInMemoryRows(
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
sortBy: string | undefined,
|
||||||
|
sortDir: "asc" | "desc",
|
||||||
|
columns: ColumnDef[],
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
if (!sortBy) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const def = columns.find((column) => column.key === sortBy);
|
||||||
|
if (!def) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = sortDir === "asc" ? 1 : -1;
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
const leftValue = left[sortBy];
|
||||||
|
const rightValue = right[sortBy];
|
||||||
|
|
||||||
|
if (leftValue == null && rightValue == null) return 0;
|
||||||
|
if (leftValue == null) return 1;
|
||||||
|
if (rightValue == null) return -1;
|
||||||
|
|
||||||
|
if (def.dataType === "number") {
|
||||||
|
return direction * (Number(leftValue) - Number(rightValue));
|
||||||
|
}
|
||||||
|
if (def.dataType === "boolean") {
|
||||||
|
return direction * (Number(Boolean(leftValue)) - Number(Boolean(rightValue)));
|
||||||
|
}
|
||||||
|
if (def.dataType === "date") {
|
||||||
|
return direction * (new Date(String(leftValue)).getTime() - new Date(String(rightValue)).getTime());
|
||||||
|
}
|
||||||
|
return direction * String(leftValue).localeCompare(String(rightValue), "de");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(columns.map((column) => [column, row[column]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMetric(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTemplateEntity(entity: EntityKey): ReportTemplateEntity {
|
||||||
|
switch (entity) {
|
||||||
|
case "resource":
|
||||||
|
return ReportTemplateEntity.RESOURCE;
|
||||||
|
case "project":
|
||||||
|
return ReportTemplateEntity.PROJECT;
|
||||||
|
case "assignment":
|
||||||
|
return ReportTemplateEntity.ASSIGNMENT;
|
||||||
|
case "resource_month":
|
||||||
|
return ReportTemplateEntity.RESOURCE_MONTH;
|
||||||
|
default:
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromTemplateEntity(entity: ReportTemplateEntity): EntityKey {
|
||||||
|
switch (entity) {
|
||||||
|
case ReportTemplateEntity.RESOURCE:
|
||||||
|
return "resource";
|
||||||
|
case ReportTemplateEntity.PROJECT:
|
||||||
|
return "project";
|
||||||
|
case ReportTemplateEntity.ASSIGNMENT:
|
||||||
|
return "assignment";
|
||||||
|
case ReportTemplateEntity.RESOURCE_MONTH:
|
||||||
|
return "resource_month";
|
||||||
|
default:
|
||||||
|
throw new TRPCError({ code: "BAD_REQUEST", message: `Unknown entity: ${entity}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve the Prisma model delegate from entity key. */
|
/** Resolve the Prisma model delegate from entity key. */
|
||||||
function getModelDelegate(db: any, entity: EntityKey) {
|
function getModelDelegate(db: any, entity: EntityKey) {
|
||||||
switch (entity) {
|
switch (entity) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from "@capakraken/application";
|
} from "@capakraken/application";
|
||||||
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@capakraken/shared";
|
||||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||||
import { computeChargeability } from "@capakraken/engine";
|
|
||||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +16,12 @@ import {
|
|||||||
getAnonymizationDirectory,
|
getAnonymizationDirectory,
|
||||||
resolveResourceIdsByDisplayedEids,
|
resolveResourceIdsByDisplayedEids,
|
||||||
} from "../lib/anonymization.js";
|
} from "../lib/anonymization.js";
|
||||||
|
import {
|
||||||
|
calculateEffectiveAvailableHours,
|
||||||
|
calculateEffectiveBookedHours,
|
||||||
|
calculateEffectiveDayAvailability,
|
||||||
|
loadResourceDailyAvailabilityContexts,
|
||||||
|
} from "../lib/resource-capacity.js";
|
||||||
|
|
||||||
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
||||||
|
|
||||||
@@ -46,6 +51,50 @@ function parseResourceCursor(cursor: string | undefined): { displayName: string;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BookingForCapacity = {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
hoursPerDay: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toIsoDate(value: Date): string {
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDailyBookedHoursMap(
|
||||||
|
bookings: BookingForCapacity[],
|
||||||
|
availability: WeekdayAvailability,
|
||||||
|
context: Parameters<typeof calculateEffectiveBookedHours>[0]["context"],
|
||||||
|
periodStart: Date,
|
||||||
|
periodEnd: Date,
|
||||||
|
): Map<string, number> {
|
||||||
|
const dailyBookedHours = new Map<string, number>();
|
||||||
|
const cursor = new Date(periodStart);
|
||||||
|
cursor.setUTCHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(periodEnd);
|
||||||
|
end.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
while (cursor <= end) {
|
||||||
|
const isoDate = toIsoDate(cursor);
|
||||||
|
const bookedHours = bookings.reduce(
|
||||||
|
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
periodStart: cursor,
|
||||||
|
periodEnd: cursor,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
dailyBookedHours.set(isoDate, bookedHours);
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dailyBookedHours;
|
||||||
|
}
|
||||||
|
|
||||||
export const resourceRouter = createTRPCRouter({
|
export const resourceRouter = createTRPCRouter({
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -1056,10 +1105,14 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
portfolioUrl: true,
|
portfolioUrl: true,
|
||||||
postalCode: true,
|
postalCode: true,
|
||||||
federalState: true,
|
federalState: true,
|
||||||
|
countryId: true,
|
||||||
|
metroCityId: true,
|
||||||
valueScore: true,
|
valueScore: true,
|
||||||
valueScoreBreakdown: true,
|
valueScoreBreakdown: true,
|
||||||
valueScoreUpdatedAt: true,
|
valueScoreUpdatedAt: true,
|
||||||
userId: true,
|
userId: true,
|
||||||
|
country: { select: { code: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const bookings = await listAssignmentBookings(ctx.db, {
|
const bookings = await listAssignmentBookings(ctx.db, {
|
||||||
@@ -1067,30 +1120,67 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
endDate: end,
|
endDate: end,
|
||||||
resourceIds: resources.map((resource) => resource.id),
|
resourceIds: resources.map((resource) => resource.id),
|
||||||
});
|
});
|
||||||
|
const bookingsByResourceId = new Map<string, typeof bookings>();
|
||||||
|
for (const booking of bookings) {
|
||||||
|
if (!booking.resourceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const items = bookingsByResourceId.get(booking.resourceId) ?? [];
|
||||||
|
items.push(booking);
|
||||||
|
bookingsByResourceId.set(booking.resourceId, items);
|
||||||
|
}
|
||||||
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||||
|
ctx.db,
|
||||||
|
resources.map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
availability: resource.availability as unknown as WeekdayAvailability,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
})),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
);
|
||||||
const directory = await getAnonymizationDirectory(ctx.db);
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
|
||||||
return resources.map((r) => {
|
return resources.map((r) => {
|
||||||
const avail = r.availability as Record<string, number>;
|
const availability = r.availability as unknown as WeekdayAvailability;
|
||||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
const context = contexts.get(r.id);
|
||||||
const periodDays =
|
const resourceBookings = (bookingsByResourceId.get(r.id) ?? []).filter(
|
||||||
(end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24) + 1;
|
|
||||||
const availableHours = dailyAvailHours * periodDays * (5 / 7);
|
|
||||||
|
|
||||||
let bookedHours = 0;
|
|
||||||
let isOverbooked = false;
|
|
||||||
const resourceBookings = bookings.filter(
|
|
||||||
(booking) =>
|
(booking) =>
|
||||||
booking.resourceId === r.id &&
|
booking.resourceId === r.id &&
|
||||||
(input.includeProposed || booking.status !== "PROPOSED"),
|
(input.includeProposed || booking.status !== "PROPOSED"),
|
||||||
);
|
);
|
||||||
for (const a of resourceBookings) {
|
const availableHours = calculateEffectiveAvailableHours({
|
||||||
const days =
|
availability,
|
||||||
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
|
periodStart: start,
|
||||||
(1000 * 60 * 60 * 24) +
|
periodEnd: end,
|
||||||
1;
|
context,
|
||||||
bookedHours += a.hoursPerDay * days;
|
});
|
||||||
if (a.hoursPerDay > dailyAvailHours) isOverbooked = true;
|
const bookedHours = resourceBookings.reduce(
|
||||||
}
|
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
periodStart: start,
|
||||||
|
periodEnd: end,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const dailyBookedHours = buildDailyBookedHoursMap(resourceBookings, availability, context, start, end);
|
||||||
|
const isOverbooked = Array.from(dailyBookedHours.entries()).some(([isoDate, hours]) => {
|
||||||
|
const date = new Date(`${isoDate}T00:00:00.000Z`);
|
||||||
|
const dayCapacity = calculateEffectiveDayAvailability({
|
||||||
|
availability,
|
||||||
|
date,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
return dayCapacity > 0 && hours > dayCapacity;
|
||||||
|
});
|
||||||
|
|
||||||
const utilizationPercent =
|
const utilizationPercent =
|
||||||
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
||||||
@@ -1125,6 +1215,11 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
chapter: true,
|
chapter: true,
|
||||||
chargeabilityTarget: true,
|
chargeabilityTarget: true,
|
||||||
availability: true,
|
availability: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
|
country: { select: { code: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const bookings = await listAssignmentBookings(ctx.db, {
|
const bookings = await listAssignmentBookings(ctx.db, {
|
||||||
@@ -1132,10 +1227,25 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
endDate: end,
|
endDate: end,
|
||||||
resourceIds: resources.map((resource) => resource.id),
|
resourceIds: resources.map((resource) => resource.id),
|
||||||
});
|
});
|
||||||
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||||
|
ctx.db,
|
||||||
|
resources.map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
availability: resource.availability as unknown as WeekdayAvailability,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
})),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
);
|
||||||
const directory = await getAnonymizationDirectory(ctx.db);
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
|
||||||
return resources.map((r) => {
|
return resources.map((r) => {
|
||||||
const avail = r.availability as unknown as WeekdayAvailability;
|
const avail = r.availability as unknown as WeekdayAvailability;
|
||||||
|
const context = contexts.get(r.id);
|
||||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
||||||
|
|
||||||
const actualAllocs = resourceBookings.filter((booking) =>
|
const actualAllocs = resourceBookings.filter((booking) =>
|
||||||
@@ -1146,8 +1256,42 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
isChargeabilityRelevantProject(booking.project, true),
|
isChargeabilityRelevantProject(booking.project, true),
|
||||||
);
|
);
|
||||||
|
|
||||||
const actual = computeChargeability(avail, actualAllocs, start, end);
|
const availableHours = calculateEffectiveAvailableHours({
|
||||||
const expected = computeChargeability(avail, expectedAllocs, start, end);
|
availability: avail,
|
||||||
|
periodStart: start,
|
||||||
|
periodEnd: end,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const actualBookedHours = actualAllocs.reduce(
|
||||||
|
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability: avail,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
periodStart: start,
|
||||||
|
periodEnd: end,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const expectedBookedHours = expectedAllocs.reduce(
|
||||||
|
(sum, booking) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability: avail,
|
||||||
|
startDate: booking.startDate,
|
||||||
|
endDate: booking.endDate,
|
||||||
|
hoursPerDay: booking.hoursPerDay,
|
||||||
|
periodStart: start,
|
||||||
|
periodEnd: end,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const actualChargeability = availableHours > 0
|
||||||
|
? Math.round((actualBookedHours / availableHours) * 100)
|
||||||
|
: 0;
|
||||||
|
const expectedChargeability = availableHours > 0
|
||||||
|
? Math.round((expectedBookedHours / availableHours) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
return anonymizeResource({
|
return anonymizeResource({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -1155,9 +1299,9 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
displayName: r.displayName,
|
displayName: r.displayName,
|
||||||
chapter: r.chapter,
|
chapter: r.chapter,
|
||||||
chargeabilityTarget: r.chargeabilityTarget,
|
chargeabilityTarget: r.chargeabilityTarget,
|
||||||
actualChargeability: actual.chargeability,
|
actualChargeability,
|
||||||
expectedChargeability: expected.chargeability,
|
expectedChargeability,
|
||||||
availableHours: actual.availableHours,
|
availableHours: Math.round(availableHours),
|
||||||
}, directory);
|
}, directory);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -1208,7 +1352,10 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
const today = new Date(now);
|
||||||
|
today.setUTCHours(0, 0, 0, 0);
|
||||||
|
const thirtyDaysFromNow = new Date(today);
|
||||||
|
thirtyDaysFromNow.setUTCDate(thirtyDaysFromNow.getUTCDate() + 29);
|
||||||
|
|
||||||
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean };
|
||||||
|
|
||||||
@@ -1223,6 +1370,11 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
skills: true,
|
skills: true,
|
||||||
availability: true,
|
availability: true,
|
||||||
chargeabilityTarget: true,
|
chargeabilityTarget: true,
|
||||||
|
countryId: true,
|
||||||
|
federalState: true,
|
||||||
|
metroCityId: true,
|
||||||
|
country: { select: { code: true } },
|
||||||
|
metroCity: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1232,7 +1384,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
where: {
|
where: {
|
||||||
resourceId: { in: allResourceIds },
|
resourceId: { in: allResourceIds },
|
||||||
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
|
status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] },
|
||||||
endDate: { gte: now },
|
endDate: { gte: today },
|
||||||
startDate: { lte: thirtyDaysFromNow },
|
startDate: { lte: thirtyDaysFromNow },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -1242,41 +1394,78 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
hoursPerDay: true,
|
hoursPerDay: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||||
|
ctx.db,
|
||||||
|
resources.map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
availability: resource.availability as unknown as WeekdayAvailability,
|
||||||
|
countryId: resource.countryId,
|
||||||
|
countryCode: resource.country?.code,
|
||||||
|
federalState: resource.federalState,
|
||||||
|
metroCityId: resource.metroCityId,
|
||||||
|
metroCityName: resource.metroCity?.name,
|
||||||
|
})),
|
||||||
|
today,
|
||||||
|
thirtyDaysFromNow,
|
||||||
|
);
|
||||||
|
const assignmentsByResourceId = new Map<string, typeof assignments>();
|
||||||
|
for (const assignment of assignments) {
|
||||||
|
const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
|
||||||
|
items.push(assignment);
|
||||||
|
assignmentsByResourceId.set(assignment.resourceId, items);
|
||||||
|
}
|
||||||
|
|
||||||
// Build utilization map (simple: booked hours per day / available hours per day)
|
// Build utilization map with holiday-aware daily capacity over the next 30 days.
|
||||||
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
const utilizationMap = new Map<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||||
for (const r of resources) {
|
for (const r of resources) {
|
||||||
const avail = r.availability as Record<string, number>;
|
const availability = r.availability as unknown as WeekdayAvailability;
|
||||||
const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5;
|
const context = contexts.get(r.id);
|
||||||
const resourceAssignments = assignments.filter((a) => a.resourceId === r.id);
|
const resourceAssignments = assignmentsByResourceId.get(r.id) ?? [];
|
||||||
|
const todayAvailableHours = calculateEffectiveAvailableHours({
|
||||||
|
availability,
|
||||||
|
periodStart: today,
|
||||||
|
periodEnd: today,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
const todayBookedHours = resourceAssignments.reduce(
|
||||||
|
(sum, assignment) => sum + calculateEffectiveBookedHours({
|
||||||
|
availability,
|
||||||
|
startDate: assignment.startDate,
|
||||||
|
endDate: assignment.endDate,
|
||||||
|
hoursPerDay: assignment.hoursPerDay,
|
||||||
|
periodStart: today,
|
||||||
|
periodEnd: today,
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const utilizationPercent = todayAvailableHours > 0
|
||||||
|
? Math.round((todayBookedHours / todayAvailableHours) * 100)
|
||||||
|
: 0;
|
||||||
|
const dailyBookedHours = buildDailyBookedHoursMap(
|
||||||
|
resourceAssignments,
|
||||||
|
availability,
|
||||||
|
context,
|
||||||
|
today,
|
||||||
|
thirtyDaysFromNow,
|
||||||
|
);
|
||||||
|
|
||||||
// Current daily booked hours (assignments overlapping today)
|
|
||||||
let todayBooked = 0;
|
|
||||||
for (const a of resourceAssignments) {
|
|
||||||
if (a.startDate <= now && a.endDate >= now) {
|
|
||||||
todayBooked += a.hoursPerDay;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0;
|
|
||||||
|
|
||||||
// Find earliest date when resource has capacity (within 30 days)
|
|
||||||
let earliestAvailableDate: Date | null = null;
|
let earliestAvailableDate: Date | null = null;
|
||||||
const checkDate = new Date(now);
|
const checkDate = new Date(today);
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
const day = checkDate.getDay();
|
const dayAvailableHours = calculateEffectiveDayAvailability({
|
||||||
if (day !== 0 && day !== 6) {
|
availability,
|
||||||
let dayBooked = 0;
|
date: checkDate,
|
||||||
for (const a of resourceAssignments) {
|
context,
|
||||||
if (a.startDate <= checkDate && a.endDate >= checkDate) {
|
});
|
||||||
dayBooked += a.hoursPerDay;
|
if (dayAvailableHours > 0) {
|
||||||
}
|
const dayBookedHours = dailyBookedHours.get(toIsoDate(checkDate)) ?? 0;
|
||||||
}
|
if (dayBookedHours < dayAvailableHours * 0.8) {
|
||||||
if (dayBooked < dailyAvailHours * 0.8) {
|
|
||||||
earliestAvailableDate = new Date(checkDate);
|
earliestAvailableDate = new Date(checkDate);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkDate.setDate(checkDate.getDate() + 1);
|
checkDate.setUTCDate(checkDate.getUTCDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
|
utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate });
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user