feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SESSION_DIR = path.join(process.cwd(), '.claude-flow', 'sessions');
|
||||
const WORKSPACE_ROOT = fs.realpathSync(process.cwd());
|
||||
const SESSION_DIR = path.join(WORKSPACE_ROOT, '.claude-flow', 'sessions');
|
||||
const SESSION_FILE = path.join(SESSION_DIR, 'current.json');
|
||||
|
||||
const commands = {
|
||||
@@ -16,7 +17,7 @@ const commands = {
|
||||
const session = {
|
||||
id: sessionId,
|
||||
startedAt: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
cwd: WORKSPACE_ROOT,
|
||||
context: {},
|
||||
metrics: {
|
||||
edits: 0,
|
||||
|
||||
@@ -10,6 +10,8 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const WORKSPACE_ROOT = fs.realpathSync(process.cwd());
|
||||
|
||||
// Configuration
|
||||
const CONFIG = {
|
||||
enabled: true,
|
||||
@@ -62,9 +64,9 @@ function getUserInfo() {
|
||||
// Get learning stats from memory database
|
||||
function getLearningStats() {
|
||||
const memoryPaths = [
|
||||
path.join(process.cwd(), '.swarm', 'memory.db'),
|
||||
path.join(process.cwd(), '.claude', 'memory.db'),
|
||||
path.join(process.cwd(), 'data', 'memory.db'),
|
||||
path.join(WORKSPACE_ROOT, '.swarm', 'memory.db'),
|
||||
path.join(WORKSPACE_ROOT, '.claude', 'memory.db'),
|
||||
path.join(WORKSPACE_ROOT, 'data', 'memory.db'),
|
||||
];
|
||||
|
||||
let patterns = 0;
|
||||
@@ -90,7 +92,7 @@ function getLearningStats() {
|
||||
}
|
||||
|
||||
// Also check for session files
|
||||
const sessionsPath = path.join(process.cwd(), '.claude', 'sessions');
|
||||
const sessionsPath = path.join(WORKSPACE_ROOT, '.claude', 'sessions');
|
||||
if (fs.existsSync(sessionsPath)) {
|
||||
try {
|
||||
const sessionFiles = fs.readdirSync(sessionsPath).filter(f => f.endsWith('.json'));
|
||||
@@ -132,7 +134,7 @@ function getV3Progress() {
|
||||
// Get security status based on actual scans
|
||||
function getSecurityStatus() {
|
||||
// Check for security scan results in memory
|
||||
const scanResultsPath = path.join(process.cwd(), '.claude', 'security-scans');
|
||||
const scanResultsPath = path.join(WORKSPACE_ROOT, '.claude', 'security-scans');
|
||||
let cvesFixed = 0;
|
||||
const totalCves = 3;
|
||||
|
||||
@@ -147,7 +149,7 @@ function getSecurityStatus() {
|
||||
}
|
||||
|
||||
// Also check .swarm/security for audit results
|
||||
const auditPath = path.join(process.cwd(), '.swarm', 'security');
|
||||
const auditPath = path.join(WORKSPACE_ROOT, '.swarm', 'security');
|
||||
if (fs.existsSync(auditPath)) {
|
||||
try {
|
||||
const audits = fs.readdirSync(auditPath).filter(f => f.includes('audit'));
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Claude Flow V3 - Real-time Swarm Activity Monitor
|
||||
# Continuously monitors and updates metrics based on running processes
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)"
|
||||
METRICS_DIR="$PROJECT_ROOT/.claude-flow/metrics"
|
||||
UPDATE_SCRIPT="$SCRIPT_DIR/update-v3-progress.sh"
|
||||
|
||||
@@ -208,4 +208,4 @@ case "${1:-check}" in
|
||||
echo "Use '$0 help' for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
esac
|
||||
|
||||
@@ -7,6 +7,7 @@ node_modules/
|
||||
# Build outputs
|
||||
.next/
|
||||
.next-e2e/
|
||||
**/.next.*
|
||||
**/.next.root-owned.*
|
||||
dist/
|
||||
build/
|
||||
@@ -70,3 +71,4 @@ packages/db/prisma/migrations/*
|
||||
# Never commit workbook source files
|
||||
*.xls
|
||||
*.xlsx
|
||||
.gstack/
|
||||
|
||||
@@ -6,19 +6,52 @@ test.describe("Resources", () => {
|
||||
await page.fill('input[type="email"]', "manager@capakraken.dev");
|
||||
await page.fill('input[type="password"]', "manager123");
|
||||
await page.click('button[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/(dashboard|resources)/);
|
||||
await page.goto("/resources");
|
||||
await expect(page).toHaveURL(/\/resources/);
|
||||
});
|
||||
|
||||
test("shows resources list", async ({ page }) => {
|
||||
await expect(page.locator("h1")).toContainText("Resources");
|
||||
await expect(page.getByRole("heading", { name: "Resources" })).toBeVisible();
|
||||
await expect(page.locator("table")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can search resources", async ({ page }) => {
|
||||
const rows = page.locator("tbody tr");
|
||||
await expect(rows.first()).toBeVisible();
|
||||
|
||||
const firstRowText = (await rows.first().textContent()) ?? "";
|
||||
const searchTerm = firstRowText
|
||||
.split(/\s+/)
|
||||
.map((token) => token.replace(/[^A-Za-z0-9@._-]/g, "").trim())
|
||||
.find((token) => token.length >= 3) ?? "EMP";
|
||||
|
||||
const searchInput = page.locator('input[type="search"]');
|
||||
await searchInput.fill("EMP-001");
|
||||
await searchInput.fill(searchTerm);
|
||||
await page.waitForTimeout(500);
|
||||
// Should show filtered results
|
||||
await expect(page.locator("tbody tr")).toHaveCount(1);
|
||||
await expect(searchInput).toHaveValue(searchTerm);
|
||||
await expect(page.getByText(`Search: "${searchTerm}"`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows a not-found state for a missing resource detail page", async ({ page }) => {
|
||||
await page.goto("/resources/does-not-exist");
|
||||
|
||||
await expect(page.getByText("Resource not found.")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Back to resources" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows a generic load error when the resource detail query fails", async ({ page }) => {
|
||||
await page.route("**/api/trpc/resource.getById**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: { message: "boom" } }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/resources/test-resource-id");
|
||||
|
||||
await expect(page.getByText("This resource could not be loaded right now.")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Back to resources" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -7,12 +8,16 @@ import { fileURLToPath } from "node:url";
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = resolve(currentDir, "../../..");
|
||||
const webRoot = resolve(currentDir, "..");
|
||||
const runtimeEnvPath = resolve(currentDir, ".playwright-runtime.json");
|
||||
const webEnvLocal = resolve(webRoot, ".env.local");
|
||||
const webEnvBackup = resolve(webRoot, ".env.local.e2e-backup");
|
||||
const webDistDir = ".next-e2e";
|
||||
const webDistDirPath = resolve(webRoot, webDistDir);
|
||||
const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs";
|
||||
const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110";
|
||||
const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`;
|
||||
const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`;
|
||||
const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true";
|
||||
const composeProjectName = `capakraken-e2e-${process.pid}`;
|
||||
const managedEnvKeys = [
|
||||
"DATABASE_URL",
|
||||
@@ -68,12 +73,24 @@ function applyEnv(env) {
|
||||
}
|
||||
|
||||
function writeManagedWebEnv(rootEnv) {
|
||||
if (existsSync(webEnvBackup)) {
|
||||
if (!manageWebEnvFile) {
|
||||
restoreWebEnv();
|
||||
return;
|
||||
}
|
||||
|
||||
if (existsSync(webEnvBackup) && isManagedEnvFile(webEnvBackup)) {
|
||||
rmSync(webEnvBackup, { force: true });
|
||||
}
|
||||
|
||||
if (existsSync(webEnvLocal)) {
|
||||
renameSync(webEnvLocal, webEnvBackup);
|
||||
if (isManagedEnvFile(webEnvLocal)) {
|
||||
rmSync(webEnvLocal, { force: true });
|
||||
} else {
|
||||
if (existsSync(webEnvBackup)) {
|
||||
rmSync(webEnvBackup, { force: true });
|
||||
}
|
||||
renameSync(webEnvLocal, webEnvBackup);
|
||||
}
|
||||
}
|
||||
|
||||
const contents = managedEnvKeys
|
||||
@@ -84,16 +101,41 @@ function writeManagedWebEnv(rootEnv) {
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
writeFileSync(webEnvLocal, `${contents}\n`, "utf8");
|
||||
writeFileSync(webEnvLocal, `${managedEnvBanner}\n${contents}\n`, "utf8");
|
||||
}
|
||||
|
||||
function restoreWebEnv() {
|
||||
if (existsSync(webEnvLocal)) {
|
||||
if (existsSync(webEnvLocal) && isManagedEnvFile(webEnvLocal)) {
|
||||
rmSync(webEnvLocal, { force: true });
|
||||
}
|
||||
|
||||
if (existsSync(webEnvBackup)) {
|
||||
renameSync(webEnvBackup, webEnvLocal);
|
||||
if (isManagedEnvFile(webEnvBackup)) {
|
||||
rmSync(webEnvBackup, { force: true });
|
||||
} else {
|
||||
renameSync(webEnvBackup, webEnvLocal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let restoredManagedEnv = false;
|
||||
|
||||
function restoreWebEnvOnce() {
|
||||
if (restoredManagedEnv) {
|
||||
return;
|
||||
}
|
||||
|
||||
restoredManagedEnv = true;
|
||||
restoreWebEnv();
|
||||
rmSync(runtimeEnvPath, { force: true });
|
||||
}
|
||||
|
||||
function isManagedEnvFile(filePath) {
|
||||
try {
|
||||
const contents = readFileSync(filePath, "utf8");
|
||||
return contents.includes(managedEnvBanner) || contents.includes("E2E_TEST_MODE=true");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,15 +349,33 @@ if (!/(^|_)(test|e2e|ci)$/u.test(playwrightDatabaseName)) {
|
||||
process.env.DATABASE_URL = playwrightDatabaseUrl;
|
||||
process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl;
|
||||
process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort);
|
||||
process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName;
|
||||
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.NEXTAUTH_SECRET = e2eAuthSecret;
|
||||
process.env.AUTH_SECRET = e2eAuthSecret;
|
||||
process.env.NEXT_DIST_DIR = webDistDir;
|
||||
process.env.E2E_TEST_MODE = "true";
|
||||
writeFileSync(
|
||||
runtimeEnvPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
PLAYWRIGHT_DATABASE_URL: process.env.PLAYWRIGHT_DATABASE_URL,
|
||||
POSTGRES_TEST_PORT: process.env.POSTGRES_TEST_PORT,
|
||||
BASE_URL: e2eBaseUrl,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
writeManagedWebEnv(rootEnv);
|
||||
process.on("exit", restoreWebEnvOnce);
|
||||
|
||||
try {
|
||||
await cleanupStaleE2eArtifacts();
|
||||
@@ -333,19 +393,19 @@ try {
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => {
|
||||
restoreWebEnv();
|
||||
restoreWebEnvOnce();
|
||||
void cleanupComposeProject();
|
||||
server.kill(signal);
|
||||
});
|
||||
}
|
||||
|
||||
server.on("exit", async (code) => {
|
||||
restoreWebEnv();
|
||||
restoreWebEnvOnce();
|
||||
await cleanupComposeProject();
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
} catch (error) {
|
||||
restoreWebEnv();
|
||||
restoreWebEnvOnce();
|
||||
await cleanupComposeProject();
|
||||
throw error;
|
||||
}
|
||||
|
||||
+1274
-3
File diff suppressed because it is too large
Load Diff
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
/// <reference path="./.next-e2e/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { checkChargeabilityAlerts } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -35,7 +36,7 @@ export async function GET(request: Request) {
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[cron/chargeability-alerts] Error:", error);
|
||||
logger.error({ error, route: "/api/cron/chargeability-alerts" }, "Chargeability alert cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { checkPendingEstimateReminders } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -37,7 +38,7 @@ export async function GET(request: Request) {
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[cron/estimate-reminders] Error:", error);
|
||||
logger.error({ error, route: "/api/cron/estimate-reminders" }, "Estimate reminder cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { createNotificationsForUsers } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { createConnection } from "net";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -123,7 +124,7 @@ export async function GET(request: Request) {
|
||||
{ status: allHealthy ? 200 : 503 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[cron/health-check] Error:", error);
|
||||
logger.error({ error, route: "/api/cron/health-check" }, "Health check cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { autoImportPublicHolidays } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -49,7 +50,7 @@ export async function GET(request: Request) {
|
||||
skippedExisting: result.skippedExisting,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[cron/public-holidays] Error:", error);
|
||||
logger.error({ error, route: "/api/cron/public-holidays", year }, "Public holiday import cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@capakraken/db";
|
||||
import { createNotificationsForUsers } from "@capakraken/api";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
@@ -87,7 +88,7 @@ function scanPackageJson(): Finding[] {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[security-audit] Error scanning package.json:", error);
|
||||
logger.error({ error, route: "/api/cron/security-audit" }, "Failed to scan package manifests for security audit");
|
||||
}
|
||||
|
||||
return findings;
|
||||
@@ -149,7 +150,7 @@ export async function GET(request: Request) {
|
||||
scannedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[cron/security-audit] Error:", error);
|
||||
logger.error({ error, route: "/api/cron/security-audit" }, "Security audit cron failed");
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Internal error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -45,8 +45,19 @@ const handler = async (req: NextRequest) => {
|
||||
};
|
||||
|
||||
if (process.env["NODE_ENV"] === "development") {
|
||||
options.onError = ({ path, error }: { path?: string; error: { message: string } }) => {
|
||||
console.error(`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
|
||||
options.onError = ({
|
||||
path,
|
||||
error,
|
||||
}: {
|
||||
path?: string;
|
||||
error: { message: string; code?: string };
|
||||
}) => {
|
||||
const label = `tRPC ${path ?? "<no-path>"}`;
|
||||
if (error.code === "NOT_FOUND") {
|
||||
console.warn(`⚠️ ${label}: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
console.error(`❌ ${label}: ${error.message}`);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { CommentEntityType } from "@capakraken/shared";
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
@@ -7,6 +8,11 @@ import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js";
|
||||
import { CommentInput } from "./CommentInput.js";
|
||||
import { sanitizeHtml } from "~/lib/sanitize.js";
|
||||
|
||||
interface CommentTarget {
|
||||
entityType: CommentEntityType;
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
interface CommentAuthor {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -32,7 +38,7 @@ interface CommentItem {
|
||||
}
|
||||
|
||||
interface CommentThreadProps {
|
||||
entityId: string;
|
||||
commentTarget: CommentTarget;
|
||||
}
|
||||
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
@@ -110,18 +116,16 @@ function CommentBody({ body }: { body: string }) {
|
||||
|
||||
function SingleComment({
|
||||
comment,
|
||||
entityId,
|
||||
commentTarget,
|
||||
isReply = false,
|
||||
}: {
|
||||
comment: CommentItem | CommentReply;
|
||||
entityId: string;
|
||||
commentTarget: CommentTarget;
|
||||
isReply?: boolean;
|
||||
}) {
|
||||
const [showReplyInput, setShowReplyInput] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
const commentTarget = { entityType: "estimate" as const, entityId };
|
||||
|
||||
const createMutation = trpc.comment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
setShowReplyInput(false);
|
||||
@@ -212,17 +216,17 @@ function SingleComment({
|
||||
{/* Inline reply input */}
|
||||
{showReplyInput && (
|
||||
<div className="mt-3">
|
||||
<CommentInput
|
||||
entityType={commentTarget.entityType}
|
||||
entityId={entityId}
|
||||
parentId={comment.id}
|
||||
onSubmit={(replyBody) => {
|
||||
createMutation.mutate({
|
||||
entityType: commentTarget.entityType,
|
||||
entityId,
|
||||
parentId: comment.id,
|
||||
body: replyBody,
|
||||
});
|
||||
<CommentInput
|
||||
entityType={commentTarget.entityType}
|
||||
entityId={commentTarget.entityId}
|
||||
parentId={comment.id}
|
||||
onSubmit={(replyBody) => {
|
||||
createMutation.mutate({
|
||||
entityType: commentTarget.entityType,
|
||||
entityId: commentTarget.entityId,
|
||||
parentId: comment.id,
|
||||
body: replyBody,
|
||||
});
|
||||
}}
|
||||
onCancel={() => setShowReplyInput(false)}
|
||||
isSubmitting={createMutation.isPending}
|
||||
@@ -255,7 +259,7 @@ function SingleComment({
|
||||
<SingleComment
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
entityId={entityId}
|
||||
commentTarget={commentTarget}
|
||||
isReply
|
||||
/>
|
||||
))}
|
||||
@@ -265,9 +269,8 @@ function SingleComment({
|
||||
);
|
||||
}
|
||||
|
||||
export function CommentThread({ entityId }: CommentThreadProps) {
|
||||
export function CommentThread({ commentTarget }: CommentThreadProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const commentTarget = { entityType: "estimate" as const, entityId };
|
||||
|
||||
const commentsQuery = trpc.comment.list.useQuery(
|
||||
commentTarget,
|
||||
@@ -308,7 +311,7 @@ export function CommentThread({ entityId }: CommentThreadProps) {
|
||||
<SingleComment
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
entityId={entityId}
|
||||
commentTarget={commentTarget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -318,11 +321,11 @@ export function CommentThread({ entityId }: CommentThreadProps) {
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<CommentInput
|
||||
entityType={commentTarget.entityType}
|
||||
entityId={entityId}
|
||||
entityId={commentTarget.entityId}
|
||||
onSubmit={(body) => {
|
||||
createMutation.mutate({
|
||||
entityType: commentTarget.entityType,
|
||||
entityId,
|
||||
entityId: commentTarget.entityId,
|
||||
body,
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -42,6 +42,19 @@ type BudgetForecastRow = {
|
||||
pctUsed: number;
|
||||
activeAssignmentCount?: number;
|
||||
calendarLocations?: BudgetForecastLocation[];
|
||||
derivation?: {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
calendarContextCount: number;
|
||||
holidayAwareAssignmentCount: number;
|
||||
fallbackAssignmentCount: number;
|
||||
baseBurnRateCents: number;
|
||||
adjustedBurnRateCents: number;
|
||||
publicHolidayDayEquivalent: number;
|
||||
publicHolidayCostDeductionCents: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceCostDeductionCents: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function formatCurrency(cents: number | undefined): string {
|
||||
@@ -49,6 +62,11 @@ function formatCurrency(cents: number | undefined): string {
|
||||
return `${(cents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €`;
|
||||
}
|
||||
|
||||
function formatDayEquivalent(value: number | undefined): string {
|
||||
if (value === undefined) return "—";
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
}
|
||||
|
||||
function formatLocation(location: BudgetForecastLocation): string {
|
||||
const parts = [
|
||||
location.countryCode ?? location.countryName ?? null,
|
||||
@@ -65,7 +83,7 @@ function SummaryCard({
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
helper: 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">
|
||||
@@ -73,7 +91,9 @@ function SummaryCard({
|
||||
{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>
|
||||
{helper ? (
|
||||
<div className="mt-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">{helper}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +133,9 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
acc.remainingCents += row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents);
|
||||
acc.burnRate += row.burnRate;
|
||||
acc.activeAssignmentCount += row.activeAssignmentCount ?? 0;
|
||||
acc.baseBurnRateCents += row.derivation?.baseBurnRateCents ?? row.burnRate;
|
||||
acc.publicHolidayCostDeductionCents += row.derivation?.publicHolidayCostDeductionCents ?? 0;
|
||||
acc.absenceCostDeductionCents += row.derivation?.absenceCostDeductionCents ?? 0;
|
||||
return acc;
|
||||
}, {
|
||||
budgetCents: 0,
|
||||
@@ -120,6 +143,9 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
remainingCents: 0,
|
||||
burnRate: 0,
|
||||
activeAssignmentCount: 0,
|
||||
baseBurnRateCents: 0,
|
||||
publicHolidayCostDeductionCents: 0,
|
||||
absenceCostDeductionCents: 0,
|
||||
}), [rows]);
|
||||
|
||||
if (isLoading && !data) {
|
||||
@@ -154,22 +180,26 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<SummaryCard
|
||||
label="Projects"
|
||||
value={String(rows.length)}
|
||||
helper={`${totals.activeAssignmentCount} active assignments in scope`}
|
||||
{...(showDetails ? { helper: `${totals.activeAssignmentCount} active assignments in scope` } : {})}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Budget"
|
||||
value={formatCurrency(totals.budgetCents)}
|
||||
helper={`${formatCurrency(totals.spentCents)} spent`}
|
||||
{...(showDetails ? { helper: `${formatCurrency(totals.spentCents)} spent` } : {})}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Remaining"
|
||||
value={formatCurrency(totals.remainingCents)}
|
||||
helper={`${rows.filter((row) => row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`}
|
||||
{...(showDetails
|
||||
? { 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"
|
||||
{...(showDetails ? {
|
||||
helper: `Base ${formatCurrency(totals.baseBurnRateCents)} · Hol -${formatCurrency(totals.publicHolidayCostDeductionCents)} · Abs -${formatCurrency(totals.absenceCostDeductionCents)}`,
|
||||
} : {})}
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1">
|
||||
@@ -200,15 +230,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</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>
|
||||
{row.derivation ? (
|
||||
<>
|
||||
<div>{row.derivation.calendarContextCount} calendar bases</div>
|
||||
<div>
|
||||
{row.derivation.holidayAwareAssignmentCount} holiday-aware
|
||||
{row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{row.calendarLocations && row.calendarLocations.length > 0 ? (
|
||||
@@ -254,7 +290,22 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</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.derivation ? (
|
||||
<>
|
||||
<div>
|
||||
Base {formatCurrency(row.derivation.baseBurnRateCents)} {"->"} Adj {formatCurrency(row.derivation.adjustedBurnRateCents)}
|
||||
</div>
|
||||
<div>
|
||||
Hol -{formatCurrency(row.derivation.publicHolidayCostDeductionCents)} ({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d) · Abs -{formatCurrency(row.derivation.absenceCostDeductionCents)} ({formatDayEquivalent(row.derivation.absenceDayEquivalent)}d)
|
||||
</div>
|
||||
<div>
|
||||
{row.derivation.holidayAwareAssignmentCount} holiday-aware assignment{row.derivation.holidayAwareAssignmentCount === 1 ? "" : "s"}
|
||||
{row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<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)}
|
||||
|
||||
@@ -53,6 +53,17 @@ function formatDemandSource(source: DemandDerivation["demandSource"] | undefined
|
||||
return "No demand basis";
|
||||
}
|
||||
|
||||
function renderCalendarBasis(derivation: DemandDerivation): string {
|
||||
if (derivation.calendarLocations.length === 0) {
|
||||
return "No location-based booking basis";
|
||||
}
|
||||
|
||||
return derivation.calendarLocations
|
||||
.slice(0, 2)
|
||||
.map((location) => `${formatLocation(location)} (${formatHours(location.allocatedHours)})`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const showDetails = config.showDetails === true;
|
||||
const groupBy = (config.groupBy as GroupBy) || "project";
|
||||
@@ -198,21 +209,15 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
row.name
|
||||
)}
|
||||
</div>
|
||||
{showDetails && groupBy === "project" && row.derivation ? (
|
||||
{showDetails && 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>
|
||||
<div>{renderCalendarBasis(row.derivation)}</div>
|
||||
{groupBy !== "project" ? (
|
||||
<div>{formatHours(row.derivation.periodWorkingHoursBase)} per 1.0 FTE base</div>
|
||||
) : null}
|
||||
{row.derivation.calendarLocations.length > 2 ? (
|
||||
<div>+ {row.derivation.calendarLocations.length - 2} more calendar contexts</div>
|
||||
) : null}
|
||||
@@ -221,7 +226,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
</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 ? (
|
||||
{showDetails && 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>
|
||||
@@ -262,7 +267,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
|
||||
)}
|
||||
<td className="px-3 py-2 text-right align-top text-gray-500">
|
||||
<div>{row.resourceCount}</div>
|
||||
{showDetails && groupBy === "project" && row.derivation?.calendarLocations.length ? (
|
||||
{showDetails && 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>
|
||||
|
||||
@@ -7,10 +7,23 @@ type PeakTimesChartRow = {
|
||||
label: string;
|
||||
bookedHours: number;
|
||||
capacityHours: number;
|
||||
baseAvailableHours: number;
|
||||
holidayHoursDeduction: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceHoursDeduction: number;
|
||||
utilizationPct: number;
|
||||
remainingHours: number;
|
||||
overbookedHours: number;
|
||||
isCurrentPeriod: boolean;
|
||||
calendarContextCount: number;
|
||||
calendarLocations: Array<{
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
resourceCount: number;
|
||||
effectiveAvailableHours: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
interface PeakTimesChartProps {
|
||||
@@ -26,6 +39,16 @@ function formatHours(value: number): string {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDayEquivalent(value: number): string {
|
||||
return Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
|
||||
}
|
||||
|
||||
function formatLocation(input: PeakTimesChartRow["calendarLocations"][number]): string {
|
||||
const parts = [input.countryCode ?? input.countryName, input.federalState, input.metroCityName]
|
||||
.filter((part): part is string => Boolean(part));
|
||||
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||
}
|
||||
|
||||
function utilizationBarTone(utilizationPct: number): string {
|
||||
if (utilizationPct > 100) return "bg-red-500";
|
||||
if (utilizationPct > 75) return "bg-emerald-500";
|
||||
@@ -132,7 +155,19 @@ export default function PeakTimesChart({
|
||||
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`}
|
||||
title={[
|
||||
`${row.label}: ${row.utilizationPct}% utilization`,
|
||||
`${formatHours(row.bookedHours)}h booked`,
|
||||
`${formatHours(row.capacityHours)}h effective capacity`,
|
||||
`${formatHours(row.baseAvailableHours)}h base`,
|
||||
`${formatHours(row.holidayHoursDeduction)}h holidays`,
|
||||
`${formatHours(row.absenceHoursDeduction)}h absences (${formatDayEquivalent(row.absenceDayEquivalent)}d)`,
|
||||
`${row.calendarContextCount} calendar base${row.calendarContextCount === 1 ? "" : "s"}`,
|
||||
...row.calendarLocations.slice(0, 3).map((location) =>
|
||||
`${formatLocation(location)}: ${location.resourceCount}x, ${formatHours(location.effectiveAvailableHours)}h capacity`),
|
||||
`${formatHours(row.remainingHours)}h free`,
|
||||
`${formatHours(row.overbookedHours)}h overbooked`,
|
||||
].join(", ")}
|
||||
onMouseEnter={() => setHoveredPeriod(row.period)}
|
||||
onMouseLeave={() => setHoveredPeriod((current) => (current === row.period ? null : current))}
|
||||
onClick={() => onSelectedPeriodChange?.(row.period)}
|
||||
|
||||
@@ -10,6 +10,51 @@ import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/Widge
|
||||
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
|
||||
type ProjectHealthRow = {
|
||||
id: string;
|
||||
projectName: string;
|
||||
shortCode: string;
|
||||
status: string;
|
||||
clientId: string | null;
|
||||
clientName: string | null;
|
||||
budgetHealth: number;
|
||||
staffingHealth: number;
|
||||
timelineHealth: number;
|
||||
compositeScore: number;
|
||||
budgetCents?: number | null;
|
||||
spentCents?: number;
|
||||
remainingBudgetCents?: number | null;
|
||||
budgetUtilizationPercent?: number | null;
|
||||
demandHeadcountTotal?: number;
|
||||
demandHeadcountFilled?: number;
|
||||
demandHeadcountOpen?: number;
|
||||
demandRequirementCount?: number;
|
||||
plannedEndDate?: string | Date | null;
|
||||
daysUntilEndDate?: number | null;
|
||||
timelineStatus?: "ON_TRACK" | "DUE_SOON" | "OVERDUE" | "UNSCHEDULED" | null;
|
||||
calendarLocations?: Array<{
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
assignmentCount: number;
|
||||
spentCents: number;
|
||||
}>;
|
||||
derivation?: {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
calendarContextCount: number;
|
||||
holidayAwareAssignmentCount: number;
|
||||
fallbackAssignmentCount: number;
|
||||
baseSpentCents: number;
|
||||
adjustedSpentCents: number;
|
||||
publicHolidayDayEquivalent: number;
|
||||
publicHolidayCostDeductionCents: number;
|
||||
absenceDayEquivalent: number;
|
||||
absenceCostDeductionCents: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function healthDot(value: number): string {
|
||||
if (value >= 70) return "bg-green-500";
|
||||
if (value >= 40) return "bg-amber-400";
|
||||
@@ -69,6 +114,11 @@ function formatLocation(location: {
|
||||
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
|
||||
}
|
||||
|
||||
function formatDayEquivalent(value?: number | null): string {
|
||||
if (value == null) return "—";
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1);
|
||||
}
|
||||
|
||||
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const showDetails = config.showDetails === true;
|
||||
const { clients } = useWidgetFilterOptions({ clients: true });
|
||||
@@ -90,7 +140,7 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const clientId = (config.clientId as string) ?? "";
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const all = data ?? [];
|
||||
const all = (data ?? []) as ProjectHealthRow[];
|
||||
return all.filter((r) => {
|
||||
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
|
||||
if (clientId && r.clientId !== clientId) return false;
|
||||
@@ -174,6 +224,22 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
|
||||
<div>
|
||||
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
|
||||
</div>
|
||||
{row.derivation ? (
|
||||
<>
|
||||
<div>
|
||||
Spend basis: {row.derivation.calendarContextCount} calendar bases · {row.derivation.holidayAwareAssignmentCount} holiday-aware
|
||||
{row.derivation.fallbackAssignmentCount > 0 ? ` · ${row.derivation.fallbackAssignmentCount} fallback` : ""}
|
||||
</div>
|
||||
<div>
|
||||
Base {formatMoney(row.derivation.baseSpentCents)} {"->"} Effective {formatMoney(row.derivation.adjustedSpentCents)}
|
||||
</div>
|
||||
<div>
|
||||
Holidays -{formatMoney(row.derivation.publicHolidayCostDeductionCents)} ({formatDayEquivalent(row.derivation.publicHolidayDayEquivalent)}d)
|
||||
{" · "}
|
||||
Absence -{formatMoney(row.derivation.absenceCostDeductionCents)} ({formatDayEquivalent(row.derivation.absenceDayEquivalent)}d)
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{(row.calendarLocations ?? []).length > 0 ? (
|
||||
<div>
|
||||
Calendar basis: {(row.calendarLocations ?? [])
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { EstimateExportFormat } from "@capakraken/shared";
|
||||
import { clsx } from "clsx";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type {
|
||||
EstimateWorkspaceView,
|
||||
WorkspaceTab,
|
||||
@@ -113,17 +114,19 @@ function ActionNotice({
|
||||
}
|
||||
|
||||
export function EstimateWorkspaceClient({ estimateId }: { estimateId: string }) {
|
||||
const { status: sessionStatus } = useSession();
|
||||
const [tab, setTab] = useState<WorkspaceTab>("overview");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const { canEdit, canViewCosts } = usePermissions();
|
||||
const isPermissionsLoading = sessionStatus === "loading";
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const detailQuery = trpc.estimate.getById.useQuery(
|
||||
{ id: estimateId },
|
||||
{
|
||||
enabled: canViewCosts,
|
||||
enabled: canViewCosts && !isPermissionsLoading,
|
||||
staleTime: 15_000,
|
||||
},
|
||||
);
|
||||
@@ -132,10 +135,16 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
const createRevisionMutation = trpc.estimate.createRevision.useMutation();
|
||||
const createExportMutation = trpc.estimate.createExport.useMutation();
|
||||
const createPlanningHandoffMutation = trpc.estimate.createPlanningHandoff.useMutation();
|
||||
const estimateCommentTarget = { entityType: "estimate" as const, entityId: estimateId };
|
||||
const canLoadCommentCount =
|
||||
canViewCosts
|
||||
&& !isPermissionsLoading
|
||||
&& detailQuery.status === "success"
|
||||
&& detailQuery.data != null;
|
||||
|
||||
const commentCountQuery = trpc.comment.count.useQuery(
|
||||
{ entityType: "estimate", entityId: estimateId },
|
||||
{ enabled: canViewCosts, staleTime: 30_000 },
|
||||
estimateCommentTarget,
|
||||
{ enabled: canLoadCommentCount, staleTime: 30_000 },
|
||||
);
|
||||
const commentCount = commentCountQuery.data ?? 0;
|
||||
|
||||
@@ -281,7 +290,9 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canViewCosts ? (
|
||||
{isPermissionsLoading ? (
|
||||
<EmptyState>Loading estimate workspace...</EmptyState>
|
||||
) : !canViewCosts ? (
|
||||
<EmptyState>Your role can access the estimate list, but not the detailed financial workspace.</EmptyState>
|
||||
) : detailQuery.isLoading ? (
|
||||
<EmptyState>Loading estimate workspace...</EmptyState>
|
||||
@@ -364,7 +375,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
|
||||
<h2 className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
Comments
|
||||
</h2>
|
||||
<CommentThread entityId={estimate.id} />
|
||||
<CommentThread commentTarget={estimateCommentTarget} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildReportWorkbookSheets,
|
||||
buildResourceMonthExplainabilitySheetRows,
|
||||
} from "./reportBuilderExplainability.js";
|
||||
|
||||
describe("reportBuilderExplainability", () => {
|
||||
it("builds a readable explainability sheet for resource month reports", () => {
|
||||
const rows = buildResourceMonthExplainabilitySheetRows(
|
||||
{
|
||||
entity: "resource_month",
|
||||
periodMonth: "2026-01",
|
||||
locationContextColumns: ["countryCode", "federalState"],
|
||||
holidayMetricColumns: ["monthlyPublicHolidayCount"],
|
||||
absenceMetricColumns: ["monthlyAbsenceHoursDeduction"],
|
||||
capacityMetricColumns: ["monthlyBaseAvailableHours", "monthlySahHours"],
|
||||
chargeabilityMetricColumns: ["monthlyActualChargeabilityPct"],
|
||||
missingRecommendedColumns: ["countryName", "monthlyTargetHours"],
|
||||
notes: ["SAH is holiday-adjusted."],
|
||||
},
|
||||
(column) => ({
|
||||
countryCode: "Country Code",
|
||||
federalState: "Federal State",
|
||||
monthlyPublicHolidayCount: "Holiday Dates",
|
||||
monthlyAbsenceHoursDeduction: "Absence Hours Deduction",
|
||||
monthlyBaseAvailableHours: "Base Available Hours",
|
||||
monthlySahHours: "SAH",
|
||||
monthlyActualChargeabilityPct: "Actual Chargeability (%)",
|
||||
countryName: "Country",
|
||||
monthlyTargetHours: "Target Hours",
|
||||
}[column] ?? column),
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
["Resource Month Explainability"],
|
||||
["Period Month", "2026-01"],
|
||||
["Location Context Columns", "Country Code", "Federal State"],
|
||||
["Holiday Metric Columns", "Holiday Dates"],
|
||||
["Absence Metric Columns", "Absence Hours Deduction"],
|
||||
["Capacity Metric Columns", "Base Available Hours", "SAH"],
|
||||
["Chargeability Metric Columns", "Actual Chargeability (%)"],
|
||||
["Missing Recommended Columns", "Country", "Target Hours"],
|
||||
[],
|
||||
["Notes"],
|
||||
["SAH is holiday-adjusted."],
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds grouped report rows and an explainability sheet to workbook output", () => {
|
||||
const sheets = buildReportWorkbookSheets({
|
||||
columns: ["displayName", "monthlySahHours"],
|
||||
rows: [
|
||||
{ displayName: "Alice", monthlySahHours: 160 },
|
||||
{ displayName: "Bob", monthlySahHours: 152 },
|
||||
],
|
||||
groups: [{ key: "BY", label: "Bayern", rowCount: 2, startIndex: 0 }],
|
||||
groupBy: "federalState",
|
||||
explainability: {
|
||||
entity: "resource_month",
|
||||
periodMonth: "2026-01",
|
||||
locationContextColumns: [],
|
||||
holidayMetricColumns: [],
|
||||
absenceMetricColumns: [],
|
||||
capacityMetricColumns: [],
|
||||
chargeabilityMetricColumns: [],
|
||||
missingRecommendedColumns: [],
|
||||
notes: [],
|
||||
},
|
||||
resolveColumnLabel: (column) => ({
|
||||
displayName: "Name",
|
||||
monthlySahHours: "SAH",
|
||||
federalState: "Federal State",
|
||||
}[column] ?? column),
|
||||
});
|
||||
|
||||
expect(sheets[0]).toEqual({
|
||||
name: "Report",
|
||||
rows: [
|
||||
["Name", "SAH"],
|
||||
["Federal State: Bayern (2)", ""],
|
||||
["Alice", 160],
|
||||
["Bob", 152],
|
||||
],
|
||||
});
|
||||
expect(sheets[1]?.name).toBe("Explainability");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
type WorkbookCellValue = boolean | Date | number | string | null | undefined;
|
||||
|
||||
export type ResourceMonthReportExplainability = {
|
||||
entity: "resource_month";
|
||||
periodMonth: string | null;
|
||||
locationContextColumns: string[];
|
||||
holidayMetricColumns: string[];
|
||||
absenceMetricColumns: string[];
|
||||
capacityMetricColumns: string[];
|
||||
chargeabilityMetricColumns: string[];
|
||||
missingRecommendedColumns: string[];
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type ReportExplainability = ResourceMonthReportExplainability;
|
||||
|
||||
export type ReportExportSheet = {
|
||||
name: string;
|
||||
rows: WorkbookCellValue[][];
|
||||
};
|
||||
|
||||
type BuildReportWorkbookSheetsInput = {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
groups: Array<{ key: string; label: string; rowCount: number; startIndex: number }>;
|
||||
groupBy?: string;
|
||||
explainability?: ReportExplainability;
|
||||
resolveColumnLabel: (column: string) => string;
|
||||
};
|
||||
|
||||
export function buildResourceMonthExplainabilitySheetRows(
|
||||
explainability: ReportExplainability,
|
||||
resolveColumnLabel: (column: string) => string,
|
||||
): WorkbookCellValue[][] {
|
||||
const mapLabels = (columns: string[]) => (
|
||||
columns.length > 0 ? columns.map(resolveColumnLabel) : ["none"]
|
||||
);
|
||||
|
||||
return [
|
||||
["Resource Month Explainability"],
|
||||
["Period Month", explainability.periodMonth ?? "current month"],
|
||||
["Location Context Columns", ...mapLabels(explainability.locationContextColumns)],
|
||||
["Holiday Metric Columns", ...mapLabels(explainability.holidayMetricColumns)],
|
||||
["Absence Metric Columns", ...mapLabels(explainability.absenceMetricColumns)],
|
||||
["Capacity Metric Columns", ...mapLabels(explainability.capacityMetricColumns)],
|
||||
["Chargeability Metric Columns", ...mapLabels(explainability.chargeabilityMetricColumns)],
|
||||
["Missing Recommended Columns", ...mapLabels(explainability.missingRecommendedColumns)],
|
||||
[],
|
||||
["Notes"],
|
||||
...explainability.notes.map((note) => [note]),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildReportWorkbookSheets(
|
||||
input: BuildReportWorkbookSheetsInput,
|
||||
): ReportExportSheet[] {
|
||||
const headerRow = input.columns.map(input.resolveColumnLabel);
|
||||
const groupStartByIndex = new Map(
|
||||
input.groups.map((group) => [group.startIndex, group] as const),
|
||||
);
|
||||
const groupByLabel = input.groupBy ? input.resolveColumnLabel(input.groupBy) : null;
|
||||
|
||||
const reportRows: WorkbookCellValue[][] = [headerRow];
|
||||
input.rows.forEach((row, index) => {
|
||||
const group = groupStartByIndex.get(index);
|
||||
if (group && groupByLabel) {
|
||||
reportRows.push([
|
||||
`${groupByLabel}: ${group.label} (${group.rowCount})`,
|
||||
...Array.from({ length: Math.max(0, input.columns.length - 1) }, () => ""),
|
||||
]);
|
||||
}
|
||||
|
||||
reportRows.push(input.columns.map((column) => {
|
||||
const value = row[column];
|
||||
if (value === undefined) {
|
||||
return "";
|
||||
}
|
||||
return value as WorkbookCellValue;
|
||||
}));
|
||||
});
|
||||
|
||||
const sheets: ReportExportSheet[] = [{ name: "Report", rows: reportRows }];
|
||||
if (input.explainability?.entity === "resource_month") {
|
||||
sheets.push({
|
||||
name: "Explainability",
|
||||
rows: buildResourceMonthExplainabilitySheetRows(input.explainability, input.resolveColumnLabel),
|
||||
});
|
||||
}
|
||||
|
||||
return sheets;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const SkillMatrixUpload = dynamic(
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
import { FadeIn } from "~/components/ui/FadeIn.js";
|
||||
import { CommentThread } from "~/components/comments/CommentThread.js";
|
||||
|
||||
interface ResourceDetailProps {
|
||||
resourceId: string;
|
||||
@@ -91,6 +92,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
const resource = _resourceQuery.data as unknown as Resource | undefined;
|
||||
const loadingResource = _resourceQuery.isLoading;
|
||||
const error = _resourceQuery.error;
|
||||
const errorCode = (error as any)?.data?.code as string | undefined;
|
||||
|
||||
// Fetch allocations for this resource (all non-cancelled)
|
||||
const now = new Date();
|
||||
@@ -119,6 +121,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
},
|
||||
{ enabled: !!resourceId },
|
||||
);
|
||||
const vacationList = (vacations ?? []) as Array<{
|
||||
endDate: Date | string;
|
||||
id: string;
|
||||
note?: string | null;
|
||||
startDate: Date | string;
|
||||
status: string;
|
||||
type: string;
|
||||
}>;
|
||||
|
||||
const chargeabilityStatsResult = trpc.resource.getChargeabilityStats.useQuery(
|
||||
{ includeProposed: includeProposedChargeability, resourceId },
|
||||
@@ -143,7 +153,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
if (errorCode === "NOT_FOUND") {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-red-700 text-sm">
|
||||
@@ -154,6 +164,17 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !resource) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6 text-amber-800 text-sm">
|
||||
This resource could not be loaded right now.{" "}
|
||||
<Link href="/resources" className="underline">Back to resources</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const skills = resource.skills as unknown as SkillEntry[];
|
||||
const resourceRoles = (resource as unknown as {
|
||||
resourceRoles?: { isPrimary: boolean; role: { id: string; name: string; color: string | null } }[];
|
||||
@@ -433,6 +454,24 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
onGenerated={async () => { await utils.resource.getById.invalidate({ id: resourceId }); }}
|
||||
/>
|
||||
|
||||
<section
|
||||
id="comments"
|
||||
className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5 scroll-mt-24"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Comments</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Discussion for this resource follows the same visibility as the resource detail itself.
|
||||
</p>
|
||||
</div>
|
||||
<CommentThread
|
||||
commentTarget={{
|
||||
entityType: "resource",
|
||||
entityId: resourceId,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Main Skills Badges */}
|
||||
{mainSkills.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||
@@ -594,11 +633,11 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
</div>
|
||||
{loadingVacations ? (
|
||||
<div className="p-6 text-center text-gray-400 text-sm animate-pulse">Loading…</div>
|
||||
) : (vacations ?? []).length === 0 ? (
|
||||
) : vacationList.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">No vacations recorded.</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{(vacations ?? []).map((v) => {
|
||||
{vacationList.map((v) => {
|
||||
const days =
|
||||
Math.round(
|
||||
(new Date(v.endDate).getTime() - new Date(v.startDate).getTime()) / (1000 * 60 * 60 * 24),
|
||||
|
||||
@@ -13,11 +13,13 @@ import { DateInput } from "~/components/ui/DateInput.js";
|
||||
interface AllocationPopoverProps {
|
||||
allocationId: string;
|
||||
projectId: string;
|
||||
initialAllocation?: AllocationPopoverAssignment | null;
|
||||
onClose: () => void;
|
||||
onOpenPanel: (projectId: string) => void;
|
||||
/** Pixel position relative to the viewport */
|
||||
anchorX: number;
|
||||
anchorY: number;
|
||||
contextDate?: Date;
|
||||
}
|
||||
|
||||
type AllocationPopoverAssignment = Assignment<AllocationLike>;
|
||||
@@ -25,10 +27,12 @@ type AllocationPopoverAssignment = Assignment<AllocationLike>;
|
||||
export function AllocationPopover({
|
||||
allocationId,
|
||||
projectId,
|
||||
initialAllocation = null,
|
||||
onClose,
|
||||
onOpenPanel,
|
||||
anchorX,
|
||||
anchorY,
|
||||
contextDate,
|
||||
}: AllocationPopoverProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
@@ -41,15 +45,22 @@ export function AllocationPopover({
|
||||
|
||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
||||
{ projectId },
|
||||
{ staleTime: 10_000 },
|
||||
{ staleTime: 10_000, enabled: !initialAllocation },
|
||||
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
|
||||
const allocation = allocationView?.assignments.find((entry) => entry.id === allocationId) as AllocationPopoverAssignment | undefined;
|
||||
const allocation = initialAllocation ?? allocationView?.assignments.find((entry) => (
|
||||
entry.id === allocationId
|
||||
|| entry.entityId === allocationId
|
||||
|| entry.sourceAllocationId === allocationId
|
||||
|| getPlanningEntryMutationId(entry) === allocationId
|
||||
)) as AllocationPopoverAssignment | undefined;
|
||||
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const [includeSaturday, setIncludeSaturday] = useState(false);
|
||||
const [role, setRole] = useState("");
|
||||
const [carveStartDate, setCarveStartDate] = useState("");
|
||||
const [carveEndDate, setCarveEndDate] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (allocation) {
|
||||
@@ -59,8 +70,11 @@ export function AllocationPopover({
|
||||
const meta = allocation.metadata as { includeSaturday?: boolean } | null;
|
||||
setIncludeSaturday(meta?.includeSaturday ?? false);
|
||||
setRole(allocation.role ?? "");
|
||||
const defaultCarveDate = contextDate ? toDateInput(contextDate) : "";
|
||||
setCarveStartDate(defaultCarveDate);
|
||||
setCarveEndDate(defaultCarveDate);
|
||||
}
|
||||
}, [allocation]);
|
||||
}, [allocation, contextDate]);
|
||||
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -70,6 +84,14 @@ export function AllocationPopover({
|
||||
},
|
||||
});
|
||||
|
||||
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidateTimeline();
|
||||
void utils.allocation.listView.invalidate();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
function toDateInput(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
@@ -89,7 +111,16 @@ export function AllocationPopover({
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading || !allocation) {
|
||||
function handleCarveRange() {
|
||||
if (!allocation || !carveStartDate || !carveEndDate) return;
|
||||
carveMutation.mutate({
|
||||
allocationId: getPlanningEntryMutationId(allocation),
|
||||
startDate: new Date(carveStartDate),
|
||||
endDate: new Date(carveEndDate),
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
const loadingPopover = (
|
||||
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
|
||||
Loading...
|
||||
@@ -98,13 +129,38 @@ export function AllocationPopover({
|
||||
return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body);
|
||||
}
|
||||
|
||||
if (!allocation) {
|
||||
const missingPopover = (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-xl"
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
The selected booking could not be resolved from the current timeline data.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { onClose(); onOpenPanel(projectId); }}
|
||||
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
Open Project Panel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return typeof document === "undefined" ? missingPopover : createPortal(missingPopover, document.body);
|
||||
}
|
||||
|
||||
const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2);
|
||||
|
||||
const carveDateRangeInvalid =
|
||||
Boolean(carveStartDate && carveEndDate) && carveEndDate < carveStartDate;
|
||||
|
||||
const popover = (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
|
||||
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-b border-gray-100">
|
||||
@@ -114,7 +170,7 @@ export function AllocationPopover({
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="space-y-3 overflow-y-auto p-4">
|
||||
{/* Resource */}
|
||||
<div className="text-xs text-gray-500">
|
||||
Resource: <span className="font-medium text-gray-700">{allocation.resource?.displayName}</span>
|
||||
@@ -182,6 +238,9 @@ export function AllocationPopover({
|
||||
{updateMutation.isError && (
|
||||
<p className="text-xs text-red-600">{updateMutation.error.message}</p>
|
||||
)}
|
||||
{carveMutation.isError && (
|
||||
<p className="text-xs text-red-600">{carveMutation.error.message}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
@@ -203,6 +262,57 @@ export function AllocationPopover({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-100 pt-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-700">Remove Date Range</div>
|
||||
<div className="text-[11px] text-gray-500">
|
||||
{contextDate ? `Prefilled from ${toDateInput(contextDate)}` : "Create a gap or split this booking."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">From</label>
|
||||
<DateInput
|
||||
value={carveStartDate}
|
||||
onChange={setCarveStartDate}
|
||||
min={startDate}
|
||||
max={endDate}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-red-300"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">To</label>
|
||||
<DateInput
|
||||
value={carveEndDate}
|
||||
onChange={setCarveEndDate}
|
||||
min={carveStartDate || startDate}
|
||||
max={endDate}
|
||||
className="w-full border border-gray-200 rounded-lg px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-red-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{carveDateRangeInvalid && (
|
||||
<p className="text-xs text-red-600">End date must be on or after the start date.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCarveRange}
|
||||
disabled={
|
||||
carveMutation.isPending ||
|
||||
!carveStartDate ||
|
||||
!carveEndDate ||
|
||||
carveDateRangeInvalid
|
||||
}
|
||||
className="w-full py-1.5 rounded-lg text-sm font-medium transition-colors bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{carveMutation.isPending ? "Removing…" : "Remove Selected Range"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link to full panel */}
|
||||
<button
|
||||
onClick={() => { onClose(); onOpenPanel(projectId); }}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||||
|
||||
const ENTITY_COMBOBOX_OVERLAY_SELECTOR = "[data-entity-combobox-overlay='true']";
|
||||
|
||||
interface BatchAssignPopoverProps {
|
||||
resourceIds: string[];
|
||||
startDate: Date;
|
||||
@@ -49,13 +51,23 @@ export function BatchAssignPopover({
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (ref.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (target instanceof Element && target.closest(ENTITY_COMBOBOX_OVERLAY_SELECTOR)) {
|
||||
return;
|
||||
}
|
||||
if (ref.current) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
}, [onClose]);
|
||||
|
||||
// Close on ESC
|
||||
@@ -88,7 +100,7 @@ export function BatchAssignPopover({
|
||||
const popover = (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[60] w-[360px] 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="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[9998] w-[360px] 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 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
|
||||
@@ -44,6 +44,7 @@ export function NewAllocationPopover({
|
||||
width: 320,
|
||||
estimatedHeight: 440,
|
||||
onClose,
|
||||
ignoreSelectors: ["[data-entity-combobox-overlay='true']"],
|
||||
});
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
@@ -82,7 +83,7 @@ export function NewAllocationPopover({
|
||||
<div
|
||||
ref={ref}
|
||||
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="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800 dark:shadow-black/40"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
@@ -90,7 +91,7 @@ export function NewAllocationPopover({
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="space-y-3 overflow-y-auto p-4">
|
||||
{/* Date range */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
|
||||
@@ -19,6 +19,7 @@ export type TimelineDisplayMode = "strip" | "bar" | "heatmap";
|
||||
import { addDays } from "./utils.js";
|
||||
import { DEFAULT_FILTERS, type TimelineFilters } from "./TimelineFilter.js";
|
||||
import { DONE_STATUSES } from "./timelineConstants.js";
|
||||
import { toLocalDateKey } from "./timelineAvailability.js";
|
||||
|
||||
// ─── Local timeline types ─────────────────────────────────────────────────────
|
||||
// These re-declare the shapes that the original TimelineView used internally.
|
||||
@@ -133,6 +134,13 @@ export type VacationEntry = {
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
scope?: string | null;
|
||||
calendarName?: string | null;
|
||||
sourceType?: string | null;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
status: string;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
@@ -149,6 +157,13 @@ export type HolidayOverlayEntry = {
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
scope?: string | null;
|
||||
calendarName?: string | null;
|
||||
sourceType?: string | null;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
};
|
||||
|
||||
// ─── Context shape ──────────────────────────────────────────────────────────
|
||||
@@ -224,7 +239,7 @@ export function TimelineProvider({
|
||||
? ((session.user as { role?: string } | undefined)?.role ?? "USER")
|
||||
: null;
|
||||
const isSelfServiceTimeline = role === "USER" || role === "VIEWER";
|
||||
const isRoleLoading = sessionStatus !== "authenticated";
|
||||
const isRoleLoading = sessionStatus === "loading";
|
||||
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
@@ -289,7 +304,7 @@ export function TimelineProvider({
|
||||
const blinkOverbookedDays = appPrefs.blinkOverbookedDays;
|
||||
|
||||
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||
const mountedRef = useRef(false);
|
||||
const initialRefreshKeyRef = useRef<string | null>(null);
|
||||
const timelineQueryInput = {
|
||||
startDate: viewStart,
|
||||
endDate: viewEnd,
|
||||
@@ -338,13 +353,31 @@ export function TimelineProvider({
|
||||
const assignments = entriesView?.assignments ?? [];
|
||||
const demands = entriesView?.demands ?? [];
|
||||
|
||||
const {
|
||||
data: vacationEntries = [],
|
||||
refetch: refetchVacations,
|
||||
} = trpc.vacation.list.useQuery(
|
||||
// Avoid TS deep-instantiation blow-ups on the large TRPC hook type here.
|
||||
const vacationListQuery = trpc.vacation.list.useQuery as unknown as (
|
||||
input: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status: VacationStatus[];
|
||||
limit: number;
|
||||
},
|
||||
options: {
|
||||
placeholderData: (prev: VacationEntry[] | undefined) => VacationEntry[] | undefined;
|
||||
refetchOnWindowFocus: boolean;
|
||||
staleTime: number;
|
||||
},
|
||||
) => {
|
||||
data: VacationEntry[] | undefined;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
const vacationEntriesQuery = vacationListQuery(
|
||||
{ startDate: viewStart, endDate: viewEnd, status: [VacationStatus.APPROVED, VacationStatus.PENDING], limit: 500 },
|
||||
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
|
||||
);
|
||||
const {
|
||||
data: vacationEntries = [],
|
||||
refetch: refetchVacations,
|
||||
} = vacationEntriesQuery;
|
||||
|
||||
const staffHolidayOverlayQuery = trpc.timeline.getHolidayOverlays.useQuery(
|
||||
timelineQueryInput,
|
||||
@@ -370,32 +403,63 @@ export function TimelineProvider({
|
||||
refetch: refetchHolidayOverlays,
|
||||
} = activeHolidayOverlayQuery;
|
||||
|
||||
useEffect(() => {
|
||||
if (mountedRef.current) return;
|
||||
if (isRoleLoading) return;
|
||||
mountedRef.current = true;
|
||||
const initialRefreshKey = useMemo(
|
||||
() =>
|
||||
JSON.stringify({
|
||||
isSelfServiceTimeline,
|
||||
start: viewStart.toISOString(),
|
||||
end: viewEnd.toISOString(),
|
||||
clients: filters.clientIds,
|
||||
projects: filters.projectIds,
|
||||
chapters: filters.chapters,
|
||||
eids: filters.eids,
|
||||
countries: filters.countryCodes,
|
||||
}),
|
||||
[
|
||||
filters.chapters,
|
||||
filters.clientIds,
|
||||
filters.countryCodes,
|
||||
filters.eids,
|
||||
filters.projectIds,
|
||||
isSelfServiceTimeline,
|
||||
viewEnd,
|
||||
viewStart,
|
||||
],
|
||||
);
|
||||
|
||||
// Harden client-side route transitions: the timeline must actively refresh
|
||||
// its core read models once on mount instead of relying on a prefetched shell.
|
||||
useEffect(() => {
|
||||
if (isRoleLoading) return;
|
||||
if (initialRefreshKeyRef.current === initialRefreshKey) return;
|
||||
initialRefreshKeyRef.current = initialRefreshKey;
|
||||
|
||||
// Harden client-side route and filter transitions: refresh the core
|
||||
// read models once per active timeline query context instead of trusting
|
||||
// prefetched or placeholder state to self-heal.
|
||||
void refetchEntriesView();
|
||||
void refetchVacations();
|
||||
void refetchHolidayOverlays();
|
||||
}, [isRoleLoading, refetchEntriesView, refetchHolidayOverlays, refetchVacations]);
|
||||
}, [
|
||||
initialRefreshKey,
|
||||
isRoleLoading,
|
||||
refetchEntriesView,
|
||||
refetchHolidayOverlays,
|
||||
refetchVacations,
|
||||
]);
|
||||
|
||||
const vacationsByResource = useMemo(() => {
|
||||
const map = new Map<string, 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);
|
||||
const start = toLocalDateKey(vacation.startDate);
|
||||
const end = toLocalDateKey(vacation.endDate);
|
||||
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 start = toLocalDateKey(holiday.startDate);
|
||||
const end = toLocalDateKey(holiday.endDate);
|
||||
const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`;
|
||||
if (existingKeys.has(key)) {
|
||||
continue;
|
||||
|
||||
@@ -9,10 +9,21 @@ import {
|
||||
type TimelineAssignmentEntry,
|
||||
type TimelineDemandEntry,
|
||||
} from "./TimelineContext.js";
|
||||
import {
|
||||
applyPointerOffsetPreviewRect,
|
||||
applyVisualOverrides,
|
||||
getDragPointerOffset,
|
||||
type TimelineVisualOverrides,
|
||||
} from "./allocationVisualState.js";
|
||||
import { heatmapColor } from "./heatmapUtils.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { TimelineTooltip } from "./TimelineTooltip.js";
|
||||
import {
|
||||
TimelineTooltip,
|
||||
type DemandHoverData,
|
||||
type HeatmapHoverData,
|
||||
type VacationHoverData,
|
||||
} from "./TimelineTooltip.js";
|
||||
import {
|
||||
ROW_HEIGHT,
|
||||
SUB_LANE_HEIGHT,
|
||||
@@ -24,11 +35,31 @@ import { getProjectColor } from "~/lib/project-colors.js";
|
||||
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
|
||||
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
|
||||
import {
|
||||
buildVacationBlocksByResource,
|
||||
renderVacationBlocks,
|
||||
renderRangeOverlay,
|
||||
renderOverbookingBlink,
|
||||
type VacationBlockInfo,
|
||||
} from "./renderHelpers.js";
|
||||
import {
|
||||
buildDemandHoverData,
|
||||
cancelHoverFrame,
|
||||
collectResourcesWithVacations,
|
||||
scheduleVacationHoverUpdate,
|
||||
updateTooltipPosition,
|
||||
} from "./timelineHover.js";
|
||||
import { buildResourceHeatmapSeries } from "./timelineHeatmap.js";
|
||||
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
||||
import {
|
||||
buildProjectRowMetrics,
|
||||
type ProjectDayMetric,
|
||||
} from "./timelineProjectMetrics.js";
|
||||
import {
|
||||
buildProjectFlatRows,
|
||||
estimateProjectRowHeight,
|
||||
type OpenDemandRowLayout,
|
||||
type ProjectFlatRow,
|
||||
} from "./timelineProjectRows.js";
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,11 +77,13 @@ interface TimelineProjectPanelProps {
|
||||
onOpenPanel: (projectId: string) => void;
|
||||
onOpenDemandClick: (demand: TimelineDemandEntry, anchorX: number, anchorY: number) => void;
|
||||
onAllocationContextMenu: (
|
||||
info: { allocationId: string; projectId: string },
|
||||
info: { allocationId: string; projectId: string; contextDate?: Date },
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void;
|
||||
multiSelectState: MultiSelectState;
|
||||
optimisticAllocations: TimelineVisualOverrides;
|
||||
suppressHoverInteractions: boolean;
|
||||
// Layout from useTimelineLayout
|
||||
CELL_WIDTH: number;
|
||||
dates: Date[];
|
||||
@@ -82,57 +115,7 @@ export interface OpenDemandAssignment {
|
||||
project?: { id: string; name: string; shortCode: string };
|
||||
}
|
||||
|
||||
type HeatmapBreakdownEntry = {
|
||||
projectId: string;
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
hoursPerDay: number;
|
||||
responsiblePerson?: string | null;
|
||||
};
|
||||
|
||||
type HeatmapHoverState = {
|
||||
date: Date;
|
||||
totalH: number;
|
||||
pct: number;
|
||||
breakdown: HeatmapBreakdownEntry[];
|
||||
};
|
||||
|
||||
type ProjectDayMetric = {
|
||||
projH: number;
|
||||
totalH: number;
|
||||
};
|
||||
|
||||
type HeatmapBreakdownAccumulator = {
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
responsiblePerson: string | null;
|
||||
hours: number;
|
||||
};
|
||||
|
||||
type ProjectFlatRow =
|
||||
| {
|
||||
type: "header";
|
||||
key: string;
|
||||
project: NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
|
||||
}
|
||||
| {
|
||||
type: "open-demand";
|
||||
key: string;
|
||||
projectId: string;
|
||||
openDemands: TimelineDemandEntry[];
|
||||
}
|
||||
| {
|
||||
type: "resource";
|
||||
key: string;
|
||||
project: NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
|
||||
resource: NonNullable<
|
||||
ReturnType<typeof useTimelineContext>["projectGroups"]
|
||||
>[number]["resourceRows"][number]["resource"];
|
||||
allocs: TimelineAssignmentEntry[];
|
||||
metricsKey: string;
|
||||
};
|
||||
type HeatmapHoverState = HeatmapHoverData;
|
||||
|
||||
const EMPTY_DAY_METRICS: ProjectDayMetric[] = [];
|
||||
const SVG_XMLNS = "http://www.w3.org/2000/svg";
|
||||
@@ -154,6 +137,8 @@ function TimelineProjectPanelInner({
|
||||
onOpenDemandClick,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
optimisticAllocations,
|
||||
suppressHoverInteractions,
|
||||
CELL_WIDTH,
|
||||
dates,
|
||||
totalCanvasWidth,
|
||||
@@ -175,6 +160,27 @@ function TimelineProjectPanelInner({
|
||||
today,
|
||||
} = useTimelineContext();
|
||||
|
||||
const visualAllocsByResource = useMemo(() => {
|
||||
if (optimisticAllocations.size === 0) return allocsByResource;
|
||||
|
||||
const next = new Map<string, TimelineAssignmentEntry[]>();
|
||||
for (const [resourceId, allocs] of allocsByResource) {
|
||||
next.set(resourceId, applyVisualOverrides(allocs, optimisticAllocations));
|
||||
}
|
||||
return next;
|
||||
}, [allocsByResource, optimisticAllocations]);
|
||||
|
||||
const visualProjectGroups = useMemo(
|
||||
() => projectGroups.map((project) => ({
|
||||
...project,
|
||||
resourceRows: project.resourceRows.map((row) => ({
|
||||
...row,
|
||||
allocs: applyVisualOverrides(row.allocs, optimisticAllocations),
|
||||
})),
|
||||
})),
|
||||
[projectGroups, optimisticAllocations],
|
||||
);
|
||||
|
||||
// ─── Heatmap hover (same mechanism as resource panel) ─────────────────────
|
||||
const heatmapRafRef = useRef<number | null>(null);
|
||||
const lastHeatmapDayRef = useRef<number>(-1);
|
||||
@@ -193,239 +199,65 @@ function TimelineProjectPanelInner({
|
||||
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
|
||||
|
||||
const [heatmapHover, setHeatmapHover] = useState<{
|
||||
date: Date;
|
||||
totalH: number;
|
||||
pct: number;
|
||||
breakdown: HeatmapBreakdownEntry[];
|
||||
} | null>(null);
|
||||
const [vacationHover, setVacationHover] = useState<null | {
|
||||
type: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
}>(null);
|
||||
const [demandHover, setDemandHover] = useState<null | {
|
||||
roleName: string;
|
||||
roleColor: string;
|
||||
projectName: string;
|
||||
projectShortCode?: string | null;
|
||||
requestedHeadcount: number;
|
||||
unfilledHeadcount: number;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
totalHours: number;
|
||||
percentage?: number;
|
||||
status?: string;
|
||||
totalCostCents?: number;
|
||||
dailyCostCents?: number;
|
||||
}>(null);
|
||||
const [heatmapHover, setHeatmapHover] = useState<HeatmapHoverState | null>(null);
|
||||
const [vacationHover, setVacationHover] = useState<VacationHoverData | null>(null);
|
||||
const [demandHover, setDemandHover] = useState<DemandHoverData | null>(null);
|
||||
|
||||
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
|
||||
const dateIndexByTime = new Map<number, number>();
|
||||
dates.forEach((date, index) => {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
dateIndexByTime.set(normalized.getTime(), index);
|
||||
});
|
||||
const resourceCapacityById = useMemo(
|
||||
() => buildResourceCapacitySeries(visualAllocsByResource, vacationsByResource, dates),
|
||||
[dates, vacationsByResource, visualAllocsByResource],
|
||||
);
|
||||
|
||||
const nextHeatmapById = new Map<string, (HeatmapHoverState | null)[]>();
|
||||
const nextTotalHoursById = new Map<string, number[]>();
|
||||
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(
|
||||
() => buildResourceHeatmapSeries(visualAllocsByResource, dates, resourceCapacityById),
|
||||
[dates, resourceCapacityById, visualAllocsByResource],
|
||||
);
|
||||
|
||||
for (const [resourceId, allocs] of allocsByResource) {
|
||||
if (allocs.length === 0) continue;
|
||||
|
||||
const totalHours = new Array<number>(dates.length).fill(0);
|
||||
const breakdownMaps = Array.from({ length: dates.length }, () => new Map<string, HeatmapBreakdownAccumulator>());
|
||||
|
||||
for (const alloc of allocs) {
|
||||
const current = new Date(alloc.startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(alloc.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current.getTime() <= end.getTime()) {
|
||||
const dayIndex = dateIndexByTime.get(current.getTime());
|
||||
if (dayIndex !== undefined) {
|
||||
totalHours[dayIndex] = (totalHours[dayIndex] ?? 0) + alloc.hoursPerDay;
|
||||
|
||||
const dayBreakdown = breakdownMaps[dayIndex];
|
||||
if (!dayBreakdown) {
|
||||
current.setDate(current.getDate() + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = dayBreakdown.get(alloc.projectId);
|
||||
if (existing) {
|
||||
existing.hours += alloc.hoursPerDay;
|
||||
} else {
|
||||
dayBreakdown.set(alloc.projectId, {
|
||||
shortCode: alloc.project.shortCode,
|
||||
projectName: alloc.project.name,
|
||||
orderType: alloc.project.orderType,
|
||||
responsiblePerson:
|
||||
(alloc.project as { responsiblePerson?: string | null }).responsiblePerson ??
|
||||
null,
|
||||
hours: alloc.hoursPerDay,
|
||||
});
|
||||
}
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
nextTotalHoursById.set(resourceId, totalHours);
|
||||
nextHeatmapById.set(
|
||||
resourceId,
|
||||
totalHours.map((totalH, dayIndex) => {
|
||||
if (totalH === 0) return null;
|
||||
|
||||
const dayBreakdown = breakdownMaps[dayIndex];
|
||||
if (!dayBreakdown) return null;
|
||||
|
||||
const breakdown: HeatmapBreakdownEntry[] = [...dayBreakdown.entries()]
|
||||
.map(([projectId, value]) => ({
|
||||
projectId,
|
||||
shortCode: value.shortCode,
|
||||
projectName: value.projectName,
|
||||
orderType: value.orderType,
|
||||
responsiblePerson: value.responsiblePerson,
|
||||
hoursPerDay: value.hours,
|
||||
}))
|
||||
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
|
||||
|
||||
return {
|
||||
date: dates[dayIndex] ?? new Date(),
|
||||
totalH,
|
||||
pct: (totalH / 8) * 100,
|
||||
breakdown,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
resourceHeatmapById: nextHeatmapById,
|
||||
resourceTotalHoursById: nextTotalHoursById,
|
||||
};
|
||||
}, [allocsByResource, dates]);
|
||||
const vacationBlocksByResource = useMemo(
|
||||
() =>
|
||||
buildVacationBlocksByResource(
|
||||
vacationsByResource,
|
||||
filters.showVacations,
|
||||
toLeft,
|
||||
toWidth,
|
||||
CELL_WIDTH,
|
||||
totalCanvasWidth,
|
||||
),
|
||||
[CELL_WIDTH, filters.showVacations, toLeft, toWidth, totalCanvasWidth, vacationsByResource],
|
||||
);
|
||||
|
||||
const projectRowMetrics = useMemo(() => {
|
||||
const dateIndexByTime = new Map<number, number>();
|
||||
dates.forEach((date, index) => {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
dateIndexByTime.set(normalized.getTime(), index);
|
||||
});
|
||||
return buildProjectRowMetrics(
|
||||
dates,
|
||||
visualProjectGroups,
|
||||
resourceTotalHoursById,
|
||||
resourceCapacityById,
|
||||
);
|
||||
}, [dates, resourceCapacityById, resourceTotalHoursById, visualProjectGroups]);
|
||||
|
||||
const nextMetrics = new Map<string, ProjectDayMetric[]>();
|
||||
|
||||
for (const project of projectGroups) {
|
||||
for (const { resource, allocs } of project.resourceRows) {
|
||||
const projectHours = new Array<number>(dates.length).fill(0);
|
||||
|
||||
for (const alloc of allocs) {
|
||||
const current = new Date(alloc.startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(alloc.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current.getTime() <= end.getTime()) {
|
||||
const dayIndex = dateIndexByTime.get(current.getTime());
|
||||
if (dayIndex !== undefined) {
|
||||
projectHours[dayIndex] = (projectHours[dayIndex] ?? 0) + alloc.hoursPerDay;
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const totalHours = resourceTotalHoursById.get(resource.id);
|
||||
nextMetrics.set(
|
||||
`${project.id}:${resource.id}`,
|
||||
projectHours.map((projH, dayIndex) => ({
|
||||
projH,
|
||||
totalH: totalHours?.[dayIndex] ?? 0,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return nextMetrics;
|
||||
}, [dates, projectGroups, resourceTotalHoursById]);
|
||||
|
||||
const flatRows = useMemo(() => {
|
||||
const rows: ProjectFlatRow[] = [];
|
||||
|
||||
for (const project of projectGroups) {
|
||||
rows.push({ type: "header", key: `header-${project.id}`, project });
|
||||
|
||||
const openDemands = openDemandsByProject.get(project.id) ?? [];
|
||||
if (openDemands.length > 0) {
|
||||
rows.push({
|
||||
type: "open-demand",
|
||||
key: `open-demand-${project.id}`,
|
||||
projectId: project.id,
|
||||
openDemands,
|
||||
});
|
||||
}
|
||||
|
||||
for (const { resource, allocs } of project.resourceRows) {
|
||||
rows.push({
|
||||
type: "resource",
|
||||
key: `${project.id}-${resource.id}`,
|
||||
project,
|
||||
resource,
|
||||
allocs,
|
||||
metricsKey: `${project.id}:${resource.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [openDemandsByProject, projectGroups]);
|
||||
const flatRows = useMemo(
|
||||
() => buildProjectFlatRows(visualProjectGroups, openDemandsByProject, optimisticAllocations),
|
||||
[openDemandsByProject, optimisticAllocations, visualProjectGroups],
|
||||
);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: flatRows.length,
|
||||
getScrollElement: () => scrollContainerRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = flatRows[index];
|
||||
if (!row) return ROW_HEIGHT;
|
||||
if (row.type === "header") return PROJECT_HEADER_HEIGHT;
|
||||
if (row.type === "open-demand") {
|
||||
const laneCount = assignDemandLanes(row.openDemands).size > 0
|
||||
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
|
||||
: 1;
|
||||
return Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
}
|
||||
return ROW_HEIGHT;
|
||||
},
|
||||
estimateSize: (index) => estimateProjectRowHeight(flatRows[index]),
|
||||
overscan: 8,
|
||||
getItemKey: (index) => flatRows[index]?.key ?? index,
|
||||
});
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const totalRowHeight = rowVirtualizer.getTotalSize();
|
||||
|
||||
const resourcesWithVacations = useMemo(() => {
|
||||
const result = new Set<string>();
|
||||
for (const [resourceId, vacations] of vacationsByResource) {
|
||||
if (vacations.length > 0) {
|
||||
result.add(resourceId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [vacationsByResource]);
|
||||
const resourcesWithVacations = useMemo(
|
||||
() => collectResourcesWithVacations(vacationsByResource),
|
||||
[vacationsByResource],
|
||||
);
|
||||
|
||||
const handleRowHeatmapMove = useCallback(
|
||||
(e: React.MouseEvent, resourceId: string) => {
|
||||
heatmapTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 52 };
|
||||
if (heatmapTooltipRef.current) {
|
||||
heatmapTooltipRef.current.style.left = `${heatmapTooltipPosRef.current.left}px`;
|
||||
heatmapTooltipRef.current.style.top = `${heatmapTooltipPosRef.current.top}px`;
|
||||
}
|
||||
updateTooltipPosition(heatmapTooltipPosRef, heatmapTooltipRef, e.clientX, e.clientY, 16, -52);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
||||
@@ -477,52 +309,28 @@ function TimelineProjectPanelInner({
|
||||
return;
|
||||
}
|
||||
|
||||
vacationTooltipPosRef.current = { left: e.clientX + 14, top: e.clientY - 8 };
|
||||
if (vacationTooltipRef.current) {
|
||||
vacationTooltipRef.current.style.left = `${vacationTooltipPosRef.current.left}px`;
|
||||
vacationTooltipRef.current.style.top = `${vacationTooltipPosRef.current.top}px`;
|
||||
}
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clientX = e.clientX;
|
||||
if (vacationHoverRafRef.current !== null) return;
|
||||
|
||||
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
||||
vacationHoverRafRef.current = null;
|
||||
const date = xToDate(clientX, rect);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const time = date.getTime();
|
||||
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
||||
const hit =
|
||||
resourceVacations.find((vacation) => {
|
||||
const start = new Date(vacation.startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(vacation.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
return time >= start.getTime() && time <= end.getTime();
|
||||
}) ?? null;
|
||||
|
||||
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
|
||||
if (nextKey === hoveredVacationKeyRef.current) return;
|
||||
|
||||
hoveredVacationKeyRef.current = nextKey;
|
||||
startTransition(() => {
|
||||
setVacationHover(hit);
|
||||
});
|
||||
updateTooltipPosition(vacationTooltipPosRef, vacationTooltipRef, e.clientX, e.clientY, 14, -8);
|
||||
scheduleVacationHoverUpdate({
|
||||
frameRef: vacationHoverRafRef,
|
||||
hoveredKeyRef: hoveredVacationKeyRef,
|
||||
resourceId,
|
||||
clientX: e.clientX,
|
||||
rect: e.currentTarget.getBoundingClientRect(),
|
||||
xToDate,
|
||||
vacations: vacationsByResource.get(resourceId) ?? [],
|
||||
onHoverChange: (hit) => {
|
||||
startTransition(() => {
|
||||
setVacationHover(hit);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[resourcesWithVacations, vacationsByResource, xToDate],
|
||||
);
|
||||
|
||||
const clearHoverTooltips = useCallback(() => {
|
||||
if (heatmapRafRef.current !== null) {
|
||||
cancelAnimationFrame(heatmapRafRef.current);
|
||||
heatmapRafRef.current = null;
|
||||
}
|
||||
if (vacationHoverRafRef.current !== null) {
|
||||
cancelAnimationFrame(vacationHoverRafRef.current);
|
||||
vacationHoverRafRef.current = null;
|
||||
}
|
||||
cancelHoverFrame(heatmapRafRef);
|
||||
cancelHoverFrame(vacationHoverRafRef);
|
||||
|
||||
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
||||
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
||||
@@ -543,37 +351,10 @@ function TimelineProjectPanelInner({
|
||||
|
||||
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);
|
||||
updateTooltipPosition(demandTooltipPosRef, demandTooltipRef, e.clientX, e.clientY, 16, -36);
|
||||
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
setDemandHover(buildDemandHoverData(demand));
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -581,13 +362,18 @@ function TimelineProjectPanelInner({
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
|
||||
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
|
||||
cancelHoverFrame(heatmapRafRef);
|
||||
cancelHoverFrame(vacationHoverRafRef);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (projectGroups.length === 0) {
|
||||
useEffect(() => {
|
||||
if (!suppressHoverInteractions) return;
|
||||
clearHoverTooltips();
|
||||
}, [clearHoverTooltips, suppressHoverInteractions]);
|
||||
|
||||
if (visualProjectGroups.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
|
||||
@@ -677,11 +463,14 @@ function TimelineProjectPanelInner({
|
||||
{gridLines}
|
||||
{projWidth > 0 && projLeft < totalCanvasWidth && (
|
||||
<div
|
||||
data-timeline-entry-type="project-bar"
|
||||
data-timeline-drag-preview="project-shift"
|
||||
data-timeline-project-id={project.id}
|
||||
className={clsx(
|
||||
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75 text-white",
|
||||
"absolute rounded flex items-center px-2 gap-1.5 text-white",
|
||||
isThisProjectShifting
|
||||
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
|
||||
: "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
|
||||
: "cursor-grab transition-[opacity,box-shadow] duration-75 hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
|
||||
)}
|
||||
style={{
|
||||
left: projLeft + 2,
|
||||
@@ -689,18 +478,35 @@ function TimelineProjectPanelInner({
|
||||
top: 8,
|
||||
height: 24,
|
||||
backgroundColor: customColor ?? projectColor.hex + "CC",
|
||||
...(isThisProjectShifting
|
||||
? {
|
||||
transform: `translateX(${getDragPointerOffset(
|
||||
dragState.pointerDeltaX,
|
||||
dragState.daysDelta,
|
||||
CELL_WIDTH,
|
||||
)}px)`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!dragState.isDragging) onOpenPanel(project.id);
|
||||
}}
|
||||
onMouseDown={(e) =>
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!dragState.isDragging) {
|
||||
onOpenPanel(project.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onProjectBarMouseDown(e, {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
startDate: project.startDate,
|
||||
endDate: project.endDate,
|
||||
})
|
||||
}
|
||||
});
|
||||
}}
|
||||
onTouchStart={(e) =>
|
||||
onProjectBarTouchStart(e, {
|
||||
projectId: project.id,
|
||||
@@ -709,7 +515,13 @@ function TimelineProjectPanelInner({
|
||||
endDate: project.endDate,
|
||||
})
|
||||
}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!dragState.isDragging) {
|
||||
onOpenPanel(project.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-semibold truncate">{project.name}</span>
|
||||
</div>
|
||||
@@ -720,7 +532,8 @@ function TimelineProjectPanelInner({
|
||||
})()
|
||||
) : row.type === "open-demand" ? (
|
||||
renderOpenDemandRow(
|
||||
row.openDemands,
|
||||
row.openDemandCount,
|
||||
row.layout,
|
||||
row.projectId,
|
||||
CELL_WIDTH,
|
||||
totalCanvasWidth,
|
||||
@@ -735,6 +548,7 @@ function TimelineProjectPanelInner({
|
||||
clearHoverTooltips,
|
||||
multiSelectState,
|
||||
allocDragState,
|
||||
suppressHoverInteractions,
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
@@ -788,6 +602,7 @@ function TimelineProjectPanelInner({
|
||||
});
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (suppressHoverInteractions) return;
|
||||
handleRowHeatmapMove(e, row.resource.id);
|
||||
handleRowVacationHover(e, row.resource.id);
|
||||
}}
|
||||
@@ -812,29 +627,20 @@ function TimelineProjectPanelInner({
|
||||
onAllocTouchStart,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
suppressHoverInteractions,
|
||||
)}
|
||||
{filters.showVacations &&
|
||||
renderVacationBlocks(
|
||||
(vacationsByResource.get(row.resource.id) ?? []).reduce<VacationBlockInfo[]>(
|
||||
(acc, v) => {
|
||||
const vStart = new Date(v.startDate);
|
||||
const vEnd = new Date(v.endDate);
|
||||
const left = toLeft(vStart);
|
||||
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
|
||||
if (width > 0 && left < totalCanvasWidth) {
|
||||
acc.push({ vacation: v, left, width });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
),
|
||||
vacationBlocksByResource.get(row.resource.id) ?? [],
|
||||
ROW_HEIGHT,
|
||||
)}
|
||||
{blinkOverbookedDays &&
|
||||
renderOverbookingBlink(
|
||||
allocsByResource.get(row.resource.id) ?? [],
|
||||
visualAllocsByResource.get(row.resource.id) ?? [],
|
||||
dates,
|
||||
CELL_WIDTH,
|
||||
resourceCapacityById.get(row.resource.id)?.capacityHoursByDay,
|
||||
resourceCapacityById.get(row.resource.id)?.bookingFactorsByDay,
|
||||
)}
|
||||
{renderRangeOverlay(
|
||||
rangeState,
|
||||
@@ -870,41 +676,9 @@ function TimelineProjectPanelInner({
|
||||
|
||||
// ─── Pure render functions ──────────────────────────────────────────────────
|
||||
|
||||
/** Assign lane indices to demands so overlapping bars don't stack on top of each other. */
|
||||
function assignDemandLanes(
|
||||
demands: TimelineDemandEntry[],
|
||||
): Map<string, number> {
|
||||
const laneMap = new Map<string, number>();
|
||||
// Each lane tracks the latest end-date occupying it
|
||||
const laneEnds: Date[] = [];
|
||||
|
||||
// Sort by start date for greedy lane assignment
|
||||
const sorted = [...demands].sort(
|
||||
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
|
||||
);
|
||||
|
||||
for (const d of sorted) {
|
||||
const start = new Date(d.startDate);
|
||||
let assigned = -1;
|
||||
for (let i = 0; i < laneEnds.length; i++) {
|
||||
if (laneEnds[i]! < start) {
|
||||
assigned = i;
|
||||
laneEnds[i] = new Date(d.endDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (assigned === -1) {
|
||||
assigned = laneEnds.length;
|
||||
laneEnds.push(new Date(d.endDate));
|
||||
}
|
||||
laneMap.set(d.id, assigned);
|
||||
}
|
||||
|
||||
return laneMap;
|
||||
}
|
||||
|
||||
function renderOpenDemandRow(
|
||||
openDemands: TimelineDemandEntry[],
|
||||
openDemandCount: number,
|
||||
layout: OpenDemandRowLayout,
|
||||
projectId: string,
|
||||
CELL_WIDTH: number,
|
||||
totalCanvasWidth: number,
|
||||
@@ -915,7 +689,7 @@ function renderOpenDemandRow(
|
||||
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocationContextMenu: (
|
||||
info: { allocationId: string; projectId: string },
|
||||
info: { allocationId: string; projectId: string; contextDate?: Date },
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
@@ -923,12 +697,10 @@ function renderOpenDemandRow(
|
||||
onClearHoverTooltips: () => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
allocDragState: AllocDragState,
|
||||
suppressHoverInteractions: boolean,
|
||||
) {
|
||||
if (openDemands.length === 0) return null;
|
||||
|
||||
const laneMap = assignDemandLanes(openDemands);
|
||||
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
|
||||
const rowHeight = Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
const { visibleOpenDemands, laneMap, rowHeight } = layout;
|
||||
if (visibleOpenDemands.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -949,7 +721,7 @@ function renderOpenDemandRow(
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">Open demand</div>
|
||||
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
|
||||
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
|
||||
{openDemandCount} open demand{openDemandCount > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -963,7 +735,7 @@ function renderOpenDemandRow(
|
||||
{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-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
|
||||
{openDemands.map((alloc) => {
|
||||
{visibleOpenDemands.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
const allocEnd = new Date(alloc.endDate);
|
||||
|
||||
@@ -984,7 +756,26 @@ function renderOpenDemandRow(
|
||||
|
||||
let left = toLeft(dispStart);
|
||||
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
// Clamp negative left (bar starts before view) to avoid extending outside canvas
|
||||
let dragTransform: string | undefined;
|
||||
|
||||
if (isAllocDragged) {
|
||||
const preview = applyPointerOffsetPreviewRect({
|
||||
left,
|
||||
width,
|
||||
mode: allocDragState.mode,
|
||||
pointerOffsetX: getDragPointerOffset(
|
||||
allocDragState.pointerDeltaX,
|
||||
allocDragState.daysDelta,
|
||||
CELL_WIDTH,
|
||||
),
|
||||
minWidth: CELL_WIDTH,
|
||||
});
|
||||
left = preview.left;
|
||||
width = preview.width;
|
||||
dragTransform = preview.transform;
|
||||
}
|
||||
|
||||
// Clamp negative left (bar starts before view) to avoid extending outside canvas.
|
||||
if (left < 0) {
|
||||
width += left;
|
||||
left = 0;
|
||||
@@ -1025,6 +816,10 @@ function renderOpenDemandRow(
|
||||
return (
|
||||
<div
|
||||
key={alloc.id}
|
||||
data-allocation-id={alloc.id}
|
||||
data-timeline-entry-type="demand"
|
||||
data-timeline-drag-preview="project-shift allocation"
|
||||
data-timeline-project-id={alloc.projectId}
|
||||
className={clsx(
|
||||
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
|
||||
isAllocDragged
|
||||
@@ -1039,8 +834,14 @@ function renderOpenDemandRow(
|
||||
height: blockHeight,
|
||||
backgroundColor: `${roleColor}4D`,
|
||||
border: `2px dashed ${roleColor}B3`,
|
||||
...(multiDragPx && multiDragMode === "move"
|
||||
? { transform: `translateX(${multiDragPx}px)` }
|
||||
...((multiDragPx && multiDragMode === "move") || dragTransform
|
||||
? {
|
||||
transform: [dragTransform, multiDragPx && multiDragMode === "move"
|
||||
? `translateX(${multiDragPx}px)`
|
||||
: null]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
@@ -1049,15 +850,20 @@ function renderOpenDemandRow(
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
}}
|
||||
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
|
||||
onMouseMove={(e) => {
|
||||
if (suppressHoverInteractions) return;
|
||||
onDemandHoverMove(e, alloc);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
onOpenDemandClick(alloc, e.clientX, e.clientY);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
@@ -1066,6 +872,7 @@ function renderOpenDemandRow(
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onOpenDemandClick(alloc, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
}}
|
||||
@@ -1136,25 +943,24 @@ function renderProjectUtilOverlay(
|
||||
|
||||
const BAND_H = 7;
|
||||
const BAR_H = ROW_HEIGHT - BAND_H - 11;
|
||||
const REF_H = 8;
|
||||
const useHeatmapColors = displayMode === "bar";
|
||||
const svgParts: string[] = [
|
||||
`<svg xmlns="${SVG_XMLNS}" width="${totalCanvasWidth}" height="${ROW_HEIGHT}" viewBox="0 0 ${totalCanvasWidth} ${ROW_HEIGHT}" preserveAspectRatio="none" shape-rendering="crispEdges">`,
|
||||
];
|
||||
|
||||
dayMetrics.forEach(({ projH, totalH }, i) => {
|
||||
if (totalH === 0 && projH === 0) return;
|
||||
dayMetrics.forEach(({ projH, totalH, capacityH }, i) => {
|
||||
if ((totalH === 0 && projH === 0) || capacityH <= 0) return;
|
||||
|
||||
const isOver = totalH > REF_H;
|
||||
const isOver = totalH > capacityH;
|
||||
const totalBarH = Math.max(
|
||||
projH > 0 ? 2 : 0,
|
||||
Math.round((Math.min(totalH, REF_H) / REF_H) * BAR_H),
|
||||
Math.round((Math.min(totalH, capacityH) / capacityH) * BAR_H),
|
||||
);
|
||||
const projBarH =
|
||||
projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / REF_H) * BAR_H))) : 0;
|
||||
projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / capacityH) * BAR_H))) : 0;
|
||||
const otherBarH = totalBarH - projBarH;
|
||||
const projPct = (projH / REF_H) * 100;
|
||||
const totalPct = (totalH / REF_H) * 100;
|
||||
const projPct = (projH / capacityH) * 100;
|
||||
const totalPct = (totalH / capacityH) * 100;
|
||||
const projColor = useHeatmapColors
|
||||
? heatmapColor(
|
||||
projPct,
|
||||
@@ -1229,11 +1035,12 @@ function renderProjectDragHandles(
|
||||
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocationContextMenu: (
|
||||
info: { allocationId: string; projectId: string },
|
||||
info: { allocationId: string; projectId: string; contextDate?: Date },
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
suppressHoverInteractions: boolean,
|
||||
) {
|
||||
return allocs.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
@@ -1249,6 +1056,24 @@ function renderProjectDragHandles(
|
||||
|
||||
let left = toLeft(dispStart);
|
||||
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
let dragTransform: string | undefined;
|
||||
|
||||
if (isAllocDragged) {
|
||||
const preview = applyPointerOffsetPreviewRect({
|
||||
left,
|
||||
width,
|
||||
mode: allocDragState.mode,
|
||||
pointerOffsetX: getDragPointerOffset(
|
||||
allocDragState.pointerDeltaX,
|
||||
allocDragState.daysDelta,
|
||||
CELL_WIDTH,
|
||||
),
|
||||
minWidth: CELL_WIDTH,
|
||||
});
|
||||
left = preview.left;
|
||||
width = preview.width;
|
||||
dragTransform = preview.transform;
|
||||
}
|
||||
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||
|
||||
// Multi-drag visual offset
|
||||
@@ -1283,6 +1108,10 @@ function renderProjectDragHandles(
|
||||
return (
|
||||
<div
|
||||
key={`dh-${alloc.id}`}
|
||||
data-allocation-id={alloc.id}
|
||||
data-timeline-entry-type="allocation"
|
||||
data-timeline-drag-preview="project-shift allocation"
|
||||
data-timeline-project-id={alloc.projectId}
|
||||
className={clsx(
|
||||
"absolute flex items-stretch rounded",
|
||||
hasRecurrence && "border-2 border-dashed border-brand-400/60",
|
||||
@@ -1296,9 +1125,18 @@ function renderProjectDragHandles(
|
||||
width: width - 4,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
...(multiDragPx && multiDragMode === "move"
|
||||
? { transform: `translateX(${multiDragPx}px)` }
|
||||
: {}),
|
||||
...((multiDragPx && multiDragMode === "move") || dragTransform
|
||||
? {
|
||||
transform: [
|
||||
dragTransform,
|
||||
multiDragPx && multiDragMode === "move"
|
||||
? `translateX(${multiDragPx}px)`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) e.stopPropagation();
|
||||
@@ -1306,6 +1144,7 @@ function renderProjectDragHandles(
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
e.clientX,
|
||||
@@ -1316,7 +1155,10 @@ function renderProjectDragHandles(
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
@@ -1327,7 +1169,10 @@ function renderProjectDragHandles(
|
||||
"flex-1 min-w-0 flex items-center",
|
||||
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, allocInfo);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, allocInfo);
|
||||
@@ -1342,7 +1187,10 @@ function renderProjectDragHandles(
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { GERMAN_FEDERAL_STATES } from "@capakraken/shared";
|
||||
import { createPortal } from "react-dom";
|
||||
import { formatCents, formatDateLong } from "~/lib/format.js";
|
||||
|
||||
@@ -33,11 +34,73 @@ export type VacationHoverData = {
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
scope?: string | null;
|
||||
calendarName?: string | null;
|
||||
sourceType?: string | null;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityName?: string | null;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
};
|
||||
|
||||
function formatHolidayScope(scope?: string | null): string | null {
|
||||
switch (scope) {
|
||||
case "COUNTRY":
|
||||
return "Country";
|
||||
case "STATE":
|
||||
return "State";
|
||||
case "CITY":
|
||||
return "City";
|
||||
default:
|
||||
return scope ? scope.replaceAll("_", " ") : null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHolidaySourceType(sourceType?: string | null): string | null {
|
||||
switch (sourceType) {
|
||||
case "SYSTEM":
|
||||
return "Built-in";
|
||||
case "CALENDAR":
|
||||
return "Calendar";
|
||||
default:
|
||||
return sourceType ? sourceType.replaceAll("_", " ") : null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatHolidayStateName(
|
||||
federalState?: string | null,
|
||||
countryCode?: string | null,
|
||||
): string | null {
|
||||
if (!federalState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (countryCode === "DE") {
|
||||
return GERMAN_FEDERAL_STATES[federalState] ?? federalState;
|
||||
}
|
||||
|
||||
return federalState;
|
||||
}
|
||||
|
||||
function buildHolidayLocationBasis(vacation: VacationHoverData): string | null {
|
||||
const parts = [
|
||||
vacation.countryName
|
||||
? `Country: ${vacation.countryName}`
|
||||
: vacation.countryCode
|
||||
? `Country: ${vacation.countryCode}`
|
||||
: null,
|
||||
formatHolidayStateName(vacation.federalState, vacation.countryCode)
|
||||
? `State: ${formatHolidayStateName(vacation.federalState, vacation.countryCode)}`
|
||||
: null,
|
||||
vacation.metroCityName ? `City: ${vacation.metroCityName}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
export type DemandHoverData = {
|
||||
roleName: string;
|
||||
roleColor: string;
|
||||
@@ -67,6 +130,142 @@ interface TimelineTooltipProps {
|
||||
demandHover?: DemandHoverData | null;
|
||||
}
|
||||
|
||||
function renderTooltipPortal(content: React.ReactNode) {
|
||||
return typeof document === "undefined" ? content : createPortal(content, document.body);
|
||||
}
|
||||
|
||||
function TooltipSurface({
|
||||
children,
|
||||
position,
|
||||
tooltipRef,
|
||||
className,
|
||||
dataTestId,
|
||||
backgroundColor,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
position: { left: number; top: number };
|
||||
tooltipRef?: React.Ref<HTMLDivElement>;
|
||||
className: string;
|
||||
dataTestId?: string;
|
||||
backgroundColor: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
data-testid={dataTestId}
|
||||
style={{
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
backgroundColor,
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipMetric({
|
||||
label,
|
||||
value,
|
||||
valueClassName = "font-medium text-gray-100",
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
valueClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-gray-500">{label}</div>
|
||||
<div className={valueClassName}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapBreakdownList({ breakdown }: { breakdown: HeatmapHoverData["breakdown"] }) {
|
||||
if (breakdown.length === 0) {
|
||||
return <div className="text-[11px] text-gray-400">No bookings on this day.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{breakdown.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.shortCode}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">
|
||||
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
||||
{entry.projectName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{[
|
||||
entry.role,
|
||||
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
|
||||
entry.orderType,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{entry.startDate && entry.endDate ? (
|
||||
<div className="text-[10px] text-gray-500">
|
||||
{entry.startDate} → {entry.endDate}
|
||||
{entry.status && entry.status !== "CONFIRMED" ? (
|
||||
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||
{entry.hoursPerDay}h
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VacationSummary({
|
||||
vacation,
|
||||
title,
|
||||
className,
|
||||
}: {
|
||||
vacation: VacationHoverData;
|
||||
title: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const isPublicHoliday = vacation.type === "PUBLIC_HOLIDAY";
|
||||
const scopeLabel = formatHolidayScope(vacation.scope);
|
||||
const sourceLabel = formatHolidaySourceType(vacation.sourceType);
|
||||
const holidayMeta = [scopeLabel, sourceLabel].filter(Boolean).join(" · ");
|
||||
const holidayLocationBasis = isPublicHoliday ? buildHolidayLocationBasis(vacation) : null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 flex-shrink-0 rounded-full bg-amber-500" />
|
||||
<span className="font-semibold text-amber-300">{title}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
||||
{formatDateLong(vacation.startDate)} to {formatDateLong(vacation.endDate)}
|
||||
</div>
|
||||
{holidayMeta ? (
|
||||
<div className="mt-1 text-[11px] text-amber-100/75">{holidayMeta}</div>
|
||||
) : null}
|
||||
{holidayLocationBasis ? (
|
||||
<div className="mt-1 text-[11px] text-amber-100/85">{holidayLocationBasis}</div>
|
||||
) : null}
|
||||
{isPublicHoliday && vacation.calendarName ? (
|
||||
<div className="mt-1 text-[11px] text-amber-200/60">
|
||||
Calendar: {vacation.calendarName}
|
||||
</div>
|
||||
) : null}
|
||||
{vacation.note && !isPublicHoliday ? (
|
||||
<div className="mt-1 text-[11px] text-amber-200/60">{vacation.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimelineTooltip({
|
||||
heatmapTooltipRef,
|
||||
heatmapTooltipPos,
|
||||
@@ -79,18 +278,14 @@ export function TimelineTooltip({
|
||||
demandHover,
|
||||
}: TimelineTooltipProps) {
|
||||
const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
|
||||
const renderTooltip = (content: React.ReactNode) =>
|
||||
typeof document === "undefined" ? content : createPortal(content, document.body);
|
||||
|
||||
if (demandHover && demandTooltipRef && demandTooltipPos) {
|
||||
return renderTooltip(
|
||||
<div
|
||||
ref={demandTooltipRef}
|
||||
style={{
|
||||
left: demandTooltipPos.left,
|
||||
top: demandTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
return renderTooltipPortal(
|
||||
<TooltipSurface
|
||||
tooltipRef={demandTooltipRef}
|
||||
dataTestId="timeline-demand-tooltip"
|
||||
position={demandTooltipPos}
|
||||
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">
|
||||
@@ -115,73 +310,58 @@ export function TimelineTooltip({
|
||||
</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} {demandHover.requestedHeadcount === 1 ? "seat" : "seats"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Open</div>
|
||||
<div className="font-medium text-amber-300">
|
||||
{demandHover.unfilledHeadcount} {demandHover.unfilledHeadcount === 1 ? "seat" : "seats"}
|
||||
</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>
|
||||
<TooltipMetric
|
||||
label="Requested"
|
||||
value={`${demandHover.requestedHeadcount} ${demandHover.requestedHeadcount === 1 ? "seat" : "seats"}`}
|
||||
/>
|
||||
<TooltipMetric
|
||||
label="Open"
|
||||
value={`${demandHover.unfilledHeadcount} ${demandHover.unfilledHeadcount === 1 ? "seat" : "seats"}`}
|
||||
valueClassName="font-medium text-amber-300"
|
||||
/>
|
||||
<TooltipMetric
|
||||
label="Range"
|
||||
value={`${formatDateLong(demandHover.startDate)} to ${formatDateLong(demandHover.endDate)}`}
|
||||
/>
|
||||
<TooltipMetric
|
||||
label="Load"
|
||||
value={`${demandHover.hoursPerDay}h/day · ${demandHover.totalHours}h`}
|
||||
/>
|
||||
{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>
|
||||
<TooltipMetric label="Allocation" value={`${demandHover.percentage}%`} />
|
||||
) : 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
|
||||
<TooltipMetric
|
||||
label="Cost"
|
||||
value={`${formatCents(demandHover.totalCostCents)} EUR${
|
||||
typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
|
||||
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 border-t border-gray-800/90 pt-2 text-[10px] uppercase tracking-[0.14em] text-gray-500">
|
||||
Click for details and actions
|
||||
</div>
|
||||
</div>,
|
||||
</TooltipSurface>,
|
||||
);
|
||||
}
|
||||
|
||||
// When both are active, render a single merged tooltip using the heatmap position
|
||||
if (heatmapHover && vacationHover) {
|
||||
return renderTooltip(
|
||||
<div
|
||||
ref={(el) => {
|
||||
// Wire both refs to the same element so position updates work from either handler
|
||||
return renderTooltipPortal(
|
||||
<TooltipSurface
|
||||
tooltipRef={(el) => {
|
||||
(heatmapTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
}}
|
||||
style={{
|
||||
left: heatmapTooltipPos.left,
|
||||
top: heatmapTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
// Keep the merged tooltip attached to the heatmap pointer path only.
|
||||
(vacationTooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = null;
|
||||
}}
|
||||
position={heatmapTooltipPos}
|
||||
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"
|
||||
>
|
||||
{/* Date + hours header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
||||
<span className="text-[11px] text-gray-300">
|
||||
@@ -189,72 +369,26 @@ export function TimelineTooltip({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Project breakdown */}
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{heatmapHover.breakdown.length > 0 ? (
|
||||
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.shortCode}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">
|
||||
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
||||
{entry.projectName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{[
|
||||
entry.role,
|
||||
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
|
||||
entry.orderType,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{entry.startDate && entry.endDate && (
|
||||
<div className="text-[10px] text-gray-500">
|
||||
{entry.startDate} → {entry.endDate}
|
||||
{entry.status && entry.status !== "CONFIRMED" && (
|
||||
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||
{entry.hoursPerDay}h
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
||||
)}
|
||||
<HeatmapBreakdownList breakdown={heatmapHover.breakdown} />
|
||||
</div>
|
||||
|
||||
{/* Vacation section — merged below */}
|
||||
<div className="mt-2 pt-2 border-t border-amber-700/40">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
|
||||
<span className="font-semibold text-amber-300">{vacationTitle}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-amber-200/80">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
|
||||
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
<VacationSummary
|
||||
vacation={vacationHover}
|
||||
title={vacationTitle ?? "Vacation"}
|
||||
className="mt-2 border-t border-amber-700/40 pt-2"
|
||||
/>
|
||||
</TooltipSurface>,
|
||||
);
|
||||
}
|
||||
|
||||
// Heatmap only
|
||||
if (heatmapHover) {
|
||||
return renderTooltip(
|
||||
<div
|
||||
ref={heatmapTooltipRef}
|
||||
style={{
|
||||
left: heatmapTooltipPos.left,
|
||||
top: heatmapTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
return renderTooltipPortal(
|
||||
<TooltipSurface
|
||||
tooltipRef={heatmapTooltipRef}
|
||||
position={heatmapTooltipPos}
|
||||
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-center justify-between gap-3">
|
||||
@@ -264,66 +398,23 @@ export function TimelineTooltip({
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{heatmapHover.breakdown.length > 0 ? (
|
||||
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.shortCode}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">
|
||||
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
||||
{entry.projectName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{[
|
||||
entry.role,
|
||||
entry.responsiblePerson ? `Lead: ${entry.responsiblePerson}` : null,
|
||||
entry.orderType,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{entry.startDate && entry.endDate && (
|
||||
<div className="text-[10px] text-gray-500">
|
||||
{entry.startDate} → {entry.endDate}
|
||||
{entry.status && entry.status !== "CONFIRMED" && (
|
||||
<span className="ml-1 uppercase text-amber-400">{entry.status}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||
{entry.hoursPerDay}h
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
||||
)}
|
||||
<HeatmapBreakdownList breakdown={heatmapHover.breakdown} />
|
||||
</div>
|
||||
</div>,
|
||||
</TooltipSurface>,
|
||||
);
|
||||
}
|
||||
|
||||
// Vacation only
|
||||
if (vacationHover) {
|
||||
return renderTooltip(
|
||||
<div
|
||||
ref={vacationTooltipRef}
|
||||
style={{
|
||||
left: vacationTooltipPos.left,
|
||||
top: vacationTooltipPos.top,
|
||||
backgroundColor: "rgba(120, 53, 15, 0.95)",
|
||||
}}
|
||||
return renderTooltipPortal(
|
||||
<TooltipSurface
|
||||
tooltipRef={vacationTooltipRef}
|
||||
position={vacationTooltipPos}
|
||||
backgroundColor="rgba(120, 53, 15, 0.95)"
|
||||
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">{vacationTitle}</div>
|
||||
<div className="mt-1 text-[11px] text-amber-100/90">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
|
||||
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>,
|
||||
<VacationSummary vacation={vacationHover} title={vacationTitle ?? "Vacation"} />
|
||||
</TooltipSurface>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
|
||||
import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
|
||||
import { AllocationPopover } from "./AllocationPopover.js";
|
||||
import { DemandPopover } from "./DemandPopover.js";
|
||||
@@ -33,6 +34,7 @@ import { TimelineResourcePanel } from "./TimelineResourcePanel.js";
|
||||
import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js";
|
||||
import { ProjectColorLegend } from "./ProjectColorLegend.js";
|
||||
import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js";
|
||||
import type { TimelineVisualOverrides } from "./allocationVisualState.js";
|
||||
|
||||
// ─── Entry point ────────────────────────────────────────────────────────────
|
||||
// Two-layer mount: the outer shell creates drag state + project context,
|
||||
@@ -56,8 +58,10 @@ export function TimelineView() {
|
||||
const [popover, setPopover] = useState<{
|
||||
allocationId: string;
|
||||
projectId: string;
|
||||
allocation?: TimelineAssignmentEntry | null;
|
||||
x: number;
|
||||
y: number;
|
||||
contextDate?: Date;
|
||||
} | null>(null);
|
||||
const [newAllocPopover, setNewAllocPopover] = useState<{
|
||||
resourceId: string;
|
||||
@@ -88,6 +92,8 @@ export function TimelineView() {
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
optimisticAllocations,
|
||||
reconcileOptimisticAllocations,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying,
|
||||
@@ -106,7 +112,7 @@ export function TimelineView() {
|
||||
onCanvasTouchMove,
|
||||
onCanvasTouchEnd,
|
||||
} = useTimelineDrag({
|
||||
cellWidth: cellWidthRef.current,
|
||||
cellWidthRef,
|
||||
onBlockClick: (info) => {
|
||||
setPopover({
|
||||
allocationId: info.allocationId,
|
||||
@@ -170,6 +176,8 @@ export function TimelineView() {
|
||||
rangeState={rangeState}
|
||||
multiSelectState={multiSelectState}
|
||||
setMultiSelectState={setMultiSelectState}
|
||||
optimisticAllocations={optimisticAllocations}
|
||||
reconcileOptimisticAllocations={reconcileOptimisticAllocations}
|
||||
onCanvasRightMouseDown={onCanvasRightMouseDown}
|
||||
clearMultiSelect={clearMultiSelect}
|
||||
shiftPreview={shiftPreview}
|
||||
@@ -214,6 +222,8 @@ function TimelineViewContent({
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
optimisticAllocations,
|
||||
reconcileOptimisticAllocations,
|
||||
onCanvasRightMouseDown,
|
||||
clearMultiSelect,
|
||||
shiftPreview,
|
||||
@@ -251,6 +261,8 @@ function TimelineViewContent({
|
||||
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
|
||||
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
|
||||
setMultiSelectState: ReturnType<typeof useTimelineDrag>["setMultiSelectState"];
|
||||
optimisticAllocations: TimelineVisualOverrides;
|
||||
reconcileOptimisticAllocations: ReturnType<typeof useTimelineDrag>["reconcileOptimisticAllocations"];
|
||||
onCanvasRightMouseDown: ReturnType<typeof useTimelineDrag>["onCanvasRightMouseDown"];
|
||||
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
|
||||
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
|
||||
@@ -269,7 +281,14 @@ function TimelineViewContent({
|
||||
onCanvasTouchMove: ReturnType<typeof useTimelineDrag>["onCanvasTouchMove"];
|
||||
onCanvasTouchEnd: ReturnType<typeof useTimelineDrag>["onCanvasTouchEnd"];
|
||||
contextResourceIds: string[];
|
||||
popover: { allocationId: string; projectId: string; x: number; y: number } | null;
|
||||
popover: {
|
||||
allocationId: string;
|
||||
projectId: string;
|
||||
allocation?: TimelineAssignmentEntry | null;
|
||||
x: number;
|
||||
y: number;
|
||||
contextDate?: Date;
|
||||
} | null;
|
||||
setPopover: React.Dispatch<React.SetStateAction<typeof popover>>;
|
||||
newAllocPopover: {
|
||||
resourceId: string;
|
||||
@@ -300,6 +319,8 @@ function TimelineViewContent({
|
||||
viewStart,
|
||||
viewEnd,
|
||||
viewDays,
|
||||
visibleAssignments,
|
||||
visibleDemands,
|
||||
setViewStart,
|
||||
setViewDays,
|
||||
filters,
|
||||
@@ -348,6 +369,23 @@ function TimelineViewContent({
|
||||
const hasActivePointerOverlay =
|
||||
dragState.isDragging || allocDragState.isActive || rangeState.isSelecting || multiSelectState.isMultiDragging;
|
||||
|
||||
useEffect(() => {
|
||||
if (optimisticAllocations.size === 0) return;
|
||||
reconcileOptimisticAllocations([...visibleAssignments, ...visibleDemands]);
|
||||
}, [
|
||||
optimisticAllocations,
|
||||
reconcileOptimisticAllocations,
|
||||
visibleAssignments,
|
||||
visibleDemands,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActivePointerOverlay) return;
|
||||
setPopover(null);
|
||||
setDemandPopover(null);
|
||||
setResourceHover(null);
|
||||
}, [hasActivePointerOverlay]);
|
||||
|
||||
// ─── Keep selection overlay visible while popover is open ───────────────────
|
||||
const effectiveRangeState: typeof rangeState = rangeState.isSelecting
|
||||
? rangeState
|
||||
@@ -386,10 +424,12 @@ function TimelineViewContent({
|
||||
info: {
|
||||
allocationId: string;
|
||||
projectId: string;
|
||||
contextDate?: Date;
|
||||
},
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) {
|
||||
if (hasActivePointerOverlay) return;
|
||||
// Check if this is a demand (not an assignment) — route to DemandPopover
|
||||
const demands = openDemandsByProject.get(info.projectId);
|
||||
const demand = demands?.find((d) => d.id === info.allocationId);
|
||||
@@ -397,11 +437,19 @@ function TimelineViewContent({
|
||||
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
||||
return;
|
||||
}
|
||||
const allocation = visibleAssignments.find((entry) => (
|
||||
entry.id === info.allocationId
|
||||
|| entry.entityId === info.allocationId
|
||||
|| entry.sourceAllocationId === info.allocationId
|
||||
|| getPlanningEntryMutationId(entry) === info.allocationId
|
||||
)) ?? null;
|
||||
setPopover({
|
||||
allocationId: info.allocationId,
|
||||
projectId: info.projectId,
|
||||
allocation,
|
||||
x: anchorX,
|
||||
y: anchorY,
|
||||
...(info.contextDate ? { contextDate: info.contextDate } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -505,12 +553,22 @@ function TimelineViewContent({
|
||||
|
||||
// ─── Resource hover card — event delegation on label columns ──────────────
|
||||
useEffect(() => {
|
||||
if (hasActivePointerOverlay) {
|
||||
if (resourceHoverTimerRef.current) {
|
||||
clearTimeout(resourceHoverTimerRef.current);
|
||||
resourceHoverTimerRef.current = null;
|
||||
}
|
||||
setResourceHover(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const HOVER_DELAY = 400;
|
||||
|
||||
function onMouseOver(e: MouseEvent) {
|
||||
if (hasActivePointerOverlay) return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>("[data-resource-hover-id]");
|
||||
if (!target) return;
|
||||
const rid = target.dataset.resourceHoverId;
|
||||
@@ -532,6 +590,7 @@ function TimelineViewContent({
|
||||
}
|
||||
|
||||
function onMouseOut(e: MouseEvent) {
|
||||
if (hasActivePointerOverlay) return;
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
// Don't close if moving into another resource-hover target or the hover card itself
|
||||
if (related?.closest?.("[data-resource-hover-id]") || related?.closest?.("[data-resource-hover-card]")) return;
|
||||
@@ -557,7 +616,7 @@ function TimelineViewContent({
|
||||
resourceHoverTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [resourceHover?.resourceId, isInitialLoading]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [resourceHover?.resourceId, isInitialLoading, hasActivePointerOverlay]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ─── Lazy-extend date range on scroll ─────────────────────────────────────
|
||||
function handleContainerScroll() {
|
||||
@@ -682,6 +741,8 @@ function TimelineViewContent({
|
||||
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
|
||||
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
|
||||
multiSelectState={multiSelectState}
|
||||
optimisticAllocations={optimisticAllocations}
|
||||
suppressHoverInteractions={hasActivePointerOverlay}
|
||||
CELL_WIDTH={CELL_WIDTH}
|
||||
dates={dates}
|
||||
totalCanvasWidth={totalCanvasWidth}
|
||||
@@ -707,9 +768,12 @@ function TimelineViewContent({
|
||||
onRowTouchStart={isSelfServiceTimeline ? () => undefined : onRowTouchStart}
|
||||
onOpenPanel={isSelfServiceTimeline ? () => undefined : setOpenPanelProjectId}
|
||||
onOpenDemandClick={isSelfServiceTimeline ? () => undefined : (demand, anchorX, anchorY) => {
|
||||
if (hasActivePointerOverlay) return;
|
||||
setDemandPopover({ demand, x: anchorX, y: anchorY });
|
||||
}}
|
||||
onAllocationContextMenu={isSelfServiceTimeline ? () => undefined : openAllocationPopoverAt}
|
||||
optimisticAllocations={optimisticAllocations}
|
||||
suppressHoverInteractions={hasActivePointerOverlay}
|
||||
CELL_WIDTH={CELL_WIDTH}
|
||||
dates={dates}
|
||||
totalCanvasWidth={totalCanvasWidth}
|
||||
@@ -827,7 +891,7 @@ function TimelineViewContent({
|
||||
)}
|
||||
|
||||
{/* Allocation / Demand popover (click path) */}
|
||||
{!isSelfServiceTimeline && popover && (() => {
|
||||
{!isSelfServiceTimeline && !hasActivePointerOverlay && popover && (() => {
|
||||
// Check if clicked allocation is actually a demand
|
||||
const clickedDemand = openDemandsByProject.get(popover.projectId)?.find((d) => d.id === popover.allocationId);
|
||||
if (clickedDemand) {
|
||||
@@ -863,6 +927,7 @@ function TimelineViewContent({
|
||||
<AllocationPopover
|
||||
allocationId={popover.allocationId}
|
||||
projectId={popover.projectId}
|
||||
initialAllocation={popover.allocation ?? null}
|
||||
onClose={() => setPopover(null)}
|
||||
onOpenPanel={(pid) => {
|
||||
setPopover(null);
|
||||
@@ -870,12 +935,13 @@ function TimelineViewContent({
|
||||
}}
|
||||
anchorX={popover.x}
|
||||
anchorY={popover.y}
|
||||
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Demand popover */}
|
||||
{!isSelfServiceTimeline && demandPopover && (
|
||||
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
|
||||
<DemandPopover
|
||||
demand={demandPopover.demand}
|
||||
onClose={() => setDemandPopover(null)}
|
||||
@@ -962,7 +1028,7 @@ function TimelineViewContent({
|
||||
)}
|
||||
|
||||
{/* Resource hover card */}
|
||||
{resourceHover && (
|
||||
{!hasActivePointerOverlay && resourceHover && (
|
||||
<ResourceHoverCard
|
||||
resourceId={resourceHover.resourceId}
|
||||
anchorEl={resourceHover.anchorEl}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
export type TimelineVisualEntry = {
|
||||
id: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
};
|
||||
|
||||
export type TimelineVisualOverride = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
|
||||
export type TimelineVisualOverrides = ReadonlyMap<string, TimelineVisualOverride>;
|
||||
|
||||
export function getDragPointerOffset(
|
||||
pointerDeltaX: number,
|
||||
daysDelta: number,
|
||||
cellWidth: number,
|
||||
): number {
|
||||
return pointerDeltaX - daysDelta * cellWidth;
|
||||
}
|
||||
|
||||
export function applyPointerOffsetPreviewRect({
|
||||
left,
|
||||
width,
|
||||
mode,
|
||||
pointerOffsetX,
|
||||
minWidth,
|
||||
}: {
|
||||
left: number;
|
||||
width: number;
|
||||
mode: "move" | "resize-start" | "resize-end";
|
||||
pointerOffsetX: number;
|
||||
minWidth: number;
|
||||
}): { left: number; width: number; transform?: string } {
|
||||
if (pointerOffsetX === 0) {
|
||||
return { left, width };
|
||||
}
|
||||
|
||||
if (mode === "move") {
|
||||
return { left, width, transform: `translateX(${pointerOffsetX}px)` };
|
||||
}
|
||||
|
||||
if (mode === "resize-start") {
|
||||
const nextWidth = width - pointerOffsetX;
|
||||
if (nextWidth < minWidth) {
|
||||
return {
|
||||
left: left + (width - minWidth),
|
||||
width: minWidth,
|
||||
};
|
||||
}
|
||||
return {
|
||||
left: left + pointerOffsetX,
|
||||
width: nextWidth,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left,
|
||||
width: Math.max(minWidth, width + pointerOffsetX),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyVisualOverrides<T extends TimelineVisualEntry>(
|
||||
entries: readonly T[],
|
||||
overrides: TimelineVisualOverrides,
|
||||
): T[] {
|
||||
if (overrides.size === 0) {
|
||||
return entries as T[];
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextEntries = entries.map((entry) => {
|
||||
const override = overrides.get(entry.id);
|
||||
if (!override) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
return {
|
||||
...entry,
|
||||
startDate: new Date(override.startDate),
|
||||
endDate: new Date(override.endDate),
|
||||
};
|
||||
});
|
||||
|
||||
return changed ? nextEntries : (entries as T[]);
|
||||
}
|
||||
@@ -21,6 +21,34 @@ export interface VacationBlockInfo {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function buildVacationBlocksByResource(
|
||||
vacationsByResource: Map<string, VacationEntry[]>,
|
||||
showVacations: boolean,
|
||||
toLeft: (date: Date) => number,
|
||||
toWidth: (start: Date, end: Date) => number,
|
||||
cellWidth: number,
|
||||
totalCanvasWidth: number,
|
||||
) {
|
||||
if (!showVacations) return new Map<string, VacationBlockInfo[]>();
|
||||
|
||||
const result = new Map<string, VacationBlockInfo[]>();
|
||||
for (const [resourceId, vacations] of vacationsByResource) {
|
||||
const blocks: VacationBlockInfo[] = [];
|
||||
for (const vacation of vacations) {
|
||||
const start = new Date(vacation.startDate);
|
||||
const end = new Date(vacation.endDate);
|
||||
const left = toLeft(start);
|
||||
const width = Math.max(cellWidth, toWidth(start, end));
|
||||
if (width <= 0 || left >= totalCanvasWidth) continue;
|
||||
blocks.push({ vacation, left, width });
|
||||
}
|
||||
if (blocks.length > 0) {
|
||||
result.set(resourceId, blocks);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Vacation block overlays ─────────────────────────────────────────────────
|
||||
|
||||
export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: number) {
|
||||
@@ -92,8 +120,9 @@ export function renderOverbookingBlink(
|
||||
allocs: TimelineAssignmentEntry[],
|
||||
dates: Date[],
|
||||
CELL_WIDTH: number,
|
||||
capacityHoursByDay?: number[],
|
||||
bookingFactorsByDay?: number[],
|
||||
) {
|
||||
const REF_H = 8;
|
||||
const overbooked: number[] = [];
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
@@ -106,9 +135,11 @@ export function renderOverbookingBlink(
|
||||
s.setHours(0, 0, 0, 0);
|
||||
const e = new Date(a.endDate);
|
||||
e.setHours(0, 0, 0, 0);
|
||||
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
|
||||
if (t >= s.getTime() && t <= e.getTime()) {
|
||||
totalH += a.hoursPerDay * (bookingFactorsByDay?.[i] ?? 1);
|
||||
}
|
||||
}
|
||||
if (totalH > REF_H) overbooked.push(i);
|
||||
if (totalH > (capacityHoursByDay?.[i] ?? 8)) overbooked.push(i);
|
||||
}
|
||||
|
||||
if (overbooked.length === 0) return null;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAllocationWorkingDaySegments,
|
||||
isAllocationScheduledOnDate,
|
||||
toLocalDateKey,
|
||||
} from "./timelineAvailability.js";
|
||||
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
|
||||
|
||||
function createAllocation(overrides?: Partial<TimelineAssignmentEntry>): TimelineAssignmentEntry {
|
||||
return {
|
||||
id: "alloc-1",
|
||||
resourceId: "resource-1",
|
||||
projectId: "project-1",
|
||||
startDate: new Date(2026, 2, 30),
|
||||
endDate: new Date(2026, 3, 6),
|
||||
hoursPerDay: 8,
|
||||
metadata: null,
|
||||
project: {
|
||||
id: "project-1",
|
||||
name: "Gelddruckmaschine",
|
||||
shortCode: "GDM",
|
||||
status: "ACTIVE",
|
||||
startDate: new Date(2026, 2, 30),
|
||||
endDate: new Date(2026, 3, 6),
|
||||
orderType: "CHARGEABLE",
|
||||
},
|
||||
resource: {
|
||||
id: "resource-1",
|
||||
displayName: "Peter Parker",
|
||||
eid: "E-001",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 10000,
|
||||
availability: {
|
||||
sunday: 0,
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 6,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as TimelineAssignmentEntry;
|
||||
}
|
||||
|
||||
describe("timelineAvailability", () => {
|
||||
it("does not treat Saturday as scheduled unless includeSaturday is enabled", () => {
|
||||
const allocation = createAllocation();
|
||||
|
||||
expect(isAllocationScheduledOnDate(allocation, new Date(2026, 3, 4))).toBe(false);
|
||||
expect(isAllocationScheduledOnDate(allocation, new Date(2026, 3, 6))).toBe(true);
|
||||
});
|
||||
|
||||
it("supports Saturday working allocations when includeSaturday is enabled", () => {
|
||||
const allocation = createAllocation({
|
||||
metadata: { includeSaturday: true },
|
||||
});
|
||||
|
||||
expect(isAllocationScheduledOnDate(allocation, new Date(2026, 3, 4))).toBe(true);
|
||||
});
|
||||
|
||||
it("splits working spans at non-working weekend days", () => {
|
||||
const allocation = createAllocation();
|
||||
const segments = buildAllocationWorkingDaySegments(allocation);
|
||||
|
||||
expect(segments).toEqual([
|
||||
{ start: new Date(2026, 2, 30), end: new Date(2026, 3, 3) },
|
||||
{ start: new Date(2026, 3, 6), end: new Date(2026, 3, 6) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("formats local calendar days without shifting them through UTC serialization", () => {
|
||||
expect(toLocalDateKey(new Date(2026, 3, 6))).toBe("2026-04-06");
|
||||
expect(toLocalDateKey(new Date(2026, 3, 10))).toBe("2026-04-10");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
export const DEFAULT_TIMELINE_AVAILABILITY: WeekdayAvailability = {
|
||||
sunday: 0,
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
export function toLocalStartOfDay(value: Date | string): Date {
|
||||
const date = value instanceof Date ? new Date(value) : new Date(value);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
export function toLocalDateKey(value: Date | string): string {
|
||||
const date = toLocalStartOfDay(value);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineAvailability(value: unknown): WeekdayAvailability {
|
||||
if (!isRecord(value)) {
|
||||
return DEFAULT_TIMELINE_AVAILABILITY;
|
||||
}
|
||||
|
||||
const normalized = { ...DEFAULT_TIMELINE_AVAILABILITY };
|
||||
for (const key of DAY_KEYS) {
|
||||
const next = value[key];
|
||||
if (typeof next === "number" && Number.isFinite(next)) {
|
||||
normalized[key] = next;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getTimelineAvailabilityHoursForDate(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const dayKey = DAY_KEYS[toLocalStartOfDay(date).getDay()];
|
||||
return dayKey ? (availability[dayKey] ?? 0) : 0;
|
||||
}
|
||||
|
||||
export function getEffectiveAllocationAvailability(
|
||||
allocation: Pick<TimelineAssignmentEntry, "resource" | "metadata">,
|
||||
): WeekdayAvailability {
|
||||
const availability = normalizeTimelineAvailability(allocation.resource?.availability);
|
||||
const metadata = allocation.metadata as Record<string, unknown> | null;
|
||||
const includeSaturday = metadata?.includeSaturday === true;
|
||||
return includeSaturday ? availability : { ...availability, saturday: 0 };
|
||||
}
|
||||
|
||||
export function isAllocationScheduledOnDate(
|
||||
allocation: Pick<TimelineAssignmentEntry, "startDate" | "endDate" | "resource" | "metadata">,
|
||||
date: Date,
|
||||
): boolean {
|
||||
const target = toLocalStartOfDay(date);
|
||||
const start = toLocalStartOfDay(allocation.startDate);
|
||||
const end = toLocalStartOfDay(allocation.endDate);
|
||||
|
||||
if (target < start || target > end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return getTimelineAvailabilityHoursForDate(getEffectiveAllocationAvailability(allocation), target) > 0;
|
||||
}
|
||||
|
||||
export function buildAllocationWorkingDaySegments(
|
||||
allocation: Pick<TimelineAssignmentEntry, "startDate" | "endDate" | "resource" | "metadata">,
|
||||
rangeStart?: Date,
|
||||
rangeEnd?: Date,
|
||||
): Array<{ start: Date; end: Date }> {
|
||||
const availability = getEffectiveAllocationAvailability(allocation);
|
||||
const start = toLocalStartOfDay(rangeStart ?? allocation.startDate);
|
||||
const end = toLocalStartOfDay(rangeEnd ?? allocation.endDate);
|
||||
|
||||
if (start > end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments: Array<{ start: Date; end: Date }> = [];
|
||||
let segmentStart: Date | null = null;
|
||||
let segmentEnd: Date | null = null;
|
||||
const cursor = new Date(start);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isWorkingDay = getTimelineAvailabilityHoursForDate(availability, cursor) > 0;
|
||||
|
||||
if (isWorkingDay) {
|
||||
if (!segmentStart) {
|
||||
segmentStart = new Date(cursor);
|
||||
}
|
||||
segmentEnd = new Date(cursor);
|
||||
} else if (segmentStart && segmentEnd) {
|
||||
segments.push({ start: segmentStart, end: segmentEnd });
|
||||
segmentStart = null;
|
||||
segmentEnd = null;
|
||||
}
|
||||
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
if (segmentStart && segmentEnd) {
|
||||
segments.push({ start: segmentStart, end: segmentEnd });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildResourceCapacitySeries } from "./timelineCapacity.js";
|
||||
import type { TimelineAssignmentEntry, VacationEntry } from "./TimelineContext.js";
|
||||
|
||||
function createAssignmentWithAvailability(availability: Record<string, number>): TimelineAssignmentEntry {
|
||||
return {
|
||||
resource: {
|
||||
id: "resource-1",
|
||||
displayName: "Peter Parker",
|
||||
eid: "E-001",
|
||||
chapter: "Delivery",
|
||||
lcrCents: 10000,
|
||||
availability,
|
||||
},
|
||||
} as TimelineAssignmentEntry;
|
||||
}
|
||||
|
||||
function buildSeries(args: { dates: Date[]; vacations?: VacationEntry[]; availability: Record<string, number> }) {
|
||||
return buildResourceCapacitySeries(
|
||||
new Map([["resource-1", [createAssignmentWithAvailability(args.availability)]]]),
|
||||
new Map([["resource-1", args.vacations ?? []]]),
|
||||
args.dates,
|
||||
).get("resource-1");
|
||||
}
|
||||
|
||||
describe("timelineCapacity", () => {
|
||||
it("maps local weekdays without UTC drift", () => {
|
||||
const series = buildSeries({
|
||||
dates: [
|
||||
new Date(2026, 2, 30),
|
||||
new Date(2026, 3, 4),
|
||||
new Date(2026, 3, 5),
|
||||
],
|
||||
availability: {
|
||||
sunday: 1,
|
||||
monday: 6,
|
||||
tuesday: 7,
|
||||
wednesday: 8,
|
||||
thursday: 5,
|
||||
friday: 4,
|
||||
saturday: 2,
|
||||
},
|
||||
});
|
||||
|
||||
expect(series?.baseHoursByDay).toEqual([6, 2, 1]);
|
||||
expect(series?.capacityHoursByDay).toEqual([6, 2, 1]);
|
||||
expect(series?.bookingFactorsByDay).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("applies approved absences using the local calendar day key", () => {
|
||||
const series = buildSeries({
|
||||
dates: [new Date(2026, 2, 30)],
|
||||
availability: {
|
||||
sunday: 0,
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
},
|
||||
vacations: [
|
||||
{
|
||||
id: "vac-1",
|
||||
resourceId: "resource-1",
|
||||
type: "ANNUAL",
|
||||
status: "APPROVED",
|
||||
startDate: "2026-03-30",
|
||||
endDate: "2026-03-30",
|
||||
isHalfDay: false,
|
||||
} as VacationEntry,
|
||||
],
|
||||
});
|
||||
|
||||
expect(series?.baseHoursByDay).toEqual([8]);
|
||||
expect(series?.capacityHoursByDay).toEqual([0]);
|
||||
expect(series?.bookingFactorsByDay).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import type { TimelineAssignmentEntry, VacationEntry } from "./TimelineContext.js";
|
||||
import {
|
||||
DEFAULT_TIMELINE_AVAILABILITY,
|
||||
getTimelineAvailabilityHoursForDate,
|
||||
normalizeTimelineAvailability,
|
||||
toLocalDateKey,
|
||||
toLocalStartOfDay,
|
||||
} from "./timelineAvailability.js";
|
||||
|
||||
const DEFAULT_AVAILABILITY: WeekdayAvailability = DEFAULT_TIMELINE_AVAILABILITY;
|
||||
|
||||
export type ResourceCapacitySeries = {
|
||||
baseHoursByDay: number[];
|
||||
capacityHoursByDay: number[];
|
||||
bookingFactorsByDay: number[];
|
||||
};
|
||||
|
||||
function normalizeAvailability(value: unknown): WeekdayAvailability {
|
||||
return normalizeTimelineAvailability(value);
|
||||
}
|
||||
|
||||
function getAvailabilityHoursForDate(availability: WeekdayAvailability, date: Date): number {
|
||||
return getTimelineAvailabilityHoursForDate(availability, date);
|
||||
}
|
||||
|
||||
function resolveResourceAvailability(allocs: TimelineAssignmentEntry[]): WeekdayAvailability {
|
||||
for (const alloc of allocs) {
|
||||
const availability = alloc.resource?.availability;
|
||||
if (availability !== null && availability !== undefined) {
|
||||
return normalizeAvailability(availability);
|
||||
}
|
||||
}
|
||||
return DEFAULT_AVAILABILITY;
|
||||
}
|
||||
|
||||
function buildAbsenceFractionsByDate(
|
||||
vacations: VacationEntry[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Map<string, number> {
|
||||
const holidayDates = new Set<string>();
|
||||
const vacationFractionsByDate = new Map<string, number>();
|
||||
const absenceFractionsByDate = new Map<string, number>();
|
||||
const normalizedStart = toLocalStartOfDay(periodStart);
|
||||
const normalizedEnd = toLocalStartOfDay(periodEnd);
|
||||
|
||||
for (const vacation of vacations) {
|
||||
const isPublicHoliday = vacation.type === "PUBLIC_HOLIDAY";
|
||||
const isApprovedVacation = vacation.status === "APPROVED";
|
||||
|
||||
if (!isPublicHoliday && !isApprovedVacation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapStart = new Date(
|
||||
Math.max(
|
||||
toLocalStartOfDay(vacation.startDate).getTime(),
|
||||
normalizedStart.getTime(),
|
||||
),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(
|
||||
toLocalStartOfDay(vacation.endDate).getTime(),
|
||||
normalizedEnd.getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fraction = vacation.isHalfDay ? 0.5 : 1;
|
||||
const cursor = new Date(overlapStart);
|
||||
|
||||
while (cursor <= overlapEnd) {
|
||||
const isoDate = toLocalDateKey(cursor);
|
||||
|
||||
if (isPublicHoliday) {
|
||||
holidayDates.add(isoDate);
|
||||
} else {
|
||||
const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
|
||||
vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
|
||||
}
|
||||
|
||||
const existingAbsence = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
if (isPublicHoliday || !holidayDates.has(isoDate)) {
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existingAbsence, fraction));
|
||||
}
|
||||
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const isoDate of holidayDates) {
|
||||
const existingAbsence = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existingAbsence, 1));
|
||||
}
|
||||
|
||||
return absenceFractionsByDate;
|
||||
}
|
||||
|
||||
export function buildResourceCapacitySeries(
|
||||
allocsByResource: Map<string, TimelineAssignmentEntry[]>,
|
||||
vacationsByResource: Map<string, VacationEntry[]>,
|
||||
dates: Date[],
|
||||
): Map<string, ResourceCapacitySeries> {
|
||||
const result = new Map<string, ResourceCapacitySeries>();
|
||||
|
||||
if (dates.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const periodStart = dates[0]!;
|
||||
const periodEnd = dates[dates.length - 1]!;
|
||||
|
||||
for (const [resourceId, allocs] of allocsByResource) {
|
||||
const availability = resolveResourceAvailability(allocs);
|
||||
const absenceFractionsByDate = buildAbsenceFractionsByDate(
|
||||
vacationsByResource.get(resourceId) ?? [],
|
||||
periodStart,
|
||||
periodEnd,
|
||||
);
|
||||
|
||||
const baseHoursByDay: number[] = [];
|
||||
const capacityHoursByDay: number[] = [];
|
||||
const bookingFactorsByDay: number[] = [];
|
||||
|
||||
for (const date of dates) {
|
||||
const baseHours = getAvailabilityHoursForDate(availability, date);
|
||||
const absenceFraction = absenceFractionsByDate.get(toLocalDateKey(date)) ?? 0;
|
||||
const bookingFactor = baseHours > 0 ? Math.max(0, 1 - absenceFraction) : 0;
|
||||
|
||||
baseHoursByDay.push(baseHours);
|
||||
bookingFactorsByDay.push(bookingFactor);
|
||||
capacityHoursByDay.push(baseHours * bookingFactor);
|
||||
}
|
||||
|
||||
result.set(resourceId, {
|
||||
baseHoursByDay,
|
||||
capacityHoursByDay,
|
||||
bookingFactorsByDay,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
|
||||
import type { HeatmapHoverData } from "./TimelineTooltip.js";
|
||||
import type { ResourceCapacitySeries } from "./timelineCapacity.js";
|
||||
import { toLocalDateKey } from "./timelineAvailability.js";
|
||||
|
||||
type HeatmapAccumulator = {
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
hours: number;
|
||||
responsiblePerson?: string | null;
|
||||
role?: string | null;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
};
|
||||
|
||||
export function buildResourceHeatmapHover(
|
||||
date: Date,
|
||||
allocations: TimelineAssignmentEntry[],
|
||||
options?: {
|
||||
capacityHours?: number;
|
||||
bookingFactor?: number;
|
||||
},
|
||||
): HeatmapHoverData {
|
||||
const target = new Date(date);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
const targetTime = target.getTime();
|
||||
const bookingFactor = options?.bookingFactor ?? 1;
|
||||
const capacityHours = options?.capacityHours ?? 8;
|
||||
|
||||
const projectHours = new Map<string, HeatmapAccumulator>();
|
||||
|
||||
for (const allocation of allocations) {
|
||||
const start = new Date(allocation.startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(allocation.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
if (targetTime < start.getTime() || targetTime > end.getTime()) continue;
|
||||
|
||||
const existing = projectHours.get(allocation.projectId);
|
||||
if (existing) {
|
||||
existing.hours += allocation.hoursPerDay * bookingFactor;
|
||||
continue;
|
||||
}
|
||||
|
||||
projectHours.set(allocation.projectId, {
|
||||
shortCode: allocation.project.shortCode,
|
||||
projectName: allocation.project.name,
|
||||
orderType: allocation.project.orderType,
|
||||
hours: allocation.hoursPerDay * bookingFactor,
|
||||
responsiblePerson:
|
||||
(allocation.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
|
||||
role: allocation.role ?? allocation.roleEntity?.name ?? null,
|
||||
status: allocation.status,
|
||||
startDate: toLocalDateKey(allocation.startDate),
|
||||
endDate: toLocalDateKey(allocation.endDate),
|
||||
});
|
||||
}
|
||||
|
||||
const breakdown: HeatmapHoverData["breakdown"] = [...projectHours.entries()]
|
||||
.map(([projectId, value]) => ({
|
||||
projectId,
|
||||
shortCode: value.shortCode,
|
||||
projectName: value.projectName,
|
||||
orderType: value.orderType,
|
||||
hoursPerDay: value.hours,
|
||||
...(value.responsiblePerson !== undefined ? { responsiblePerson: value.responsiblePerson } : {}),
|
||||
...(value.role !== undefined ? { role: value.role } : {}),
|
||||
...(value.status !== undefined ? { status: value.status } : {}),
|
||||
...(value.startDate !== undefined ? { startDate: value.startDate } : {}),
|
||||
...(value.endDate !== undefined ? { endDate: value.endDate } : {}),
|
||||
}))
|
||||
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
|
||||
|
||||
const totalH = breakdown.reduce((sum, entry) => sum + entry.hoursPerDay, 0);
|
||||
|
||||
return {
|
||||
date,
|
||||
totalH,
|
||||
pct: capacityHours > 0 ? (totalH / capacityHours) * 100 : 0,
|
||||
breakdown,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildResourceHeatmapSeries(
|
||||
allocsByResource: Map<string, TimelineAssignmentEntry[]>,
|
||||
dates: Date[],
|
||||
resourceCapacityById?: Map<string, ResourceCapacitySeries>,
|
||||
): {
|
||||
resourceHeatmapById: Map<string, (HeatmapHoverData | null)[]>;
|
||||
resourceTotalHoursById: Map<string, number[]>;
|
||||
} {
|
||||
const dateIndexByTime = new Map<number, number>();
|
||||
dates.forEach((date, index) => {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
dateIndexByTime.set(normalized.getTime(), index);
|
||||
});
|
||||
|
||||
const resourceHeatmapById = new Map<string, (HeatmapHoverData | null)[]>();
|
||||
const resourceTotalHoursById = new Map<string, number[]>();
|
||||
|
||||
for (const [resourceId, allocs] of allocsByResource) {
|
||||
if (allocs.length === 0) continue;
|
||||
const capacity = resourceCapacityById?.get(resourceId);
|
||||
|
||||
const totalHours = new Array<number>(dates.length).fill(0);
|
||||
const breakdownMaps = Array.from(
|
||||
{ length: dates.length },
|
||||
() => new Map<string, HeatmapAccumulator>(),
|
||||
);
|
||||
|
||||
for (const alloc of allocs) {
|
||||
const current = new Date(alloc.startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(alloc.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current.getTime() <= end.getTime()) {
|
||||
const dayIndex = dateIndexByTime.get(current.getTime());
|
||||
if (dayIndex !== undefined) {
|
||||
const effectiveHours =
|
||||
alloc.hoursPerDay * (capacity?.bookingFactorsByDay[dayIndex] ?? 1);
|
||||
totalHours[dayIndex] = (totalHours[dayIndex] ?? 0) + effectiveHours;
|
||||
|
||||
const dayBreakdown = breakdownMaps[dayIndex];
|
||||
if (dayBreakdown) {
|
||||
const existing = dayBreakdown.get(alloc.projectId);
|
||||
if (existing) {
|
||||
existing.hours += effectiveHours;
|
||||
} else {
|
||||
dayBreakdown.set(alloc.projectId, {
|
||||
shortCode: alloc.project.shortCode,
|
||||
projectName: alloc.project.name,
|
||||
orderType: alloc.project.orderType,
|
||||
responsiblePerson:
|
||||
(alloc.project as { responsiblePerson?: string | null }).responsiblePerson ??
|
||||
null,
|
||||
hours: effectiveHours,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
resourceTotalHoursById.set(resourceId, totalHours);
|
||||
resourceHeatmapById.set(
|
||||
resourceId,
|
||||
totalHours.map((totalH, dayIndex) => {
|
||||
if (totalH === 0) return null;
|
||||
|
||||
const dayBreakdown = breakdownMaps[dayIndex];
|
||||
if (!dayBreakdown) return null;
|
||||
|
||||
const breakdown = [...dayBreakdown.entries()]
|
||||
.map(([projectId, value]) => ({
|
||||
projectId,
|
||||
shortCode: value.shortCode,
|
||||
projectName: value.projectName,
|
||||
orderType: value.orderType,
|
||||
responsiblePerson: value.responsiblePerson ?? null,
|
||||
hoursPerDay: value.hours,
|
||||
}))
|
||||
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
|
||||
|
||||
return {
|
||||
date: dates[dayIndex] ?? new Date(),
|
||||
totalH,
|
||||
pct:
|
||||
(capacity?.capacityHoursByDay[dayIndex] ?? 8) > 0
|
||||
? (totalH / (capacity?.capacityHoursByDay[dayIndex] ?? 8)) * 100
|
||||
: 0,
|
||||
breakdown,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
resourceHeatmapById,
|
||||
resourceTotalHoursById,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import type { TimelineDemandEntry, VacationEntry } from "./TimelineContext.js";
|
||||
import type { DemandHoverData } from "./TimelineTooltip.js";
|
||||
|
||||
export type TooltipPosition = {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
export function updateTooltipPosition(
|
||||
positionRef: MutableRefObject<TooltipPosition>,
|
||||
tooltipRef: RefObject<HTMLDivElement | null>,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
) {
|
||||
positionRef.current = { left: clientX + offsetX, top: clientY + offsetY };
|
||||
if (!tooltipRef.current) return;
|
||||
|
||||
tooltipRef.current.style.left = `${positionRef.current.left}px`;
|
||||
tooltipRef.current.style.top = `${positionRef.current.top}px`;
|
||||
}
|
||||
|
||||
export function cancelHoverFrame(frameRef: MutableRefObject<number | null>) {
|
||||
if (frameRef.current === null) return;
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
|
||||
export function findVacationHit<T extends { startDate: Date | string; endDate: Date | string }>(
|
||||
vacations: T[],
|
||||
date: Date,
|
||||
): T | null {
|
||||
const time = new Date(date);
|
||||
time.setHours(0, 0, 0, 0);
|
||||
const target = time.getTime();
|
||||
|
||||
return (
|
||||
vacations.find((vacation) => {
|
||||
const start = new Date(vacation.startDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(vacation.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
return target >= start.getTime() && target <= end.getTime();
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function collectResourcesWithVacations(
|
||||
vacationsByResource: Map<string, VacationEntry[]>,
|
||||
) {
|
||||
const result = new Set<string>();
|
||||
for (const [resourceId, vacations] of vacationsByResource) {
|
||||
if (vacations.length > 0) {
|
||||
result.add(resourceId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function scheduleVacationHoverUpdate<T extends { id: string; startDate: Date | string; endDate: Date | string }>(
|
||||
args: {
|
||||
frameRef: MutableRefObject<number | null>;
|
||||
hoveredKeyRef: MutableRefObject<string | null>;
|
||||
resourceId: string;
|
||||
clientX: number;
|
||||
rect: DOMRect;
|
||||
xToDate: (clientX: number, rect: DOMRect) => Date;
|
||||
vacations: T[];
|
||||
onHoverChange: (vacation: T | null) => void;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
frameRef,
|
||||
hoveredKeyRef,
|
||||
resourceId,
|
||||
clientX,
|
||||
rect,
|
||||
xToDate,
|
||||
vacations,
|
||||
onHoverChange,
|
||||
} = args;
|
||||
|
||||
if (frameRef.current !== null) return;
|
||||
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
const date = xToDate(clientX, rect);
|
||||
const hit = findVacationHit(vacations, date);
|
||||
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
|
||||
if (nextKey === hoveredKeyRef.current) return;
|
||||
|
||||
hoveredKeyRef.current = nextKey;
|
||||
onHoverChange(hit);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDemandHoverData(demand: TimelineDemandEntry): DemandHoverData {
|
||||
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);
|
||||
|
||||
return {
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { TimelineAssignmentEntry } from "./TimelineContext.js";
|
||||
import type { ResourceCapacitySeries } from "./timelineCapacity.js";
|
||||
|
||||
export type ProjectDayMetric = {
|
||||
projH: number;
|
||||
totalH: number;
|
||||
capacityH: number;
|
||||
};
|
||||
|
||||
type ProjectMetricRow = {
|
||||
resource: { id: string };
|
||||
allocs: TimelineAssignmentEntry[];
|
||||
};
|
||||
|
||||
type ProjectMetricGroup = {
|
||||
id: string;
|
||||
resourceRows: ProjectMetricRow[];
|
||||
};
|
||||
|
||||
export function getProjectRowMetricsKey(projectId: string, resourceId: string): string {
|
||||
return `${projectId}:${resourceId}`;
|
||||
}
|
||||
|
||||
export function buildProjectRowMetrics(
|
||||
dates: Date[],
|
||||
projectGroups: ProjectMetricGroup[],
|
||||
resourceTotalHoursById: Map<string, number[]>,
|
||||
resourceCapacityById: Map<string, ResourceCapacitySeries>,
|
||||
): Map<string, ProjectDayMetric[]> {
|
||||
const dateIndexByTime = new Map<number, number>();
|
||||
dates.forEach((date, index) => {
|
||||
const normalized = new Date(date);
|
||||
normalized.setHours(0, 0, 0, 0);
|
||||
dateIndexByTime.set(normalized.getTime(), index);
|
||||
});
|
||||
|
||||
const nextMetrics = new Map<string, ProjectDayMetric[]>();
|
||||
|
||||
for (const project of projectGroups) {
|
||||
for (const { resource, allocs } of project.resourceRows) {
|
||||
const projectHours = new Array<number>(dates.length).fill(0);
|
||||
const capacity = resourceCapacityById.get(resource.id);
|
||||
|
||||
for (const alloc of allocs) {
|
||||
const current = new Date(alloc.startDate);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const end = new Date(alloc.endDate);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
while (current.getTime() <= end.getTime()) {
|
||||
const dayIndex = dateIndexByTime.get(current.getTime());
|
||||
if (dayIndex !== undefined) {
|
||||
projectHours[dayIndex] =
|
||||
(projectHours[dayIndex] ?? 0) +
|
||||
alloc.hoursPerDay * (capacity?.bookingFactorsByDay[dayIndex] ?? 1);
|
||||
}
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const totalHours = resourceTotalHoursById.get(resource.id);
|
||||
nextMetrics.set(
|
||||
getProjectRowMetricsKey(project.id, resource.id),
|
||||
projectHours.map((projH, dayIndex) => ({
|
||||
projH,
|
||||
totalH: totalHours?.[dayIndex] ?? 0,
|
||||
capacityH: capacity?.capacityHoursByDay[dayIndex] ?? 8,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return nextMetrics;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
applyVisualOverrides,
|
||||
type TimelineVisualOverrides,
|
||||
} from "./allocationVisualState.js";
|
||||
import type {
|
||||
TimelineAssignmentEntry,
|
||||
TimelineDemandEntry,
|
||||
useTimelineContext,
|
||||
} from "./TimelineContext.js";
|
||||
import { PROJECT_HEADER_HEIGHT, ROW_HEIGHT, SUB_LANE_HEIGHT } from "./timelineConstants.js";
|
||||
import { getProjectRowMetricsKey } from "./timelineProjectMetrics.js";
|
||||
|
||||
export type TimelineProjectGroup = NonNullable<ReturnType<typeof useTimelineContext>["projectGroups"]>[number];
|
||||
|
||||
export type OpenDemandRowLayout = {
|
||||
visibleOpenDemands: TimelineDemandEntry[];
|
||||
laneMap: Map<string, number>;
|
||||
laneCount: number;
|
||||
rowHeight: number;
|
||||
};
|
||||
|
||||
export type ProjectFlatRow =
|
||||
| {
|
||||
type: "header";
|
||||
key: string;
|
||||
project: TimelineProjectGroup;
|
||||
}
|
||||
| {
|
||||
type: "open-demand";
|
||||
key: string;
|
||||
projectId: string;
|
||||
openDemandCount: number;
|
||||
layout: OpenDemandRowLayout;
|
||||
}
|
||||
| {
|
||||
type: "resource";
|
||||
key: string;
|
||||
project: TimelineProjectGroup;
|
||||
resource: TimelineProjectGroup["resourceRows"][number]["resource"];
|
||||
allocs: TimelineAssignmentEntry[];
|
||||
metricsKey: string;
|
||||
};
|
||||
|
||||
export function estimateProjectRowHeight(row: ProjectFlatRow | undefined) {
|
||||
if (!row) return ROW_HEIGHT;
|
||||
if (row.type === "header") return PROJECT_HEADER_HEIGHT;
|
||||
if (row.type === "open-demand") return row.layout.rowHeight;
|
||||
return ROW_HEIGHT;
|
||||
}
|
||||
|
||||
export function buildProjectFlatRows(
|
||||
visualProjectGroups: TimelineProjectGroup[],
|
||||
openDemandsByProject: Map<string, TimelineDemandEntry[]>,
|
||||
optimisticAllocations: TimelineVisualOverrides,
|
||||
): ProjectFlatRow[] {
|
||||
const rows: ProjectFlatRow[] = [];
|
||||
|
||||
for (const project of visualProjectGroups) {
|
||||
rows.push({ type: "header", key: `header-${project.id}`, project });
|
||||
|
||||
const openDemands = openDemandsByProject.get(project.id) ?? [];
|
||||
if (openDemands.length > 0) {
|
||||
rows.push({
|
||||
type: "open-demand",
|
||||
key: `open-demand-${project.id}`,
|
||||
projectId: project.id,
|
||||
openDemandCount: openDemands.length,
|
||||
layout: buildOpenDemandRowLayout(openDemands, optimisticAllocations),
|
||||
});
|
||||
}
|
||||
|
||||
for (const { resource, allocs } of project.resourceRows) {
|
||||
rows.push({
|
||||
type: "resource",
|
||||
key: `${project.id}-${resource.id}`,
|
||||
project,
|
||||
resource,
|
||||
allocs,
|
||||
metricsKey: getProjectRowMetricsKey(project.id, resource.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function assignDemandLanes(demands: TimelineDemandEntry[]): Map<string, number> {
|
||||
const laneMap = new Map<string, number>();
|
||||
const laneEnds: Date[] = [];
|
||||
|
||||
const sorted = [...demands].sort(
|
||||
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(),
|
||||
);
|
||||
|
||||
for (const demand of sorted) {
|
||||
const start = new Date(demand.startDate);
|
||||
let assigned = -1;
|
||||
for (let i = 0; i < laneEnds.length; i++) {
|
||||
if (laneEnds[i]! < start) {
|
||||
assigned = i;
|
||||
laneEnds[i] = new Date(demand.endDate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (assigned === -1) {
|
||||
assigned = laneEnds.length;
|
||||
laneEnds.push(new Date(demand.endDate));
|
||||
}
|
||||
laneMap.set(demand.id, assigned);
|
||||
}
|
||||
|
||||
return laneMap;
|
||||
}
|
||||
|
||||
function buildOpenDemandRowLayout(
|
||||
openDemands: TimelineDemandEntry[],
|
||||
optimisticAllocations: TimelineVisualOverrides,
|
||||
): OpenDemandRowLayout {
|
||||
const visibleOpenDemands = applyVisualOverrides(openDemands, optimisticAllocations);
|
||||
const laneMap = assignDemandLanes(visibleOpenDemands);
|
||||
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
|
||||
|
||||
return {
|
||||
visibleOpenDemands,
|
||||
laneMap,
|
||||
laneCount,
|
||||
rowHeight: Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16),
|
||||
};
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export function EntityCombobox<T extends { id: string }>({
|
||||
? createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-entity-combobox-overlay="true"
|
||||
className="fixed z-[9998] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-600 dark:bg-gray-800"
|
||||
style={{
|
||||
top: position.top,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { ProgressRing } from "~/components/ui/ProgressRing.js";
|
||||
|
||||
interface BalanceCardProps {
|
||||
resourceId: string;
|
||||
year?: number;
|
||||
@@ -11,7 +10,7 @@ interface BalanceCardProps {
|
||||
}
|
||||
|
||||
export function BalanceCard({ resourceId, year = new Date().getFullYear(), compact = false }: BalanceCardProps) {
|
||||
const { data: balance, isLoading } = trpc.entitlement.getBalance.useQuery(
|
||||
const { data: balance, isLoading } = trpc.entitlement.getBalanceDetail.useQuery(
|
||||
{ resourceId, year },
|
||||
{ staleTime: 30_000 },
|
||||
);
|
||||
@@ -27,20 +26,20 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
|
||||
|
||||
if (!balance) return null;
|
||||
|
||||
const pct = balance.entitledDays > 0
|
||||
? Math.round((balance.usedDays / balance.entitledDays) * 100)
|
||||
const pct = balance.entitlement > 0
|
||||
? Math.round((balance.taken / balance.entitlement) * 100)
|
||||
: 0;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{balance.remainingDays}d remaining</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">{balance.remaining}d remaining</span>
|
||||
<span className="text-gray-400 dark:text-gray-600">·</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{balance.usedDays}d used of {balance.entitledDays}d</span>
|
||||
{balance.pendingDays > 0 && (
|
||||
<span className="text-gray-500 dark:text-gray-400">{balance.taken}d used of {balance.entitlement}d</span>
|
||||
{balance.pending > 0 && (
|
||||
<>
|
||||
<span className="text-gray-400 dark:text-gray-600">·</span>
|
||||
<span className="text-amber-600">{balance.pendingDays}d pending</span>
|
||||
<span className="text-amber-600">{balance.pending}d pending</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -52,31 +51,36 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
|
||||
: pct >= 70
|
||||
? "var(--color-amber-500, #f59e0b)"
|
||||
: "var(--color-emerald-500, #10b981)";
|
||||
const holidayBasisVariants = balance.deductionSummary?.holidayBasisVariants ?? [];
|
||||
const excludedHolidayCount = balance.deductionSummary?.excludedHolidayDates.length ?? 0;
|
||||
const excludedHolidayTooltip = (balance.vacations ?? [])
|
||||
.flatMap((vacation) => vacation.holidayDetails.map((detail) => `${detail.date} · ${detail.source}`))
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProgressRing value={pct} size={52} strokeWidth={4} color={ringColor}>
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{balance.remainingDays}d</span>
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{balance.remaining}d</span>
|
||||
</ProgressRing>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Vacation Balance {year}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{balance.usedDays} of {balance.entitledDays} days used</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">{balance.taken} of {balance.entitlement} days used</p>
|
||||
</div>
|
||||
</div>
|
||||
{balance.carryoverDays > 0 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 inline-flex items-center">+{balance.carryoverDays}d carried over<InfoTooltip content="Unused days from the previous year. Automatically calculated on first access." /></span>
|
||||
{balance.carryOver > 0 && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 inline-flex items-center">+{balance.carryOver}d carried over<InfoTooltip content="Unused days from the previous year. Automatically calculated on first access." /></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900 dark:text-gray-100" tooltip="Total vacation days granted for this year, including carryover from previous year." />
|
||||
<Stat label="Used" value={balance.usedDays} color="text-gray-600 dark:text-gray-400" tooltip="Days already consumed by approved vacations that have passed." />
|
||||
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" tooltip="Days reserved by approved future vacations not yet started." />
|
||||
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} tooltip="Entitled - Used - Pending. Red if fewer than 5 days remain." />
|
||||
<Stat label="Entitled" value={balance.entitlement} color="text-gray-900 dark:text-gray-100" tooltip="Total vacation days granted for this year, including carryover from previous year." />
|
||||
<Stat label="Used" value={balance.taken} color="text-gray-600 dark:text-gray-400" tooltip="Days already consumed by approved vacations that have passed." />
|
||||
<Stat label="Pending" value={balance.pending} color="text-amber-600" tooltip="Days reserved by approved future vacations not yet started." />
|
||||
<Stat label="Remaining" value={balance.remaining} color={balance.remaining < 5 ? "text-red-600" : "text-emerald-600"} tooltip="Entitled - Used - Pending. Red if fewer than 5 days remain." />
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
@@ -85,12 +89,12 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
|
||||
className="absolute inset-y-0 left-0 bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, pct)}%` }}
|
||||
/>
|
||||
{balance.pendingDays > 0 && (
|
||||
{balance.pending > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 bg-amber-400 rounded-full"
|
||||
style={{
|
||||
left: `${Math.min(100, pct)}%`,
|
||||
width: `${Math.min(100 - pct, Math.round((balance.pendingDays / balance.entitledDays) * 100))}%`,
|
||||
width: `${Math.min(100 - pct, Math.round((balance.pending / balance.entitlement) * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -101,6 +105,35 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
|
||||
{balance.sickDays} sick day{balance.sickDays !== 1 ? "s" : ""} recorded (not deducted from annual leave)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!!balance.deductionSummary && (balance.deductionSummary.approvedVacationCount > 0 || balance.deductionSummary.pendingVacationCount > 0) && (
|
||||
<div className="rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-400">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Formula
|
||||
<InfoTooltip content={balance.deductionSummary.formula} />
|
||||
</span>
|
||||
<span>
|
||||
Vacation deductions: {balance.deductionSummary.approvedDeductedDays}d approved
|
||||
{balance.deductionSummary.pendingDeductedDays > 0 ? ` · ${balance.deductionSummary.pendingDeductedDays}d pending` : ""}
|
||||
</span>
|
||||
<span>
|
||||
Requested: {balance.deductionSummary.approvedRequestedDays + balance.deductionSummary.pendingRequestedDays}d
|
||||
</span>
|
||||
{excludedHolidayCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
Excluded holidays: {excludedHolidayCount}
|
||||
{excludedHolidayTooltip.length > 0 && <InfoTooltip content={excludedHolidayTooltip} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{holidayBasisVariants.length > 0 && (
|
||||
<div className="mt-1">
|
||||
Holiday basis: {holidayBasisVariants.join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,11 @@ export function EntitlementManager() {
|
||||
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Resource</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chapter</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
Location <InfoTooltip content="Country, state and city determine which regional holidays can reduce deducted vacation days differently per person." />
|
||||
</span>
|
||||
</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<span className="inline-flex items-center justify-end gap-0.5">
|
||||
Entitled <InfoTooltip content="Total vacation days granted to this resource for the selected year." />
|
||||
@@ -114,6 +119,11 @@ export function EntitlementManager() {
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({row.eid})</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{row.chapter ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">
|
||||
{[row.countryCode ?? row.countryName, row.federalState, row.metroCityName]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(" / ") || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.entitledDays}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-400 dark:text-gray-500">{row.carryoverDays}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.usedDays}</td>
|
||||
|
||||
@@ -32,6 +32,28 @@ type CountryRow = {
|
||||
metroCities: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type HolidayPreviewDetail = {
|
||||
count: number;
|
||||
locationContext: {
|
||||
countryCode: string | null;
|
||||
stateCode: string | null;
|
||||
metroCity: string | null;
|
||||
year: number;
|
||||
};
|
||||
summary: {
|
||||
byScope: Array<{ scope: string; count: number }>;
|
||||
bySourceType: Array<{ sourceType: string; count: number }>;
|
||||
byCalendar: Array<{ calendarName: string; count: number }>;
|
||||
};
|
||||
holidays: Array<{
|
||||
date: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
calendarName: string;
|
||||
sourceType: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<ScopeType, string> = {
|
||||
COUNTRY: "Land",
|
||||
STATE: "Bundesland/Region",
|
||||
@@ -87,7 +109,15 @@ export function HolidayCalendarEditor() {
|
||||
return rows.find((country) => country.id === selectedCalendar?.country.id) ?? null;
|
||||
}, [countries, selectedCalendar]);
|
||||
|
||||
const previewQuery = trpc.holidayCalendar.previewResolvedHolidays.useQuery(
|
||||
const previewQuery = (trpc.holidayCalendar.previewResolvedHolidaysDetail.useQuery as unknown as (
|
||||
input: {
|
||||
countryId: string;
|
||||
year: number;
|
||||
stateCode?: string;
|
||||
metroCityId?: string;
|
||||
},
|
||||
options: { enabled: boolean; staleTime: number },
|
||||
) => { data: HolidayPreviewDetail | undefined; isLoading: boolean })(
|
||||
{
|
||||
countryId: selectedCalendar?.country.id ?? countryId,
|
||||
year: previewYear,
|
||||
@@ -105,6 +135,7 @@ export function HolidayCalendarEditor() {
|
||||
utils.holidayCalendar.listCalendars.invalidate(),
|
||||
utils.holidayCalendar.getCalendarById.invalidate(),
|
||||
utils.holidayCalendar.previewResolvedHolidays.invalidate(),
|
||||
utils.holidayCalendar.previewResolvedHolidaysDetail.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -829,26 +860,45 @@ export function HolidayCalendarEditor() {
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{previewQuery.data && (
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300">
|
||||
<div className="font-medium text-gray-700 dark:text-gray-200">
|
||||
Basis: {[
|
||||
previewQuery.data.locationContext.countryCode,
|
||||
previewQuery.data.locationContext.stateCode,
|
||||
previewQuery.data.locationContext.metroCity,
|
||||
].filter(Boolean).join(" / ") || "n/a"}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
Scope: {previewQuery.data.summary.byScope.map((item) => `${item.scope} ${item.count}`).join(" · ") || "none"}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
Sources: {previewQuery.data.summary.bySourceType.map((item) => `${item.sourceType} ${item.count}`).join(" · ") || "none"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<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">Scope</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 && (
|
||||
{(!previewQuery.data || previewQuery.data.holidays.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-6 text-center text-sm text-gray-400">
|
||||
<td colSpan={4} 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) => (
|
||||
{(previewQuery.data?.holidays ?? []).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.scope}</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{entry.calendarName}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -8,6 +8,17 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { BalanceCard } from "./BalanceCard.js";
|
||||
import { VacationCalendar } from "./VacationCalendar.js";
|
||||
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js";
|
||||
import { getHolidayBasis, getHolidayBreakdown, getRequestedDays, type VacationExplainabilityEntry } from "./vacationExplainability.js";
|
||||
|
||||
type VacationListItem = VacationExplainabilityEntry & {
|
||||
id: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
status: string;
|
||||
type: string;
|
||||
note?: string | null;
|
||||
rejectionReason?: string | null;
|
||||
};
|
||||
|
||||
export function MyVacationsClient() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -21,8 +32,17 @@ export function MyVacationsClient() {
|
||||
|
||||
const resourceId = myResource?.id;
|
||||
|
||||
const { data: vacations, isLoading, refetch } = trpc.vacation.list.useQuery(
|
||||
{ resourceId, limit: 200 },
|
||||
const vacationListQuery = trpc.vacation.list.useQuery as unknown as (
|
||||
input: { limit: number; resourceId?: string | undefined },
|
||||
options: { enabled: boolean; staleTime: number },
|
||||
) => {
|
||||
data: VacationListItem[] | undefined;
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
const { data: vacations, isLoading, refetch } = vacationListQuery(
|
||||
{ limit: 200, ...(resourceId ? { resourceId } : {}) },
|
||||
{ enabled: !!resourceId, staleTime: 15_000 },
|
||||
);
|
||||
|
||||
@@ -33,7 +53,7 @@ export function MyVacationsClient() {
|
||||
},
|
||||
});
|
||||
|
||||
const vacationList = vacations ?? [];
|
||||
const vacationList: VacationListItem[] = vacations ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
@@ -102,10 +122,13 @@ export function MyVacationsClient() {
|
||||
{vacationList.map((v) => {
|
||||
const start = new Date(v.startDate);
|
||||
const end = new Date(v.endDate);
|
||||
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
|
||||
const requestedDays = getRequestedDays(v);
|
||||
const deductedDays = typeof v.deductedDays === "number" ? v.deductedDays : null;
|
||||
const holidayBasis = getHolidayBasis(v);
|
||||
const holidayBreakdown = getHolidayBreakdown(v);
|
||||
const status = v.status as string;
|
||||
const type = v.type as string;
|
||||
const vWithExtra = v as unknown as { rejectionReason?: string | null; isHalfDay?: boolean };
|
||||
const affectsBalance = type === VacationType.ANNUAL || type === VacationType.OTHER;
|
||||
|
||||
return (
|
||||
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
@@ -116,16 +139,54 @@ export function MyVacationsClient() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{start.toLocaleDateString("en-GB")}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{end.toLocaleDateString("en-GB")}</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{vWithExtra.isHalfDay ? "0.5" : days}</td>
|
||||
<td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">
|
||||
<div className="space-y-1">
|
||||
<div>{requestedDays}</div>
|
||||
{affectsBalance && deductedDays !== null && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500">
|
||||
Deducted: {deductedDays}
|
||||
</div>
|
||||
)}
|
||||
{affectsBalance && deductedDays === 0 && holidayBreakdown.length > 0 && (
|
||||
<div className="text-[11px] text-emerald-600 dark:text-emerald-400">
|
||||
Fully covered by holidays
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}>
|
||||
{status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-[200px]">
|
||||
{vWithExtra.rejectionReason ? (
|
||||
<span className="text-red-500">{vWithExtra.rejectionReason}</span>
|
||||
) : (v.note ?? "—")}
|
||||
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-[280px]">
|
||||
{v.rejectionReason ? (
|
||||
<span className="text-red-500">{v.rejectionReason}</span>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div>{v.note ?? "—"}</div>
|
||||
{affectsBalance && holidayBasis.length > 0 && (
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
Holiday basis: {holidayBasis.join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
{affectsBalance && holidayBreakdown.length > 0 && (
|
||||
<div className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
Excluded holidays: {holidayBreakdown.map((holiday) => `${holiday.date} (${holiday.source})`).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!v.rejectionReason && affectsBalance && deductedDays !== null && deductedDays !== requestedDays && holidayBreakdown.length === 0 && (
|
||||
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
Local holiday-adjusted deduction snapshot applied.
|
||||
</div>
|
||||
)}
|
||||
{!v.rejectionReason && !affectsBalance && (
|
||||
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
This leave type does not reduce annual vacation entitlement.
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{(status === VacationStatus.PENDING || status === VacationStatus.APPROVED) && (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { VacationStatus, VacationType } from "@capakraken/shared";
|
||||
import { VacationStatus } from "@capakraken/shared";
|
||||
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
|
||||
import { buildVacationExplainabilityTooltip, type VacationExplainabilityEntry } from "./vacationExplainability.js";
|
||||
|
||||
interface VacationEntry {
|
||||
interface VacationEntry extends VacationExplainabilityEntry {
|
||||
id: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
@@ -142,11 +143,12 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
|
||||
const colorClass = VACATION_CALENDAR_COLORS[v.type] ?? "bg-gray-400";
|
||||
const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100";
|
||||
const name = v.resource?.displayName ?? "—";
|
||||
const explainabilityTitle = buildVacationExplainabilityTooltip(v);
|
||||
return (
|
||||
<div
|
||||
key={v.id + dateStr}
|
||||
className={`${colorClass} ${opacityClass} text-white text-xs px-1 rounded truncate`}
|
||||
title={`${name} — ${v.type} (${v.status})`}
|
||||
title={[`${name} — ${v.type} (${v.status})`, explainabilityTitle].filter(Boolean).join("\n")}
|
||||
>
|
||||
{name.split(" ")[0]}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
export const HOLIDAY_SOURCE_LABELS = {
|
||||
CALENDAR: "Holiday Calendar",
|
||||
LEGACY_PUBLIC_HOLIDAY: "Legacy import",
|
||||
CALENDAR_AND_LEGACY: "Holiday Calendar + legacy",
|
||||
} as const;
|
||||
|
||||
export type VacationExplainabilityEntry = {
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
isHalfDay?: boolean | null;
|
||||
deductedDays?: number | null;
|
||||
holidayCountryCode?: string | null;
|
||||
holidayCountryName?: string | null;
|
||||
holidayFederalState?: string | null;
|
||||
holidayMetroCityName?: string | null;
|
||||
holidayCalendarDates?: unknown;
|
||||
holidayLegacyPublicHolidayDates?: unknown;
|
||||
};
|
||||
|
||||
function toSortedDateList(value: unknown): string[] {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
||||
? [...value].sort()
|
||||
: [];
|
||||
}
|
||||
|
||||
export function getRequestedDays(vacation: Pick<VacationExplainabilityEntry, "startDate" | "endDate" | "isHalfDay">): number {
|
||||
if (vacation.isHalfDay) {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
const start = new Date(vacation.startDate);
|
||||
const end = new Date(vacation.endDate);
|
||||
return Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
export function getHolidayBasis(vacation: VacationExplainabilityEntry): string[] {
|
||||
return [
|
||||
vacation.holidayCountryName ?? vacation.holidayCountryCode ?? null,
|
||||
vacation.holidayFederalState ?? null,
|
||||
vacation.holidayMetroCityName ?? null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
export function getHolidayBreakdown(vacation: VacationExplainabilityEntry): Array<{ date: string; source: string }> {
|
||||
const calendarDates = toSortedDateList(vacation.holidayCalendarDates);
|
||||
const legacyDates = toSortedDateList(vacation.holidayLegacyPublicHolidayDates);
|
||||
const uniqueDates = [...new Set([...calendarDates, ...legacyDates])].sort();
|
||||
|
||||
return uniqueDates.map((date) => ({
|
||||
date,
|
||||
source:
|
||||
calendarDates.includes(date) && legacyDates.includes(date)
|
||||
? HOLIDAY_SOURCE_LABELS.CALENDAR_AND_LEGACY
|
||||
: calendarDates.includes(date)
|
||||
? HOLIDAY_SOURCE_LABELS.CALENDAR
|
||||
: HOLIDAY_SOURCE_LABELS.LEGACY_PUBLIC_HOLIDAY,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildVacationExplainabilityTooltip(
|
||||
vacation: VacationExplainabilityEntry & { type?: string | null },
|
||||
): string | null {
|
||||
const requestedDays = getRequestedDays(vacation);
|
||||
const deductedDays = typeof vacation.deductedDays === "number" ? vacation.deductedDays : null;
|
||||
const holidayBasis = getHolidayBasis(vacation);
|
||||
const holidayBreakdown = getHolidayBreakdown(vacation);
|
||||
const lines = [`Requested: ${requestedDays}d`];
|
||||
|
||||
if (deductedDays !== null) {
|
||||
lines.push(`Deducted: ${deductedDays}d`);
|
||||
}
|
||||
|
||||
if (holidayBasis.length > 0) {
|
||||
lines.push(`Holiday basis: ${holidayBasis.join(" / ")}`);
|
||||
}
|
||||
|
||||
if (holidayBreakdown.length > 0) {
|
||||
lines.push(`Excluded holidays: ${holidayBreakdown.map((holiday) => `${holiday.date} (${holiday.source})`).join(", ")}`);
|
||||
}
|
||||
|
||||
if ((vacation.type === "SICK" || vacation.type === "PUBLIC_HOLIDAY") && deductedDays === 0) {
|
||||
lines.push("Does not reduce annual vacation entitlement.");
|
||||
}
|
||||
|
||||
return lines.length > 0 ? lines.join("\n") : null;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ interface UseAnchoredOverlayOptions<TTrigger extends HTMLElement> {
|
||||
crossAlign?: VerticalAlign;
|
||||
matchTriggerWidth?: boolean;
|
||||
triggerRef?: RefObject<TTrigger | null>;
|
||||
ignoreElements?: Array<HTMLElement | null>;
|
||||
ignoreSelectors?: string[];
|
||||
}
|
||||
|
||||
interface OverlayPosition {
|
||||
@@ -32,15 +34,19 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
|
||||
crossAlign = "start",
|
||||
matchTriggerWidth = false,
|
||||
triggerRef: externalTriggerRef,
|
||||
ignoreElements = [],
|
||||
ignoreSelectors = [],
|
||||
}: UseAnchoredOverlayOptions<TTrigger>) {
|
||||
const internalTriggerRef = useRef<TTrigger | null>(null);
|
||||
const triggerRef = externalTriggerRef ?? internalTriggerRef;
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const [position, setPosition] = useState<OverlayPosition>({ top: 0, left: 0 });
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) {
|
||||
if (!trigger || !trigger.isConnected) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,18 +95,30 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
|
||||
left: boundedLeft,
|
||||
...(matchTriggerWidth ? { minWidth: rect.width } : {}),
|
||||
});
|
||||
}, [align, crossAlign, matchTriggerWidth, offset, side, triggerRef, viewportPadding]);
|
||||
}, [align, crossAlign, matchTriggerWidth, offset, onClose, side, triggerRef, viewportPadding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreElements.some((element) => element?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target instanceof Element
|
||||
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -110,30 +128,52 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
}, [ignoreElements, ignoreSelectors, onClose, open, triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
updatePosition();
|
||||
const rafId = window.requestAnimationFrame(updatePosition);
|
||||
function cancelScheduledFrame() {
|
||||
if (frameRef.current === null) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
function scheduleUpdate() {
|
||||
if (frameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
|
||||
updatePosition();
|
||||
scheduleUpdate();
|
||||
|
||||
window.addEventListener("resize", scheduleUpdate, { passive: true });
|
||||
window.addEventListener("scroll", scheduleUpdate, true);
|
||||
window.visualViewport?.addEventListener("resize", scheduleUpdate, { passive: true });
|
||||
window.visualViewport?.addEventListener("scroll", scheduleUpdate, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
cancelScheduledFrame();
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
window.removeEventListener("scroll", scheduleUpdate, true);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleUpdate);
|
||||
window.visualViewport?.removeEventListener("scroll", scheduleUpdate);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useInvalidateTimeline() {
|
||||
void utils.timeline.getMyEntriesView.invalidate();
|
||||
void utils.timeline.getHolidayOverlays.invalidate();
|
||||
void utils.timeline.getMyHolidayOverlays.invalidate();
|
||||
void utils.vacation.list.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
@@ -31,6 +32,7 @@ export function useInvalidatePlanningViews() {
|
||||
void utils.timeline.getMyEntriesView.invalidate();
|
||||
void utils.timeline.getHolidayOverlays.invalidate();
|
||||
void utils.timeline.getMyHolidayOverlays.invalidate();
|
||||
void utils.vacation.list.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
||||
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
|
||||
|
||||
const DRAG_CLICK_THRESHOLD_PX = 5;
|
||||
|
||||
// ─── Project-shift drag state ───────────────────────────────────────────────
|
||||
|
||||
export interface DragState {
|
||||
@@ -17,6 +19,7 @@ export interface DragState {
|
||||
currentStartDate: Date | null;
|
||||
currentEndDate: Date | null;
|
||||
startMouseX: number;
|
||||
pointerDeltaX: number;
|
||||
originalLeft: number;
|
||||
blockWidth: number;
|
||||
daysDelta: number;
|
||||
@@ -50,6 +53,7 @@ const INITIAL_DRAG_STATE: DragState = {
|
||||
currentStartDate: null,
|
||||
currentEndDate: null,
|
||||
startMouseX: 0,
|
||||
pointerDeltaX: 0,
|
||||
originalLeft: 0,
|
||||
blockWidth: 0,
|
||||
daysDelta: 0,
|
||||
@@ -58,39 +62,175 @@ const INITIAL_DRAG_STATE: DragState = {
|
||||
// ─── Per-allocation drag state ──────────────────────────────────────────────
|
||||
|
||||
export type AllocDragMode = "move" | "resize-start" | "resize-end";
|
||||
export type AllocDragScope = "allocation" | "segment";
|
||||
|
||||
export interface AllocDragState {
|
||||
isActive: boolean;
|
||||
mode: AllocDragMode;
|
||||
scope: AllocDragScope;
|
||||
allocationId: string | null;
|
||||
mutationAllocationId: string | null;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
resourceId: string | null;
|
||||
allocationStartDate: Date | null;
|
||||
allocationEndDate: Date | null;
|
||||
originalStartDate: Date | null;
|
||||
originalEndDate: Date | null;
|
||||
currentStartDate: Date | null;
|
||||
currentEndDate: Date | null;
|
||||
startMouseX: number;
|
||||
pointerDeltaX: number;
|
||||
daysDelta: number;
|
||||
}
|
||||
|
||||
const INITIAL_ALLOC_DRAG: AllocDragState = {
|
||||
isActive: false,
|
||||
mode: "move",
|
||||
scope: "allocation",
|
||||
allocationId: null,
|
||||
mutationAllocationId: null,
|
||||
projectId: null,
|
||||
projectName: null,
|
||||
resourceId: null,
|
||||
allocationStartDate: null,
|
||||
allocationEndDate: null,
|
||||
originalStartDate: null,
|
||||
originalEndDate: null,
|
||||
currentStartDate: null,
|
||||
currentEndDate: null,
|
||||
startMouseX: 0,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
|
||||
type LivePreviewMode = AllocDragMode;
|
||||
|
||||
type LivePreviewTarget = {
|
||||
element: HTMLElement;
|
||||
baseLeft: number;
|
||||
baseWidth: number;
|
||||
baseTransform: string;
|
||||
};
|
||||
|
||||
type LivePreviewSession = {
|
||||
mode: LivePreviewMode;
|
||||
cellWidth: number;
|
||||
targets: LivePreviewTarget[];
|
||||
pointerDeltaX: number;
|
||||
daysDelta: number;
|
||||
frame: number | null;
|
||||
};
|
||||
|
||||
function toPxValue(value: string): number {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function joinTransforms(...parts: Array<string | undefined>): string {
|
||||
return parts
|
||||
.map((part) => part?.trim())
|
||||
.filter((part): part is string => Boolean(part) && part !== "none")
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function captureLivePreviewTargets(elements: Iterable<HTMLElement>): LivePreviewTarget[] {
|
||||
const seen = new Set<HTMLElement>();
|
||||
const targets: LivePreviewTarget[] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
if (seen.has(element)) continue;
|
||||
seen.add(element);
|
||||
targets.push({
|
||||
element,
|
||||
baseLeft: toPxValue(element.style.left),
|
||||
baseWidth: toPxValue(element.style.width),
|
||||
baseTransform: element.style.transform,
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function renderLivePreview(session: LivePreviewSession) {
|
||||
const pointerOffsetX = session.pointerDeltaX - session.daysDelta * session.cellWidth;
|
||||
|
||||
for (const target of session.targets) {
|
||||
let left = target.baseLeft;
|
||||
let width = target.baseWidth;
|
||||
|
||||
if (session.mode === "move") {
|
||||
left += session.daysDelta * session.cellWidth;
|
||||
} else if (session.mode === "resize-start") {
|
||||
left += session.daysDelta * session.cellWidth;
|
||||
width = Math.max(session.cellWidth, width - session.daysDelta * session.cellWidth);
|
||||
} else {
|
||||
width = Math.max(session.cellWidth, width + session.daysDelta * session.cellWidth);
|
||||
}
|
||||
|
||||
if (session.mode === "resize-start") {
|
||||
const nextWidth = width - pointerOffsetX;
|
||||
if (nextWidth < session.cellWidth) {
|
||||
left += width - session.cellWidth;
|
||||
width = session.cellWidth;
|
||||
} else {
|
||||
left += pointerOffsetX;
|
||||
width = nextWidth;
|
||||
}
|
||||
} else if (session.mode === "resize-end") {
|
||||
width = Math.max(session.cellWidth, width + pointerOffsetX);
|
||||
}
|
||||
|
||||
target.element.style.left = `${left}px`;
|
||||
target.element.style.width = `${Math.max(session.cellWidth, width)}px`;
|
||||
target.element.style.transform = joinTransforms(
|
||||
target.baseTransform,
|
||||
session.mode === "move" && pointerOffsetX !== 0
|
||||
? `translateX(${pointerOffsetX}px)`
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLivePreview(session: LivePreviewSession) {
|
||||
if (session.frame !== null) return;
|
||||
session.frame = requestAnimationFrame(() => {
|
||||
session.frame = null;
|
||||
renderLivePreview(session);
|
||||
});
|
||||
}
|
||||
|
||||
function clearLivePreview(session: LivePreviewSession | null) {
|
||||
if (!session) return;
|
||||
if (session.frame !== null) {
|
||||
cancelAnimationFrame(session.frame);
|
||||
}
|
||||
|
||||
for (const target of session.targets) {
|
||||
target.element.style.left = `${target.baseLeft}px`;
|
||||
target.element.style.width = `${target.baseWidth}px`;
|
||||
target.element.style.transform = target.baseTransform;
|
||||
}
|
||||
}
|
||||
|
||||
function datesMatch(a: Date | null, b: Date | null) {
|
||||
return Boolean(a && b) && a!.getTime() === b!.getTime();
|
||||
}
|
||||
|
||||
function preserveLivePreview(session: LivePreviewSession | null) {
|
||||
if (!session) return;
|
||||
if (session.frame !== null) {
|
||||
cancelAnimationFrame(session.frame);
|
||||
session.frame = null;
|
||||
}
|
||||
|
||||
for (const target of session.targets) {
|
||||
target.baseLeft = toPxValue(target.element.style.left);
|
||||
target.baseWidth = toPxValue(target.element.style.width);
|
||||
target.baseTransform = target.element.style.transform;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Range-select state ─────────────────────────────────────────────────────
|
||||
|
||||
export interface RangeState {
|
||||
@@ -163,8 +303,19 @@ export interface AllocationMovedSnapshot {
|
||||
after: { startDate: Date; endDate: Date };
|
||||
}
|
||||
|
||||
export interface OptimisticTimelineEntry {
|
||||
id: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
}
|
||||
|
||||
export interface OptimisticTimelineOverride {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export function useTimelineDrag({
|
||||
cellWidth,
|
||||
cellWidthRef,
|
||||
onShiftApplied,
|
||||
onBlockClick,
|
||||
onRangeSelected,
|
||||
@@ -172,7 +323,7 @@ export function useTimelineDrag({
|
||||
onShiftClickAlloc,
|
||||
onMultiDragComplete,
|
||||
}: {
|
||||
cellWidth: number;
|
||||
cellWidthRef: MutableRefObject<number>;
|
||||
onShiftApplied?: (projectId: string) => void;
|
||||
onBlockClick?: (info: BlockClickInfo) => void;
|
||||
onRangeSelected?: (info: RangeSelectedInfo) => void;
|
||||
@@ -189,13 +340,14 @@ export function useTimelineDrag({
|
||||
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
|
||||
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
|
||||
const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
|
||||
const projectPreviewRef = useRef<LivePreviewSession | null>(null);
|
||||
const allocPreviewRef = useRef<LivePreviewSession | null>(null);
|
||||
const projectDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const allocDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const multiSelectCleanupRef = useRef<(() => void) | null>(null);
|
||||
// Keep ref in sync with state so document-level handlers read the latest selection
|
||||
multiSelectRef.current = multiSelectState;
|
||||
|
||||
// Keep always-current refs for values used inside document event handlers
|
||||
const cellWidthRef = useRef(cellWidth);
|
||||
cellWidthRef.current = cellWidth;
|
||||
|
||||
// Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
|
||||
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({
|
||||
x: 0,
|
||||
@@ -218,6 +370,148 @@ export function useTimelineDrag({
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
|
||||
clearLivePreview(projectPreviewRef.current);
|
||||
|
||||
const projectTargets = captureLivePreviewTargets(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
`[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${projectId}"]`,
|
||||
),
|
||||
);
|
||||
|
||||
if (projectTargets.length === 0 && currentTarget instanceof HTMLElement) {
|
||||
projectTargets.push(...captureLivePreviewTargets([currentTarget]));
|
||||
}
|
||||
|
||||
projectPreviewRef.current =
|
||||
projectTargets.length > 0
|
||||
? {
|
||||
mode: "move",
|
||||
cellWidth: cellWidthRef.current,
|
||||
targets: projectTargets,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
}
|
||||
: null;
|
||||
}, []);
|
||||
|
||||
const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
|
||||
clearLivePreview(allocPreviewRef.current);
|
||||
|
||||
const root =
|
||||
currentTarget instanceof HTMLElement
|
||||
? currentTarget.closest<HTMLElement>('[data-timeline-drag-preview~="allocation"]')
|
||||
: null;
|
||||
const targets = root ? captureLivePreviewTargets([root]) : [];
|
||||
|
||||
allocPreviewRef.current =
|
||||
targets.length > 0
|
||||
? {
|
||||
mode,
|
||||
cellWidth: cellWidthRef.current,
|
||||
targets,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
}
|
||||
: null;
|
||||
}, []);
|
||||
|
||||
const updateLivePreview = useCallback(
|
||||
(previewRef: MutableRefObject<LivePreviewSession | null>, pointerDeltaX: number, daysDelta: number) => {
|
||||
const preview = previewRef.current;
|
||||
if (!preview) return;
|
||||
preview.cellWidth = cellWidthRef.current;
|
||||
preview.pointerDeltaX = pointerDeltaX;
|
||||
preview.daysDelta = daysDelta;
|
||||
scheduleLivePreview(preview);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateProjectDragPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) return false;
|
||||
|
||||
const deltaX = clientX - drag.startMouseX;
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||
updateLivePreview(projectPreviewRef, deltaX, daysDelta);
|
||||
|
||||
if (daysDelta === drag.daysDelta) {
|
||||
if (deltaX !== drag.pointerDeltaX) {
|
||||
dragStateRef.current = { ...drag, pointerDeltaX: deltaX };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
"move",
|
||||
drag.originalStartDate,
|
||||
drag.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
const updated: DragState = {
|
||||
...drag,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
pointerDeltaX: deltaX,
|
||||
daysDelta,
|
||||
};
|
||||
dragStateRef.current = updated;
|
||||
setDragState(updated);
|
||||
return true;
|
||||
},
|
||||
[updateLivePreview],
|
||||
);
|
||||
|
||||
const updateAllocationDragPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
const alloc = allocDragRef.current;
|
||||
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return false;
|
||||
|
||||
const pointerDeltaX = clientX - alloc.startMouseX;
|
||||
const daysDelta = pixelsToDays(pointerDeltaX, cellWidthRef.current);
|
||||
updateLivePreview(allocPreviewRef, pointerDeltaX, daysDelta);
|
||||
|
||||
if (daysDelta === alloc.daysDelta) {
|
||||
if (pointerDeltaX !== alloc.pointerDeltaX) {
|
||||
allocDragRef.current = { ...alloc, pointerDeltaX };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
alloc.mode,
|
||||
alloc.originalStartDate,
|
||||
alloc.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
|
||||
const updated: AllocDragState = {
|
||||
...alloc,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
pointerDeltaX,
|
||||
daysDelta,
|
||||
};
|
||||
allocDragRef.current = updated;
|
||||
setAllocDragState(updated);
|
||||
return true;
|
||||
},
|
||||
[updateLivePreview],
|
||||
);
|
||||
|
||||
const clearProjectDragSession = useCallback(() => {
|
||||
projectDragCleanupRef.current?.();
|
||||
projectDragCleanupRef.current = null;
|
||||
clearLivePreview(projectPreviewRef.current);
|
||||
projectPreviewRef.current = null;
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
}, []);
|
||||
|
||||
// Project-shift preview
|
||||
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
|
||||
{
|
||||
@@ -248,7 +542,46 @@ export function useTimelineDrag({
|
||||
mutateAsync: (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const finalizeProjectDrag = useCallback(
|
||||
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => {
|
||||
updateProjectDragPosition(clientX);
|
||||
const finalDrag = dragStateRef.current;
|
||||
if (!finalDrag.isDragging) return null;
|
||||
|
||||
const mutationInput =
|
||||
finalDrag.daysDelta !== 0 &&
|
||||
finalDrag.projectId &&
|
||||
finalDrag.currentStartDate &&
|
||||
finalDrag.currentEndDate
|
||||
? {
|
||||
projectId: finalDrag.projectId,
|
||||
newStartDate: finalDrag.currentStartDate,
|
||||
newEndDate: finalDrag.currentEndDate,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (finalDrag.daysDelta !== 0) {
|
||||
preserveLivePreview(projectPreviewRef.current);
|
||||
}
|
||||
|
||||
clearProjectDragSession();
|
||||
|
||||
if (!mutationInput) return null;
|
||||
if (mode === "mutateAsync") {
|
||||
return applyShiftMutation.mutateAsync(mutationInput);
|
||||
}
|
||||
|
||||
applyShiftMutation.mutate(mutationInput);
|
||||
return null;
|
||||
},
|
||||
[applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
|
||||
);
|
||||
|
||||
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
|
||||
const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
|
||||
const [optimisticAllocations, setOptimisticAllocations] = useState<Map<string, OptimisticTimelineOverride>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -259,8 +592,60 @@ export function useTimelineDrag({
|
||||
pendingSnapshotRef.current = null;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
clearPendingOptimisticAllocation();
|
||||
},
|
||||
});
|
||||
|
||||
const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidateTimeline();
|
||||
},
|
||||
});
|
||||
|
||||
const clearPendingOptimisticAllocation = useCallback((allocationId?: string | null) => {
|
||||
pendingSnapshotRef.current = null;
|
||||
const optimisticAllocationId = allocationId ?? pendingOptimisticAllocationIdRef.current;
|
||||
if (!optimisticAllocationId) {
|
||||
pendingOptimisticAllocationIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setOptimisticAllocations((prev) => {
|
||||
if (!prev.has(optimisticAllocationId)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(optimisticAllocationId);
|
||||
return next;
|
||||
});
|
||||
pendingOptimisticAllocationIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => {
|
||||
setOptimisticAllocations((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
|
||||
const next = new Map(prev);
|
||||
for (const entry of entries) {
|
||||
const override = next.get(entry.id);
|
||||
if (!override) continue;
|
||||
|
||||
const startTime = new Date(entry.startDate).getTime();
|
||||
const endTime = new Date(entry.endDate).getTime();
|
||||
if (
|
||||
startTime === override.startDate.getTime() &&
|
||||
endTime === override.endDate.getTime()
|
||||
) {
|
||||
next.delete(entry.id);
|
||||
if (pendingOptimisticAllocationIdRef.current === entry.id) {
|
||||
pendingOptimisticAllocationIdRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
|
||||
|
||||
const onProjectBarMouseDown = useCallback(
|
||||
@@ -286,14 +671,36 @@ export function useTimelineDrag({
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
pointerDeltaX: 0,
|
||||
originalLeft: 0,
|
||||
blockWidth: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
dragStateRef.current = state;
|
||||
setDragState(state);
|
||||
|
||||
setProjectPreviewTargets(opts.projectId, e.currentTarget);
|
||||
projectDragCleanupRef.current?.();
|
||||
|
||||
function handleMove(ev: MouseEvent) {
|
||||
updateProjectDragPosition(ev.clientX);
|
||||
}
|
||||
|
||||
function handleUp(ev: MouseEvent) {
|
||||
projectDragCleanupRef.current?.();
|
||||
projectDragCleanupRef.current = null;
|
||||
void finalizeProjectDrag(ev.clientX);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
projectDragCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
},
|
||||
[],
|
||||
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
|
||||
);
|
||||
|
||||
// Legacy — kept for backward compat (triggers project shift from allocation block)
|
||||
@@ -323,6 +730,7 @@ export function useTimelineDrag({
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
pointerDeltaX: 0,
|
||||
originalLeft: opts.blockLeft,
|
||||
blockWidth: opts.blockWidth,
|
||||
daysDelta: 0,
|
||||
@@ -351,6 +759,9 @@ export function useTimelineDrag({
|
||||
resourceId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allocationStartDate?: Date;
|
||||
allocationEndDate?: Date;
|
||||
scope?: AllocDragScope;
|
||||
},
|
||||
) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -373,6 +784,7 @@ export function useTimelineDrag({
|
||||
|
||||
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
|
||||
multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };
|
||||
multiSelectCleanupRef.current?.();
|
||||
|
||||
function handleMultiMove(ev: MouseEvent) {
|
||||
const deltaX = ev.clientX - startMouseX;
|
||||
@@ -384,11 +796,11 @@ export function useTimelineDrag({
|
||||
multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
|
||||
}
|
||||
|
||||
function handleMultiUp() {
|
||||
document.removeEventListener("mousemove", handleMultiMove);
|
||||
document.removeEventListener("mouseup", handleMultiUp);
|
||||
function handleMultiUp(ev: MouseEvent) {
|
||||
multiSelectCleanupRef.current?.();
|
||||
multiSelectCleanupRef.current = null;
|
||||
|
||||
const finalDelta = currentDaysDelta;
|
||||
const finalDelta = pixelsToDays(ev.clientX - startMouseX, cellWidthRef.current);
|
||||
|
||||
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
|
||||
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
|
||||
@@ -402,6 +814,10 @@ export function useTimelineDrag({
|
||||
|
||||
document.addEventListener("mousemove", handleMultiMove);
|
||||
document.addEventListener("mouseup", handleMultiUp);
|
||||
multiSelectCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", handleMultiMove);
|
||||
document.removeEventListener("mouseup", handleMultiUp);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -410,56 +826,57 @@ export function useTimelineDrag({
|
||||
const initial: AllocDragState = {
|
||||
isActive: true,
|
||||
mode: opts.mode,
|
||||
scope: opts.scope ?? "allocation",
|
||||
allocationId: opts.allocationId,
|
||||
mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId,
|
||||
projectId: opts.projectId,
|
||||
projectName: opts.projectName,
|
||||
resourceId: opts.resourceId,
|
||||
allocationStartDate: opts.allocationStartDate ?? opts.startDate,
|
||||
allocationEndDate: opts.allocationEndDate ?? opts.endDate,
|
||||
originalStartDate: opts.startDate,
|
||||
originalEndDate: opts.endDate,
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
allocDragRef.current = initial;
|
||||
setAllocDragState(initial);
|
||||
setAllocationPreviewTarget(e.currentTarget, opts.mode);
|
||||
allocDragCleanupRef.current?.();
|
||||
|
||||
// ── document handlers ────────────────────────────────────────────────
|
||||
|
||||
function handleMove(ev: MouseEvent) {
|
||||
const alloc = allocDragRef.current;
|
||||
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
|
||||
|
||||
const deltaX = ev.clientX - alloc.startMouseX;
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||
if (daysDelta === alloc.daysDelta) return;
|
||||
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
alloc.mode,
|
||||
alloc.originalStartDate,
|
||||
alloc.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
|
||||
const updated: AllocDragState = {
|
||||
...alloc,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
daysDelta,
|
||||
};
|
||||
allocDragRef.current = updated;
|
||||
setAllocDragState(updated);
|
||||
updateAllocationDragPosition(ev.clientX);
|
||||
}
|
||||
|
||||
function handleUp() {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
|
||||
function handleUp(ev: MouseEvent) {
|
||||
allocDragCleanupRef.current?.();
|
||||
allocDragCleanupRef.current = null;
|
||||
updateAllocationDragPosition(ev.clientX);
|
||||
const alloc = allocDragRef.current;
|
||||
if (!alloc.isActive) return;
|
||||
const pointerDelta = Math.abs(alloc.pointerDeltaX);
|
||||
const hasDateChange =
|
||||
Boolean(alloc.originalStartDate && alloc.currentStartDate && alloc.originalEndDate && alloc.currentEndDate) &&
|
||||
(
|
||||
alloc.originalStartDate!.getTime() !== alloc.currentStartDate!.getTime() ||
|
||||
alloc.originalEndDate!.getTime() !== alloc.currentEndDate!.getTime()
|
||||
);
|
||||
|
||||
if (alloc.daysDelta === 0 && alloc.allocationId) {
|
||||
if (hasDateChange) {
|
||||
preserveLivePreview(allocPreviewRef.current);
|
||||
}
|
||||
clearLivePreview(allocPreviewRef.current);
|
||||
allocPreviewRef.current = null;
|
||||
const shouldTreatAsClick =
|
||||
alloc.mode === "move" &&
|
||||
alloc.daysDelta === 0 &&
|
||||
pointerDelta <= DRAG_CLICK_THRESHOLD_PX;
|
||||
|
||||
if (shouldTreatAsClick && alloc.allocationId) {
|
||||
// No movement → treat as click
|
||||
if (wasShift) {
|
||||
// Shift+Click → toggle multi-selection for this allocation
|
||||
@@ -474,19 +891,61 @@ export function useTimelineDrag({
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
}
|
||||
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
|
||||
} else if (hasDateChange && alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
|
||||
const activeAllocationId = alloc.allocationId;
|
||||
const currentStartDate = alloc.currentStartDate;
|
||||
const currentEndDate = alloc.currentEndDate;
|
||||
const baseMutationAllocationId = alloc.mutationAllocationId ?? activeAllocationId;
|
||||
const requiresExtraction =
|
||||
alloc.scope === "segment" &&
|
||||
(!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) ||
|
||||
!datesMatch(alloc.originalEndDate, alloc.allocationEndDate));
|
||||
|
||||
pendingSnapshotRef.current = {
|
||||
allocationId: alloc.allocationId,
|
||||
mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
|
||||
allocationId: activeAllocationId,
|
||||
mutationAllocationId: baseMutationAllocationId,
|
||||
projectName: alloc.projectName ?? "",
|
||||
before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! },
|
||||
after: { startDate: alloc.currentStartDate, endDate: alloc.currentEndDate },
|
||||
after: { startDate: currentStartDate, endDate: currentEndDate },
|
||||
};
|
||||
updateAllocMutation.mutate({
|
||||
allocationId: alloc.mutationAllocationId ?? alloc.allocationId,
|
||||
startDate: alloc.currentStartDate,
|
||||
endDate: alloc.currentEndDate,
|
||||
pendingOptimisticAllocationIdRef.current = activeAllocationId;
|
||||
setOptimisticAllocations((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeAllocationId, {
|
||||
startDate: currentStartDate,
|
||||
endDate: currentEndDate,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
let mutationAllocationId = baseMutationAllocationId;
|
||||
|
||||
if (requiresExtraction) {
|
||||
const extracted = await extractAllocFragmentMutation.mutateAsync({
|
||||
allocationId: mutationAllocationId,
|
||||
startDate: alloc.originalStartDate!,
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
mutationAllocationId = extracted.extractedAllocationId;
|
||||
}
|
||||
|
||||
pendingSnapshotRef.current = pendingSnapshotRef.current
|
||||
? {
|
||||
...pendingSnapshotRef.current,
|
||||
mutationAllocationId,
|
||||
}
|
||||
: null;
|
||||
|
||||
updateAllocMutation.mutate({
|
||||
allocationId: mutationAllocationId,
|
||||
startDate: currentStartDate,
|
||||
endDate: currentEndDate,
|
||||
});
|
||||
} catch {
|
||||
clearPendingOptimisticAllocation(activeAllocationId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
||||
@@ -495,8 +954,18 @@ export function useTimelineDrag({
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
allocDragCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
},
|
||||
[updateAllocMutation.mutate], // mutate is stable across renders (React Query guarantee)
|
||||
[
|
||||
clearPendingOptimisticAllocation,
|
||||
extractAllocFragmentMutation,
|
||||
setAllocationPreviewTarget,
|
||||
updateAllocationDragPosition,
|
||||
updateAllocMutation,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Range-select ────────────────────────────────────────────────────────────
|
||||
@@ -531,27 +1000,7 @@ export function useTimelineDrag({
|
||||
|
||||
const onCanvasMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Project shift
|
||||
const drag = dragStateRef.current;
|
||||
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
|
||||
const deltaX = e.clientX - drag.startMouseX;
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidth);
|
||||
if (daysDelta !== drag.daysDelta) {
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
"move",
|
||||
drag.originalStartDate,
|
||||
drag.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
const updated: DragState = {
|
||||
...drag,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
daysDelta,
|
||||
};
|
||||
dragStateRef.current = updated;
|
||||
setDragState(updated);
|
||||
}
|
||||
if (updateProjectDragPosition(e.clientX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -559,7 +1008,7 @@ export function useTimelineDrag({
|
||||
const range = rangeStateRef.current;
|
||||
if (range.isSelecting && range.startDate) {
|
||||
const deltaX = e.clientX - range.startClientX;
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidth);
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||
const currentDate = new Date(range.startDate);
|
||||
currentDate.setDate(currentDate.getDate() + daysDelta);
|
||||
|
||||
@@ -573,7 +1022,7 @@ export function useTimelineDrag({
|
||||
setRangeState(updated);
|
||||
}
|
||||
},
|
||||
[cellWidth],
|
||||
[updateProjectDragPosition],
|
||||
);
|
||||
|
||||
const onCanvasMouseUp = useCallback(
|
||||
@@ -581,29 +1030,11 @@ export function useTimelineDrag({
|
||||
// Project shift
|
||||
const drag = dragStateRef.current;
|
||||
if (drag.isDragging) {
|
||||
if (drag.daysDelta === 0) {
|
||||
if (drag.projectId && drag.originalStartDate && drag.originalEndDate) {
|
||||
onBlockClick?.({
|
||||
allocationId: drag.allocationId ?? "",
|
||||
projectId: drag.projectId,
|
||||
projectName: drag.projectName ?? "",
|
||||
startDate: drag.originalStartDate,
|
||||
endDate: drag.originalEndDate,
|
||||
});
|
||||
}
|
||||
} else if (drag.projectId && drag.currentStartDate && drag.currentEndDate) {
|
||||
try {
|
||||
await applyShiftMutation.mutateAsync({
|
||||
projectId: drag.projectId,
|
||||
newStartDate: drag.currentStartDate,
|
||||
newEndDate: drag.currentEndDate,
|
||||
});
|
||||
} catch {
|
||||
// Validation error — revert visually
|
||||
}
|
||||
try {
|
||||
await finalizeProjectDrag(e.clientX, "mutateAsync");
|
||||
} catch {
|
||||
// Validation error — revert visually
|
||||
}
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -627,16 +1058,12 @@ export function useTimelineDrag({
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
}
|
||||
},
|
||||
[applyShiftMutation, onBlockClick, onRangeSelected],
|
||||
[finalizeProjectDrag, onRangeSelected],
|
||||
);
|
||||
|
||||
const onCanvasMouseLeave = useCallback(() => {
|
||||
// Only cancel project-shift and range-select on canvas leave.
|
||||
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
|
||||
if (dragStateRef.current.isDragging) {
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
}
|
||||
if (rangeStateRef.current.isSelecting) {
|
||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
@@ -664,6 +1091,7 @@ export function useTimelineDrag({
|
||||
};
|
||||
multiSelectRef.current = initial;
|
||||
setMultiSelectState(initial);
|
||||
multiSelectCleanupRef.current?.();
|
||||
|
||||
function handleMove(ev: MouseEvent) {
|
||||
const ms = multiSelectRef.current;
|
||||
@@ -679,8 +1107,8 @@ export function useTimelineDrag({
|
||||
}
|
||||
|
||||
function handleUp(ev: MouseEvent) {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
multiSelectCleanupRef.current?.();
|
||||
multiSelectCleanupRef.current = null;
|
||||
|
||||
const ms = multiSelectRef.current;
|
||||
if (!ms.isSelecting) return;
|
||||
@@ -711,6 +1139,10 @@ export function useTimelineDrag({
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
multiSelectCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearMultiSelect = useCallback(() => {
|
||||
@@ -761,6 +1193,9 @@ export function useTimelineDrag({
|
||||
resourceId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allocationStartDate?: Date;
|
||||
allocationEndDate?: Date;
|
||||
scope?: AllocDragScope;
|
||||
},
|
||||
) => {
|
||||
e.preventDefault();
|
||||
@@ -831,6 +1266,25 @@ export function useTimelineDrag({
|
||||
[onCanvasMouseUp],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
projectDragCleanupRef.current?.();
|
||||
allocDragCleanupRef.current?.();
|
||||
multiSelectCleanupRef.current?.();
|
||||
projectDragCleanupRef.current = null;
|
||||
allocDragCleanupRef.current = null;
|
||||
multiSelectCleanupRef.current = null;
|
||||
clearLivePreview(projectPreviewRef.current);
|
||||
clearLivePreview(allocPreviewRef.current);
|
||||
projectPreviewRef.current = null;
|
||||
allocPreviewRef.current = null;
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const shiftPreview: ShiftPreviewData | null =
|
||||
@@ -852,6 +1306,8 @@ export function useTimelineDrag({
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
optimisticAllocations,
|
||||
reconcileOptimisticAllocations,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying: applyShiftMutation.isPending,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
type PopoverAnchor =
|
||||
| { kind: "point"; x: number; y: number }
|
||||
@@ -17,6 +17,8 @@ interface UseViewportPopoverOptions {
|
||||
offset?: number;
|
||||
viewportPadding?: number;
|
||||
ignoreElements?: Array<HTMLElement | null>;
|
||||
ignoreSelectors?: string[];
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export function useViewportPopover({
|
||||
@@ -29,37 +31,23 @@ export function useViewportPopover({
|
||||
offset = 8,
|
||||
viewportPadding = 16,
|
||||
ignoreElements = [],
|
||||
ignoreSelectors = [],
|
||||
zIndex = 9998,
|
||||
}: UseViewportPopoverOptions) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<number | null>(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();
|
||||
const computeStyle = (): CSSProperties => {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
position: "fixed",
|
||||
left: viewportPadding,
|
||||
top: viewportPadding,
|
||||
width,
|
||||
zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -94,17 +82,134 @@ export function useViewportPopover({
|
||||
}
|
||||
}
|
||||
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedHeight - viewportPadding);
|
||||
const measuredWidth = ref.current?.offsetWidth ?? width;
|
||||
const measuredHeight = ref.current?.offsetHeight ?? estimatedHeight;
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - measuredWidth - viewportPadding);
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - measuredHeight - viewportPadding);
|
||||
|
||||
return {
|
||||
position: "fixed",
|
||||
left: Math.min(Math.max(left, viewportPadding), maxLeft),
|
||||
top: Math.min(Math.max(top, viewportPadding), maxTop),
|
||||
width,
|
||||
zIndex: 60,
|
||||
zIndex,
|
||||
};
|
||||
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width]);
|
||||
};
|
||||
|
||||
const [style, setStyle] = useState<CSSProperties>(() => computeStyle());
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (ref.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreElements.some((element) => element?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target instanceof Element
|
||||
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [ignoreElements, ignoreSelectors, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
setStyle(computeStyle());
|
||||
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
setStyle(computeStyle());
|
||||
});
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
function cancelScheduledFrame() {
|
||||
if (frameRef.current === null) return;
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
|
||||
function updateOrClose() {
|
||||
if (anchor.kind === "element" && !anchor.element.isConnected) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setStyle(computeStyle());
|
||||
}
|
||||
|
||||
function scheduleUpdate(reason: "scroll" | "resize") {
|
||||
if (frameRef.current !== null) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
updateOrClose();
|
||||
});
|
||||
}
|
||||
|
||||
updateOrClose();
|
||||
|
||||
const handleScroll = () => {
|
||||
scheduleUpdate("scroll");
|
||||
};
|
||||
const handleResize = () => {
|
||||
scheduleUpdate("resize");
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
window.visualViewport?.addEventListener("resize", handleResize, { passive: true });
|
||||
window.visualViewport?.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
cancelScheduledFrame();
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.visualViewport?.removeEventListener("resize", handleResize);
|
||||
window.visualViewport?.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [
|
||||
align,
|
||||
anchor,
|
||||
estimatedHeight,
|
||||
offset,
|
||||
onClose,
|
||||
side,
|
||||
viewportPadding,
|
||||
width,
|
||||
zIndex,
|
||||
]);
|
||||
|
||||
return { ref, style };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
"e2e/**/*.ts",
|
||||
".next/types/**/*.ts",
|
||||
"typecheck-env.d.ts",
|
||||
"next.config.ts",
|
||||
"playwright.config.ts",
|
||||
"sentry.client.config.ts",
|
||||
"sentry.edge.config.ts",
|
||||
"sentry.server.config.ts",
|
||||
"tailwind.config.ts",
|
||||
"postcss.config.js",
|
||||
"vitest.config.mts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".next-e2e",
|
||||
"playwright-report",
|
||||
"test-results"
|
||||
]
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "lcov"],
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: ["src/**/*.d.ts", "src/**/*.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const batches = [
|
||||
"src/__tests__/assistant-tools-timeline-resource-selection.test.ts",
|
||||
"src/__tests__/assistant-tools-advanced-resource-ranking.test.ts",
|
||||
"src/__tests__/assistant-tools-advanced-timeline-entries-view.test.ts",
|
||||
"src/__tests__/assistant-tools-advanced-timeline-holiday-overlays.test.ts",
|
||||
"src/__tests__/assistant-tools-advanced-project-timeline-context.test.ts",
|
||||
"src/__tests__/assistant-tools-advanced-project-shift-preview.test.ts",
|
||||
],
|
||||
@@ -81,6 +82,8 @@ const batches = [
|
||||
"src/__tests__/assistant-tools-settings-role-config-admin.test.ts",
|
||||
"src/__tests__/assistant-tools-import.test.ts",
|
||||
"src/__tests__/assistant-tools-export.test.ts",
|
||||
"src/__tests__/assistant-tools-export-projects.test.ts",
|
||||
"src/__tests__/assistant-tools-holiday-resolution-errors.test.ts",
|
||||
"src/__tests__/ai-client.test.ts",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PermissionKey, SystemRole, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
|
||||
import { getAvailableAssistantTools } from "../router/assistant-tool-policy.js";
|
||||
import { TOOL_DEFINITIONS } from "../router/assistant-tools.js";
|
||||
|
||||
function getToolNames(
|
||||
permissions: PermissionKeyValue[],
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
) {
|
||||
return getAvailableAssistantTools(new Set(permissions), userRole).map((tool) => tool.function.name);
|
||||
}
|
||||
|
||||
describe("assistant tool policy", () => {
|
||||
it("hides advanced tools unless the dedicated assistant permission is granted", () => {
|
||||
const withoutAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
]);
|
||||
const withAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
]);
|
||||
|
||||
expect(withoutAdvanced).not.toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("find_best_project_resource");
|
||||
expect(withAdvanced).toContain("get_chargeability_report");
|
||||
expect(withAdvanced).toContain("get_resource_computation_graph");
|
||||
expect(withAdvanced).toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps user self-service tools available to plain authenticated users", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(userNames).toContain("get_current_user");
|
||||
expect(userNames).toContain("get_dashboard_layout");
|
||||
expect(userNames).toContain("save_dashboard_layout");
|
||||
expect(userNames).toContain("get_favorite_project_ids");
|
||||
expect(userNames).toContain("toggle_favorite_project");
|
||||
expect(userNames).toContain("get_column_preferences");
|
||||
expect(userNames).toContain("set_column_preferences");
|
||||
expect(userNames).toContain("get_mfa_status");
|
||||
expect(userNames).toContain("list_notifications");
|
||||
expect(userNames).toContain("get_unread_notification_count");
|
||||
expect(userNames).toContain("list_tasks");
|
||||
expect(userNames).toContain("get_task_counts");
|
||||
expect(userNames).toContain("create_reminder");
|
||||
expect(userNames).toContain("list_reminders");
|
||||
expect(userNames).toContain("update_reminder");
|
||||
expect(userNames).toContain("delete_reminder");
|
||||
});
|
||||
|
||||
it("keeps admin-only user tools hidden from non-admin roles", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("list_users");
|
||||
expect(adminNames).toContain("get_active_user_count");
|
||||
expect(adminNames).toContain("create_user");
|
||||
expect(adminNames).toContain("set_user_password");
|
||||
expect(adminNames).toContain("update_user_role");
|
||||
expect(adminNames).toContain("update_user_name");
|
||||
expect(adminNames).toContain("link_user_resource");
|
||||
expect(adminNames).toContain("auto_link_users_by_email");
|
||||
expect(adminNames).toContain("set_user_permissions");
|
||||
expect(adminNames).toContain("reset_user_permissions");
|
||||
expect(adminNames).toContain("get_effective_user_permissions");
|
||||
expect(adminNames).toContain("disable_user_totp");
|
||||
|
||||
expect(managerNames).not.toContain("list_users");
|
||||
expect(managerNames).not.toContain("create_user");
|
||||
expect(managerNames).not.toContain("set_user_permissions");
|
||||
expect(managerNames).not.toContain("disable_user_totp");
|
||||
expect(userNames).not.toContain("list_users");
|
||||
expect(userNames).not.toContain("get_active_user_count");
|
||||
expect(userNames).not.toContain("create_user");
|
||||
expect(userNames).not.toContain("set_user_password");
|
||||
expect(userNames).not.toContain("update_user_role");
|
||||
expect(userNames).not.toContain("update_user_name");
|
||||
expect(userNames).not.toContain("link_user_resource");
|
||||
expect(userNames).not.toContain("auto_link_users_by_email");
|
||||
expect(userNames).not.toContain("set_user_permissions");
|
||||
expect(userNames).not.toContain("reset_user_permissions");
|
||||
expect(userNames).not.toContain("get_effective_user_permissions");
|
||||
expect(userNames).not.toContain("disable_user_totp");
|
||||
});
|
||||
|
||||
it("keeps assignable users and manager notification lifecycle tools behind manager/admin role", () => {
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(managerNames).toContain("list_assignable_users");
|
||||
expect(managerNames).toContain("create_notification");
|
||||
expect(managerNames).toContain("create_task_for_user");
|
||||
expect(managerNames).toContain("assign_task");
|
||||
expect(managerNames).toContain("send_broadcast");
|
||||
expect(managerNames).toContain("list_broadcasts");
|
||||
expect(managerNames).toContain("get_broadcast_detail");
|
||||
expect(adminNames).toContain("list_assignable_users");
|
||||
expect(adminNames).toContain("create_task_for_user");
|
||||
expect(adminNames).toContain("send_broadcast");
|
||||
expect(userNames).not.toContain("list_assignable_users");
|
||||
expect(userNames).not.toContain("create_notification");
|
||||
expect(userNames).not.toContain("create_task_for_user");
|
||||
expect(userNames).not.toContain("assign_task");
|
||||
expect(userNames).not.toContain("send_broadcast");
|
||||
expect(userNames).not.toContain("list_broadcasts");
|
||||
expect(userNames).not.toContain("get_broadcast_detail");
|
||||
});
|
||||
|
||||
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");
|
||||
expect(names).not.toContain("get_chargeability_report");
|
||||
expect(names).not.toContain("get_resource_computation_graph");
|
||||
expect(names).not.toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps controller-grade readmodels hidden from plain users while allowing controller roles", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("query_change_history");
|
||||
expect(controllerNames).toContain("get_entity_timeline");
|
||||
expect(controllerNames).toContain("search_by_skill");
|
||||
expect(controllerNames).toContain("export_resources_csv");
|
||||
expect(controllerNames).toContain("export_projects_csv");
|
||||
expect(controllerNames).toContain("list_audit_log_entries");
|
||||
expect(controllerNames).toContain("get_audit_log_entry");
|
||||
expect(controllerNames).toContain("get_audit_log_timeline");
|
||||
expect(controllerNames).toContain("get_audit_activity_summary");
|
||||
expect(controllerNames).toContain("get_chargeability_report");
|
||||
expect(controllerNames).toContain("get_resource_computation_graph");
|
||||
expect(controllerNames).toContain("get_project_computation_graph");
|
||||
expect(userNames).not.toContain("query_change_history");
|
||||
expect(userNames).not.toContain("get_entity_timeline");
|
||||
expect(userNames).not.toContain("search_by_skill");
|
||||
expect(userNames).not.toContain("export_resources_csv");
|
||||
expect(userNames).not.toContain("export_projects_csv");
|
||||
expect(userNames).not.toContain("list_audit_log_entries");
|
||||
expect(userNames).not.toContain("get_audit_log_entry");
|
||||
expect(userNames).not.toContain("get_audit_log_timeline");
|
||||
expect(userNames).not.toContain("get_audit_activity_summary");
|
||||
expect(userNames).not.toContain("get_chargeability_report");
|
||||
expect(userNames).not.toContain("get_resource_computation_graph");
|
||||
expect(userNames).not.toContain("get_project_computation_graph");
|
||||
});
|
||||
|
||||
it("keeps entity-scoped comment tools available to plain authenticated users", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(userNames).toContain("list_comments");
|
||||
expect(userNames).toContain("create_comment");
|
||||
expect(userNames).toContain("resolve_comment");
|
||||
});
|
||||
|
||||
it("keeps planning read tools behind the explicit planning permission", () => {
|
||||
const userWithoutPlanning = getToolNames([], SystemRole.USER);
|
||||
const userWithPlanning = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
|
||||
|
||||
expect(userWithoutPlanning).not.toContain("list_allocations");
|
||||
expect(userWithoutPlanning).not.toContain("list_demands");
|
||||
expect(userWithoutPlanning).not.toContain("list_blueprints");
|
||||
expect(userWithoutPlanning).not.toContain("get_blueprint");
|
||||
expect(userWithoutPlanning).not.toContain("list_clients");
|
||||
expect(userWithoutPlanning).not.toContain("list_roles");
|
||||
expect(userWithoutPlanning).not.toContain("list_management_levels");
|
||||
expect(userWithoutPlanning).not.toContain("list_utilization_categories");
|
||||
expect(userWithoutPlanning).not.toContain("check_resource_availability");
|
||||
expect(userWithoutPlanning).not.toContain("find_capacity");
|
||||
expect(userWithoutPlanning).not.toContain("get_staffing_suggestions");
|
||||
expect(userWithoutPlanning).not.toContain("find_best_project_resource");
|
||||
expect(userWithPlanning).toContain("list_allocations");
|
||||
expect(userWithPlanning).toContain("list_demands");
|
||||
expect(userWithPlanning).toContain("list_blueprints");
|
||||
expect(userWithPlanning).toContain("get_blueprint");
|
||||
expect(userWithPlanning).toContain("list_clients");
|
||||
expect(userWithPlanning).toContain("list_roles");
|
||||
expect(userWithPlanning).toContain("list_management_levels");
|
||||
expect(userWithPlanning).toContain("list_utilization_categories");
|
||||
expect(userWithPlanning).toContain("check_resource_availability");
|
||||
expect(userWithPlanning).toContain("find_capacity");
|
||||
expect(userWithPlanning).not.toContain("get_staffing_suggestions");
|
||||
expect(userWithPlanning).not.toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps cost-aware staffing assistant tools behind cost and advanced gates", () => {
|
||||
const planningOnly = getToolNames([PermissionKey.VIEW_PLANNING], SystemRole.USER);
|
||||
const planningAndCosts = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
], SystemRole.USER);
|
||||
const planningCostsAndAdvanced = getToolNames([
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(planningOnly).not.toContain("get_staffing_suggestions");
|
||||
expect(planningOnly).not.toContain("find_best_project_resource");
|
||||
expect(planningAndCosts).toContain("get_staffing_suggestions");
|
||||
expect(planningAndCosts).not.toContain("find_best_project_resource");
|
||||
expect(planningCostsAndAdvanced).toContain("get_staffing_suggestions");
|
||||
expect(planningCostsAndAdvanced).toContain("find_best_project_resource");
|
||||
});
|
||||
|
||||
it("keeps controller-only project and dashboard reads hidden from plain users", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("search_projects");
|
||||
expect(controllerNames).toContain("get_project");
|
||||
expect(controllerNames).toContain("get_statistics");
|
||||
expect(controllerNames).toContain("get_dashboard_detail");
|
||||
expect(controllerNames).toContain("get_skill_gaps");
|
||||
expect(controllerNames).toContain("get_project_health");
|
||||
expect(controllerNames).toContain("get_budget_forecast");
|
||||
expect(controllerNames).toContain("get_budget_status");
|
||||
expect(controllerNames).toContain("get_shoring_ratio");
|
||||
expect(userNames).not.toContain("search_projects");
|
||||
expect(userNames).not.toContain("get_project");
|
||||
expect(userNames).not.toContain("get_statistics");
|
||||
expect(userNames).not.toContain("get_dashboard_detail");
|
||||
expect(userNames).not.toContain("get_skill_gaps");
|
||||
expect(userNames).not.toContain("get_project_health");
|
||||
expect(userNames).not.toContain("get_budget_forecast");
|
||||
expect(userNames).not.toContain("get_budget_status");
|
||||
expect(userNames).not.toContain("get_shoring_ratio");
|
||||
});
|
||||
|
||||
it("keeps legacy controller-only analysis and report tools hidden from plain users", () => {
|
||||
const controllerNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.CONTROLLER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.VIEW_COSTS,
|
||||
PermissionKey.VIEW_PLANNING,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("detect_anomalies");
|
||||
expect(controllerNames).toContain("get_insights_summary");
|
||||
expect(controllerNames).toContain("run_report");
|
||||
expect(controllerNames).toContain("lookup_rate");
|
||||
expect(controllerNames).toContain("simulate_scenario");
|
||||
expect(controllerNames).toContain("generate_project_narrative");
|
||||
expect(controllerNames).toContain("list_rate_cards");
|
||||
expect(controllerNames).toContain("resolve_rate");
|
||||
expect(userNames).not.toContain("detect_anomalies");
|
||||
expect(userNames).not.toContain("get_insights_summary");
|
||||
expect(userNames).not.toContain("run_report");
|
||||
expect(userNames).not.toContain("lookup_rate");
|
||||
expect(userNames).not.toContain("simulate_scenario");
|
||||
expect(userNames).not.toContain("generate_project_narrative");
|
||||
expect(userNames).not.toContain("list_rate_cards");
|
||||
expect(userNames).not.toContain("resolve_rate");
|
||||
});
|
||||
|
||||
it("keeps cost-sensitive legacy rate tools hidden without viewCosts", () => {
|
||||
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||
const controllerWithCosts = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||
|
||||
expect(controllerWithoutCosts).not.toContain("list_rate_cards");
|
||||
expect(controllerWithoutCosts).not.toContain("resolve_rate");
|
||||
expect(controllerWithCosts).toContain("list_rate_cards");
|
||||
expect(controllerWithCosts).toContain("resolve_rate");
|
||||
});
|
||||
|
||||
it("requires both controller role and advanced assistant access for timeline detail tools", () => {
|
||||
const controllerWithAdvanced = getToolNames([
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.CONTROLLER);
|
||||
const controllerWithoutAdvanced = getToolNames([], SystemRole.CONTROLLER);
|
||||
const userWithAdvanced = getToolNames([
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
|
||||
expect(controllerWithAdvanced).toContain("get_timeline_entries_view");
|
||||
expect(controllerWithAdvanced).toContain("get_timeline_holiday_overlays");
|
||||
expect(controllerWithAdvanced).toContain("get_project_timeline_context");
|
||||
expect(controllerWithAdvanced).toContain("preview_project_shift");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_timeline_entries_view");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_timeline_holiday_overlays");
|
||||
expect(controllerWithoutAdvanced).not.toContain("get_project_timeline_context");
|
||||
expect(controllerWithoutAdvanced).not.toContain("preview_project_shift");
|
||||
expect(userWithAdvanced).not.toContain("get_timeline_entries_view");
|
||||
expect(userWithAdvanced).not.toContain("get_timeline_holiday_overlays");
|
||||
expect(userWithAdvanced).not.toContain("get_project_timeline_context");
|
||||
expect(userWithAdvanced).not.toContain("preview_project_shift");
|
||||
});
|
||||
|
||||
it("exposes self-service timeline tools to authenticated users without advanced assistant access", () => {
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
const viewerNames = getToolNames([], SystemRole.VIEWER);
|
||||
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||
|
||||
expect(userNames).toContain("get_my_timeline_entries_view");
|
||||
expect(userNames).toContain("get_my_timeline_holiday_overlays");
|
||||
expect(viewerNames).toContain("get_my_timeline_entries_view");
|
||||
expect(viewerNames).toContain("get_my_timeline_holiday_overlays");
|
||||
expect(controllerNames).toContain("get_my_timeline_entries_view");
|
||||
expect(controllerNames).toContain("get_my_timeline_holiday_overlays");
|
||||
});
|
||||
|
||||
it("keeps timeline write parity tools behind manager/admin role, manageAllocations, and advanced assistant access", () => {
|
||||
const managerNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
], SystemRole.USER);
|
||||
const missingAdvancedNames = getToolNames([
|
||||
PermissionKey.MANAGE_ALLOCATIONS,
|
||||
], SystemRole.MANAGER);
|
||||
|
||||
expect(managerNames).toContain("update_timeline_allocation_inline");
|
||||
expect(managerNames).toContain("apply_timeline_project_shift");
|
||||
expect(managerNames).toContain("quick_assign_timeline_resource");
|
||||
expect(managerNames).toContain("batch_quick_assign_timeline_resources");
|
||||
expect(managerNames).toContain("batch_shift_timeline_allocations");
|
||||
expect(userNames).not.toContain("update_timeline_allocation_inline");
|
||||
expect(userNames).not.toContain("apply_timeline_project_shift");
|
||||
expect(userNames).not.toContain("quick_assign_timeline_resource");
|
||||
expect(userNames).not.toContain("batch_quick_assign_timeline_resources");
|
||||
expect(userNames).not.toContain("batch_shift_timeline_allocations");
|
||||
expect(missingAdvancedNames).not.toContain("update_timeline_allocation_inline");
|
||||
expect(missingAdvancedNames).not.toContain("quick_assign_timeline_resource");
|
||||
});
|
||||
|
||||
it("keeps estimate lifecycle mutations behind manager/admin role and their router permissions", () => {
|
||||
const managerProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.MANAGER);
|
||||
const managerAllocationNames = getToolNames([PermissionKey.MANAGE_ALLOCATIONS], SystemRole.MANAGER);
|
||||
const userProjectNames = getToolNames([PermissionKey.MANAGE_PROJECTS], SystemRole.USER);
|
||||
|
||||
expect(managerProjectNames).toContain("create_estimate");
|
||||
expect(managerProjectNames).toContain("clone_estimate");
|
||||
expect(managerProjectNames).toContain("update_estimate_draft");
|
||||
expect(managerProjectNames).toContain("submit_estimate_version");
|
||||
expect(managerProjectNames).toContain("approve_estimate_version");
|
||||
expect(managerProjectNames).toContain("create_estimate_revision");
|
||||
expect(managerProjectNames).toContain("create_estimate_export");
|
||||
expect(managerProjectNames).toContain("generate_estimate_weekly_phasing");
|
||||
expect(managerProjectNames).toContain("update_estimate_commercial_terms");
|
||||
expect(managerProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||
expect(managerAllocationNames).toContain("create_estimate_planning_handoff");
|
||||
expect(managerAllocationNames).not.toContain("create_estimate");
|
||||
expect(userProjectNames).not.toContain("create_estimate");
|
||||
expect(userProjectNames).not.toContain("clone_estimate");
|
||||
expect(userProjectNames).not.toContain("update_estimate_draft");
|
||||
expect(userProjectNames).not.toContain("submit_estimate_version");
|
||||
expect(userProjectNames).not.toContain("approve_estimate_version");
|
||||
expect(userProjectNames).not.toContain("create_estimate_revision");
|
||||
expect(userProjectNames).not.toContain("create_estimate_export");
|
||||
expect(userProjectNames).not.toContain("generate_estimate_weekly_phasing");
|
||||
expect(userProjectNames).not.toContain("update_estimate_commercial_terms");
|
||||
expect(userProjectNames).not.toContain("create_estimate_planning_handoff");
|
||||
});
|
||||
|
||||
it("keeps estimate read tools aligned to controller/manager/admin visibility and cost requirements", () => {
|
||||
const controllerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.CONTROLLER);
|
||||
const controllerWithoutCosts = getToolNames([], SystemRole.CONTROLLER);
|
||||
const managerNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.MANAGER);
|
||||
const managerWithoutCosts = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([PermissionKey.VIEW_COSTS], SystemRole.USER);
|
||||
|
||||
expect(controllerNames).toContain("search_estimates");
|
||||
expect(controllerNames).toContain("get_estimate_detail");
|
||||
expect(controllerNames).toContain("list_estimate_versions");
|
||||
expect(controllerNames).toContain("get_estimate_version_snapshot");
|
||||
expect(controllerNames).toContain("get_estimate_weekly_phasing");
|
||||
expect(controllerNames).toContain("get_estimate_commercial_terms");
|
||||
expect(controllerWithoutCosts).toContain("search_estimates");
|
||||
expect(controllerWithoutCosts).not.toContain("get_estimate_detail");
|
||||
expect(controllerWithoutCosts).toContain("list_estimate_versions");
|
||||
expect(controllerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||
expect(controllerWithoutCosts).toContain("get_estimate_weekly_phasing");
|
||||
expect(controllerWithoutCosts).toContain("get_estimate_commercial_terms");
|
||||
expect(managerNames).toContain("search_estimates");
|
||||
expect(managerNames).toContain("get_estimate_detail");
|
||||
expect(managerNames).toContain("list_estimate_versions");
|
||||
expect(managerNames).toContain("get_estimate_version_snapshot");
|
||||
expect(managerNames).toContain("get_estimate_weekly_phasing");
|
||||
expect(managerNames).toContain("get_estimate_commercial_terms");
|
||||
expect(managerWithoutCosts).toContain("search_estimates");
|
||||
expect(managerWithoutCosts).toContain("list_estimate_versions");
|
||||
expect(managerWithoutCosts).not.toContain("get_estimate_version_snapshot");
|
||||
expect(userNames).not.toContain("search_estimates");
|
||||
expect(userNames).not.toContain("get_estimate_detail");
|
||||
expect(userNames).not.toContain("list_estimate_versions");
|
||||
expect(userNames).not.toContain("get_estimate_version_snapshot");
|
||||
expect(userNames).not.toContain("get_estimate_weekly_phasing");
|
||||
expect(userNames).not.toContain("get_estimate_commercial_terms");
|
||||
});
|
||||
|
||||
it("keeps import/dispo parity tools aligned to router roles and permissions", () => {
|
||||
const managerNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.MANAGER);
|
||||
const controllerNames = getToolNames([], SystemRole.CONTROLLER);
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const userNames = getToolNames([PermissionKey.IMPORT_DATA], SystemRole.USER);
|
||||
|
||||
expect(managerNames).toContain("import_csv_data");
|
||||
expect(controllerNames).toContain("export_resources_csv");
|
||||
expect(controllerNames).toContain("export_projects_csv");
|
||||
expect(adminNames).toContain("list_dispo_import_batches");
|
||||
expect(adminNames).toContain("get_dispo_import_batch");
|
||||
expect(adminNames).toContain("stage_dispo_import_batch");
|
||||
expect(adminNames).toContain("validate_dispo_import_batch");
|
||||
expect(adminNames).toContain("cancel_dispo_import_batch");
|
||||
expect(adminNames).toContain("list_dispo_staged_resources");
|
||||
expect(adminNames).toContain("list_dispo_staged_projects");
|
||||
expect(adminNames).toContain("list_dispo_staged_assignments");
|
||||
expect(adminNames).toContain("list_dispo_staged_vacations");
|
||||
expect(adminNames).toContain("list_dispo_staged_unresolved_records");
|
||||
expect(adminNames).toContain("resolve_dispo_staged_record");
|
||||
expect(adminNames).toContain("commit_dispo_import_batch");
|
||||
expect(userNames).not.toContain("import_csv_data");
|
||||
expect(userNames).not.toContain("export_resources_csv");
|
||||
expect(userNames).not.toContain("export_projects_csv");
|
||||
expect(userNames).not.toContain("list_dispo_import_batches");
|
||||
expect(userNames).not.toContain("get_dispo_import_batch");
|
||||
expect(userNames).not.toContain("stage_dispo_import_batch");
|
||||
expect(userNames).not.toContain("validate_dispo_import_batch");
|
||||
expect(userNames).not.toContain("list_dispo_staged_resources");
|
||||
expect(userNames).not.toContain("commit_dispo_import_batch");
|
||||
});
|
||||
|
||||
it("keeps settings and webhook admin tools hidden while preserving protected parity tools", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("get_system_settings");
|
||||
expect(adminNames).toContain("update_system_settings");
|
||||
expect(adminNames).toContain("clear_stored_runtime_secrets");
|
||||
expect(adminNames).toContain("test_ai_connection");
|
||||
expect(adminNames).toContain("test_smtp_connection");
|
||||
expect(adminNames).toContain("test_gemini_connection");
|
||||
expect(adminNames).toContain("list_system_role_configs");
|
||||
expect(adminNames).toContain("update_system_role_config");
|
||||
expect(adminNames).toContain("list_webhooks");
|
||||
expect(adminNames).toContain("get_webhook");
|
||||
expect(adminNames).toContain("create_webhook");
|
||||
expect(adminNames).toContain("update_webhook");
|
||||
expect(adminNames).toContain("delete_webhook");
|
||||
expect(adminNames).toContain("test_webhook");
|
||||
expect(adminNames).toContain("get_ai_configured");
|
||||
|
||||
expect(managerNames).not.toContain("get_system_settings");
|
||||
expect(managerNames).not.toContain("update_system_settings");
|
||||
expect(managerNames).not.toContain("clear_stored_runtime_secrets");
|
||||
expect(managerNames).not.toContain("test_ai_connection");
|
||||
expect(managerNames).not.toContain("get_ai_configured");
|
||||
expect(managerNames).not.toContain("list_system_role_configs");
|
||||
expect(managerNames).not.toContain("update_system_role_config");
|
||||
expect(managerNames).not.toContain("list_webhooks");
|
||||
expect(managerNames).not.toContain("create_webhook");
|
||||
|
||||
expect(userNames).not.toContain("get_system_settings");
|
||||
expect(userNames).not.toContain("update_system_settings");
|
||||
expect(userNames).not.toContain("clear_stored_runtime_secrets");
|
||||
expect(userNames).not.toContain("test_ai_connection");
|
||||
expect(userNames).not.toContain("get_ai_configured");
|
||||
expect(userNames).not.toContain("list_system_role_configs");
|
||||
expect(userNames).not.toContain("update_system_role_config");
|
||||
expect(userNames).not.toContain("list_webhooks");
|
||||
expect(userNames).not.toContain("create_webhook");
|
||||
});
|
||||
|
||||
it("keeps client deletion admin-only while still allowing manager client maintenance", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("create_client");
|
||||
expect(adminNames).toContain("update_client");
|
||||
expect(adminNames).toContain("delete_client");
|
||||
expect(managerNames).toContain("create_client");
|
||||
expect(managerNames).toContain("update_client");
|
||||
expect(managerNames).not.toContain("delete_client");
|
||||
expect(userNames).not.toContain("create_client");
|
||||
expect(userNames).not.toContain("update_client");
|
||||
expect(userNames).not.toContain("delete_client");
|
||||
});
|
||||
|
||||
it("keeps holiday calendar catalog tools admin-only while leaving preview available", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("list_holiday_calendars");
|
||||
expect(adminNames).toContain("get_holiday_calendar");
|
||||
expect(adminNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(adminNames).toContain("create_holiday_calendar");
|
||||
expect(managerNames).not.toContain("list_holiday_calendars");
|
||||
expect(managerNames).not.toContain("get_holiday_calendar");
|
||||
expect(managerNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(userNames).not.toContain("list_holiday_calendars");
|
||||
expect(userNames).not.toContain("get_holiday_calendar");
|
||||
expect(userNames).toContain("preview_resolved_holiday_calendar");
|
||||
expect(managerNames).not.toContain("create_holiday_calendar");
|
||||
expect(managerNames).not.toContain("update_holiday_calendar");
|
||||
expect(managerNames).not.toContain("delete_holiday_calendar");
|
||||
expect(managerNames).not.toContain("create_holiday_calendar_entry");
|
||||
expect(managerNames).not.toContain("update_holiday_calendar_entry");
|
||||
expect(managerNames).not.toContain("delete_holiday_calendar_entry");
|
||||
});
|
||||
|
||||
it("keeps country and metro-city mutation tools admin-only while leaving read tools available", () => {
|
||||
const adminNames = getToolNames([], SystemRole.ADMIN);
|
||||
const managerNames = getToolNames([], SystemRole.MANAGER);
|
||||
const userNames = getToolNames([], SystemRole.USER);
|
||||
const userWithResourceOverview = getToolNames([PermissionKey.VIEW_ALL_RESOURCES], SystemRole.USER);
|
||||
const userWithManagedResources = getToolNames([PermissionKey.MANAGE_RESOURCES], SystemRole.USER);
|
||||
|
||||
expect(adminNames).toContain("list_countries");
|
||||
expect(adminNames).toContain("create_country");
|
||||
expect(adminNames).toContain("update_country");
|
||||
expect(adminNames).toContain("create_metro_city");
|
||||
expect(adminNames).toContain("update_metro_city");
|
||||
expect(adminNames).toContain("delete_metro_city");
|
||||
expect(managerNames).toContain("list_countries");
|
||||
expect(managerNames).not.toContain("create_country");
|
||||
expect(managerNames).not.toContain("update_country");
|
||||
expect(managerNames).not.toContain("create_metro_city");
|
||||
expect(managerNames).not.toContain("update_metro_city");
|
||||
expect(managerNames).not.toContain("delete_metro_city");
|
||||
expect(userNames).not.toContain("search_resources");
|
||||
expect(userNames).not.toContain("get_country");
|
||||
expect(userNames).not.toContain("list_org_units");
|
||||
expect(userWithResourceOverview).toContain("search_resources");
|
||||
expect(userWithResourceOverview).toContain("get_country");
|
||||
expect(userWithResourceOverview).toContain("list_org_units");
|
||||
expect(userWithManagedResources).toContain("search_resources");
|
||||
expect(userWithManagedResources).toContain("get_country");
|
||||
expect(userWithManagedResources).toContain("list_org_units");
|
||||
});
|
||||
|
||||
it("attaches explicit access metadata to legacy monolithic tools with restricted visibility", () => {
|
||||
const toolAccess = new Map(
|
||||
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool.access]),
|
||||
);
|
||||
|
||||
expect(toolAccess.get("run_report")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("simulate_scenario")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("detect_anomalies")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("get_insights_summary")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("lookup_rate")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
});
|
||||
expect(toolAccess.get("list_rate_cards")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
});
|
||||
expect(toolAccess.get("resolve_rate")).toEqual({
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
});
|
||||
expect(toolAccess.get("import_csv_data")).toEqual({
|
||||
requiredPermissions: [PermissionKey.IMPORT_DATA],
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
});
|
||||
});
|
||||
|
||||
it("aligns assistant tool visibility with router role and permission rules", () => {
|
||||
const managerWithRolePermission = getToolNames(
|
||||
[PermissionKey.MANAGE_ROLES],
|
||||
SystemRole.MANAGER,
|
||||
);
|
||||
const managerWithoutRolePermission = getToolNames([], SystemRole.MANAGER);
|
||||
|
||||
expect(managerWithRolePermission).toContain("create_role");
|
||||
expect(managerWithRolePermission).toContain("update_role");
|
||||
expect(managerWithRolePermission).toContain("delete_role");
|
||||
expect(managerWithRolePermission).toContain("create_client");
|
||||
expect(managerWithRolePermission).toContain("update_client");
|
||||
expect(managerWithRolePermission).not.toContain("create_org_unit");
|
||||
expect(managerWithRolePermission).not.toContain("update_org_unit");
|
||||
|
||||
expect(managerWithoutRolePermission).not.toContain("create_role");
|
||||
expect(managerWithoutRolePermission).not.toContain("update_role");
|
||||
expect(managerWithoutRolePermission).not.toContain("delete_role");
|
||||
expect(managerWithoutRolePermission).toContain("create_client");
|
||||
expect(managerWithoutRolePermission).toContain("update_client");
|
||||
|
||||
const adminWithRolePermission = getToolNames(
|
||||
[PermissionKey.MANAGE_ROLES],
|
||||
SystemRole.ADMIN,
|
||||
);
|
||||
expect(adminWithRolePermission).toContain("create_org_unit");
|
||||
expect(adminWithRolePermission).toContain("update_org_unit");
|
||||
|
||||
const standardUserTools = getToolNames([], SystemRole.USER);
|
||||
expect(standardUserTools).toContain("get_vacation_balance");
|
||||
expect(standardUserTools).toContain("create_vacation");
|
||||
expect(standardUserTools).toContain("cancel_vacation");
|
||||
expect(standardUserTools).not.toContain("approve_vacation");
|
||||
expect(standardUserTools).not.toContain("reject_vacation");
|
||||
expect(standardUserTools).not.toContain("set_entitlement");
|
||||
|
||||
const managerVacationTools = getToolNames([], SystemRole.MANAGER);
|
||||
expect(managerVacationTools).toContain("approve_vacation");
|
||||
expect(managerVacationTools).toContain("reject_vacation");
|
||||
expect(managerVacationTools).toContain("set_entitlement");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SystemRole } 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([]),
|
||||
listAssignmentBookings: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
userRole: SystemRole = SystemRole.CONTROLLER,
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole,
|
||||
permissions: new Set(),
|
||||
session: {
|
||||
user: { email: "assistant@example.com", name: "Assistant User", image: null },
|
||||
expires: "2026-03-29T00:00:00.000Z",
|
||||
},
|
||||
dbUser: {
|
||||
id: "user_1",
|
||||
systemRole: userRole,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
roleDefaults: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant audit tools", () => {
|
||||
it("lists audit entries through the real audit router path", async () => {
|
||||
const ctx = createToolContext({
|
||||
auditLog: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "audit_1",
|
||||
entityType: "Project",
|
||||
entityId: "project_1",
|
||||
entityName: "Gelddruckmaschine",
|
||||
action: "UPDATE",
|
||||
userId: "user_1",
|
||||
source: "ui",
|
||||
summary: "Updated project dates",
|
||||
createdAt: new Date("2026-03-28T10:00:00.000Z"),
|
||||
user: {
|
||||
id: "user_1",
|
||||
name: "Larissa",
|
||||
email: "larissa@example.com",
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"list_audit_log_entries",
|
||||
JSON.stringify({
|
||||
entityType: "Project",
|
||||
search: "Gelddruckmaschine",
|
||||
limit: 10,
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
filters: {
|
||||
entityType: "Project",
|
||||
entityId: null,
|
||||
userId: null,
|
||||
action: null,
|
||||
source: null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
search: "Gelddruckmaschine",
|
||||
},
|
||||
itemCount: 1,
|
||||
nextCursor: null,
|
||||
items: [
|
||||
{
|
||||
id: "audit_1",
|
||||
entityType: "Project",
|
||||
entityId: "project_1",
|
||||
entityName: "Gelddruckmaschine",
|
||||
action: "UPDATE",
|
||||
userId: "user_1",
|
||||
source: "ui",
|
||||
summary: "Updated project dates",
|
||||
createdAt: "2026-03-28T10:00:00.000Z",
|
||||
user: {
|
||||
id: "user_1",
|
||||
name: "Larissa",
|
||||
email: "larissa@example.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces controller access for audit tools via the backing router", async () => {
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
auditLog: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
SystemRole.USER,
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"query_change_history",
|
||||
JSON.stringify({ entityType: "Project" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
error: "You do not have permission to perform this action.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { executeTool, type ToolContext } from "../router/assistant-tools.js";
|
||||
|
||||
function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
permissions: string[] = [],
|
||||
userRole: SystemRole = SystemRole.ADMIN,
|
||||
): ToolContext {
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole,
|
||||
permissions: new Set(permissions) as ToolContext["permissions"],
|
||||
session: {
|
||||
user: { email: "assistant@example.com", name: "Assistant User", image: null },
|
||||
expires: "2026-03-29T00:00:00.000Z",
|
||||
},
|
||||
dbUser: {
|
||||
id: "user_1",
|
||||
systemRole: userRole,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
roleDefaults: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant country tools", () => {
|
||||
it("lists countries with schedule rules, active state, and metro cities", async () => {
|
||||
const findMany = vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "country_de",
|
||||
code: "DE",
|
||||
name: "Deutschland",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
isActive: true,
|
||||
metroCities: [{ id: "city_muc", name: "Munich" }],
|
||||
},
|
||||
{
|
||||
id: "country_es",
|
||||
code: "ES",
|
||||
name: "Spain",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
isActive: true,
|
||||
metroCities: [{ id: "city_mad", name: "Madrid" }],
|
||||
},
|
||||
]);
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findMany,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"list_countries",
|
||||
JSON.stringify({ search: "deu" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
count: number;
|
||||
countries: Array<{
|
||||
code: string;
|
||||
isActive: boolean;
|
||||
metroCities: Array<{ id: string; name: string }>;
|
||||
cities: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(findMany).toHaveBeenCalledWith({
|
||||
where: { isActive: true },
|
||||
include: { metroCities: { orderBy: { name: "asc" } } },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
expect(parsed.count).toBe(1);
|
||||
expect(parsed.countries[0]).toMatchObject({
|
||||
code: "DE",
|
||||
isActive: true,
|
||||
cities: ["Munich"],
|
||||
metroCities: [{ id: "city_muc", name: "Munich" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("gets a country by code and exposes schedule details and resource count", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: "country_es",
|
||||
code: "ES",
|
||||
name: "Spain",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: {
|
||||
type: "spain",
|
||||
fridayHours: 6.5,
|
||||
summerPeriod: { from: "07-01", to: "09-15" },
|
||||
summerHours: 6.5,
|
||||
regularHours: 9,
|
||||
},
|
||||
isActive: true,
|
||||
metroCities: [{ id: "city_mad", name: "Madrid" }],
|
||||
_count: { resources: 4 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"get_country",
|
||||
JSON.stringify({ identifier: "ES" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
code: string;
|
||||
resourceCount: number | null;
|
||||
scheduleRules: { type: string };
|
||||
metroCities: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
expect(parsed).toMatchObject({
|
||||
code: "ES",
|
||||
resourceCount: 4,
|
||||
scheduleRules: { type: "spain" },
|
||||
metroCities: [{ name: "Madrid" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when a country cannot be resolved", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"get_country",
|
||||
JSON.stringify({ identifier: "Atlantis" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Country not found with the given criteria.",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a country for admin users and returns an invalidation action", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({
|
||||
id: "country_es",
|
||||
code: "ES",
|
||||
name: "Spain",
|
||||
dailyWorkingHours: 8,
|
||||
scheduleRules: null,
|
||||
isActive: true,
|
||||
metroCities: [],
|
||||
_count: { resources: 0 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"create_country",
|
||||
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({
|
||||
type: "invalidate",
|
||||
scope: ["country", "resource", "holidayCalendar", "vacation"],
|
||||
});
|
||||
expect(result.data).toMatchObject({
|
||||
success: true,
|
||||
country: { code: "ES", name: "Spain" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when creating a country with a duplicate code", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "country_es_existing",
|
||||
code: "ES",
|
||||
name: "Existing Spain",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"create_country",
|
||||
JSON.stringify({ code: "ES", name: "Spain", dailyWorkingHours: 8 }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "A country with this code already exists.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when updating a missing country", async () => {
|
||||
const ctx = createToolContext({
|
||||
country: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"update_country",
|
||||
JSON.stringify({ id: "country_missing", data: { name: "Atlantis" } }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Country not found with the given criteria.",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses country mutations for non-admin users", async () => {
|
||||
const ctx = createToolContext({ country: {} }, [], SystemRole.MANAGER);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_country",
|
||||
JSON.stringify({ code: "ES", name: "Spain" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Admin role required to perform this action.",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes metro cities only when no resources are assigned", async () => {
|
||||
const ctx = createToolContext({
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "city_ham",
|
||||
name: "Hamburg",
|
||||
_count: { resources: 0 },
|
||||
}),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"delete_metro_city",
|
||||
JSON.stringify({ id: "city_ham" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.action).toEqual({
|
||||
type: "invalidate",
|
||||
scope: ["country", "resource", "holidayCalendar", "vacation"],
|
||||
});
|
||||
expect(result.data).toMatchObject({
|
||||
success: true,
|
||||
message: "Deleted metro city: Hamburg",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when updating a missing metro city", async () => {
|
||||
const ctx = createToolContext({
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"update_metro_city",
|
||||
JSON.stringify({ id: "city_missing", data: { name: "Hamburg-Mitte" } }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Metro city not found with the given criteria.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable error when deleting a metro city that is still assigned", async () => {
|
||||
const ctx = createToolContext({
|
||||
metroCity: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "city_ham",
|
||||
name: "Hamburg",
|
||||
_count: { resources: 3 },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await executeTool(
|
||||
"delete_metro_city",
|
||||
JSON.stringify({ id: "city_ham" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Metro city cannot be deleted while it is still assigned to resources.",
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,16 @@ export function createToolContext(
|
||||
db: Record<string, unknown>,
|
||||
userRole: SystemRole = SystemRole.USER,
|
||||
): ToolContext {
|
||||
const dbWithTransaction = "$transaction" in db
|
||||
? db
|
||||
: {
|
||||
...db,
|
||||
$transaction: async <T>(callback: (tx: ToolContext["db"]) => Promise<T>) =>
|
||||
callback(db as ToolContext["db"]),
|
||||
};
|
||||
|
||||
return {
|
||||
db: db as ToolContext["db"],
|
||||
db: dbWithTransaction as ToolContext["db"],
|
||||
userId: "user_1",
|
||||
userRole,
|
||||
permissions: new Set(),
|
||||
|
||||
@@ -96,4 +96,68 @@ describe("assistant project admin create tools - success", () => {
|
||||
);
|
||||
expect(auditCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies assistant-side default project fields when optional create inputs are omitted", async () => {
|
||||
const projectCreate = vi.fn().mockResolvedValue({
|
||||
id: "project_2",
|
||||
shortCode: "PROJ-DEFAULTS",
|
||||
name: "Project Defaults",
|
||||
status: "DRAFT",
|
||||
});
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
displayName: "Peter Parker",
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: projectCreate,
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({ id: "audit_2" }),
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_project",
|
||||
JSON.stringify({
|
||||
shortCode: "PROJ-DEFAULTS",
|
||||
name: "Project Defaults",
|
||||
orderType: "CHARGEABLE",
|
||||
budgetCents: 250000,
|
||||
startDate: "2026-07-01",
|
||||
endDate: "2026-07-31",
|
||||
responsiblePerson: "Peter Parker",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual(
|
||||
expect.objectContaining({
|
||||
success: true,
|
||||
projectId: "project_2",
|
||||
shortCode: "PROJ-DEFAULTS",
|
||||
}),
|
||||
);
|
||||
expect(projectCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
allocationType: "INT",
|
||||
winProbability: 100,
|
||||
status: "DRAFT",
|
||||
staffingReqs: [],
|
||||
dynamicFields: {},
|
||||
responsiblePerson: "Peter Parker",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,4 +94,89 @@ describe("assistant project admin create tools - validation", () => {
|
||||
expect(resourceFindFirst).not.toHaveBeenCalled();
|
||||
expect(projectCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the responsible person resolution error when no active resource matches", async () => {
|
||||
const projectCreate = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: projectCreate,
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_project",
|
||||
JSON.stringify({
|
||||
shortCode: "PROJ-RP-MISSING",
|
||||
name: "Missing Responsible Match",
|
||||
orderType: "CHARGEABLE",
|
||||
budgetCents: 150000,
|
||||
startDate: "2026-05-01",
|
||||
endDate: "2026-06-30",
|
||||
responsiblePerson: "Mary Jane",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: 'No active resource found matching "Mary Jane". The responsible person must be an existing resource.',
|
||||
});
|
||||
expect(projectCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a stable assistant error when the referenced client cannot be resolved", async () => {
|
||||
const projectCreate = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
displayName: "Peter Parker",
|
||||
}),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: projectCreate,
|
||||
},
|
||||
client: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"create_project",
|
||||
JSON.stringify({
|
||||
shortCode: "PROJ-NO-CLIENT",
|
||||
name: "Missing Client",
|
||||
orderType: "CHARGEABLE",
|
||||
budgetCents: 150000,
|
||||
startDate: "2026-05-01",
|
||||
endDate: "2026-06-30",
|
||||
responsiblePerson: "Peter Parker",
|
||||
clientName: "Missing Client",
|
||||
}),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: 'Client not found: "Missing Client"',
|
||||
});
|
||||
expect(projectCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,4 +116,32 @@ describe("assistant project admin delete tools", () => {
|
||||
error: "Project not found: project_1",
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns a stable assistant error when the project cannot be resolved before deletion", async () => {
|
||||
const transaction = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
$transaction: transaction,
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"delete_project",
|
||||
JSON.stringify({ projectId: "missing-project" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "Project not found: missing-project",
|
||||
});
|
||||
expect(transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,4 +108,68 @@ describe("assistant project admin update tools", () => {
|
||||
error: "Project not found with the given criteria.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a stable assistant error when no update fields are provided", async () => {
|
||||
const projectUpdate = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(createProject()),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update: projectUpdate,
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"update_project",
|
||||
JSON.stringify({ id: "PROJ-1" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: "No fields to update",
|
||||
});
|
||||
expect(projectUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns responsible person resolver errors unchanged during update", async () => {
|
||||
const projectUpdate = vi.fn();
|
||||
const ctx = createToolContext(
|
||||
{
|
||||
project: {
|
||||
findUnique: vi.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(createProject()),
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update: projectUpdate,
|
||||
},
|
||||
resource: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
userRole: SystemRole.ADMIN,
|
||||
permissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeTool(
|
||||
"update_project",
|
||||
JSON.stringify({ id: "PROJ-1", responsiblePerson: "Mary Jane" }),
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(JSON.parse(result.content)).toEqual({
|
||||
error: 'No active resource found matching "Mary Jane". The responsible person must be an existing resource.',
|
||||
});
|
||||
expect(projectUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,60 @@ describe("notification procedure support", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites notification recipient foreign-key errors to a not found TRPC error", () => {
|
||||
const error = {
|
||||
code: "P2003",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
};
|
||||
|
||||
try {
|
||||
rethrowNotificationReferenceError(error);
|
||||
throw new Error("expected notification reference error");
|
||||
} catch (caught) {
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect(caught).toMatchObject<Partial<TRPCError>>({
|
||||
code: "NOT_FOUND",
|
||||
message: "Notification recipient user not found",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites task recipient foreign-key errors to a not found TRPC error", () => {
|
||||
const error = {
|
||||
code: "P2003",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
};
|
||||
|
||||
try {
|
||||
rethrowNotificationReferenceError(error, "task");
|
||||
throw new Error("expected notification reference error");
|
||||
} catch (caught) {
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect(caught).toMatchObject<Partial<TRPCError>>({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task recipient user not found",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites broadcast recipient foreign-key errors to a not found TRPC error", () => {
|
||||
const error = {
|
||||
code: "P2003",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
};
|
||||
|
||||
try {
|
||||
rethrowNotificationReferenceError(error, "broadcast");
|
||||
throw new Error("expected notification reference error");
|
||||
} catch (caught) {
|
||||
expect(caught).toBeInstanceOf(TRPCError);
|
||||
expect(caught).toMatchObject<Partial<TRPCError>>({
|
||||
code: "NOT_FOUND",
|
||||
message: "Broadcast recipient user not found",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rethrows unrelated errors unchanged", () => {
|
||||
const error = new Error("boom");
|
||||
|
||||
|
||||
@@ -72,6 +72,21 @@ function createManagerCaller(db: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
function createAdminCaller(db: Record<string, unknown>) {
|
||||
return createCaller({
|
||||
session: {
|
||||
user: { email: "admin@example.com", name: "Admin", image: null },
|
||||
expires: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
db: db as never,
|
||||
dbUser: {
|
||||
id: "user_admin",
|
||||
systemRole: SystemRole.ADMIN,
|
||||
permissionOverrides: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sample data ──────────────────────────────────────────────────────────────
|
||||
|
||||
function sampleNotification(overrides: Record<string, unknown> = {}) {
|
||||
@@ -281,6 +296,45 @@ describe("notification.create", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults task-like managed notifications to OPEN when no taskStatus is provided", async () => {
|
||||
const created = sampleNotification({
|
||||
userId: "target_user",
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
});
|
||||
const db = withUserLookup(
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockResolvedValue(created),
|
||||
findUnique: vi.fn().mockResolvedValue(created),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
);
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.create({
|
||||
userId: "target_user",
|
||||
type: "TASK_CREATED",
|
||||
title: "Review proposal",
|
||||
category: "TASK",
|
||||
});
|
||||
|
||||
expect(db.notification.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
id: "notif_1",
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects creation by a regular user (FORBIDDEN)", async () => {
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
@@ -293,6 +347,36 @@ describe("notification.create", () => {
|
||||
caller.create({ userId: "target", type: "INFO", title: "Nope" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("maps missing notification recipients to a not found error", async () => {
|
||||
const db = withUserLookup(
|
||||
{
|
||||
notification: {
|
||||
create: vi.fn().mockRejectedValue({
|
||||
code: "P2003",
|
||||
message: "Foreign key constraint failed",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
}),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
"user_mgr",
|
||||
);
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.create({
|
||||
userId: "user_missing",
|
||||
type: "INFO",
|
||||
title: "Test notification",
|
||||
})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Notification recipient user not found",
|
||||
});
|
||||
|
||||
expect(db.notification.findUnique).not.toHaveBeenCalled();
|
||||
expect(emitNotificationCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createBroadcast ────────────────────────────────────────────────────────
|
||||
@@ -590,6 +674,75 @@ describe("notification.createBroadcast", () => {
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps missing broadcast recipients during fan-out to not found errors", async () => {
|
||||
resolveRecipientsMock.mockResolvedValue(["user_a", "user_missing"]);
|
||||
|
||||
const txCreateBroadcast = vi.fn().mockResolvedValue({
|
||||
id: "broadcast_tx_missing_recipient",
|
||||
title: "Ops update",
|
||||
createdAt: new Date("2026-03-30T10:00:00Z"),
|
||||
});
|
||||
const txUpdateBroadcast = vi.fn();
|
||||
const txCreateNotification = vi.fn()
|
||||
.mockResolvedValueOnce({ id: "notif_a", userId: "user_a" })
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Foreign key constraint failed"), {
|
||||
code: "P2003",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
}),
|
||||
);
|
||||
const tx = {
|
||||
notificationBroadcast: {
|
||||
create: txCreateBroadcast,
|
||||
update: txUpdateBroadcast,
|
||||
},
|
||||
notification: {
|
||||
create: txCreateNotification,
|
||||
},
|
||||
};
|
||||
const outerCreateBroadcast = vi.fn();
|
||||
const outerUpdateBroadcast = vi.fn();
|
||||
const outerCreateNotification = vi.fn();
|
||||
const db = {
|
||||
$transaction: vi.fn(async (callback: (db: typeof tx) => Promise<unknown>) => callback(tx)),
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
notificationBroadcast: {
|
||||
create: outerCreateBroadcast,
|
||||
update: outerUpdateBroadcast,
|
||||
},
|
||||
notification: {
|
||||
create: outerCreateNotification,
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.createBroadcast({
|
||||
title: "Ops update",
|
||||
body: "Email everyone",
|
||||
channel: "both",
|
||||
targetType: "all",
|
||||
})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Broadcast recipient user not found",
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(txCreateBroadcast).toHaveBeenCalledTimes(1);
|
||||
expect(txCreateNotification).toHaveBeenCalledTimes(2);
|
||||
expect(txUpdateBroadcast).not.toHaveBeenCalled();
|
||||
expect(outerCreateBroadcast).not.toHaveBeenCalled();
|
||||
expect(outerUpdateBroadcast).not.toHaveBeenCalled();
|
||||
expect(outerCreateNotification).not.toHaveBeenCalled();
|
||||
expect(db.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(sendEmailMock).not.toHaveBeenCalled();
|
||||
expect(emitNotificationCreated).not.toHaveBeenCalled();
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits recipient SSE only after an immediate broadcast commits", async () => {
|
||||
resolveRecipientsMock.mockResolvedValue(["user_a", "user_b"]);
|
||||
|
||||
@@ -1301,6 +1454,78 @@ describe("notification.updateTaskStatus", () => {
|
||||
});
|
||||
|
||||
describe("notification.assignTask", () => {
|
||||
it("returns NOT_FOUND when assigning a missing task", async () => {
|
||||
const update = vi.fn();
|
||||
const db = {
|
||||
notification: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
update,
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.assignTask({ id: "task_missing", assigneeId: "user_4" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task not found",
|
||||
});
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps missing task recipients to a not found error without side effects", async () => {
|
||||
const db = {
|
||||
notification: {
|
||||
create: vi.fn().mockRejectedValue({
|
||||
code: "P2003",
|
||||
message: "Foreign key constraint failed",
|
||||
meta: { field_name: "Notification_userId_fkey" },
|
||||
}),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.createTask({
|
||||
userId: "user_missing",
|
||||
title: "Review proposal",
|
||||
channel: "in_app",
|
||||
})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task recipient user not found",
|
||||
});
|
||||
|
||||
expect(db.notification.findUnique).not.toHaveBeenCalled();
|
||||
expect(emitNotificationCreated).not.toHaveBeenCalled();
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects assigning non-task notifications", async () => {
|
||||
const update = vi.fn();
|
||||
const db = {
|
||||
notification: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "notif_9",
|
||||
category: "REMINDER",
|
||||
assigneeId: null,
|
||||
}),
|
||||
update,
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.assignTask({ id: "notif_9", assigneeId: "user_4" })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Only tasks and approvals can be assigned",
|
||||
});
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reassigns a task and emits the assignment event for the new assignee", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
id: "task_9",
|
||||
@@ -1365,6 +1590,103 @@ describe("notification.assignTask", () => {
|
||||
});
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalledWith("user_missing", "task_9");
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND when the task disappears before reassignment is persisted", async () => {
|
||||
const findUnique = vi.fn().mockResolvedValue({
|
||||
id: "task_9",
|
||||
category: "TASK",
|
||||
assigneeId: "user_2",
|
||||
});
|
||||
const update = vi.fn().mockRejectedValue(
|
||||
Object.assign(new Error("Record to update not found"), {
|
||||
code: "P2025",
|
||||
}),
|
||||
);
|
||||
const db = {
|
||||
notification: {
|
||||
findUnique,
|
||||
update,
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
|
||||
await expect(caller.assignTask({ id: "task_9", assigneeId: "user_4" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task not found",
|
||||
});
|
||||
|
||||
expect(findUnique).toHaveBeenCalledWith({ where: { id: "task_9" } });
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
where: { id: "task_9" },
|
||||
data: { assigneeId: "user_4" },
|
||||
});
|
||||
expect(emitTaskAssigned).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.executeTaskAction", () => {
|
||||
it("rejects dismissed tasks before executing their domain action", async () => {
|
||||
const updateAssignment = vi.fn();
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "task_1",
|
||||
userId: "user_1",
|
||||
assigneeId: null,
|
||||
taskAction: "confirm_assignment:assign_1",
|
||||
taskStatus: "DISMISSED",
|
||||
}),
|
||||
update: vi.fn(),
|
||||
},
|
||||
assignment: {
|
||||
findUnique: vi.fn(),
|
||||
update: updateAssignment,
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.executeTaskAction({ id: "task_1" })).rejects.toMatchObject({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "This task has been dismissed",
|
||||
});
|
||||
|
||||
expect(updateAssignment).not.toHaveBeenCalled();
|
||||
expect(db.notification.update).not.toHaveBeenCalled();
|
||||
expect(emitTaskCompleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects task action execution when transactional persistence support is unavailable", async () => {
|
||||
const updateVacation = vi.fn();
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "task_1",
|
||||
userId: "user_1",
|
||||
assigneeId: null,
|
||||
taskAction: "approve_vacation:vac_1",
|
||||
taskStatus: "OPEN",
|
||||
}),
|
||||
update: vi.fn(),
|
||||
},
|
||||
vacation: {
|
||||
findUnique: vi.fn(),
|
||||
update: updateVacation,
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createAdminCaller(db);
|
||||
|
||||
await expect(caller.executeTaskAction({ id: "task_1" })).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Task action execution requires transactional persistence support.",
|
||||
});
|
||||
|
||||
expect(updateVacation).not.toHaveBeenCalled();
|
||||
expect(db.notification.update).not.toHaveBeenCalled();
|
||||
expect(emitTaskCompleted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reminders ──────────────────────────────────────────────────────────────
|
||||
@@ -1467,6 +1789,28 @@ describe("notification.updateReminder", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND when the reminder is missing or belongs to another user", async () => {
|
||||
const update = vi.fn();
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
update,
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.updateReminder({
|
||||
id: "rem_missing",
|
||||
title: "Updated reminder",
|
||||
})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Reminder not found or you do not have permission",
|
||||
});
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.deleteReminder", () => {
|
||||
@@ -1492,6 +1836,25 @@ describe("notification.deleteReminder", () => {
|
||||
});
|
||||
expect(deleteFn).toHaveBeenCalledWith({ where: { id: "rem_1" } });
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND when the reminder is missing or belongs to another user", async () => {
|
||||
const deleteFn = vi.fn();
|
||||
const db = withUserLookup({
|
||||
notification: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
delete: deleteFn,
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
|
||||
await expect(caller.deleteReminder({ id: "rem_missing" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
message: "Reminder not found or you do not have permission",
|
||||
});
|
||||
|
||||
expect(deleteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("notification.listReminders", () => {
|
||||
|
||||
@@ -59,6 +59,67 @@ describe("report router", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("exposes extended resource and project basis columns for report completeness", async () => {
|
||||
const caller = createControllerCaller({});
|
||||
|
||||
const resourceColumns = await caller.getAvailableColumns({ entity: "resource" });
|
||||
const projectColumns = await caller.getAvailableColumns({ entity: "project" });
|
||||
|
||||
expect(resourceColumns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "enterpriseId", label: "Enterprise ID" }),
|
||||
expect.objectContaining({ key: "valueScore", label: "Value Score" }),
|
||||
expect.objectContaining({ key: "blueprint.name", label: "Blueprint" }),
|
||||
expect.objectContaining({ key: "clientUnit.name", label: "Client Unit" }),
|
||||
]));
|
||||
expect(projectColumns).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: "shoringThreshold", label: "Shoring Threshold (%)" }),
|
||||
expect.objectContaining({ key: "onshoreCountryCode", label: "Onshore Country Code" }),
|
||||
expect.objectContaining({ key: "color", label: "Color" }),
|
||||
]));
|
||||
});
|
||||
|
||||
it("lists backend-managed report blueprints for resource_month", async () => {
|
||||
const caller = createControllerCaller({});
|
||||
const blueprints = await caller.listBlueprints({ entity: "resource_month" });
|
||||
|
||||
expect(blueprints).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "resource-month-sah-transparency",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly SAH transparency",
|
||||
config: expect.objectContaining({
|
||||
entity: "resource_month",
|
||||
sortBy: "displayName",
|
||||
sortDir: "asc",
|
||||
filters: [],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "resource-month-chargeability-audit",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly chargeability audit",
|
||||
config: expect.objectContaining({
|
||||
entity: "resource_month",
|
||||
sortBy: "monthlyActualChargeabilityPct",
|
||||
sortDir: "desc",
|
||||
filters: [],
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "resource-month-location-comparison",
|
||||
entity: "resource_month",
|
||||
templateName: "Monthly holiday comparison by location",
|
||||
config: expect.objectContaining({
|
||||
entity: "resource_month",
|
||||
groupBy: "federalState",
|
||||
sortBy: "monthlyPublicHolidayHoursDeduction",
|
||||
sortDir: "desc",
|
||||
filters: [],
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports resource month basis and computed columns in CSV", async () => {
|
||||
const db = {
|
||||
resource: {
|
||||
@@ -114,6 +175,42 @@ describe("report router", () => {
|
||||
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");
|
||||
expect(result.columns).toEqual([
|
||||
"id",
|
||||
"displayName",
|
||||
"countryCode",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyTargetHours",
|
||||
"monthlyUnassignedHours",
|
||||
]);
|
||||
expect(result.explainability).toEqual({
|
||||
entity: "resource_month",
|
||||
periodMonth: "2026-04",
|
||||
locationContextColumns: ["countryCode"],
|
||||
holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"],
|
||||
absenceMetricColumns: ["monthlyAbsenceHoursDeduction"],
|
||||
capacityMetricColumns: ["monthlySahHours", "monthlyTargetHours"],
|
||||
chargeabilityMetricColumns: ["monthlyUnassignedHours"],
|
||||
missingRecommendedColumns: [
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
],
|
||||
notes: [
|
||||
"monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.",
|
||||
"monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.",
|
||||
"monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps holiday and absence deductions separate in resource_month rows", async () => {
|
||||
@@ -178,6 +275,132 @@ describe("report router", () => {
|
||||
monthlySahHours: 156,
|
||||
},
|
||||
]);
|
||||
expect(result.explainability).toEqual({
|
||||
entity: "resource_month",
|
||||
periodMonth: "2026-04",
|
||||
locationContextColumns: [],
|
||||
holidayMetricColumns: ["monthlyPublicHolidayCount", "monthlyPublicHolidayHoursDeduction"],
|
||||
absenceMetricColumns: ["monthlyAbsenceDayEquivalent", "monthlyAbsenceHoursDeduction"],
|
||||
capacityMetricColumns: ["monthlySahHours"],
|
||||
chargeabilityMetricColumns: [],
|
||||
missingRecommendedColumns: [
|
||||
"countryCode",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
],
|
||||
notes: [
|
||||
"monthlySahHours already reflects region-specific public holidays from country, federal state, and city context when those attributes exist on the resource.",
|
||||
"monthlyAbsence* metrics only deduct workdays that are not already counted as public holidays.",
|
||||
"monthlyBaseAvailableHours shows pre-deduction capacity; compare it with holiday, absence, and SAH columns to explain the final monthly availability.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens extended assignment resource and project context columns", async () => {
|
||||
const db = {
|
||||
assignment: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "asg_1",
|
||||
hoursPerDay: 6,
|
||||
resource: {
|
||||
displayName: "Alice",
|
||||
resourceType: "EMPLOYEE",
|
||||
chargeabilityTarget: 85,
|
||||
orgUnit: { name: "Delivery" },
|
||||
managementLevelGroup: { name: "Senior IC" },
|
||||
managementLevel: { name: "Senior Artist" },
|
||||
},
|
||||
project: {
|
||||
name: "Gelddruckmaschine",
|
||||
orderType: "TIME_AND_MATERIAL",
|
||||
allocationType: "PROJECT",
|
||||
blueprint: { name: "Consulting Blueprint" },
|
||||
utilizationCategory: { name: "Billable" },
|
||||
},
|
||||
},
|
||||
]),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createControllerCaller(db);
|
||||
const result = await caller.getReportData({
|
||||
entity: "assignment",
|
||||
columns: [
|
||||
"resource.displayName",
|
||||
"resource.resourceType",
|
||||
"resource.chargeabilityTarget",
|
||||
"resource.orgUnit.name",
|
||||
"resource.managementLevelGroup.name",
|
||||
"resource.managementLevel.name",
|
||||
"project.name",
|
||||
"project.orderType",
|
||||
"project.allocationType",
|
||||
"project.blueprint.name",
|
||||
"project.utilizationCategory.name",
|
||||
"hoursPerDay",
|
||||
],
|
||||
filters: [],
|
||||
sortBy: "hoursPerDay",
|
||||
sortDir: "desc",
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(db.assignment.findMany).toHaveBeenCalledWith({
|
||||
select: {
|
||||
id: true,
|
||||
hoursPerDay: true,
|
||||
resource: {
|
||||
select: {
|
||||
displayName: true,
|
||||
resourceType: true,
|
||||
chargeabilityTarget: true,
|
||||
orgUnit: { select: { name: true } },
|
||||
managementLevelGroup: { select: { name: true } },
|
||||
managementLevel: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
orderType: true,
|
||||
allocationType: true,
|
||||
blueprint: { select: { name: true } },
|
||||
utilizationCategory: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {},
|
||||
orderBy: [{ hoursPerDay: "desc" }],
|
||||
take: 10,
|
||||
skip: 0,
|
||||
});
|
||||
expect(result.rows).toEqual([
|
||||
{
|
||||
id: "asg_1",
|
||||
"resource.displayName": "Alice",
|
||||
"resource.resourceType": "EMPLOYEE",
|
||||
"resource.chargeabilityTarget": 85,
|
||||
"resource.orgUnit.name": "Delivery",
|
||||
"resource.managementLevelGroup.name": "Senior IC",
|
||||
"resource.managementLevel.name": "Senior Artist",
|
||||
"project.name": "Gelddruckmaschine",
|
||||
"project.orderType": "TIME_AND_MATERIAL",
|
||||
"project.allocationType": "PROJECT",
|
||||
"project.blueprint.name": "Consulting Blueprint",
|
||||
"project.utilizationCategory.name": "Billable",
|
||||
hoursPerDay: 6,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects invalid resource_month period months instead of silently normalizing them", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildResourceMonthTemplateCompleteness } from "../router/report-blueprints-support.js";
|
||||
import {
|
||||
DeleteReportTemplateInputSchema,
|
||||
deleteReportTemplate,
|
||||
@@ -21,6 +22,36 @@ function createContext(reportTemplate: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
describe("report template procedure support", () => {
|
||||
it("reuses the shared resource month completeness basis", () => {
|
||||
expect(buildResourceMonthTemplateCompleteness([
|
||||
"monthKey",
|
||||
"displayName",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyTargetHours",
|
||||
"monthlyActualBookedHours",
|
||||
"monthlyUnassignedHours",
|
||||
])).toMatchObject({
|
||||
scope: "resource_month",
|
||||
isAuditReady: true,
|
||||
isRecommendedComplete: false,
|
||||
minimumAuditColumnCount: 13,
|
||||
selectedMinimumAuditColumnCount: 13,
|
||||
missingMinimumAuditColumns: [],
|
||||
missingRecommendedColumns: expect.arrayContaining([
|
||||
"eid",
|
||||
"chapter",
|
||||
"monthlyExpectedBookedHours",
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it("lists shared and owned templates with parsed config and ownership flags", async () => {
|
||||
const updatedAt = new Date("2026-03-31T10:00:00.000Z");
|
||||
const ctx = createContext({
|
||||
|
||||
@@ -24,7 +24,10 @@ vi.mock("@capakraken/application", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js";
|
||||
import {
|
||||
carveTimelineAllocationRange,
|
||||
extractTimelineAllocationFragment,
|
||||
} from "../router/timeline-allocation-fragment-support.js";
|
||||
|
||||
function createResolvedAssignment() {
|
||||
return {
|
||||
@@ -172,4 +175,83 @@ describe("timeline allocation fragment support", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts a middle segment into the original assignment and creates siblings", async () => {
|
||||
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
|
||||
updateAssignmentMock.mockResolvedValue({ id: "assignment_1" });
|
||||
createAssignmentMock
|
||||
.mockResolvedValueOnce({ id: "assignment_left" })
|
||||
.mockResolvedValueOnce({ id: "assignment_right" });
|
||||
|
||||
const db = {
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
|
||||
const result = await extractTimelineAllocationFragment({
|
||||
db: db as never,
|
||||
allocationId: "assignment_1",
|
||||
startDate: new Date("2026-04-09T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "extracted",
|
||||
allocationGroupId: expect.any(String),
|
||||
extractedAllocationId: "assignment_1",
|
||||
updatedAllocationIds: ["assignment_1"],
|
||||
createdAllocationIds: ["assignment_left", "assignment_right"],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(updateAssignmentMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
createAssignmentMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
expect(updateAssignmentMock).toHaveBeenCalledWith(
|
||||
db,
|
||||
"assignment_1",
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-04-09T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-10T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
expect(createAssignmentMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
db,
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-08T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
expect(createAssignmentMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
db,
|
||||
expect.objectContaining({
|
||||
startDate: new Date("2026-04-11T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unchanged when extracting the full assignment range", async () => {
|
||||
loadAllocationEntryMock.mockResolvedValue(createResolvedAssignment());
|
||||
|
||||
const result = await extractTimelineAllocationFragment({
|
||||
db: { $transaction: vi.fn() } as never,
|
||||
allocationId: "assignment_1",
|
||||
startDate: new Date("2026-04-06T00:00:00.000Z"),
|
||||
endDate: new Date("2026-04-17T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "unchanged",
|
||||
allocationGroupId: expect.any(String),
|
||||
extractedAllocationId: "assignment_1",
|
||||
updatedAllocationIds: [],
|
||||
createdAllocationIds: [],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(updateAssignmentMock).not.toHaveBeenCalled();
|
||||
expect(createAssignmentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../sse/event-bus.js", () => ({
|
||||
emitAllocationCreated: vi.fn(),
|
||||
emitAllocationDeleted: vi.fn(),
|
||||
emitAllocationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -13,11 +15,20 @@ vi.mock("../router/timeline-allocation-inline-support.js", () => ({
|
||||
applyTimelineInlineAllocationUpdate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../router/timeline-allocation-fragment-support.js", () => ({
|
||||
carveTimelineAllocationRange: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../router/timeline-allocation-procedure-support.js", () => ({
|
||||
shiftTimelineAllocations: vi.fn(),
|
||||
}));
|
||||
|
||||
import { emitAllocationUpdated } from "../sse/event-bus.js";
|
||||
import {
|
||||
emitAllocationCreated,
|
||||
emitAllocationDeleted,
|
||||
emitAllocationUpdated,
|
||||
} from "../sse/event-bus.js";
|
||||
import { carveTimelineAllocationRange } from "../router/timeline-allocation-fragment-support.js";
|
||||
import {
|
||||
createTimelineBatchQuickAssignments,
|
||||
createTimelineQuickAssignment,
|
||||
@@ -26,12 +37,16 @@ import { applyTimelineInlineAllocationUpdate } from "../router/timeline-allocati
|
||||
import { shiftTimelineAllocations } from "../router/timeline-allocation-procedure-support.js";
|
||||
import {
|
||||
applyTimelineAllocationBatchShiftMutation,
|
||||
carveTimelineAllocationRangeMutation,
|
||||
createTimelineBatchQuickAssignMutation,
|
||||
createTimelineQuickAssignMutation,
|
||||
updateTimelineAllocationInlineMutation,
|
||||
} from "../router/timeline-allocation-router-support.js";
|
||||
|
||||
const emitAllocationCreatedMock = vi.mocked(emitAllocationCreated);
|
||||
const emitAllocationDeletedMock = vi.mocked(emitAllocationDeleted);
|
||||
const emitAllocationUpdatedMock = vi.mocked(emitAllocationUpdated);
|
||||
const carveTimelineAllocationRangeMock = vi.mocked(carveTimelineAllocationRange);
|
||||
const createTimelineBatchQuickAssignmentsMock = vi.mocked(createTimelineBatchQuickAssignments);
|
||||
const createTimelineQuickAssignmentMock = vi.mocked(createTimelineQuickAssignment);
|
||||
const applyTimelineInlineAllocationUpdateMock = vi.mocked(applyTimelineInlineAllocationUpdate);
|
||||
@@ -183,4 +198,59 @@ describe("timeline allocation router support", () => {
|
||||
mode: "preserve-duration",
|
||||
});
|
||||
});
|
||||
|
||||
it("carves an allocation range and emits update/create/delete events for every affected fragment", async () => {
|
||||
const db = {} as never;
|
||||
const startDate = new Date("2026-04-09T00:00:00.000Z");
|
||||
const endDate = new Date("2026-04-10T00:00:00.000Z");
|
||||
|
||||
carveTimelineAllocationRangeMock.mockResolvedValueOnce({
|
||||
action: "split",
|
||||
allocationGroupId: "group_1",
|
||||
updatedAllocationIds: ["allocation_left"],
|
||||
createdAllocationIds: ["allocation_right"],
|
||||
deletedAllocationIds: ["allocation_removed"],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
carveTimelineAllocationRangeMutation({
|
||||
db,
|
||||
allocationId: "allocation_1",
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
action: "split",
|
||||
allocationGroupId: "group_1",
|
||||
updatedAllocationIds: ["allocation_left"],
|
||||
createdAllocationIds: ["allocation_right"],
|
||||
deletedAllocationIds: ["allocation_removed"],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
|
||||
expect(carveTimelineAllocationRangeMock).toHaveBeenCalledWith({
|
||||
db,
|
||||
allocationId: "allocation_1",
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
expect(emitAllocationUpdatedMock).toHaveBeenCalledWith({
|
||||
id: "allocation_left",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(emitAllocationCreatedMock).toHaveBeenCalledWith({
|
||||
id: "allocation_right",
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(emitAllocationDeletedMock).toHaveBeenCalledWith(
|
||||
"allocation_removed",
|
||||
"project_1",
|
||||
"resource_1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,6 +222,124 @@ describe("timeline allocation entry resolution", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts a visible subrange into its own assignment fragment", async () => {
|
||||
const existingAssignment = {
|
||||
id: "assignment_1",
|
||||
demandRequirementId: null,
|
||||
resourceId: "resource_1",
|
||||
projectId: "project_1",
|
||||
startDate: new Date("2026-03-16"),
|
||||
endDate: new Date("2026-03-27"),
|
||||
hoursPerDay: 8,
|
||||
percentage: 100,
|
||||
role: "Compositor",
|
||||
roleId: "role_comp",
|
||||
dailyCostCents: 40000,
|
||||
status: AllocationStatus.CONFIRMED,
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-03-13"),
|
||||
updatedAt: new Date("2026-03-13"),
|
||||
resource: {
|
||||
id: "resource_1",
|
||||
displayName: "Alice",
|
||||
eid: "E-001",
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
},
|
||||
project: { id: "project_1", name: "Project One", shortCode: "PRJ" },
|
||||
roleEntity: { id: "role_comp", name: "Compositor", color: "#111111" },
|
||||
demandRequirement: null,
|
||||
};
|
||||
|
||||
const db = {
|
||||
project: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "resource_1",
|
||||
lcrCents: 5000,
|
||||
availability: {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
wednesday: 8,
|
||||
thursday: 8,
|
||||
friday: 8,
|
||||
saturday: 0,
|
||||
sunday: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
demandRequirement: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
assignment: {
|
||||
findUnique: vi.fn().mockResolvedValue(existingAssignment),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
update: vi.fn().mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
|
||||
...existingAssignment,
|
||||
...data,
|
||||
metadata: data.metadata ?? existingAssignment.metadata,
|
||||
})),
|
||||
create: vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
...existingAssignment,
|
||||
id: "assignment_left",
|
||||
startDate: new Date("2026-03-16"),
|
||||
endDate: new Date("2026-03-20"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
...existingAssignment,
|
||||
id: "assignment_right",
|
||||
startDate: new Date("2026-03-26"),
|
||||
endDate: new Date("2026-03-27"),
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
|
||||
};
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
const result = await caller.extractAllocationFragment({
|
||||
allocationId: "assignment_1",
|
||||
startDate: new Date("2026-03-23"),
|
||||
endDate: new Date("2026-03-25"),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: "extracted",
|
||||
allocationGroupId: expect.any(String),
|
||||
extractedAllocationId: "assignment_1",
|
||||
updatedAllocationIds: ["assignment_1"],
|
||||
createdAllocationIds: ["assignment_left", "assignment_right"],
|
||||
projectId: "project_1",
|
||||
resourceId: "resource_1",
|
||||
});
|
||||
expect(db.assignment.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "assignment_1" },
|
||||
data: expect.objectContaining({
|
||||
startDate: new Date("2026-03-23"),
|
||||
endDate: new Date("2026-03-25"),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(db.assignment.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to default rules when calculationRule and vacation tables are missing", async () => {
|
||||
const existingAssignment = {
|
||||
id: "assignment_legacy_1",
|
||||
|
||||
@@ -142,7 +142,7 @@ describe("timeline router detail views", () => {
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
]);
|
||||
@@ -177,6 +177,10 @@ describe("timeline router detail views", () => {
|
||||
resourceId: "res_self",
|
||||
note: "Heilige Drei Könige",
|
||||
scope: "STATE",
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Muenchen",
|
||||
}),
|
||||
]);
|
||||
expect(demandFindMany).not.toHaveBeenCalled();
|
||||
@@ -365,7 +369,7 @@ describe("timeline router detail views", () => {
|
||||
countryId: "country_de",
|
||||
federalState: "BY",
|
||||
metroCityId: "city_munich",
|
||||
country: { code: "DE" },
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
{
|
||||
@@ -373,7 +377,7 @@ describe("timeline router detail views", () => {
|
||||
countryId: "country_de",
|
||||
federalState: "HH",
|
||||
metroCityId: "city_hamburg",
|
||||
country: { code: "DE" },
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
]),
|
||||
@@ -409,6 +413,10 @@ describe("timeline router detail views", () => {
|
||||
startDate: "2026-01-06",
|
||||
note: "Heilige Drei Könige",
|
||||
scope: "STATE",
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Muenchen",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
|
||||
import OpenAI, { AzureOpenAI } from "openai";
|
||||
import { logger } from "./lib/logger.js";
|
||||
import { resolveSystemSettingsRuntime } from "./lib/system-settings-runtime.js";
|
||||
@@ -123,7 +124,7 @@ export function parseAiError(err: unknown): string {
|
||||
return "Deployment not found — check the deployment name matches exactly what's configured in Azure.";
|
||||
}
|
||||
if (lower.includes("404") || lower.includes("not found")) {
|
||||
return "Model not found — verify the model name (e.g. gpt-4o-mini) is correct and available on your account.";
|
||||
return `Model not found — verify the model name (e.g. ${DEFAULT_OPENAI_MODEL}) is correct and available on your account.`;
|
||||
}
|
||||
if (lower.includes("429") || lower.includes("rate limit") || lower.includes("ratelimiterror")) {
|
||||
return "Rate limit exceeded — wait a moment and try again.";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380";
|
||||
const KEY_PREFIX = "dashboard:";
|
||||
@@ -15,7 +16,7 @@ function getRedis(): Redis {
|
||||
commandTimeout: 2000,
|
||||
});
|
||||
redis.on("error", (e: unknown) => {
|
||||
console.error("[Redis cache]", e);
|
||||
logger.warn({ err: e, redisUrl: REDIS_URL }, "Redis cache connection emitted an error");
|
||||
});
|
||||
}
|
||||
return redis;
|
||||
|
||||
@@ -84,7 +84,14 @@ export const allocationAssignmentProcedures = {
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const result = await ensureAssignmentRecord(ctx.db, input);
|
||||
const result = await ensureAssignmentRecord(ctx.db, {
|
||||
resourceId: input.resourceId,
|
||||
projectId: input.projectId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
hoursPerDay: input.hoursPerDay,
|
||||
...(input.role !== undefined ? { role: input.role } : {}),
|
||||
});
|
||||
|
||||
if (result.action === "reactivated") {
|
||||
publishAllocationUpdated(ctx.db, {
|
||||
|
||||
@@ -1,294 +1,11 @@
|
||||
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
||||
import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS } from "./assistant-tools.js";
|
||||
import { type PermissionKey } from "@capakraken/shared";
|
||||
|
||||
import { getAvailableAssistantToolsForContext } from "./assistant-tools.js";
|
||||
import type { ToolDef } from "./assistant-tools/shared.js";
|
||||
|
||||
const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
update_resource: "manageResources",
|
||||
create_resource: "manageResources",
|
||||
deactivate_resource: "manageResources",
|
||||
create_role: PermissionKey.MANAGE_ROLES,
|
||||
update_role: PermissionKey.MANAGE_ROLES,
|
||||
delete_role: PermissionKey.MANAGE_ROLES,
|
||||
update_project: "manageProjects",
|
||||
create_project: "manageProjects",
|
||||
delete_project: "manageProjects",
|
||||
create_estimate: "manageProjects",
|
||||
clone_estimate: "manageProjects",
|
||||
update_estimate_draft: "manageProjects",
|
||||
submit_estimate_version: "manageProjects",
|
||||
approve_estimate_version: "manageProjects",
|
||||
create_estimate_revision: "manageProjects",
|
||||
create_estimate_export: "manageProjects",
|
||||
generate_estimate_weekly_phasing: "manageProjects",
|
||||
update_estimate_commercial_terms: "manageProjects",
|
||||
generate_project_cover: "manageProjects",
|
||||
remove_project_cover: "manageProjects",
|
||||
import_csv_data: PermissionKey.IMPORT_DATA,
|
||||
create_allocation: "manageAllocations",
|
||||
cancel_allocation: "manageAllocations",
|
||||
update_allocation_status: "manageAllocations",
|
||||
update_timeline_allocation_inline: "manageAllocations",
|
||||
apply_timeline_project_shift: "manageAllocations",
|
||||
quick_assign_timeline_resource: "manageAllocations",
|
||||
batch_quick_assign_timeline_resources: "manageAllocations",
|
||||
batch_shift_timeline_allocations: "manageAllocations",
|
||||
create_demand: "manageAllocations",
|
||||
fill_demand: "manageAllocations",
|
||||
create_estimate_planning_handoff: "manageAllocations",
|
||||
execute_task_action: "manageAllocations",
|
||||
};
|
||||
|
||||
const COST_TOOLS = new Set([
|
||||
"get_budget_status",
|
||||
"get_chargeability",
|
||||
"get_chargeability_report",
|
||||
"get_resource_computation_graph",
|
||||
"get_project_computation_graph",
|
||||
"resolve_rate",
|
||||
"list_rate_cards",
|
||||
"get_estimate_detail",
|
||||
"get_estimate_version_snapshot",
|
||||
"find_best_project_resource",
|
||||
"get_staffing_suggestions",
|
||||
]);
|
||||
|
||||
const PLANNING_READ_TOOLS = new Set([
|
||||
"list_allocations",
|
||||
"list_demands",
|
||||
"list_blueprints",
|
||||
"get_blueprint",
|
||||
"list_clients",
|
||||
"list_roles",
|
||||
"list_management_levels",
|
||||
"list_utilization_categories",
|
||||
"check_resource_availability",
|
||||
"get_staffing_suggestions",
|
||||
"find_capacity",
|
||||
"find_best_project_resource",
|
||||
]);
|
||||
|
||||
const RESOURCE_OVERVIEW_TOOLS = new Set([
|
||||
"search_resources",
|
||||
"get_country",
|
||||
"list_org_units",
|
||||
]);
|
||||
|
||||
const CONTROLLER_ONLY_TOOLS = new Set([
|
||||
"search_by_skill",
|
||||
"search_projects",
|
||||
"get_project",
|
||||
"search_estimates",
|
||||
"get_timeline_entries_view",
|
||||
"get_timeline_holiday_overlays",
|
||||
"get_project_timeline_context",
|
||||
"preview_project_shift",
|
||||
"get_statistics",
|
||||
"get_dashboard_detail",
|
||||
"get_skill_gaps",
|
||||
"get_project_health",
|
||||
"get_budget_forecast",
|
||||
"query_change_history",
|
||||
"get_entity_timeline",
|
||||
"export_resources_csv",
|
||||
"export_projects_csv",
|
||||
"list_audit_log_entries",
|
||||
"get_audit_log_entry",
|
||||
"get_audit_log_timeline",
|
||||
"get_audit_activity_summary",
|
||||
"get_chargeability_report",
|
||||
"get_resource_computation_graph",
|
||||
"get_project_computation_graph",
|
||||
"get_estimate_detail",
|
||||
"list_estimate_versions",
|
||||
"get_estimate_version_snapshot",
|
||||
"get_estimate_weekly_phasing",
|
||||
"get_estimate_commercial_terms",
|
||||
]);
|
||||
|
||||
const MANAGER_ONLY_TOOLS = new Set([
|
||||
"import_csv_data",
|
||||
"list_assignable_users",
|
||||
"create_notification",
|
||||
"update_timeline_allocation_inline",
|
||||
"apply_timeline_project_shift",
|
||||
"quick_assign_timeline_resource",
|
||||
"batch_quick_assign_timeline_resources",
|
||||
"batch_shift_timeline_allocations",
|
||||
"create_estimate",
|
||||
"clone_estimate",
|
||||
"update_estimate_draft",
|
||||
"submit_estimate_version",
|
||||
"approve_estimate_version",
|
||||
"create_estimate_revision",
|
||||
"create_estimate_export",
|
||||
"create_estimate_planning_handoff",
|
||||
"generate_estimate_weekly_phasing",
|
||||
"update_estimate_commercial_terms",
|
||||
"create_task_for_user",
|
||||
"assign_task",
|
||||
"send_broadcast",
|
||||
"list_broadcasts",
|
||||
"get_broadcast_detail",
|
||||
"approve_vacation",
|
||||
"reject_vacation",
|
||||
"get_pending_vacation_approvals",
|
||||
"get_entitlement_summary",
|
||||
"set_entitlement",
|
||||
"create_role",
|
||||
"update_role",
|
||||
"delete_role",
|
||||
"create_client",
|
||||
"update_client",
|
||||
]);
|
||||
|
||||
const ADMIN_ONLY_TOOLS = new Set([
|
||||
"list_users",
|
||||
"get_active_user_count",
|
||||
"create_user",
|
||||
"set_user_password",
|
||||
"update_user_role",
|
||||
"update_user_name",
|
||||
"link_user_resource",
|
||||
"auto_link_users_by_email",
|
||||
"set_user_permissions",
|
||||
"reset_user_permissions",
|
||||
"get_effective_user_permissions",
|
||||
"disable_user_totp",
|
||||
"list_dispo_import_batches",
|
||||
"get_dispo_import_batch",
|
||||
"stage_dispo_import_batch",
|
||||
"validate_dispo_import_batch",
|
||||
"cancel_dispo_import_batch",
|
||||
"list_dispo_staged_resources",
|
||||
"list_dispo_staged_projects",
|
||||
"list_dispo_staged_assignments",
|
||||
"list_dispo_staged_vacations",
|
||||
"list_dispo_staged_unresolved_records",
|
||||
"resolve_dispo_staged_record",
|
||||
"commit_dispo_import_batch",
|
||||
"get_system_settings",
|
||||
"update_system_settings",
|
||||
"clear_stored_runtime_secrets",
|
||||
"get_ai_configured",
|
||||
"test_ai_connection",
|
||||
"test_smtp_connection",
|
||||
"test_gemini_connection",
|
||||
"list_system_role_configs",
|
||||
"update_system_role_config",
|
||||
"list_webhooks",
|
||||
"get_webhook",
|
||||
"create_webhook",
|
||||
"update_webhook",
|
||||
"delete_webhook",
|
||||
"test_webhook",
|
||||
"create_org_unit",
|
||||
"update_org_unit",
|
||||
"create_country",
|
||||
"update_country",
|
||||
"create_metro_city",
|
||||
"update_metro_city",
|
||||
"delete_metro_city",
|
||||
"list_holiday_calendars",
|
||||
"get_holiday_calendar",
|
||||
"create_holiday_calendar",
|
||||
"update_holiday_calendar",
|
||||
"delete_holiday_calendar",
|
||||
"create_holiday_calendar_entry",
|
||||
"update_holiday_calendar_entry",
|
||||
"delete_holiday_calendar_entry",
|
||||
]);
|
||||
|
||||
function hasLegacyToolAccess(
|
||||
toolName: string,
|
||||
permissions: Set<PermissionKey>,
|
||||
userRole: string,
|
||||
hasResourceOverviewAccess: boolean,
|
||||
hasControllerAccess: boolean,
|
||||
hasManagerAccess: boolean,
|
||||
) {
|
||||
const requiredPerm = TOOL_PERMISSION_MAP[toolName];
|
||||
|
||||
if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
|
||||
return false;
|
||||
}
|
||||
if (ADMIN_ONLY_TOOLS.has(toolName) && userRole !== SystemRole.ADMIN) {
|
||||
return false;
|
||||
}
|
||||
if (MANAGER_ONLY_TOOLS.has(toolName) && !hasManagerAccess) {
|
||||
return false;
|
||||
}
|
||||
if (RESOURCE_OVERVIEW_TOOLS.has(toolName) && !hasResourceOverviewAccess) {
|
||||
return false;
|
||||
}
|
||||
if (CONTROLLER_ONLY_TOOLS.has(toolName) && !hasControllerAccess) {
|
||||
return false;
|
||||
}
|
||||
if (PLANNING_READ_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_PLANNING)) {
|
||||
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 hasToolAccess(
|
||||
tool: ToolDef,
|
||||
permissions: Set<PermissionKey>,
|
||||
userRole: string,
|
||||
hasResourceOverviewAccess: boolean,
|
||||
): boolean {
|
||||
if (!tool.access) {
|
||||
const hasControllerAccess = userRole === SystemRole.ADMIN
|
||||
|| userRole === SystemRole.MANAGER
|
||||
|| userRole === SystemRole.CONTROLLER;
|
||||
const hasManagerAccess = userRole === SystemRole.ADMIN
|
||||
|| userRole === SystemRole.MANAGER;
|
||||
|
||||
return hasLegacyToolAccess(
|
||||
tool.function.name,
|
||||
permissions,
|
||||
userRole,
|
||||
hasResourceOverviewAccess,
|
||||
hasControllerAccess,
|
||||
hasManagerAccess,
|
||||
);
|
||||
}
|
||||
|
||||
if (tool.access.requiredPermissions?.some((permission) => !permissions.has(permission))) {
|
||||
return false;
|
||||
}
|
||||
if (tool.access.allowedSystemRoles && !tool.access.allowedSystemRoles.includes(userRole as SystemRole)) {
|
||||
return false;
|
||||
}
|
||||
if (tool.access.requiresResourceOverview && !hasResourceOverviewAccess) {
|
||||
return false;
|
||||
}
|
||||
if (tool.access.requiresPlanningRead && !permissions.has(PermissionKey.VIEW_PLANNING)) {
|
||||
return false;
|
||||
}
|
||||
if (tool.access.requiresCostView && !permissions.has(PermissionKey.VIEW_COSTS)) {
|
||||
return false;
|
||||
}
|
||||
if (tool.access.requiresAdvancedAssistant && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getAvailableAssistantTools(
|
||||
permissions: Set<PermissionKey>,
|
||||
userRole: string,
|
||||
): ToolDef[] {
|
||||
const hasResourceOverviewAccess = permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|
||||
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
|
||||
|
||||
return TOOL_DEFINITIONS.filter((tool) => (
|
||||
hasToolAccess(tool, permissions, userRole, hasResourceOverviewAccess)
|
||||
));
|
||||
return getAvailableAssistantToolsForContext(permissions, userRole);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,18 @@ const TOOL_SELECTION_HINTS = [
|
||||
{
|
||||
keywords: ["holiday", "holidays", "feiertag", "feiertage", "vacation", "vacations", "urlaub", "ferien", "abwesen"],
|
||||
nameFragments: ["holiday", "vacation", "entitlement"],
|
||||
exactTools: ["list_holidays_by_region", "get_resource_holidays", "get_my_timeline_holiday_overlays", "list_holiday_calendars", "get_holiday_calendar", "preview_resolved_holiday_calendar"],
|
||||
exactTools: [
|
||||
"list_holidays_by_region",
|
||||
"get_resource_holidays",
|
||||
"get_vacation_balance",
|
||||
"get_entitlement_summary",
|
||||
"list_vacations_upcoming",
|
||||
"get_team_vacation_overlap",
|
||||
"get_my_timeline_holiday_overlays",
|
||||
"list_holiday_calendars",
|
||||
"get_holiday_calendar",
|
||||
"preview_resolved_holiday_calendar",
|
||||
],
|
||||
},
|
||||
{
|
||||
keywords: ["resource", "resources", "ressource", "ressourcen", "employee", "mitarbeiter", "person", "people", "team", "chapter", "skill", "skills"],
|
||||
@@ -43,7 +54,16 @@ const TOOL_SELECTION_HINTS = [
|
||||
{
|
||||
keywords: ["dashboard", "widget", "widgets", "peak", "forecast", "insight", "insights", "anomaly", "anomalies", "report", "reports", "analyse", "analysis", "bericht"],
|
||||
nameFragments: ["dashboard", "statistics", "report", "insight", "anomal", "health", "forecast", "skill"],
|
||||
exactTools: ["get_statistics", "get_dashboard_detail", "detect_anomalies", "get_skill_gaps", "get_project_health", "get_budget_forecast", "get_insights_summary", "run_report"],
|
||||
exactTools: [
|
||||
"get_statistics",
|
||||
"get_dashboard_detail",
|
||||
"detect_anomalies",
|
||||
"get_skill_gaps",
|
||||
"get_project_health",
|
||||
"get_budget_forecast",
|
||||
"get_insights_summary",
|
||||
"run_report",
|
||||
],
|
||||
},
|
||||
{
|
||||
keywords: ["estimate", "estimates", "angebot", "angebote", "budget", "budgets", "cost", "costs", "kosten", "rate", "rates", "preis", "preise"],
|
||||
|
||||
@@ -1345,6 +1345,15 @@ function getTrpcValidationIssues(error: TRPCError): Array<{
|
||||
function toAssistantUserResourceLinkError(
|
||||
error: unknown,
|
||||
): AssistantToolErrorResult | null {
|
||||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||||
if (error.message.includes("already linked")) {
|
||||
return { error: "Resource is already linked to another user." };
|
||||
}
|
||||
if (error.message.includes("changed during update")) {
|
||||
return { error: "Resource link changed during update. Please retry." };
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
if (error.message.includes("Resource")) {
|
||||
return { error: "Resource not found with the given criteria." };
|
||||
@@ -1478,6 +1487,9 @@ function toAssistantTaskActionError(
|
||||
if (error.message.includes("already completed")) {
|
||||
return { error: "Task is already completed." };
|
||||
}
|
||||
if (error.message.includes("dismissed")) {
|
||||
return { error: "Task has been dismissed and cannot be executed." };
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
|
||||
@@ -1812,6 +1824,14 @@ function toAssistantNotificationCreationError(
|
||||
return { error: "No recipients matched the broadcast target." };
|
||||
}
|
||||
|
||||
if (
|
||||
context === "broadcast"
|
||||
&& trpcError?.code === "BAD_REQUEST"
|
||||
&& trpcError.message === "Scheduled broadcasts with task metadata are not supported yet."
|
||||
) {
|
||||
return { error: "Scheduled broadcasts with task metadata are not supported yet." };
|
||||
}
|
||||
|
||||
if (trpcError?.code === "NOT_FOUND") {
|
||||
if (trpcError.message.includes("broadcast")) {
|
||||
return { error: "Broadcast not found with the given criteria." };
|
||||
@@ -2048,6 +2068,128 @@ export const TOOL_DEFINITIONS: ToolDef[] = withToolAccess([
|
||||
...settingsAdminToolDefinitions,
|
||||
], LEGACY_MONOLITHIC_TOOL_ACCESS);
|
||||
|
||||
const TOOL_DEFINITIONS_BY_NAME = new Map(
|
||||
TOOL_DEFINITIONS.map((tool) => [tool.function.name, tool]),
|
||||
);
|
||||
|
||||
type AssistantToolAccessEvaluationContext = Pick<ToolContext, "permissions" | "userRole">;
|
||||
|
||||
type AssistantToolAccessFailure =
|
||||
| { type: "role" }
|
||||
| {
|
||||
type: "permission";
|
||||
permission?: PermissionKey;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function hasAssistantResourceOverviewAccess(
|
||||
permissions: Set<PermissionKey>,
|
||||
): boolean {
|
||||
return permissions.has(PermissionKey.VIEW_ALL_RESOURCES)
|
||||
|| permissions.has(PermissionKey.MANAGE_RESOURCES);
|
||||
}
|
||||
|
||||
function getAssistantToolAccessRequirements(
|
||||
tool: ToolDef,
|
||||
): ToolAccessRequirements | undefined {
|
||||
return tool.access ?? LEGACY_MONOLITHIC_TOOL_ACCESS[tool.function.name];
|
||||
}
|
||||
|
||||
function getAssistantToolAccessFailure(
|
||||
tool: ToolDef,
|
||||
ctx: AssistantToolAccessEvaluationContext,
|
||||
): AssistantToolAccessFailure | null {
|
||||
const access = getAssistantToolAccessRequirements(tool);
|
||||
if (!access) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
access.allowedSystemRoles
|
||||
&& !access.allowedSystemRoles.includes(ctx.userRole as SystemRole)
|
||||
) {
|
||||
return { type: "role" };
|
||||
}
|
||||
|
||||
const missingRequiredPermission = access.requiredPermissions?.find(
|
||||
(permission) => !ctx.permissions.has(permission),
|
||||
);
|
||||
if (missingRequiredPermission) {
|
||||
return {
|
||||
type: "permission",
|
||||
permission: missingRequiredPermission,
|
||||
};
|
||||
}
|
||||
|
||||
if (access.requiresPlanningRead && !ctx.permissions.has(PermissionKey.VIEW_PLANNING)) {
|
||||
return {
|
||||
type: "permission",
|
||||
permission: PermissionKey.VIEW_PLANNING,
|
||||
};
|
||||
}
|
||||
|
||||
if (access.requiresCostView && !ctx.permissions.has(PermissionKey.VIEW_COSTS)) {
|
||||
return {
|
||||
type: "permission",
|
||||
permission: PermissionKey.VIEW_COSTS,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
access.requiresAdvancedAssistant
|
||||
&& !ctx.permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)
|
||||
) {
|
||||
return {
|
||||
type: "permission",
|
||||
permission: PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
access.requiresResourceOverview
|
||||
&& !hasAssistantResourceOverviewAccess(ctx.permissions)
|
||||
) {
|
||||
return {
|
||||
type: "permission",
|
||||
message: "Permission denied: you need resource overview access to perform this action.",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toAssistantToolAccessError(
|
||||
failure: AssistantToolAccessFailure,
|
||||
): AssistantVisibleError {
|
||||
if (failure.type === "role") {
|
||||
return new AssistantVisibleError("You do not have permission to perform this action.");
|
||||
}
|
||||
|
||||
if (failure.permission) {
|
||||
return new AssistantVisibleError(
|
||||
`Permission denied: you need the "${failure.permission}" permission to perform this action.`,
|
||||
);
|
||||
}
|
||||
|
||||
return new AssistantVisibleError(
|
||||
failure.message ?? "You do not have permission to perform this action.",
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessAssistantTool(
|
||||
tool: ToolDef,
|
||||
ctx: AssistantToolAccessEvaluationContext,
|
||||
): boolean {
|
||||
return getAssistantToolAccessFailure(tool, ctx) === null;
|
||||
}
|
||||
|
||||
export function getAvailableAssistantToolsForContext(
|
||||
permissions: Set<PermissionKey>,
|
||||
userRole: string,
|
||||
): ToolDef[] {
|
||||
return TOOL_DEFINITIONS.filter((tool) => canAccessAssistantTool(tool, { permissions, userRole }));
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve a responsible person name against existing resources. Returns the exact displayName or an error object. */
|
||||
@@ -2285,6 +2427,7 @@ const executors = {
|
||||
...createUserSelfServiceExecutors({
|
||||
createUserCaller,
|
||||
createScopedCallerContext,
|
||||
toAssistantCurrentUserError: toAssistantUserMutationError,
|
||||
toAssistantTotpEnableError,
|
||||
}),
|
||||
...createNotificationsTasksExecutors({
|
||||
@@ -2352,6 +2495,14 @@ export async function executeTool(
|
||||
if (!executor) return { content: JSON.stringify({ error: `Unknown tool: ${name}` }) };
|
||||
|
||||
try {
|
||||
const toolDefinition = TOOL_DEFINITIONS_BY_NAME.get(name);
|
||||
const accessFailure = toolDefinition
|
||||
? getAssistantToolAccessFailure(toolDefinition, ctx)
|
||||
: null;
|
||||
if (accessFailure) {
|
||||
throw toAssistantToolAccessError(accessFailure);
|
||||
}
|
||||
|
||||
const params = JSON.parse(args);
|
||||
|
||||
// Audit-log all mutation tool executions (EGAI 4.1.3.1 / IAAI 3.6.26)
|
||||
|
||||
@@ -0,0 +1,770 @@
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import { AllocationStatus, PermissionKey, SystemRole } from "@capakraken/shared";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
export const advancedTimelineToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "find_best_project_resource",
|
||||
description: "Advanced assistant tool: find the best already-assigned resource on a project for a given period, ranked by remaining capacity or LCR. Holiday- and vacation-aware. Requires viewCosts and advanced assistant permissions.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
|
||||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
|
||||
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
|
||||
minHoursPerDay: { type: "number", description: "Minimum remaining availability per effective working day. Default: 3." },
|
||||
rankingMode: { type: "string", description: "Ranking mode: lowest_lcr, highest_remaining_hours_per_day, or highest_remaining_hours. Default: lowest_lcr." },
|
||||
chapter: { type: "string", description: "Optional chapter filter for candidate resources." },
|
||||
roleName: { type: "string", description: "Optional role filter for candidate resources." },
|
||||
},
|
||||
required: ["projectIdentifier"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_timeline_entries_view",
|
||||
description: "Advanced assistant tool: read-only timeline entries view with the same timeline/disposition readmodel used by the app. Returns allocations, demands, assignments, and matching holiday overlays for a period.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
|
||||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
|
||||
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
|
||||
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the view." },
|
||||
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the view." },
|
||||
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the view." },
|
||||
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
|
||||
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the view." },
|
||||
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_timeline_holiday_overlays",
|
||||
description: "Advanced assistant tool: read-only holiday overlays for the timeline, resolved with the same holiday logic as the app. Useful to explain regional holiday differences for assigned or filtered resources.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD. Default: today." },
|
||||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD. If omitted, durationDays is used." },
|
||||
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted. Default: 21." },
|
||||
resourceIds: { type: "array", items: { type: "string" }, description: "Optional resource IDs to scope the overlays." },
|
||||
projectIds: { type: "array", items: { type: "string" }, description: "Optional project IDs to scope the overlays via matching assignments." },
|
||||
clientIds: { type: "array", items: { type: "string" }, description: "Optional client IDs to scope the overlays via matching projects." },
|
||||
chapters: { type: "array", items: { type: "string" }, description: "Optional chapter filters." },
|
||||
eids: { type: "array", items: { type: "string" }, description: "Optional employee IDs to scope the overlays." },
|
||||
countryCodes: { type: "array", items: { type: "string" }, description: "Optional country codes such as DE or ES." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_project_timeline_context",
|
||||
description: "Advanced assistant tool: read-only project timeline/disposition context. Reuses the same project context readmodel as the app and adds holiday overlays plus cross-project overlap summaries for assigned resources.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||||
startDate: { type: "string", description: "Optional holiday/conflict window start date in YYYY-MM-DD. Defaults to the project start date when available." },
|
||||
endDate: { type: "string", description: "Optional holiday/conflict window end date in YYYY-MM-DD. Defaults to the project end date when available." },
|
||||
durationDays: { type: "integer", description: "Optional duration in calendar days when endDate is omitted." },
|
||||
},
|
||||
required: ["projectIdentifier"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "preview_project_shift",
|
||||
description: "Advanced assistant tool: read-only preview of the timeline shift validation for a project. Uses the same preview logic as the timeline router and does not write changes.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||||
newStartDate: { type: "string", description: "New start date in YYYY-MM-DD." },
|
||||
newEndDate: { type: "string", description: "New end date in YYYY-MM-DD." },
|
||||
},
|
||||
required: ["projectIdentifier", "newStartDate", "newEndDate"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_timeline_allocation_inline",
|
||||
description: "Advanced assistant mutation: update a timeline allocation inline with the same manager/admin + manageAllocations validation as the timeline API. Supports hours/day, dates, includeSaturday, and role changes. Requires useAssistantAdvancedTools.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allocationId: { type: "string", description: "Allocation, assignment, or demand row ID to update." },
|
||||
hoursPerDay: { type: "number", description: "Optional new booked hours per day." },
|
||||
startDate: { type: "string", description: "Optional new start date in YYYY-MM-DD." },
|
||||
endDate: { type: "string", description: "Optional new end date in YYYY-MM-DD." },
|
||||
includeSaturday: { type: "boolean", description: "Optional Saturday-working flag stored in metadata." },
|
||||
role: { type: "string", description: "Optional new role label." },
|
||||
},
|
||||
required: ["allocationId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "apply_timeline_project_shift",
|
||||
description: "Advanced assistant mutation: apply the real timeline project shift mutation, including validation, date movement, cost recalculation, audit logging, and SSE. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||||
newStartDate: { type: "string", description: "New project start date in YYYY-MM-DD." },
|
||||
newEndDate: { type: "string", description: "New project end date in YYYY-MM-DD." },
|
||||
},
|
||||
required: ["projectIdentifier", "newStartDate", "newEndDate"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "quick_assign_timeline_resource",
|
||||
description: "Advanced assistant mutation: create a timeline quick-assignment with the same manager/admin + manageAllocations rules as the timeline UI. Resolves resource and project identifiers before calling the real mutation. Requires useAssistantAdvancedTools.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
resourceIdentifier: { type: "string", description: "Resource ID, eid, or display name." },
|
||||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||||
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
|
||||
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
|
||||
hoursPerDay: { type: "number", description: "Hours per day. Default: 8." },
|
||||
role: { type: "string", description: "Role label. Default: Team Member." },
|
||||
roleId: { type: "string", description: "Optional concrete role ID." },
|
||||
status: { type: "string", enum: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"], description: "Assignment status. Default: PROPOSED." },
|
||||
},
|
||||
required: ["resourceIdentifier", "projectIdentifier", "startDate", "endDate"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "batch_quick_assign_timeline_resources",
|
||||
description: "Advanced assistant mutation: batch-create timeline quick-assignments using the same timeline router logic, permission checks, and audit/SSE side effects as the app. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
assignments: {
|
||||
type: "array",
|
||||
minItems: 1,
|
||||
maxItems: 50,
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
resourceIdentifier: { type: "string", description: "Resource ID, eid, or display name." },
|
||||
projectIdentifier: { type: "string", description: "Project ID, short code, or project name." },
|
||||
startDate: { type: "string", description: "Start date in YYYY-MM-DD." },
|
||||
endDate: { type: "string", description: "End date in YYYY-MM-DD." },
|
||||
hoursPerDay: { type: "number", description: "Hours per day. Default: 8." },
|
||||
role: { type: "string", description: "Role label. Default: Team Member." },
|
||||
status: { type: "string", enum: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"], description: "Assignment status. Default: PROPOSED." },
|
||||
},
|
||||
required: ["resourceIdentifier", "projectIdentifier", "startDate", "endDate"],
|
||||
},
|
||||
description: "Assignment rows to create in one batch.",
|
||||
},
|
||||
},
|
||||
required: ["assignments"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "batch_shift_timeline_allocations",
|
||||
description: "Advanced assistant mutation: shift multiple timeline allocations by a shared day delta using the real timeline batch move/resize mutation. Requires manager/admin, manageAllocations, and useAssistantAdvancedTools.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allocationIds: { type: "array", items: { type: "string" }, description: "Allocation IDs to shift." },
|
||||
daysDelta: { type: "integer", description: "Signed day delta to apply." },
|
||||
mode: { type: "string", enum: ["move", "resize-start", "resize-end"], description: "Shift mode. Default: move." },
|
||||
},
|
||||
required: ["allocationIds", "daysDelta"],
|
||||
},
|
||||
},
|
||||
},
|
||||
], {
|
||||
find_best_project_resource: {
|
||||
requiresPlanningRead: true,
|
||||
requiresCostView: true,
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
get_timeline_entries_view: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
get_timeline_holiday_overlays: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
get_project_timeline_context: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
preview_project_shift: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
update_timeline_allocation_inline: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
apply_timeline_project_shift: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
quick_assign_timeline_resource: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
batch_quick_assign_timeline_resources: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
batch_shift_timeline_allocations: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
});
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
type ResolvedProject = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
shortCode?: string | null;
|
||||
};
|
||||
|
||||
type ResolvedResource = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
type TimelineMutationContext = "updateInline" | "applyShift" | "quickAssign" | "batchShift";
|
||||
|
||||
type BatchQuickAssignmentInput = {
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay?: number;
|
||||
role?: string;
|
||||
status?: AllocationStatus;
|
||||
};
|
||||
|
||||
type AdvancedTimelineDeps = {
|
||||
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
|
||||
createStaffingCaller: (ctx: TRPCContext) => {
|
||||
getBestProjectResourceDetail: (params: {
|
||||
projectId: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
durationDays?: number;
|
||||
minHoursPerDay?: number;
|
||||
rankingMode?: "lowest_lcr" | "highest_remaining_hours_per_day" | "highest_remaining_hours";
|
||||
chapter?: string;
|
||||
roleName?: string;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
createTimelineCaller: (ctx: TRPCContext) => {
|
||||
getEntriesDetail: (params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
durationDays?: number;
|
||||
resourceIds?: string[];
|
||||
projectIds?: string[];
|
||||
clientIds?: string[];
|
||||
chapters?: string[];
|
||||
eids?: string[];
|
||||
countryCodes?: string[];
|
||||
}) => Promise<unknown>;
|
||||
getHolidayOverlayDetail: (params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
durationDays?: number;
|
||||
resourceIds?: string[];
|
||||
projectIds?: string[];
|
||||
clientIds?: string[];
|
||||
chapters?: string[];
|
||||
eids?: string[];
|
||||
countryCodes?: string[];
|
||||
}) => Promise<unknown>;
|
||||
getProjectContextDetail: (params: {
|
||||
projectId: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
durationDays?: number;
|
||||
}) => Promise<unknown>;
|
||||
getShiftPreviewDetail: (params: {
|
||||
projectId: string;
|
||||
newStartDate: Date;
|
||||
newEndDate: Date;
|
||||
}) => Promise<unknown>;
|
||||
updateAllocationInline: (params: {
|
||||
allocationId: string;
|
||||
hoursPerDay?: number;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
includeSaturday?: boolean;
|
||||
role?: string;
|
||||
}) => Promise<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId?: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
role?: string | null;
|
||||
status: string;
|
||||
}>;
|
||||
applyShift: (params: {
|
||||
projectId: string;
|
||||
newStartDate: Date;
|
||||
newEndDate: Date;
|
||||
}) => Promise<{
|
||||
project: {
|
||||
id: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
};
|
||||
validation: unknown;
|
||||
}>;
|
||||
quickAssign: (params: {
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay?: number;
|
||||
role?: string;
|
||||
roleId?: string;
|
||||
status?: AllocationStatus;
|
||||
}) => Promise<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
resourceId?: string | null;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
hoursPerDay: number;
|
||||
role?: string | null;
|
||||
status: string;
|
||||
}>;
|
||||
batchQuickAssign: (params: { assignments: BatchQuickAssignmentInput[] }) => Promise<{ count: number }>;
|
||||
batchShiftAllocations: (params: {
|
||||
allocationIds: string[];
|
||||
daysDelta: number;
|
||||
mode?: "move" | "resize-start" | "resize-end";
|
||||
}) => Promise<{ count: number }>;
|
||||
};
|
||||
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
||||
resolveProjectIdentifier: (
|
||||
ctx: ToolContext,
|
||||
identifier: string,
|
||||
) => Promise<ResolvedProject | AssistantToolErrorResult>;
|
||||
resolveResourceIdentifier: (
|
||||
ctx: ToolContext,
|
||||
identifier: string,
|
||||
) => Promise<ResolvedResource | AssistantToolErrorResult>;
|
||||
parseIsoDate: (value: string, fieldName: string) => Date;
|
||||
fmtDate: (value: Date | null | undefined) => string | null;
|
||||
isAssistantToolErrorResult: (value: unknown) => value is AssistantToolErrorResult;
|
||||
toAssistantIndexedFieldError: (index: number, field: string, message: string) => unknown;
|
||||
toAssistantTimelineMutationError: (error: unknown, context: TimelineMutationContext) => unknown;
|
||||
};
|
||||
|
||||
function toDate(value: Date | string): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
export function createAdvancedTimelineExecutors(
|
||||
deps: AdvancedTimelineDeps,
|
||||
): Record<string, ToolExecutor> {
|
||||
return {
|
||||
async find_best_project_resource(params: {
|
||||
projectIdentifier: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
durationDays?: number;
|
||||
minHoursPerDay?: number;
|
||||
rankingMode?: "lowest_lcr" | "highest_remaining_hours_per_day" | "highest_remaining_hours";
|
||||
chapter?: string;
|
||||
roleName?: string;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
deps.assertPermission(ctx, PermissionKey.VIEW_COSTS);
|
||||
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = deps.createStaffingCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getBestProjectResourceDetail({
|
||||
projectId: project.id,
|
||||
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
|
||||
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
|
||||
...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}),
|
||||
...(params.minHoursPerDay !== undefined ? { minHoursPerDay: params.minHoursPerDay } : {}),
|
||||
...(params.rankingMode ? { rankingMode: params.rankingMode } : {}),
|
||||
...(params.chapter ? { chapter: params.chapter } : {}),
|
||||
...(params.roleName ? { roleName: params.roleName } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
async get_timeline_entries_view(params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
durationDays?: number;
|
||||
resourceIds?: string[];
|
||||
projectIds?: string[];
|
||||
clientIds?: string[];
|
||||
chapters?: string[];
|
||||
eids?: string[];
|
||||
countryCodes?: string[];
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getEntriesDetail({ ...params });
|
||||
},
|
||||
|
||||
async get_timeline_holiday_overlays(params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
durationDays?: number;
|
||||
resourceIds?: string[];
|
||||
projectIds?: string[];
|
||||
clientIds?: string[];
|
||||
chapters?: string[];
|
||||
eids?: string[];
|
||||
countryCodes?: string[];
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getHolidayOverlayDetail({ ...params });
|
||||
},
|
||||
|
||||
async get_project_timeline_context(params: {
|
||||
projectIdentifier: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
durationDays?: number;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getProjectContextDetail({
|
||||
projectId: project.id,
|
||||
...(params.startDate ? { startDate: params.startDate } : {}),
|
||||
...(params.endDate ? { endDate: params.endDate } : {}),
|
||||
...(params.durationDays !== undefined ? { durationDays: params.durationDays } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
async preview_project_shift(params: {
|
||||
projectIdentifier: string;
|
||||
newStartDate: string;
|
||||
newEndDate: string;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getShiftPreviewDetail({
|
||||
projectId: project.id,
|
||||
newStartDate: deps.parseIsoDate(params.newStartDate, "newStartDate"),
|
||||
newEndDate: deps.parseIsoDate(params.newEndDate, "newEndDate"),
|
||||
});
|
||||
},
|
||||
|
||||
async update_timeline_allocation_inline(params: {
|
||||
allocationId: string;
|
||||
hoursPerDay?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
includeSaturday?: boolean;
|
||||
role?: string;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
let updated;
|
||||
try {
|
||||
updated = await caller.updateAllocationInline({
|
||||
allocationId: params.allocationId,
|
||||
...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}),
|
||||
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
|
||||
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
|
||||
...(params.includeSaturday !== undefined ? { includeSaturday: params.includeSaturday } : {}),
|
||||
...(params.role !== undefined ? { role: params.role } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantTimelineMutationError(error, "updateInline");
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["allocation", "timeline", "project"],
|
||||
success: true,
|
||||
message: `Updated timeline allocation ${updated.id}.`,
|
||||
allocation: {
|
||||
id: updated.id,
|
||||
projectId: updated.projectId,
|
||||
resourceId: updated.resourceId ?? null,
|
||||
startDate: deps.fmtDate(updated.startDate),
|
||||
endDate: deps.fmtDate(updated.endDate),
|
||||
hoursPerDay: updated.hoursPerDay,
|
||||
role: updated.role ?? null,
|
||||
status: updated.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async apply_timeline_project_shift(params: {
|
||||
projectIdentifier: string;
|
||||
newStartDate: string;
|
||||
newEndDate: string;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectIdentifier);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const newStartDate = deps.parseIsoDate(params.newStartDate, "newStartDate");
|
||||
const newEndDate = deps.parseIsoDate(params.newEndDate, "newEndDate");
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
let result;
|
||||
try {
|
||||
result = await caller.applyShift({
|
||||
projectId: project.id,
|
||||
newStartDate,
|
||||
newEndDate,
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantTimelineMutationError(error, "applyShift");
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["allocation", "timeline", "project"],
|
||||
success: true,
|
||||
message: `Shifted project ${project.shortCode ?? project.name ?? project.id} to ${deps.fmtDate(newStartDate)} - ${deps.fmtDate(newEndDate)}.`,
|
||||
project: {
|
||||
id: result.project.id,
|
||||
startDate: deps.fmtDate(result.project.startDate),
|
||||
endDate: deps.fmtDate(result.project.endDate),
|
||||
},
|
||||
validation: result.validation,
|
||||
};
|
||||
},
|
||||
|
||||
async quick_assign_timeline_resource(params: {
|
||||
resourceIdentifier: string;
|
||||
projectIdentifier: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
hoursPerDay?: number;
|
||||
role?: string;
|
||||
roleId?: string;
|
||||
status?: AllocationStatus;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const [resource, project] = await Promise.all([
|
||||
deps.resolveResourceIdentifier(ctx, params.resourceIdentifier),
|
||||
deps.resolveProjectIdentifier(ctx, params.projectIdentifier),
|
||||
]);
|
||||
if ("error" in resource) {
|
||||
return resource;
|
||||
}
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
let allocation;
|
||||
try {
|
||||
allocation = await caller.quickAssign({
|
||||
resourceId: resource.id,
|
||||
projectId: project.id,
|
||||
startDate: deps.parseIsoDate(params.startDate, "startDate"),
|
||||
endDate: deps.parseIsoDate(params.endDate, "endDate"),
|
||||
...(params.hoursPerDay !== undefined ? { hoursPerDay: params.hoursPerDay } : {}),
|
||||
...(params.role !== undefined ? { role: params.role } : {}),
|
||||
...(params.roleId !== undefined ? { roleId: params.roleId } : {}),
|
||||
...(params.status !== undefined ? { status: params.status } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantTimelineMutationError(error, "quickAssign");
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["allocation", "timeline", "project"],
|
||||
success: true,
|
||||
message: `Quick-assigned ${resource.displayName} to ${project.name} (${project.shortCode ?? project.id}).`,
|
||||
allocation: {
|
||||
id: allocation.id,
|
||||
projectId: allocation.projectId,
|
||||
resourceId: allocation.resourceId ?? null,
|
||||
startDate: deps.fmtDate(toDate(allocation.startDate)),
|
||||
endDate: deps.fmtDate(toDate(allocation.endDate)),
|
||||
hoursPerDay: allocation.hoursPerDay,
|
||||
role: allocation.role ?? null,
|
||||
status: allocation.status,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async batch_quick_assign_timeline_resources(params: {
|
||||
assignments: Array<{
|
||||
resourceIdentifier: string;
|
||||
projectIdentifier: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
hoursPerDay?: number;
|
||||
role?: string;
|
||||
status?: AllocationStatus;
|
||||
}>;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const resolvedAssignments = await Promise.all(params.assignments.map(async (assignment, index) => {
|
||||
const [resource, project] = await Promise.all([
|
||||
deps.resolveResourceIdentifier(ctx, assignment.resourceIdentifier),
|
||||
deps.resolveProjectIdentifier(ctx, assignment.projectIdentifier),
|
||||
]);
|
||||
if ("error" in resource) {
|
||||
return deps.toAssistantIndexedFieldError(index, "resourceIdentifier", resource.error);
|
||||
}
|
||||
if ("error" in project) {
|
||||
return deps.toAssistantIndexedFieldError(index, "projectIdentifier", project.error);
|
||||
}
|
||||
return {
|
||||
resourceId: resource.id,
|
||||
projectId: project.id,
|
||||
startDate: deps.parseIsoDate(assignment.startDate, `assignments[${index}].startDate`),
|
||||
endDate: deps.parseIsoDate(assignment.endDate, `assignments[${index}].endDate`),
|
||||
...(assignment.hoursPerDay !== undefined ? { hoursPerDay: assignment.hoursPerDay } : {}),
|
||||
...(assignment.role !== undefined ? { role: assignment.role } : {}),
|
||||
...(assignment.status !== undefined ? { status: assignment.status } : {}),
|
||||
};
|
||||
}));
|
||||
|
||||
const resolutionError = resolvedAssignments.find(deps.isAssistantToolErrorResult);
|
||||
if (resolutionError) {
|
||||
return resolutionError;
|
||||
}
|
||||
const validAssignments = resolvedAssignments.filter(
|
||||
(assignment): assignment is BatchQuickAssignmentInput => !deps.isAssistantToolErrorResult(assignment),
|
||||
);
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
let result;
|
||||
try {
|
||||
result = await caller.batchQuickAssign({
|
||||
assignments: validAssignments,
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantTimelineMutationError(error, "quickAssign");
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["allocation", "timeline", "project"],
|
||||
success: true,
|
||||
message: `Created ${result.count} timeline quick-assignment(s).`,
|
||||
count: result.count,
|
||||
};
|
||||
},
|
||||
|
||||
async batch_shift_timeline_allocations(params: {
|
||||
allocationIds: string[];
|
||||
daysDelta: number;
|
||||
mode?: "move" | "resize-start" | "resize-end";
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
deps.assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
let result;
|
||||
try {
|
||||
result = await caller.batchShiftAllocations({
|
||||
allocationIds: params.allocationIds,
|
||||
daysDelta: params.daysDelta,
|
||||
...(params.mode !== undefined ? { mode: params.mode } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantTimelineMutationError(error, "batchShift");
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["allocation", "timeline", "project"],
|
||||
success: true,
|
||||
message: `Shifted ${result.count} allocation(s) by ${params.daysDelta} day(s).`,
|
||||
count: result.count,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import { AllocationStatus, PermissionKey, UpdateAssignmentSchema } from "@capakraken/shared";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { fmtEur } from "../../lib/format-utils.js";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
type ResolvedProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortCode: string;
|
||||
};
|
||||
|
||||
type ResolvedResource = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
type AllocationPlanningDeps = {
|
||||
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
|
||||
createAllocationCaller: (ctx: TRPCContext) => {
|
||||
listView: (params: {
|
||||
resourceId?: string;
|
||||
projectId?: string;
|
||||
status?: AllocationStatus;
|
||||
}) => Promise<{
|
||||
assignments: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
hoursPerDay: number;
|
||||
dailyCostCents?: number | null;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
role?: string | null;
|
||||
roleEntity?: { name?: string | null } | null;
|
||||
resource?: { displayName?: string | null; eid?: string | null } | null;
|
||||
project?: { name?: string | null; shortCode?: string | null } | null;
|
||||
}>;
|
||||
}>;
|
||||
ensureAssignment: (params: {
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
role?: string;
|
||||
}) => Promise<{
|
||||
action: "created" | "reactivated";
|
||||
assignment: {
|
||||
id: string;
|
||||
status: string;
|
||||
};
|
||||
}>;
|
||||
resolveAssignment: (params: {
|
||||
assignmentId?: string;
|
||||
resourceId?: string;
|
||||
projectId?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
selectionMode: "WINDOW" | "EXACT_START";
|
||||
excludeCancelled?: boolean;
|
||||
}) => Promise<{
|
||||
id: string;
|
||||
status: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
resource: { displayName: string };
|
||||
project: { name: string; shortCode: string };
|
||||
}>;
|
||||
updateAssignment: (params: {
|
||||
id: string;
|
||||
data: z.input<typeof UpdateAssignmentSchema>;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
createTimelineCaller: (ctx: TRPCContext) => {
|
||||
getBudgetStatus: (params: { projectId: string }) => Promise<{
|
||||
projectName: string;
|
||||
projectCode: string;
|
||||
budgetCents: number;
|
||||
confirmedCents: number;
|
||||
proposedCents: number;
|
||||
allocatedCents: number;
|
||||
remainingCents: number;
|
||||
utilizationPercent: number;
|
||||
winProbabilityWeightedCents: number;
|
||||
totalAllocations: number;
|
||||
}>;
|
||||
};
|
||||
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
||||
resolveProjectIdentifier: (
|
||||
ctx: ToolContext,
|
||||
identifier: string,
|
||||
) => Promise<ResolvedProject | AssistantToolErrorResult>;
|
||||
resolveResourceIdentifier: (
|
||||
ctx: ToolContext,
|
||||
identifier: string,
|
||||
) => Promise<ResolvedResource | AssistantToolErrorResult>;
|
||||
parseIsoDate: (value: string, fieldName: string) => Date;
|
||||
parseOptionalIsoDate: (value: string | undefined, fieldName: string) => Date | undefined;
|
||||
fmtDate: (value: Date | null | undefined) => string | null;
|
||||
toAssistantAllocationNotFoundError: (error: unknown) => unknown;
|
||||
};
|
||||
|
||||
export const allocationPlanningReadToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_allocations",
|
||||
description: "List assignments/allocations, optionally filtered by resource or project. Shows who is assigned where, hours/day, dates, and cost.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
resourceId: { type: "string", description: "Filter by resource ID" },
|
||||
projectId: { type: "string", description: "Filter by project ID" },
|
||||
resourceName: { type: "string", description: "Filter by resource name (partial match)" },
|
||||
projectCode: { type: "string", description: "Filter by project short code (partial match)" },
|
||||
status: { type: "string", description: "Filter by status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" },
|
||||
limit: { type: "integer", description: "Max results. Default: 30" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_budget_status",
|
||||
description: "Get the budget status of a project: total budget, confirmed/proposed costs, remaining, utilization percentage.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID or short code" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
], {
|
||||
list_allocations: {
|
||||
requiresPlanningRead: true,
|
||||
},
|
||||
get_budget_status: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const allocationPlanningMutationToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_allocation",
|
||||
description: "Create a new allocation/booking for a resource on a project. Requires manageAllocations permission. Always confirm with the user before calling this. Created with PROPOSED status.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
resourceId: { type: "string", description: "Resource ID" },
|
||||
projectId: { type: "string", description: "Project ID" },
|
||||
startDate: { type: "string", description: "Start date YYYY-MM-DD" },
|
||||
endDate: { type: "string", description: "End date YYYY-MM-DD" },
|
||||
hoursPerDay: { type: "number", description: "Hours per day (e.g. 8)" },
|
||||
role: { type: "string", description: "Optional role name" },
|
||||
},
|
||||
required: ["resourceId", "projectId", "startDate", "endDate", "hoursPerDay"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "cancel_allocation",
|
||||
description: "Cancel an existing allocation. Can find by allocation ID, or by resource name + project code + date range. Requires manageAllocations permission. Always confirm with the user before calling this.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allocationId: { type: "string", description: "Allocation ID (if known)" },
|
||||
resourceName: { type: "string", description: "Resource name (partial match)" },
|
||||
projectCode: { type: "string", description: "Project short code (partial match)" },
|
||||
startDate: { type: "string", description: "Filter by start date YYYY-MM-DD" },
|
||||
endDate: { type: "string", description: "Filter by end date YYYY-MM-DD" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_allocation_status",
|
||||
description: "Change the status of an existing allocation. Can reactivate cancelled allocations, confirm proposed ones, etc. Requires manageAllocations permission. Always confirm with the user before calling.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allocationId: { type: "string", description: "Allocation ID" },
|
||||
resourceName: { type: "string", description: "Resource name (partial match, used if no allocationId)" },
|
||||
projectCode: { type: "string", description: "Project short code (partial match, used if no allocationId)" },
|
||||
startDate: { type: "string", description: "Start date filter YYYY-MM-DD (used if no allocationId)" },
|
||||
newStatus: { type: "string", description: "New status: PROPOSED, CONFIRMED, ACTIVE, COMPLETED, CANCELLED" },
|
||||
},
|
||||
required: ["newStatus"],
|
||||
},
|
||||
},
|
||||
},
|
||||
], {
|
||||
create_allocation: {
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
},
|
||||
cancel_allocation: {
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
},
|
||||
update_allocation_status: {
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
},
|
||||
});
|
||||
|
||||
export function createAllocationPlanningExecutors(
|
||||
deps: AllocationPlanningDeps,
|
||||
): Record<string, ToolExecutor> {
|
||||
return {
|
||||
async list_allocations(params: {
|
||||
resourceId?: string;
|
||||
projectId?: string;
|
||||
resourceName?: string;
|
||||
projectCode?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
|
||||
const status = params.status && Object.values(AllocationStatus).includes(params.status as AllocationStatus)
|
||||
? params.status as AllocationStatus
|
||||
: undefined;
|
||||
const readModel = await caller.listView({
|
||||
...(params.resourceId ? { resourceId: params.resourceId } : {}),
|
||||
...(params.projectId ? { projectId: params.projectId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
});
|
||||
|
||||
const resourceNameQuery = params.resourceName?.trim().toLowerCase();
|
||||
const projectCodeQuery = params.projectCode?.trim().toLowerCase();
|
||||
const limit = Math.min(params.limit ?? 30, 50);
|
||||
|
||||
return readModel.assignments
|
||||
.filter((assignment) => {
|
||||
if (
|
||||
resourceNameQuery
|
||||
&& !assignment.resource?.displayName?.toLowerCase().includes(resourceNameQuery)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
projectCodeQuery
|
||||
&& !assignment.project?.shortCode?.toLowerCase().includes(projectCodeQuery)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map((assignment) => ({
|
||||
id: assignment.id,
|
||||
resource: assignment.resource?.displayName ?? "Unknown",
|
||||
resourceEid: assignment.resource?.eid ?? null,
|
||||
project: assignment.project?.name ?? "Unknown",
|
||||
projectCode: assignment.project?.shortCode ?? null,
|
||||
role: assignment.role ?? assignment.roleEntity?.name ?? null,
|
||||
status: assignment.status,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
dailyCost: assignment.dailyCostCents == null ? null : fmtEur(assignment.dailyCostCents),
|
||||
start: deps.fmtDate(new Date(assignment.startDate)),
|
||||
end: deps.fmtDate(new Date(assignment.endDate)),
|
||||
}));
|
||||
},
|
||||
|
||||
async get_budget_status(params: { projectId: string }, ctx: ToolContext) {
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = deps.createTimelineCaller(deps.createScopedCallerContext(ctx));
|
||||
const budgetStatus = await caller.getBudgetStatus({ projectId: project.id });
|
||||
|
||||
if (budgetStatus.budgetCents <= 0) {
|
||||
return {
|
||||
project: budgetStatus.projectName,
|
||||
code: budgetStatus.projectCode,
|
||||
budget: "Not set",
|
||||
note: "No budget defined for this project",
|
||||
totalAllocations: budgetStatus.totalAllocations,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
project: budgetStatus.projectName,
|
||||
code: budgetStatus.projectCode,
|
||||
budget: fmtEur(budgetStatus.budgetCents),
|
||||
confirmed: fmtEur(budgetStatus.confirmedCents),
|
||||
proposed: fmtEur(budgetStatus.proposedCents),
|
||||
allocated: fmtEur(budgetStatus.allocatedCents),
|
||||
remaining: fmtEur(budgetStatus.remainingCents),
|
||||
utilization: `${budgetStatus.utilizationPercent.toFixed(1)}%`,
|
||||
winWeighted: fmtEur(budgetStatus.winProbabilityWeightedCents),
|
||||
};
|
||||
},
|
||||
|
||||
async create_allocation(params: {
|
||||
resourceId: string;
|
||||
projectId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
hoursPerDay: number;
|
||||
role?: string;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
const [resource, project] = await Promise.all([
|
||||
deps.resolveResourceIdentifier(ctx, params.resourceId),
|
||||
deps.resolveProjectIdentifier(ctx, params.projectId),
|
||||
]);
|
||||
if ("error" in resource) {
|
||||
return resource;
|
||||
}
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
|
||||
try {
|
||||
const result = await caller.ensureAssignment({
|
||||
resourceId: resource.id,
|
||||
projectId: project.id,
|
||||
startDate: deps.parseIsoDate(params.startDate, "startDate"),
|
||||
endDate: deps.parseIsoDate(params.endDate, "endDate"),
|
||||
hoursPerDay: params.hoursPerDay,
|
||||
...(params.role ? { role: params.role } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["allocation", "timeline"],
|
||||
success: true,
|
||||
message: `${result.action === "reactivated" ? "Reactivated" : "Created"} allocation: ${resource.displayName} → ${project.name} (${project.shortCode}), ${params.hoursPerDay}h/day, ${params.startDate} to ${params.endDate}`,
|
||||
allocationId: result.assignment.id,
|
||||
status: result.assignment.status,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError && error.code === "CONFLICT") {
|
||||
return { error: "Allocation already exists for this resource/project/dates. No new allocation created." };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async cancel_allocation(params: {
|
||||
allocationId?: string;
|
||||
resourceName?: string;
|
||||
projectCode?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
|
||||
let resourceId: string | undefined;
|
||||
let projectId: string | undefined;
|
||||
if (!params.allocationId && params.resourceName && params.projectCode) {
|
||||
const [resource, project] = await Promise.all([
|
||||
deps.resolveResourceIdentifier(ctx, params.resourceName),
|
||||
deps.resolveProjectIdentifier(ctx, params.projectCode),
|
||||
]);
|
||||
if ("error" in resource) {
|
||||
return resource;
|
||||
}
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
resourceId = resource.id;
|
||||
projectId = project.id;
|
||||
}
|
||||
|
||||
const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate");
|
||||
const endDate = deps.parseOptionalIsoDate(params.endDate, "endDate");
|
||||
let assignment;
|
||||
try {
|
||||
assignment = await caller.resolveAssignment({
|
||||
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
|
||||
...(resourceId ? { resourceId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(startDate ? { startDate } : {}),
|
||||
...(endDate ? { endDate } : {}),
|
||||
selectionMode: "WINDOW",
|
||||
excludeCancelled: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantAllocationNotFoundError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await caller.updateAssignment({
|
||||
id: assignment.id,
|
||||
data: UpdateAssignmentSchema.parse({ status: AllocationStatus.CANCELLED }),
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantAllocationNotFoundError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["allocation", "timeline"],
|
||||
success: true,
|
||||
message: `Cancelled allocation: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}`,
|
||||
};
|
||||
},
|
||||
|
||||
async update_allocation_status(params: {
|
||||
allocationId?: string;
|
||||
resourceName?: string;
|
||||
projectCode?: string;
|
||||
startDate?: string;
|
||||
newStatus: string;
|
||||
}, ctx: ToolContext) {
|
||||
deps.assertPermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||
|
||||
const validStatuses = ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED", "CANCELLED"];
|
||||
if (!validStatuses.includes(params.newStatus)) {
|
||||
return { error: `Invalid status: ${params.newStatus}. Valid: ${validStatuses.join(", ")}` };
|
||||
}
|
||||
|
||||
const caller = deps.createAllocationCaller(deps.createScopedCallerContext(ctx));
|
||||
let resourceId: string | undefined;
|
||||
let projectId: string | undefined;
|
||||
if (!params.allocationId && params.resourceName && params.projectCode) {
|
||||
const [resource, project] = await Promise.all([
|
||||
deps.resolveResourceIdentifier(ctx, params.resourceName),
|
||||
deps.resolveProjectIdentifier(ctx, params.projectCode),
|
||||
]);
|
||||
if ("error" in resource) {
|
||||
return resource;
|
||||
}
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
resourceId = resource.id;
|
||||
projectId = project.id;
|
||||
}
|
||||
|
||||
const startDate = deps.parseOptionalIsoDate(params.startDate, "startDate");
|
||||
let assignment;
|
||||
try {
|
||||
assignment = await caller.resolveAssignment({
|
||||
...(params.allocationId ? { assignmentId: params.allocationId } : {}),
|
||||
...(resourceId ? { resourceId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
...(startDate ? { startDate } : {}),
|
||||
selectionMode: "EXACT_START",
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantAllocationNotFoundError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const oldStatus = assignment.status;
|
||||
try {
|
||||
await caller.updateAssignment({
|
||||
id: assignment.id,
|
||||
data: UpdateAssignmentSchema.parse({
|
||||
status: params.newStatus as AllocationStatus,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantAllocationNotFoundError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["allocation", "timeline"],
|
||||
success: true,
|
||||
message: `Updated allocation status: ${assignment.resource.displayName} → ${assignment.project.name} (${assignment.project.shortCode}), ${deps.fmtDate(assignment.startDate)} to ${deps.fmtDate(assignment.endDate)}: ${oldStatus} → ${params.newStatus}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import { PermissionKey } from "@capakraken/shared";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
@@ -67,7 +67,7 @@ export type ChargeabilityComputationDeps = {
|
||||
) => Promise<ResolvedProject | AssistantToolErrorResult>;
|
||||
};
|
||||
|
||||
export const chargeabilityComputationReadToolDefinitions: ToolDef[] = [
|
||||
export const chargeabilityComputationReadToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -122,7 +122,23 @@ export const chargeabilityComputationReadToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
get_chargeability_report: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiredPermissions: [PermissionKey.VIEW_COSTS],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
get_resource_computation_graph: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiredPermissions: [PermissionKey.VIEW_COSTS],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
get_project_computation_graph: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiredPermissions: [PermissionKey.VIEW_COSTS],
|
||||
requiresAdvancedAssistant: true,
|
||||
},
|
||||
});
|
||||
|
||||
export function createChargeabilityComputationExecutors(
|
||||
deps: ChargeabilityComputationDeps,
|
||||
|
||||
@@ -2,11 +2,12 @@ import type { TRPCContext } from "../../trpc.js";
|
||||
import {
|
||||
CreateClientSchema,
|
||||
CreateOrgUnitSchema,
|
||||
SystemRole,
|
||||
UpdateClientSchema,
|
||||
UpdateOrgUnitSchema,
|
||||
} from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
@@ -49,7 +50,7 @@ type ClientsOrgUnitsDeps = {
|
||||
) => AssistantToolErrorResult | null;
|
||||
};
|
||||
|
||||
export const clientMutationToolDefinitions: ToolDef[] = [
|
||||
export const clientMutationToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -102,9 +103,19 @@ export const clientMutationToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
create_client: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
update_client: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
delete_client: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
});
|
||||
|
||||
export const orgUnitMutationToolDefinitions: ToolDef[] = [
|
||||
export const orgUnitMutationToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -142,7 +153,14 @@ export const orgUnitMutationToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
create_org_unit: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
update_org_unit: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
});
|
||||
|
||||
export function createClientsOrgUnitsExecutors(
|
||||
deps: ClientsOrgUnitsDeps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type ConfigReadmodelsDeps = {
|
||||
createManagementLevelCaller: (ctx: TRPCContext) => {
|
||||
@@ -77,7 +78,7 @@ type ConfigReadmodelsDeps = {
|
||||
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
||||
};
|
||||
|
||||
export const configReadmodelToolDefinitions: ToolDef[] = [
|
||||
export const configReadmodelToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -118,7 +119,23 @@ export const configReadmodelToolDefinitions: ToolDef[] = [
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
list_management_levels: {
|
||||
requiresPlanningRead: true,
|
||||
},
|
||||
list_utilization_categories: {
|
||||
requiresPlanningRead: true,
|
||||
},
|
||||
list_calculation_rules: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
list_effort_rules: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
list_experience_multipliers: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
});
|
||||
|
||||
export function createConfigReadmodelExecutors(
|
||||
deps: ConfigReadmodelsDeps,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Prisma } from "@capakraken/db";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import {
|
||||
CreateCountrySchema,
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
UpdateMetroCitySchema,
|
||||
} from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
@@ -52,7 +53,7 @@ type CountryMetroAdminDeps = {
|
||||
) => AssistantToolErrorResult | null;
|
||||
};
|
||||
|
||||
export const countryMetroAdminToolDefinitions: ToolDef[] = [
|
||||
export const countryMetroAdminToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -146,7 +147,23 @@ export const countryMetroAdminToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
create_country: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
update_country: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
create_metro_city: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
update_metro_city: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
delete_metro_city: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
});
|
||||
|
||||
export function createCountryMetroAdminExecutors(
|
||||
deps: CountryMetroAdminDeps,
|
||||
|
||||
@@ -2,11 +2,16 @@ import type {
|
||||
CreateEstimateInput,
|
||||
EstimateExportFormat,
|
||||
EstimateStatus,
|
||||
PermissionKey,
|
||||
UpdateEstimateDraftInput,
|
||||
} from "@capakraken/shared";
|
||||
import { PermissionKey, SystemRole } from "@capakraken/shared";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import {
|
||||
withToolAccess,
|
||||
type ToolContext,
|
||||
type ToolDef,
|
||||
type ToolExecutor,
|
||||
} from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
@@ -155,7 +160,7 @@ async function resolveEstimateProjectId(
|
||||
return project.id;
|
||||
}
|
||||
|
||||
export const estimateReadToolDefinitions: ToolDef[] = [
|
||||
export const estimateReadToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -228,9 +233,27 @@ export const estimateReadToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
get_estimate_detail: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
},
|
||||
list_estimate_versions: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
get_estimate_version_snapshot: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiresCostView: true,
|
||||
},
|
||||
get_estimate_weekly_phasing: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
get_estimate_commercial_terms: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
});
|
||||
|
||||
export const estimateMutationToolDefinitions: ToolDef[] = [
|
||||
export const estimateMutationToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -412,7 +435,48 @@ export const estimateMutationToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
create_estimate: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
clone_estimate: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
update_estimate_draft: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
submit_estimate_version: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
approve_estimate_version: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
create_estimate_revision: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
create_estimate_export: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
create_estimate_planning_handoff: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ALLOCATIONS],
|
||||
},
|
||||
generate_estimate_weekly_phasing: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
update_estimate_commercial_terms: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_PROJECTS],
|
||||
},
|
||||
});
|
||||
|
||||
export function createEstimateExecutors(
|
||||
deps: EstimateToolsDeps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
@@ -143,7 +144,7 @@ type NotificationsTasksDeps = {
|
||||
) => AssistantToolErrorResult | null;
|
||||
};
|
||||
|
||||
export const notificationInboxToolDefinitions: ToolDef[] = [
|
||||
export const notificationInboxToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -207,9 +208,13 @@ export const notificationInboxToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
create_notification: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
});
|
||||
|
||||
export const notificationTaskToolDefinitions: ToolDef[] = [
|
||||
export const notificationTaskToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -444,7 +449,23 @@ export const notificationTaskToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
create_task_for_user: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
assign_task: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
send_broadcast: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
list_broadcasts: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
get_broadcast_detail: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
});
|
||||
|
||||
export function createNotificationsTasksExecutors(
|
||||
deps: NotificationsTasksDeps,
|
||||
|
||||
@@ -25,9 +25,6 @@ type ProjectRecord = ProjectSummaryRecord & {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type ParsedCreateProjectInput = ReturnType<typeof CreateProjectSchema.parse>;
|
||||
type ParsedUpdateProjectInput = ReturnType<typeof UpdateProjectSchema.parse>;
|
||||
|
||||
type ResponsiblePersonResolution =
|
||||
| {
|
||||
status: "resolved";
|
||||
@@ -41,17 +38,10 @@ type ResponsiblePersonResolution =
|
||||
type ProjectToolsDeps = {
|
||||
assertPermission: (ctx: ToolContext, perm: PermissionKey) => void;
|
||||
createProjectCaller: (ctx: TRPCContext) => {
|
||||
searchSummariesDetail: (params: {
|
||||
search?: string | undefined;
|
||||
status?: ProjectStatus | undefined;
|
||||
limit: number;
|
||||
}) => Promise<unknown>;
|
||||
searchSummariesDetail: (params: any) => Promise<unknown>;
|
||||
getByIdentifierDetail: (params: { identifier: string }) => Promise<unknown>;
|
||||
update: (params: {
|
||||
id: string;
|
||||
data: ParsedUpdateProjectInput;
|
||||
}) => Promise<ProjectSummaryRecord>;
|
||||
create: (params: ParsedCreateProjectInput) => Promise<ProjectRecord>;
|
||||
update: (params: any) => Promise<ProjectSummaryRecord>;
|
||||
create: (params: any) => Promise<ProjectRecord>;
|
||||
delete: (params: { id: string }) => Promise<unknown>;
|
||||
generateCover: (params: {
|
||||
projectId: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import { CreateRoleSchema, UpdateRoleSchema } from "@capakraken/shared";
|
||||
import { CreateRoleSchema, PermissionKey, SystemRole, UpdateRoleSchema } from "@capakraken/shared";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
@@ -63,7 +63,7 @@ type RolesAnalyticsDeps = {
|
||||
) => AssistantToolErrorResult | null;
|
||||
};
|
||||
|
||||
export const rolesAnalyticsReadToolDefinitions: ToolDef[] = [
|
||||
export const rolesAnalyticsReadToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -109,9 +109,22 @@ export const rolesAnalyticsReadToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
list_roles: {
|
||||
requiresPlanningRead: true,
|
||||
},
|
||||
search_by_skill: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
get_statistics: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
get_chargeability: {
|
||||
requiresCostView: true,
|
||||
},
|
||||
});
|
||||
|
||||
export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = [
|
||||
export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -160,7 +173,20 @@ export const rolesAnalyticsMutationToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
create_role: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ROLES],
|
||||
},
|
||||
update_role: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ROLES],
|
||||
},
|
||||
delete_role: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
requiredPermissions: [PermissionKey.MANAGE_ROLES],
|
||||
},
|
||||
});
|
||||
|
||||
export function createRolesAnalyticsExecutors(
|
||||
deps: RolesAnalyticsDeps,
|
||||
|
||||
@@ -142,6 +142,7 @@ export const scenarioRateAnalysisToolDefinitions: ToolDef[] = withToolAccess([
|
||||
], {
|
||||
lookup_rate: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
requiredPermissions: [PermissionKey.VIEW_COSTS],
|
||||
},
|
||||
simulate_scenario: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
|
||||
@@ -0,0 +1,809 @@
|
||||
import { isAiConfigured } from "../../ai-client.js";
|
||||
import { resolveSystemSettingsRuntime } from "../../lib/system-settings-runtime.js";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
export const settingsAdminToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_system_settings",
|
||||
description: "Get sanitized system settings through the real settings router. Admin role required.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_system_settings",
|
||||
description: "Update non-secret system settings through the real settings router. Runtime secrets must be provisioned via deployment environment or secret manager. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
aiProvider: { type: "string", enum: ["openai", "azure"] },
|
||||
azureOpenAiEndpoint: { type: "string" },
|
||||
azureOpenAiDeployment: { type: "string" },
|
||||
azureApiVersion: { type: "string" },
|
||||
aiMaxCompletionTokens: { type: "integer" },
|
||||
aiTemperature: { type: "number" },
|
||||
aiSummaryPrompt: { type: "string" },
|
||||
scoreWeights: { type: "object" },
|
||||
scoreVisibleRoles: { type: "array", items: { type: "string" } },
|
||||
smtpHost: { type: "string" },
|
||||
smtpPort: { type: "integer" },
|
||||
smtpUser: { type: "string" },
|
||||
smtpFrom: { type: "string" },
|
||||
smtpTls: { type: "boolean" },
|
||||
anonymizationEnabled: { type: "boolean" },
|
||||
anonymizationDomain: { type: "string" },
|
||||
anonymizationMode: { type: "string", enum: ["global"] },
|
||||
azureDalleDeployment: { type: "string" },
|
||||
azureDalleEndpoint: { type: "string" },
|
||||
geminiModel: { type: "string" },
|
||||
imageProvider: { type: "string", enum: ["dalle", "gemini"] },
|
||||
vacationDefaultDays: { type: "integer" },
|
||||
timelineUndoMaxSteps: { type: "integer" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "clear_stored_runtime_secrets",
|
||||
description: "Clear legacy database-stored runtime secrets after they have been migrated to deployment secret management. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_ai_connection",
|
||||
description: "Run the real AI connection test from system settings. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_smtp_connection",
|
||||
description: "Run the real SMTP connection test from system settings. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_gemini_connection",
|
||||
description: "Run the real Gemini connection test from system settings. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_ai_configured",
|
||||
description: "Get whether AI is configured for the current system via the real settings router. Admin role required.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_system_role_configs",
|
||||
description: "List system role configuration defaults via the real system-role-config router. Admin role required.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_system_role_config",
|
||||
description: "Update one system role configuration via the real system-role-config router. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string", description: "System role key." },
|
||||
label: { type: "string", description: "Optional role label." },
|
||||
description: { type: "string", description: "Optional role description." },
|
||||
color: { type: "string", description: "Optional role color." },
|
||||
defaultPermissions: { type: "array", items: { type: "string" }, description: "Optional default permission set." },
|
||||
},
|
||||
required: ["role"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_webhooks",
|
||||
description: "List webhooks via the real webhook router. Secrets are masked in assistant responses. Admin role required.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_webhook",
|
||||
description: "Get one webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Webhook ID." },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_webhook",
|
||||
description: "Create a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Webhook name." },
|
||||
url: { type: "string", description: "Webhook target URL." },
|
||||
secret: { type: "string", description: "Optional webhook signing secret." },
|
||||
events: { type: "array", items: { type: "string" }, description: "Subscribed webhook events." },
|
||||
isActive: { type: "boolean", description: "Whether the webhook is active. Default: true." },
|
||||
},
|
||||
required: ["name", "url", "events"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_webhook",
|
||||
description: "Update a webhook via the real webhook router. Secrets are masked in assistant responses. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Webhook ID." },
|
||||
data: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
url: { type: "string" },
|
||||
secret: { type: "string" },
|
||||
events: { type: "array", items: { type: "string" } },
|
||||
isActive: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["id", "data"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "delete_webhook",
|
||||
description: "Delete a webhook via the real webhook router. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Webhook ID." },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "test_webhook",
|
||||
description: "Send a real test payload to a webhook via the real webhook router. Admin role required. Always confirm first.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Webhook ID." },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_audit_log_entries",
|
||||
description: "List audit log entries with full audit-router filters and cursor pagination. Controller/manager/admin roles only.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
entityType: { type: "string", description: "Optional entity type filter." },
|
||||
entityId: { type: "string", description: "Optional entity ID filter." },
|
||||
userId: { type: "string", description: "Optional user ID filter." },
|
||||
action: { type: "string", description: "Optional action filter such as CREATE, UPDATE, DELETE, SHIFT, IMPORT." },
|
||||
source: { type: "string", description: "Optional source filter such as ui or assistant." },
|
||||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
|
||||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
|
||||
search: { type: "string", description: "Optional case-insensitive search across entity name, summary, and entity type." },
|
||||
limit: { type: "integer", description: "Max results. Default: 50, max: 100." },
|
||||
cursor: { type: "string", description: "Optional pagination cursor (last seen audit entry ID)." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_audit_log_entry",
|
||||
description: "Get one audit log entry including the full changes payload. Controller/manager/admin roles only.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", description: "Audit log entry ID." },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_audit_log_timeline",
|
||||
description: "Get audit log entries grouped by day for a time window. Controller/manager/admin roles only.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
|
||||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
|
||||
limit: { type: "integer", description: "Max entries. Default: 200, max: 500." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_audit_activity_summary",
|
||||
description: "Get audit activity totals by entity type, action, and user for a date range. Controller/manager/admin roles only.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
startDate: { type: "string", description: "Optional start date in YYYY-MM-DD." },
|
||||
endDate: { type: "string", description: "Optional end date in YYYY-MM-DD." },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_shoring_ratio",
|
||||
description: "Get the onshore/offshore staffing ratio for a project. Higher offshore is better (cost-efficient). The threshold is the MINIMUM offshore target. Shows country breakdown and whether the target is met.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
projectId: { type: "string", description: "Project ID or short code" },
|
||||
},
|
||||
required: ["projectId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
], {
|
||||
get_system_settings: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
update_system_settings: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
clear_stored_runtime_secrets: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
test_ai_connection: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
test_smtp_connection: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
test_gemini_connection: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
get_ai_configured: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
list_system_role_configs: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
update_system_role_config: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
list_webhooks: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
get_webhook: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
create_webhook: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
update_webhook: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
delete_webhook: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
test_webhook: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
list_audit_log_entries: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
get_audit_log_entry: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
get_audit_log_timeline: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
get_audit_activity_summary: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
get_shoring_ratio: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER],
|
||||
},
|
||||
});
|
||||
|
||||
type SettingsAdminDeps = {
|
||||
createSettingsCaller: (ctx: TRPCContext) => {
|
||||
getSystemSettings: () => Promise<unknown>;
|
||||
updateSystemSettings: (params: {
|
||||
aiProvider?: "openai" | "azure";
|
||||
azureOpenAiEndpoint?: string;
|
||||
azureOpenAiDeployment?: string;
|
||||
azureApiVersion?: string;
|
||||
aiMaxCompletionTokens?: number;
|
||||
aiTemperature?: number;
|
||||
aiSummaryPrompt?: string;
|
||||
scoreWeights?: {
|
||||
skillDepth: number;
|
||||
skillBreadth: number;
|
||||
costEfficiency: number;
|
||||
chargeability: number;
|
||||
experience: number;
|
||||
};
|
||||
scoreVisibleRoles?: SystemRole[];
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpUser?: string;
|
||||
smtpFrom?: string;
|
||||
smtpTls?: boolean;
|
||||
anonymizationEnabled?: boolean;
|
||||
anonymizationDomain?: string;
|
||||
anonymizationMode?: "global";
|
||||
azureDalleDeployment?: string;
|
||||
azureDalleEndpoint?: string;
|
||||
geminiModel?: string;
|
||||
imageProvider?: "dalle" | "gemini";
|
||||
vacationDefaultDays?: number;
|
||||
timelineUndoMaxSteps?: number;
|
||||
}) => Promise<unknown>;
|
||||
clearStoredRuntimeSecrets: () => Promise<unknown>;
|
||||
testAiConnection: () => Promise<unknown>;
|
||||
testSmtpConnection: () => Promise<unknown>;
|
||||
testGeminiConnection: () => Promise<unknown>;
|
||||
};
|
||||
createSystemRoleConfigCaller: (ctx: TRPCContext) => {
|
||||
list: () => Promise<unknown>;
|
||||
update: (params: {
|
||||
role: string;
|
||||
label?: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
defaultPermissions?: string[];
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
createWebhookCaller: (ctx: TRPCContext) => {
|
||||
list: () => Promise<Array<{ secret?: string | null }>>;
|
||||
getById: (params: { id: string }) => Promise<{ secret?: string | null }>;
|
||||
create: (params: {
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: [string, ...string[]];
|
||||
isActive?: boolean;
|
||||
}) => Promise<{ secret?: string | null }>;
|
||||
update: (params: {
|
||||
id: string;
|
||||
data: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string | null;
|
||||
events?: [string, ...string[]];
|
||||
isActive?: boolean;
|
||||
};
|
||||
}) => Promise<{ secret?: string | null }>;
|
||||
delete: (params: { id: string }) => Promise<unknown>;
|
||||
test: (params: { id: string }) => Promise<unknown>;
|
||||
};
|
||||
createAuditLogCaller: (ctx: TRPCContext) => {
|
||||
listDetail: (params: {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
userId?: string;
|
||||
action?: string;
|
||||
source?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}) => Promise<{ items: unknown[]; nextCursor?: string | null }>;
|
||||
getByIdDetail: (params: { id: string }) => Promise<unknown>;
|
||||
getTimelineDetail: (params: {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
limit?: number;
|
||||
}) => Promise<unknown>;
|
||||
getActivitySummary: (params: {
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}) => Promise<unknown>;
|
||||
};
|
||||
createProjectCaller: (ctx: TRPCContext) => {
|
||||
getShoringRatio: (params: { projectId: string }) => Promise<{
|
||||
totalHours: number;
|
||||
byCountry: Record<string, { pct: number; resourceCount: number }>;
|
||||
offshoreRatio: number;
|
||||
threshold: number;
|
||||
onshoreRatio: number;
|
||||
onshoreCountryCode: string;
|
||||
unknownCount: number;
|
||||
}>;
|
||||
};
|
||||
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
||||
parseIsoDate: (value: string, fieldName: string) => Date;
|
||||
resolveProjectIdentifier: (
|
||||
ctx: ToolContext,
|
||||
identifier: string,
|
||||
) => Promise<{ id: string; name: string; shortCode: string } | { error: string }>;
|
||||
sanitizeWebhook: <T extends { secret?: string | null }>(webhook: T) => Omit<T, "secret"> & { hasSecret: boolean };
|
||||
sanitizeWebhookList: <T extends { secret?: string | null }>(webhooks: T[]) => Array<Omit<T, "secret"> & { hasSecret: boolean }>;
|
||||
toAssistantWebhookNotFoundError: (error: unknown) => unknown;
|
||||
toAssistantWebhookMutationError: (error: unknown, action?: "create" | "update") => unknown;
|
||||
toAssistantAuditLogEntryNotFoundError: (error: unknown) => unknown;
|
||||
};
|
||||
|
||||
export function createSettingsAdminExecutors(deps: SettingsAdminDeps): Record<string, ToolExecutor> {
|
||||
return {
|
||||
async get_system_settings(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getSystemSettings();
|
||||
},
|
||||
|
||||
async update_system_settings(params: {
|
||||
aiProvider?: "openai" | "azure";
|
||||
azureOpenAiEndpoint?: string;
|
||||
azureOpenAiDeployment?: string;
|
||||
azureApiVersion?: string;
|
||||
aiMaxCompletionTokens?: number;
|
||||
aiTemperature?: number;
|
||||
aiSummaryPrompt?: string;
|
||||
scoreWeights?: {
|
||||
skillDepth: number;
|
||||
skillBreadth: number;
|
||||
costEfficiency: number;
|
||||
chargeability: number;
|
||||
experience: number;
|
||||
};
|
||||
scoreVisibleRoles?: SystemRole[];
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpUser?: string;
|
||||
smtpFrom?: string;
|
||||
smtpTls?: boolean;
|
||||
anonymizationEnabled?: boolean;
|
||||
anonymizationDomain?: string;
|
||||
anonymizationMode?: "global";
|
||||
azureDalleDeployment?: string;
|
||||
azureDalleEndpoint?: string;
|
||||
geminiModel?: string;
|
||||
imageProvider?: "dalle" | "gemini";
|
||||
vacationDefaultDays?: number;
|
||||
timelineUndoMaxSteps?: number;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.updateSystemSettings(params);
|
||||
},
|
||||
|
||||
async clear_stored_runtime_secrets(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.clearStoredRuntimeSecrets();
|
||||
},
|
||||
|
||||
async test_ai_connection(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.testAiConnection();
|
||||
},
|
||||
|
||||
async test_smtp_connection(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.testSmtpConnection();
|
||||
},
|
||||
|
||||
async test_gemini_connection(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createSettingsCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.testGeminiConnection();
|
||||
},
|
||||
|
||||
async get_ai_configured(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const settings = resolveSystemSettingsRuntime(await ctx.db.systemSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
azureOpenAiEndpoint: true,
|
||||
azureOpenAiDeployment: true,
|
||||
azureOpenAiApiKey: true,
|
||||
},
|
||||
}));
|
||||
return { configured: isAiConfigured(settings) };
|
||||
},
|
||||
|
||||
async list_system_role_configs(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createSystemRoleConfigCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.list();
|
||||
},
|
||||
|
||||
async update_system_role_config(params: {
|
||||
role: string;
|
||||
label?: string;
|
||||
description?: string | null;
|
||||
color?: string | null;
|
||||
defaultPermissions?: string[];
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createSystemRoleConfigCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.update(params);
|
||||
},
|
||||
|
||||
async list_webhooks(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
|
||||
const webhooks = await caller.list();
|
||||
return deps.sanitizeWebhookList(webhooks);
|
||||
},
|
||||
|
||||
async get_webhook(params: {
|
||||
id: string;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await caller.getById({ id: params.id });
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantWebhookNotFoundError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return deps.sanitizeWebhook(webhook);
|
||||
},
|
||||
|
||||
async create_webhook(params: {
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: string[];
|
||||
isActive?: boolean;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await caller.create({
|
||||
name: params.name,
|
||||
url: params.url,
|
||||
events: params.events as [string, ...string[]],
|
||||
...(params.secret !== undefined ? { secret: params.secret } : {}),
|
||||
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantWebhookMutationError(error, "create");
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return deps.sanitizeWebhook(webhook);
|
||||
},
|
||||
|
||||
async update_webhook(params: {
|
||||
id: string;
|
||||
data: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
secret?: string | null;
|
||||
events?: string[];
|
||||
isActive?: boolean;
|
||||
};
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
|
||||
let webhook;
|
||||
try {
|
||||
webhook = await caller.update({
|
||||
id: params.id,
|
||||
data: {
|
||||
...(params.data.name !== undefined ? { name: params.data.name } : {}),
|
||||
...(params.data.url !== undefined ? { url: params.data.url } : {}),
|
||||
...(params.data.secret !== undefined ? { secret: params.data.secret } : {}),
|
||||
...(params.data.events !== undefined ? { events: params.data.events as [string, ...string[]] } : {}),
|
||||
...(params.data.isActive !== undefined ? { isActive: params.data.isActive } : {}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantWebhookMutationError(error, "update");
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return deps.sanitizeWebhook(webhook);
|
||||
},
|
||||
|
||||
async delete_webhook(params: {
|
||||
id: string;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
|
||||
try {
|
||||
await caller.delete({ id: params.id });
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantWebhookNotFoundError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { ok: true, id: params.id };
|
||||
},
|
||||
|
||||
async test_webhook(params: {
|
||||
id: string;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createWebhookCaller(deps.createScopedCallerContext(ctx));
|
||||
try {
|
||||
return await caller.test({ id: params.id });
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantWebhookNotFoundError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async list_audit_log_entries(params: {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
userId?: string;
|
||||
action?: string;
|
||||
source?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
|
||||
const result = await caller.listDetail({
|
||||
...(params.entityType ? { entityType: params.entityType } : {}),
|
||||
...(params.entityId ? { entityId: params.entityId } : {}),
|
||||
...(params.userId ? { userId: params.userId } : {}),
|
||||
...(params.action ? { action: params.action } : {}),
|
||||
...(params.source ? { source: params.source } : {}),
|
||||
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
|
||||
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
|
||||
...(params.search ? { search: params.search } : {}),
|
||||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 100) } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
filters: {
|
||||
entityType: params.entityType ?? null,
|
||||
entityId: params.entityId ?? null,
|
||||
userId: params.userId ?? null,
|
||||
action: params.action ?? null,
|
||||
source: params.source ?? null,
|
||||
startDate: params.startDate ?? null,
|
||||
endDate: params.endDate ?? null,
|
||||
search: params.search ?? null,
|
||||
},
|
||||
itemCount: result.items.length,
|
||||
nextCursor: result.nextCursor ?? null,
|
||||
items: result.items,
|
||||
};
|
||||
},
|
||||
|
||||
async get_audit_log_entry(params: {
|
||||
id: string;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
|
||||
try {
|
||||
return await caller.getByIdDetail({ id: params.id });
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantAuditLogEntryNotFoundError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async get_audit_log_timeline(params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getTimelineDetail({
|
||||
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
|
||||
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
|
||||
...(params.limit !== undefined ? { limit: Math.min(Math.max(params.limit, 1), 500) } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
async get_audit_activity_summary(params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createAuditLogCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getActivitySummary({
|
||||
...(params.startDate ? { startDate: deps.parseIsoDate(params.startDate, "startDate") } : {}),
|
||||
...(params.endDate ? { endDate: deps.parseIsoDate(params.endDate, "endDate") } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
async get_shoring_ratio(params: { projectId: string }, ctx: ToolContext) {
|
||||
const project = await deps.resolveProjectIdentifier(ctx, params.projectId);
|
||||
if ("error" in project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
const caller = deps.createProjectCaller(deps.createScopedCallerContext(ctx));
|
||||
const result = await caller.getShoringRatio({ projectId: project.id });
|
||||
|
||||
if (result.totalHours <= 0) {
|
||||
return `Project "${project.name}" (${project.shortCode}): No active assignments — shoring ratio not available.`;
|
||||
}
|
||||
|
||||
const countryParts = Object.entries(result.byCountry)
|
||||
.sort((a, b) => b[1].pct - a[1].pct)
|
||||
.map(([code, info]) => `${code} ${info.pct}% (${info.resourceCount} people)`)
|
||||
.join(", ");
|
||||
|
||||
const status = result.offshoreRatio >= result.threshold
|
||||
? `Target met (>=${result.threshold}% offshore)`
|
||||
: result.offshoreRatio >= result.threshold - 10
|
||||
? `Close to target (${result.threshold}% offshore needed)`
|
||||
: `Below target — only ${result.offshoreRatio}% offshore, need ${result.threshold}%`;
|
||||
|
||||
return `Project "${project.name}" (${project.shortCode}): ${result.onshoreRatio}% onshore (${result.onshoreCountryCode}), ${result.offshoreRatio}% offshore. ${status}. Breakdown: ${countryParts}.${result.unknownCount > 0 ? ` (${result.unknownCount} resource(s) without country)` : ""}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { prisma } from "@capakraken/db";
|
||||
import type { PermissionKey, SystemRole } from "@capakraken/shared";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
|
||||
export type ToolContext = {
|
||||
db: typeof prisma;
|
||||
userId: string;
|
||||
userRole: string;
|
||||
permissions: Set<PermissionKey>;
|
||||
session?: TRPCContext["session"];
|
||||
dbUser?: TRPCContext["dbUser"];
|
||||
roleDefaults?: TRPCContext["roleDefaults"];
|
||||
};
|
||||
|
||||
export interface ToolAccessRequirements {
|
||||
requiredPermissions?: PermissionKey[];
|
||||
allowedSystemRoles?: SystemRole[];
|
||||
requiresPlanningRead?: boolean;
|
||||
requiresCostView?: boolean;
|
||||
requiresAdvancedAssistant?: boolean;
|
||||
requiresResourceOverview?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolDef {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
access?: ToolAccessRequirements;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ToolExecutor = (params: any, ctx: ToolContext) => Promise<unknown>;
|
||||
|
||||
export function withToolAccess(
|
||||
tools: ToolDef[],
|
||||
accessByName: Partial<Record<string, ToolAccessRequirements>>,
|
||||
): ToolDef[] {
|
||||
return tools.map((tool) => ({
|
||||
...tool,
|
||||
...(accessByName[tool.function.name]
|
||||
? { access: accessByName[tool.function.name] }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
@@ -48,7 +48,7 @@ type UserAdminDeps = {
|
||||
) => AssistantToolErrorResult | null;
|
||||
};
|
||||
|
||||
export const userAdminToolDefinitions: ToolDef[] = [
|
||||
export const userAdminToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -212,7 +212,41 @@ export const userAdminToolDefinitions: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
list_users: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
create_user: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
set_user_password: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
update_user_role: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
update_user_name: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
link_user_resource: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
auto_link_users_by_email: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
set_user_permissions: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
reset_user_permissions: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
get_effective_user_permissions: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
disable_user_totp: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
});
|
||||
|
||||
export function createUserAdminExecutors(
|
||||
deps: UserAdminDeps,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import type { TRPCContext } from "../../trpc.js";
|
||||
import type { ToolContext, ToolDef, ToolExecutor } from "./shared.js";
|
||||
import { withToolAccess, type ToolContext, type ToolDef, type ToolExecutor } from "./shared.js";
|
||||
|
||||
type AssistantToolErrorResult = { error: string };
|
||||
|
||||
@@ -35,12 +36,15 @@ type UserSelfServiceDeps = {
|
||||
activeCount: () => Promise<unknown>;
|
||||
};
|
||||
createScopedCallerContext: (ctx: ToolContext) => TRPCContext;
|
||||
toAssistantCurrentUserError: (
|
||||
error: unknown,
|
||||
) => AssistantToolErrorResult | null;
|
||||
toAssistantTotpEnableError: (
|
||||
error: unknown,
|
||||
) => AssistantToolErrorResult | null;
|
||||
};
|
||||
|
||||
export const userSelfServiceToolDefinitions: ToolDef[] = [
|
||||
export const userSelfServiceToolDefinitions: ToolDef[] = withToolAccess([
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -187,11 +191,30 @@ export const userSelfServiceToolDefinitions: ToolDef[] = [
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
], {
|
||||
list_assignable_users: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN, SystemRole.MANAGER],
|
||||
},
|
||||
get_active_user_count: {
|
||||
allowedSystemRoles: [SystemRole.ADMIN],
|
||||
},
|
||||
});
|
||||
|
||||
export function createUserSelfServiceExecutors(
|
||||
deps: UserSelfServiceDeps,
|
||||
): Record<string, ToolExecutor> {
|
||||
async function withCurrentUserErrorMapping<T>(run: () => Promise<T>) {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
const mapped = deps.toAssistantCurrentUserError(error);
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async list_assignable_users(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
@@ -200,17 +223,22 @@ export function createUserSelfServiceExecutors(
|
||||
|
||||
async get_current_user(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.me();
|
||||
return withCurrentUserErrorMapping(() => caller.me());
|
||||
},
|
||||
|
||||
async get_dashboard_layout(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getDashboardLayout();
|
||||
return withCurrentUserErrorMapping(() => caller.getDashboardLayout());
|
||||
},
|
||||
|
||||
async save_dashboard_layout(params: { layout: unknown[] }, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
const result = await caller.saveDashboardLayout({ layout: params.layout });
|
||||
const result = await withCurrentUserErrorMapping(
|
||||
() => caller.saveDashboardLayout({ layout: params.layout }),
|
||||
);
|
||||
if ("error" in result) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
__action: "invalidate" as const,
|
||||
scope: ["dashboard"],
|
||||
@@ -222,12 +250,17 @@ export function createUserSelfServiceExecutors(
|
||||
|
||||
async get_favorite_project_ids(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getFavoriteProjectIds();
|
||||
return withCurrentUserErrorMapping(() => caller.getFavoriteProjectIds());
|
||||
},
|
||||
|
||||
async toggle_favorite_project(params: { projectId: string }, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
const result = await caller.toggleFavoriteProject({ projectId: params.projectId });
|
||||
const result = await withCurrentUserErrorMapping(
|
||||
() => caller.toggleFavoriteProject({ projectId: params.projectId }),
|
||||
);
|
||||
if ("error" in result) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
__action: "invalidate" as const,
|
||||
scope: ["project"],
|
||||
@@ -239,7 +272,7 @@ export function createUserSelfServiceExecutors(
|
||||
|
||||
async get_column_preferences(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getColumnPreferences();
|
||||
return withCurrentUserErrorMapping(() => caller.getColumnPreferences());
|
||||
},
|
||||
|
||||
async set_column_preferences(params: {
|
||||
@@ -249,12 +282,15 @@ export function createUserSelfServiceExecutors(
|
||||
rowOrder?: string[] | null;
|
||||
}, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
const result = await caller.setColumnPreferences({
|
||||
const result = await withCurrentUserErrorMapping(() => caller.setColumnPreferences({
|
||||
view: params.view,
|
||||
...(params.visible !== undefined ? { visible: params.visible } : {}),
|
||||
...(params.sort !== undefined ? { sort: params.sort } : {}),
|
||||
...(params.rowOrder !== undefined ? { rowOrder: params.rowOrder } : {}),
|
||||
});
|
||||
}));
|
||||
if ("error" in result) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
__action: "invalidate" as const,
|
||||
scope: ["user"],
|
||||
@@ -266,7 +302,10 @@ export function createUserSelfServiceExecutors(
|
||||
|
||||
async generate_totp_secret(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
const result = await caller.generateTotpSecret();
|
||||
const result = await withCurrentUserErrorMapping(() => caller.generateTotpSecret());
|
||||
if ("error" in result) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
__action: "invalidate" as const,
|
||||
scope: ["user"],
|
||||
@@ -299,7 +338,7 @@ export function createUserSelfServiceExecutors(
|
||||
|
||||
async get_mfa_status(_params: Record<string, never>, ctx: ToolContext) {
|
||||
const caller = deps.createUserCaller(deps.createScopedCallerContext(ctx));
|
||||
return caller.getMfaStatus();
|
||||
return withCurrentUserErrorMapping(() => caller.getMfaStatus());
|
||||
},
|
||||
|
||||
async get_active_user_count(_params: Record<string, never>, ctx: ToolContext) {
|
||||
|
||||
@@ -47,7 +47,10 @@ function requireImmediateBroadcastTransaction(
|
||||
function buildBroadcastCreateData(
|
||||
senderId: string,
|
||||
input: z.infer<typeof CreateBroadcastInputSchema>,
|
||||
recipientCount?: number,
|
||||
options: {
|
||||
includeScheduledAt?: boolean;
|
||||
recipientCount?: number;
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
senderId,
|
||||
@@ -59,8 +62,10 @@ function buildBroadcastCreateData(
|
||||
channel: input.channel,
|
||||
targetType: input.targetType,
|
||||
...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}),
|
||||
...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}),
|
||||
...(recipientCount !== undefined ? { recipientCount } : {}),
|
||||
...(options.includeScheduledAt && input.scheduledAt !== undefined
|
||||
? { scheduledAt: input.scheduledAt }
|
||||
: {}),
|
||||
...(options.recipientCount !== undefined ? { recipientCount: options.recipientCount } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +98,10 @@ async function createScheduledBroadcastRecord(
|
||||
recipientIds: string[],
|
||||
) {
|
||||
return db.notificationBroadcast.create({
|
||||
data: buildBroadcastCreateData(senderId, input, recipientIds.length),
|
||||
data: buildBroadcastCreateData(senderId, input, {
|
||||
includeScheduledAt: true,
|
||||
recipientCount: recipientIds.length,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -183,7 +191,7 @@ export async function createBroadcast(
|
||||
try {
|
||||
return await createScheduledBroadcastRecord(ctx.db, senderId, input, recipientIds);
|
||||
} catch (error) {
|
||||
rethrowNotificationReferenceError(error);
|
||||
rethrowNotificationReferenceError(error, "broadcast");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +206,7 @@ export async function createBroadcast(
|
||||
persistedBroadcast = transactionResult.broadcast;
|
||||
notificationIds = transactionResult.notificationIds;
|
||||
} catch (error) {
|
||||
rethrowNotificationReferenceError(error);
|
||||
rethrowNotificationReferenceError(error, "broadcast");
|
||||
}
|
||||
|
||||
emitImmediateBroadcastSideEffects(ctx.db, input, notificationIds);
|
||||
|
||||
@@ -84,7 +84,10 @@ export function getNotificationErrorCandidates(error: unknown): Array<{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function rethrowNotificationReferenceError(error: unknown): never {
|
||||
export function rethrowNotificationReferenceError(
|
||||
error: unknown,
|
||||
recipientContext: "notification" | "task" | "broadcast" = "notification",
|
||||
): never {
|
||||
for (const candidate of getNotificationErrorCandidates(error)) {
|
||||
const fieldName = typeof candidate.meta?.field_name === "string"
|
||||
? candidate.meta.field_name.toLowerCase()
|
||||
@@ -122,9 +125,14 @@ export function rethrowNotificationReferenceError(error: unknown): never {
|
||||
&& (candidate.code === "P2003" || candidate.code === "P2025")
|
||||
&& fieldName.includes("userid")
|
||||
) {
|
||||
const message = recipientContext === "broadcast"
|
||||
? "Broadcast recipient user not found"
|
||||
: recipientContext === "task"
|
||||
? "Task recipient user not found"
|
||||
: "Notification recipient user not found";
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Broadcast recipient user not found",
|
||||
message,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -347,25 +355,32 @@ export async function createManagedNotification(
|
||||
input: z.infer<typeof CreateManagedNotificationInputSchema>,
|
||||
) {
|
||||
const currentUserId = requireNotificationDbUser(ctx).id;
|
||||
const isTaskLikeCategory = input.category === "TASK" || input.category === "APPROVAL";
|
||||
const taskStatus = input.taskStatus ?? (isTaskLikeCategory ? "OPEN" : undefined);
|
||||
|
||||
const notificationId = await createNotification({
|
||||
db: ctx.db,
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
category: input.category,
|
||||
priority: input.priority,
|
||||
link: input.link,
|
||||
taskStatus: input.taskStatus,
|
||||
taskAction: input.taskAction,
|
||||
assigneeId: input.assigneeId,
|
||||
dueDate: input.dueDate,
|
||||
channel: input.channel,
|
||||
senderId: input.senderId ?? currentUserId,
|
||||
});
|
||||
let notificationId: string;
|
||||
try {
|
||||
notificationId = await createNotification({
|
||||
db: ctx.db,
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
category: input.category,
|
||||
priority: input.priority,
|
||||
link: input.link,
|
||||
taskStatus,
|
||||
taskAction: input.taskAction,
|
||||
assigneeId: input.assigneeId,
|
||||
dueDate: input.dueDate,
|
||||
channel: input.channel,
|
||||
senderId: input.senderId ?? currentUserId,
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowNotificationReferenceError(error, "notification");
|
||||
}
|
||||
|
||||
if (input.category === "TASK" || input.category === "APPROVAL") {
|
||||
emitTaskAssigned(input.userId, notificationId);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import {
|
||||
AssignTaskInputSchema,
|
||||
CreateTaskInputSchema,
|
||||
getNotificationErrorCandidates,
|
||||
ListNotificationTasksInputSchema,
|
||||
NotificationIdInputSchema,
|
||||
type NotificationProcedureContext,
|
||||
@@ -22,6 +23,19 @@ import {
|
||||
UpdateNotificationTaskStatusInputSchema,
|
||||
} from "./notification-procedure-base.js";
|
||||
|
||||
function requireTaskActionTransaction(
|
||||
db: NotificationProcedureContext["db"],
|
||||
): NonNullable<NotificationProcedureContext["db"]["$transaction"]> {
|
||||
if (typeof db.$transaction !== "function") {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Task action execution requires transactional persistence support.",
|
||||
});
|
||||
}
|
||||
|
||||
return db.$transaction.bind(db);
|
||||
}
|
||||
|
||||
export async function listNotificationTasks(
|
||||
ctx: NotificationProcedureContext,
|
||||
input: z.infer<typeof ListNotificationTasksInputSchema>,
|
||||
@@ -207,6 +221,12 @@ export async function executeNotificationTaskAction(
|
||||
message: "This task is already completed",
|
||||
});
|
||||
}
|
||||
if (task.taskStatus === "DISMISSED") {
|
||||
throw new TRPCError({
|
||||
code: "PRECONDITION_FAILED",
|
||||
message: "This task has been dismissed",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = parseTaskAction(task.taskAction);
|
||||
if (!parsed) {
|
||||
@@ -237,21 +257,29 @@ export async function executeNotificationTaskAction(
|
||||
});
|
||||
}
|
||||
|
||||
const actionResult = await handler.execute(parsed.entityId, ctx.db, userId);
|
||||
if (!actionResult.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: actionResult.message,
|
||||
});
|
||||
}
|
||||
const transaction = requireTaskActionTransaction(ctx.db);
|
||||
const { completedTask, actionResult } = await transaction(async (tx) => {
|
||||
const actionResult = await handler.execute(parsed.entityId, tx as typeof ctx.db, userId);
|
||||
if (!actionResult.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: actionResult.message,
|
||||
});
|
||||
}
|
||||
|
||||
const completedTask = await ctx.db.notification.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
taskStatus: "DONE",
|
||||
completedAt: new Date(),
|
||||
completedBy: userId,
|
||||
},
|
||||
const completedTask = await tx.notification.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
taskStatus: "DONE",
|
||||
completedAt: new Date(),
|
||||
completedBy: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
completedTask,
|
||||
actionResult,
|
||||
};
|
||||
});
|
||||
|
||||
emitTaskCompleted(task.userId, task.id);
|
||||
@@ -270,23 +298,28 @@ export async function createTask(
|
||||
input: z.infer<typeof CreateTaskInputSchema>,
|
||||
) {
|
||||
const senderId = requireNotificationDbUser(ctx).id;
|
||||
const notificationId = await createNotification({
|
||||
db: ctx.db,
|
||||
userId: input.userId,
|
||||
type: "TASK_CREATED",
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
title: input.title,
|
||||
priority: input.priority,
|
||||
senderId,
|
||||
channel: input.channel,
|
||||
body: input.body,
|
||||
dueDate: input.dueDate,
|
||||
taskAction: input.taskAction,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
link: input.link,
|
||||
});
|
||||
let notificationId: string;
|
||||
try {
|
||||
notificationId = await createNotification({
|
||||
db: ctx.db,
|
||||
userId: input.userId,
|
||||
type: "TASK_CREATED",
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
title: input.title,
|
||||
priority: input.priority,
|
||||
senderId,
|
||||
channel: input.channel,
|
||||
body: input.body,
|
||||
dueDate: input.dueDate,
|
||||
taskAction: input.taskAction,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
link: input.link,
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowNotificationReferenceError(error, "task");
|
||||
}
|
||||
|
||||
emitTaskAssigned(input.userId, notificationId);
|
||||
|
||||
@@ -320,7 +353,16 @@ export async function assignTask(
|
||||
data: { assigneeId: input.assigneeId },
|
||||
});
|
||||
} catch (error) {
|
||||
rethrowNotificationReferenceError(error);
|
||||
for (const candidate of getNotificationErrorCandidates(error)) {
|
||||
if (candidate.code === "P2025") {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task not found",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
rethrowNotificationReferenceError(error, "task");
|
||||
}
|
||||
|
||||
emitTaskAssigned(input.assigneeId, updated.id);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
reportEntitySchema,
|
||||
ReportTemplateConfigSchema,
|
||||
type EntityKey,
|
||||
} from "./report-query-config.js";
|
||||
|
||||
export const ReportBlueprintSummarySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
entity: reportEntitySchema,
|
||||
templateName: z.string().min(1),
|
||||
config: ReportTemplateConfigSchema,
|
||||
});
|
||||
|
||||
export const ReportBlueprintCatalogSchema = z.array(ReportBlueprintSummarySchema);
|
||||
|
||||
export type ReportBlueprintSummary = z.infer<typeof ReportBlueprintSummarySchema>;
|
||||
|
||||
const REPORT_BLUEPRINTS = ReportBlueprintCatalogSchema.parse([
|
||||
{
|
||||
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",
|
||||
config: {
|
||||
entity: "resource_month",
|
||||
columns: [
|
||||
"monthKey",
|
||||
"displayName",
|
||||
"eid",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"orgUnitName",
|
||||
"managementLevelGroupName",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyEffectiveWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
],
|
||||
filters: [],
|
||||
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",
|
||||
config: {
|
||||
entity: "resource_month",
|
||||
columns: [
|
||||
"monthKey",
|
||||
"displayName",
|
||||
"eid",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"orgUnitName",
|
||||
"managementLevelGroupName",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyChargeabilityTargetPct",
|
||||
"monthlyTargetHours",
|
||||
"monthlyActualBookedHours",
|
||||
"monthlyExpectedBookedHours",
|
||||
"monthlyActualChargeabilityPct",
|
||||
"monthlyExpectedChargeabilityPct",
|
||||
"monthlyUnassignedHours",
|
||||
"lcrCents",
|
||||
"currency",
|
||||
],
|
||||
filters: [],
|
||||
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",
|
||||
config: {
|
||||
entity: "resource_month",
|
||||
columns: [
|
||||
"monthKey",
|
||||
"displayName",
|
||||
"chapter",
|
||||
"countryName",
|
||||
"federalState",
|
||||
"metroCityName",
|
||||
"orgUnitName",
|
||||
"monthlyBaseWorkingDays",
|
||||
"monthlyBaseAvailableHours",
|
||||
"monthlyPublicHolidayCount",
|
||||
"monthlyPublicHolidayWorkdayCount",
|
||||
"monthlyPublicHolidayHoursDeduction",
|
||||
"monthlyAbsenceDayEquivalent",
|
||||
"monthlyAbsenceHoursDeduction",
|
||||
"monthlySahHours",
|
||||
"monthlyActualChargeabilityPct",
|
||||
],
|
||||
filters: [],
|
||||
groupBy: "federalState",
|
||||
sortBy: "monthlyPublicHolidayHoursDeduction",
|
||||
sortDir: "desc",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export function listReportBlueprints(entity?: EntityKey): ReportBlueprintSummary[] {
|
||||
return entity
|
||||
? REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity)
|
||||
: REPORT_BLUEPRINTS;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user