diff --git a/apps/web/src/components/ui/Badge.tsx b/apps/web/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..55fdab5
--- /dev/null
+++ b/apps/web/src/components/ui/Badge.tsx
@@ -0,0 +1,29 @@
+import { clsx } from "clsx";
+
+interface BadgeProps {
+ children: React.ReactNode;
+ variant?: "default" | "success" | "warning" | "danger" | "info";
+ className?: string;
+}
+
+const variantClasses = {
+ default: "bg-gray-100 text-gray-700",
+ success: "bg-green-100 text-green-700",
+ warning: "bg-yellow-100 text-yellow-700",
+ danger: "bg-red-100 text-red-700",
+ info: "bg-blue-100 text-blue-700",
+};
+
+export function Badge({ children, variant = "default", className }: BadgeProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx
new file mode 100644
index 0000000..8ad7305
--- /dev/null
+++ b/apps/web/src/components/ui/Button.tsx
@@ -0,0 +1,47 @@
+import { clsx } from "clsx";
+import type { ButtonHTMLAttributes } from "react";
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: "primary" | "secondary" | "ghost" | "danger";
+ size?: "sm" | "md" | "lg";
+}
+
+const variantClasses = {
+ primary: "bg-brand-600 hover:bg-brand-700 text-white",
+ secondary: "bg-white hover:bg-gray-50 text-gray-700 border border-gray-300",
+ ghost: "text-gray-600 hover:bg-gray-100",
+ danger: "bg-red-600 hover:bg-red-700 text-white",
+};
+
+const sizeClasses = {
+ sm: "px-3 py-1.5 text-xs",
+ md: "px-4 py-2 text-sm",
+ lg: "px-6 py-3 text-base",
+};
+
+export function Button({
+ variant = "primary",
+ size = "md",
+ className,
+ children,
+ disabled,
+ ...props
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts
new file mode 100644
index 0000000..5dc6886
--- /dev/null
+++ b/apps/web/src/hooks/useLocalStorage.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+/**
+ * Persist a value in localStorage with SSR safety and JSON serialization.
+ *
+ * When `T` is a plain object, stored values are merged with `defaultValue`
+ * so fields added to the schema later get their defaults filled in.
+ * For primitive types (string, number, boolean) the stored value is used as-is.
+ *
+ * The optional `changeEventName` parameter causes every write to dispatch a
+ * CustomEvent so other instances of the hook in the same tab stay in sync.
+ */
+export function useLocalStorage(
+ key: string,
+ defaultValue: T,
+ changeEventName?: string,
+): [T, (updater: T | ((prev: T) => T)) => void] {
+ const [value, setValue] = useState(() => read(key, defaultValue));
+
+ // Re-read after hydration (SSR → client) and subscribe to cross-instance events.
+ useEffect(() => {
+ setValue(read(key, defaultValue));
+
+ if (!changeEventName) return;
+ function handleChange(e: Event) {
+ setValue((e as CustomEvent).detail);
+ }
+ window.addEventListener(changeEventName, handleChange);
+ return () => window.removeEventListener(changeEventName, handleChange);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, changeEventName]);
+
+ const set = useCallback(
+ (updater: T | ((prev: T) => T)) => {
+ setValue((prev) => {
+ const next = typeof updater === "function" ? (updater as (p: T) => T)(prev) : updater;
+ if (typeof window !== "undefined") {
+ localStorage.setItem(key, JSON.stringify(next));
+ if (changeEventName) {
+ window.dispatchEvent(new CustomEvent(changeEventName, { detail: next }));
+ }
+ }
+ return next;
+ });
+ },
+ [key, changeEventName],
+ );
+
+ return [value, set];
+}
+
+function read(key: string, defaultValue: T): T {
+ if (typeof window === "undefined") return defaultValue;
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return defaultValue;
+ const parsed = JSON.parse(raw) as T;
+ // For plain objects, merge with defaults to handle schema evolution.
+ if (isPlainObject(defaultValue) && isPlainObject(parsed)) {
+ return { ...defaultValue, ...parsed } as T;
+ }
+ return parsed;
+ } catch {
+ return defaultValue;
+ }
+}
+
+function isPlainObject(v: unknown): v is Record {
+ return typeof v === "object" && v !== null && !Array.isArray(v);
+}
diff --git a/packages/api/src/__tests__/assistant-tools-workflow-scenarios.test.ts b/packages/api/src/__tests__/assistant-tools-workflow-scenarios.test.ts
new file mode 100644
index 0000000..ff31830
--- /dev/null
+++ b/packages/api/src/__tests__/assistant-tools-workflow-scenarios.test.ts
@@ -0,0 +1,551 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { listAssignmentBookings } from "@capakraken/application";
+import { PermissionKey, SystemRole } from "@capakraken/shared";
+
+vi.mock("@capakraken/application", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ approveEstimateVersion: vi.fn(),
+ cloneEstimate: vi.fn(),
+ commitDispoImportBatch: vi.fn(),
+ countPlanningEntries: vi.fn().mockResolvedValue({ countsByRoleId: new Map() }),
+ createEstimateExport: vi.fn(),
+ createEstimatePlanningHandoff: vi.fn(),
+ createEstimateRevision: vi.fn(),
+ assessDispoImportReadiness: vi.fn(),
+ loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map()),
+ getDashboardDemand: vi.fn().mockResolvedValue([]),
+ getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
+ getDashboardOverview: vi.fn(),
+ getDashboardSkillGapSummary: vi.fn().mockResolvedValue({
+ roleGaps: [],
+ totalOpenPositions: 0,
+ skillSupplyTop10: [],
+ resourcesByRole: [],
+ }),
+ getDashboardProjectHealth: vi.fn().mockResolvedValue([]),
+ getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
+ getDashboardTopValueResources: vi.fn().mockResolvedValue([]),
+ getEstimateById: vi.fn(),
+ listAssignmentBookings: vi.fn().mockResolvedValue([]),
+ stageDispoImportBatch: vi.fn(),
+ submitEstimateVersion: vi.fn(),
+ updateEstimateDraft: vi.fn(),
+ };
+});
+
+import { executeTool } from "../router/assistant-tools.js";
+import { createToolContext } from "./assistant-tools-insights-scenarios-test-helpers.js";
+
+describe("assistant tools workflow scenarios", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(listAssignmentBookings).mockResolvedValue([]);
+ });
+
+ it("capacity search then resource check: finds capacity and then checks a specific resource from the result", async () => {
+ vi.mocked(listAssignmentBookings).mockResolvedValue([]);
+
+ const resourceRecord = {
+ id: "res_1",
+ displayName: "Bruce Banner",
+ eid: "EMP-001",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
+ countryId: "country_de",
+ federalState: null,
+ metroCityId: null,
+ country: { code: "DE", dailyWorkingHours: 8 },
+ metroCity: null,
+ areaRole: { name: "Pipeline TD" },
+ chapter: "Delivery",
+ };
+
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([resourceRecord]),
+ findUnique: vi.fn().mockResolvedValue(resourceRecord),
+ findFirst: vi.fn().mockResolvedValue(null),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ systemSettings: {
+ findUnique: vi.fn().mockResolvedValue(null),
+ },
+ };
+
+ const ctx = createToolContext(db, {
+ userRole: SystemRole.CONTROLLER,
+ permissions: [PermissionKey.VIEW_PLANNING],
+ });
+
+ // Step 1: find capacity for the period
+ const capacityResult = await executeTool(
+ "find_capacity",
+ JSON.stringify({
+ startDate: "2026-05-01",
+ endDate: "2026-05-30",
+ minHoursPerDay: 4,
+ chapter: "Delivery",
+ }),
+ ctx,
+ );
+
+ const capacityParsed = JSON.parse(capacityResult.content) as {
+ results: Array<{ id: string; name: string; eid: string }>;
+ totalFound: number;
+ };
+
+ expect(db.resource.findMany).toHaveBeenCalled();
+ expect(capacityParsed.results).toHaveLength(1);
+ expect(capacityParsed.results[0].name).toBe("Bruce Banner");
+
+ // Step 2: use resource ID from result to check availability in a specific date window
+ const foundResourceId = capacityParsed.results[0].id;
+ expect(foundResourceId).toBe("res_1");
+
+ const availabilityResult = await executeTool(
+ "check_resource_availability",
+ JSON.stringify({
+ resourceId: foundResourceId,
+ startDate: "2026-05-05",
+ endDate: "2026-05-09",
+ }),
+ ctx,
+ );
+
+ const availabilityParsed = JSON.parse(availabilityResult.content) as {
+ workingDays: number;
+ periodAvailableHours: number;
+ periodBookedHours: number;
+ periodRemainingHours: number;
+ isFullyAvailable: boolean;
+ };
+
+ // 2026-05-05 (Tue) to 2026-05-09 (Sat) = 4 working days (Tue-Fri)
+ expect(availabilityParsed.workingDays).toBe(4);
+ expect(availabilityParsed.periodAvailableHours).toBe(32);
+ expect(availabilityParsed.periodBookedHours).toBe(0);
+ expect(availabilityParsed.periodRemainingHours).toBe(32);
+ expect(availabilityParsed.isFullyAvailable).toBe(true);
+ });
+
+ it("vacation balance then upcoming vacations: retrieves balance and then lists upcoming vacations for the same resource", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z"));
+
+ try {
+ const db = {
+ resource: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "res_2",
+ eid: "EMP-002",
+ displayName: "Tony Stark",
+ userId: "user_2",
+ chapter: "Engineering",
+ federalState: null,
+ countryId: "country_de",
+ metroCityId: null,
+ country: { code: "DE", name: "Germany" },
+ metroCity: null,
+ }),
+ },
+ holidayCalendar: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ systemSettings: {
+ findUnique: vi.fn().mockResolvedValue({ vacationDefaultDays: 28 }),
+ },
+ vacationEntitlement: {
+ findUnique: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockResolvedValue({
+ id: "ent_2026_res2",
+ resourceId: "res_2",
+ year: 2026,
+ entitledDays: 28,
+ carryoverDays: 0,
+ usedDays: 0,
+ pendingDays: 0,
+ }),
+ update: vi.fn().mockResolvedValue({
+ id: "ent_2026_res2",
+ resourceId: "res_2",
+ year: 2026,
+ entitledDays: 28,
+ carryoverDays: 0,
+ usedDays: 0,
+ pendingDays: 0,
+ }),
+ },
+ vacation: {
+ findMany: vi.fn().mockImplementation(async (args: { where?: { type?: unknown; status?: unknown; startDate?: unknown } } = {}) => {
+ if (args?.where?.status === "APPROVED" && args?.where?.startDate) {
+ // list_vacations_upcoming query
+ return [
+ {
+ id: "vac_1",
+ resourceId: "res_2",
+ status: "APPROVED",
+ type: "ANNUAL",
+ startDate: new Date("2026-05-10T00:00:00.000Z"),
+ endDate: new Date("2026-05-14T00:00:00.000Z"),
+ isHalfDay: false,
+ halfDayPart: null,
+ resource: {
+ id: "res_2",
+ displayName: "Tony Stark",
+ eid: "EMP-002",
+ lcrCents: 9_000,
+ chapter: "Engineering",
+ },
+ requestedBy: null,
+ approvedBy: null,
+ },
+ ];
+ }
+ // vacation balance queries (by type)
+ return [];
+ }),
+ },
+ };
+
+ const ctx = createToolContext(db, { userRole: SystemRole.ADMIN });
+
+ // Step 1: get vacation balance for the resource
+ const balanceResult = await executeTool(
+ "get_vacation_balance",
+ JSON.stringify({ resourceId: "res_2", year: 2026 }),
+ ctx,
+ );
+
+ const balanceParsed = JSON.parse(balanceResult.content) as {
+ resource: string;
+ eid: string;
+ year: number;
+ entitlement: number;
+ remaining: number;
+ };
+
+ expect(balanceParsed.resource).toBe("Tony Stark");
+ expect(balanceParsed.eid).toBe("EMP-002");
+ expect(balanceParsed.year).toBe(2026);
+ expect(balanceParsed.entitlement).toBe(28);
+ expect(balanceParsed.remaining).toBe(28);
+
+ // Step 2: list upcoming vacations for the same resource (filter by name)
+ const upcomingResult = await executeTool(
+ "list_vacations_upcoming",
+ JSON.stringify({ resourceName: "Tony", daysAhead: 30, limit: 10 }),
+ ctx,
+ );
+
+ const upcomingParsed = JSON.parse(upcomingResult.content) as Array<{
+ resource: string;
+ eid: string;
+ start: string;
+ end: string;
+ }>;
+
+ expect(upcomingParsed).toHaveLength(1);
+ expect(upcomingParsed[0].resource).toBe("Tony Stark");
+ expect(upcomingParsed[0].eid).toBe("EMP-002");
+ expect(upcomingParsed[0].start).toBe("2026-05-10");
+ expect(upcomingParsed[0].end).toBe("2026-05-14");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("demand listing then staffing suggestions: lists open demands and then retrieves suggestions for the project", async () => {
+ vi.mocked(listAssignmentBookings).mockResolvedValue([]);
+
+ const projectRecord = {
+ id: "project_1",
+ shortCode: "GDM",
+ name: "Gelddruckmaschine",
+ status: "ACTIVE",
+ responsiblePerson: null,
+ startDate: new Date("2026-06-01T00:00:00.000Z"),
+ endDate: new Date("2026-06-30T00:00:00.000Z"),
+ };
+
+ const db = {
+ demandRequirement: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "demand_1",
+ projectId: "project_1",
+ startDate: new Date("2026-06-01T00:00:00.000Z"),
+ endDate: new Date("2026-06-30T00:00:00.000Z"),
+ hoursPerDay: 8,
+ percentage: 100,
+ role: "Pipeline TD",
+ roleId: null,
+ headcount: 2,
+ status: "OPEN",
+ metadata: null,
+ createdAt: new Date("2026-05-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-05-01T00:00:00.000Z"),
+ project: {
+ id: "project_1",
+ name: "Gelddruckmaschine",
+ shortCode: "GDM",
+ status: "ACTIVE",
+ endDate: new Date("2026-06-30T00:00:00.000Z"),
+ },
+ roleEntity: null,
+ assignments: [],
+ },
+ ]),
+ },
+ project: {
+ findUnique: vi.fn().mockResolvedValue(projectRecord),
+ findFirst: vi.fn().mockResolvedValue(null),
+ },
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "res_3",
+ displayName: "Natasha Romanoff",
+ eid: "EMP-003",
+ fte: 1,
+ chapter: "VFX",
+ skills: [{ skill: "Houdini", category: "FX", proficiency: 4, isMainSkill: true }],
+ lcrCents: 7_500,
+ chargeabilityTarget: 80,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
+ valueScore: 85,
+ countryId: null,
+ federalState: null,
+ metroCityId: null,
+ country: null,
+ metroCity: null,
+ areaRole: { name: "Pipeline TD" },
+ },
+ ]),
+ },
+ systemSettings: {
+ findUnique: vi.fn().mockResolvedValue(null),
+ },
+ };
+
+ const ctx = createToolContext(db, {
+ userRole: SystemRole.CONTROLLER,
+ permissions: [PermissionKey.VIEW_PLANNING, PermissionKey.VIEW_COSTS],
+ });
+
+ // Step 1: list demands (no status filter — AllocationStatus enum doesn't include OPEN)
+ const demandsResult = await executeTool(
+ "list_demands",
+ JSON.stringify({}),
+ ctx,
+ );
+
+ const demandsParsed = JSON.parse(demandsResult.content) as Array<{
+ id: string;
+ project: string;
+ role: string;
+ status: string;
+ headcount: number;
+ remaining: number;
+ }>;
+
+ expect(demandsParsed).toHaveLength(1);
+ expect(demandsParsed[0].id).toBe("demand_1");
+ expect(demandsParsed[0].project).toBe("Gelddruckmaschine");
+ expect(demandsParsed[0].status).toBe("OPEN");
+ expect(demandsParsed[0].headcount).toBe(2);
+ expect(demandsParsed[0].remaining).toBe(2);
+
+ // Step 2: get staffing suggestions for the project from the demand
+ const projectId = demandsParsed[0].id.startsWith("demand_") ? "project_1" : demandsParsed[0].id;
+
+ const suggestionsResult = await executeTool(
+ "get_staffing_suggestions",
+ JSON.stringify({
+ projectId,
+ startDate: "2026-06-01",
+ endDate: "2026-06-30",
+ limit: 5,
+ }),
+ ctx,
+ );
+
+ const suggestionsParsed = JSON.parse(suggestionsResult.content) as {
+ project: string;
+ suggestions: Array<{ id: string; name: string }>;
+ };
+
+ expect(suggestionsParsed.project).toBe("Gelddruckmaschine (GDM)");
+ expect(Array.isArray(suggestionsParsed.suggestions)).toBe(true);
+ });
+
+ it("budget status check: retrieves budget utilization for a project", async () => {
+ vi.mocked(listAssignmentBookings).mockResolvedValue([
+ {
+ projectId: "project_2",
+ status: "CONFIRMED",
+ dailyCostCents: 12_000,
+ hoursPerDay: 8,
+ startDate: new Date("2026-07-01T00:00:00.000Z"),
+ endDate: new Date("2026-07-02T00:00:00.000Z"),
+ project: {
+ id: "project_2",
+ status: "ACTIVE",
+ },
+ },
+ {
+ projectId: "project_2",
+ status: "CONFIRMED",
+ dailyCostCents: 8_000,
+ hoursPerDay: 8,
+ startDate: new Date("2026-07-03T00:00:00.000Z"),
+ endDate: new Date("2026-07-03T00:00:00.000Z"),
+ project: {
+ id: "project_2",
+ status: "ACTIVE",
+ },
+ },
+ ] as Awaited>);
+
+ const db = {
+ project: {
+ findUnique: vi
+ .fn()
+ .mockResolvedValueOnce({
+ id: "project_2",
+ name: "Raketenbauprojekt",
+ shortCode: "RBP",
+ status: "ACTIVE",
+ responsiblePerson: "Elon",
+ })
+ .mockResolvedValueOnce({
+ id: "project_2",
+ name: "Raketenbauprojekt",
+ shortCode: "RBP",
+ budgetCents: 500_000,
+ winProbability: 100,
+ startDate: new Date("2026-07-01T00:00:00.000Z"),
+ endDate: new Date("2026-07-31T00:00:00.000Z"),
+ }),
+ findFirst: vi.fn(),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+
+ const ctx = createToolContext(db, {
+ userRole: SystemRole.CONTROLLER,
+ permissions: [PermissionKey.VIEW_COSTS],
+ });
+
+ const result = await executeTool(
+ "get_budget_status",
+ JSON.stringify({ projectId: "project_2" }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as {
+ project: string;
+ code: string;
+ budget: string;
+ confirmed: string;
+ proposed: string;
+ allocated: string;
+ remaining: string;
+ utilization: string;
+ };
+
+ expect(vi.mocked(listAssignmentBookings)).toHaveBeenCalledWith(db, {
+ projectIds: ["project_2"],
+ });
+ expect(parsed.project).toBe("Raketenbauprojekt");
+ expect(parsed.code).toBe("RBP");
+ expect(parsed.budget).toBe("5.000,00 EUR");
+ // Booking 1: July 1-2 (2 days) × 12000 cents = 24000 cents = 240 EUR
+ // Booking 2: July 3 (1 day) × 8000 cents = 8000 cents = 80 EUR
+ // Total confirmed: 320 EUR
+ expect(parsed.confirmed).toBe("320,00 EUR");
+ expect(parsed.proposed).toBe("0,00 EUR");
+ expect(parsed.allocated).toBe("320,00 EUR");
+ expect(parsed.remaining).toBe("4.680,00 EUR");
+ expect(parsed.utilization).toBe("6.4%");
+ });
+
+ it("search resources with availability filter: finds resources in a chapter", async () => {
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "res_4",
+ eid: "EMP-004",
+ displayName: "Wanda Maximoff",
+ chapter: "FX",
+ fte: 1,
+ lcrCents: 8_000,
+ chargeabilityTarget: 80,
+ isActive: true,
+ areaRole: { name: "FX Artist" },
+ country: { code: "DE", name: "Germany" },
+ metroCity: null,
+ orgUnit: null,
+ },
+ {
+ id: "res_5",
+ eid: "EMP-005",
+ displayName: "Pietro Maximoff",
+ chapter: "FX",
+ fte: 0.8,
+ lcrCents: 7_200,
+ chargeabilityTarget: 75,
+ isActive: true,
+ areaRole: { name: "Motion Designer" },
+ country: { code: "DE", name: "Germany" },
+ metroCity: null,
+ orgUnit: null,
+ },
+ ]),
+ },
+ };
+
+ const ctx = createToolContext(db, {
+ userRole: SystemRole.CONTROLLER,
+ permissions: [PermissionKey.VIEW_ALL_RESOURCES],
+ });
+
+ const result = await executeTool(
+ "search_resources",
+ JSON.stringify({ orgUnit: undefined, country: "DE", limit: 10 }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as Array<{
+ id: string;
+ name: string;
+ eid: string;
+ chapter: string;
+ role: string;
+ }>;
+
+ expect(db.resource.findMany).toHaveBeenCalled();
+ expect(parsed).toHaveLength(2);
+
+ const wanda = parsed.find((r) => r.id === "res_4");
+ expect(wanda).toBeDefined();
+ expect(wanda?.name).toBe("Wanda Maximoff");
+ expect(wanda?.eid).toBe("EMP-004");
+ expect(wanda?.chapter).toBe("FX");
+ expect(wanda?.role).toBe("FX Artist");
+
+ const pietro = parsed.find((r) => r.id === "res_5");
+ expect(pietro).toBeDefined();
+ expect(pietro?.name).toBe("Pietro Maximoff");
+ expect(pietro?.eid).toBe("EMP-005");
+ });
+});
diff --git a/packages/api/src/lib/audit-helpers.ts b/packages/api/src/lib/audit-helpers.ts
new file mode 100644
index 0000000..258c278
--- /dev/null
+++ b/packages/api/src/lib/audit-helpers.ts
@@ -0,0 +1,34 @@
+import type { PrismaClient } from "@capakraken/db";
+import { createAuditEntry } from "./audit.js";
+
+type AuditAction = "CREATE" | "UPDATE" | "DELETE" | "SHIFT" | "IMPORT";
+type AuditSource = "ui" | "api" | "ai" | "import" | "cron";
+
+interface BoundAuditParams {
+ entityType: string;
+ entityId: string;
+ entityName?: string;
+ action: AuditAction;
+ before?: Record;
+ after?: Record;
+ summary?: string;
+ metadata?: Record;
+}
+
+/**
+ * Creates a fire-and-forget audit logger with db, userId, and source pre-bound.
+ * Use at the top of a procedure after resolving the current user.
+ *
+ * @example
+ * const audit = makeAuditLogger(ctx.db, userRecord?.id);
+ * audit({ entityType: "Vacation", entityId: v.id, action: "UPDATE", after: v });
+ */
+export function makeAuditLogger(
+ db: PrismaClient,
+ userId: string | undefined,
+ source: AuditSource = "ui",
+): (params: BoundAuditParams) => void {
+ return (params) => {
+ void createAuditEntry({ db, ...(userId !== undefined ? { userId } : {}), source, ...params });
+ };
+}
diff --git a/packages/api/src/router/assistant-tool-registry.ts b/packages/api/src/router/assistant-tool-registry.ts
new file mode 100644
index 0000000..9ac9215
--- /dev/null
+++ b/packages/api/src/router/assistant-tool-registry.ts
@@ -0,0 +1,134 @@
+import type { ToolManifest } from "@capakraken/shared";
+
+/**
+ * Rich metadata registry for AI assistant tools.
+ * Complements the OpenAI function schema (ToolDef) with category, mutation flag,
+ * and intent descriptions that improve model tool selection and documentation.
+ *
+ * Start: 10 high-traffic tools. Add entries as needed — the registry is additive
+ * and does not affect tool execution.
+ */
+export const TOOL_REGISTRY: ToolManifest[] = [
+ // ─── Planning ──────────────────────────────────────────────────────────────
+ {
+ name: "create_allocation",
+ category: "planning",
+ isMutation: true,
+ intent: "Assign a resource to a project for a specific date range at a utilization percentage",
+ examples: [
+ "Book Alice on Project X from April to June at 80%",
+ "Assign the lead developer to the backend project starting next Monday at full capacity",
+ ],
+ },
+ {
+ name: "cancel_allocation",
+ category: "planning",
+ isMutation: true,
+ intent: "Remove a resource booking from a project",
+ examples: [
+ "Cancel Bob's allocation on Project Y",
+ "Remove the allocation for resource 123 on the frontend project",
+ ],
+ },
+ {
+ name: "list_allocations",
+ category: "planning",
+ isMutation: false,
+ intent: "List all allocations for a project or resource within a date range",
+ examples: [
+ "Show me all bookings for the Alpha project this quarter",
+ "What projects is Alice allocated to in May?",
+ ],
+ },
+ // ─── Resource ──────────────────────────────────────────────────────────────
+ {
+ name: "search_resources",
+ category: "resource",
+ isMutation: false,
+ intent: "Search for resources by name, skill, chapter, or seniority",
+ examples: [
+ "Find all senior developers in the Berlin chapter",
+ "Who has React skills and is available next month?",
+ ],
+ },
+ {
+ name: "check_resource_availability",
+ category: "resource",
+ isMutation: false,
+ intent: "Check how many hours a resource has free in a date range",
+ examples: [
+ "Is Alice available for a new project in Q3?",
+ "How much capacity does the frontend chapter have next month?",
+ ],
+ },
+ // ─── Staffing ──────────────────────────────────────────────────────────────
+ {
+ name: "get_staffing_suggestions",
+ category: "staffing",
+ isMutation: false,
+ requiresAdvanced: true,
+ intent: "Get ranked resource suggestions for an open demand, scored by skill match, availability, and cost",
+ examples: [
+ "Who should I assign to fill the senior designer role on Project Z?",
+ "Find the best available backend engineers for a 3-month engagement starting in June",
+ ],
+ },
+ {
+ name: "fill_demand",
+ category: "staffing",
+ isMutation: true,
+ intent: "Create an allocation to fill an open staffing demand",
+ examples: [
+ "Fill the open designer demand with Alice",
+ "Assign Bob to satisfy the pending backend demand",
+ ],
+ },
+ // ─── Vacation ──────────────────────────────────────────────────────────────
+ {
+ name: "create_vacation",
+ category: "vacation",
+ isMutation: true,
+ intent: "Submit a vacation or leave request for a resource",
+ examples: [
+ "Request 2 weeks of annual leave for Alice starting August 1st",
+ "Book sick leave for Bob for today",
+ ],
+ },
+ {
+ name: "approve_vacation",
+ category: "vacation",
+ isMutation: true,
+ intent: "Approve a pending vacation request",
+ examples: [
+ "Approve Alice's vacation request",
+ "Accept the leave request with ID abc123",
+ ],
+ },
+ // ─── Reporting ─────────────────────────────────────────────────────────────
+ {
+ name: "get_chargeability_report",
+ category: "reporting",
+ isMutation: false,
+ requiresAdvanced: true,
+ intent: "Generate a chargeability report showing billable vs. non-billable utilization across resources or chapters",
+ examples: [
+ "Show me chargeability for the engineering chapter in Q1",
+ "What is the billable rate for the team last month?",
+ ],
+ },
+];
+
+/** Lookup map for O(1) manifest access by tool name. */
+export const TOOL_REGISTRY_MAP: ReadonlyMap = new Map(
+ TOOL_REGISTRY.map((m) => [m.name, m]),
+);
+
+/** Get the manifest for a given tool name, or undefined if not registered. */
+export function getToolManifest(name: string): ToolManifest | undefined {
+ return TOOL_REGISTRY_MAP.get(name);
+}
+
+/** Filter the registry by category. */
+export function getToolsByCategory(category: ToolManifest["category"]): ToolManifest[] {
+ return TOOL_REGISTRY.filter((m) => m.category === category);
+}
diff --git a/packages/db/prisma/migrations/20260407_assignment_composite_indexes.sql b/packages/db/prisma/migrations/20260407_assignment_composite_indexes.sql
new file mode 100644
index 0000000..2d01262
--- /dev/null
+++ b/packages/db/prisma/migrations/20260407_assignment_composite_indexes.sql
@@ -0,0 +1,8 @@
+-- Add composite indexes on Assignment for status+date-range queries
+-- These speed up cross-project status scans and project-scoped status+date filters.
+
+CREATE INDEX CONCURRENTLY IF NOT EXISTS "assignments_status_startDate_endDate_idx"
+ ON "assignments" ("status", "startDate", "endDate");
+
+CREATE INDEX CONCURRENTLY IF NOT EXISTS "assignments_projectId_status_startDate_endDate_idx"
+ ON "assignments" ("projectId", "status", "startDate", "endDate");
diff --git a/packages/shared/src/types/tool-manifest.ts b/packages/shared/src/types/tool-manifest.ts
new file mode 100644
index 0000000..0b4559c
--- /dev/null
+++ b/packages/shared/src/types/tool-manifest.ts
@@ -0,0 +1,29 @@
+/**
+ * Tool manifest types for the AI assistant.
+ * Provides rich metadata beyond the OpenAI function schema (ToolDef).
+ */
+
+export type ToolCategory =
+ | "planning"
+ | "resource"
+ | "project"
+ | "vacation"
+ | "staffing"
+ | "reporting"
+ | "admin"
+ | "system";
+
+export interface ToolManifest {
+ /** Tool name as registered in OpenAI function schema. */
+ name: string;
+ /** Functional category for grouping and filtering. */
+ category: ToolCategory;
+ /** True if the tool performs a write operation (used for audit and confirmation). */
+ isMutation: boolean;
+ /** True if the tool requires advanced assistant permission. */
+ requiresAdvanced?: boolean;
+ /** One-line description of what this tool does (for system prompts and docs). */
+ intent: string;
+ /** Optional natural-language examples to guide model tool selection. */
+ examples?: string[];
+}