-
-
- {row.shortCode}
- {row.projectName}
+
+
+
+ {row.shortCode}
+ {row.projectName}
+
+ {showDetails ? (
+
+
+ Budget: {formatMoney(row.spentCents ?? 0)} spent
+ {row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"}
+ {row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""}
+
+
+ Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC
+ {typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""}
+ {typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""}
+
+
+ Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
+
+ {(row.calendarLocations ?? []).length > 0 ? (
+
+ Calendar basis: {(row.calendarLocations ?? [])
+ .slice(0, 2)
+ .map((location) => `${formatLocation(location)} (${formatMoney(location.spentCents)} / ${location.assignmentCount} assign.)`)
+ .join(" · ")}
+ {(row.calendarLocations ?? []).length > 2
+ ? ` · +${(row.calendarLocations ?? []).length - 2} more`
+ : ""}
+
+ ) : null}
+
+ ) : null}
-
-
-
-
+
+
+
+
+
+
+
+ B {row.budgetUtilizationPercent ?? 0}% used
+
+ {showDetails ? (
+
+ S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
+
+ ) : null}
diff --git a/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx b/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx
index beab32b..1ad8163 100644
--- a/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx
+++ b/apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx
@@ -19,6 +19,8 @@ function StatCard({
value,
suffix,
sub,
+ details,
+ showDetails = false,
info,
accentColor,
delay = 0,
@@ -28,6 +30,8 @@ function StatCard({
value: number;
suffix?: string;
sub?: string;
+ details?: string[];
+ showDetails?: boolean;
info?: React.ReactNode;
accentColor?: "green" | "amber" | "red";
delay?: number;
@@ -66,13 +70,37 @@ function StatCard({
)}
{sub && {sub}
}
+ {showDetails && details && details.length > 0 ? (
+
+ {details.map((detail) => (
+
{detail}
+ ))}
+
+ ) : null}
);
}
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export function StatCardsWidget(_props: Partial = {}) {
+function formatShortDate(value?: string | Date | null): string {
+ if (!value) {
+ return "n/a";
+ }
+
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "n/a";
+ }
+
+ return new Intl.DateTimeFormat("de-DE", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "numeric",
+ }).format(date);
+}
+
+export function StatCardsWidget(props: Partial = {}) {
+ const showDetails = props.config?.showDetails === true;
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
staleTime: 60_000,
placeholderData: (prev) => prev,
@@ -104,21 +132,33 @@ export function StatCardsWidget(_props: Partial = {}) {
@@ -127,7 +167,13 @@ export function StatCardsWidget(_props: Partial = {}) {
value={budgetPct}
suffix="%"
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
- info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
+ details={[
+ `Remaining: ${formatMoney(data.budgetBasis?.remainingBudgetCents ?? (data.budgetSummary.totalBudgetCents - data.budgetSummary.totalCostCents))}`,
+ `Basis: ${data.budgetBasis?.trackedAssignmentCount ?? 0} non-cancelled assignments across ${data.budgetBasis?.budgetedProjects ?? 0} budgeted projects`,
+ `Window: ${formatShortDate(data.budgetBasis?.windowStart)} - ${formatShortDate(data.budgetBasis?.windowEnd)}`,
+ ]}
+ showDetails={showDetails}
+ info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost uses the effective allocation cost basis including holiday-adjusted working capacity where available."
accentColor={budgetAccent}
delay={0.15}
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx
index b59989d..da31a9d 100644
--- a/apps/web/src/components/layout/AppShell.tsx
+++ b/apps/web/src/components/layout/AppShell.tsx
@@ -231,6 +231,7 @@ const adminNavEntries: AdminEntry[] = [
],
},
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: },
+ { href: "/admin/vacations", label: "Vacations & Holidays", icon: },
{ href: "/admin/users", label: "Users", icon: },
{ href: "/admin/system-roles", label: "System Roles", icon: },
{ href: "/admin/settings", label: "Settings", icon: },
diff --git a/apps/web/src/components/notifications/NotificationBell.tsx b/apps/web/src/components/notifications/NotificationBell.tsx
index 9c37753..f45597d 100644
--- a/apps/web/src/components/notifications/NotificationBell.tsx
+++ b/apps/web/src/components/notifications/NotificationBell.tsx
@@ -1,11 +1,12 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useSession } from "next-auth/react";
import Link from "next/link";
import type { Route } from "next";
import { motion, useAnimationControls } from "framer-motion";
+import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string {
@@ -28,12 +29,16 @@ type TabKey = "all" | "tasks" | "reminders";
export function NotificationBell() {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState("all");
- const ref = useRef(null);
const bellRef = useRef(null);
- const dropdownRef = useRef(null);
- const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
+ const { panelRef, position, handleOpenChange } = useAnchoredOverlay({
+ open,
+ onClose: () => setOpen(false),
+ side: "right",
+ crossAlign: "start",
+ triggerRef: bellRef,
+ });
const badgeControls = useAnimationControls();
const prevUnreadRef = useRef(null);
@@ -96,39 +101,6 @@ export function NotificationBell() {
},
});
- // Compute dropdown position when opening
- const updatePosition = useCallback(() => {
- if (!bellRef.current) return;
- const rect = bellRef.current.getBoundingClientRect();
- const panelHeight = 440; // approximate max height
- let top = rect.top;
- // If it would overflow the bottom, flip upward
- if (top + panelHeight > window.innerHeight) {
- top = Math.max(8, window.innerHeight - panelHeight - 8);
- }
- setDropdownPos({ top, left: rect.right + 8 });
- }, []);
-
- useEffect(() => {
- if (open) updatePosition();
- }, [open, updatePosition]);
-
- // Close dropdown on outside click
- useEffect(() => {
- if (!open) return;
- function handleClick(e: MouseEvent) {
- const target = e.target as Node;
- if (
- ref.current && !ref.current.contains(target) &&
- dropdownRef.current && !dropdownRef.current.contains(target)
- ) {
- setOpen(false);
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [open]);
-
function handleMarkAllRead() {
if (!isAuthenticated) return;
markRead.mutate({});
@@ -150,12 +122,18 @@ export function NotificationBell() {
];
return (
-
+
{/* Bell button */}
setOpen((v) => !v)}
+ onClick={() => {
+ setOpen((current) => {
+ const nextOpen = !current;
+ handleOpenChange(nextOpen);
+ return nextOpen;
+ });
+ }}
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Notifications"
>
@@ -193,12 +171,12 @@ export function NotificationBell() {
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
{open && createPortal(
{/* Header */}
diff --git a/apps/web/src/components/reports/ReportBuilder.tsx b/apps/web/src/components/reports/ReportBuilder.tsx
index 83fdccb..46437f9 100644
--- a/apps/web/src/components/reports/ReportBuilder.tsx
+++ b/apps/web/src/components/reports/ReportBuilder.tsx
@@ -7,7 +7,7 @@ import { clsx } from "clsx";
// ─── Types ──────────────────────────────────────────────────────────────────
-type EntityType = "resource" | "project" | "assignment";
+type EntityType = "resource" | "project" | "assignment" | "resource_month";
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
interface FilterRow {
@@ -17,10 +17,50 @@ interface FilterRow {
value: string;
}
+interface AvailableColumn {
+ key: string;
+ label: string;
+ dataType: "string" | "number" | "date" | "boolean";
+}
+
+interface TemplateConfig {
+ entity: EntityType;
+ columns: string[];
+ filters: Omit
[];
+ groupBy?: string;
+ sortBy?: string;
+ sortDir?: "asc" | "desc";
+ periodMonth?: string;
+}
+
+interface ReportTemplateSummary {
+ id: string;
+ name: string;
+ description?: string | null;
+ entity: EntityType;
+ config: TemplateConfig;
+ isShared: boolean;
+ isOwner: boolean;
+ updatedAt: string | Date;
+}
+
+interface ReportBlueprint {
+ id: string;
+ label: string;
+ description: string;
+ entity: EntityType;
+ columns: string[];
+ groupBy?: string;
+ sortBy?: string;
+ sortDir?: "asc" | "desc";
+ templateName: string;
+}
+
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "resource", label: "Resources" },
{ value: "project", label: "Projects" },
{ value: "assignment", label: "Assignments" },
+ { value: "resource_month", label: "Resource Months" },
];
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
@@ -36,10 +76,120 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
const PAGE_SIZE = 50;
+const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
+ "displayName",
+ "eid",
+ "chapter",
+ "countryCode",
+ "countryName",
+ "federalState",
+ "metroCityName",
+ "monthlyBaseWorkingDays",
+ "monthlyEffectiveWorkingDays",
+ "monthlyBaseAvailableHours",
+ "monthlyPublicHolidayWorkdayCount",
+ "monthlyPublicHolidayHoursDeduction",
+ "monthlyAbsenceDayEquivalent",
+ "monthlyAbsenceHoursDeduction",
+ "monthlySahHours",
+ "monthlyChargeabilityTargetPct",
+ "monthlyTargetHours",
+ "monthlyActualBookedHours",
+ "monthlyExpectedBookedHours",
+ "monthlyActualChargeabilityPct",
+ "monthlyExpectedChargeabilityPct",
+ "monthlyUnassignedHours",
+] as const;
+
+const REPORT_BLUEPRINTS: ReportBlueprint[] = [
+ {
+ id: "resource-month-sah-transparency",
+ label: "SAH transparency",
+ description: "Explains how monthly SAH is reduced by holidays and absences per person.",
+ entity: "resource_month",
+ templateName: "Monthly SAH transparency",
+ columns: [
+ "displayName",
+ "eid",
+ "chapter",
+ "countryName",
+ "federalState",
+ "metroCityName",
+ "monthlyBaseWorkingDays",
+ "monthlyEffectiveWorkingDays",
+ "monthlyBaseAvailableHours",
+ "monthlyPublicHolidayWorkdayCount",
+ "monthlyPublicHolidayHoursDeduction",
+ "monthlyAbsenceDayEquivalent",
+ "monthlyAbsenceHoursDeduction",
+ "monthlySahHours",
+ "monthlyChargeabilityTargetPct",
+ "monthlyTargetHours",
+ ],
+ sortBy: "displayName",
+ sortDir: "asc",
+ },
+ {
+ id: "resource-month-chargeability-audit",
+ label: "Chargeability audit",
+ description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
+ entity: "resource_month",
+ templateName: "Monthly chargeability audit",
+ columns: [
+ "displayName",
+ "eid",
+ "chapter",
+ "countryName",
+ "federalState",
+ "metroCityName",
+ "monthlySahHours",
+ "monthlyChargeabilityTargetPct",
+ "monthlyTargetHours",
+ "monthlyActualBookedHours",
+ "monthlyExpectedBookedHours",
+ "monthlyActualChargeabilityPct",
+ "monthlyExpectedChargeabilityPct",
+ "monthlyUnassignedHours",
+ "lcrCents",
+ "currency",
+ ],
+ sortBy: "monthlyActualChargeabilityPct",
+ sortDir: "desc",
+ },
+ {
+ id: "resource-month-location-comparison",
+ label: "Location comparison",
+ description: "Compares holiday impact across country, state and city contexts for the same month.",
+ entity: "resource_month",
+ templateName: "Monthly holiday comparison by location",
+ columns: [
+ "displayName",
+ "chapter",
+ "countryName",
+ "federalState",
+ "metroCityName",
+ "monthlyBaseWorkingDays",
+ "monthlyPublicHolidayWorkdayCount",
+ "monthlyPublicHolidayHoursDeduction",
+ "monthlyAbsenceHoursDeduction",
+ "monthlySahHours",
+ "monthlyActualChargeabilityPct",
+ ],
+ groupBy: "federalState",
+ sortBy: "monthlyPublicHolidayHoursDeduction",
+ sortDir: "desc",
+ },
+];
+
function generateId(): string {
return Math.random().toString(36).slice(2, 10);
}
+function getCurrentPeriodMonth(): string {
+ const now = new Date();
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
+}
+
// ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() {
@@ -50,6 +200,9 @@ export function ReportBuilder() {
const [groupBy, setGroupBy] = useState("");
const [sortBy, setSortBy] = useState("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
+ const [periodMonth, setPeriodMonth] = useState(getCurrentPeriodMonth());
+ const [selectedTemplateId, setSelectedTemplateId] = useState("");
+ const [templateName, setTemplateName] = useState("");
const [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false);
@@ -59,7 +212,21 @@ export function ReportBuilder() {
{ placeholderData: keepPreviousData },
);
- const availableColumns = columnsQuery.data ?? [];
+ const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
+ const templatesQuery = trpc.report.listTemplates.useQuery();
+ const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
+ onSuccess: async (result) => {
+ setSelectedTemplateId(result.id);
+ await templatesQuery.refetch();
+ },
+ });
+ const deleteTemplateMutation = trpc.report.deleteTemplate.useMutation({
+ onSuccess: async () => {
+ setSelectedTemplateId("");
+ setTemplateName("");
+ await templatesQuery.refetch();
+ },
+ });
// Scalar columns (for filter/sort/group — only non-relation columns)
const scalarColumns = useMemo(
@@ -76,12 +243,13 @@ export function ReportBuilder() {
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
+ ...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
};
- }, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
+ }, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
// Fetch report data
const reportQuery = trpc.report.getReportData.useQuery(
@@ -99,6 +267,40 @@ export function ReportBuilder() {
setFilters([]);
setGroupBy("");
setSortBy("");
+ if (newEntity === "resource_month") {
+ setPeriodMonth((current) => current || getCurrentPeriodMonth());
+ }
+ setRunQuery(false);
+ setPage(0);
+ }, []);
+
+ const applyTemplate = useCallback((template: ReportTemplateSummary) => {
+ const config = template.config;
+ setSelectedTemplateId(template.id);
+ setTemplateName(template.name);
+ setEntity(config.entity);
+ setSelectedColumns(new Set(config.columns));
+ setFilters(config.filters.map((filter: Omit) => ({ id: generateId(), ...filter })));
+ setGroupBy(config.groupBy ?? "");
+ setSortBy(config.sortBy ?? "");
+ setSortDir(config.sortDir ?? "asc");
+ setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
+ setRunQuery(false);
+ setPage(0);
+ }, [templatesQuery.data]);
+
+ const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
+ setSelectedTemplateId("");
+ setTemplateName(blueprint.templateName);
+ setEntity(blueprint.entity);
+ setSelectedColumns(new Set(blueprint.columns));
+ setFilters([]);
+ setGroupBy(blueprint.groupBy ?? "");
+ setSortBy(blueprint.sortBy ?? "");
+ setSortDir(blueprint.sortDir ?? "asc");
+ if (blueprint.entity === "resource_month") {
+ setPeriodMonth((current) => current || getCurrentPeriodMonth());
+ }
setRunQuery(false);
setPage(0);
}, []);
@@ -163,6 +365,7 @@ export function ReportBuilder() {
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
+ ...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
@@ -179,7 +382,42 @@ export function ReportBuilder() {
} catch {
// Error handled by tRPC
}
- }, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
+ }, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
+
+ const handleSaveTemplate = useCallback(async () => {
+ if (!templateName.trim() || selectedColumns.size === 0) return;
+
+ await saveTemplateMutation.mutateAsync({
+ ...(selectedTemplateId ? { id: selectedTemplateId } : {}),
+ name: templateName.trim(),
+ config: {
+ entity,
+ columns: Array.from(selectedColumns),
+ filters: filters
+ .filter((filter) => filter.field && filter.value)
+ .map(({ field, op, value }) => ({ field, op, value })),
+ ...(entity === "resource_month" ? { periodMonth } : {}),
+ ...(groupBy ? { groupBy } : {}),
+ ...(sortBy ? { sortBy, sortDir } : {}),
+ },
+ });
+ }, [
+ entity,
+ filters,
+ groupBy,
+ periodMonth,
+ saveTemplateMutation,
+ selectedColumns,
+ selectedTemplateId,
+ sortBy,
+ sortDir,
+ templateName,
+ ]);
+
+ const handleDeleteTemplate = useCallback(async () => {
+ if (!selectedTemplateId) return;
+ await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
+ }, [deleteTemplateMutation, selectedTemplateId]);
// ─── Derived ──────────────────────────────────────────────────────────
@@ -188,6 +426,15 @@ export function ReportBuilder() {
const outputColumns = reportQuery.data?.columns ?? [];
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching;
+ const templates = templatesQuery.data ?? [];
+ const resourceMonthBlueprints = useMemo(
+ () => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity),
+ [entity],
+ );
+ const recommendedColumnSet = useMemo(
+ () => entity === "resource_month" ? new Set(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set(),
+ [entity],
+ );
// Column label lookup
const columnLabelMap = useMemo(() => {
@@ -212,6 +459,61 @@ export function ReportBuilder() {
{/* Config Panel */}
+
+
+
+ Template
+
+ {
+ const nextId = e.target.value;
+ setSelectedTemplateId(nextId);
+ const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
+ if (template) {
+ applyTemplate(template);
+ }
+ }}
+ className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
+ >
+ Unsaved view
+ {templates.map((template) => (
+
+ {template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
+
+ ))}
+
+
+
+
+ Template name
+
+ setTemplateName(e.target.value)}
+ placeholder="Monthly SAH by location"
+ className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
+ />
+
+
void handleSaveTemplate()}
+ disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
+ className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+ {saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
+
+
void handleDeleteTemplate()}
+ disabled={!selectedTemplateId || deleteTemplateMutation.isPending}
+ className="self-end rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300 dark:hover:bg-slate-900"
+ >
+ {deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
+
+
+
{/* Entity Selector */}
@@ -234,6 +536,73 @@ export function ReportBuilder() {
))}
+ {entity === "resource_month" && (
+
+
+
+
+ Period month
+
+ setPeriodMonth(e.target.value)}
+ className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300"
+ />
+
+
+ Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context.
+
+
+
+
+ {resourceMonthBlueprints.map((blueprint) => (
+
applyBlueprint(blueprint)}
+ className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
+ >
+
+ {blueprint.label}
+
+
+ {blueprint.description}
+
+
+ ))}
+
+
+
+
+ Recommended transparency columns
+
+
+ {RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => (
+ toggleColumn(column)}
+ className={clsx(
+ "rounded-full border px-3 py-1 text-xs font-medium transition",
+ selectedColumns.has(column)
+ ? "border-emerald-500 bg-emerald-500 text-white"
+ : "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
+ )}
+ >
+ {columnLabelMap.get(column) ?? column}
+
+ ))}
+
+
+ Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
+
+
+ Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
+
+
+
+ )}
{/* Column Picker */}
@@ -276,6 +645,11 @@ export function ReportBuilder() {
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
/>
{col.label}
+ {recommendedColumnSet.has(col.key) && (
+
+ Rec
+
+ )}
{col.dataType}
@@ -428,13 +802,18 @@ export function ReportBuilder() {
{/* Results Header */}
-
-
Results
- {!isLoading && (
-
- {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
-
- )}
+
+
+
Results
+ {!isLoading && (
+
+ {totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
+
+ )}
+
+
+ CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here.
+
;
+ };
+ ranking?: {
+ rank: number;
+ baseRank: number;
+ tieBreakerApplied: boolean;
+ tieBreakerReason: string | null;
+ model: string;
+ components: Array<{
+ key: string;
+ label: string;
+ score: number;
+ }>;
+ };
}
interface SuggestionCardProps {
@@ -231,10 +288,24 @@ interface SuggestionCardProps {
}
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) {
- const [expanded, setExpanded] = useState(false);
+ const [showDetails, setShowDetails] = useState(false);
+ const [showAssignForm, setShowAssignForm] = useState(false);
+ const locationLabel = suggestion.location?.label
+ || [suggestion.location?.countryCode, suggestion.location?.federalState, suggestion.location?.metroCityName]
+ .filter(Boolean)
+ .join(" / ")
+ || "No location";
+ const capacity = suggestion.capacity;
+ const conflicts = suggestion.conflicts;
+ const conflictCount = conflicts?.count ?? suggestion.availabilityConflicts.length;
+ const remainingHours = capacity?.remainingHours ?? suggestion.remainingHours ?? 0;
+ const remainingHoursPerDay = capacity?.remainingHoursPerDay ?? suggestion.remainingHoursPerDay ?? 0;
+ const baseAvailableHours = capacity?.baseAvailableHours ?? suggestion.baseAvailableHours ?? 0;
+ const effectiveAvailableHours = capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0;
+ const holidayHoursDeduction = capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0;
return (
-
+
@@ -243,15 +314,23 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
{suggestion.resourceName}
{suggestion.eid}
+
{locationLabel}
+
setShowDetails((prev) => !prev)}
+ >
+ {showDetails ? "Hide Details" : "Details"}
+
setExpanded((prev) => !prev)}
+ onClick={() => setShowAssignForm((prev) => !prev)}
>
- {expanded ? "Cancel" : "Assign"}
+ {showAssignForm ? "Close Assign" : "Assign"}
Match Score
@@ -260,13 +339,6 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
-
-
-
-
-
-
-
{suggestion.matchedSkills.map((skill) => (
@@ -280,24 +352,144 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
))}
+
+ = searchCriteria.hoursPerDay ? "good" : "warn"}
+ helper={`${formatHours(remainingHours)} total in window`}
+ />
+
+ 0 ? formatHours(holidayHoursDeduction) : "0h"}
+ tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
+ helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
+ />
+ 0 ? "warn" : "good"}
+ helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"}
+ />
+
+
LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h
Utilization: {Math.round(suggestion.currentUtilization)}%
- {suggestion.availabilityConflicts.length > 0 && (
+ {suggestion.valueScore != null && (
+ Value Score: {suggestion.valueScore}
+ )}
+ {conflictCount > 0 && (
- {suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
+ {conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"}
)}
- {expanded && (
+ {showDetails && (
+
+
+
+
+
+
+
+
+
+
+
Capacity Basis
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ranking Basis
+
+ {suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}
+
+
+ {(suggestion.ranking?.components ?? []).map((component) => (
+
+ ))}
+ {suggestion.ranking?.tieBreakerReason && (
+
+ {suggestion.ranking.tieBreakerReason}
+
+ )}
+
+
+
+
+
Location + Calendar
+
+
+
+
+
+
+
+
+
+
+
+
+
Conflict Check
+
+ Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
+
+
+ {conflictCount === 0 ? (
+
+ No overloaded working days in the selected window.
+
+ ) : (
+
+ {(conflicts?.details ?? []).slice(0, 6).map((item) => (
+
+
+ {item.date}
+ Short by {formatHours(item.shortageHours)}
+
+
+ Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
+
+
+ ))}
+ {conflictCount > 6 && (
+
+ +{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"}
+
+ )}
+
+ )}
+
+
+ )}
+
+ {showAssignForm && (
onAssigned(suggestion.resourceId, suggestion.resourceName)}
onError={onError}
- onCancel={() => setExpanded(false)}
+ onCancel={() => setShowAssignForm(false)}
/>
)}
@@ -499,3 +691,45 @@ function ScoreBar({ label, value, tooltip }: { label: string; value: number; too
);
}
+
+function formatHours(value: number): string {
+ const rounded = Math.round(value * 10) / 10;
+ return `${rounded}h`;
+}
+
+function MetricLine({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function StatCard({
+ label,
+ value,
+ helper,
+ tone = "neutral",
+}: {
+ label: string;
+ value: string;
+ helper?: string;
+ tone?: "neutral" | "good" | "warn";
+}) {
+ const toneClass = tone === "good"
+ ? "border-green-200 bg-green-50/70 dark:border-green-900/40 dark:bg-green-950/20"
+ : tone === "warn"
+ ? "border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20"
+ : "border-gray-200 bg-gray-50/70 dark:border-gray-700 dark:bg-gray-900/40";
+
+ return (
+
+
{label}
+
{value}
+ {helper && (
+
{helper}
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/timeline/AllocationPopover.tsx b/apps/web/src/components/timeline/AllocationPopover.tsx
index 653a665..96244e7 100644
--- a/apps/web/src/components/timeline/AllocationPopover.tsx
+++ b/apps/web/src/components/timeline/AllocationPopover.tsx
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
+import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
@@ -28,9 +29,14 @@ export function AllocationPopover({
anchorX,
anchorY,
}: AllocationPopoverProps) {
- const ref = useRef
(null);
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
+ const { ref, style } = useViewportPopover({
+ anchor: { kind: "point", x: anchorX, y: anchorY },
+ width: 300,
+ estimatedHeight: 360,
+ onClose,
+ });
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
@@ -63,17 +69,6 @@ export function AllocationPopover({
},
});
- // Close on outside click
- useEffect(() => {
- function handleClick(e: MouseEvent) {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- onClose();
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [onClose]);
-
function toDateInput(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -93,18 +88,9 @@ export function AllocationPopover({
});
}
- // Position popover so it stays on screen
- const popoverStyle: React.CSSProperties = {
- position: "fixed",
- left: Math.min(anchorX, window.innerWidth - 320),
- top: Math.min(anchorY + 8, window.innerHeight - 360),
- zIndex: 50,
- width: 300,
- };
-
if (isLoading || !allocation) {
return (
-
+
Loading...
);
@@ -115,7 +101,7 @@ export function AllocationPopover({
return (
{/* Header */}
diff --git a/apps/web/src/components/timeline/DemandPopover.tsx b/apps/web/src/components/timeline/DemandPopover.tsx
index 18ba71c..42fe775 100644
--- a/apps/web/src/components/timeline/DemandPopover.tsx
+++ b/apps/web/src/components/timeline/DemandPopover.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatCents, formatDateLong } from "~/lib/format.js";
+import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface DemandPopoverProps {
demand: TimelineDemandEntry;
@@ -21,17 +21,12 @@ export function DemandPopover({
anchorX,
anchorY,
}: DemandPopoverProps) {
- const ref = useRef
(null);
-
- useEffect(() => {
- function handleClick(e: MouseEvent) {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- onClose();
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [onClose]);
+ const { ref, style } = useViewportPopover({
+ anchor: { kind: "point", x: anchorX, y: anchorY },
+ width: 300,
+ estimatedHeight: 340,
+ onClose,
+ });
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
@@ -41,18 +36,10 @@ export function DemandPopover({
const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * days;
- const popoverStyle: React.CSSProperties = {
- position: "fixed",
- left: Math.min(anchorX, window.innerWidth - 320),
- top: Math.min(anchorY + 8, window.innerHeight - 340),
- zIndex: 50,
- width: 300,
- };
-
return (
{/* Header */}
diff --git a/apps/web/src/components/timeline/NewAllocationPopover.tsx b/apps/web/src/components/timeline/NewAllocationPopover.tsx
index 19876d9..c58e29b 100644
--- a/apps/web/src/components/timeline/NewAllocationPopover.tsx
+++ b/apps/web/src/components/timeline/NewAllocationPopover.tsx
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
+import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js";
interface NewAllocationPopoverProps {
@@ -36,7 +37,12 @@ export function NewAllocationPopover({
onClose,
onCreated,
}: NewAllocationPopoverProps) {
- const ref = useRef
(null);
+ const { ref, style } = useViewportPopover({
+ anchor: { kind: "point", x: anchorX - 10, y: anchorY },
+ width: 320,
+ estimatedHeight: 440,
+ onClose,
+ });
const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
@@ -67,17 +73,6 @@ export function NewAllocationPopover({
},
});
- // Close on outside click
- useEffect(() => {
- function handleClick(e: MouseEvent) {
- if (ref.current && !ref.current.contains(e.target as Node)) {
- onClose();
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [onClose]);
-
function handleCreate() {
if (!selectedProjectId) return;
createMutation.mutate({
@@ -93,13 +88,10 @@ export function NewAllocationPopover({
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
- const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
- const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
-
return (
{/* Header */}
diff --git a/apps/web/src/components/timeline/ResourceHoverCard.tsx b/apps/web/src/components/timeline/ResourceHoverCard.tsx
index cbc89f9..f69539e 100644
--- a/apps/web/src/components/timeline/ResourceHoverCard.tsx
+++ b/apps/web/src/components/timeline/ResourceHoverCard.tsx
@@ -1,9 +1,9 @@
"use client";
-import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { formatCents } from "~/lib/format.js";
import type { SkillEntry } from "@capakraken/shared";
+import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface ResourceHoverCardProps {
resourceId: string;
@@ -12,34 +12,20 @@ interface ResourceHoverCardProps {
}
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
- const ref = useRef
(null);
- const [pos, setPos] = useState({ left: 0, top: 0 });
+ const { ref, style } = useViewportPopover({
+ anchor: { kind: "element", element: anchorEl },
+ width: 280,
+ estimatedHeight: 320,
+ onClose,
+ side: "right",
+ ignoreElements: [anchorEl],
+ });
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
{ id: resourceId },
{ staleTime: 60_000 },
);
- // Position relative to anchor element
- useEffect(() => {
- const rect = anchorEl.getBoundingClientRect();
- setPos({
- left: rect.right + 8,
- top: Math.min(rect.top, window.innerHeight - 320),
- });
- }, [anchorEl]);
-
- // Close on outside click
- useEffect(() => {
- function handleClick(e: MouseEvent) {
- if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
- onClose();
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [onClose, anchorEl]);
-
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
const mainSkills = skills.filter((s) => s.isMainSkill);
const topSkills = skills
@@ -47,19 +33,11 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 6);
- const popoverStyle: React.CSSProperties = {
- position: "fixed",
- left: Math.min(pos.left, window.innerWidth - 300),
- top: pos.top,
- zIndex: 50,
- width: 280,
- };
-
return (
diff --git a/apps/web/src/components/timeline/TimelineContext.tsx b/apps/web/src/components/timeline/TimelineContext.tsx
index c03758c..c54892c 100644
--- a/apps/web/src/components/timeline/TimelineContext.tsx
+++ b/apps/web/src/components/timeline/TimelineContext.tsx
@@ -113,6 +113,16 @@ export type VacationEntry = {
halfDayPart?: string | null;
};
+export type HolidayOverlayEntry = {
+ id: string;
+ resourceId: string;
+ type: string;
+ status: string;
+ startDate: Date | string;
+ endDate: Date | string;
+ note?: string | null;
+};
+
// ─── Context shape ──────────────────────────────────────────────────────────
export interface TimelineContextValue {
@@ -314,9 +324,43 @@ export function TimelineProvider({
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
+ const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
+ {
+ startDate: viewStart,
+ endDate: viewEnd,
+ ...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
+ ...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
+ ...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
+ ...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
+ ...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
+ },
+ { placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
+ );
+
const vacationsByResource = useMemo(() => {
const map = new Map
();
- for (const vacation of vacationEntries as VacationEntry[]) {
+ const mergedEntries = [...(vacationEntries as VacationEntry[])];
+ const existingKeys = new Set(
+ mergedEntries.map((vacation) => {
+ const start = new Date(vacation.startDate).toISOString().slice(0, 10);
+ const end = new Date(vacation.endDate).toISOString().slice(0, 10);
+ return `${vacation.resourceId}:${vacation.type}:${start}:${end}`;
+ }),
+ );
+
+ for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) {
+ const start = new Date(holiday.startDate).toISOString().slice(0, 10);
+ const end = new Date(holiday.endDate).toISOString().slice(0, 10);
+ const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`;
+ if (existingKeys.has(key)) {
+ continue;
+ }
+
+ existingKeys.add(key);
+ mergedEntries.push(holiday as VacationEntry);
+ }
+
+ for (const vacation of mergedEntries) {
const existing = map.get(vacation.resourceId);
if (existing) {
existing.push(vacation);
@@ -325,7 +369,7 @@ export function TimelineProvider({
}
}
return map;
- }, [vacationEntries]);
+ }, [holidayOverlayEntries, vacationEntries]);
// When EID filter is active, explicitly fetch those resources.
const { data: eidFilterData } = trpc.resource.list.useQuery(
diff --git a/apps/web/src/components/timeline/TimelineFilter.tsx b/apps/web/src/components/timeline/TimelineFilter.tsx
index 63ab1c4..13eaf15 100644
--- a/apps/web/src/components/timeline/TimelineFilter.tsx
+++ b/apps/web/src/components/timeline/TimelineFilter.tsx
@@ -2,7 +2,8 @@
import { clsx } from "clsx";
import { createPortal } from "react-dom";
-import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
+import { useRef, useState, type RefObject } from "react";
+import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters {
@@ -159,55 +160,12 @@ export function TimelineFilter({
isOpen,
onClose,
}: TimelineFilterProps) {
- const panelRef = useRef(null);
- const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 });
-
- const updatePanelPosition = useCallback(() => {
- const trigger = anchorRef.current;
- if (!trigger) return;
-
- const rect = trigger.getBoundingClientRect();
- const panelWidth = panelRef.current?.offsetWidth ?? 320;
- const viewportPadding = 16;
- const maxLeft = window.innerWidth - panelWidth - viewportPadding;
-
- setPanelPosition({
- top: rect.bottom + 8,
- left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
- });
- }, [anchorRef]);
-
- useEffect(() => {
- if (!isOpen) return;
-
- updatePanelPosition();
- const rafId = window.requestAnimationFrame(updatePanelPosition);
- const handlePointerDown = (event: MouseEvent) => {
- const target = event.target as Node;
- if (anchorRef.current?.contains(target) || panelRef.current?.contains(target)) {
- return;
- }
- onClose();
- };
- const handleEscape = (event: KeyboardEvent) => {
- if (event.key === "Escape") {
- onClose();
- }
- };
-
- window.addEventListener("resize", updatePanelPosition);
- window.addEventListener("scroll", updatePanelPosition, true);
- window.addEventListener("mousedown", handlePointerDown);
- window.addEventListener("keydown", handleEscape);
-
- return () => {
- window.cancelAnimationFrame(rafId);
- window.removeEventListener("resize", updatePanelPosition);
- window.removeEventListener("scroll", updatePanelPosition, true);
- window.removeEventListener("mousedown", handlePointerDown);
- window.removeEventListener("keydown", handleEscape);
- };
- }, [anchorRef, isOpen, onClose, updatePanelPosition]);
+ const { panelRef, position } = useAnchoredOverlay({
+ open: isOpen,
+ onClose,
+ align: "end",
+ triggerRef: anchorRef,
+ });
if (!isOpen) return null;
@@ -221,7 +179,7 @@ export function TimelineFilter({
return createPortal(
diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx
index e6caa68..d67515f 100644
--- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx
+++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx
@@ -188,8 +188,10 @@ function TimelineProjectPanelInner({
} | null>(null);
const heatmapTooltipRef = useRef(null);
const vacationTooltipRef = useRef(null);
+ const demandTooltipRef = useRef(null);
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
+ const demandTooltipPosRef = useRef({ left: 0, top: 0 });
const [heatmapHover, setHeatmapHover] = useState<{
date: Date;
@@ -206,6 +208,22 @@ function TimelineProjectPanelInner({
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
}>(null);
+ const [demandHover, setDemandHover] = useState(null);
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
const dateIndexByTime = new Map();
@@ -472,6 +490,7 @@ function TimelineProjectPanelInner({
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
+ date.setHours(0, 0, 0, 0);
const time = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
@@ -507,18 +526,58 @@ function TimelineProjectPanelInner({
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
+ const shouldClearDemand = demandHover !== null;
lastHeatmapDayRef.current = -1;
lastHeatmapResourceRef.current = null;
hoveredVacationKeyRef.current = null;
- if (shouldClearHeatmap || shouldClearVacation) {
+ if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
startTransition(() => {
if (shouldClearHeatmap) setHeatmapHover(null);
if (shouldClearVacation) setVacationHover(null);
+ if (shouldClearDemand) setDemandHover(null);
});
}
- }, []);
+ }, [demandHover]);
+
+ const handleDemandHoverMove = useCallback(
+ (e: React.MouseEvent, demand: TimelineDemandEntry) => {
+ demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 };
+ if (demandTooltipRef.current) {
+ demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`;
+ demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`;
+ }
+
+ const startDate = new Date(demand.startDate);
+ const endDate = new Date(demand.endDate);
+ const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
+
+ startTransition(() => {
+ setDemandHover({
+ roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand",
+ roleColor: demand.roleEntity?.color ?? "#f59e0b",
+ projectName: demand.project.name,
+ projectShortCode: demand.project.shortCode,
+ requestedHeadcount: demand.requestedHeadcount,
+ unfilledHeadcount: demand.unfilledHeadcount,
+ startDate: demand.startDate,
+ endDate: demand.endDate,
+ hoursPerDay: demand.hoursPerDay,
+ totalHours: demand.hoursPerDay * days,
+ percentage: demand.percentage,
+ status: demand.status,
+ ...(demand.dailyCostCents > 0
+ ? {
+ totalCostCents: demand.dailyCostCents * days,
+ dailyCostCents: demand.dailyCostCents,
+ }
+ : {}),
+ });
+ });
+ },
+ [],
+ );
useEffect(
() => () => {
@@ -672,6 +731,8 @@ function TimelineProjectPanelInner({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
+ handleDemandHoverMove,
+ clearHoverTooltips,
multiSelectState,
allocDragState,
)
@@ -699,6 +760,9 @@ function TimelineProjectPanelInner({
);
@@ -852,6 +919,8 @@ function renderOpenDemandRow(
anchorX: number,
anchorY: number,
) => void,
+ onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
+ onClearHoverTooltips: () => void,
multiSelectState: MultiSelectState,
allocDragState: AllocDragState,
) {
@@ -889,6 +958,7 @@ function renderOpenDemandRow(
{rowGridLines}
@@ -962,7 +1032,6 @@ function renderOpenDemandRow(
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
- title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} – ${formatDateLong(allocEnd)}`}
style={{
left: left + 2,
width: width - 4,
@@ -986,6 +1055,7 @@ function renderOpenDemandRow(
e.clientY,
);
}}
+ onMouseMove={(e) => onDemandHoverMove(e, alloc)}
>
{/* Left resize handle */}
(null);
- const panelRef = useRef
(null);
- const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
-
- const updatePanelPosition = useCallback(() => {
- const trigger = dropdownRef.current;
- if (!trigger) return;
-
- const rect = trigger.getBoundingClientRect();
- const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
- const viewportPadding = 16;
- const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
-
- setPanelPosition({
- top: rect.bottom + 8,
- left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
- minWidth: rect.width,
- });
- }, []);
-
- useEffect(() => {
- function handlePointerDown(event: MouseEvent) {
- const target = event.target as Node;
- if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
- return;
- }
- setIsOpen(false);
- }
-
- document.addEventListener("mousedown", handlePointerDown);
- return () => document.removeEventListener("mousedown", handlePointerDown);
- }, []);
-
- useEffect(() => {
- if (!isOpen) return;
-
- updatePanelPosition();
- const rafId = window.requestAnimationFrame(updatePanelPosition);
- const handleEscape = (event: KeyboardEvent) => {
- if (event.key === "Escape") {
- setIsOpen(false);
- }
- };
-
- window.addEventListener("resize", updatePanelPosition);
- window.addEventListener("scroll", updatePanelPosition, true);
- window.addEventListener("keydown", handleEscape);
-
- return () => {
- window.cancelAnimationFrame(rafId);
- window.removeEventListener("resize", updatePanelPosition);
- window.removeEventListener("scroll", updatePanelPosition, true);
- window.removeEventListener("keydown", handleEscape);
- };
- }, [isOpen, updatePanelPosition]);
+ const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay({
+ open: isOpen,
+ onClose: () => setIsOpen(false),
+ matchTriggerWidth: true,
+ });
return (
-
+
setIsOpen((current) => !current)}
+ onClick={() => {
+ const nextOpen = !isOpen;
+ handleOpenChange(nextOpen);
+ setIsOpen(nextOpen);
+ }}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
{label}
@@ -95,9 +50,9 @@ function TimelineFilterDropdown({
ref={panelRef}
style={{
position: "fixed",
- top: panelPosition.top,
- left: panelPosition.left,
- minWidth: panelPosition.minWidth,
+ top: position.top,
+ left: position.left,
+ minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx
index 4e3a175..7a4c60d 100644
--- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx
+++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx
@@ -359,6 +359,7 @@ function TimelineResourcePanelInner({
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
+ date.setHours(0, 0, 0, 0);
const t = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
@@ -494,6 +495,10 @@ function TimelineResourcePanelInner({
{/* Row canvas */}
{
@@ -542,10 +547,11 @@ function TimelineResourcePanelInner({
onAllocationContextMenu,
multiSelectState,
)}
- {renderVacationBlocks(
- vacationBlocksByResource.get(resource.id) ?? [],
- rowHeight,
- )}
+ {filters.showVacations &&
+ renderVacationBlocks(
+ vacationBlocksByResource.get(resource.id) ?? [],
+ rowHeight,
+ )}
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
{displayMode === "heatmap" &&
renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
diff --git a/apps/web/src/components/timeline/TimelineTooltip.tsx b/apps/web/src/components/timeline/TimelineTooltip.tsx
index 3281755..54abdce 100644
--- a/apps/web/src/components/timeline/TimelineTooltip.tsx
+++ b/apps/web/src/components/timeline/TimelineTooltip.tsx
@@ -1,6 +1,13 @@
"use client";
-import { formatDateLong } from "~/lib/format.js";
+import { formatCents, formatDateLong } from "~/lib/format.js";
+
+function getVacationTitle(vacation: VacationHoverData): string {
+ if (vacation.type === "PUBLIC_HOLIDAY" && vacation.note) {
+ return vacation.note;
+ }
+ return vacation.type.replaceAll("_", " ");
+}
export type HeatmapHoverData = {
date: Date;
@@ -30,6 +37,23 @@ export type VacationHoverData = {
approvedAt?: Date | string | null;
};
+export type DemandHoverData = {
+ roleName: string;
+ roleColor: string;
+ projectName: string;
+ projectShortCode?: string | null;
+ requestedHeadcount: number;
+ unfilledHeadcount: number;
+ startDate: Date | string;
+ endDate: Date | string;
+ hoursPerDay: number;
+ totalHours: number;
+ percentage?: number;
+ status?: string;
+ totalCostCents?: number;
+ dailyCostCents?: number;
+};
+
interface TimelineTooltipProps {
heatmapTooltipRef: React.RefObject
;
heatmapTooltipPos: { left: number; top: number };
@@ -37,6 +61,9 @@ interface TimelineTooltipProps {
vacationTooltipPos: { left: number; top: number };
heatmapHover: HeatmapHoverData | null;
vacationHover: VacationHoverData | null;
+ demandTooltipRef?: React.RefObject;
+ demandTooltipPos?: { left: number; top: number };
+ demandHover?: DemandHoverData | null;
}
export function TimelineTooltip({
@@ -46,7 +73,87 @@ export function TimelineTooltip({
vacationTooltipPos,
heatmapHover,
vacationHover,
+ demandTooltipRef,
+ demandTooltipPos,
+ demandHover,
}: TimelineTooltipProps) {
+ const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
+
+ if (demandHover && demandTooltipRef && demandTooltipPos) {
+ return (
+
+
+
+
+
+ {demandHover.roleName}
+
+
+ {demandHover.projectShortCode ? `${demandHover.projectShortCode} · ` : ""}
+ {demandHover.projectName}
+
+
+ {demandHover.status ? (
+
+ {demandHover.status}
+
+ ) : null}
+
+
+
+
+
Requested
+
{demandHover.requestedHeadcount}
+
+
+
Open
+
{demandHover.unfilledHeadcount}
+
+
+
Range
+
+ {formatDateLong(demandHover.startDate)} to {formatDateLong(demandHover.endDate)}
+
+
+
+
Load
+
+ {demandHover.hoursPerDay}h/day · {demandHover.totalHours}h
+
+
+ {typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? (
+
+
Allocation
+
{demandHover.percentage}%
+
+ ) : null}
+ {typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
+
+
Cost
+
+ {formatCents(demandHover.totalCostCents)} EUR
+ {typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
+ ? ` · ${formatCents(demandHover.dailyCostCents)}/d`
+ : ""}
+
+
+ ) : null}
+
+
+ );
+ }
+
// When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) {
return (
@@ -114,14 +221,12 @@ export function TimelineTooltip({
-
- {vacationHover.type.replaceAll("_", " ")}
-
+ {vacationTitle}
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
- {vacationHover.note ? (
+ {vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
{vacationHover.note}
) : null}
@@ -200,11 +305,11 @@ export function TimelineTooltip({
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
- {vacationHover.type.replaceAll("_", " ")}
+ {vacationTitle}
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
- {vacationHover.note ? (
+ {vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
{vacationHover.note}
) : null}
diff --git a/apps/web/src/components/timeline/renderHelpers.tsx b/apps/web/src/components/timeline/renderHelpers.tsx
index 23ed8fe..1988f9d 100644
--- a/apps/web/src/components/timeline/renderHelpers.tsx
+++ b/apps/web/src/components/timeline/renderHelpers.tsx
@@ -35,6 +35,11 @@ export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: num
return (
(null);
-
- useEffect(() => {
- if (!open) return;
- function handleClick(e: MouseEvent) {
- if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
- setOpen(false);
- }
- }
- document.addEventListener("mousedown", handleClick);
- return () => document.removeEventListener("mousedown", handleClick);
- }, [open]);
+ const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay
({
+ open,
+ onClose: () => setOpen(false),
+ align: "end",
+ });
const dragKey = useRef(null);
@@ -59,11 +54,20 @@ export function ColumnTogglePanel({
const builtins = allColumns.filter((c) => !c.isCustom);
const customs = allColumns.filter((c) => c.isCustom);
+ const handleToggleOpen = useCallback(() => {
+ setOpen((current) => {
+ const nextOpen = !current;
+ handleOpenChange(nextOpen);
+ return nextOpen;
+ });
+ }, [handleOpenChange]);
+
return (
-
+
setOpen((o) => !o)}
+ onClick={handleToggleOpen}
title="Toggle columns"
className={`p-1.5 rounded-lg border text-sm transition-colors ${
open
@@ -80,83 +84,107 @@ export function ColumnTogglePanel({
- {open && (
-
-
- Columns
-
- Reset
-
-
+ {open &&
+ createPortal(
+
+
+
+ Columns
+
+
+ Reset
+
+
-
- {builtins.map((col) => {
- const isVisible = visibleKeys.includes(col.key);
- return (
-
{ dragKey.current = col.key; }}
- onDragOver={(e) => { e.preventDefault(); }}
- onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
- className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${
- !col.hideable ? "opacity-50" : "cursor-grab"
- }`}
- >
- {col.hideable && isVisible && (
- ⠿
- )}
-
- toggle(col.key)}
- disabled={!col.hideable}
- className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
- />
- {col.label}
-
-
- );
- })}
+
+ {builtins.map((col) => {
+ const isVisible = visibleKeys.includes(col.key);
+ return (
+
{
+ dragKey.current = col.key;
+ }}
+ onDragOver={(event) => {
+ event.preventDefault();
+ }}
+ onDrop={() => {
+ if (dragKey.current) reorder(dragKey.current, col.key);
+ dragKey.current = null;
+ }}
+ className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 ${
+ !col.hideable ? "opacity-50" : "cursor-grab"
+ }`}
+ >
+ {col.hideable && isVisible && (
+ ⠿
+ )}
+
+ toggle(col.key)}
+ disabled={!col.hideable}
+ className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
+ />
+ {col.label}
+
+
+ );
+ })}
- {customs.length > 0 && (
- <>
-
-
Custom Fields
- {customs.map((col) => {
- const isVisible = visibleKeys.includes(col.key);
- return (
-
{ dragKey.current = col.key; }}
- onDragOver={(e) => { e.preventDefault(); }}
- onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
- className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab"
- >
- {isVisible && ⠿ }
-
- toggle(col.key)}
- className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
- />
- {col.label}
-
-
- );
- })}
- >
- )}
-
-
- )}
+ {customs.length > 0 && (
+ <>
+
+
Custom Fields
+ {customs.map((col) => {
+ const isVisible = visibleKeys.includes(col.key);
+ return (
+
{
+ dragKey.current = col.key;
+ }}
+ onDragOver={(event) => {
+ event.preventDefault();
+ }}
+ onDrop={() => {
+ if (dragKey.current) reorder(dragKey.current, col.key);
+ dragKey.current = null;
+ }}
+ className="flex cursor-grab items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800"
+ >
+ {isVisible && ⠿ }
+
+ toggle(col.key)}
+ className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
+ />
+
+ {col.label}
+
+
+
+ );
+ })}
+ >
+ )}
+
+
,
+ document.body,
+ )}
);
}
diff --git a/apps/web/src/components/vacations/HolidayCalendarEditor.tsx b/apps/web/src/components/vacations/HolidayCalendarEditor.tsx
new file mode 100644
index 0000000..ed6db9a
--- /dev/null
+++ b/apps/web/src/components/vacations/HolidayCalendarEditor.tsx
@@ -0,0 +1,865 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { trpc } from "~/lib/trpc/client.js";
+
+type ScopeType = "COUNTRY" | "STATE" | "CITY";
+
+type CalendarRow = {
+ id: string;
+ name: string;
+ scopeType: ScopeType;
+ stateCode: string | null;
+ metroCityId: string | null;
+ isActive: boolean;
+ priority: number;
+ country: { id: string; code: string; name: string };
+ metroCity: { id: string; name: string } | null;
+ entries: Array<{
+ id: string;
+ date: string | Date;
+ name: string;
+ isRecurringAnnual: boolean;
+ source: string | null;
+ }>;
+ _count?: { entries: number };
+};
+
+type CountryRow = {
+ id: string;
+ code: string;
+ name: string;
+ metroCities: { id: string; name: string }[];
+};
+
+const SCOPE_LABELS: Record
= {
+ COUNTRY: "Land",
+ STATE: "Bundesland/Region",
+ CITY: "Stadt",
+};
+
+function formatDate(value: string | Date): string {
+ return new Date(value).toISOString().slice(0, 10);
+}
+
+export function HolidayCalendarEditor() {
+ const utils = trpc.useUtils();
+ const [selectedCalendarId, setSelectedCalendarId] = useState(null);
+ const [scopeType, setScopeType] = useState("COUNTRY");
+ const [countryId, setCountryId] = useState("");
+ const [stateCode, setStateCode] = useState("");
+ const [metroCityId, setMetroCityId] = useState("");
+ const [name, setName] = useState("");
+ const [priority, setPriority] = useState(0);
+ const [entryDate, setEntryDate] = useState("");
+ const [entryName, setEntryName] = useState("");
+ const [entryRecurring, setEntryRecurring] = useState(false);
+ const [entrySource, setEntrySource] = useState("");
+ const [previewYear, setPreviewYear] = useState(new Date().getFullYear());
+ const [error, setError] = useState(null);
+ const [calendarDraft, setCalendarDraft] = useState({
+ name: "",
+ priority: 0,
+ stateCode: "",
+ metroCityId: "",
+ isActive: true,
+ });
+ const [editingEntryId, setEditingEntryId] = useState(null);
+ const [entryDraft, setEntryDraft] = useState({
+ date: "",
+ name: "",
+ isRecurringAnnual: false,
+ source: "",
+ });
+
+ const { data: countries } = trpc.country.list.useQuery();
+ const { data: calendars } = trpc.holidayCalendar.listCalendars.useQuery({ includeInactive: true });
+
+ const selectedCalendar = ((calendars ?? []) as unknown as CalendarRow[]).find((calendar) => calendar.id === selectedCalendarId) ?? null;
+
+ const selectedCountry = useMemo(() => {
+ const rows = (countries ?? []) as unknown as CountryRow[];
+ return rows.find((country) => country.id === countryId) ?? null;
+ }, [countries, countryId]);
+
+ const selectedCalendarCountry = useMemo(() => {
+ const rows = (countries ?? []) as unknown as CountryRow[];
+ return rows.find((country) => country.id === selectedCalendar?.country.id) ?? null;
+ }, [countries, selectedCalendar]);
+
+ const previewQuery = trpc.holidayCalendar.previewResolvedHolidays.useQuery(
+ {
+ countryId: selectedCalendar?.country.id ?? countryId,
+ year: previewYear,
+ ...(selectedCalendar?.stateCode ? { stateCode: selectedCalendar.stateCode } : {}),
+ ...(selectedCalendar?.metroCityId ? { metroCityId: selectedCalendar.metroCityId } : {}),
+ },
+ {
+ enabled: Boolean(selectedCalendar?.country.id ?? countryId),
+ staleTime: 30_000,
+ },
+ );
+
+ const invalidate = async () => {
+ await Promise.all([
+ utils.holidayCalendar.listCalendars.invalidate(),
+ utils.holidayCalendar.getCalendarById.invalidate(),
+ utils.holidayCalendar.previewResolvedHolidays.invalidate(),
+ ]);
+ };
+
+ const createCalendar = trpc.holidayCalendar.createCalendar.useMutation({
+ onSuccess: async (calendar) => {
+ await invalidate();
+ setSelectedCalendarId(calendar.id);
+ setName("");
+ setStateCode("");
+ setMetroCityId("");
+ setPriority(0);
+ setError(null);
+ },
+ onError: (mutationError) => setError(mutationError.message),
+ });
+
+ const updateCalendar = trpc.holidayCalendar.updateCalendar.useMutation({
+ onSuccess: async () => {
+ await invalidate();
+ setError(null);
+ },
+ onError: (mutationError) => setError(mutationError.message),
+ });
+
+ const deleteCalendar = trpc.holidayCalendar.deleteCalendar.useMutation({
+ onSuccess: async () => {
+ await invalidate();
+ setSelectedCalendarId(null);
+ setError(null);
+ },
+ onError: (mutationError) => setError(mutationError.message),
+ });
+
+ const createEntry = trpc.holidayCalendar.createEntry.useMutation({
+ onSuccess: async () => {
+ await invalidate();
+ setEntryDate("");
+ setEntryName("");
+ setEntryRecurring(false);
+ setEntrySource("");
+ setError(null);
+ },
+ onError: (mutationError) => setError(mutationError.message),
+ });
+
+ const updateEntry = trpc.holidayCalendar.updateEntry.useMutation({
+ onSuccess: async () => {
+ await invalidate();
+ setEditingEntryId(null);
+ setError(null);
+ },
+ onError: (mutationError) => setError(mutationError.message),
+ });
+
+ const deleteEntry = trpc.holidayCalendar.deleteEntry.useMutation({
+ onSuccess: async () => {
+ await invalidate();
+ setError(null);
+ },
+ onError: (mutationError) => setError(mutationError.message),
+ });
+
+ const countryRows = (countries ?? []) as unknown as CountryRow[];
+ const calendarRows = (calendars ?? []) as unknown as CalendarRow[];
+ const isCreateScopeValid = scopeType === "COUNTRY"
+ ? Boolean(countryId && name.trim())
+ : scopeType === "STATE"
+ ? Boolean(countryId && name.trim() && stateCode.trim())
+ : Boolean(countryId && name.trim() && metroCityId);
+ const isCalendarDirty = selectedCalendar !== null && (
+ calendarDraft.name !== selectedCalendar.name
+ || calendarDraft.priority !== selectedCalendar.priority
+ || calendarDraft.isActive !== selectedCalendar.isActive
+ || calendarDraft.stateCode !== (selectedCalendar.stateCode ?? "")
+ || calendarDraft.metroCityId !== (selectedCalendar.metroCityId ?? "")
+ );
+
+ useEffect(() => {
+ if (!selectedCalendar) {
+ setCalendarDraft({
+ name: "",
+ priority: 0,
+ stateCode: "",
+ metroCityId: "",
+ isActive: true,
+ });
+ return;
+ }
+
+ setCalendarDraft({
+ name: selectedCalendar.name,
+ priority: selectedCalendar.priority,
+ stateCode: selectedCalendar.stateCode ?? "",
+ metroCityId: selectedCalendar.metroCityId ?? "",
+ isActive: selectedCalendar.isActive,
+ });
+ setEditingEntryId(null);
+ }, [selectedCalendar]);
+
+ function handleCreateCalendar(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ if (!isCreateScopeValid) {
+ setError("Bitte alle Pflichtfelder fuer den gewaehlten Scope ausfuellen.");
+ return;
+ }
+ createCalendar.mutate({
+ name: name.trim(),
+ scopeType,
+ countryId,
+ ...(scopeType === "STATE" && stateCode.trim() ? { stateCode: stateCode.trim().toUpperCase() } : {}),
+ ...(scopeType === "CITY" && metroCityId ? { metroCityId } : {}),
+ priority,
+ isActive: true,
+ });
+ }
+
+ function handleAddEntry(e: React.FormEvent) {
+ e.preventDefault();
+ if (!selectedCalendarId) return;
+ if (!entryDate || !entryName.trim()) {
+ setError("Datum und Feiertagsname sind erforderlich.");
+ return;
+ }
+ createEntry.mutate({
+ holidayCalendarId: selectedCalendarId,
+ date: new Date(`${entryDate}T00:00:00.000Z`),
+ name: entryName.trim(),
+ isRecurringAnnual: entryRecurring,
+ ...(entrySource.trim() ? { source: entrySource.trim() } : {}),
+ });
+ }
+
+ function resetCalendarDraft() {
+ if (!selectedCalendar) {
+ return;
+ }
+
+ setCalendarDraft({
+ name: selectedCalendar.name,
+ priority: selectedCalendar.priority,
+ stateCode: selectedCalendar.stateCode ?? "",
+ metroCityId: selectedCalendar.metroCityId ?? "",
+ isActive: selectedCalendar.isActive,
+ });
+ }
+
+ function handleUpdateCalendar(e: React.FormEvent) {
+ e.preventDefault();
+ if (!selectedCalendar) {
+ return;
+ }
+
+ setError(null);
+ const normalizedStateCode = calendarDraft.stateCode.trim().toUpperCase();
+ if (selectedCalendar.scopeType === "STATE" && !normalizedStateCode) {
+ setError("State-Kalender benoetigen einen Regionscode.");
+ return;
+ }
+ if (selectedCalendar.scopeType === "CITY" && !calendarDraft.metroCityId) {
+ setError("City-Kalender benoetigen eine Stadtzuordnung.");
+ return;
+ }
+
+ updateCalendar.mutate({
+ id: selectedCalendar.id,
+ data: {
+ name: calendarDraft.name.trim(),
+ priority: calendarDraft.priority,
+ isActive: calendarDraft.isActive,
+ ...(selectedCalendar.scopeType === "STATE" ? { stateCode: normalizedStateCode } : {}),
+ ...(selectedCalendar.scopeType === "CITY" ? { metroCityId: calendarDraft.metroCityId } : {}),
+ },
+ });
+ }
+
+ function startEditingEntry(entry: CalendarRow["entries"][number]) {
+ setEditingEntryId(entry.id);
+ setEntryDraft({
+ date: formatDate(entry.date),
+ name: entry.name,
+ isRecurringAnnual: entry.isRecurringAnnual,
+ source: entry.source ?? "",
+ });
+ }
+
+ function handleUpdateEntry(entryId: string) {
+ if (!entryDraft.date || !entryDraft.name.trim()) {
+ setError("Ein Feiertagseintrag braucht Datum und Name.");
+ return;
+ }
+
+ setError(null);
+ updateEntry.mutate({
+ id: entryId,
+ data: {
+ date: new Date(`${entryDraft.date}T00:00:00.000Z`),
+ name: entryDraft.name.trim(),
+ isRecurringAnnual: entryDraft.isRecurringAnnual,
+ source: entryDraft.source.trim() || null,
+ },
+ });
+ }
+
+ function handleDeleteCalendar(calendar: CalendarRow) {
+ if (deleteCalendar.isPending) {
+ return;
+ }
+
+ const confirmed = globalThis.confirm(
+ `Feiertagskalender "${calendar.name}" wirklich loeschen? Alle Eintraege gehen dabei verloren.`,
+ );
+
+ if (!confirmed) {
+ return;
+ }
+
+ setError(null);
+ deleteCalendar.mutate({ id: calendar.id });
+ }
+
+ function handleDeleteEntry(entry: CalendarRow["entries"][number]) {
+ if (deleteEntry.isPending) {
+ return;
+ }
+
+ const confirmed = globalThis.confirm(
+ `Feiertag "${entry.name}" am ${formatDate(entry.date)} wirklich entfernen?`,
+ );
+
+ if (!confirmed) {
+ return;
+ }
+
+ setError(null);
+ deleteEntry.mutate({ id: entry.id });
+ }
+
+ return (
+
+
+
Holiday Calendar Editor
+
+ Pflege Feiertagskalender pro Land, Bundesland/Region oder Stadt. Die Vorschau zeigt den effektiv aufgeloesten Kalender fuer den gewaelten Scope.
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+
+
+ Kalender
+ Scope
+ Zuordnung
+ Eintraege
+
+
+
+ {calendarRows.length === 0 && (
+
+ Noch keine Feiertagskalender vorhanden.
+
+ )}
+ {calendarRows.map((calendar) => (
+ setSelectedCalendarId(calendar.id)}
+ >
+
+ {calendar.name}
+ {calendar.country.name}
+
+ {SCOPE_LABELS[calendar.scopeType]}
+
+ {calendar.scopeType === "COUNTRY" && calendar.country.code}
+ {calendar.scopeType === "STATE" && calendar.stateCode}
+ {calendar.scopeType === "CITY" && calendar.metroCity?.name}
+
+ {calendar._count?.entries ?? calendar.entries.length}
+
+ ))}
+
+
+
+
+ {selectedCalendar && (
+
+
+
+
+
{selectedCalendar.name}
+
+ {SCOPE_LABELS[selectedCalendar.scopeType]} · {selectedCalendar.country.name}
+ {selectedCalendar.stateCode ? ` · ${selectedCalendar.stateCode}` : ""}
+ {selectedCalendar.metroCity?.name ? ` · ${selectedCalendar.metroCity.name}` : ""}
+
+
+
+ updateCalendar.mutate({
+ id: selectedCalendar.id,
+ data: { isActive: !selectedCalendar.isActive },
+ })}
+ className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
+ >
+ {selectedCalendar.isActive ? "Deaktivieren" : "Aktivieren"}
+
+ handleDeleteCalendar(selectedCalendar)}
+ disabled={deleteCalendar.isPending}
+ className="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950/30"
+ >
+ Loeschen
+
+
+
+
+
+
+
+
+
+ setEntryRecurring(e.target.checked)}
+ className="rounded border-gray-300 dark:border-gray-600"
+ />
+ Jaehrlich wiederkehrend
+
+
+
+
+
+
+
+
+
Vorschau
+
Effektiv aufgeloeste Feiertage fuer den gewaehlten Scope.
+
+
setPreviewYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
+ className="w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
+ />
+
+
+
+
+
+
+ Datum
+ Name
+ Quelle
+
+
+
+ {(previewQuery.data ?? []).length === 0 && (
+
+
+ {previewQuery.isLoading ? "Laedt Vorschau..." : "Keine Feiertage fuer diese Auswahl vorhanden."}
+
+
+ )}
+ {(previewQuery.data ?? []).map((entry) => (
+
+ {entry.date}
+ {entry.name}
+ {entry.calendarName}
+
+ ))}
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/vacations/VacationClient.tsx b/apps/web/src/components/vacations/VacationClient.tsx
index 66c3879..c053104 100644
--- a/apps/web/src/components/vacations/VacationClient.tsx
+++ b/apps/web/src/components/vacations/VacationClient.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback } from "react";
+import Link from "next/link";
import { VacationStatus, VacationType } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { VacationModal } from "./VacationModal.js";
@@ -137,6 +138,13 @@ export function VacationClient() {
Vacations
Manage vacation requests and approvals
+
+ Regional public holidays are maintained in{" "}
+
+ Holiday Calendars
+
+ .
+
type !== VacationType.PUBLIC_HOLIDAY);
+
+const HOLIDAY_SOURCE_LABELS = {
+ CALENDAR: "Calendar",
+ LEGACY_PUBLIC_HOLIDAY: "Legacy import",
+ CALENDAR_AND_LEGACY: "Calendar + legacy",
+} as const;
+
+type VacationPreviewData = {
+ requestedDays: number;
+ effectiveDays: number;
+ deductedDays: number;
+ publicHolidayDates: string[];
+ holidayDetails: Array<{
+ date: string;
+ source: string;
+ }>;
+ holidayContext: {
+ countryCode: string | null;
+ countryName: string | null;
+ federalState: string | null;
+ metroCityName: string | null;
+ sources: {
+ hasCalendarHolidays: boolean;
+ hasLegacyPublicHolidayEntries: boolean;
+ };
+ };
+};
interface VacationModalProps {
resourceId?: string;
@@ -17,13 +45,34 @@ interface VacationModalProps {
onSuccess: () => void;
}
-function toDateInputValue(date: Date | string | null | undefined): string {
- if (!date) return "";
- const d = typeof date === "string" ? new Date(date) : date;
- const y = d.getFullYear();
- const m = String(d.getMonth() + 1).padStart(2, "0");
- const day = String(d.getDate()).padStart(2, "0");
- return `${y}-${m}-${day}`;
+function toUtcInputDate(value: string): Date {
+ return new Date(`${value}T00:00:00.000Z`);
+}
+
+function buildHolidayBasisLabel(preview: VacationPreviewData): string[] {
+ const parts = [];
+
+ if (preview.holidayContext.countryName || preview.holidayContext.countryCode) {
+ parts.push(preview.holidayContext.countryName ?? preview.holidayContext.countryCode ?? "");
+ }
+
+ if (preview.holidayContext.federalState) {
+ parts.push(preview.holidayContext.federalState);
+ }
+
+ if (preview.holidayContext.metroCityName) {
+ parts.push(preview.holidayContext.metroCityName);
+ }
+
+ return parts.filter(Boolean);
+}
+
+function getHolidaySourceLabel(source: string): string {
+ if (source in HOLIDAY_SOURCE_LABELS) {
+ return HOLIDAY_SOURCE_LABELS[source as keyof typeof HOLIDAY_SOURCE_LABELS];
+ }
+
+ return source;
}
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
@@ -70,6 +119,24 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
);
+ const previewQuery = trpc.vacation.previewRequest.useQuery(
+ {
+ resourceId,
+ type,
+ startDate: toUtcInputDate(debouncedStart || "1970-01-01"),
+ endDate: toUtcInputDate(debouncedEnd || "1970-01-01"),
+ ...(isHalfDay ? { isHalfDay: true } : {}),
+ },
+ {
+ enabled:
+ !!resourceId
+ && !!debouncedStart
+ && !!debouncedEnd
+ && (!isHalfDay || debouncedStart === debouncedEnd),
+ staleTime: 10_000,
+ },
+ );
+
const utils = trpc.useUtils();
const createMutation = trpc.vacation.create.useMutation({
@@ -166,7 +233,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{/* Type */}
- Type *
+ Type *
setType(e.target.value as VacationType)}
className={inputClass}
>
- {VACATION_TYPES.map((t) => (
+ {REQUESTABLE_VACATION_TYPES.map((t) => (
{VACATION_TYPE_LABELS[t]}
@@ -282,6 +349,81 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
)}
+ {!!resourceId && !!startDate && !!endDate && (
+
+
+ Leave preview
+ {previewQuery.isLoading && (
+ Calculating…
+ )}
+
+
+ {previewQuery.data && (
+
+
+
+
Requested
+
+ {previewQuery.data.requestedDays}
+
+
+
+
Effective
+
+ {previewQuery.data.effectiveDays}
+
+
+
+
Deducted
+
+ {previewQuery.data.deductedDays}
+
+
+
+
+ {buildHolidayBasisLabel(previewQuery.data).length > 0 && (
+
+ Holiday basis: {" "}
+ {buildHolidayBasisLabel(previewQuery.data).join(" / ")}
+
+ )}
+
+ {(previewQuery.data.holidayContext.sources.hasCalendarHolidays || previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
+
+ Sources: {" "}
+ {[
+ previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null,
+ previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null,
+ ].filter(Boolean).join(" + ")}
+
+ )}
+
+ {previewQuery.data.publicHolidayDates.length > 0 && (
+
+ Excluded public holidays: {" "}
+ {previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")}
+
+ )}
+
+ {previewQuery.data.requestedDays !== previewQuery.data.deductedDays && (
+
+ Public holidays in the selected range are excluded from deducted leave days.
+
+ )}
+
+ )}
+
+ {previewQuery.error && (
+
+ {previewQuery.error.message}
+
+ )}
+
+ )}
+
{/* Note */}
diff --git a/apps/web/src/hooks/useAnchoredOverlay.ts b/apps/web/src/hooks/useAnchoredOverlay.ts
new file mode 100644
index 0000000..a1db5c9
--- /dev/null
+++ b/apps/web/src/hooks/useAnchoredOverlay.ts
@@ -0,0 +1,155 @@
+import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
+
+type HorizontalAlign = "start" | "end" | "center";
+type VerticalAlign = "start" | "end" | "center";
+type OverlaySide = "bottom" | "right";
+
+interface UseAnchoredOverlayOptions {
+ open: boolean;
+ onClose: () => void;
+ offset?: number;
+ viewportPadding?: number;
+ side?: OverlaySide;
+ align?: HorizontalAlign;
+ crossAlign?: VerticalAlign;
+ matchTriggerWidth?: boolean;
+ triggerRef?: RefObject;
+}
+
+interface OverlayPosition {
+ top: number;
+ left: number;
+ minWidth?: number;
+}
+
+export function useAnchoredOverlay({
+ open,
+ onClose,
+ offset = 8,
+ viewportPadding = 16,
+ side = "bottom",
+ align = "start",
+ crossAlign = "start",
+ matchTriggerWidth = false,
+ triggerRef: externalTriggerRef,
+}: UseAnchoredOverlayOptions) {
+ const internalTriggerRef = useRef(null);
+ const triggerRef = externalTriggerRef ?? internalTriggerRef;
+ const panelRef = useRef(null);
+ const [position, setPosition] = useState({ top: 0, left: 0 });
+
+ const updatePosition = useCallback(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) {
+ return;
+ }
+
+ const rect = trigger.getBoundingClientRect();
+ const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
+ const panelHeight = panelRef.current?.offsetHeight ?? 0;
+
+ let nextTop = rect.bottom + offset;
+ let nextLeft = rect.left;
+
+ if (side === "right") {
+ nextLeft = rect.right + offset;
+ if (crossAlign === "center") {
+ nextTop = rect.top + rect.height / 2 - panelHeight / 2;
+ } else if (crossAlign === "end") {
+ nextTop = rect.bottom - panelHeight;
+ } else {
+ nextTop = rect.top;
+ }
+ } else {
+ if (align === "end") {
+ nextLeft = rect.right - panelWidth;
+ } else if (align === "center") {
+ nextLeft = rect.left + rect.width / 2 - panelWidth / 2;
+ }
+
+ nextTop = rect.bottom + offset;
+ const nextBottom = nextTop + panelHeight;
+ const flippedTop = rect.top - panelHeight - offset;
+ if (panelHeight > 0 && nextBottom > window.innerHeight - viewportPadding && flippedTop >= viewportPadding) {
+ nextTop = flippedTop;
+ }
+ }
+
+ const boundedLeft = Math.min(
+ Math.max(nextLeft, viewportPadding),
+ Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding),
+ );
+ const boundedTop = Math.min(
+ Math.max(nextTop, viewportPadding),
+ Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding),
+ );
+
+ setPosition({
+ top: boundedTop,
+ left: boundedLeft,
+ ...(matchTriggerWidth ? { minWidth: rect.width } : {}),
+ });
+ }, [align, crossAlign, matchTriggerWidth, offset, side, triggerRef, viewportPadding]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ function handlePointerDown(event: MouseEvent) {
+ const target = event.target as Node;
+ if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
+ return;
+ }
+ onClose();
+ }
+
+ function handleEscape(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ onClose();
+ }
+ }
+
+ document.addEventListener("mousedown", handlePointerDown);
+ window.addEventListener("keydown", handleEscape);
+
+ return () => {
+ document.removeEventListener("mousedown", handlePointerDown);
+ window.removeEventListener("keydown", handleEscape);
+ };
+ }, [onClose, open]);
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+
+ updatePosition();
+ const rafId = window.requestAnimationFrame(updatePosition);
+
+ window.addEventListener("resize", updatePosition);
+ window.addEventListener("scroll", updatePosition, true);
+
+ return () => {
+ window.cancelAnimationFrame(rafId);
+ window.removeEventListener("resize", updatePosition);
+ window.removeEventListener("scroll", updatePosition, true);
+ };
+ }, [open, updatePosition]);
+
+ const handleOpenChange = useCallback((nextOpen: boolean) => {
+ if (nextOpen) {
+ updatePosition();
+ return;
+ }
+ onClose();
+ }, [onClose, updatePosition]);
+
+ return {
+ triggerRef,
+ panelRef,
+ position,
+ updatePosition,
+ handleOpenChange,
+ };
+}
diff --git a/apps/web/src/hooks/useViewportPopover.ts b/apps/web/src/hooks/useViewportPopover.ts
new file mode 100644
index 0000000..49e5b6b
--- /dev/null
+++ b/apps/web/src/hooks/useViewportPopover.ts
@@ -0,0 +1,110 @@
+import { useEffect, useMemo, useRef, type CSSProperties } from "react";
+
+type PopoverAnchor =
+ | { kind: "point"; x: number; y: number }
+ | { kind: "element"; element: HTMLElement };
+
+type PopoverSide = "bottom" | "right";
+type PopoverAlign = "start" | "end" | "center";
+
+interface UseViewportPopoverOptions {
+ anchor: PopoverAnchor;
+ width: number;
+ estimatedHeight: number;
+ onClose: () => void;
+ side?: PopoverSide;
+ align?: PopoverAlign;
+ offset?: number;
+ viewportPadding?: number;
+ ignoreElements?: Array;
+}
+
+export function useViewportPopover({
+ anchor,
+ width,
+ estimatedHeight,
+ onClose,
+ side = "bottom",
+ align = "start",
+ offset = 8,
+ viewportPadding = 16,
+ ignoreElements = [],
+}: UseViewportPopoverOptions) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ function handlePointerDown(event: MouseEvent) {
+ const target = event.target as Node;
+ if (ref.current?.contains(target)) {
+ return;
+ }
+ if (ignoreElements.some((element) => element?.contains(target))) {
+ return;
+ }
+ onClose();
+ }
+
+ function handleEscape(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ onClose();
+ }
+ }
+
+ document.addEventListener("mousedown", handlePointerDown);
+ window.addEventListener("keydown", handleEscape);
+
+ return () => {
+ document.removeEventListener("mousedown", handlePointerDown);
+ window.removeEventListener("keydown", handleEscape);
+ };
+ }, [ignoreElements, onClose]);
+
+ const style = useMemo(() => {
+ let left = 0;
+ let top = 0;
+
+ if (anchor.kind === "element") {
+ const rect = anchor.element.getBoundingClientRect();
+ if (side === "right") {
+ left = rect.right + offset;
+ if (align === "end") {
+ top = rect.bottom - estimatedHeight;
+ } else if (align === "center") {
+ top = rect.top + rect.height / 2 - estimatedHeight / 2;
+ } else {
+ top = rect.top;
+ }
+ } else {
+ left = rect.left;
+ if (align === "end") {
+ left = rect.right - width;
+ } else if (align === "center") {
+ left = rect.left + rect.width / 2 - width / 2;
+ }
+ top = rect.bottom + offset;
+ }
+ } else {
+ left = anchor.x;
+ top = anchor.y + offset;
+
+ if (align === "end") {
+ left = anchor.x - width;
+ } else if (align === "center") {
+ left = anchor.x - width / 2;
+ }
+ }
+
+ const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
+ const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedHeight - viewportPadding);
+
+ return {
+ position: "fixed",
+ left: Math.min(Math.max(left, viewportPadding), maxLeft),
+ top: Math.min(Math.max(top, viewportPadding), maxTop),
+ width,
+ zIndex: 60,
+ };
+ }, [align, anchor, estimatedHeight, offset, side, viewportPadding, width]);
+
+ return { ref, style };
+}
diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts
index e51a636..a7a8b41 100644
--- a/apps/web/src/server/auth.ts
+++ b/apps/web/src/server/auth.ts
@@ -1,6 +1,6 @@
import { prisma } from "@capakraken/db";
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
-import { createAuditEntry } from "@capakraken/api";
+import { createAuditEntry } from "@capakraken/api/lib/audit";
import { logger } from "@capakraken/api/lib/logger";
import NextAuth, { type NextAuthConfig } from "next-auth";
import Credentials from "next-auth/providers/credentials";
@@ -27,9 +27,12 @@ const authConfig = {
if (!parsed.success) return null;
const { email, password, totp } = parsed.data;
+ const isE2eTestMode = process.env["E2E_TEST_MODE"] === "true";
// Rate limit: 5 login attempts per 15 minutes per email
- const rateLimitResult = authRateLimiter(email.toLowerCase());
+ const rateLimitResult = isE2eTestMode
+ ? { allowed: true }
+ : authRateLimiter(email.toLowerCase());
if (!rateLimitResult.allowed) {
// Audit failed login (rate limited)
void createAuditEntry({
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index cc87008..eae1cbe 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -16,10 +16,11 @@
"isolatedModules": true
},
"include": [
- "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
- ".next/types/**/*.ts"
+ ".next/types/**/*.ts",
+ "next-env.d.ts",
+ ".next-e2e/types/**/*.ts"
],
"exclude": [
"node_modules"
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 9207698..a17ee4d 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -1,3 +1,5 @@
+name: capakraken-prod
+
services:
postgres:
image: postgres:16-alpine
@@ -66,4 +68,6 @@ services:
volumes:
capakraken_prod_pgdata:
+ name: capakraken_prod_pgdata
capakraken_prod_redis:
+ name: capakraken_prod_redis
diff --git a/docker-compose.yml b/docker-compose.yml
index a4af6bf..7947e02 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,5 @@
+name: capakraken
+
services:
postgres:
image: postgres:16-alpine
@@ -69,7 +71,7 @@ services:
postgres-test:
image: postgres:16-alpine
ports:
- - "5434:5432"
+ - "${POSTGRES_TEST_PORT:-5434}:5432"
environment:
POSTGRES_DB: capakraken_test
POSTGRES_USER: capakraken
@@ -81,3 +83,4 @@ services:
volumes:
capakraken_pgdata:
+ name: capakraken_pgdata
diff --git a/docs/assistant-capability-gap-analysis.md b/docs/assistant-capability-gap-analysis.md
new file mode 100644
index 0000000..f517de6
--- /dev/null
+++ b/docs/assistant-capability-gap-analysis.md
@@ -0,0 +1,492 @@
+# Assistant Capability Gap Analysis
+
+## Zielbild
+
+Der AI Assistant soll grundsaetzlich alles lesen und ausfuehren koennen, was ein eingeloggter Nutzer gemaess seiner Rolle, Permission-Overrides und Objekt-Sichtbarkeit auch kann. Er darf weder weniger fachlich relevante Informationen sehen als die UI noch mehr Rechte erhalten als der Nutzer selbst.
+
+## Ist-Zustand
+
+Der Assistant ist bereits relativ breit aufgestellt:
+
+- Er haengt an `packages/api/src/router/assistant.ts`.
+- Er exponiert aktuell 88 Function-Calling-Tools aus `packages/api/src/router/assistant-tools.ts`.
+- Er deckt viele Kernbereiche bereits ab: Ressourcen, Projekte, Allokationen, Urlaub, Feiertagsabfragen, Staffing, Demand, Dashboard, einfache Insights, Kommentare, Notifications, Tasks, Reporting, Szenario-Simulation und Navigation.
+
+Trotzdem ist die Paritaet zur eigentlichen App/API noch nicht erreicht. Die groessten Luecken liegen nicht bei "gar nichts vorhanden", sondern bei:
+
+- fehlenden Admin- und Konfigurationsfaehigkeiten,
+- fehlenden tiefen Fach-Readmodels,
+- inkonsistentem Permission-Gating,
+- fehlender serverseitiger Absicherung fuer schreibende AI-Aktionen,
+- und einigen objektbezogenen Sichtbarkeitsfehlern.
+
+## Architektur des Assistants
+
+### Routing und Tool-Aufruf
+
+- `assistant.chat` baut den System Prompt, filtert die verfuegbaren Tools und laesst das Modell Tools aufrufen.
+- Der eigentliche Datenzugriff liegt fast komplett in `executeTool(...)` und den `executors` in `packages/api/src/router/assistant-tools.ts`.
+
+### Permission-Gating
+
+Es gibt aktuell vier Permission-/Scope-Ebenen:
+
+1. Tool-Sichtbarkeit vor dem Modellaufruf in `assistant.ts`
+- `TOOL_PERMISSION_MAP` blendet bestimmte Schreib-Tools aus.
+- `COST_TOOLS` blendet kostenrelevante Tools ohne `viewCosts` aus.
+
+2. Laufzeit-Guards in einzelnen Tool-Executors
+- Viele Mutationen nutzen `assertPermission(...)`.
+
+3. Objekt-/Ownership-Checks in einzelnen Tools
+- Beispiel: `update_task_status` und `execute_task_action` pruefen, ob das Task dem Nutzer gehoert.
+
+4. Normale DB-/TRPC-Semantik der zugrunde liegenden Queries
+- Diese ist aber im Assistant nicht automatisch identisch mit den eigentlichen Routern, weil die Assistant-Tools oft eigene DB-Queries verwenden.
+
+## Assistant Capability Matrix
+
+### Bereits gut abgedeckt
+
+- Ressourcen lesen und teilweise verwalten
+- Projekte lesen und teilweise verwalten
+- Allokationen lesen sowie erstellen/stornieren/status aendern
+- Vacation-Grundfaelle: erstellen, genehmigen, ablehnen, stornieren, Balance, Overlap, Pending Approvals
+- Feiertage aufgeloest nach Region oder Ressource lesen
+- Staffing/Demand-Grundfaelle
+- Dashboard-Detailabfragen auf grober Ebene
+- Basis-Insights
+- Kommentare lesen/anlegen/resolve
+- Notifications und Tasks in Grundzuegen
+- Szenario-Simulation read-only
+- Navigation in die UI
+
+### Teilweise abgedeckt
+
+- Timeline: nur indirekt ueber Navigation und Allokations-Basisabfragen
+- Estimates: nur Suche, Detail und Anlegen, aber kein voller Lifecycle
+- Reports: `run_report` ist flexibel, deckt aber nicht die spezialisierten Report-/Analyse-Readmodels ab
+- Audit/History: nur einfache History-Abfragen, keine volle Audit-API
+- Notification/Tasking: Kernfaelle vorhanden, aber keine volle Reminder-/Task-/Notification-Paritaet
+- Country-/Location-Stammdaten: nur lesend und auch dort nur flach
+- Insights: Summary-Ebene vorhanden, Drilldowns fehlen
+
+### Vollstaendig fehlend oder fachlich nicht ausreichend
+
+- Holiday-Calendar-Admin und Editor-Funktionen
+- Computation Graph fuer vollstaendige Herleitungen
+- Chargeability Report Readmodel
+- Webhook-Administration
+- System Settings / AI / SMTP / Image-Provider Administration
+- System Role Config Administration
+- Import/Export-Flows
+- User Self-Service und Preferences
+- Country- und Metro-City-Administration
+- Volle Timeline-Readmodels und Timeline-Mutationen
+- Voller Estimate-Lifecycle
+- Dispo-/Import-spezifische Flows
+
+## Kritische Inkonsistenzen und Risiken
+
+### P0: Human-in-the-Loop nur im Prompt, nicht serverseitig erzwungen
+
+Der System Prompt fordert bestaetigte Freigabe vor jeder schreibenden Aktion. Technisch wird das aber nicht serverseitig erzwungen. Wenn das Modell direkt ein Mutation-Tool aufruft, wird es ausgefuehrt.
+
+Betroffene Stellen:
+
+- `packages/api/src/router/assistant.ts`
+- `packages/api/src/router/assistant-tools.ts`
+
+Konsequenz:
+
+- Die wichtigste Governance-Regel ist aktuell nur Prompt-Disziplin, keine technische Policy.
+
+### P0: Notification-Scoping im Assistant ist fachlich/sicherheitsseitig falsch
+
+Die dedizierte `notificationRouter` scoped strikt auf den aktuellen Nutzer. Die Assistant-Tools tun das in `list_notifications` und `mark_notification_read` nicht.
+
+Assistant-Verhalten:
+
+- `list_notifications` listet Notifications ohne `userId`-Filter.
+- `mark_notification_read` markiert per ID ohne Ownership-Check.
+
+Konsequenz:
+
+- Der Assistant kann Informationen sehen oder veraendern, die der Nutzer in der normalen Notification-UI nicht sehen duerfte.
+
+### P0: `list_users` ist als admin-only beschrieben, aber nicht effektiv admin-only
+
+Der Tool-Text sagt "Requires admin permission", aber es gibt weder einen Eintrag in `TOOL_PERMISSION_MAP` noch einen `assertPermission(...)` im Executor.
+
+Konsequenz:
+
+- Jeder Nutzer mit Assistant-Zugriff kann potenziell die User-Liste lesen, obwohl die normale App dies ueber `userRouter.list` nur Admins gibt.
+
+### P1: Permission-Beschreibungen und technische Guards sind nicht konsistent
+
+Beispiele:
+
+- `create_estimate`
+ - Beschreibung: "Requires manageEstimates permission"
+ - Technik: `TOOL_PERMISSION_MAP` und Executor verlangen `manageProjects`
+
+- `create_org_unit` / `update_org_unit`
+ - Beschreibung: "Requires admin permission"
+ - Technik: `manageResources`
+
+- `send_broadcast`
+ - Beschreibung: "Requires manager permission"
+ - Technik: `manageProjects`
+
+Konsequenz:
+
+- Der Assistant ist fuer Nutzer und fuer uns selbst schwer vorhersehbar.
+- Ein sauberer Rechteabgleich "User kann X in UI, also Assistant auch" ist dadurch nicht belastbar.
+
+### P1: Nicht alle Assistant-Mutationen sind als Mutation-Typ sauber nachverfolgbar
+
+`MUTATION_TOOLS` dient dem Logging von AI-Mutationen. Nicht jede schreibende Aktion ist dort gleich gut abgebildet.
+
+Beispiel:
+
+- `mark_notification_read` aendert Daten, ist aber nicht in `MUTATION_TOOLS`.
+
+Konsequenz:
+
+- Luecken im AI-spezifischen Audit-Trail.
+
+## Was der Assistant heute noch nicht "weiss"
+
+Die folgende Liste meint: Informationen, die in App/API bereits existieren oder fuer Nutzer sichtbar sind, aber im Assistant heute gar nicht oder nicht in gleichwertiger Tiefe/Struktur verfuegbar sind.
+
+### Feiertage und Kalender
+
+- Vollstaendige Holiday-Calendar-Stammdaten:
+ - Kalender-Liste mit Scope, Prioritaet, Aktiv-Status, Entry-Count
+ - einzelne Kalender inklusive aller Entries
+ - Preview der aufgeloesten Feiertage fuer geplante Kalenderaenderungen
+- Editierkontext des Holiday-Editors:
+ - was global, state-spezifisch oder city-spezifisch konfiguriert ist
+ - welche Kalender sich gegenseitig ueberschreiben oder ergaenzen
+
+Aktuell im Assistant vorhanden:
+
+- aufgeloeste Feiertage nach Region oder Ressource
+
+Fehlend:
+
+- die eigentlichen Kalenderobjekte und deren Pflegekontext
+
+### Timeline und Disposition
+
+- Vollstaendiges Timeline-Readmodel:
+ - `getEntriesView`
+ - Projekt-/Demand-/Assignment-Kontext in derselben Struktur wie die UI
+ - Holiday-Overlays der Timeline
+ - Projektkontext fuer Drag/Shift/Panel-Interaktionen
+- Timeline-spezifische Vorschau-/Validierungsdaten:
+ - `previewShift`
+ - genaue Konflikte, Kosten-Delta, Auswirkungen vor Commit
+- Batch- und Inline-Operationen der Timeline:
+ - `updateAllocationInline`
+ - `quickAssign`
+ - `batchQuickAssign`
+ - `batchShiftAllocations`
+ - `applyShift`
+- Dispo-spezifische Import-/Workbook-Flows
+
+Konsequenz:
+
+- Der Assistant kann heute nicht denselben Timeline-Arbeitsmodus wie ein Nutzer in der UI abbilden.
+
+### Transparenz, Herleitungen und Berechnungsgraphen
+
+- Vollstaendige Computation-Graph-Daten fuer Resource- und Project-Views:
+ - Herleitungsfaktoren
+ - Formeln
+ - Holiday-/State-/City-Kontext pro Berechnung
+ - Node/Link-Zusammenhaenge
+- Spezialisierter Chargeability Report:
+ - Monatsreihen
+ - Org-Unit-, Management-Level- und Country-Filter
+ - Gruppenaggregate und Luecken zum Target
+
+Konsequenz:
+
+- Der Assistant kann zwar Teilantworten zu Chargeability/Budget geben, aber noch nicht dieselbe Erklaerungstiefe wie die spezialisierten Analyseansichten.
+
+### Audit, Verlauf und Governance
+
+- Vollstaendige Audit-API:
+ - paginierte Listen
+ - Detailansicht mit voller `changes`-Struktur
+ - Timeline-View
+ - Activity Summary
+
+Aktuell im Assistant vorhanden:
+
+- vereinfachte History-Suche (`query_change_history`)
+- Entity-History (`get_entity_timeline`)
+
+Fehlend:
+
+- die vollstaendige Governance-/Revisionstiefe der Audit-Oberflaeche
+
+### Admin- und Systemkonfiguration
+
+- System Settings:
+ - AI-Provider-Konfiguration
+ - SMTP-Konfiguration
+ - Image-Provider-Konfiguration
+ - Score Weights / Sichtbarkeiten
+ - Vacation Defaults
+ - Timeline Undo Settings
+ - Connection Tests
+- System Role Config:
+ - Rollenlabels
+ - Beschreibungen
+ - Farben
+ - Default-Permissions
+- Webhooks:
+ - Liste, Detail, Create, Update, Delete, Test
+
+Konsequenz:
+
+- Ein Admin kann in der UI deutlich mehr Systemsteuerung als der Assistant.
+
+### User Self-Service
+
+- `user.me`
+- Dashboard-Layout lesen/speichern
+- Favorite Projects lesen/toggeln
+- Column Preferences lesen/speichern
+- MFA / TOTP aktivieren, pruefen, Status lesen
+- einige Nutzerverwaltungsaktionen aus `userRouter`
+
+Konsequenz:
+
+- Der Assistant kennt den Nutzerkontext nur oberflaechlich, aber nicht dessen persoenliche Einstellungen und Self-Service-Moeglichkeiten.
+
+### Stammdaten fuer Laender und Orte
+
+- Country-Details inklusive `scheduleRules`
+- Metro-City-Verwaltung
+- Country-/City-CRUD
+
+Aktuell im Assistant vorhanden:
+
+- `list_countries` mit relativ flachem Output
+
+Fehlend:
+
+- volle fachliche Pflege und die tieferen Standortregeln, die fuer Feiertage, SAH und Forecasts relevant sind
+
+### Estimate-Lifecycle und Fachobjekte unterhalb des Estimates
+
+- volle Estimate-Listen-/Detail-Paritaet
+- Versionen, Scope Items, Demand Lines, Locking, Freigaben, weiterfuehrende Mutationen
+
+Aktuell im Assistant vorhanden:
+
+- Suche
+- Baseline-Detail
+- Anlegen
+
+Fehlend:
+
+- der eigentliche Arbeitsprozess auf Estimate-Ebene
+
+### Notifications, Tasks und Reminder
+
+Vorhanden:
+
+- Listen, Task-Detail, Statuswechsel, Reminder anlegen, Task fuer User anlegen, Broadcast senden
+
+Fehlend:
+
+- Reminder-Liste
+- Reminder-Update/Delete
+- Unread Count
+- Task Counts
+- generische Notification-Erstellung mit derselben Tiefe wie `notificationRouter`
+
+## Capability Gaps nach Router
+
+### Komplett fehlende Router-Paritaet
+
+- `holidayCalendar`
+- `importExport`
+- `chargeabilityReport`
+- `computationGraph`
+- `settings`
+- `systemRoleConfig`
+- `webhook`
+- `dispo`
+
+### Deutlich unvollstaendige Router-Paritaet
+
+- `timeline`
+- `vacation`
+- `estimate`
+- `notification`
+- `user`
+- `country`
+- `auditLog`
+- `insights`
+- `scenario`
+- `resource`
+- `project`
+- `comment`
+
+### Nahe an brauchbarer Grundabdeckung, aber nicht vollstaendig
+
+- `resource`
+- `project`
+- `staffing`
+- `report`
+- `dashboard`
+
+## System Prompt: offensichtliche Uebertreibungen / Irrefuehrungen
+
+Der Prompt suggeriert an mehreren Stellen mehr Paritaet, als technisch heute vorhanden ist.
+
+### Problematische Aussagen
+
+- "Urlaub, Feiertage" ist fuer Leseabfragen ok, aber nicht fuer Holiday-Calendar-Administration.
+- "Notifications anzeigen" stimmt nur eingeschraenkt, weil das Assistant-Tooling aktuell nicht sauber auf den aktuellen Nutzer scoped.
+- "Dashboard-Details abrufen" stimmt nur fuer einen Teil der Dashboard-/Analysewelt.
+- "Den User zu relevanten Seiten navigieren" stimmt, ersetzt aber keine echte Daten-/Aktionsparitaet in Timeline, Holiday Editor oder Admin-Bereichen.
+- "Ressourcenplanung und Projektmanagement" klingt umfassender, als die reale Tool-Abdeckung in spezialisierten Subsystemen ist.
+
+### Wichtigste Prompt-Luecke
+
+Die Human-in-the-Loop-Regel wird als harte Pflicht formuliert, ist technisch aber nicht hart erzwungen.
+
+## Was getan werden muss, damit der Assistant wirklich dieselben Nutzerfaehigkeiten hat
+
+### P0: Sicherheits- und Governance-Hardening
+
+1. Serverseitige Confirm-Policy fuer alle schreibenden Assistant-Tools einziehen.
+- Schreibende Tool-Calls duerfen ohne bestaetigten Confirmation-Token nicht ausgefuehrt werden.
+- Diese Policy darf nicht nur im Prompt stehen.
+
+2. Assistant-Tools auf denselben Objekt-Scope wie die eigentlichen Router bringen.
+- Besonders:
+ - Notifications
+ - Tasks
+ - User-Liste
+ - alle personenbezogenen oder sensitiven Admin-Daten
+
+3. Permission-Quellen vereinheitlichen.
+- Ein zentraler Capability-/Policy-Registry sollte festlegen:
+ - welches Tool welche Permission braucht,
+ - ob Objekt-Ownership gilt,
+ - ob `viewCosts` zusaetzlich erforderlich ist,
+ - ob Human Confirmation erforderlich ist.
+
+### P1: Fachliche Paritaet fuer die wichtigsten Nutzer-Workflows
+
+1. Holiday-Calendar-Assistant-Strang bauen
+- `list_holiday_calendars`
+- `get_holiday_calendar`
+- `create_holiday_calendar`
+- `update_holiday_calendar`
+- `delete_holiday_calendar`
+- `create_holiday_entry`
+- `update_holiday_entry`
+- `delete_holiday_entry`
+- `preview_resolved_holidays`
+
+2. Timeline-Assistant-Strang bauen
+- Read:
+ - `get_timeline_entries_view`
+ - `get_timeline_holiday_overlays`
+ - `get_timeline_project_context`
+ - `preview_project_shift`
+- Write:
+ - `update_allocation_inline`
+ - `apply_project_shift`
+ - `quick_assign`
+ - `batch_quick_assign`
+ - `batch_shift_allocations`
+
+3. Analyse-/Transparenz-Paritaet bauen
+- `get_chargeability_report`
+- `get_resource_computation_graph`
+- `get_project_computation_graph`
+
+### P2: Admin- und Stammdaten-Paritaet
+
+1. Settings-Admin-Tools
+- lesen
+- aktualisieren
+- Connection Tests
+
+2. SystemRoleConfig-Tools
+- listen
+- update
+
+3. Country-/City-Tools
+- Country-Detail
+- Country-Create/Update
+- City-Create/Update/Delete
+
+4. Webhook-Tools
+- list/get/create/update/delete/test
+
+### P3: Self-Service- und Workflow-Paritaet
+
+1. User-Tools
+- `get_me`
+- Dashboard-Layout lesen/schreiben
+- Favoriten lesen/toggeln
+- Column Preferences lesen/schreiben
+- MFA-Status / TOTP-Flows
+
+2. Notification-/Reminder-Paritaet
+- Reminder listen/update/delete
+- unreadCount
+- taskCounts
+
+3. Estimate-Lifecycle vertiefen
+- Versionen
+- Scope Items
+- Demand Lines
+- Status-/Locking-/Approval-Flows
+
+## Empfohlene Umsetzungsreihenfolge
+
+### Stream A: Safety / Policy
+
+- serverseitige Confirmation-Gates
+- Ownership-/Permission-Fixes
+- Mutation-Audit vervollstaendigen
+
+### Stream B: Holiday + Timeline Parity
+
+- Holiday-Calendar-Editor-Tools
+- Timeline-Readmodels
+- Timeline-Shift-/Assign-Aktionen
+
+### Stream C: Explainability / Analytics
+
+- Chargeability Report
+- Computation Graph
+- Audit Summary
+
+### Stream D: Admin / Ops
+
+- Settings
+- System Role Config
+- Webhooks
+- Import/Export
+
+## Kurzfazit
+
+Der Assistant ist bereits breit genug, um viele operative Fragen und Standardaktionen abzudecken. Er ist aber noch nicht in dem Zustand, dass man sagen kann: "Alles, was der Nutzer kann, kann auch der Assistant."
+
+Die drei groessten Blocker dafuer sind:
+
+1. fehlende serverseitige Absicherung fuer schreibende AI-Aktionen,
+2. unvollstaendige fachliche Paritaet in Holiday/Timeline/Analytics/Admin-Bereichen,
+3. inkonsistente oder zu schwache Permission- und Ownership-Pruefungen in einzelnen Tools.
diff --git a/docs/holiday-calendar-implementation-plan.md b/docs/holiday-calendar-implementation-plan.md
new file mode 100644
index 0000000..87b6aa7
--- /dev/null
+++ b/docs/holiday-calendar-implementation-plan.md
@@ -0,0 +1,393 @@
+# Holiday Calendar Implementation Plan
+
+## Ziel
+
+Planarchy soll standortabhaengige Feiertage fachlich korrekt berechnen koennen, sodass zwei Personen im selben Land, aber in unterschiedlichen Regionen oder Staedten, unterschiedliche `SAH` und damit unterschiedliche Chargeability erhalten koennen.
+
+Die Feiertagsaufloesung soll kuenftig diese Prioritaet haben:
+
+1. `metroCity`
+2. `federalState` / Region
+3. `country`
+
+Manuelle, ressourcenspezifische `PUBLIC_HOLIDAY`-Eintraege bleiben weiterhin moeglich und ueberschreiben bzw. ergaenzen den Kalender.
+
+## Ist-Zustand
+
+Aktuell existieren drei getrennte Mechanismen:
+
+1. Statisch codierte Feiertage in `packages/shared/src/constants/publicHolidays.ts`
+2. Batch-/Auto-Import von `PUBLIC_HOLIDAY`-Vacations
+3. Laufzeitberechnung von `SAH` bzw. Chargeability aus Land/Bundesland
+
+Die zentralen Luecken:
+
+- Es gibt kein Holiday-Stammdatenmodell in der Datenbank.
+- Es gibt keinen Editor fuer Feiertagskalender.
+- `metroCity` wird fuer Feiertage nicht ausgewertet.
+- Die aktuelle Logik ist faktisch auf Deutschland plus `federalState` zugeschnitten.
+- Feiertagswissen ist doppelt vorhanden: statische Kalenderlogik plus importierte `Vacation`-Datensaetze.
+
+## Zielarchitektur
+
+### 1. Holiday Calendar als Stammdatenmodell
+
+Neue Stammdatenobjekte:
+
+- `HolidayCalendar`
+- `HolidayCalendarEntry`
+
+`HolidayCalendar` beschreibt den Gueltigkeitsbereich eines Kalenders:
+
+- `scopeType`: `COUNTRY | STATE | CITY`
+- `countryId`
+- optional `stateCode`
+- optional `metroCityId`
+- `name`
+- `isActive`
+- optional `priority`
+
+`HolidayCalendarEntry` beschreibt den einzelnen Feiertag:
+
+- `holidayCalendarId`
+- `date`
+- `name`
+- optional `isRecurringAnnual`
+- optional `source`
+
+Fachregel:
+
+- Pro Scope soll es genau einen aktiven Kalender geben.
+- Die effektiven Feiertage eines Mitarbeiters ergeben sich aus Merge mit Prioritaet `country < state < city`.
+- Gleiche Daten auf engerem Scope ueberschreiben denselben Tag vom breiteren Scope.
+
+### 2. Laufzeit-Resolver statt statischer Sonderlogik
+
+Neue gemeinsame Backend-Komponente:
+
+- `resolveResourceHolidayCalendar(...)`
+
+Aufgaben:
+
+- liest Kalenderdaten fuer `countryId`, `federalState`, `metroCityId`
+- ermittelt die effektiven Feiertage fuer einen Zeitraum
+- merged diese mit expliziten `Vacation`-Eintraegen vom Typ `PUBLIC_HOLIDAY`
+- liefert:
+ - `publicHolidayStrings`
+ - `absenceDays`
+ - optional Debug-Metadaten zur Herkunft eines Feiertags
+
+Diese Komponente wird die einzige Quelle fuer Feiertagslogik in:
+
+- Chargeability Report
+- Chargeability Alerts
+- Computation Graph
+- ggf. weitere SAH-/Allocation-Pfade
+
+### 3. Import und Editor werden auf Stammdaten umgestellt
+
+Der heutige Batch-/Auto-Import darf nicht die Primarlogik fuer Feiertage bleiben.
+
+Zielbild:
+
+- Stammdatenkalender sind die Quelle der Wahrheit.
+- Optionaler Import in `Vacation` bleibt nur als Kompatibilitaets- oder Exportfunktion.
+- Bestehende `PUBLIC_HOLIDAY`-Vacations werden fuer Uebergangszeit weiter beruecksichtigt.
+
+## Datenmodell
+
+### Prisma-Erweiterungen
+
+Neue Modelle:
+
+```prisma
+model HolidayCalendar {
+ id String @id @default(cuid())
+ name String
+ scopeType HolidayCalendarScope
+ countryId String
+ stateCode String?
+ metroCityId String?
+ isActive Boolean @default(true)
+ priority Int @default(0)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ country Country @relation(fields: [countryId], references: [id])
+ metroCity MetroCity? @relation(fields: [metroCityId], references: [id])
+ entries HolidayCalendarEntry[]
+
+ @@index([countryId, scopeType])
+ @@index([metroCityId])
+}
+
+model HolidayCalendarEntry {
+ id String @id @default(cuid())
+ holidayCalendarId String
+ date DateTime @db.Date
+ name String
+ isRecurringAnnual Boolean @default(false)
+ source String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ holidayCalendar HolidayCalendar @relation(fields: [holidayCalendarId], references: [id], onDelete: Cascade)
+
+ @@unique([holidayCalendarId, date, name])
+ @@index([date])
+}
+```
+
+Neues Enum:
+
+```prisma
+enum HolidayCalendarScope {
+ COUNTRY
+ STATE
+ CITY
+}
+```
+
+### Integritaetsregeln
+
+- `STATE` verlangt `stateCode`.
+- `CITY` verlangt `metroCityId`.
+- `CITY` und `STATE` muessen zum selben `countryId` passen.
+- Ein `CITY`-Kalender darf nur fuer eine `MetroCity` des angegebenen Landes existieren.
+
+Diese Regeln werden teils im Schema, teils in Router-Validierung erzwungen.
+
+## API- und Backend-Pakete
+
+### Paket A: Schema und Datenzugriff
+
+Dateien:
+
+- `packages/db/prisma/schema.prisma`
+- neue Migration
+- ggf. `packages/shared/src/types/*`
+- ggf. `packages/shared/src/schemas/*`
+
+Ergebnis:
+
+- Holiday-Calendar-Datenmodell ist vorhanden
+- Zod-/Shared-Typen fuer CRUD sind definiert
+
+### Paket B: Holiday Calendar Router
+
+Neue oder erweiterte API:
+
+- `packages/api/src/router/holiday-calendar.ts`
+- Router in `packages/api/src/index.ts`
+
+Operationen:
+
+- `listCalendars`
+- `getCalendarById`
+- `createCalendar`
+- `updateCalendar`
+- `deleteCalendar`
+- `createEntry`
+- `updateEntry`
+- `deleteEntry`
+- optional `previewResolvedHolidays`
+
+### Paket C: Gemeinsamer Resolver
+
+Dateien:
+
+- `packages/api/src/lib/holiday-resolver.ts`
+- bestehende Hilfen in `packages/api/src/lib/holiday-availability.ts` refactoren oder ersetzen
+
+Ergebnis:
+
+- einheitliche Feiertagsaufloesung fuer alle Backend-Pfade
+- keine neue statische Sonderlogik in Routern
+
+### Paket D: Integration in Berechnungen
+
+Betroffene Stellen:
+
+- `packages/api/src/router/chargeability-report.ts`
+- `packages/api/src/lib/chargeability-alerts.ts`
+- `packages/api/src/router/computation-graph.ts`
+- weitere `calculateSAH`-Aufrufer mit Feiertagsbezug
+
+Abnahme:
+
+- dieselbe Ressource liefert je nach `metroCity` / `federalState` unterschiedliche `SAH`
+- gleiche Eingaben erzeugen in allen Reports denselben Feiertagseffekt
+
+### Paket E: UI / Admin
+
+Betroffene Stellen:
+
+- neue Admin-Seite oder Erweiterung im Country-Admin
+- Wiederverwendung moeglicher Muster aus:
+ - `apps/web/src/components/admin/CountriesClient.tsx`
+ - `apps/web/src/components/vacations/PublicHolidayBatch.tsx`
+ - vorhandene Modal-/Table-Komponenten
+
+Ziel:
+
+- Kalender pro Land / Bundesland / Stadt anlegen und bearbeiten
+- Eintraege pro Jahr pflegen
+- Aufloesung fuer eine Beispiel-Ressource optional vorschauen
+
+### Paket F: Kompatibilitaet / Migration
+
+Uebergangsstrategie:
+
+1. Bestehende `PUBLIC_HOLIDAY`-Vacations bleiben gueltig.
+2. Neuer Resolver nutzt zuerst Stammdatenkalender plus manuelle Overrides.
+3. Batch-/Auto-Import wird als Legacy-Funktion markiert.
+4. Spaeter kann entschieden werden, ob Import nur noch Materialisierung fuer Sonderfaelle ist.
+
+## Fachliche Aufloesungsregeln
+
+### Prioritaet
+
+1. Manuelle ressourcenspezifische `PUBLIC_HOLIDAY`-Vacation
+2. `CITY`-Kalender
+3. `STATE`-Kalender
+4. `COUNTRY`-Kalender
+
+### Merge-Regeln
+
+- Gleiches Datum mehrfach:
+ - engster Scope gewinnt fuer Anzeige/Quelle
+ - fuer `SAH` zaehlt der Tag genau einmal
+- Feiertag auf Wochenende:
+ - erscheint im Kalender
+ - reduziert `SAH` nur, wenn der Tag laut Verfuegbarkeit ein Arbeitstag ist
+- Halbtag-Feiertage:
+ - aktuell nicht erforderlich
+ - nur aufnehmen, wenn fachlich explizit benoetigt
+
+## Umsetzung in parallelen Workern
+
+### Worker 1: Schema + Shared Contracts
+
+Verantwortung:
+
+- Prisma-Modelle
+- Migration
+- Shared Types / Zod Schemas
+
+Write Scope:
+
+- `packages/db/prisma/schema.prisma`
+- `packages/shared/src/types/*`
+- `packages/shared/src/schemas/*`
+
+### Worker 2: Backend Router + Validation
+
+Verantwortung:
+
+- CRUD-API fuer Holiday Calendars
+- Validierung von Scope-Regeln
+- Audit-Logging
+
+Write Scope:
+
+- `packages/api/src/router/holiday-calendar.ts`
+- `packages/api/src/index.ts`
+- eng verbundene Tests
+
+### Worker 3: Resolver + Berechnungsintegration
+
+Verantwortung:
+
+- gemeinsamer Holiday Resolver
+- Integration in Report, Alerts, Computation Graph
+- Entfernung duplizierter Feiertagslogik
+
+Write Scope:
+
+- `packages/api/src/lib/holiday-resolver.ts`
+- `packages/api/src/lib/holiday-availability.ts`
+- `packages/api/src/router/chargeability-report.ts`
+- `packages/api/src/lib/chargeability-alerts.ts`
+- `packages/api/src/router/computation-graph.ts`
+- eng verbundene Tests
+
+### Worker 4: Admin UI
+
+Verantwortung:
+
+- neue Holiday-Calendar-Admin-Oberflaeche
+- Calendar-Entry-Editing
+- optional Preview fuer aufgeloeste Feiertage
+
+Write Scope:
+
+- `apps/web/src/components/admin/*`
+- relevante App-Routen
+- eng verbundene UI-Tests falls vorhanden
+
+### Worker 5: Migration / Legacy Behavior / Verify
+
+Verantwortung:
+
+- Legacy Import klar einhaengen oder abgrenzen
+- Verify-/Smoke-Pfade
+- End-to-End-Pruefung der fachlichen Szenarien
+
+Write Scope:
+
+- `packages/api/src/lib/holiday-auto-import.ts`
+- `packages/api/src/router/vacation.ts`
+- Verify-Skripte und Tests
+
+## Teststrategie
+
+### Unit
+
+- Resolver merged `country + state + city` korrekt
+- `CITY` ueberschreibt `STATE`, `STATE` ergaenzt `COUNTRY`
+- manuelle `PUBLIC_HOLIDAY`-Vacation wird beruecksichtigt
+- identisches Datum wird nur einmal auf `SAH` angerechnet
+
+### Integration
+
+- Chargeability Report: zwei Ressourcen, gleiches Land, unterschiedliche Stadt, unterschiedliche `SAH`
+- Chargeability Alerts: derselbe Feiertagseffekt wie im Report
+- Computation Graph: dieselbe Feiertagsanzahl wie Resolver
+
+### UI
+
+- Kalender anlegen fuer `COUNTRY`, `STATE`, `CITY`
+- Eintrag anlegen/aendern/loeschen
+- Scope-Validierung verhindert ungueltige Kombinationen
+
+### Datenmigration / Regression
+
+- bestehende `PUBLIC_HOLIDAY`-Vacations bleiben wirksam
+- alte Batch-Funktion erzeugt keine Konflikte
+- Repo-weit:
+ - `pnpm test`
+ - `pnpm typecheck`
+ - relevanter E2E-Smoke fuer Admin-Pfad, falls vorhanden
+
+## Abnahme-Kriterien
+
+- Feiertage sind nicht mehr hart an Deutschland/Bundesland im Laufzeitpfad gekoppelt.
+- `metroCity` kann `SAH` fachlich beeinflussen.
+- Es gibt eine Admin-faehige Pflege fuer Feiertagskalender.
+- Report, Alerts und Computation Graph verwenden denselben Resolver.
+- Bestehende manuelle Feiertagsabwesenheiten bleiben kompatibel.
+
+## Empfohlene Reihenfolge
+
+1. Schema + Shared Contracts
+2. Backend Router
+3. Resolver + Integration
+4. UI
+5. Migration/Legacy und Gesamttests
+
+## Offene Produktentscheidungen
+
+- Sollen Feiertage kuenftig nur manuell gepflegt werden oder auch per externem Provider importierbar sein?
+- Brauchen wir Halbtag-Feiertage?
+- Reicht `metroCity` als lokaler Scope oder brauchen wir spaeter feinere Geo-Einheiten?
+- Soll Legacy-Batch-Import langfristig entfernt oder als Materialisierung behalten werden?
diff --git a/package.json b/package.json
index 2b75f0f..8ca0afd 100644
--- a/package.json
+++ b/package.json
@@ -6,13 +6,14 @@
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
- "test": "turbo test",
+ "test": "turbo run test:unit",
"test:unit": "turbo test:unit",
"test:e2e": "turbo test:e2e",
- "db:push": "pnpm --filter @capakraken/db db:push",
- "db:migrate": "pnpm --filter @capakraken/db db:migrate",
- "db:seed": "pnpm --filter @capakraken/db db:seed",
- "db:studio": "pnpm --filter @capakraken/db db:studio",
+ "db:doctor": "node ./scripts/db-doctor.mjs capakraken",
+ "db:push": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:push",
+ "db:migrate": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:migrate",
+ "db:seed": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:seed",
+ "db:studio": "node ./scripts/with-env.mjs pnpm --filter @capakraken/db db:studio",
"db:reset:dispo": "pnpm --filter @capakraken/db db:reset:dispo",
"db:import:dispo": "pnpm --filter @capakraken/db db:import:dispo",
"db:readiness:demand-assignment": "pnpm --filter @capakraken/db db:readiness:demand-assignment",
diff --git a/packages/api/package.json b/packages/api/package.json
index e5acab4..2efd570 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -8,6 +8,7 @@
"./router": "./src/router/index.ts",
"./trpc": "./src/trpc.ts",
"./sse": "./src/sse/event-bus.ts",
+ "./lib/audit": "./src/lib/audit.ts",
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts",
"./lib/logger": "./src/lib/logger.ts",
"./middleware/rate-limit": "./src/middleware/rate-limit.ts"
diff --git a/packages/api/src/__tests__/allocation-router.test.ts b/packages/api/src/__tests__/allocation-router.test.ts
index c2788bd..3cbd213 100644
--- a/packages/api/src/__tests__/allocation-router.test.ts
+++ b/packages/api/src/__tests__/allocation-router.test.ts
@@ -1,13 +1,14 @@
import { AllocationStatus, SystemRole } from "@capakraken/shared";
import { describe, expect, it, vi } from "vitest";
import { allocationRouter } from "../router/allocation.js";
-import { emitAllocationCreated, emitAllocationDeleted } from "../sse/event-bus.js";
+import { emitAllocationCreated, emitAllocationDeleted, emitNotificationCreated } from "../sse/event-bus.js";
import { createCallerFactory } from "../trpc.js";
vi.mock("../sse/event-bus.js", () => ({
emitAllocationCreated: vi.fn(),
emitAllocationDeleted: vi.fn(),
emitAllocationUpdated: vi.fn(),
+ emitNotificationCreated: vi.fn(),
}));
vi.mock("../lib/budget-alerts.js", () => ({
@@ -18,6 +19,10 @@ vi.mock("../lib/cache.js", () => ({
invalidateDashboardCache: vi.fn(),
}));
+vi.mock("../lib/webhook-dispatcher.js", () => ({
+ dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
+}));
+
const createCaller = createCallerFactory(allocationRouter);
function createManagerCaller(db: Record) {
@@ -35,7 +40,100 @@ function createManagerCaller(db: Record) {
});
}
+function createDemandWorkflowDb(overrides: Record = {}) {
+ const db = {
+ project: {
+ findUnique: vi.fn().mockResolvedValue({ id: "project_1", name: "Project One" }),
+ },
+ role: {
+ findUnique: vi.fn().mockResolvedValue({ name: "FX Artist" }),
+ },
+ user: {
+ findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
+ },
+ notification: {
+ create: vi.fn().mockImplementation(async ({ data }: { data: { userId: string } }) => ({
+ id: `notif_${data.userId}`,
+ })),
+ },
+ auditLog: {
+ create: vi.fn().mockResolvedValue({}),
+ },
+ };
+
+ return {
+ ...db,
+ ...overrides,
+ project: { ...db.project, ...(overrides.project as Record | undefined) },
+ role: { ...db.role, ...(overrides.role as Record | undefined) },
+ user: { ...db.user, ...(overrides.user as Record | undefined) },
+ notification: {
+ ...db.notification,
+ ...(overrides.notification as Record | undefined),
+ },
+ auditLog: { ...db.auditLog, ...(overrides.auditLog as Record | undefined) },
+ };
+}
+
describe("allocation entry resolution router", () => {
+ it("excludes regional holidays from resource availability coverage", async () => {
+ const db = {
+ resource: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "resource_1",
+ displayName: "Bruce Banner",
+ eid: "E-001",
+ fte: 1,
+ availability: {
+ monday: 8,
+ tuesday: 8,
+ wednesday: 8,
+ thursday: 8,
+ friday: 8,
+ saturday: 0,
+ sunday: 0,
+ },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { dailyWorkingHours: 8, code: "DE" },
+ metroCity: null,
+ }),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "assignment_1",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ hoursPerDay: 8,
+ status: AllocationStatus.CONFIRMED,
+ project: { name: "Gamma", shortCode: "GAM" },
+ },
+ ]),
+ },
+ };
+
+ const caller = createManagerCaller(db);
+ const result = await caller.checkResourceAvailability({
+ resourceId: "resource_1",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ hoursPerDay: 8,
+ });
+
+ expect(result).toMatchObject({
+ dailyCapacity: 8,
+ totalWorkingDays: 1,
+ availableDays: 0,
+ partialDays: 0,
+ conflictDays: 1,
+ totalAvailableHours: 0,
+ totalRequestedHours: 8,
+ coveragePercent: 0,
+ });
+ });
+
it("creates an open demand through allocation.create without requiring isPlaceholder", async () => {
const createdDemandRequirement = {
id: "demand_1",
@@ -187,6 +285,7 @@ describe("allocation entry resolution router", () => {
it("creates an explicit demand requirement without dual-writing a legacy allocation row", async () => {
vi.mocked(emitAllocationCreated).mockClear();
+ vi.mocked(emitNotificationCreated).mockClear();
const createdDemandRequirement = {
id: "demand_explicit_1",
@@ -206,18 +305,14 @@ describe("allocation entry resolution router", () => {
roleEntity: { id: "role_fx", name: "FX Artist", color: "#222222" },
};
- const db = {
- project: {
- findUnique: vi.fn().mockResolvedValue({ id: "project_1" }),
- },
+ const db = createDemandWorkflowDb({
demandRequirement: {
create: vi.fn().mockResolvedValue(createdDemandRequirement),
},
- auditLog: {
- create: vi.fn().mockResolvedValue({}),
- },
+ }) as Record;
+ Object.assign(db, {
$transaction: vi.fn(async (callback: (tx: unknown) => unknown) => callback(db)),
- };
+ });
const caller = createManagerCaller(db);
const result = await caller.createDemandRequirement({
@@ -247,6 +342,8 @@ describe("allocation entry resolution router", () => {
projectId: "project_1",
resourceId: null,
});
+ expect(db.notification.create).toHaveBeenCalledTimes(2);
+ expect(emitNotificationCreated).toHaveBeenCalledTimes(2);
});
it("creates an explicit assignment without dual-writing a legacy allocation row", async () => {
@@ -730,4 +827,3 @@ describe("allocation entry resolution router", () => {
});
});
});
-
diff --git a/packages/api/src/__tests__/assistant-insights.test.ts b/packages/api/src/__tests__/assistant-insights.test.ts
new file mode 100644
index 0000000..2c27a79
--- /dev/null
+++ b/packages/api/src/__tests__/assistant-insights.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from "vitest";
+import { buildAssistantInsight } from "../router/assistant-insights.js";
+
+describe("assistant insights", () => {
+ it("builds a transparent chargeability insight from holiday-aware payloads", () => {
+ const insight = buildAssistantInsight("get_chargeability", {
+ resource: "Bruce Banner",
+ month: "2026-01",
+ chargeability: "42.9%",
+ chargeabilityPct: 42.9,
+ targetPct: 80,
+ availableHours: 168,
+ bookedHours: 72,
+ unassignedHours: 96,
+ targetHours: 134.4,
+ baseWorkingDays: 23,
+ workingDays: 21,
+ baseAvailableHours: 184,
+ locationContext: { country: "Deutschland", federalState: "BY", metroCity: "Augsburg" },
+ holidaySummary: { count: 2, workdayCount: 2, hoursDeduction: 16 },
+ absenceSummary: { dayEquivalent: 0.5, hoursDeduction: 4 },
+ });
+
+ expect(insight).toEqual(
+ expect.objectContaining({
+ kind: "chargeability",
+ title: "Bruce Banner · 2026-01",
+ metrics: expect.arrayContaining([
+ expect.objectContaining({ label: "Chargeability", value: "42.9%", tone: "warn" }),
+ expect.objectContaining({ label: "Available", value: "168 h" }),
+ expect.objectContaining({ label: "Target", value: "134.4 h" }),
+ ]),
+ sections: expect.arrayContaining([
+ expect.objectContaining({
+ title: "Basis",
+ metrics: expect.arrayContaining([
+ expect.objectContaining({ label: "Location", value: "Augsburg, BY, Deutschland" }),
+ ]),
+ }),
+ expect.objectContaining({
+ title: "Deductions",
+ metrics: expect.arrayContaining([
+ expect.objectContaining({ label: "Holiday deduction", value: "16 h" }),
+ expect.objectContaining({ label: "Absence deduction", value: "4 h" }),
+ ]),
+ }),
+ ]),
+ }),
+ );
+ });
+
+ it("builds a holiday comparison insight with regional scope counts", () => {
+ const insight = buildAssistantInsight("list_holidays_by_region", {
+ locationContext: { countryCode: "DE", federalState: "BY" },
+ count: 14,
+ periodStart: "2026-01-01",
+ periodEnd: "2026-12-31",
+ summary: {
+ byScope: [
+ { scope: "NATIONAL", count: 9 },
+ { scope: "STATE", count: 5 },
+ ],
+ },
+ });
+
+ expect(insight).toEqual(
+ expect.objectContaining({
+ kind: "holiday_region",
+ title: "BY, DE",
+ metrics: expect.arrayContaining([
+ expect.objectContaining({ label: "Resolved holidays", value: "14" }),
+ ]),
+ sections: [
+ expect.objectContaining({
+ title: "Scopes",
+ metrics: expect.arrayContaining([
+ expect.objectContaining({ label: "STATE", value: "5" }),
+ ]),
+ }),
+ ],
+ }),
+ );
+ });
+
+ it("builds a best-resource insight from staffing recommendations", () => {
+ const insight = buildAssistantInsight("find_best_project_resource", {
+ project: { name: "Gelddruckmaschine", shortCode: "GDM" },
+ period: { startDate: "2026-04-01", endDate: "2026-04-21", minHoursPerDay: 3, rankingMode: "lowest_lcr" },
+ candidateCount: 4,
+ bestMatch: {
+ name: "Jane Doe",
+ role: "TD",
+ chapter: "Lighting",
+ country: "Deutschland",
+ federalState: "BY",
+ metroCity: "Muenchen",
+ lcr: "€85.00",
+ remainingHours: 74,
+ remainingHoursPerDay: 3.5,
+ availableHours: 120,
+ baseAvailableHours: 136,
+ holidaySummary: { hoursDeduction: 8 },
+ absenceSummary: { hoursDeduction: 0 },
+ },
+ });
+
+ expect(insight).toEqual(
+ expect.objectContaining({
+ kind: "resource_match",
+ title: "GDM staffing",
+ metrics: expect.arrayContaining([
+ expect.objectContaining({ label: "Best match", value: "Jane Doe" }),
+ expect.objectContaining({ label: "Remaining", value: "74 h", tone: "good" }),
+ ]),
+ sections: expect.arrayContaining([
+ expect.objectContaining({
+ title: "Selection",
+ metrics: expect.arrayContaining([
+ expect.objectContaining({ label: "Location", value: "Muenchen, BY, Deutschland" }),
+ ]),
+ }),
+ ]),
+ }),
+ );
+ });
+});
diff --git a/packages/api/src/__tests__/assistant-router.test.ts b/packages/api/src/__tests__/assistant-router.test.ts
new file mode 100644
index 0000000..fe6ec0b
--- /dev/null
+++ b/packages/api/src/__tests__/assistant-router.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { PermissionKey, type PermissionKey as PermissionKeyValue } from "@capakraken/shared";
+import { getAvailableAssistantTools } from "../router/assistant.js";
+
+function getToolNames(permissions: PermissionKeyValue[]) {
+ return getAvailableAssistantTools(new Set(permissions)).map((tool) => tool.function.name);
+}
+
+describe("assistant router tool gating", () => {
+ it("hides advanced tools unless the dedicated assistant permission is granted", () => {
+ const withoutAdvanced = getToolNames([PermissionKey.VIEW_COSTS]);
+ const withAdvanced = getToolNames([
+ PermissionKey.VIEW_COSTS,
+ PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS,
+ ]);
+
+ expect(withoutAdvanced).not.toContain("find_best_project_resource");
+ expect(withAdvanced).toContain("find_best_project_resource");
+ });
+
+ it("keeps user administration tools behind manageUsers", () => {
+ const withoutManageUsers = getToolNames([]);
+ const withManageUsers = getToolNames([PermissionKey.MANAGE_USERS]);
+
+ expect(withoutManageUsers).not.toContain("list_users");
+ expect(withManageUsers).toContain("list_users");
+ });
+
+ it("continues to hide cost-aware advanced tools when viewCosts is missing", () => {
+ const names = getToolNames([PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS]);
+
+ expect(names).not.toContain("find_best_project_resource");
+ });
+});
diff --git a/packages/api/src/__tests__/assistant-tools-advanced.test.ts b/packages/api/src/__tests__/assistant-tools-advanced.test.ts
new file mode 100644
index 0000000..c695666
--- /dev/null
+++ b/packages/api/src/__tests__/assistant-tools-advanced.test.ts
@@ -0,0 +1,262 @@
+import { describe, expect, it, vi } from "vitest";
+import { PermissionKey } from "@capakraken/shared";
+
+vi.mock("@capakraken/application", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
+ getDashboardPeakTimes: vi.fn().mockResolvedValue([]),
+ };
+});
+
+import { executeTool, type ToolContext } from "../router/assistant-tools.js";
+
+function createToolContext(
+ db: Record,
+ permissions: PermissionKey[] = [],
+): ToolContext {
+ return {
+ db: db as ToolContext["db"],
+ userId: "user_1",
+ userRole: "ADMIN",
+ permissions: new Set(permissions),
+ };
+}
+
+describe("assistant advanced tools and scoping", () => {
+ it("finds the best project resource with holiday-aware remaining capacity and LCR ranking", async () => {
+ const assignmentFindMany = vi
+ .fn()
+ .mockResolvedValueOnce([
+ {
+ resourceId: "res_carol",
+ hoursPerDay: 2,
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-16T00:00:00.000Z"),
+ status: "PROPOSED",
+ resource: {
+ id: "res_carol",
+ eid: "carol.danvers",
+ displayName: "Carol Danvers",
+ chapter: "Delivery",
+ lcrCents: 7664,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "HH",
+ metroCityId: "city_hamburg",
+ country: { code: "DE", name: "Deutschland" },
+ metroCity: { name: "Hamburg" },
+ areaRole: { name: "Artist" },
+ },
+ },
+ {
+ resourceId: "res_steve",
+ hoursPerDay: 4,
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-16T00:00:00.000Z"),
+ status: "CONFIRMED",
+ resource: {
+ id: "res_steve",
+ eid: "steve.rogers",
+ displayName: "Steve Rogers",
+ chapter: "Delivery",
+ lcrCents: 13377,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: "city_augsburg",
+ country: { code: "DE", name: "Deutschland" },
+ metroCity: { name: "Augsburg" },
+ areaRole: { name: "Artist" },
+ },
+ },
+ ])
+ .mockResolvedValueOnce([
+ {
+ resourceId: "res_carol",
+ projectId: "project_lari",
+ hoursPerDay: 2,
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-16T00:00:00.000Z"),
+ status: "PROPOSED",
+ project: { name: "Gelddruckmaschine", shortCode: "LARI" },
+ },
+ {
+ resourceId: "res_steve",
+ projectId: "project_lari",
+ hoursPerDay: 4,
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-16T00:00:00.000Z"),
+ status: "CONFIRMED",
+ project: { name: "Gelddruckmaschine", shortCode: "LARI" },
+ },
+ ]);
+
+ const ctx = createToolContext(
+ {
+ project: {
+ findUnique: vi
+ .fn()
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({
+ id: "project_lari",
+ name: "Gelddruckmaschine",
+ shortCode: "LARI",
+ status: "ACTIVE",
+ responsiblePerson: "Larissa Joos",
+ }),
+ findFirst: vi.fn(),
+ },
+ assignment: {
+ findMany: assignmentFindMany,
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ },
+ [PermissionKey.VIEW_COSTS, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS],
+ );
+
+ const result = await executeTool(
+ "find_best_project_resource",
+ JSON.stringify({
+ projectIdentifier: "LARI",
+ startDate: "2026-01-05",
+ endDate: "2026-01-16",
+ minHoursPerDay: 3,
+ rankingMode: "lowest_lcr",
+ }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as {
+ project: { shortCode: string };
+ candidateCount: number;
+ bestMatch: {
+ name: string;
+ remainingHoursPerDay: number;
+ lcrCents: number | null;
+ federalState: string | null;
+ metroCity: string | null;
+ baseAvailableHours: number;
+ holidaySummary: { count: number };
+ };
+ candidates: Array<{
+ name: string;
+ remainingHoursPerDay: number;
+ workingDays: number;
+ baseAvailableHours: number;
+ holidaySummary: { count: number; hoursDeduction: number };
+ capacityBreakdown: { holidayHoursDeduction: number };
+ }>;
+ };
+
+ expect(parsed.project.shortCode).toBe("LARI");
+ expect(parsed.candidateCount).toBe(2);
+ expect(parsed.bestMatch).toEqual(
+ expect.objectContaining({
+ name: "Carol Danvers",
+ remainingHoursPerDay: 6,
+ lcrCents: 7664,
+ federalState: "HH",
+ metroCity: "Hamburg",
+ baseAvailableHours: 80,
+ holidaySummary: expect.objectContaining({ count: 0 }),
+ }),
+ );
+ expect(parsed.candidates).toEqual([
+ expect.objectContaining({
+ name: "Carol Danvers",
+ remainingHoursPerDay: 6,
+ workingDays: 10,
+ baseAvailableHours: 80,
+ holidaySummary: expect.objectContaining({ count: 0, hoursDeduction: 0 }),
+ capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 0 }),
+ }),
+ expect.objectContaining({
+ name: "Steve Rogers",
+ remainingHoursPerDay: 4,
+ workingDays: 9,
+ baseAvailableHours: 80,
+ holidaySummary: expect.objectContaining({ count: 1, hoursDeduction: 8 }),
+ capacityBreakdown: expect.objectContaining({ holidayHoursDeduction: 8 }),
+ }),
+ ]);
+ });
+
+ it("requires the dedicated advanced assistant permission for the high-level resource tool", async () => {
+ const ctx = createToolContext({}, [PermissionKey.VIEW_COSTS]);
+
+ const result = await executeTool(
+ "find_best_project_resource",
+ JSON.stringify({ projectIdentifier: "LARI" }),
+ ctx,
+ );
+
+ expect(JSON.parse(result.content)).toEqual(
+ expect.objectContaining({
+ error: expect.stringContaining(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS),
+ }),
+ );
+ });
+
+ it("scopes assistant notification listing to the current user", async () => {
+ const findMany = vi.fn().mockResolvedValue([]);
+ const ctx = createToolContext({
+ notification: {
+ findMany,
+ },
+ });
+
+ await executeTool("list_notifications", JSON.stringify({ unreadOnly: true }), ctx);
+
+ expect(findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ userId: "user_1",
+ readAt: null,
+ }),
+ }),
+ );
+ });
+
+ it("rejects marking notifications that do not belong to the current user", async () => {
+ const update = vi.fn();
+ const ctx = createToolContext({
+ notification: {
+ findUnique: vi.fn().mockResolvedValue({ id: "notif_1", userId: "someone_else" }),
+ update,
+ },
+ });
+
+ const result = await executeTool(
+ "mark_notification_read",
+ JSON.stringify({ notificationId: "notif_1" }),
+ ctx,
+ );
+
+ expect(JSON.parse(result.content)).toEqual({
+ error: "Access denied: this notification does not belong to you",
+ });
+ expect(update).not.toHaveBeenCalled();
+ });
+
+ it("requires manageUsers before listing users through the assistant", async () => {
+ const findMany = vi.fn();
+ const ctx = createToolContext({
+ user: {
+ findMany,
+ },
+ });
+
+ const result = await executeTool("list_users", JSON.stringify({ limit: 10 }), ctx);
+
+ expect(JSON.parse(result.content)).toEqual(
+ expect.objectContaining({
+ error: expect.stringContaining(PermissionKey.MANAGE_USERS),
+ }),
+ );
+ expect(findMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/api/src/__tests__/assistant-tools-holidays.test.ts b/packages/api/src/__tests__/assistant-tools-holidays.test.ts
new file mode 100644
index 0000000..a1effe2
--- /dev/null
+++ b/packages/api/src/__tests__/assistant-tools-holidays.test.ts
@@ -0,0 +1,575 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("@capakraken/application", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ getDashboardBudgetForecast: vi.fn().mockResolvedValue([]),
+ };
+});
+
+import { executeTool, type ToolContext } from "../router/assistant-tools.js";
+
+function createToolContext(
+ db: Record,
+ permissions: string[] = [],
+): ToolContext {
+ return {
+ db: db as ToolContext["db"],
+ userId: "user_1",
+ userRole: "ADMIN",
+ permissions: new Set(permissions) as ToolContext["permissions"],
+ };
+}
+
+describe("assistant holiday tools", () => {
+ it("lists regional holidays and distinguishes Bavaria from Hamburg", async () => {
+ const ctx = createToolContext({});
+
+ const bavaria = await executeTool(
+ "list_holidays_by_region",
+ JSON.stringify({ countryCode: "DE", federalState: "BY", year: 2026 }),
+ ctx,
+ );
+ const hamburg = await executeTool(
+ "list_holidays_by_region",
+ JSON.stringify({ countryCode: "DE", federalState: "HH", year: 2026 }),
+ ctx,
+ );
+
+ const bavariaResult = JSON.parse(bavaria.content) as {
+ count: number;
+ locationContext: { federalState: string | null };
+ summary: { byScope: Array<{ scope: string; count: number }> };
+ holidays: Array<{ name: string; date: string }>;
+ };
+ const hamburgResult = JSON.parse(hamburg.content) as {
+ count: number;
+ locationContext: { federalState: string | null };
+ holidays: Array<{ name: string; date: string }>;
+ };
+
+ expect(bavariaResult.count).toBeGreaterThan(hamburgResult.count);
+ expect(bavariaResult.locationContext.federalState).toBe("BY");
+ expect(bavariaResult.summary.byScope).toEqual(
+ expect.arrayContaining([expect.objectContaining({ scope: "STATE" })]),
+ );
+ expect(bavariaResult.holidays).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
+ ]),
+ );
+ expect(hamburgResult.holidays).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
+ ]),
+ );
+ });
+
+ it("resolves resource-specific holidays including city-local dates", async () => {
+ const db = {
+ resource: {
+ findUnique: vi
+ .fn()
+ .mockResolvedValueOnce(null)
+ .mockResolvedValueOnce({ id: "res_1", eid: "bruce.banner", displayName: "Bruce Banner", federalState: "BY", countryId: "country_de", metroCityId: "city_augsburg", country: { code: "DE", name: "Deutschland" }, metroCity: { name: "Augsburg" } }),
+ findFirst: vi.fn(),
+ },
+ };
+ const ctx = createToolContext(db);
+
+ const result = await executeTool(
+ "get_resource_holidays",
+ JSON.stringify({ identifier: "bruce.banner", year: 2026 }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as {
+ resource: { eid: string; federalState: string | null; metroCity: string | null };
+ summary: { byScope: Array<{ scope: string; count: number }> };
+ holidays: Array<{ name: string; date: string }>;
+ };
+
+ expect(parsed.resource).toEqual(
+ expect.objectContaining({
+ eid: "bruce.banner",
+ federalState: "BY",
+ metroCity: "Augsburg",
+ }),
+ );
+ expect(parsed.holidays).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "Augsburger Friedensfest", date: "2026-08-08" }),
+ ]),
+ );
+ expect(parsed.summary.byScope).toEqual(
+ expect.arrayContaining([expect.objectContaining({ scope: "CITY" })]),
+ );
+ });
+
+ it("calculates chargeability with regional holidays excluded from booked and available hours", async () => {
+ const db = {
+ resource: {
+ findUnique: vi
+ .fn()
+ .mockResolvedValueOnce({
+ id: "res_1",
+ displayName: "Bruce Banner",
+ eid: "bruce.banner",
+ fte: 1,
+ chargeabilityTarget: 80,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE", dailyWorkingHours: 8 },
+ metroCity: null,
+ }),
+ findFirst: vi.fn(),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ hoursPerDay: 8,
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ status: "CONFIRMED",
+ project: { name: "Gamma", shortCode: "GAM" },
+ },
+ ]),
+ },
+ };
+ const ctx = createToolContext(db);
+
+ const result = await executeTool(
+ "get_chargeability",
+ JSON.stringify({ resourceId: "res_1", month: "2026-01" }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as {
+ baseWorkingDays: number;
+ baseAvailableHours: number;
+ availableHours: number;
+ bookedHours: number;
+ workingDays: number;
+ targetHours: number;
+ unassignedHours: number;
+ holidaySummary: { count: number; workdayCount: number; hoursDeduction: number };
+ capacityBreakdown: { formula: string; holidayHoursDeduction: number; absenceHoursDeduction: number };
+ locationContext: { federalState: string | null };
+ allocations: Array<{ hours: number }>;
+ };
+
+ expect(parsed.bookedHours).toBe(8);
+ expect(parsed.allocations).toEqual([expect.objectContaining({ hours: 8 })]);
+ expect(parsed.baseWorkingDays).toBe(23);
+ expect(parsed.baseAvailableHours).toBe(184);
+ expect(parsed.availableHours).toBe(168);
+ expect(parsed.workingDays).toBe(21);
+ expect(parsed.targetHours).toBe(134.4);
+ expect(parsed.unassignedHours).toBe(160);
+ expect(parsed.locationContext.federalState).toBe("BY");
+ expect(parsed.holidaySummary).toEqual(
+ expect.objectContaining({
+ count: 2,
+ workdayCount: 2,
+ hoursDeduction: 16,
+ }),
+ );
+ expect(parsed.capacityBreakdown).toEqual(
+ expect.objectContaining({
+ formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
+ holidayHoursDeduction: 16,
+ absenceHoursDeduction: 0,
+ }),
+ );
+ });
+
+ it("returns holiday-aware budget forecast data from the dashboard use-case", async () => {
+ const { getDashboardBudgetForecast } = await import("@capakraken/application");
+ vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
+ {
+ projectId: "project_1",
+ projectName: "Gelddruckmaschine",
+ shortCode: "GDM",
+ budgetCents: 100_000,
+ spentCents: 60_000,
+ burnRate: 5_000,
+ pctUsed: 60,
+ estimatedExhaustionDate: "2026-02-20",
+ },
+ ]);
+
+ const ctx = createToolContext({}, ["viewCosts"]);
+ const result = await executeTool("get_budget_forecast", "{}", ctx);
+ const parsed = JSON.parse(result.content) as {
+ forecasts: Array<{
+ projectName: string;
+ shortCode: string;
+ budgetCents: number;
+ spentCents: number;
+ remainingCents: number;
+ projectedCents: number;
+ burnRateCents: number;
+ burnStatus: string;
+ }>;
+ };
+
+ expect(getDashboardBudgetForecast).toHaveBeenCalled();
+ expect(parsed.forecasts).toEqual([
+ expect.objectContaining({
+ projectName: "Gelddruckmaschine",
+ shortCode: "GDM",
+ budgetCents: 100_000,
+ spentCents: 60_000,
+ remainingCents: 40_000,
+ projectedCents: 100_000,
+ burnRateCents: 5_000,
+ burnStatus: "on_track",
+ }),
+ ]);
+ });
+
+ it("checks resource availability with regional holidays excluded from capacity", async () => {
+ const db = {
+ resource: {
+ findUnique: vi
+ .fn()
+ .mockResolvedValueOnce({
+ id: "res_1",
+ displayName: "Bruce Banner",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ }),
+ findFirst: vi.fn(),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ hoursPerDay: 8,
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ status: "CONFIRMED",
+ project: { name: "Gamma", shortCode: "GAM" },
+ },
+ ]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+ const ctx = createToolContext(db);
+
+ const result = await executeTool(
+ "check_resource_availability",
+ JSON.stringify({ resourceId: "res_1", startDate: "2026-01-05", endDate: "2026-01-06" }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as {
+ workingDays: number;
+ periodAvailableHours: number;
+ periodBookedHours: number;
+ periodRemainingHours: number;
+ availableHoursPerDay: number;
+ isFullyAvailable: boolean;
+ };
+
+ expect(parsed.workingDays).toBe(1);
+ expect(parsed.periodAvailableHours).toBe(8);
+ expect(parsed.periodBookedHours).toBe(8);
+ expect(parsed.periodRemainingHours).toBe(0);
+ expect(parsed.availableHoursPerDay).toBe(0);
+ expect(parsed.isFullyAvailable).toBe(false);
+ });
+
+ it("keeps scenario simulation flat when a proposed change falls on a local holiday", async () => {
+ const db = {
+ project: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "project_1",
+ name: "Holiday Project",
+ budgetCents: 500_000,
+ startDate: new Date("2026-01-01T00:00:00.000Z"),
+ endDate: new Date("2026-01-31T00:00:00.000Z"),
+ }),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "assignment_1",
+ resourceId: "res_1",
+ hoursPerDay: 8,
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-05T00:00:00.000Z"),
+ status: "CONFIRMED",
+ resource: {
+ id: "res_1",
+ displayName: "Bruce Banner",
+ lcrCents: 100,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ chargeabilityTarget: 80,
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE", dailyWorkingHours: 8 },
+ metroCity: null,
+ },
+ },
+ ]),
+ },
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "res_1",
+ displayName: "Bruce Banner",
+ lcrCents: 100,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ chargeabilityTarget: 80,
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE", dailyWorkingHours: 8 },
+ metroCity: null,
+ },
+ ]),
+ },
+ };
+ const ctx = createToolContext(db, ["manageAllocations"]);
+
+ const result = await executeTool(
+ "simulate_scenario",
+ JSON.stringify({
+ projectId: "project_1",
+ changes: [
+ {
+ resourceId: "res_1",
+ startDate: "2026-01-06",
+ endDate: "2026-01-06",
+ hoursPerDay: 8,
+ },
+ ],
+ }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as {
+ baseline: { totalHours: number; totalCostCents: number };
+ scenario: { totalHours: number; totalCostCents: number };
+ delta: { hours: number; costCents: number };
+ };
+
+ expect(parsed.baseline).toEqual(
+ expect.objectContaining({
+ totalHours: 8,
+ totalCostCents: 800,
+ }),
+ );
+ expect(parsed.scenario).toEqual(
+ expect.objectContaining({
+ totalHours: 8,
+ totalCostCents: 800,
+ }),
+ );
+ expect(parsed.delta).toEqual(
+ expect.objectContaining({
+ hours: 0,
+ costCents: 0,
+ }),
+ );
+ });
+
+ it("prefers resources without a local holiday in staffing suggestions", async () => {
+ const db = {
+ project: {
+ findFirst: vi.fn().mockResolvedValue({
+ id: "project_1",
+ name: "Holiday Project",
+ shortCode: "HP",
+ startDate: new Date("2026-01-06T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ }),
+ },
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "res_by",
+ displayName: "Bavaria",
+ eid: "BY-1",
+ fte: 1,
+ lcrCents: 10000,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ areaRole: { name: "Consultant" },
+ chapter: "CGI",
+ assignments: [],
+ },
+ {
+ id: "res_hh",
+ displayName: "Hamburg",
+ eid: "HH-1",
+ fte: 1,
+ lcrCents: 10000,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "HH",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ areaRole: { name: "Consultant" },
+ chapter: "CGI",
+ assignments: [],
+ },
+ ]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+ const ctx = createToolContext(db);
+
+ const result = await executeTool(
+ "get_staffing_suggestions",
+ JSON.stringify({ projectId: "project_1", limit: 5 }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as {
+ suggestions: Array<{ name: string; availableHours: number }>;
+ };
+
+ expect(parsed.suggestions).toHaveLength(1);
+ expect(parsed.suggestions[0]).toEqual(
+ expect.objectContaining({ name: "Hamburg", availableHours: 8 }),
+ );
+ });
+
+ it("finds capacity with local holidays respected", async () => {
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "res_by",
+ displayName: "Bavaria",
+ eid: "BY-1",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ areaRole: { name: "Consultant" },
+ chapter: "CGI",
+ assignments: [],
+ },
+ {
+ id: "res_hh",
+ displayName: "Hamburg",
+ eid: "HH-1",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "HH",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ areaRole: { name: "Consultant" },
+ chapter: "CGI",
+ assignments: [],
+ },
+ ]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+ const ctx = createToolContext(db);
+
+ const result = await executeTool(
+ "find_capacity",
+ JSON.stringify({ startDate: "2026-01-06", endDate: "2026-01-06", minHoursPerDay: 1 }),
+ ctx,
+ );
+
+ const parsed = JSON.parse(result.content) as {
+ results: Array<{ name: string; availableHours: number; availableHoursPerDay: number }>;
+ };
+
+ expect(parsed.results).toHaveLength(1);
+ expect(parsed.results[0]).toEqual(
+ expect.objectContaining({ name: "Hamburg", availableHours: 8, availableHoursPerDay: 8 }),
+ );
+ });
+
+ it("uses holiday-aware assignment hours for assistant shoring ratio", async () => {
+ const db = {
+ project: {
+ findUnique: vi
+ .fn()
+ .mockResolvedValueOnce({
+ id: "project_1",
+ name: "Holiday Project",
+ shortCode: "HP",
+ shoringThreshold: 55,
+ onshoreCountryCode: "DE",
+ }),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ resourceId: "res_by",
+ hoursPerDay: 8,
+ startDate: new Date("2026-01-06T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ resource: {
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ },
+ },
+ {
+ resourceId: "res_in",
+ hoursPerDay: 8,
+ startDate: new Date("2026-01-06T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ resource: {
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_in",
+ federalState: null,
+ metroCityId: null,
+ country: { code: "IN" },
+ metroCity: null,
+ },
+ },
+ ]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+ const ctx = createToolContext(db);
+
+ const result = await executeTool(
+ "get_shoring_ratio",
+ JSON.stringify({ projectId: "project_1" }),
+ ctx,
+ );
+
+ expect(result.content).toContain("0% onshore (DE), 100% offshore");
+ expect(result.content).toContain("IN 100% (1 people)");
+ });
+});
diff --git a/packages/api/src/__tests__/chargeability-alerts.test.ts b/packages/api/src/__tests__/chargeability-alerts.test.ts
new file mode 100644
index 0000000..ae0fef0
--- /dev/null
+++ b/packages/api/src/__tests__/chargeability-alerts.test.ts
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@capakraken/application", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ isChargeabilityActualBooking: actual.isChargeabilityActualBooking,
+ listAssignmentBookings: vi.fn(),
+ };
+});
+
+import { listAssignmentBookings } from "@capakraken/application";
+import { checkChargeabilityAlerts } from "../lib/chargeability-alerts.js";
+
+describe("chargeability alerts", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-15T12:00:00.000Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("creates an alert when a regional holiday reduces booked hours below threshold", async () => {
+ const notifications: Array<{ userId: string; title: string; body?: string }> = [];
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "res_1",
+ displayName: "Bruce Banner",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ metroCityId: null,
+ federalState: "BY",
+ chargeabilityTarget: 21,
+ country: {
+ id: "country_de",
+ code: "DE",
+ dailyWorkingHours: 8,
+ scheduleRules: null,
+ },
+ managementLevelGroup: { targetPercentage: 0.21 },
+ metroCity: null,
+ },
+ ]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ notification: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ create: vi.fn().mockImplementation(async ({ data }) => {
+ notifications.push(data);
+ return { id: `notification_${notifications.length}`, userId: data.userId };
+ }),
+ },
+ user: {
+ findMany: vi.fn().mockResolvedValue([{ id: "manager_1" }]),
+ },
+ };
+
+ vi.mocked(listAssignmentBookings).mockResolvedValue([
+ {
+ id: "assignment_1",
+ projectId: "project_1",
+ resourceId: "res_1",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ hoursPerDay: 8,
+ dailyCostCents: 0,
+ status: "CONFIRMED",
+ project: {
+ id: "project_1",
+ name: "Gamma",
+ shortCode: "GAM",
+ status: "ACTIVE",
+ orderType: "CLIENT",
+ dynamicFields: null,
+ },
+ resource: { id: "res_1", displayName: "Bruce Banner", chapter: "CGI" },
+ },
+ ]);
+
+ const alertCount = await checkChargeabilityAlerts(db);
+
+ expect(alertCount).toBe(1);
+ expect(notifications).toHaveLength(1);
+ expect(notifications[0]?.title).toContain("Bruce Banner");
+ expect(notifications[0]?.body).toContain("gap: 16pp");
+ });
+});
diff --git a/packages/api/src/__tests__/chargeability-report-router.test.ts b/packages/api/src/__tests__/chargeability-report-router.test.ts
index 12d65ab..ba1e31d 100644
--- a/packages/api/src/__tests__/chargeability-report-router.test.ts
+++ b/packages/api/src/__tests__/chargeability-report-router.test.ts
@@ -45,6 +45,10 @@ describe("chargeability report router", () => {
eid: "E-001",
displayName: "Alice",
fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_es",
+ federalState: null,
+ metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_es",
@@ -143,6 +147,10 @@ describe("chargeability report router", () => {
eid: "E-001",
displayName: "Alice",
fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_es",
+ federalState: null,
+ metroCityId: "city_1",
chargeabilityTarget: 80,
country: {
id: "country_es",
@@ -204,4 +212,217 @@ describe("chargeability report router", () => {
expect(withProposed.resources[0]?.months[0]?.chg).toBeGreaterThan(0);
expect(withProposed.groupTotals[0]?.chg).toBeGreaterThan(strict.groupTotals[0]?.chg ?? 0);
});
+
+ it("reduces SAH for German public holidays based on the calendar", async () => {
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "resource_de",
+ eid: "E-001",
+ displayName: "Alice",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: null,
+ metroCityId: "city_1",
+ chargeabilityTarget: 80,
+ country: {
+ id: "country_de",
+ code: "DE",
+ dailyWorkingHours: 8,
+ scheduleRules: null,
+ },
+ orgUnit: { id: "org_1", name: "CGI" },
+ managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
+ managementLevel: { id: "level_1", name: "L7" },
+ metroCity: { id: "city_1", name: "Munich" },
+ },
+ ]),
+ },
+ project: {
+ findMany: vi.fn().mockResolvedValue([
+ { id: "project_full_month", utilizationCategory: { code: "Chg" } },
+ ]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+
+ vi.mocked(listAssignmentBookings).mockResolvedValue([
+ {
+ id: "assignment_full_month",
+ projectId: "project_full_month",
+ resourceId: "resource_de",
+ startDate: new Date("2026-01-01T00:00:00.000Z"),
+ endDate: new Date("2026-01-31T00:00:00.000Z"),
+ hoursPerDay: 7,
+ dailyCostCents: 0,
+ status: "CONFIRMED",
+ project: {
+ id: "project_full_month",
+ name: "Full Month Project",
+ shortCode: "FMP",
+ status: "ACTIVE",
+ orderType: "CLIENT",
+ dynamicFields: null,
+ },
+ resource: { id: "resource_de", displayName: "Alice", chapter: "CGI" },
+ },
+ ]);
+
+ const caller = createControllerCaller(db);
+ const report = await caller.getReport({
+ startMonth: "2026-01",
+ endMonth: "2026-01",
+ });
+
+ const month = report.resources[0]?.months[0];
+
+ expect(month).toBeDefined();
+ expect(month?.sah).toBe(168);
+ expect(month?.chg).toBeCloseTo(0.875, 5);
+ });
+
+ it("applies city-specific public holidays to SAH", async () => {
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "resource_augsburg",
+ eid: "E-001",
+ displayName: "Alice",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: "city_1",
+ chargeabilityTarget: 80,
+ country: {
+ id: "country_de",
+ code: "DE",
+ dailyWorkingHours: 8,
+ scheduleRules: null,
+ },
+ orgUnit: { id: "org_1", name: "CGI" },
+ managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
+ managementLevel: { id: "level_1", name: "L7" },
+ metroCity: { id: "city_1", name: "Augsburg" },
+ },
+ {
+ id: "resource_munich",
+ eid: "E-002",
+ displayName: "Bob",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: "city_2",
+ chargeabilityTarget: 80,
+ country: {
+ id: "country_de",
+ code: "DE",
+ dailyWorkingHours: 8,
+ scheduleRules: null,
+ },
+ orgUnit: { id: "org_1", name: "CGI" },
+ managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
+ managementLevel: { id: "level_1", name: "L7" },
+ metroCity: { id: "city_2", name: "Munich" },
+ },
+ ]),
+ },
+ project: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+
+ vi.mocked(listAssignmentBookings).mockResolvedValue([]);
+
+ const caller = createControllerCaller(db);
+ const report = await caller.getReport({
+ startMonth: "2028-08",
+ endMonth: "2028-08",
+ });
+
+ const augsburg = report.resources.find((resource) => resource.city === "Augsburg");
+ const munich = report.resources.find((resource) => resource.city === "Munich");
+
+ expect(augsburg?.months[0]?.sah).toBe((munich?.months[0]?.sah ?? 0) - 8);
+ });
+
+ it("respects individual weekday availability when computing booked hours", async () => {
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "resource_pt",
+ eid: "E-003",
+ displayName: "Carla",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 0 },
+ countryId: "country_de",
+ federalState: null,
+ metroCityId: "city_3",
+ chargeabilityTarget: 80,
+ country: {
+ id: "country_de",
+ code: "DE",
+ dailyWorkingHours: 8,
+ scheduleRules: null,
+ },
+ orgUnit: { id: "org_1", name: "CGI" },
+ managementLevelGroup: { id: "mgmt_1", name: "Senior", targetPercentage: 0.8 },
+ managementLevel: { id: "level_1", name: "L7" },
+ metroCity: { id: "city_3", name: "Berlin" },
+ },
+ ]),
+ },
+ project: {
+ findMany: vi.fn().mockResolvedValue([
+ { id: "project_week", utilizationCategory: { code: "Chg" } },
+ ]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+
+ vi.mocked(listAssignmentBookings).mockResolvedValue([
+ {
+ id: "assignment_week",
+ projectId: "project_week",
+ resourceId: "resource_pt",
+ startDate: new Date("2026-03-02T00:00:00.000Z"),
+ endDate: new Date("2026-03-06T00:00:00.000Z"),
+ hoursPerDay: 4,
+ dailyCostCents: 0,
+ status: "CONFIRMED",
+ project: {
+ id: "project_week",
+ name: "Week Project",
+ shortCode: "WP",
+ status: "ACTIVE",
+ orderType: "CLIENT",
+ dynamicFields: null,
+ },
+ resource: { id: "resource_pt", displayName: "Carla", chapter: "CGI" },
+ },
+ ]);
+
+ const caller = createControllerCaller(db);
+ const report = await caller.getReport({
+ startMonth: "2026-03",
+ endMonth: "2026-03",
+ });
+
+ const month = report.resources[0]?.months[0];
+
+ expect(month).toBeDefined();
+ expect(month?.chg).toBeCloseTo(16 / 144, 5);
+ });
});
diff --git a/packages/api/src/__tests__/computation-graph-router.test.ts b/packages/api/src/__tests__/computation-graph-router.test.ts
new file mode 100644
index 0000000..3e9ee0f
--- /dev/null
+++ b/packages/api/src/__tests__/computation-graph-router.test.ts
@@ -0,0 +1,195 @@
+import { SystemRole } from "@capakraken/shared";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { computationGraphRouter } from "../router/computation-graph.js";
+import { createCallerFactory } from "../trpc.js";
+
+const createCaller = createCallerFactory(computationGraphRouter);
+
+type ResourceGraphMeta = {
+ countryCode: string | null;
+ countryName: string | null;
+ federalState: string | null;
+ metroCityName: string | null;
+ resolvedHolidays: Array<{
+ date: string;
+ name: string;
+ scope: "COUNTRY" | "STATE" | "CITY";
+ calendarName: string | null;
+ }>;
+ factors: {
+ baseAvailableHours: number;
+ effectiveAvailableHours: number;
+ publicHolidayCount: number;
+ publicHolidayWorkdayCount: number;
+ publicHolidayHoursDeduction: number;
+ absenceDayCount: number;
+ absenceHoursDeduction: number;
+ };
+};
+
+function createControllerCaller(db: Record) {
+ return createCaller({
+ session: {
+ user: { email: "controller@example.com", name: "Controller", image: null },
+ expires: "2026-03-14T00:00:00.000Z",
+ },
+ db: db as never,
+ dbUser: {
+ id: "user_controller",
+ systemRole: SystemRole.CONTROLLER,
+ permissionOverrides: null,
+ },
+ });
+}
+
+function createDb(resourceFindImpl: ReturnType) {
+ return {
+ resource: {
+ findUniqueOrThrow: resourceFindImpl,
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ holidayCalendar: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ calculationRule: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+}
+
+function buildResource(overrides: Record = {}) {
+ return {
+ id: "resource_1",
+ displayName: "Bruce Banner",
+ eid: "bruce.banner",
+ fte: 1,
+ lcrCents: 5_000,
+ chargeabilityTarget: 80,
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ availability: {
+ monday: 8,
+ tuesday: 8,
+ wednesday: 8,
+ thursday: 8,
+ friday: 8,
+ saturday: 0,
+ sunday: 0,
+ },
+ country: {
+ id: "country_de",
+ code: "DE",
+ name: "Deutschland",
+ dailyWorkingHours: 8,
+ scheduleRules: null,
+ },
+ metroCity: null,
+ managementLevelGroup: {
+ id: "mlg_1",
+ name: "Senior",
+ targetPercentage: 0.8,
+ },
+ ...overrides,
+ };
+}
+
+describe("computation graph router", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("exposes location context and city-local holidays in the resource graph", async () => {
+ const db = createDb(vi.fn().mockResolvedValue(buildResource({
+ id: "resource_augsburg",
+ metroCityId: "city_augsburg",
+ metroCity: { id: "city_augsburg", name: "Augsburg" },
+ })));
+
+ const caller = createControllerCaller(db);
+ const result = await caller.getResourceData({
+ resourceId: "resource_augsburg",
+ month: "2026-08",
+ });
+ const meta = result.meta as ResourceGraphMeta;
+ const nodeIds = result.nodes.map((node) => node.id);
+ const holidayExamples = result.nodes.find((node) => node.id === "input.holidayExamples");
+
+ expect(new Set(nodeIds).size).toBe(nodeIds.length);
+ expect(nodeIds).toEqual(expect.arrayContaining([
+ "input.country",
+ "input.state",
+ "input.city",
+ "input.holidayContext",
+ "input.holidayExamples",
+ "sah.baseHours",
+ "sah.publicHolidayHours",
+ "sah.absenceHours",
+ ]));
+ expect(meta).toMatchObject({
+ countryCode: "DE",
+ countryName: "Deutschland",
+ federalState: "BY",
+ metroCityName: "Augsburg",
+ });
+ expect(meta.resolvedHolidays).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ date: "2026-08-08",
+ name: "Augsburger Friedensfest",
+ scope: "CITY",
+ }),
+ ]));
+ expect(meta.factors.publicHolidayCount).toBeGreaterThan(0);
+ expect(meta.factors.publicHolidayWorkdayCount).toBe(0);
+ expect(holidayExamples?.value).toEqual(expect.stringContaining("Augsburger Friedensfest"));
+ });
+
+ it("derives different effective SAH values for Bavaria and Hamburg", async () => {
+ const db = createDb(vi.fn()
+ .mockResolvedValueOnce(buildResource({
+ id: "resource_by",
+ federalState: "BY",
+ managementLevelGroup: null,
+ }))
+ .mockResolvedValueOnce(buildResource({
+ id: "resource_hh",
+ federalState: "HH",
+ managementLevelGroup: null,
+ })));
+
+ const caller = createControllerCaller(db);
+ const bavaria = await caller.getResourceData({
+ resourceId: "resource_by",
+ month: "2026-01",
+ });
+ const hamburg = await caller.getResourceData({
+ resourceId: "resource_hh",
+ month: "2026-01",
+ });
+
+ const bavariaMeta = bavaria.meta as ResourceGraphMeta;
+ const hamburgMeta = hamburg.meta as ResourceGraphMeta;
+
+ expect(bavariaMeta.federalState).toBe("BY");
+ expect(hamburgMeta.federalState).toBe("HH");
+ expect(bavariaMeta.factors.baseAvailableHours).toBe(176);
+ expect(hamburgMeta.factors.baseAvailableHours).toBe(176);
+ expect(bavariaMeta.factors.effectiveAvailableHours).toBe(160);
+ expect(hamburgMeta.factors.effectiveAvailableHours).toBe(168);
+ expect(bavariaMeta.factors.publicHolidayWorkdayCount).toBe(2);
+ expect(hamburgMeta.factors.publicHolidayWorkdayCount).toBe(1);
+ expect(bavariaMeta.factors.publicHolidayHoursDeduction).toBe(16);
+ expect(hamburgMeta.factors.publicHolidayHoursDeduction).toBe(8);
+ expect(bavariaMeta.resolvedHolidays).toEqual(expect.arrayContaining([
+ expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06", scope: "STATE" }),
+ ]));
+ expect(hamburgMeta.resolvedHolidays).not.toEqual(expect.arrayContaining([
+ expect.objectContaining({ name: "Heilige Drei Könige", date: "2026-01-06" }),
+ ]));
+ });
+});
diff --git a/packages/api/src/__tests__/dashboard-router.test.ts b/packages/api/src/__tests__/dashboard-router.test.ts
index dd38410..e50f90f 100644
--- a/packages/api/src/__tests__/dashboard-router.test.ts
+++ b/packages/api/src/__tests__/dashboard-router.test.ts
@@ -10,6 +10,7 @@ vi.mock("@capakraken/application", async (importOriginal) => {
getDashboardDemand: vi.fn(),
getDashboardTopValueResources: vi.fn(),
getDashboardChargeabilityOverview: vi.fn(),
+ getDashboardBudgetForecast: vi.fn(),
};
});
@@ -29,6 +30,7 @@ import {
getDashboardDemand,
getDashboardTopValueResources,
getDashboardChargeabilityOverview,
+ getDashboardBudgetForecast,
} from "@capakraken/application";
import { dashboardRouter } from "../router/dashboard.js";
import { createCallerFactory } from "../trpc.js";
@@ -302,4 +304,52 @@ describe("dashboard router", () => {
);
});
});
+
+ describe("getBudgetForecast", () => {
+ it("returns budget forecast rows with calendar location context", async () => {
+ vi.mocked(getDashboardBudgetForecast).mockResolvedValue([
+ {
+ projectId: "project_1",
+ projectName: "Alpha",
+ shortCode: "ALPHA",
+ clientId: "client_1",
+ clientName: "Client One",
+ budgetCents: 100_000,
+ spentCents: 40_000,
+ remainingCents: 60_000,
+ burnRate: 10_000,
+ estimatedExhaustionDate: "2026-06-30",
+ pctUsed: 40,
+ activeAssignmentCount: 2,
+ calendarLocations: [
+ {
+ countryCode: "DE",
+ countryName: "Germany",
+ federalState: "BY",
+ metroCityName: "Munich",
+ activeAssignmentCount: 2,
+ burnRateCents: 10_000,
+ },
+ ],
+ },
+ ]);
+
+ const caller = createProtectedCaller({});
+ const result = await caller.getBudgetForecast();
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ projectName: "Alpha",
+ activeAssignmentCount: 2,
+ calendarLocations: [
+ expect.objectContaining({
+ countryCode: "DE",
+ federalState: "BY",
+ metroCityName: "Munich",
+ }),
+ ],
+ });
+ expect(getDashboardBudgetForecast).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/packages/api/src/__tests__/effort-rule-router.test.ts b/packages/api/src/__tests__/effort-rule-router.test.ts
index 120d262..35d730f 100644
--- a/packages/api/src/__tests__/effort-rule-router.test.ts
+++ b/packages/api/src/__tests__/effort-rule-router.test.ts
@@ -150,6 +150,7 @@ describe("effortRule.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -180,6 +181,7 @@ describe("effortRule.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -212,6 +214,7 @@ describe("effortRule.update", () => {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -236,6 +239,7 @@ describe("effortRule.update", () => {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -281,6 +285,7 @@ describe("effortRule.delete", () => {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
diff --git a/packages/api/src/__tests__/entitlement-router.test.ts b/packages/api/src/__tests__/entitlement-router.test.ts
index 7600c39..d97b482 100644
--- a/packages/api/src/__tests__/entitlement-router.test.ts
+++ b/packages/api/src/__tests__/entitlement-router.test.ts
@@ -16,7 +16,17 @@ const createCaller = createCallerFactory(entitlementRouter);
/** Injects a default resource ownership mock so the ownership check in getBalance passes */
function createProtectedCaller(db: Record) {
const withResourceOwnership = {
- resource: { findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }) },
+ resource: {
+ findUnique: vi.fn().mockImplementation(async (args?: { select?: Record }) => {
+ const select = args?.select ?? {};
+ return {
+ ...(select.userId ? { userId: "user_1" } : {}),
+ ...(select.federalState ? { federalState: "BY" } : {}),
+ ...(select.country ? { country: { code: "DE" } } : {}),
+ ...(select.metroCity ? { metroCity: null } : {}),
+ };
+ }),
+ },
...db,
};
return createCaller({
@@ -80,6 +90,14 @@ function sampleEntitlement(overrides: Record = {}) {
};
}
+function mockEntitlementFindUniqueByYear(
+ entitlementsByYear: Record | null>,
+) {
+ return vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
+ entitlementsByYear[where.resourceId_year.year] ?? null
+ ));
+}
+
// ─── getBalance ──────────────────────────────────────────────────────────────
describe("entitlement.getBalance", () => {
@@ -90,7 +108,7 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
- findUnique: vi.fn().mockResolvedValue(entitlement),
+ findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
@@ -129,10 +147,9 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
vacationEntitlement: {
- findUnique: vi
- .fn()
- .mockResolvedValueOnce(null) // current year not found
- .mockResolvedValueOnce(prevEntitlement), // previous year found
+ findUnique: mockEntitlementFindUniqueByYear({
+ 2025: prevEntitlement,
+ }),
create: vi.fn().mockResolvedValue(createdEntitlement),
update: vi.fn().mockResolvedValue(createdEntitlement),
},
@@ -164,7 +181,7 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
- findUnique: vi.fn().mockResolvedValue(entitlement),
+ findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
@@ -185,12 +202,14 @@ describe("entitlement.getBalance", () => {
findUnique: vi.fn().mockResolvedValue(null),
},
vacationEntitlement: {
- findUnique: vi.fn().mockResolvedValue(entitlement),
+ findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi
.fn()
+ // Public holiday vacations for holiday context
+ .mockResolvedValueOnce([])
// First call: balance-type vacations (for syncEntitlement)
.mockResolvedValueOnce([])
// Second call: sick days
@@ -209,19 +228,169 @@ describe("entitlement.getBalance", () => {
expect(result.sickDays).toBe(3);
});
+
+ it("does not deduct city-specific public holidays from leave balance", async () => {
+ const entitlement = sampleEntitlement({ usedDays: 0, pendingDays: 0, entitledDays: 30, carryoverDays: 0 });
+ const db = {
+ systemSettings: {
+ findUnique: vi.fn().mockResolvedValue(null),
+ },
+ resource: {
+ findUnique: vi.fn().mockResolvedValue({
+ userId: "user_1",
+ federalState: "BY",
+ country: { code: "DE" },
+ metroCity: { name: "Augsburg" },
+ }),
+ },
+ vacationEntitlement: {
+ findUnique: mockEntitlementFindUniqueByYear({ 2028: entitlement }),
+ update: vi.fn().mockImplementation(async ({ data }) => ({
+ ...entitlement,
+ ...data,
+ })),
+ },
+ vacation: {
+ findMany: vi
+ .fn()
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([
+ {
+ startDate: new Date("2028-08-08T00:00:00.000Z"),
+ endDate: new Date("2028-08-08T00:00:00.000Z"),
+ status: "APPROVED",
+ isHalfDay: false,
+ },
+ ])
+ .mockResolvedValueOnce([])
+ .mockResolvedValueOnce([]),
+ },
+ };
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.getBalance({ resourceId: "res_1", year: 2028 });
+
+ expect(result.usedDays).toBe(0);
+ expect(result.remainingDays).toBe(30);
+ });
+
+ it("recomputes carryover from the previous year when the next year already exists", async () => {
+ const entitlements = new Map([
+ [2025, sampleEntitlement({
+ id: "ent_2025",
+ year: 2025,
+ entitledDays: 28,
+ carryoverDays: 0,
+ usedDays: 8,
+ pendingDays: 0,
+ })],
+ [2026, sampleEntitlement({
+ id: "ent_2026",
+ year: 2026,
+ entitledDays: 28,
+ carryoverDays: 0,
+ usedDays: 0,
+ pendingDays: 0,
+ })],
+ ]);
+ const db = {
+ systemSettings: {
+ findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
+ },
+ resource: {
+ findUnique: vi.fn().mockResolvedValue({
+ userId: "user_1",
+ federalState: "BY",
+ countryId: "country_de",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ }),
+ },
+ holidayCalendar: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ vacationEntitlement: {
+ findUnique: vi.fn().mockImplementation(async ({ where }: { where: { resourceId_year: { year: number } } }) => (
+ entitlements.get(where.resourceId_year.year) ?? null
+ )),
+ create: vi.fn(),
+ update: vi.fn().mockImplementation(async ({ where, data }: {
+ where: { id: string };
+ data: Record;
+ }) => {
+ const current = [...entitlements.values()].find((entry) => entry.id === where.id);
+ if (!current) {
+ throw new Error(`Unknown entitlement ${where.id}`);
+ }
+ const updated = { ...current, ...data };
+ entitlements.set(updated.year, updated);
+ return updated;
+ }),
+ },
+ vacation: {
+ findMany: vi
+ .fn()
+ // 2025 holiday context
+ .mockResolvedValueOnce([])
+ // 2025 balance vacations
+ .mockResolvedValueOnce([
+ {
+ startDate: new Date("2025-06-10T00:00:00.000Z"),
+ endDate: new Date("2025-06-17T00:00:00.000Z"),
+ status: "APPROVED",
+ isHalfDay: false,
+ },
+ ])
+ // 2026 holiday context
+ .mockResolvedValueOnce([])
+ // 2026 balance vacations
+ .mockResolvedValueOnce([])
+ // 2026 sick days
+ .mockResolvedValueOnce([]),
+ },
+ };
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.getBalance({ resourceId: "res_1", year: 2026 });
+
+ expect(result.carryoverDays).toBe(20);
+ expect(result.entitledDays).toBe(48);
+ expect(db.vacationEntitlement.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: "ent_2026" },
+ data: expect.objectContaining({
+ carryoverDays: 20,
+ entitledDays: 48,
+ }),
+ }),
+ );
+ });
});
// ─── get ─────────────────────────────────────────────────────────────────────
describe("entitlement.get", () => {
it("returns existing entitlement (manager role)", async () => {
- const entitlement = sampleEntitlement();
+ const entitlement = sampleEntitlement({
+ entitledDays: 30,
+ carryoverDays: 0,
+ usedDays: 0,
+ pendingDays: 0,
+ });
const db = {
systemSettings: {
- findUnique: vi.fn().mockResolvedValue(null),
+ findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 30 }),
},
vacationEntitlement: {
- findUnique: vi.fn().mockResolvedValue(entitlement),
+ findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
+ update: vi.fn().mockImplementation(async ({ data }: { data: Record }) => ({
+ ...entitlement,
+ ...data,
+ })),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
},
};
@@ -259,6 +428,7 @@ describe("entitlement.set", () => {
update: vi.fn().mockResolvedValue(updated),
create: vi.fn(),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -286,6 +456,7 @@ describe("entitlement.set", () => {
update: vi.fn(),
create: vi.fn().mockResolvedValue(created),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -324,6 +495,7 @@ describe("entitlement.bulkSet", () => {
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createAdminCaller(db);
@@ -350,6 +522,7 @@ describe("entitlement.bulkSet", () => {
vacationEntitlement: {
upsert: vi.fn().mockResolvedValue(sampleEntitlement()),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createAdminCaller(db);
@@ -396,10 +569,15 @@ describe("entitlement.getYearSummary", () => {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
+ findUnique: vi.fn().mockResolvedValue({
+ federalState: "BY",
+ country: { code: "DE" },
+ metroCity: null,
+ }),
findMany: vi.fn().mockResolvedValue(resources),
},
vacationEntitlement: {
- findUnique: vi.fn().mockResolvedValue(entitlement),
+ findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
diff --git a/packages/api/src/__tests__/event-bus-debounce.test.ts b/packages/api/src/__tests__/event-bus-debounce.test.ts
index daf1e9f..16a122c 100644
--- a/packages/api/src/__tests__/event-bus-debounce.test.ts
+++ b/packages/api/src/__tests__/event-bus-debounce.test.ts
@@ -24,10 +24,12 @@ vi.mock("ioredis", () => {
describe("event-bus debounce", () => {
let received: SseEvent[];
let unsubscribe: () => void;
+ let consoleWarnSpy: ReturnType;
beforeEach(() => {
vi.useFakeTimers();
received = [];
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
unsubscribe = eventBus.subscribe((event) => {
received.push(event);
});
@@ -36,6 +38,7 @@ describe("event-bus debounce", () => {
afterEach(() => {
unsubscribe();
cancelPendingEvents();
+ consoleWarnSpy.mockRestore();
vi.useRealTimers();
});
diff --git a/packages/api/src/__tests__/experience-multiplier-router.test.ts b/packages/api/src/__tests__/experience-multiplier-router.test.ts
index a26aa73..e362114 100644
--- a/packages/api/src/__tests__/experience-multiplier-router.test.ts
+++ b/packages/api/src/__tests__/experience-multiplier-router.test.ts
@@ -174,6 +174,7 @@ describe("experienceMultiplier.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
create: vi.fn().mockResolvedValue(created),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -203,6 +204,7 @@ describe("experienceMultiplier.create", () => {
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
create: vi.fn().mockResolvedValue(created),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -235,6 +237,7 @@ describe("experienceMultiplier.update", () => {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -259,6 +262,7 @@ describe("experienceMultiplier.update", () => {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
createMany: vi.fn().mockResolvedValue({ count: 2 }),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
@@ -308,6 +312,7 @@ describe("experienceMultiplier.delete", () => {
findUnique: vi.fn().mockResolvedValue(existing),
delete: vi.fn().mockResolvedValue(existing),
},
+ auditLog: { create: vi.fn().mockResolvedValue({}) },
};
const caller = createManagerCaller(db);
diff --git a/packages/api/src/__tests__/holiday-calendar-router.test.ts b/packages/api/src/__tests__/holiday-calendar-router.test.ts
new file mode 100644
index 0000000..7da141c
--- /dev/null
+++ b/packages/api/src/__tests__/holiday-calendar-router.test.ts
@@ -0,0 +1,168 @@
+import { SystemRole } from "@capakraken/shared";
+import { describe, expect, it, vi } from "vitest";
+import { createCallerFactory } from "../trpc.js";
+import { holidayCalendarRouter } from "../router/holiday-calendar.js";
+
+vi.mock("../lib/audit.js", () => ({
+ createAuditEntry: vi.fn().mockResolvedValue(undefined),
+}));
+
+const createCaller = createCallerFactory(holidayCalendarRouter);
+
+function createProtectedCaller(db: Record) {
+ return createCaller({
+ session: {
+ user: { email: "user@example.com", name: "User", image: null },
+ expires: "2026-12-31T00:00:00.000Z",
+ },
+ db: db as never,
+ dbUser: {
+ id: "user_1",
+ systemRole: SystemRole.USER,
+ permissionOverrides: null,
+ },
+ });
+}
+
+function createAdminCaller(db: Record) {
+ return createCaller({
+ session: {
+ user: { email: "admin@example.com", name: "Admin", image: null },
+ expires: "2026-12-31T00:00:00.000Z",
+ },
+ db: db as never,
+ dbUser: {
+ id: "admin_1",
+ systemRole: SystemRole.ADMIN,
+ permissionOverrides: null,
+ },
+ });
+}
+
+describe("holiday calendar router", () => {
+ it("merges built-in and scoped custom holidays in preview", async () => {
+ const db = {
+ country: {
+ findUnique: vi.fn().mockResolvedValue({ code: "DE" }),
+ },
+ metroCity: {
+ findUnique: vi.fn().mockResolvedValue({ name: "Augsburg" }),
+ },
+ holidayCalendar: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "cal_city",
+ name: "Augsburg lokal",
+ scopeType: "CITY",
+ priority: 10,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ entries: [
+ {
+ date: new Date("2020-01-01T00:00:00.000Z"),
+ name: "Augsburg Neujahr",
+ isRecurringAnnual: true,
+ },
+ {
+ date: new Date("2020-08-08T00:00:00.000Z"),
+ name: "Friedensfest lokal",
+ isRecurringAnnual: true,
+ },
+ ],
+ },
+ ]),
+ },
+ };
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.previewResolvedHolidays({
+ countryId: "country_de",
+ metroCityId: "city_augsburg",
+ year: 2026,
+ });
+
+ expect(db.holidayCalendar.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: expect.objectContaining({
+ countryId: "country_de",
+ isActive: true,
+ }),
+ }),
+ );
+ expect(result).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ date: "2026-01-01",
+ name: "Augsburg Neujahr",
+ scopeType: "CITY",
+ calendarName: "Augsburg lokal",
+ }),
+ expect.objectContaining({
+ date: "2026-08-08",
+ name: "Friedensfest lokal",
+ scopeType: "CITY",
+ calendarName: "Augsburg lokal",
+ }),
+ ]),
+ );
+ });
+
+ it("rejects duplicate calendar scopes on create", async () => {
+ const db = {
+ country: {
+ findUnique: vi
+ .fn()
+ .mockResolvedValueOnce({ id: "country_de", name: "Deutschland" })
+ .mockResolvedValueOnce({ id: "country_de", name: "Deutschland" }),
+ },
+ metroCity: {
+ findUnique: vi.fn(),
+ },
+ holidayCalendar: {
+ findFirst: vi.fn().mockResolvedValue({ id: "existing_scope" }),
+ create: vi.fn(),
+ },
+ auditLog: {
+ create: vi.fn().mockResolvedValue({}),
+ },
+ };
+
+ const caller = createAdminCaller(db);
+
+ await expect(caller.createCalendar({
+ name: "Deutschland Standard",
+ scopeType: "COUNTRY",
+ countryId: "country_de",
+ priority: 0,
+ isActive: true,
+ })).rejects.toThrow("A holiday calendar for this exact scope already exists");
+
+ expect(db.holidayCalendar.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects duplicate entry dates within the same calendar", async () => {
+ const db = {
+ holidayCalendar: {
+ findUnique: vi.fn().mockResolvedValue({ id: "cal_1", name: "Deutschland Standard" }),
+ },
+ holidayCalendarEntry: {
+ findFirst: vi.fn().mockResolvedValue({ id: "entry_existing" }),
+ create: vi.fn(),
+ },
+ auditLog: {
+ create: vi.fn().mockResolvedValue({}),
+ },
+ };
+
+ const caller = createAdminCaller(db);
+
+ await expect(caller.createEntry({
+ holidayCalendarId: "cal_1",
+ date: new Date("2026-12-24T00:00:00.000Z"),
+ name: "Heiligabend lokal",
+ isRecurringAnnual: true,
+ source: "manual",
+ })).rejects.toThrow("A holiday entry for this calendar and date already exists");
+
+ expect(db.holidayCalendarEntry.create).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/api/src/__tests__/notification-router.test.ts b/packages/api/src/__tests__/notification-router.test.ts
index f8bbe0b..3452153 100644
--- a/packages/api/src/__tests__/notification-router.test.ts
+++ b/packages/api/src/__tests__/notification-router.test.ts
@@ -187,6 +187,7 @@ describe("notification.create", () => {
{
notification: {
create: vi.fn().mockResolvedValue(created),
+ findUnique: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
@@ -209,6 +210,7 @@ describe("notification.create", () => {
}),
}),
);
+ expect(db.notification.findUnique).toHaveBeenCalledWith({ where: { id: "notif_1" } });
});
it("creates a notification with optional fields", async () => {
@@ -222,6 +224,7 @@ describe("notification.create", () => {
{
notification: {
create: vi.fn().mockResolvedValue(created),
+ findUnique: vi.fn().mockResolvedValue(created),
},
},
"user_mgr",
diff --git a/packages/api/src/__tests__/project-router.test.ts b/packages/api/src/__tests__/project-router.test.ts
index 6c762a5..fb334a8 100644
--- a/packages/api/src/__tests__/project-router.test.ts
+++ b/packages/api/src/__tests__/project-router.test.ts
@@ -134,12 +134,14 @@ describe("project router", () => {
create: vi.fn().mockResolvedValue(created),
},
auditLog: { create: vi.fn().mockResolvedValue({}) },
+ webhook: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createManagerCaller(db);
const result = await caller.create({
shortCode: "PRJ-001",
name: "Test Project",
+ responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
winProbability: 80,
@@ -167,6 +169,7 @@ describe("project router", () => {
caller.create({
shortCode: "PRJ-001",
name: "Duplicate",
+ responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
budgetCents: 100_00,
@@ -189,6 +192,7 @@ describe("project router", () => {
caller.create({
shortCode: "PRJ-002",
name: "Blocked",
+ responsiblePerson: "Alice",
orderType: OrderType.CHARGEABLE,
allocationType: AllocationType.INT,
budgetCents: 100_00,
@@ -239,6 +243,64 @@ describe("project router", () => {
});
});
+ describe("getShoringRatio", () => {
+ it("excludes regional holidays from shoring weighting", async () => {
+ const db = {
+ project: {
+ findUnique: vi.fn().mockResolvedValue({
+ id: "project_1",
+ name: "Test Project",
+ shoringThreshold: 55,
+ onshoreCountryCode: "DE",
+ }),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "a1",
+ resourceId: "res_de",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ hoursPerDay: 8,
+ resource: {
+ id: "res_de",
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
+ country: { id: "country_de", code: "DE" },
+ metroCity: null,
+ },
+ },
+ {
+ id: "a2",
+ resourceId: "res_es",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ hoursPerDay: 8,
+ resource: {
+ id: "res_es",
+ countryId: "country_es",
+ federalState: null,
+ metroCityId: null,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8, saturday: 0, sunday: 0 },
+ country: { id: "country_es", code: "ES" },
+ metroCity: null,
+ },
+ },
+ ]),
+ },
+ };
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.getShoringRatio({ projectId: "project_1" });
+
+ expect(result.totalHours).toBe(24);
+ expect(result.onshoreRatio).toBe(33);
+ expect(result.offshoreRatio).toBe(67);
+ });
+ });
+
// ─── update ───────────────────────────────────────────────────────────────
describe("update", () => {
@@ -294,6 +356,7 @@ describe("project router", () => {
project: {
update: vi.fn().mockResolvedValue(updated),
},
+ webhook: { findMany: vi.fn().mockResolvedValue([]) },
};
const caller = createManagerCaller(db);
diff --git a/packages/api/src/__tests__/report-router.test.ts b/packages/api/src/__tests__/report-router.test.ts
new file mode 100644
index 0000000..2390a87
--- /dev/null
+++ b/packages/api/src/__tests__/report-router.test.ts
@@ -0,0 +1,118 @@
+import { SystemRole } from "@capakraken/shared";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@capakraken/application", () => ({
+ isChargeabilityActualBooking: vi.fn(() => false),
+ isChargeabilityRelevantProject: vi.fn(() => false),
+ listAssignmentBookings: vi.fn().mockResolvedValue([]),
+}));
+
+vi.mock("../lib/resource-capacity.js", () => ({
+ calculateEffectiveAvailableHours: vi.fn(({ context }: { context?: unknown }) => (context ? 156 : 168)),
+ calculateEffectiveBookedHours: vi.fn(() => 0),
+ countEffectiveWorkingDays: vi.fn(({ context }: { context?: unknown }) => (context ? 19.5 : 21)),
+ getAvailabilityHoursForDate: vi.fn(() => 8),
+ loadResourceDailyAvailabilityContexts: vi.fn().mockResolvedValue(new Map([
+ [
+ "res_1",
+ {
+ holidayDates: new Set(["2026-04-10"]),
+ vacationFractionsByDate: new Map([["2026-04-14", 0.5]]),
+ },
+ ],
+ ])),
+}));
+
+import { reportRouter } from "../router/report.js";
+import { createCallerFactory } from "../trpc.js";
+
+const createCaller = createCallerFactory(reportRouter);
+
+function createControllerCaller(db: Record) {
+ return createCaller({
+ session: {
+ user: { email: "controller@example.com", name: "Controller", image: null },
+ expires: "2099-01-01T00:00:00.000Z",
+ },
+ db: db as never,
+ dbUser: {
+ id: "user_controller",
+ systemRole: SystemRole.CONTROLLER,
+ permissionOverrides: null,
+ },
+ });
+}
+
+describe("report router", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("lists the new resource month transparency columns", async () => {
+ const caller = createControllerCaller({});
+ const columns = await caller.getAvailableColumns({ entity: "resource_month" });
+
+ expect(columns).toEqual(expect.arrayContaining([
+ expect.objectContaining({ key: "monthlyPublicHolidayCount", label: "Holiday Dates" }),
+ expect.objectContaining({ key: "monthlyTargetHours", label: "Target Hours" }),
+ expect.objectContaining({ key: "monthlyUnassignedHours", label: "Unassigned Hours" }),
+ ]));
+ });
+
+ it("exports resource month basis and computed columns in CSV", async () => {
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "res_1",
+ eid: "alice",
+ displayName: "Alice",
+ email: "alice@example.com",
+ chapter: "VFX",
+ resourceType: "EMPLOYEE",
+ isActive: true,
+ chgResponsibility: false,
+ rolledOff: false,
+ departed: false,
+ lcrCents: 7500,
+ ucrCents: 10000,
+ currency: "EUR",
+ fte: 1,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ chargeabilityTarget: 80,
+ federalState: "BY",
+ countryId: "country_de",
+ metroCityId: null,
+ country: { code: "DE", name: "Germany" },
+ metroCity: null,
+ orgUnit: { name: "Delivery" },
+ managementLevelGroup: null,
+ managementLevel: { name: "Senior" },
+ },
+ ]),
+ },
+ };
+
+ const caller = createControllerCaller(db);
+ const result = await caller.exportReport({
+ entity: "resource_month",
+ columns: [
+ "displayName",
+ "countryCode",
+ "monthlyPublicHolidayCount",
+ "monthlyPublicHolidayHoursDeduction",
+ "monthlyAbsenceHoursDeduction",
+ "monthlySahHours",
+ "monthlyTargetHours",
+ "monthlyUnassignedHours",
+ ],
+ filters: [],
+ periodMonth: "2026-04",
+ limit: 100,
+ });
+
+ expect(result.rowCount).toBe(1);
+ expect(result.csv).toContain("Name,Country Code,Holiday Dates,Holiday Hours Deduction,Absence Hours Deduction,SAH,Target Hours,Unassigned Hours");
+ expect(result.csv).toContain("Alice,DE,1,8,4,156,124.8,156");
+ });
+});
diff --git a/packages/api/src/__tests__/resource-router.test.ts b/packages/api/src/__tests__/resource-router.test.ts
index 48de273..d7d84b3 100644
--- a/packages/api/src/__tests__/resource-router.test.ts
+++ b/packages/api/src/__tests__/resource-router.test.ts
@@ -86,6 +86,10 @@ describe("resource router", () => {
valueScoreBreakdown: null,
valueScoreUpdatedAt: null,
userId: null,
+ countryId: "country_de",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
};
const db = {
resource: {
@@ -158,6 +162,165 @@ describe("resource router", () => {
});
});
+ it("calculates utilization with regional holidays removed from available hours", async () => {
+ const resource = {
+ id: "resource_1",
+ eid: "E-001",
+ displayName: "Alice",
+ email: "alice@example.com",
+ chapter: "CGI",
+ lcrCents: 5000,
+ ucrCents: 9000,
+ currency: "EUR",
+ chargeabilityTarget: 80,
+ availability: {
+ monday: 8,
+ tuesday: 8,
+ wednesday: 8,
+ thursday: 8,
+ friday: 8,
+ saturday: 0,
+ sunday: 0,
+ },
+ skills: [],
+ dynamicFields: {},
+ blueprintId: null,
+ isActive: true,
+ createdAt: new Date("2026-03-01"),
+ updatedAt: new Date("2026-03-01"),
+ roleId: null,
+ portfolioUrl: null,
+ postalCode: null,
+ federalState: "BY",
+ countryId: "country_de",
+ metroCityId: null,
+ valueScore: null,
+ valueScoreBreakdown: null,
+ valueScoreUpdatedAt: null,
+ userId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ };
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([resource]),
+ },
+ };
+
+ vi.mocked(listAssignmentBookings).mockResolvedValue([
+ {
+ id: "assignment_confirmed",
+ projectId: "project_1",
+ resourceId: "resource_1",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ hoursPerDay: 8,
+ dailyCostCents: 0,
+ status: "CONFIRMED",
+ project: {
+ id: "project_1",
+ name: "Project 1",
+ shortCode: "P1",
+ status: "ACTIVE",
+ orderType: "CLIENT",
+ dynamicFields: null,
+ },
+ resource: { id: "resource_1", displayName: "Alice", chapter: "CGI" },
+ },
+ ]);
+
+ const caller = createControllerCaller(db);
+ const result = await caller.listWithUtilization({
+ startDate: "2026-01-05T00:00:00.000Z",
+ endDate: "2026-01-06T00:00:00.000Z",
+ });
+
+ expect(result[0]).toMatchObject({
+ bookingCount: 1,
+ bookedHours: 8,
+ availableHours: 8,
+ utilizationPercent: 100,
+ });
+ });
+
+ it("shifts marketplace availability when a local holiday blocks today", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-06T10:00:00.000Z"));
+
+ try {
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "resource_by",
+ displayName: "Bavaria Artist",
+ eid: "E-BY",
+ chapter: "CGI",
+ skills: [{ skill: "Houdini", proficiency: 5 }],
+ availability: {
+ monday: 8,
+ tuesday: 8,
+ wednesday: 8,
+ thursday: 8,
+ friday: 8,
+ saturday: 0,
+ sunday: 0,
+ },
+ chargeabilityTarget: 80,
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ },
+ {
+ id: "resource_hh",
+ displayName: "Hamburg Artist",
+ eid: "E-HH",
+ chapter: "CGI",
+ skills: [{ skill: "Houdini", proficiency: 5 }],
+ availability: {
+ monday: 8,
+ tuesday: 8,
+ wednesday: 8,
+ thursday: 8,
+ friday: 8,
+ saturday: 0,
+ sunday: 0,
+ },
+ chargeabilityTarget: 80,
+ countryId: "country_de",
+ federalState: "HH",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ },
+ ]),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ demandRequirement: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ };
+
+ const caller = createControllerCaller(db);
+ const result = await caller.getSkillMarketplace({
+ searchSkill: "houdini",
+ availableOnly: true,
+ });
+
+ const bavaria = result.searchResults.find((resource) => resource.id === "resource_by");
+ const hamburg = result.searchResults.find((resource) => resource.id === "resource_hh");
+
+ expect(bavaria?.availableFrom).toBe("2026-01-07T00:00:00.000Z");
+ expect(hamburg?.availableFrom).toBe("2026-01-06T00:00:00.000Z");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("uses a composite displayName/id cursor for stable pagination", async () => {
const db = {
resource: {
@@ -314,6 +477,84 @@ describe("resource router", () => {
expect(withProposed[0]?.expectedChargeability).toBe(strict[0]?.expectedChargeability);
});
+ it("excludes regional public holidays from chargeability stats", async () => {
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "resource_by",
+ eid: "E-BY",
+ displayName: "Bavaria",
+ chapter: "CGI",
+ chargeabilityTarget: 80,
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: "city_munich",
+ country: { code: "DE" },
+ metroCity: { name: "Munich" },
+ availability: {
+ monday: 8,
+ tuesday: 8,
+ wednesday: 8,
+ thursday: 8,
+ friday: 8,
+ },
+ },
+ ]),
+ },
+ };
+
+ vi.mocked(listAssignmentBookings).mockResolvedValue([
+ {
+ id: "assignment_holiday",
+ projectId: "project_1",
+ resourceId: "resource_by",
+ startDate: new Date("2026-01-06T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ hoursPerDay: 8,
+ dailyCostCents: 0,
+ status: "CONFIRMED",
+ project: {
+ id: "project_1",
+ name: "Project 1",
+ shortCode: "P1",
+ status: "ACTIVE",
+ orderType: "CLIENT",
+ dynamicFields: null,
+ },
+ resource: { id: "resource_by", displayName: "Bavaria", chapter: "CGI" },
+ },
+ ]);
+
+ const RealDate = Date;
+ class MockDate extends Date {
+ constructor(...args: ConstructorParameters) {
+ if (args.length === 0) {
+ super("2026-01-15T00:00:00.000Z");
+ return;
+ }
+ super(...args);
+ }
+ static now() {
+ return new RealDate("2026-01-15T00:00:00.000Z").getTime();
+ }
+ }
+ vi.stubGlobal("Date", MockDate);
+
+ try {
+ const caller = createControllerCaller(db);
+ const result = await caller.getChargeabilityStats({});
+
+ expect(result[0]).toMatchObject({
+ actualChargeability: 0,
+ expectedChargeability: 0,
+ availableHours: 168,
+ });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
it("applies country filters including explicit no-country toggle", async () => {
const db = {
resource: {
diff --git a/packages/api/src/__tests__/staffing-router.test.ts b/packages/api/src/__tests__/staffing-router.test.ts
index 9fa7bf9..7b126ef 100644
--- a/packages/api/src/__tests__/staffing-router.test.ts
+++ b/packages/api/src/__tests__/staffing-router.test.ts
@@ -17,23 +17,6 @@ vi.mock("@capakraken/staffing", () => ({
},
})),
),
- analyzeUtilization: vi.fn().mockReturnValue({
- resourceId: "res_1",
- displayName: "Alice",
- totalDays: 20,
- allocatedDays: 15,
- utilizationPercent: 75,
- chargeablePercent: 60,
- overallocatedDays: 0,
- dailyBreakdown: [],
- }),
- findCapacityWindows: vi.fn().mockReturnValue([
- {
- startDate: new Date("2026-04-01"),
- endDate: new Date("2026-04-10"),
- availableHoursPerDay: 6,
- },
- ]),
}));
vi.mock("@capakraken/application", () => ({
@@ -76,6 +59,11 @@ function sampleResource(overrides: Record = {}) {
isActive: true,
valueScore: 85,
chapter: "VFX",
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
...overrides,
};
}
@@ -105,6 +93,30 @@ describe("staffing.getSuggestions", () => {
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("score");
+ expect(result[0]).toMatchObject({
+ resourceName: "Alice",
+ eid: "alice",
+ location: {
+ countryCode: "DE",
+ federalState: "BY",
+ },
+ capacity: expect.objectContaining({
+ requestedHoursPerDay: 8,
+ baseAvailableHours: expect.any(Number),
+ effectiveAvailableHours: expect.any(Number),
+ remainingHoursPerDay: expect.any(Number),
+ holidayHoursDeduction: expect.any(Number),
+ }),
+ conflicts: {
+ count: expect.any(Number),
+ conflictDays: expect.any(Array),
+ details: expect.any(Array),
+ },
+ ranking: expect.objectContaining({
+ rank: 1,
+ components: expect.any(Array),
+ }),
+ });
});
it("filters resources by chapter when provided", async () => {
@@ -175,6 +187,58 @@ describe("staffing.getSuggestions", () => {
}),
);
});
+
+ it("uses value score as a transparent tiebreaker within two score points", async () => {
+ const resources = [
+ sampleResource({ id: "res_1", displayName: "Alice", eid: "alice", valueScore: 60 }),
+ sampleResource({ id: "res_2", displayName: "Bob", eid: "bob", valueScore: 95 }),
+ ];
+ const db = {
+ resource: {
+ findMany: vi.fn().mockResolvedValue(resources),
+ },
+ };
+
+ const { rankResources } = await import("@capakraken/staffing");
+ vi.mocked(rankResources).mockImplementationOnce((input: { resources: Array<{ id: string }> }) => ([
+ {
+ resourceId: input.resources[0]!.id,
+ score: 80,
+ breakdown: {
+ skillScore: 80,
+ availabilityScore: 80,
+ costScore: 80,
+ utilizationScore: 80,
+ },
+ },
+ {
+ resourceId: input.resources[1]!.id,
+ score: 79,
+ breakdown: {
+ skillScore: 79,
+ availabilityScore: 79,
+ costScore: 79,
+ utilizationScore: 79,
+ },
+ },
+ ]));
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.getSuggestions({
+ requiredSkills: ["Compositing"],
+ startDate: new Date("2026-04-01"),
+ endDate: new Date("2026-04-30"),
+ hoursPerDay: 8,
+ });
+
+ expect(result[0]?.resourceId).toBe("res_2");
+ expect(result[0]?.ranking).toMatchObject({
+ rank: 1,
+ baseRank: 2,
+ tieBreakerApplied: true,
+ });
+ expect(result[0]?.ranking.tieBreakerReason).toContain("value score");
+ });
});
// ─── analyzeUtilization ──────────────────────────────────────────────────────
@@ -186,6 +250,11 @@ describe("staffing.analyzeUtilization", () => {
displayName: "Alice",
chargeabilityTarget: 80,
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
};
const db = {
resource: {
@@ -200,10 +269,56 @@ describe("staffing.analyzeUtilization", () => {
endDate: new Date("2026-04-30"),
});
- expect(result).toHaveProperty("utilizationPercent");
+ expect(result).toHaveProperty("currentChargeability");
expect(result.resourceId).toBe("res_1");
});
+ it("excludes Bavarian public holidays from chargeability analysis", async () => {
+ const resource = {
+ id: "res_1",
+ displayName: "Alice",
+ chargeabilityTarget: 80,
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ };
+ const db = {
+ resource: {
+ findUnique: vi.fn().mockResolvedValue(resource),
+ },
+ };
+
+ const { listAssignmentBookings } = await import("@capakraken/application");
+ vi.mocked(listAssignmentBookings).mockResolvedValue([
+ {
+ id: "a1",
+ projectId: "project_1",
+ resourceId: "res_1",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ hoursPerDay: 8,
+ dailyCostCents: 0,
+ status: "CONFIRMED",
+ project: { id: "project_1", name: "Chargeable", shortCode: "CHG", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
+ resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
+ },
+ ]);
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.analyzeUtilization({
+ resourceId: "res_1",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ });
+
+ expect(result.currentChargeability).toBe(100);
+ expect(result.overallocatedDays).toEqual([]);
+ expect(result.underutilizedDays).toEqual([]);
+ });
+
it("throws NOT_FOUND when resource does not exist", async () => {
const db = {
resource: {
@@ -230,6 +345,11 @@ describe("staffing.findCapacity", () => {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
};
const db = {
resource: {
@@ -244,8 +364,53 @@ describe("staffing.findCapacity", () => {
endDate: new Date("2026-04-30"),
});
- expect(result).toHaveLength(1);
+ expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("availableHoursPerDay");
+ expect(result.every((window) => window.availableHoursPerDay > 0)).toBe(true);
+ expect(result.reduce((sum, window) => sum + window.availableDays, 0)).toBeGreaterThan(0);
+ });
+
+ it("splits capacity windows around Bavarian public holidays", async () => {
+ const resource = {
+ id: "res_1",
+ displayName: "Alice",
+ availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ };
+ const db = {
+ resource: {
+ findUnique: vi.fn().mockResolvedValue(resource),
+ },
+ };
+
+ const { listAssignmentBookings } = await import("@capakraken/application");
+ vi.mocked(listAssignmentBookings).mockResolvedValue([]);
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.findCapacity({
+ resourceId: "res_1",
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-07T00:00:00.000Z"),
+ minAvailableHoursPerDay: 4,
+ });
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toEqual(
+ expect.objectContaining({
+ startDate: new Date("2026-01-05T00:00:00.000Z"),
+ endDate: new Date("2026-01-05T00:00:00.000Z"),
+ }),
+ );
+ expect(result[1]).toEqual(
+ expect.objectContaining({
+ startDate: new Date("2026-01-07T00:00:00.000Z"),
+ endDate: new Date("2026-01-07T00:00:00.000Z"),
+ }),
+ );
});
it("throws NOT_FOUND when resource does not exist", async () => {
@@ -265,11 +430,16 @@ describe("staffing.findCapacity", () => {
).rejects.toThrow("Resource not found");
});
- it("passes minAvailableHoursPerDay to engine", async () => {
+ it("honors minAvailableHoursPerDay when computing holiday-aware windows", async () => {
const resource = {
id: "res_1",
displayName: "Alice",
availability: { monday: 8, tuesday: 8, wednesday: 8, thursday: 8, friday: 8 },
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
};
const db = {
resource: {
@@ -277,21 +447,30 @@ describe("staffing.findCapacity", () => {
},
};
- const { findCapacityWindows } = await import("@capakraken/staffing");
+ const { listAssignmentBookings } = await import("@capakraken/application");
+ vi.mocked(listAssignmentBookings).mockResolvedValue([
+ {
+ id: "a1",
+ projectId: "project_1",
+ resourceId: "res_1",
+ startDate: new Date("2026-04-01T00:00:00.000Z"),
+ endDate: new Date("2026-04-30T00:00:00.000Z"),
+ hoursPerDay: 3,
+ dailyCostCents: 0,
+ status: "CONFIRMED",
+ project: { id: "project_1", name: "Project", shortCode: "PRJ", status: "ACTIVE", orderType: "CHARGEABLE", clientId: null, dynamicFields: null },
+ resource: { id: "res_1", displayName: "Alice", chapter: "VFX" },
+ },
+ ]);
+
const caller = createProtectedCaller(db);
- await caller.findCapacity({
+ const result = await caller.findCapacity({
resourceId: "res_1",
startDate: new Date("2026-04-01"),
endDate: new Date("2026-04-30"),
minAvailableHoursPerDay: 6,
});
- expect(findCapacityWindows).toHaveBeenCalledWith(
- expect.anything(),
- expect.anything(),
- expect.any(Date),
- expect.any(Date),
- 6,
- );
+ expect(result.every((window) => window.availableHoursPerDay >= 6)).toBe(true);
});
});
diff --git a/packages/api/src/__tests__/timeline-allocation.test.ts b/packages/api/src/__tests__/timeline-allocation.test.ts
index 6afe222..9bdcc11 100644
--- a/packages/api/src/__tests__/timeline-allocation.test.ts
+++ b/packages/api/src/__tests__/timeline-allocation.test.ts
@@ -290,4 +290,83 @@ describe("timeline allocation entry resolution", () => {
}),
);
});
+
+ it("returns resolved holiday overlays for assigned resources", async () => {
+ const db = {
+ demandRequirement: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ assignment: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "assignment_1",
+ kind: "assignment",
+ resourceId: "resource_by",
+ projectId: "project_1",
+ startDate: new Date("2026-01-01"),
+ endDate: new Date("2026-01-31"),
+ hoursPerDay: 8,
+ status: AllocationStatus.CONFIRMED,
+ metadata: {},
+ project: {
+ id: "project_1",
+ name: "Project One",
+ shortCode: "PRJ",
+ status: "ACTIVE",
+ startDate: new Date("2026-01-01"),
+ endDate: new Date("2026-03-31"),
+ orderType: "CHARGEABLE",
+ clientId: null,
+ },
+ resource: {
+ id: "resource_by",
+ displayName: "Alice",
+ eid: "E-001",
+ chapter: null,
+ },
+ },
+ ]),
+ },
+ resource: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "resource_by",
+ countryId: "country_de",
+ federalState: "BY",
+ metroCityId: null,
+ country: { code: "DE" },
+ metroCity: null,
+ },
+ ]),
+ },
+ project: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ holidayCalendar: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ country: {
+ findUnique: vi.fn(),
+ },
+ metroCity: {
+ findUnique: vi.fn(),
+ },
+ };
+
+ const caller = createManagerCaller(db);
+ const overlays = await caller.getHolidayOverlays({
+ startDate: new Date("2026-01-01"),
+ endDate: new Date("2026-01-31"),
+ });
+
+ expect(overlays).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ resourceId: "resource_by",
+ type: "PUBLIC_HOLIDAY",
+ note: "Heilige Drei Könige",
+ }),
+ ]),
+ );
+ });
});
diff --git a/packages/api/src/__tests__/vacation-router.test.ts b/packages/api/src/__tests__/vacation-router.test.ts
index 3c06c92..741f262 100644
--- a/packages/api/src/__tests__/vacation-router.test.ts
+++ b/packages/api/src/__tests__/vacation-router.test.ts
@@ -9,12 +9,30 @@ vi.mock("../sse/event-bus.js", () => ({
emitVacationUpdated: vi.fn(),
emitVacationDeleted: vi.fn(),
emitNotificationCreated: vi.fn(),
+ emitTaskAssigned: vi.fn(),
}));
vi.mock("../lib/email.js", () => ({
sendEmail: vi.fn(),
}));
+vi.mock("../lib/create-notification.js", () => ({
+ createNotification: vi.fn().mockResolvedValue("notif_1"),
+}));
+
+vi.mock("../lib/vacation-conflicts.js", () => ({
+ checkVacationConflicts: vi.fn().mockResolvedValue({ warnings: [] }),
+ checkBatchVacationConflicts: vi.fn().mockResolvedValue(new Map()),
+}));
+
+vi.mock("../lib/webhook-dispatcher.js", () => ({
+ dispatchWebhooks: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("../lib/audit.js", () => ({
+ createAuditEntry: vi.fn().mockResolvedValue(undefined),
+}));
+
const createCaller = createCallerFactory(vacationRouter);
function createProtectedCaller(db: Record) {
@@ -91,6 +109,56 @@ const sampleVacation = {
approvedBy: null,
};
+function createVacationDb(overrides: Record = {}) {
+ const db = {
+ user: {
+ findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
+ findMany: vi.fn().mockResolvedValue([{ id: "mgr_1" }, { id: "admin_1" }]),
+ },
+ resource: {
+ findUnique: vi.fn().mockImplementation(async (args?: { select?: Record }) => {
+ const select = args?.select ?? {};
+ return {
+ ...(select.userId ? { userId: "user_1" } : {}),
+ ...(select.displayName ? { displayName: "Alice" } : {}),
+ ...(select.user ? { user: null } : {}),
+ ...(select.federalState ? { federalState: "BY" } : {}),
+ ...(select.country ? { country: { code: "DE", name: "Germany" } } : {}),
+ ...(select.metroCity ? { metroCity: null } : {}),
+ };
+ }),
+ count: vi.fn().mockResolvedValue(0),
+ },
+ vacation: {
+ findFirst: vi.fn().mockResolvedValue(null),
+ findUnique: vi.fn().mockResolvedValue(sampleVacation),
+ findMany: vi.fn().mockResolvedValue([]),
+ create: vi.fn().mockResolvedValue(sampleVacation),
+ update: vi.fn().mockResolvedValue(sampleVacation),
+ updateMany: vi.fn().mockResolvedValue({ count: 1 }),
+ },
+ notification: {
+ updateMany: vi.fn().mockResolvedValue({ count: 1 }),
+ },
+ auditLog: {
+ create: vi.fn().mockResolvedValue({}),
+ },
+ };
+
+ return {
+ ...db,
+ ...overrides,
+ user: { ...db.user, ...(overrides.user as Record | undefined) },
+ resource: { ...db.resource, ...(overrides.resource as Record | undefined) },
+ vacation: { ...db.vacation, ...(overrides.vacation as Record | undefined) },
+ notification: {
+ ...db.notification,
+ ...(overrides.notification as Record | undefined),
+ },
+ auditLog: { ...db.auditLog, ...(overrides.auditLog as Record | undefined) },
+ };
+}
+
describe("vacation router", () => {
describe("list", () => {
it("returns vacations with default filters", async () => {
@@ -199,18 +267,11 @@ describe("vacation router", () => {
status: VacationStatus.PENDING,
};
- const db = {
- user: {
- findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
- },
- resource: {
- findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
- },
+ const db = createVacationDb({
vacation: {
- findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
- };
+ });
const caller = createProtectedCaller(db);
const result = await caller.create({
@@ -239,15 +300,14 @@ describe("vacation router", () => {
approvedById: "mgr_1",
};
- const db = {
+ const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1", systemRole: "MANAGER" }),
},
vacation: {
- findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
- };
+ });
const caller = createManagerCaller(db);
const result = await caller.create({
@@ -269,17 +329,11 @@ describe("vacation router", () => {
});
it("rejects overlapping vacation", async () => {
- const db = {
- user: {
- findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
- },
- resource: {
- findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
- },
+ const db = createVacationDb({
vacation: {
findFirst: vi.fn().mockResolvedValue({ id: "existing_vac" }),
},
- };
+ });
const caller = createProtectedCaller(db);
await expect(
@@ -293,10 +347,10 @@ describe("vacation router", () => {
});
it("rejects when end date is before start date", async () => {
- const db = {
+ const db = createVacationDb({
user: { findUnique: vi.fn() },
vacation: { findFirst: vi.fn() },
- };
+ });
const caller = createProtectedCaller(db);
await expect(
@@ -316,18 +370,11 @@ describe("vacation router", () => {
halfDayPart: "MORNING",
};
- const db = {
- user: {
- findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
- },
- resource: {
- findUnique: vi.fn().mockResolvedValue({ userId: "user_1" }),
- },
+ const db = createVacationDb({
vacation: {
- findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue(createdVacation),
},
- };
+ });
const caller = createProtectedCaller(db);
const result = await caller.create({
@@ -349,6 +396,235 @@ describe("vacation router", () => {
}),
);
});
+
+ it("rejects multi-day half-day vacations", async () => {
+ const db = createVacationDb();
+ const caller = createProtectedCaller(db);
+
+ await expect(caller.create({
+ resourceId: "res_1",
+ type: VacationType.ANNUAL,
+ startDate: new Date("2026-06-01"),
+ endDate: new Date("2026-06-02"),
+ isHalfDay: true,
+ halfDayPart: "MORNING",
+ })).rejects.toThrow();
+
+ expect(db.vacation.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects half-day vacations without a half-day part", async () => {
+ const db = createVacationDb();
+ const caller = createProtectedCaller(db);
+
+ await expect(caller.create({
+ resourceId: "res_1",
+ type: VacationType.ANNUAL,
+ startDate: new Date("2026-06-01"),
+ endDate: new Date("2026-06-01"),
+ isHalfDay: true,
+ })).rejects.toThrow();
+
+ expect(db.vacation.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects half-day parts on full-day vacations", async () => {
+ const db = createVacationDb();
+ const caller = createProtectedCaller(db);
+
+ await expect(caller.create({
+ resourceId: "res_1",
+ type: VacationType.ANNUAL,
+ startDate: new Date("2026-06-01"),
+ endDate: new Date("2026-06-01"),
+ halfDayPart: "AFTERNOON",
+ })).rejects.toThrow();
+
+ expect(db.vacation.create).not.toHaveBeenCalled();
+ });
+
+ it("rejects leave requests that only hit public holidays", async () => {
+ const db = createVacationDb({
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ });
+
+ const caller = createProtectedCaller(db);
+
+ await expect(caller.create({
+ resourceId: "res_1",
+ type: VacationType.ANNUAL,
+ startDate: new Date("2026-01-06T00:00:00.000Z"),
+ endDate: new Date("2026-01-06T00:00:00.000Z"),
+ })).rejects.toThrow("does not deduct any vacation days");
+
+ expect(db.vacation.create).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("previewRequest", () => {
+ it("shows public holidays as non-deductible leave days", async () => {
+ const db = createVacationDb({
+ resource: {
+ findUnique: vi.fn().mockResolvedValue({
+ userId: "user_1",
+ federalState: "BY",
+ country: { code: "DE", name: "Germany" },
+ metroCity: { name: "Augsburg" },
+ }),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ });
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.previewRequest({
+ resourceId: "res_1",
+ type: VacationType.ANNUAL,
+ startDate: new Date("2028-08-08T00:00:00.000Z"),
+ endDate: new Date("2028-08-08T00:00:00.000Z"),
+ });
+
+ expect(result.requestedDays).toBe(1);
+ expect(result.effectiveDays).toBe(0);
+ expect(result.deductedDays).toBe(0);
+ expect(result.publicHolidayDates).toContain("2028-08-08");
+ expect(result.holidayContext).toEqual({
+ countryCode: "DE",
+ countryName: "Germany",
+ federalState: "BY",
+ metroCityName: "Augsburg",
+ sources: {
+ hasCalendarHolidays: true,
+ hasLegacyPublicHolidayEntries: false,
+ },
+ });
+ expect(result.holidayDetails).toContainEqual({
+ date: "2028-08-08",
+ source: "CALENDAR",
+ });
+ });
+
+ it("uses custom city holiday calendars for non-deductible leave days", async () => {
+ const db = createVacationDb({
+ resource: {
+ findUnique: vi.fn().mockResolvedValue({
+ userId: "user_1",
+ countryId: "country_de",
+ metroCityId: "city_muc",
+ federalState: "BY",
+ country: { code: "DE", name: "Germany" },
+ metroCity: { name: "Muenchen" },
+ }),
+ },
+ holidayCalendar: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: "cal_muc",
+ name: "Muenchen lokal",
+ scopeType: "CITY",
+ priority: 10,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ entries: [
+ {
+ date: new Date("2020-11-15T00:00:00.000Z"),
+ name: "Lokaler Stadtfeiertag",
+ isRecurringAnnual: true,
+ },
+ ],
+ },
+ ]),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([]),
+ },
+ });
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.previewRequest({
+ resourceId: "res_1",
+ type: VacationType.ANNUAL,
+ startDate: new Date("2026-11-15T00:00:00.000Z"),
+ endDate: new Date("2026-11-15T00:00:00.000Z"),
+ });
+
+ expect(result.requestedDays).toBe(1);
+ expect(result.effectiveDays).toBe(0);
+ expect(result.publicHolidayDates).toContain("2026-11-15");
+ expect(result.holidayContext.countryName).toBe("Germany");
+ expect(result.holidayContext.metroCityName).toBe("Muenchen");
+ expect(db.holidayCalendar.findMany).toHaveBeenCalled();
+ });
+
+ it("marks legacy public holiday entries as a separate preview source", async () => {
+ const db = createVacationDb({
+ resource: {
+ findUnique: vi.fn().mockResolvedValue({
+ userId: "user_1",
+ federalState: "HH",
+ country: { code: "DE", name: "Germany" },
+ metroCity: { name: "Hamburg" },
+ }),
+ },
+ vacation: {
+ findMany: vi.fn().mockResolvedValue([
+ {
+ startDate: new Date("2026-05-01T00:00:00.000Z"),
+ endDate: new Date("2026-05-01T00:00:00.000Z"),
+ },
+ ]),
+ },
+ });
+
+ const caller = createProtectedCaller(db);
+ const result = await caller.previewRequest({
+ resourceId: "res_1",
+ type: VacationType.ANNUAL,
+ startDate: new Date("2026-05-01T00:00:00.000Z"),
+ endDate: new Date("2026-05-01T00:00:00.000Z"),
+ });
+
+ expect(result.publicHolidayDates).toContain("2026-05-01");
+ expect(result.holidayContext.sources).toEqual({
+ hasCalendarHolidays: true,
+ hasLegacyPublicHolidayEntries: true,
+ });
+ expect(result.holidayDetails).toContainEqual({
+ date: "2026-05-01",
+ source: "CALENDAR_AND_LEGACY",
+ });
+ });
+
+ it("rejects multi-day half-day previews", async () => {
+ const db = createVacationDb();
+ const caller = createProtectedCaller(db);
+
+ await expect(caller.previewRequest({
+ resourceId: "res_1",
+ type: VacationType.ANNUAL,
+ startDate: new Date("2026-06-01"),
+ endDate: new Date("2026-06-02"),
+ isHalfDay: true,
+ })).rejects.toThrow();
+ });
+ });
+
+ describe("create manual public holiday handling", () => {
+ it("rejects manual public holiday creation requests", async () => {
+ const db = createVacationDb();
+ const caller = createManagerCaller(db);
+
+ await expect(caller.create({
+ resourceId: "res_1",
+ type: VacationType.PUBLIC_HOLIDAY,
+ startDate: new Date("2026-05-01T00:00:00.000Z"),
+ endDate: new Date("2026-05-01T00:00:00.000Z"),
+ })).rejects.toThrow("Public holidays must be managed via Holiday Calendars or the legacy holiday import");
+
+ expect(db.vacation.create).not.toHaveBeenCalled();
+ });
});
describe("approve", () => {
@@ -359,7 +635,7 @@ describe("vacation router", () => {
approvedById: "mgr_1",
};
- const db = {
+ const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
@@ -370,7 +646,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
- };
+ });
const caller = createManagerCaller(db);
const result = await caller.approve({ id: "vac_1" });
@@ -388,25 +664,25 @@ describe("vacation router", () => {
});
it("throws NOT_FOUND for missing vacation", async () => {
- const db = {
+ const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
- };
+ });
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("rejects approving an already APPROVED vacation", async () => {
- const db = {
+ const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
- };
+ });
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
@@ -429,7 +705,7 @@ describe("vacation router", () => {
rejectionReason: "Team conflict",
};
- const db = {
+ const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
@@ -437,7 +713,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
- };
+ });
const caller = createManagerCaller(db);
const result = await caller.reject({ id: "vac_1", rejectionReason: "Team conflict" });
@@ -454,14 +730,14 @@ describe("vacation router", () => {
});
it("throws when rejecting non-PENDING vacation", async () => {
- const db = {
+ const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.APPROVED,
}),
},
- };
+ });
const caller = createManagerCaller(db);
await expect(caller.reject({ id: "vac_1" })).rejects.toThrow(
@@ -477,15 +753,12 @@ describe("vacation router", () => {
status: VacationStatus.CANCELLED,
};
- const db = {
- user: {
- findUnique: vi.fn().mockResolvedValue({ id: "user_1", systemRole: "USER" }),
- },
+ const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(sampleVacation),
update: vi.fn().mockResolvedValue(updatedVacation),
},
- };
+ });
const caller = createProtectedCaller(db);
const result = await caller.cancel({ id: "vac_1" });
@@ -494,25 +767,25 @@ describe("vacation router", () => {
});
it("throws NOT_FOUND for missing vacation", async () => {
- const db = {
+ const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue(null),
},
- };
+ });
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "missing" })).rejects.toThrow("Vacation not found");
});
it("throws when already cancelled", async () => {
- const db = {
+ const db = createVacationDb({
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
status: VacationStatus.CANCELLED,
}),
},
- };
+ });
const caller = createProtectedCaller(db);
await expect(caller.cancel({ id: "vac_1" })).rejects.toThrow("Already cancelled");
@@ -521,7 +794,7 @@ describe("vacation router", () => {
describe("batchApprove", () => {
it("approves multiple pending vacations", async () => {
- const db = {
+ const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
@@ -535,7 +808,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
- };
+ });
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
@@ -552,7 +825,7 @@ describe("vacation router", () => {
});
it("only approves PENDING vacations from the requested set", async () => {
- const db = {
+ const db = createVacationDb({
user: {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
@@ -565,7 +838,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
- };
+ });
const caller = createManagerCaller(db);
const result = await caller.batchApprove({ ids: ["vac_1", "vac_already_approved"] });
@@ -581,7 +854,10 @@ describe("vacation router", () => {
describe("batchReject", () => {
it("rejects multiple pending vacations with optional reason", async () => {
- const db = {
+ const db = createVacationDb({
+ user: {
+ findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
+ },
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
@@ -591,7 +867,7 @@ describe("vacation router", () => {
resource: {
findUnique: vi.fn().mockResolvedValue(null),
},
- };
+ });
const caller = createManagerCaller(db);
const result = await caller.batchReject({
@@ -731,8 +1007,8 @@ describe("vacation router", () => {
const db = {
resource: {
findMany: vi.fn().mockResolvedValue([
- { id: "res_1" },
- { id: "res_2" },
+ { id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
+ { id: "res_2", federalState: "BY", country: { code: "DE" }, metroCity: null },
]),
},
user: {
@@ -759,7 +1035,9 @@ describe("vacation router", () => {
it("skips already existing holidays", async () => {
const db = {
resource: {
- findMany: vi.fn().mockResolvedValue([{ id: "res_1" }]),
+ findMany: vi.fn().mockResolvedValue([
+ { id: "res_1", federalState: "BY", country: { code: "DE" }, metroCity: null },
+ ]),
},
user: {
findUnique: vi.fn().mockResolvedValue({ id: "admin_1" }),
diff --git a/packages/api/src/lib/auto-staffing.ts b/packages/api/src/lib/auto-staffing.ts
index 5e11f6e..fecd654 100644
--- a/packages/api/src/lib/auto-staffing.ts
+++ b/packages/api/src/lib/auto-staffing.ts
@@ -1,6 +1,11 @@
import { listAssignmentBookings } from "@capakraken/application";
import { rankResources } from "@capakraken/staffing";
-import type { SkillEntry } from "@capakraken/shared";
+import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
+import {
+ calculateEffectiveAvailableHours,
+ calculateEffectiveBookedHours,
+ loadResourceDailyAvailabilityContexts,
+} from "./resource-capacity.js";
import { createNotificationsForUsers } from "./create-notification.js";
/**
@@ -58,6 +63,11 @@ type DbClient = Parameters[0] & {
chargeabilityTarget: number;
availability: unknown;
valueScore: number | null;
+ countryId: string | null;
+ federalState: string | null;
+ metroCityId: string | null;
+ country: { code: string | null } | null;
+ metroCity: { name: string | null } | null;
}>>;
};
notification: {
@@ -154,27 +164,54 @@ export async function generateAutoSuggestions(
endDate: demand.endDate,
resourceIds: resources.map((r) => r.id),
});
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ db as Parameters[0],
+ resources.map((resource) => ({
+ id: resource.id,
+ availability: resource.availability as unknown as WeekdayAvailability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ })),
+ demand.startDate,
+ demand.endDate,
+ );
// 5. Enrich resources with utilization data for the demand's date range
const enrichedResources = resources.map((resource) => {
- const avail = resource.availability as
- | { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
- | null;
- const totalAvailableHours = avail?.monday ?? 8;
+ const availability = resource.availability as unknown as WeekdayAvailability;
+ const context = contexts.get(resource.id);
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
-
- const allocatedHoursPerDay = resourceBookings.reduce(
- (sum, b) => sum + b.hoursPerDay,
+ const totalAvailableHours = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: demand.startDate,
+ periodEnd: demand.endDate,
+ context,
+ });
+ const allocatedHours = resourceBookings.reduce(
+ (sum, booking) =>
+ sum + calculateEffectiveBookedHours({
+ availability,
+ startDate: booking.startDate,
+ endDate: booking.endDate,
+ hoursPerDay: booking.hoursPerDay,
+ periodStart: demand.startDate,
+ periodEnd: demand.endDate,
+ context,
+ }),
0,
);
const utilizationPercent =
totalAvailableHours > 0
- ? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
+ ? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
: 0;
- const wouldExceedCapacity =
- allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
+ const wouldExceedCapacity = totalAvailableHours > 0
+ ? allocatedHours + demand.hoursPerDay > totalAvailableHours
+ : demand.hoursPerDay > 0;
return {
id: resource.id,
diff --git a/packages/api/src/lib/chargeability-alerts.ts b/packages/api/src/lib/chargeability-alerts.ts
index 24712d0..89e1529 100644
--- a/packages/api/src/lib/chargeability-alerts.ts
+++ b/packages/api/src/lib/chargeability-alerts.ts
@@ -1,14 +1,16 @@
import {
deriveResourceForecast,
getMonthRange,
- countWorkingDaysInOverlap,
- calculateSAH,
type AssignmentSlice,
} from "@capakraken/engine";
-import type { SpainScheduleRule } from "@capakraken/shared";
+import type { WeekdayAvailability } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
-import { VacationStatus } from "@capakraken/db";
import { createNotificationsForUsers } from "./create-notification.js";
+import {
+ calculateEffectiveAvailableHours,
+ calculateEffectiveBookedHours,
+ loadResourceDailyAvailabilityContexts,
+} from "./resource-capacity.js";
/**
* Minimal DB client type for chargeability alerts.
@@ -24,23 +26,19 @@ type DbClient = {
id: string;
displayName: string;
fte: number;
+ availability: unknown;
+ countryId: string | null;
+ metroCityId: string | null;
+ federalState: string | null;
chargeabilityTarget: number;
- country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null;
+ country: {
+ id?: string | null;
+ code: string | null;
+ dailyWorkingHours: number | null;
+ scheduleRules: unknown;
+ } | null;
managementLevelGroup: { targetPercentage: number | null } | null;
- }>
- >;
- };
- vacation: {
- findMany: (args: {
- where: Record;
- select: Record;
- }) => Promise<
- Array<{
- resourceId: string;
- startDate: Date;
- endDate: Date;
- type: string;
- isHalfDay: boolean;
+ metroCity: { id?: string | null; name: string | null } | null;
}>
>;
};
@@ -105,9 +103,14 @@ export async function checkChargeabilityAlerts(
id: true,
displayName: true,
fte: true,
+ availability: true,
+ countryId: true,
+ metroCityId: true,
+ federalState: true,
chargeabilityTarget: true,
- country: { select: { dailyWorkingHours: true, scheduleRules: true } },
+ country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
managementLevelGroup: { select: { targetPercentage: true } },
+ metroCity: { select: { id: true, name: true } },
},
});
@@ -121,56 +124,32 @@ export async function checkChargeabilityAlerts(
endDate: monthEnd,
resourceIds,
});
-
- // Fetch vacations for the current month
- const vacations = await (db as DbClient).vacation.findMany({
- where: {
- resourceId: { in: resourceIds },
- status: VacationStatus.APPROVED,
- startDate: { lte: monthEnd },
- endDate: { gte: monthStart },
- },
- select: {
- resourceId: true,
- startDate: true,
- endDate: true,
- type: true,
- isHalfDay: true,
- },
- });
+ const availabilityContexts = await loadResourceDailyAvailabilityContexts(
+ db as Parameters[0],
+ resources.map((resource) => ({
+ id: resource.id,
+ availability: resource.availability as unknown as WeekdayAvailability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ })),
+ monthStart,
+ monthEnd,
+ );
// Compute chargeability per resource
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
for (const resource of resources) {
- const dailyHours = resource.country?.dailyWorkingHours ?? 8;
-
- // Compute absence dates for SAH
- const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
- const absenceDates: string[] = [];
- for (const v of resourceVacations) {
- const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
- const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
- if (vStart > vEnd) continue;
- const cursor = new Date(vStart);
- cursor.setUTCHours(0, 0, 0, 0);
- const endNorm = new Date(vEnd);
- endNorm.setUTCHours(0, 0, 0, 0);
- while (cursor <= endNorm) {
- absenceDates.push(cursor.toISOString().slice(0, 10));
- cursor.setUTCDate(cursor.getUTCDate() + 1);
- }
- }
-
- const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null;
- const sahResult = calculateSAH({
- dailyWorkingHours: dailyHours,
- scheduleRules,
- fte: resource.fte,
+ const availability = resource.availability as unknown as WeekdayAvailability;
+ const context = availabilityContexts.get(resource.id);
+ const availableHours = calculateEffectiveAvailableHours({
+ availability,
periodStart: monthStart,
periodEnd: monthEnd,
- publicHolidays: [],
- absenceDays: absenceDates,
+ context,
});
// Build assignment slices
@@ -178,12 +157,24 @@ export async function checkChargeabilityAlerts(
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
);
- const slices: AssignmentSlice[] = resourceBookings.map((b) => {
- const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate);
+ const slices: AssignmentSlice[] = resourceBookings.flatMap((b) => {
+ const totalChargeableHours = calculateEffectiveBookedHours({
+ availability,
+ startDate: b.startDate,
+ endDate: b.endDate,
+ hoursPerDay: b.hoursPerDay,
+ periodStart: monthStart,
+ periodEnd: monthEnd,
+ context,
+ });
+ if (totalChargeableHours <= 0) {
+ return [];
+ }
return {
hoursPerDay: b.hoursPerDay,
- workingDays,
+ workingDays: 0,
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
+ totalChargeableHours,
};
});
@@ -194,7 +185,7 @@ export async function checkChargeabilityAlerts(
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
- sah: sahResult.standardAvailableHours,
+ sah: availableHours,
});
const chgPct = forecast.chg * 100;
diff --git a/packages/api/src/lib/holiday-auto-import.ts b/packages/api/src/lib/holiday-auto-import.ts
index d18b377..e32bc46 100644
--- a/packages/api/src/lib/holiday-auto-import.ts
+++ b/packages/api/src/lib/holiday-auto-import.ts
@@ -9,7 +9,7 @@
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
*/
-import { getPublicHolidays } from "@capakraken/shared";
+import { asHolidayResolverDb, getResolvedCalendarHolidays } from "./holiday-availability.js";
interface MinimalVacation {
resourceId: string;
@@ -19,14 +19,20 @@ interface MinimalVacation {
interface AutoImportDb {
resource: {
- findMany: (args: {
- where: { isActive: boolean };
- select: { id: string; federalState: string };
- }) => Promise>;
+ findMany: (args: any) => any;
+ };
+ country?: {
+ findUnique: (args: any) => any;
+ };
+ metroCity?: {
+ findUnique: (args: any) => any;
+ };
+ holidayCalendar?: {
+ findMany: (args: any) => any;
};
vacation: {
- findMany: (args: unknown) => Promise;
- createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>;
+ findMany: (args: any) => any;
+ createMany: (args: any) => any;
};
}
@@ -42,34 +48,60 @@ export interface AutoImportResult {
* Returns the number of holiday vacation records created.
*/
export async function autoImportPublicHolidays(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- db: any,
+ db: AutoImportDb,
year: number,
): Promise {
- const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({
+ const resources = await db.resource.findMany({
where: { isActive: true },
- select: { id: true, federalState: true },
+ select: {
+ id: true,
+ federalState: true,
+ countryId: true,
+ metroCityId: true,
+ country: { select: { code: true } },
+ metroCity: { select: { name: true } },
+ },
});
if (resources.length === 0) {
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
}
- // Group resources by federal state (null = federal-only holidays)
- const byState = new Map();
+ const nextYearStart = new Date(`${year}-01-01T00:00:00.000Z`);
+ const nextYearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
+ const byHolidayProfile = new Map();
+
for (const resource of resources) {
- const state = resource.federalState ?? null;
- const group = byState.get(state) ?? [];
- group.push(resource.id);
- byState.set(state, group);
+ const profileKey = JSON.stringify({
+ countryCode: resource.country?.code ?? null,
+ federalState: resource.federalState ?? null,
+ metroCityName: resource.metroCity?.name ?? null,
+ });
+ const group = byHolidayProfile.get(profileKey) ?? [];
+ group.push(resource);
+ byHolidayProfile.set(profileKey, group);
}
let totalCreated = 0;
let totalSkipped = 0;
- for (const [state, resourceIds] of byState) {
- const holidays = getPublicHolidays(year, state ?? undefined);
+ for (const [, groupedResources] of byHolidayProfile) {
+ const sample = groupedResources[0];
+ if (!sample) {
+ continue;
+ }
+
+ const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
+ periodStart: nextYearStart,
+ periodEnd: nextYearEnd,
+ countryId: sample.countryId,
+ countryCode: sample.country?.code ?? null,
+ federalState: sample.federalState,
+ metroCityId: sample.metroCityId,
+ metroCityName: sample.metroCity?.name ?? null,
+ });
if (holidays.length === 0) continue;
+ const resourceIds = groupedResources.map((resource: { id: string }) => resource.id);
for (const holiday of holidays) {
const holidayDate = new Date(holiday.date);
@@ -86,13 +118,13 @@ export async function autoImportPublicHolidays(
});
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
- const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id));
+ const newResourceIds = resourceIds.filter((id: string) => !existingResourceIds.has(id));
totalSkipped += existingResourceIds.size;
if (newResourceIds.length === 0) continue;
- const records = newResourceIds.map((resourceId) => ({
+ const records = newResourceIds.map((resourceId: string) => ({
resourceId,
type: "PUBLIC_HOLIDAY",
status: "APPROVED",
diff --git a/packages/api/src/lib/holiday-availability.ts b/packages/api/src/lib/holiday-availability.ts
new file mode 100644
index 0000000..04d7df1
--- /dev/null
+++ b/packages/api/src/lib/holiday-availability.ts
@@ -0,0 +1,464 @@
+import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
+
+type VacationLike = {
+ startDate: Date;
+ endDate: Date;
+ type: string;
+ isHalfDay: boolean;
+};
+
+type HolidayAvailabilityInput = {
+ vacations: VacationLike[];
+ periodStart: Date;
+ periodEnd: Date;
+ countryCode?: string | null | undefined;
+ federalState?: string | null | undefined;
+ metroCityName?: string | null | undefined;
+ resolvedHolidayStrings?: string[] | undefined;
+};
+
+type HolidayAvailabilityResult = {
+ absenceDateStrings: string[];
+ publicHolidayStrings: string[];
+ absenceDays: AbsenceDay[];
+};
+
+export type CalendarHoliday = {
+ date: string;
+ name: string;
+ scope: "COUNTRY" | "STATE" | "CITY";
+};
+
+type CalendarScope = CalendarHoliday["scope"];
+
+type HolidayCalendarEntryRecord = {
+ date: Date;
+ name: string;
+ isRecurringAnnual: boolean;
+};
+
+type HolidayCalendarRecord = {
+ id: string;
+ name: string;
+ scopeType: CalendarScope;
+ priority: number;
+ createdAt?: Date;
+ entries: HolidayCalendarEntryRecord[];
+};
+
+type HolidayResolverDb = {
+ [key: string]: unknown;
+ country?: {
+ findUnique: (args: any) => any;
+ };
+ metroCity?: {
+ findUnique: (args: any) => any;
+ };
+ holidayCalendar?: {
+ findMany: (args: any) => any;
+ };
+};
+
+type ResolvedHoliday = CalendarHoliday & {
+ calendarName: string;
+ priority: number;
+ sourceType: "BUILTIN" | "CUSTOM";
+};
+
+export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
+ return db as HolidayResolverDb;
+}
+
+export function toIsoDate(value: Date): string {
+ return value.toISOString().slice(0, 10);
+}
+
+type CityHolidayRule = {
+ countryCode: string;
+ cityName: string;
+ resolveDates: (year: number) => string[];
+};
+
+const CITY_HOLIDAY_RULES: CityHolidayRule[] = [
+ {
+ countryCode: "DE",
+ cityName: "Augsburg",
+ resolveDates: (year) => [`${year}-08-08`],
+ },
+];
+
+const SCOPE_WEIGHT: Record = {
+ COUNTRY: 1,
+ STATE: 2,
+ CITY: 3,
+};
+
+function normalizeCityName(cityName?: string | null): string | null {
+ const normalized = cityName?.trim().toLowerCase();
+ return normalized && normalized.length > 0 ? normalized : null;
+}
+
+function normalizeStateCode(stateCode?: string | null): string | null {
+ const normalized = stateCode?.trim().toUpperCase();
+ return normalized && normalized.length > 0 ? normalized : null;
+}
+
+function resolveCalendarEntries(
+ calendars: HolidayCalendarRecord[],
+ periodStart: Date,
+ periodEnd: Date,
+): ResolvedHoliday[] {
+ const startYear = periodStart.getUTCFullYear();
+ const endYear = periodEnd.getUTCFullYear();
+ const startIso = toIsoDate(periodStart);
+ const endIso = toIsoDate(periodEnd);
+ const resolved = new Map();
+
+ for (const calendar of calendars) {
+ for (const entry of calendar.entries) {
+ const baseDate = new Date(entry.date);
+
+ for (let year = startYear; year <= endYear; year += 1) {
+ const effectiveDate = entry.isRecurringAnnual
+ ? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
+ : baseDate;
+ const key = toIsoDate(effectiveDate);
+
+ if (key < startIso || key > endIso) {
+ if (!entry.isRecurringAnnual) {
+ break;
+ }
+ continue;
+ }
+
+ const candidate: ResolvedHoliday = {
+ date: key,
+ name: entry.name,
+ scope: calendar.scopeType,
+ calendarName: calendar.name,
+ priority: calendar.priority,
+ sourceType: "CUSTOM",
+ };
+ const existing = resolved.get(key);
+
+ if (
+ !existing
+ || SCOPE_WEIGHT[candidate.scope] > SCOPE_WEIGHT[existing.scope]
+ || (
+ SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
+ && candidate.priority > existing.priority
+ )
+ || (
+ SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
+ && candidate.priority === existing.priority
+ && existing.sourceType === "BUILTIN"
+ )
+ ) {
+ resolved.set(key, candidate);
+ }
+
+ if (!entry.isRecurringAnnual) {
+ break;
+ }
+ }
+ }
+ }
+
+ return [...resolved.values()].sort((left, right) => left.date.localeCompare(right.date));
+}
+
+function mergeResolvedHolidays(
+ builtInHolidays: CalendarHoliday[],
+ customHolidays: ResolvedHoliday[],
+): ResolvedHoliday[] {
+ const merged = new Map();
+
+ for (const holiday of builtInHolidays) {
+ merged.set(holiday.date, {
+ ...holiday,
+ calendarName: "System",
+ priority: Number.MIN_SAFE_INTEGER,
+ sourceType: "BUILTIN",
+ });
+ }
+
+ for (const holiday of customHolidays) {
+ const existing = merged.get(holiday.date);
+ if (
+ !existing
+ || SCOPE_WEIGHT[holiday.scope] > SCOPE_WEIGHT[existing.scope]
+ || (
+ SCOPE_WEIGHT[holiday.scope] === SCOPE_WEIGHT[existing.scope]
+ && holiday.priority >= existing.priority
+ )
+ ) {
+ merged.set(holiday.date, holiday);
+ }
+ }
+
+ return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
+}
+
+async function loadScopedHolidayCalendars(
+ db: HolidayResolverDb,
+ input: {
+ countryId?: string | null | undefined;
+ stateCode?: string | null | undefined;
+ metroCityId?: string | null | undefined;
+ },
+): Promise {
+ if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
+ return [];
+ }
+
+ const stateCode = normalizeStateCode(input.stateCode);
+ const metroCityId = input.metroCityId?.trim() || null;
+
+ return db.holidayCalendar.findMany({
+ where: {
+ isActive: true,
+ countryId: input.countryId,
+ OR: [
+ { scopeType: "COUNTRY" },
+ ...(stateCode ? [{ scopeType: "STATE" as const, stateCode }] : []),
+ ...(metroCityId ? [{ scopeType: "CITY" as const, metroCityId }] : []),
+ ],
+ },
+ include: { entries: true },
+ orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
+ });
+}
+
+export function getCalendarHolidayStrings(
+ periodStart: Date,
+ periodEnd: Date,
+ countryCode?: string | null,
+ federalState?: string | null,
+ metroCityName?: string | null,
+): string[] {
+ return getCalendarHolidays(
+ periodStart,
+ periodEnd,
+ countryCode,
+ federalState,
+ metroCityName,
+ ).map((holiday) => holiday.date);
+}
+
+export function getCalendarHolidays(
+ periodStart: Date,
+ periodEnd: Date,
+ countryCode?: string | null,
+ federalState?: string | null,
+ metroCityName?: string | null,
+): CalendarHoliday[] {
+ const startYear = periodStart.getUTCFullYear();
+ const endYear = periodEnd.getUTCFullYear();
+ const holidays = new Map();
+
+ if (countryCode === "DE") {
+ for (let year = startYear; year <= endYear; year += 1) {
+ for (const holiday of getPublicHolidays(year, federalState ?? undefined)) {
+ if (holiday.date >= toIsoDate(periodStart) && holiday.date <= toIsoDate(periodEnd)) {
+ holidays.set(holiday.date, {
+ date: holiday.date,
+ name: holiday.name,
+ scope: holiday.federal ? "COUNTRY" : "STATE",
+ });
+ }
+ }
+ }
+ }
+
+ const normalizedCityName = normalizeCityName(metroCityName);
+ if (countryCode && normalizedCityName) {
+ for (const rule of CITY_HOLIDAY_RULES) {
+ if (
+ rule.countryCode === countryCode
+ && normalizeCityName(rule.cityName) === normalizedCityName
+ ) {
+ for (let year = startYear; year <= endYear; year += 1) {
+ for (const holidayDate of rule.resolveDates(year)) {
+ if (holidayDate >= toIsoDate(periodStart) && holidayDate <= toIsoDate(periodEnd)) {
+ holidays.set(holidayDate, {
+ date: holidayDate,
+ name: "Augsburger Friedensfest",
+ scope: "CITY",
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return [...holidays.values()].sort((left, right) => left.date.localeCompare(right.date));
+}
+
+export async function getResolvedCalendarHolidays(
+ db: HolidayResolverDb,
+ input: {
+ periodStart: Date;
+ periodEnd: Date;
+ countryId?: string | null | undefined;
+ countryCode?: string | null | undefined;
+ federalState?: string | null | undefined;
+ metroCityId?: string | null | undefined;
+ metroCityName?: string | null | undefined;
+ },
+): Promise {
+ let countryCode = input.countryCode ?? null;
+ if (!countryCode && input.countryId && typeof db.country?.findUnique === "function") {
+ const country = await db.country.findUnique({
+ where: { id: input.countryId },
+ select: { code: true },
+ });
+ countryCode = country?.code ?? null;
+ }
+
+ let metroCityName = input.metroCityName ?? null;
+ if (!metroCityName && input.metroCityId && typeof db.metroCity?.findUnique === "function") {
+ const metroCity = await db.metroCity.findUnique({
+ where: { id: input.metroCityId },
+ select: { name: true },
+ });
+ metroCityName = metroCity?.name ?? null;
+ }
+
+ const builtIn = getCalendarHolidays(
+ input.periodStart,
+ input.periodEnd,
+ countryCode,
+ input.federalState,
+ metroCityName,
+ );
+ const calendars = await loadScopedHolidayCalendars(db, {
+ countryId: input.countryId,
+ stateCode: input.federalState,
+ metroCityId: input.metroCityId,
+ });
+ const custom = resolveCalendarEntries(calendars, input.periodStart, input.periodEnd);
+
+ return mergeResolvedHolidays(builtIn, custom);
+}
+
+export async function getResolvedCalendarHolidayStrings(
+ db: HolidayResolverDb,
+ input: {
+ periodStart: Date;
+ periodEnd: Date;
+ countryId?: string | null | undefined;
+ countryCode?: string | null | undefined;
+ federalState?: string | null | undefined;
+ metroCityId?: string | null | undefined;
+ metroCityName?: string | null | undefined;
+ },
+): Promise {
+ const holidays = await getResolvedCalendarHolidays(db, input);
+ return holidays.map((holiday) => holiday.date);
+}
+
+export function collectHolidayAvailability(
+ input: HolidayAvailabilityInput,
+): HolidayAvailabilityResult {
+ const periodStartIso = toIsoDate(input.periodStart);
+ const periodEndIso = toIsoDate(input.periodEnd);
+ const publicHolidaySet = new Set(
+ input.resolvedHolidayStrings
+ ? input.resolvedHolidayStrings.filter((date) => date >= periodStartIso && date <= periodEndIso)
+ : getCalendarHolidayStrings(
+ input.periodStart,
+ input.periodEnd,
+ input.countryCode,
+ input.federalState,
+ input.metroCityName,
+ ),
+ );
+ const absenceDateSet = new Set();
+ const absenceDayMap = new Map();
+
+ for (const isoDate of publicHolidaySet) {
+ absenceDayMap.set(isoDate, {
+ date: new Date(`${isoDate}T00:00:00.000Z`),
+ type: "PUBLIC_HOLIDAY",
+ });
+ }
+
+ for (const vacation of input.vacations) {
+ if (vacation.type !== "PUBLIC_HOLIDAY") {
+ continue;
+ }
+
+ const overlapStart = new Date(
+ Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
+ );
+ const overlapEnd = new Date(
+ Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
+ );
+
+ if (overlapStart > overlapEnd) {
+ continue;
+ }
+
+ const cursor = new Date(overlapStart);
+ cursor.setUTCHours(0, 0, 0, 0);
+ const end = new Date(overlapEnd);
+ end.setUTCHours(0, 0, 0, 0);
+
+ while (cursor <= end) {
+ const isoDate = toIsoDate(cursor);
+ publicHolidaySet.add(isoDate);
+ absenceDayMap.set(isoDate, {
+ date: new Date(cursor),
+ type: "PUBLIC_HOLIDAY",
+ ...(vacation.isHalfDay ? { isHalfDay: true } : {}),
+ });
+
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+ }
+
+ for (const vacation of input.vacations) {
+ if (vacation.type === "PUBLIC_HOLIDAY") {
+ continue;
+ }
+
+ const overlapStart = new Date(
+ Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
+ );
+ const overlapEnd = new Date(
+ Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
+ );
+
+ if (overlapStart > overlapEnd) {
+ continue;
+ }
+
+ const cursor = new Date(overlapStart);
+ cursor.setUTCHours(0, 0, 0, 0);
+ const end = new Date(overlapEnd);
+ end.setUTCHours(0, 0, 0, 0);
+
+ const triggerType = vacation.type === "SICK" ? "SICK" : "VACATION";
+
+ while (cursor <= end) {
+ const isoDate = toIsoDate(cursor);
+ if (!publicHolidaySet.has(isoDate)) {
+ absenceDateSet.add(isoDate);
+ absenceDayMap.set(isoDate, {
+ date: new Date(cursor),
+ type: triggerType,
+ ...(vacation.isHalfDay ? { isHalfDay: true } : {}),
+ });
+ }
+
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+ }
+
+ return {
+ absenceDateStrings: [...absenceDateSet].sort(),
+ publicHolidayStrings: [...publicHolidaySet].sort(),
+ absenceDays: [...absenceDayMap.values()],
+ };
+}
diff --git a/packages/api/src/lib/logger.ts b/packages/api/src/lib/logger.ts
index 9552a7b..aaa084a 100644
--- a/packages/api/src/lib/logger.ts
+++ b/packages/api/src/lib/logger.ts
@@ -3,23 +3,24 @@ import pino from "pino";
const isProduction = process.env["NODE_ENV"] === "production";
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
+const devDestination = pino.destination({ dest: 1, sync: true });
-export const logger = pino({
- level: LOG_LEVEL,
- base: { service: "capakraken-api" },
- ...(isProduction
- ? {}
- : {
- transport: {
- target: "pino/file",
- options: { destination: 1 }, // stdout
- },
+export const logger = isProduction
+ ? pino({
+ level: LOG_LEVEL,
+ base: { service: "capakraken-api" },
+ })
+ : pino(
+ {
+ level: LOG_LEVEL,
+ base: { service: "capakraken-api" },
formatters: {
level(label: string) {
return { level: label };
},
},
- }),
-});
+ },
+ devDestination,
+ );
export type Logger = typeof logger;
diff --git a/packages/api/src/lib/resource-capacity.ts b/packages/api/src/lib/resource-capacity.ts
new file mode 100644
index 0000000..5995ba0
--- /dev/null
+++ b/packages/api/src/lib/resource-capacity.ts
@@ -0,0 +1,439 @@
+import { VacationStatus } from "@capakraken/db";
+import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
+
+type CalendarScope = "COUNTRY" | "STATE" | "CITY";
+
+type HolidayCalendarEntryRecord = {
+ date: Date;
+ isRecurringAnnual: boolean;
+};
+
+type HolidayCalendarRecord = {
+ entries: HolidayCalendarEntryRecord[];
+};
+
+type VacationRecord = {
+ resourceId: string;
+ startDate: Date;
+ endDate: Date;
+ type: string;
+ isHalfDay: boolean;
+};
+
+export type ResourceCapacityProfile = {
+ id: string;
+ availability: WeekdayAvailability;
+ countryId: string | null | undefined;
+ countryCode: string | null | undefined;
+ federalState: string | null | undefined;
+ metroCityId: string | null | undefined;
+ metroCityName: string | null | undefined;
+};
+
+export type ResourceDailyAvailabilityContext = {
+ absenceFractionsByDate: Map;
+ holidayDates: Set;
+ vacationFractionsByDate: Map;
+};
+
+type ResourceCapacityDbClient = {
+ holidayCalendar?: {
+ findMany: (args: {
+ where: Record;
+ include: { entries: true };
+ orderBy: Array>;
+ }) => Promise;
+ };
+ vacation?: {
+ findMany: (args: {
+ where: Record;
+ select: Record>;
+ }) => Promise;
+ };
+};
+
+const DAY_KEYS: (keyof WeekdayAvailability)[] = [
+ "sunday",
+ "monday",
+ "tuesday",
+ "wednesday",
+ "thursday",
+ "friday",
+ "saturday",
+];
+
+const CITY_HOLIDAY_RULES: Array<{
+ countryCode: string;
+ cityName: string;
+ resolveDates: (year: number) => string[];
+}> = [
+ {
+ countryCode: "DE",
+ cityName: "Augsburg",
+ resolveDates: (year) => [`${year}-08-08`],
+ },
+];
+
+function toIsoDate(value: Date): string {
+ return value.toISOString().slice(0, 10);
+}
+
+function normalizeCityName(cityName?: string | null): string | null {
+ const normalized = cityName?.trim().toLowerCase();
+ return normalized && normalized.length > 0 ? normalized : null;
+}
+
+function normalizeStateCode(stateCode?: string | null): string | null {
+ const normalized = stateCode?.trim().toUpperCase();
+ return normalized && normalized.length > 0 ? normalized : null;
+}
+
+export function getAvailabilityHoursForDate(
+ availability: WeekdayAvailability,
+ date: Date,
+): number {
+ const key = DAY_KEYS[date.getUTCDay()];
+ return key ? (availability[key] ?? 0) : 0;
+}
+
+function listBuiltinHolidayDates(input: {
+ periodStart: Date;
+ periodEnd: Date;
+ countryCode: string | null | undefined;
+ federalState: string | null | undefined;
+ metroCityName: string | null | undefined;
+}): Set {
+ const dates = new Set();
+ const startIso = toIsoDate(input.periodStart);
+ const endIso = toIsoDate(input.periodEnd);
+ const startYear = input.periodStart.getUTCFullYear();
+ const endYear = input.periodEnd.getUTCFullYear();
+
+ if (input.countryCode === "DE") {
+ for (let year = startYear; year <= endYear; year += 1) {
+ for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
+ if (holiday.date >= startIso && holiday.date <= endIso) {
+ dates.add(holiday.date);
+ }
+ }
+ }
+ }
+
+ const normalizedCityName = normalizeCityName(input.metroCityName);
+ if (input.countryCode && normalizedCityName) {
+ for (const rule of CITY_HOLIDAY_RULES) {
+ if (
+ rule.countryCode === input.countryCode
+ && normalizeCityName(rule.cityName) === normalizedCityName
+ ) {
+ for (let year = startYear; year <= endYear; year += 1) {
+ for (const date of rule.resolveDates(year)) {
+ if (date >= startIso && date <= endIso) {
+ dates.add(date);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return dates;
+}
+
+function resolveCalendarEntryDates(
+ calendars: HolidayCalendarRecord[],
+ periodStart: Date,
+ periodEnd: Date,
+): Set {
+ const dates = new Set();
+ const startIso = toIsoDate(periodStart);
+ const endIso = toIsoDate(periodEnd);
+ const startYear = periodStart.getUTCFullYear();
+ const endYear = periodEnd.getUTCFullYear();
+
+ for (const calendar of calendars) {
+ for (const entry of calendar.entries) {
+ const baseDate = new Date(entry.date);
+ for (let year = startYear; year <= endYear; year += 1) {
+ const effectiveDate = entry.isRecurringAnnual
+ ? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
+ : baseDate;
+ const isoDate = toIsoDate(effectiveDate);
+ if (isoDate >= startIso && isoDate <= endIso) {
+ dates.add(isoDate);
+ }
+ if (!entry.isRecurringAnnual) {
+ break;
+ }
+ }
+ }
+ }
+
+ return dates;
+}
+
+async function loadCustomHolidayDates(
+ db: ResourceCapacityDbClient,
+ input: {
+ periodStart: Date;
+ periodEnd: Date;
+ countryId: string | null | undefined;
+ federalState: string | null | undefined;
+ metroCityId: string | null | undefined;
+ },
+): Promise> {
+ if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
+ return new Set();
+ }
+
+ const stateCode = normalizeStateCode(input.federalState);
+ const metroCityId = input.metroCityId?.trim() || null;
+ const calendars = await db.holidayCalendar.findMany({
+ where: {
+ isActive: true,
+ countryId: input.countryId,
+ OR: [
+ { scopeType: "COUNTRY" as CalendarScope },
+ ...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
+ ...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
+ ],
+ },
+ include: { entries: true },
+ orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
+ });
+
+ return resolveCalendarEntryDates(
+ calendars as HolidayCalendarRecord[],
+ input.periodStart,
+ input.periodEnd,
+ );
+}
+
+function buildProfileKey(profile: ResourceCapacityProfile): string {
+ return JSON.stringify({
+ countryId: profile.countryId ?? null,
+ countryCode: profile.countryCode ?? null,
+ federalState: profile.federalState ?? null,
+ metroCityId: profile.metroCityId ?? null,
+ metroCityName: profile.metroCityName ?? null,
+ });
+}
+
+export async function loadResourceDailyAvailabilityContexts(
+ db: ResourceCapacityDbClient,
+ resources: ResourceCapacityProfile[],
+ periodStart: Date,
+ periodEnd: Date,
+): Promise> {
+ const profileHolidayCache = new Map>>();
+ const resourceIds = resources.map((resource) => resource.id);
+
+ const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
+ ? await db.vacation.findMany({
+ where: {
+ resourceId: { in: resourceIds },
+ status: VacationStatus.APPROVED,
+ startDate: { lte: periodEnd },
+ endDate: { gte: periodStart },
+ },
+ select: {
+ resourceId: true,
+ startDate: true,
+ endDate: true,
+ type: true,
+ isHalfDay: true,
+ },
+ })
+ : [];
+
+ const vacationsByResourceId = new Map();
+ for (const vacation of vacations as VacationRecord[]) {
+ const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
+ items.push(vacation);
+ vacationsByResourceId.set(vacation.resourceId, items);
+ }
+
+ const contexts = new Map();
+
+ for (const resource of resources) {
+ const profileKey = buildProfileKey(resource);
+ const holidayPromise = profileHolidayCache.get(profileKey)
+ ?? (async () => {
+ const builtin = listBuiltinHolidayDates({
+ periodStart,
+ periodEnd,
+ countryCode: resource.countryCode,
+ federalState: resource.federalState,
+ metroCityName: resource.metroCityName,
+ });
+ const custom = await loadCustomHolidayDates(db, {
+ periodStart,
+ periodEnd,
+ countryId: resource.countryId,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ });
+ return new Set([...builtin, ...custom]);
+ })();
+
+ if (!profileHolidayCache.has(profileKey)) {
+ profileHolidayCache.set(profileKey, holidayPromise);
+ }
+
+ const holidayDates = new Set(await holidayPromise);
+ const absenceFractionsByDate = new Map();
+ const vacationFractionsByDate = new Map();
+ const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
+
+ for (const vacation of resourceVacations) {
+ const overlapStart = new Date(Math.max(vacation.startDate.getTime(), periodStart.getTime()));
+ const overlapEnd = new Date(Math.min(vacation.endDate.getTime(), periodEnd.getTime()));
+ if (overlapStart > overlapEnd) {
+ continue;
+ }
+
+ const cursor = new Date(overlapStart);
+ cursor.setUTCHours(0, 0, 0, 0);
+ const end = new Date(overlapEnd);
+ end.setUTCHours(0, 0, 0, 0);
+
+ while (cursor <= end) {
+ const isoDate = toIsoDate(cursor);
+ const fraction = vacation.isHalfDay ? 0.5 : 1;
+
+ if (vacation.type === "PUBLIC_HOLIDAY") {
+ holidayDates.add(isoDate);
+ }
+
+ if (vacation.type !== "PUBLIC_HOLIDAY") {
+ const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
+ vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
+ }
+
+ const existing = absenceFractionsByDate.get(isoDate) ?? 0;
+ if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
+ absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
+ }
+
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+ }
+
+ for (const isoDate of holidayDates) {
+ const existing = absenceFractionsByDate.get(isoDate) ?? 0;
+ absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
+ }
+
+ contexts.set(resource.id, {
+ absenceFractionsByDate,
+ holidayDates,
+ vacationFractionsByDate,
+ });
+ }
+
+ return contexts;
+}
+
+function calculateDayAvailabilityFraction(
+ context: ResourceDailyAvailabilityContext | undefined,
+ isoDate: string,
+): number {
+ const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
+ return Math.max(0, 1 - fraction);
+}
+
+export function calculateEffectiveDayAvailability(input: {
+ availability: WeekdayAvailability;
+ date: Date;
+ context: ResourceDailyAvailabilityContext | undefined;
+}): number {
+ const baseHours = getAvailabilityHoursForDate(input.availability, input.date);
+ if (baseHours <= 0) {
+ return 0;
+ }
+
+ return baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(input.date));
+}
+
+export function calculateEffectiveAvailableHours(input: {
+ availability: WeekdayAvailability;
+ periodStart: Date;
+ periodEnd: Date;
+ context: ResourceDailyAvailabilityContext | undefined;
+}): number {
+ let hours = 0;
+ const cursor = new Date(input.periodStart);
+ cursor.setUTCHours(0, 0, 0, 0);
+ const end = new Date(input.periodEnd);
+ end.setUTCHours(0, 0, 0, 0);
+
+ while (cursor <= end) {
+ hours += calculateEffectiveDayAvailability({
+ availability: input.availability,
+ date: cursor,
+ context: input.context,
+ });
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+
+ return hours;
+}
+
+export function countEffectiveWorkingDays(input: {
+ availability: WeekdayAvailability;
+ periodStart: Date;
+ periodEnd: Date;
+ context: ResourceDailyAvailabilityContext | undefined;
+}): number {
+ let days = 0;
+ const cursor = new Date(input.periodStart);
+ cursor.setUTCHours(0, 0, 0, 0);
+ const end = new Date(input.periodEnd);
+ end.setUTCHours(0, 0, 0, 0);
+
+ while (cursor <= end) {
+ if (calculateEffectiveDayAvailability({
+ availability: input.availability,
+ date: cursor,
+ context: input.context,
+ }) > 0) {
+ days += 1;
+ }
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+
+ return days;
+}
+
+export function calculateEffectiveBookedHours(input: {
+ availability: WeekdayAvailability;
+ startDate: Date;
+ endDate: Date;
+ hoursPerDay: number;
+ periodStart: Date;
+ periodEnd: Date;
+ context: ResourceDailyAvailabilityContext | undefined;
+}): number {
+ const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime()));
+ const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime()));
+
+ if (overlapStart > overlapEnd) {
+ return 0;
+ }
+
+ let hours = 0;
+ const cursor = new Date(overlapStart);
+ cursor.setUTCHours(0, 0, 0, 0);
+ const end = new Date(overlapEnd);
+ end.setUTCHours(0, 0, 0, 0);
+
+ while (cursor <= end) {
+ const dayBaseHours = getAvailabilityHoursForDate(input.availability, cursor);
+ if (dayBaseHours > 0) {
+ hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
+ }
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+
+ return hours;
+}
diff --git a/packages/api/src/lib/resource-holiday-context.ts b/packages/api/src/lib/resource-holiday-context.ts
new file mode 100644
index 0000000..690033e
--- /dev/null
+++ b/packages/api/src/lib/resource-holiday-context.ts
@@ -0,0 +1,102 @@
+import { VacationStatus, VacationType } from "@capakraken/db";
+import { getResolvedCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
+
+type ResourceHolidayContextDb = {
+ resource: {
+ findUnique: (args: any) => any;
+ };
+ country?: {
+ findUnique: (args: any) => any;
+ };
+ metroCity?: {
+ findUnique: (args: any) => any;
+ };
+ holidayCalendar?: {
+ findMany: (args: any) => any;
+ };
+ vacation: {
+ findMany: (args: any) => any;
+ };
+};
+
+export type ResourceHolidayContext = {
+ countryId?: string | null;
+ countryCode?: string | null;
+ countryName?: string | null;
+ federalState?: string | null;
+ metroCityId?: string | null;
+ metroCityName?: string | null;
+ calendarHolidayStrings: string[];
+ publicHolidayStrings: string[];
+};
+
+function clampToDay(value: Date): Date {
+ const date = new Date(value);
+ date.setUTCHours(0, 0, 0, 0);
+ return date;
+}
+
+export async function loadResourceHolidayContext(
+ db: ResourceHolidayContextDb,
+ resourceId: string,
+ periodStart: Date,
+ periodEnd: Date,
+): Promise {
+ const resource = typeof db.resource?.findUnique === "function"
+ ? await db.resource.findUnique({
+ where: { id: resourceId },
+ select: {
+ federalState: true,
+ countryId: true,
+ metroCityId: true,
+ country: { select: { code: true, name: true } },
+ metroCity: { select: { name: true } },
+ },
+ })
+ : null;
+
+ const holidayVacations = typeof db.vacation?.findMany === "function"
+ ? await db.vacation.findMany({
+ where: {
+ resourceId,
+ type: VacationType.PUBLIC_HOLIDAY,
+ status: VacationStatus.APPROVED,
+ startDate: { lte: periodEnd },
+ endDate: { gte: periodStart },
+ },
+ select: { startDate: true, endDate: true },
+ })
+ : [];
+
+ const calendarHolidayStrings = await getResolvedCalendarHolidayStrings(db, {
+ periodStart,
+ periodEnd,
+ countryId: resource?.countryId ?? null,
+ countryCode: resource?.country?.code ?? null,
+ federalState: resource?.federalState ?? null,
+ metroCityId: resource?.metroCityId ?? null,
+ metroCityName: resource?.metroCity?.name ?? null,
+ });
+ const publicHolidayStrings = new Set();
+
+ for (const holiday of holidayVacations) {
+ const cursor = clampToDay(new Date(Math.max(holiday.startDate.getTime(), periodStart.getTime())));
+ const end = clampToDay(new Date(Math.min(holiday.endDate.getTime(), periodEnd.getTime())));
+
+ while (cursor <= end) {
+ publicHolidayStrings.add(toIsoDate(cursor));
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+ }
+
+ return {
+ countryId: resource?.countryId ?? null,
+ countryCode: resource?.country?.code ?? null,
+ countryName: resource?.country?.name ?? null,
+ federalState: resource?.federalState ?? null,
+ metroCityId: resource?.metroCityId ?? null,
+ metroCityName: resource?.metroCity?.name ?? null,
+ calendarHolidayStrings,
+ publicHolidayStrings: [...publicHolidayStrings].sort(),
+ };
+}
diff --git a/packages/api/src/lib/vacation-day-count.ts b/packages/api/src/lib/vacation-day-count.ts
new file mode 100644
index 0000000..43160b6
--- /dev/null
+++ b/packages/api/src/lib/vacation-day-count.ts
@@ -0,0 +1,112 @@
+import { getCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
+
+type VacationSpan = {
+ startDate: Date;
+ endDate: Date;
+ isHalfDay: boolean;
+};
+
+type HolidayContext = {
+ countryCode?: string | null | undefined;
+ federalState?: string | null | undefined;
+ metroCityName?: string | null | undefined;
+ calendarHolidayStrings?: string[] | undefined;
+ publicHolidayStrings?: string[] | undefined;
+};
+
+type CountVacationChargeableDaysInput = HolidayContext & {
+ vacation: VacationSpan;
+ periodStart?: Date | undefined;
+ periodEnd?: Date | undefined;
+};
+
+function clampToDay(value: Date): Date {
+ const date = new Date(value);
+ date.setUTCHours(0, 0, 0, 0);
+ return date;
+}
+
+function getOverlapRange(
+ startDate: Date,
+ endDate: Date,
+ periodStart?: Date,
+ periodEnd?: Date,
+): { start: Date; end: Date } | null {
+ const startBoundary = clampToDay(periodStart ?? startDate);
+ const endBoundary = clampToDay(periodEnd ?? endDate);
+ const overlapStart = clampToDay(new Date(Math.max(startDate.getTime(), startBoundary.getTime())));
+ const overlapEnd = clampToDay(new Date(Math.min(endDate.getTime(), endBoundary.getTime())));
+
+ if (overlapStart > overlapEnd) {
+ return null;
+ }
+
+ return { start: overlapStart, end: overlapEnd };
+}
+
+export function countCalendarDaysInPeriod(
+ vacation: VacationSpan,
+ periodStart?: Date,
+ periodEnd?: Date,
+): number {
+ const overlap = getOverlapRange(vacation.startDate, vacation.endDate, periodStart, periodEnd);
+ if (!overlap) {
+ return 0;
+ }
+
+ if (vacation.isHalfDay) {
+ return 0.5;
+ }
+
+ const ms = overlap.end.getTime() - overlap.start.getTime();
+ return Math.round(ms / 86_400_000) + 1;
+}
+
+export function countVacationChargeableDays(
+ input: CountVacationChargeableDaysInput,
+): number {
+ const overlap = getOverlapRange(
+ input.vacation.startDate,
+ input.vacation.endDate,
+ input.periodStart,
+ input.periodEnd,
+ );
+ if (!overlap) {
+ return 0;
+ }
+
+ const holidaySet = new Set(
+ input.calendarHolidayStrings
+ ? input.calendarHolidayStrings.filter((isoDate) => isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end))
+ : getCalendarHolidayStrings(
+ overlap.start,
+ overlap.end,
+ input.countryCode,
+ input.federalState,
+ input.metroCityName,
+ ),
+ );
+
+ for (const isoDate of input.publicHolidayStrings ?? []) {
+ if (isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end)) {
+ holidaySet.add(isoDate);
+ }
+ }
+
+ if (input.vacation.isHalfDay) {
+ return holidaySet.has(toIsoDate(overlap.start)) ? 0 : 0.5;
+ }
+
+ let total = 0;
+ const cursor = new Date(overlap.start);
+
+ while (cursor <= overlap.end) {
+ if (!holidaySet.has(toIsoDate(cursor))) {
+ total += 1;
+ }
+
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+
+ return total;
+}
diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts
index eeeaa82..c649d23 100644
--- a/packages/api/src/router/allocation.ts
+++ b/packages/api/src/router/allocation.ts
@@ -21,6 +21,7 @@ import {
FillDemandRequirementSchema,
FillOpenDemandByAllocationSchema,
PermissionKey,
+ type WeekdayAvailability,
UpdateAssignmentSchema,
UpdateAllocationSchema,
UpdateDemandRequirementSchema,
@@ -34,6 +35,13 @@ import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { generateAutoSuggestions } from "../lib/auto-staffing.js";
import { invalidateDashboardCache } from "../lib/cache.js";
+import {
+ calculateEffectiveAvailableHours,
+ calculateEffectiveBookedHours,
+ calculateEffectiveDayAvailability,
+ countEffectiveWorkingDays,
+ loadResourceDailyAvailabilityContexts,
+} from "../lib/resource-capacity.js";
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
@@ -328,12 +336,26 @@ export const allocationRouter = createTRPCRouter({
where: { id: input.resourceId },
select: {
id: true, displayName: true, eid: true, fte: true,
- country: { select: { dailyWorkingHours: true } },
+ availability: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { dailyWorkingHours: true, code: true } },
+ metroCity: { select: { name: true } },
},
});
if (!resource) throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
- const dailyCapacity = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
+ const fallbackDailyHours = (resource.country?.dailyWorkingHours ?? 8) * (resource.fte ?? 1);
+ const availability = (resource.availability as WeekdayAvailability | null) ?? {
+ monday: fallbackDailyHours,
+ tuesday: fallbackDailyHours,
+ wednesday: fallbackDailyHours,
+ thursday: fallbackDailyHours,
+ friday: fallbackDailyHours,
+ saturday: 0,
+ sunday: 0,
+ };
// Get existing assignments in the date range
const existingAssignments = await ctx.db.assignment.findMany({
@@ -350,19 +372,29 @@ export const allocationRouter = createTRPCRouter({
orderBy: { startDate: "asc" },
});
- // Get vacations in the date range
- const vacations = await ctx.db.vacation.findMany({
- where: {
- resourceId: input.resourceId,
- status: "APPROVED",
- startDate: { lte: input.endDate },
- endDate: { gte: input.startDate },
- },
- select: { startDate: true, endDate: true, isHalfDay: true },
- });
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ [{
+ id: resource.id,
+ availability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ }],
+ input.startDate,
+ input.endDate,
+ );
+ const context = contexts.get(resource.id);
// Calculate day-by-day availability
- let totalWorkingDays = 0;
+ const totalWorkingDays = countEffectiveWorkingDays({
+ availability,
+ periodStart: input.startDate,
+ periodEnd: input.endDate,
+ context,
+ });
let availableDays = 0;
let conflictDays = 0;
let partialDays = 0;
@@ -372,36 +404,27 @@ export const allocationRouter = createTRPCRouter({
const d = new Date(input.startDate);
const end = new Date(input.endDate);
while (d <= end) {
- const dow = d.getDay();
- if (dow !== 0 && dow !== 6) {
- totalWorkingDays++;
+ const effectiveDayCapacity = calculateEffectiveDayAvailability({
+ availability,
+ date: d,
+ context,
+ });
- // Check vacation
- const isVacation = vacations.some((v) => {
- const vs = new Date(v.startDate); vs.setHours(0, 0, 0, 0);
- const ve = new Date(v.endDate); ve.setHours(0, 0, 0, 0);
- const dc = new Date(d); dc.setHours(0, 0, 0, 0);
- return dc >= vs && dc <= ve;
- });
-
- if (isVacation) {
- conflictDays++;
- d.setDate(d.getDate() + 1);
- continue;
- }
-
- // Sum existing hours on this day
+ if (effectiveDayCapacity > 0) {
let bookedHours = 0;
for (const a of existingAssignments) {
- const as2 = new Date(a.startDate); as2.setHours(0, 0, 0, 0);
- const ae = new Date(a.endDate); ae.setHours(0, 0, 0, 0);
- const dc = new Date(d); dc.setHours(0, 0, 0, 0);
- if (dc >= as2 && dc <= ae) {
- bookedHours += a.hoursPerDay;
- }
+ bookedHours += calculateEffectiveBookedHours({
+ availability,
+ startDate: a.startDate,
+ endDate: a.endDate,
+ hoursPerDay: a.hoursPerDay,
+ periodStart: d,
+ periodEnd: d,
+ context,
+ });
}
- const remainingCapacity = Math.max(0, dailyCapacity - bookedHours);
+ const remainingCapacity = Math.max(0, effectiveDayCapacity - bookedHours);
if (remainingCapacity >= requestedHpd) {
availableDays++;
totalAvailableHours += requestedHpd;
@@ -416,6 +439,15 @@ export const allocationRouter = createTRPCRouter({
}
const totalRequestedHours = totalWorkingDays * requestedHpd;
+ const totalPeriodCapacity = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: input.startDate,
+ periodEnd: input.endDate,
+ context,
+ });
+ const dailyCapacity = totalWorkingDays > 0
+ ? Math.round((totalPeriodCapacity / totalWorkingDays) * 10) / 10
+ : 0;
return {
resource: { id: resource.id, name: resource.displayName, eid: resource.eid },
diff --git a/packages/api/src/router/assistant-insights.ts b/packages/api/src/router/assistant-insights.ts
new file mode 100644
index 0000000..d9cc69c
--- /dev/null
+++ b/packages/api/src/router/assistant-insights.ts
@@ -0,0 +1,243 @@
+export interface AssistantInsightMetric {
+ label: string;
+ value: string;
+ tone?: "neutral" | "good" | "warn" | "danger" | "info";
+}
+
+export interface AssistantInsightSection {
+ title: string;
+ metrics: AssistantInsightMetric[];
+}
+
+export interface AssistantInsight {
+ kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
+ title: string;
+ subtitle?: string;
+ metrics: AssistantInsightMetric[];
+ sections?: AssistantInsightSection[];
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function asString(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value : null;
+}
+
+function asNumber(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function formatHours(value: unknown): string | null {
+ const num = asNumber(value);
+ return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} h`;
+}
+
+function formatDays(value: unknown): string | null {
+ const num = asNumber(value);
+ return num == null ? null : `${num.toFixed(num % 1 === 0 ? 0 : 1)} d`;
+}
+
+function pushMetric(
+ metrics: AssistantInsightMetric[],
+ label: string,
+ value: string | null,
+ tone?: AssistantInsightMetric["tone"],
+) {
+ if (!value) return;
+ metrics.push({ label, value, ...(tone ? { tone } : {}) });
+}
+
+function createLocationLabel(locationContext: Record | undefined): string | null {
+ if (!locationContext) return null;
+ const parts = [
+ asString(locationContext.metroCity),
+ asString(locationContext.federalState),
+ asString(locationContext.country),
+ asString(locationContext.countryCode),
+ ].filter(Boolean);
+ return parts.length > 0 ? parts.join(", ") : null;
+}
+
+function buildChargeabilityInsight(data: Record): AssistantInsight | null {
+ const resource = asString(data.resource);
+ const month = asString(data.month);
+ if (!resource || !month) return null;
+
+ const holidaySummary = isRecord(data.holidaySummary) ? data.holidaySummary : undefined;
+ const absenceSummary = isRecord(data.absenceSummary) ? data.absenceSummary : undefined;
+ const capacityBreakdown = isRecord(data.capacityBreakdown) ? data.capacityBreakdown : undefined;
+ const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
+ const chargeabilityPct = asNumber(data.chargeabilityPct);
+ const targetPct = asNumber(data.targetPct);
+
+ const metrics: AssistantInsightMetric[] = [];
+ pushMetric(metrics, "Chargeability", asString(data.chargeability), chargeabilityPct == null || targetPct == null
+ ? "info"
+ : chargeabilityPct >= targetPct ? "good" : "warn");
+ pushMetric(metrics, "Available", formatHours(data.availableHours));
+ pushMetric(metrics, "Booked", formatHours(data.bookedHours));
+ pushMetric(metrics, "Unassigned", formatHours(data.unassignedHours));
+ pushMetric(metrics, "Target", formatHours(data.targetHours));
+ pushMetric(metrics, "Holidays", formatDays(holidaySummary?.workdayCount ?? holidaySummary?.count));
+
+ const sections: AssistantInsightSection[] = [];
+
+ const basisMetrics: AssistantInsightMetric[] = [];
+ pushMetric(basisMetrics, "Location", createLocationLabel(locationContext), "info");
+ pushMetric(basisMetrics, "Base working days", formatDays(data.baseWorkingDays));
+ pushMetric(basisMetrics, "Effective working days", formatDays(data.workingDays));
+ pushMetric(basisMetrics, "Base capacity", formatHours(data.baseAvailableHours));
+ if (basisMetrics.length > 0) {
+ sections.push({ title: "Basis", metrics: basisMetrics });
+ }
+
+ const deductionMetrics: AssistantInsightMetric[] = [];
+ pushMetric(deductionMetrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction ?? capacityBreakdown?.holidayHoursDeduction), "warn");
+ pushMetric(deductionMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
+ pushMetric(deductionMetrics, "Absence days", formatDays(absenceSummary?.dayEquivalent));
+ if (deductionMetrics.length > 0) {
+ sections.push({ title: "Deductions", metrics: deductionMetrics });
+ }
+
+ return {
+ kind: "chargeability",
+ title: `${resource} · ${month}`,
+ subtitle: "Holiday-aware monthly capacity",
+ metrics,
+ ...(sections.length > 0 ? { sections } : {}),
+ };
+}
+
+function buildHolidayRegionInsight(data: Record): AssistantInsight | null {
+ const locationContext = isRecord(data.locationContext) ? data.locationContext : undefined;
+ const periodStart = asString(data.periodStart);
+ const periodEnd = asString(data.periodEnd);
+
+ const metrics: AssistantInsightMetric[] = [];
+ pushMetric(metrics, "Region", createLocationLabel(locationContext), "info");
+ pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
+ pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
+
+ const summary = isRecord(data.summary) ? data.summary : undefined;
+ const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
+ const scopeMetrics = scopeItems
+ .map((item) => {
+ if (!isRecord(item)) return null;
+ const scope = asString(item.scope);
+ const count = asNumber(item.count);
+ if (!scope || count == null) return null;
+ return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
+ })
+ .filter((item): item is AssistantInsightMetric => item !== null);
+
+ return {
+ kind: "holiday_region",
+ title: createLocationLabel(locationContext) ?? "Regional holidays",
+ subtitle: "Resolved public holiday set",
+ metrics,
+ ...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
+ };
+}
+
+function buildResourceHolidayInsight(data: Record): AssistantInsight | null {
+ const resource = isRecord(data.resource) ? data.resource : undefined;
+ const summary = isRecord(data.summary) ? data.summary : undefined;
+ const periodStart = asString(data.periodStart);
+ const periodEnd = asString(data.periodEnd);
+
+ const metrics: AssistantInsightMetric[] = [];
+ pushMetric(metrics, "Employee", asString(resource?.name) ?? asString(resource?.eid));
+ pushMetric(metrics, "Location", createLocationLabel(resource), "info");
+ pushMetric(metrics, "Resolved holidays", asNumber(data.count)?.toString() ?? null);
+ pushMetric(metrics, "Period", periodStart && periodEnd ? `${periodStart} to ${periodEnd}` : null);
+
+ const scopeItems = Array.isArray(summary?.byScope) ? summary.byScope : [];
+ const scopeMetrics = scopeItems
+ .map((item) => {
+ if (!isRecord(item)) return null;
+ const scope = asString(item.scope);
+ const count = asNumber(item.count);
+ if (!scope || count == null) return null;
+ return { label: scope, value: String(count) } satisfies AssistantInsightMetric;
+ })
+ .filter((item): item is AssistantInsightMetric => item !== null);
+
+ return {
+ kind: "resource_holidays",
+ title: `${asString(resource?.name) ?? "Resource"} holidays`,
+ subtitle: "Location-specific holiday resolution",
+ metrics,
+ ...(scopeMetrics.length > 0 ? { sections: [{ title: "Scopes", metrics: scopeMetrics }] } : {}),
+ };
+}
+
+function buildResourceMatchInsight(data: Record): AssistantInsight | null {
+ const project = isRecord(data.project) ? data.project : undefined;
+ const period = isRecord(data.period) ? data.period : undefined;
+ const bestMatch = isRecord(data.bestMatch) ? data.bestMatch : undefined;
+ if (!project || !period || !bestMatch) return null;
+
+ const remainingHours = asNumber(bestMatch.remainingHours);
+ const remainingHoursPerDay = asNumber(bestMatch.remainingHoursPerDay);
+ const lcr = asString(bestMatch.lcr);
+ const holidaySummary = isRecord(bestMatch.holidaySummary) ? bestMatch.holidaySummary : undefined;
+ const absenceSummary = isRecord(bestMatch.absenceSummary) ? bestMatch.absenceSummary : undefined;
+ const capacityBreakdown = isRecord(bestMatch.capacityBreakdown) ? bestMatch.capacityBreakdown : undefined;
+
+ const metrics: AssistantInsightMetric[] = [];
+ pushMetric(metrics, "Best match", asString(bestMatch.name) ?? asString(bestMatch.eid), "good");
+ pushMetric(metrics, "Project", asString(project.name) ?? asString(project.shortCode));
+ pushMetric(metrics, "Remaining", formatHours(remainingHours), remainingHours != null && remainingHours > 0 ? "good" : "warn");
+ pushMetric(metrics, "Per workday", formatHours(remainingHoursPerDay));
+ pushMetric(metrics, "LCR", lcr);
+ pushMetric(metrics, "Holiday deduction", formatHours(holidaySummary?.hoursDeduction), "warn");
+
+ const sections: AssistantInsightSection[] = [];
+
+ const profileMetrics: AssistantInsightMetric[] = [];
+ pushMetric(profileMetrics, "Role", asString(bestMatch.role));
+ pushMetric(profileMetrics, "Chapter", asString(bestMatch.chapter));
+ pushMetric(profileMetrics, "Location", createLocationLabel(bestMatch), "info");
+ pushMetric(profileMetrics, "Candidate pool", asNumber(data.candidateCount)?.toString() ?? null);
+ if (profileMetrics.length > 0) {
+ sections.push({ title: "Selection", metrics: profileMetrics });
+ }
+
+ const basisMetrics: AssistantInsightMetric[] = [];
+ pushMetric(basisMetrics, "Window", asString(period.startDate) && asString(period.endDate) ? `${asString(period.startDate)} to ${asString(period.endDate)}` : null);
+ pushMetric(basisMetrics, "Ranking", asString(period.rankingMode));
+ pushMetric(basisMetrics, "Min/day", formatHours(period.minHoursPerDay));
+ pushMetric(basisMetrics, "Base capacity", formatHours(capacityBreakdown?.baseAvailableHours ?? bestMatch.baseAvailableHours));
+ pushMetric(basisMetrics, "Effective capacity", formatHours(bestMatch.availableHours));
+ pushMetric(basisMetrics, "Absence deduction", formatHours(absenceSummary?.hoursDeduction ?? capacityBreakdown?.absenceHoursDeduction), "warn");
+ if (basisMetrics.length > 0) {
+ sections.push({ title: "Capacity basis", metrics: basisMetrics });
+ }
+
+ return {
+ kind: "resource_match",
+ title: `${asString(project.shortCode) ?? asString(project.name) ?? "Project"} staffing`,
+ subtitle: "Holiday-aware best-fit resource",
+ metrics,
+ ...(sections.length > 0 ? { sections } : {}),
+ };
+}
+
+export function buildAssistantInsight(toolName: string, data: unknown): AssistantInsight | null {
+ if (!isRecord(data)) return null;
+
+ switch (toolName) {
+ case "get_chargeability":
+ return buildChargeabilityInsight(data);
+ case "find_best_project_resource":
+ return buildResourceMatchInsight(data);
+ case "list_holidays_by_region":
+ return buildHolidayRegionInsight(data);
+ case "get_resource_holidays":
+ return buildResourceHolidayInsight(data);
+ default:
+ return null;
+ }
+}
diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts
index def2c7d..ff0be07 100644
--- a/packages/api/src/router/assistant-tools.ts
+++ b/packages/api/src/router/assistant-tools.ts
@@ -4,13 +4,22 @@
*/
import { prisma } from "@capakraken/db";
-import { calculateAllocation, checkDuplicateAssignment, countWorkingDays } from "@capakraken/engine/allocation";
+import { checkDuplicateAssignment } from "@capakraken/engine/allocation";
import { computeBudgetStatus } from "@capakraken/engine";
-import type { PermissionKey } from "@capakraken/shared";
-import { parseTaskAction } from "@capakraken/shared";
+import { PermissionKey, parseTaskAction } from "@capakraken/shared";
+import type { WeekdayAvailability } from "@capakraken/shared";
+import { getDashboardBudgetForecast, getDashboardPeakTimes } from "@capakraken/application";
import { createAiClient, createDalleClient, isAiConfigured, isDalleConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
import { getTaskAction } from "../lib/task-actions.js";
import { fmtEur } from "../lib/format-utils.js";
+import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
+import {
+ countEffectiveWorkingDays,
+ calculateEffectiveAvailableHours,
+ calculateEffectiveBookedHours,
+ getAvailabilityHoursForDate,
+ loadResourceDailyAvailabilityContexts,
+} from "../lib/resource-capacity.js";
import { resolveRecipients } from "../lib/notification-targeting.js";
import {
emitNotificationCreated,
@@ -35,7 +44,11 @@ const MUTATION_TOOLS = new Set([
"create_org_unit", "update_org_unit",
"send_broadcast", "create_task_for_user", "create_reminder",
"update_task_status", "execute_task_action",
- "create_comment", "resolve_comment",
+ "create_comment", "resolve_comment", "mark_notification_read",
+]);
+
+export const ADVANCED_ASSISTANT_TOOLS = new Set([
+ "find_best_project_resource",
]);
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -47,7 +60,7 @@ export type ToolContext = {
permissions: Set;
};
-interface ToolDef {
+export interface ToolDef {
type: "function";
function: {
name: string;
@@ -71,6 +84,169 @@ function assertPermission(ctx: ToolContext, perm: PermissionKey): void {
}
}
+function createUtcDate(year: number, monthIndex: number, day: number): Date {
+ return new Date(Date.UTC(year, monthIndex, day));
+}
+
+function resolveHolidayPeriod(input: {
+ year?: number;
+ periodStart?: string;
+ periodEnd?: string;
+}): { year: number | null; periodStart: Date; periodEnd: Date } {
+ if (input.periodStart || input.periodEnd) {
+ if (!input.periodStart || !input.periodEnd) {
+ throw new Error("periodStart and periodEnd must both be provided when using a custom holiday range.");
+ }
+
+ const periodStart = new Date(`${input.periodStart}T00:00:00.000Z`);
+ const periodEnd = new Date(`${input.periodEnd}T00:00:00.000Z`);
+ if (Number.isNaN(periodStart.getTime())) {
+ throw new Error(`Invalid periodStart: ${input.periodStart}`);
+ }
+ if (Number.isNaN(periodEnd.getTime())) {
+ throw new Error(`Invalid periodEnd: ${input.periodEnd}`);
+ }
+ if (periodEnd < periodStart) {
+ throw new Error("periodEnd must be on or after periodStart.");
+ }
+
+ return { year: null, periodStart, periodEnd };
+ }
+
+ const year = input.year ?? new Date().getUTCFullYear();
+ return {
+ year,
+ periodStart: createUtcDate(year, 0, 1),
+ periodEnd: createUtcDate(year, 11, 31),
+ };
+}
+
+function formatResolvedHoliday(holiday: {
+ date: string;
+ name: string;
+ scope: string;
+ calendarName: string;
+ sourceType: string;
+}) {
+ return {
+ date: holiday.date,
+ name: holiday.name,
+ scope: holiday.scope,
+ calendarName: holiday.calendarName,
+ sourceType: holiday.sourceType,
+ };
+}
+
+function summarizeResolvedHolidays(holidays: Array<{
+ date: string;
+ name: string;
+ scope: string;
+ calendarName: string;
+ sourceType: string;
+}>) {
+ const byScope = new Map();
+ const bySourceType = new Map();
+ const byCalendar = new Map();
+
+ for (const holiday of holidays) {
+ byScope.set(holiday.scope, (byScope.get(holiday.scope) ?? 0) + 1);
+ bySourceType.set(holiday.sourceType, (bySourceType.get(holiday.sourceType) ?? 0) + 1);
+ byCalendar.set(holiday.calendarName, (byCalendar.get(holiday.calendarName) ?? 0) + 1);
+ }
+
+ return {
+ byScope: [...byScope.entries()]
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([scope, count]) => ({ scope, count })),
+ bySourceType: [...bySourceType.entries()]
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([sourceType, count]) => ({ sourceType, count })),
+ byCalendar: [...byCalendar.entries()]
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([calendarName, count]) => ({ calendarName, count })),
+ };
+}
+
+function round1(value: number): number {
+ return Math.round(value * 10) / 10;
+}
+
+function averagePerWorkingDay(totalHours: number, workingDays: number): number {
+ return workingDays > 0 ? round1(totalHours / workingDays) : 0;
+}
+
+function createDateRange(input: {
+ startDate?: string | undefined;
+ endDate?: string | undefined;
+ durationDays?: number | undefined;
+}): { startDate: Date; endDate: Date } {
+ const startDate = input.startDate
+ ? new Date(`${input.startDate}T00:00:00.000Z`)
+ : createUtcDate(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate());
+
+ if (Number.isNaN(startDate.getTime())) {
+ throw new Error(`Invalid startDate: ${input.startDate}`);
+ }
+
+ const endDate = input.endDate
+ ? new Date(`${input.endDate}T00:00:00.000Z`)
+ : createUtcDate(
+ startDate.getUTCFullYear(),
+ startDate.getUTCMonth(),
+ startDate.getUTCDate() + Math.max((input.durationDays ?? 21) - 1, 0),
+ );
+
+ if (Number.isNaN(endDate.getTime())) {
+ throw new Error(`Invalid endDate: ${input.endDate}`);
+ }
+ if (endDate < startDate) {
+ throw new Error("endDate must be on or after startDate.");
+ }
+
+ return { startDate, endDate };
+}
+
+async function resolveProjectIdentifier(
+ identifier: string,
+ db: ToolContext["db"],
+): Promise<{
+ id: string;
+ name: string;
+ shortCode: string;
+ status: string;
+ responsiblePerson: string | null;
+} | { error: string }> {
+ const select = {
+ id: true,
+ name: true,
+ shortCode: true,
+ status: true,
+ responsiblePerson: true,
+ } as const;
+
+ let project = await db.project.findUnique({
+ where: { id: identifier },
+ select,
+ });
+ if (!project) {
+ project = await db.project.findUnique({
+ where: { shortCode: identifier },
+ select,
+ });
+ }
+ if (!project) {
+ project = await db.project.findFirst({
+ where: { name: { contains: identifier, mode: "insensitive" } },
+ select,
+ });
+ }
+ if (!project) {
+ return { error: `Project not found: ${identifier}` };
+ }
+
+ return project;
+}
+
// ─── Tool Definitions ───────────────────────────────────────────────────────
export const TOOL_DEFINITIONS: ToolDef[] = [
@@ -137,6 +313,27 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
},
},
},
+ {
+ 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: {
@@ -200,6 +397,42 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
},
},
},
+ {
+ type: "function",
+ function: {
+ name: "list_holidays_by_region",
+ description: "List resolved public holidays for a country, federal state, and optionally a city in a given year or date range. Use this to compare regions such as Bayern vs Hamburg.",
+ parameters: {
+ type: "object",
+ properties: {
+ countryCode: { type: "string", description: "Country code such as DE, ES, US, IN." },
+ federalState: { type: "string", description: "Federal state / region code, e.g. BY, HH, NRW." },
+ metroCity: { type: "string", description: "Optional city name for local city-specific holidays, e.g. Augsburg." },
+ year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." },
+ periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." },
+ periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." },
+ },
+ required: ["countryCode"],
+ },
+ },
+ },
+ {
+ type: "function",
+ function: {
+ name: "get_resource_holidays",
+ description: "List resolved public holidays for a specific resource based on that person's country, federal state, and city context.",
+ parameters: {
+ type: "object",
+ properties: {
+ identifier: { type: "string", description: "Resource ID, EID, or display name." },
+ year: { type: "integer", description: "Full year, e.g. 2026. Default: current year." },
+ periodStart: { type: "string", description: "Optional start date in YYYY-MM-DD. Requires periodEnd." },
+ periodEnd: { type: "string", description: "Optional end date in YYYY-MM-DD. Requires periodStart." },
+ },
+ required: ["identifier"],
+ },
+ },
+ },
{
type: "function",
function: {
@@ -1724,6 +1957,300 @@ const executors = {
};
},
+ 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) {
+ assertPermission(ctx, PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS);
+ assertPermission(ctx, PermissionKey.VIEW_COSTS);
+
+ const project = await resolveProjectIdentifier(params.projectIdentifier, ctx.db);
+ if ("error" in project) {
+ return project;
+ }
+
+ const { startDate, endDate } = createDateRange({
+ startDate: params.startDate,
+ endDate: params.endDate,
+ durationDays: params.durationDays,
+ });
+ const minHoursPerDay = Math.max(params.minHoursPerDay ?? 3, 0);
+ const rankingMode = params.rankingMode ?? "lowest_lcr";
+
+ const projectAssignments = await ctx.db.assignment.findMany({
+ where: {
+ projectId: project.id,
+ status: { not: "CANCELLED" },
+ startDate: { lte: endDate },
+ endDate: { gte: startDate },
+ resource: {
+ isActive: true,
+ ...(params.chapter ? { chapter: { contains: params.chapter, mode: "insensitive" } } : {}),
+ ...(params.roleName ? { areaRole: { name: { contains: params.roleName, mode: "insensitive" } } } : {}),
+ },
+ },
+ select: {
+ resourceId: true,
+ hoursPerDay: true,
+ startDate: true,
+ endDate: true,
+ status: true,
+ resource: {
+ select: {
+ id: true,
+ eid: true,
+ displayName: true,
+ chapter: true,
+ lcrCents: true,
+ availability: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true, name: true } },
+ metroCity: { select: { name: true } },
+ areaRole: { select: { name: true } },
+ },
+ },
+ },
+ orderBy: [{ resourceId: "asc" }, { startDate: "asc" }],
+ });
+
+ if (projectAssignments.length === 0) {
+ return {
+ project,
+ period: {
+ startDate: fmtDate(startDate),
+ endDate: fmtDate(endDate),
+ minHoursPerDay,
+ rankingMode,
+ },
+ candidateCount: 0,
+ candidates: [],
+ bestMatch: null,
+ note: "No active project resources matched the requested filters in the selected period.",
+ };
+ }
+
+ const resourcesById = new Map();
+ const assignmentsOnProjectByResourceId = new Map();
+ for (const assignment of projectAssignments) {
+ resourcesById.set(assignment.resourceId, assignment.resource);
+ const items = assignmentsOnProjectByResourceId.get(assignment.resourceId) ?? [];
+ items.push(assignment);
+ assignmentsOnProjectByResourceId.set(assignment.resourceId, items);
+ }
+
+ const resourceIds = [...resourcesById.keys()];
+ const overlappingAssignments = await ctx.db.assignment.findMany({
+ where: {
+ resourceId: { in: resourceIds },
+ status: { not: "CANCELLED" },
+ startDate: { lte: endDate },
+ endDate: { gte: startDate },
+ },
+ select: {
+ resourceId: true,
+ projectId: true,
+ hoursPerDay: true,
+ startDate: true,
+ endDate: true,
+ status: true,
+ project: { select: { name: true, shortCode: true } },
+ },
+ orderBy: [{ resourceId: "asc" }, { startDate: "asc" }],
+ });
+
+ const assignmentsByResourceId = new Map();
+ for (const assignment of overlappingAssignments) {
+ const items = assignmentsByResourceId.get(assignment.resourceId) ?? [];
+ items.push(assignment);
+ assignmentsByResourceId.set(assignment.resourceId, items);
+ }
+
+ const resources = [...resourcesById.values()];
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ resources.map((resource) => ({
+ id: resource.id,
+ availability: resource.availability as unknown as WeekdayAvailability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ })),
+ startDate,
+ endDate,
+ );
+
+ const candidates = resources.map((resource) => {
+ const availability = resource.availability as unknown as WeekdayAvailability;
+ const context = contexts.get(resource.id);
+ const baseWorkingDays = countEffectiveWorkingDays({
+ availability,
+ periodStart: startDate,
+ periodEnd: endDate,
+ context: undefined,
+ });
+ const workingDays = countEffectiveWorkingDays({
+ availability,
+ periodStart: startDate,
+ periodEnd: endDate,
+ context,
+ });
+ const baseAvailableHours = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: startDate,
+ periodEnd: endDate,
+ context: undefined,
+ });
+ const availableHours = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: startDate,
+ periodEnd: endDate,
+ context,
+ });
+ const assignments = assignmentsByResourceId.get(resource.id) ?? [];
+ const bookedHours = assignments.reduce(
+ (sum, assignment) =>
+ sum + calculateEffectiveBookedHours({
+ availability,
+ startDate: assignment.startDate,
+ endDate: assignment.endDate,
+ hoursPerDay: assignment.hoursPerDay,
+ periodStart: startDate,
+ periodEnd: endDate,
+ context,
+ }),
+ 0,
+ );
+ const projectHours = (assignmentsOnProjectByResourceId.get(resource.id) ?? []).reduce(
+ (sum, assignment) =>
+ sum + calculateEffectiveBookedHours({
+ availability,
+ startDate: assignment.startDate,
+ endDate: assignment.endDate,
+ hoursPerDay: assignment.hoursPerDay,
+ periodStart: startDate,
+ periodEnd: endDate,
+ context,
+ }),
+ 0,
+ );
+ let excludedCapacityDays = 0;
+ for (const fraction of context?.absenceFractionsByDate.values() ?? []) {
+ excludedCapacityDays += fraction;
+ }
+ const holidayWorkdayCount = [...(context?.holidayDates ?? new Set())].reduce((count, isoDate) => (
+ count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
+ ), 0);
+ const holidayHoursDeduction = [...(context?.holidayDates ?? new Set())].reduce((sum, isoDate) => (
+ sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`))
+ ), 0);
+ let absenceDayEquivalent = 0;
+ let absenceHoursDeduction = 0;
+ for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
+ const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`));
+ if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
+ continue;
+ }
+ absenceDayEquivalent += fraction;
+ absenceHoursDeduction += dayHours * fraction;
+ }
+
+ const remainingHours = Math.max(0, availableHours - bookedHours);
+ const remainingHoursPerDay = averagePerWorkingDay(remainingHours, workingDays);
+
+ return {
+ id: resource.id,
+ eid: resource.eid,
+ name: resource.displayName,
+ role: resource.areaRole?.name ?? null,
+ chapter: resource.chapter ?? null,
+ country: resource.country?.name ?? resource.country?.code ?? null,
+ countryCode: resource.country?.code ?? null,
+ federalState: resource.federalState ?? null,
+ metroCity: resource.metroCity?.name ?? null,
+ lcrCents: resource.lcrCents ?? null,
+ lcr: resource.lcrCents != null ? fmtEur(resource.lcrCents) : null,
+ baseWorkingDays: round1(baseWorkingDays),
+ workingDays,
+ excludedCapacityDays: round1(excludedCapacityDays),
+ baseAvailableHours: round1(baseAvailableHours),
+ availableHours: round1(availableHours),
+ bookedHours: round1(bookedHours),
+ remainingHours: round1(remainingHours),
+ remainingHoursPerDay,
+ projectHours: round1(projectHours),
+ assignmentCount: assignments.length,
+ holidaySummary: {
+ count: context?.holidayDates.size ?? 0,
+ workdayCount: holidayWorkdayCount,
+ hoursDeduction: round1(holidayHoursDeduction),
+ holidayDates: [...(context?.holidayDates ?? new Set())].sort(),
+ },
+ absenceSummary: {
+ dayEquivalent: round1(absenceDayEquivalent),
+ hoursDeduction: round1(absenceHoursDeduction),
+ },
+ capacityBreakdown: {
+ formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
+ baseAvailableHours: round1(baseAvailableHours),
+ holidayHoursDeduction: round1(holidayHoursDeduction),
+ absenceHoursDeduction: round1(absenceHoursDeduction),
+ availableHours: round1(availableHours),
+ },
+ };
+ }).filter((candidate) => candidate.remainingHoursPerDay >= minHoursPerDay);
+
+ const compareCandidates = (left: (typeof candidates)[number], right: (typeof candidates)[number]): number => {
+ if (rankingMode === "highest_remaining_hours_per_day") {
+ return right.remainingHoursPerDay - left.remainingHoursPerDay
+ || right.remainingHours - left.remainingHours
+ || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER);
+ }
+ if (rankingMode === "highest_remaining_hours") {
+ return right.remainingHours - left.remainingHours
+ || right.remainingHoursPerDay - left.remainingHoursPerDay
+ || (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER);
+ }
+ return (left.lcrCents ?? Number.MAX_SAFE_INTEGER) - (right.lcrCents ?? Number.MAX_SAFE_INTEGER)
+ || right.remainingHoursPerDay - left.remainingHoursPerDay
+ || right.remainingHours - left.remainingHours;
+ };
+
+ candidates.sort(compareCandidates);
+
+ return {
+ project: {
+ id: project.id,
+ name: project.name,
+ shortCode: project.shortCode,
+ status: project.status,
+ responsiblePerson: project.responsiblePerson,
+ },
+ period: {
+ startDate: fmtDate(startDate),
+ endDate: fmtDate(endDate),
+ minHoursPerDay,
+ rankingMode,
+ },
+ filters: {
+ chapter: params.chapter ?? null,
+ roleName: params.roleName ?? null,
+ },
+ candidateCount: candidates.length,
+ bestMatch: candidates[0] ?? null,
+ candidates,
+ };
+ },
+
async list_allocations(params: {
resourceId?: string; projectId?: string;
resourceName?: string; projectCode?: string;
@@ -1909,6 +2436,108 @@ const executors = {
}));
},
+ async list_holidays_by_region(params: {
+ countryCode: string;
+ federalState?: string;
+ metroCity?: string;
+ year?: number;
+ periodStart?: string;
+ periodEnd?: string;
+ }, ctx: ToolContext) {
+ const { year, periodStart, periodEnd } = resolveHolidayPeriod(params);
+
+ const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
+ periodStart,
+ periodEnd,
+ countryCode: params.countryCode.trim().toUpperCase(),
+ federalState: params.federalState?.trim().toUpperCase() || null,
+ metroCityName: params.metroCity?.trim() || null,
+ });
+ const formattedHolidays = holidays.map(formatResolvedHoliday);
+
+ return {
+ locationContext: {
+ countryCode: params.countryCode.trim().toUpperCase(),
+ federalState: params.federalState?.trim().toUpperCase() || null,
+ metroCity: params.metroCity?.trim() || null,
+ },
+ year,
+ periodStart: fmtDate(periodStart),
+ periodEnd: fmtDate(periodEnd),
+ count: holidays.length,
+ summary: summarizeResolvedHolidays(formattedHolidays),
+ holidays: formattedHolidays,
+ };
+ },
+
+ async get_resource_holidays(params: {
+ identifier: string;
+ year?: number;
+ periodStart?: string;
+ periodEnd?: string;
+ }, ctx: ToolContext) {
+ const select = {
+ id: true,
+ eid: true,
+ displayName: true,
+ federalState: true,
+ countryId: true,
+ metroCityId: true,
+ country: { select: { code: true, name: true } },
+ metroCity: { select: { name: true } },
+ } as const;
+
+ let resource = await ctx.db.resource.findUnique({
+ where: { id: params.identifier },
+ select,
+ });
+ if (!resource) {
+ resource = await ctx.db.resource.findUnique({
+ where: { eid: params.identifier },
+ select,
+ });
+ }
+ if (!resource) {
+ resource = await ctx.db.resource.findFirst({
+ where: { displayName: { contains: params.identifier, mode: "insensitive" } },
+ select,
+ });
+ }
+ if (!resource) {
+ return { error: `Resource not found: ${params.identifier}` };
+ }
+
+ const { year, periodStart, periodEnd } = resolveHolidayPeriod(params);
+ const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
+ periodStart,
+ periodEnd,
+ countryId: resource.countryId ?? null,
+ countryCode: resource.country?.code ?? null,
+ federalState: resource.federalState ?? null,
+ metroCityId: resource.metroCityId ?? null,
+ metroCityName: resource.metroCity?.name ?? null,
+ });
+ const formattedHolidays = holidays.map(formatResolvedHoliday);
+
+ return {
+ resource: {
+ id: resource.id,
+ eid: resource.eid,
+ name: resource.displayName,
+ country: resource.country?.name ?? resource.country?.code ?? null,
+ countryCode: resource.country?.code ?? null,
+ federalState: resource.federalState ?? null,
+ metroCity: resource.metroCity?.name ?? null,
+ },
+ year,
+ periodStart: fmtDate(periodStart),
+ periodEnd: fmtDate(periodEnd),
+ count: holidays.length,
+ summary: summarizeResolvedHolidays(formattedHolidays),
+ holidays: formattedHolidays,
+ };
+ },
+
async list_roles(_params: Record, ctx: ToolContext) {
const roles = await ctx.db.role.findMany({
select: { id: true, name: true, color: true },
@@ -1982,7 +2611,11 @@ const executors = {
const sel = {
id: true, displayName: true, eid: true, fte: true, chargeabilityTarget: true,
availability: true,
- country: { select: { dailyWorkingHours: true } },
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true, name: true, dailyWorkingHours: true } },
+ metroCity: { select: { name: true } },
} as const;
let resource = await ctx.db.resource.findUnique({ where: { id: params.resourceId }, select: sel });
if (!resource) {
@@ -2019,37 +2652,93 @@ const executors = {
},
});
- // Count working days in month
- const dailyHours = resource.country?.dailyWorkingHours ?? 8;
- let workingDays = 0;
- const d = new Date(start);
- while (d <= end) {
- const dow = d.getDay();
- if (dow !== 0 && dow !== 6) workingDays++;
- d.setDate(d.getDate() + 1);
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ [{
+ id: resource.id,
+ availability: resource.availability as unknown as import("@capakraken/shared").WeekdayAvailability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ }],
+ start,
+ end,
+ );
+ const context = contexts.get(resource.id);
+ const availability = resource.availability as unknown as WeekdayAvailability;
+ const baseAvailableHours = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context: undefined,
+ });
+ const baseWorkingDays = countEffectiveWorkingDays({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context: undefined,
+ });
+ const availableHours = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
+ const workingDays = countEffectiveWorkingDays({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
+
+ const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
+ periodStart: start,
+ periodEnd: end,
+ countryId: resource.countryId ?? null,
+ countryCode: resource.country?.code ?? null,
+ federalState: resource.federalState ?? null,
+ metroCityId: resource.metroCityId ?? null,
+ metroCityName: resource.metroCity?.name ?? null,
+ });
+ const formattedHolidays = resolvedHolidays
+ .filter((holiday) => context?.holidayDates.has(holiday.date) ?? true)
+ .map(formatResolvedHoliday);
+ const holidayWorkdayCount = [...(context?.holidayDates ?? new Set())].reduce((count, isoDate) => (
+ count + (getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`)) > 0 ? 1 : 0)
+ ), 0);
+ const holidayHoursDeduction = [...(context?.holidayDates ?? new Set())].reduce((sum, isoDate) => (
+ sum + getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`))
+ ), 0);
+ let absenceDayEquivalent = 0;
+ let absenceHoursDeduction = 0;
+ for (const [isoDate, fraction] of context?.vacationFractionsByDate ?? []) {
+ const dayHours = getAvailabilityHoursForDate(availability, new Date(`${isoDate}T00:00:00.000Z`));
+ if (dayHours <= 0 || context?.holidayDates.has(isoDate)) {
+ continue;
+ }
+ absenceDayEquivalent += fraction;
+ absenceHoursDeduction += dayHours * fraction;
}
- const availableHours = workingDays * dailyHours * resource.fte;
-
- // Sum booked hours (simplified: intersection of alloc dates with month)
let bookedHours = 0;
const allocDetails: Array<{ project: string; code: string; hours: number; status: string }> = [];
for (const a of allocs) {
- const overlapStart = a.startDate > start ? a.startDate : start;
- const overlapEnd = a.endDate < end ? a.endDate : end;
- let days = 0;
- const cur = new Date(overlapStart);
- while (cur <= overlapEnd) {
- const dow = cur.getDay();
- if (dow !== 0 && dow !== 6) days++;
- cur.setDate(cur.getDate() + 1);
- }
- const hours = days * a.hoursPerDay;
+ const hours = calculateEffectiveBookedHours({
+ availability,
+ startDate: a.startDate,
+ endDate: a.endDate,
+ hoursPerDay: a.hoursPerDay,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
bookedHours += hours;
allocDetails.push({
project: a.project.name,
code: a.project.shortCode,
- hours: Math.round(hours * 10) / 10,
+ hours: round1(hours),
status: a.status,
});
}
@@ -2057,18 +2746,58 @@ const executors = {
const chargeabilityPercent = availableHours > 0
? Math.round((bookedHours / availableHours) * 1000) / 10
: 0;
+ const targetPct = resource.chargeabilityTarget;
+ const targetHours = availableHours > 0 ? round1((availableHours * targetPct) / 100) : 0;
+ const unassignedHours = round1(Math.max(0, availableHours - bookedHours));
return {
resource: resource.displayName,
eid: resource.eid,
month,
+ periodStart: fmtDate(start),
+ periodEnd: fmtDate(end),
fte: resource.fte,
target: `${resource.chargeabilityTarget}%`,
+ targetPct,
+ targetHours,
workingDays,
- availableHours: Math.round(availableHours * 10) / 10,
- bookedHours: Math.round(bookedHours * 10) / 10,
+ baseWorkingDays,
+ locationContext: {
+ countryCode: resource.country?.code ?? null,
+ country: resource.country?.name ?? resource.country?.code ?? null,
+ federalState: resource.federalState ?? null,
+ metroCity: resource.metroCity?.name ?? null,
+ },
+ baseAvailableHours: round1(baseAvailableHours),
+ availableHours: round1(availableHours),
+ bookedHours: round1(bookedHours),
+ unassignedHours,
chargeability: `${chargeabilityPercent}%`,
+ chargeabilityPct: chargeabilityPercent,
onTarget: chargeabilityPercent >= resource.chargeabilityTarget,
+ holidaySummary: {
+ count: formattedHolidays.length,
+ workdayCount: holidayWorkdayCount,
+ hoursDeduction: round1(holidayHoursDeduction),
+ holidays: formattedHolidays,
+ breakdown: summarizeResolvedHolidays(formattedHolidays),
+ },
+ absenceSummary: {
+ dayEquivalent: round1(absenceDayEquivalent),
+ hoursDeduction: round1(absenceHoursDeduction),
+ },
+ capacityBreakdown: {
+ formula: "baseAvailableHours - holidayHoursDeduction - absenceHoursDeduction = availableHours",
+ baseAvailableHours: round1(baseAvailableHours),
+ holidayHoursDeduction: round1(holidayHoursDeduction),
+ absenceHoursDeduction: round1(absenceHoursDeduction),
+ availableHours: round1(availableHours),
+ },
+ averages: {
+ availableHoursPerWorkingDay: averagePerWorkingDay(availableHours, workingDays),
+ bookedHoursPerWorkingDay: averagePerWorkingDay(bookedHours, workingDays),
+ remainingHoursPerWorkingDay: averagePerWorkingDay(Math.max(0, availableHours - bookedHours), workingDays),
+ },
allocations: allocDetails,
};
},
@@ -3095,18 +3824,48 @@ const executors = {
}, ctx: ToolContext) {
let resource = await ctx.db.resource.findUnique({
where: { id: params.resourceId },
- select: { id: true, displayName: true, fte: true },
+ select: {
+ id: true,
+ displayName: true,
+ fte: true,
+ availability: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true } },
+ metroCity: { select: { name: true } },
+ },
});
if (!resource) {
resource = await ctx.db.resource.findUnique({
where: { eid: params.resourceId },
- select: { id: true, displayName: true, fte: true },
+ select: {
+ id: true,
+ displayName: true,
+ fte: true,
+ availability: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true } },
+ metroCity: { select: { name: true } },
+ },
});
}
if (!resource) {
resource = await ctx.db.resource.findFirst({
where: { displayName: { contains: params.resourceId, mode: "insensitive" } },
- select: { id: true, displayName: true, fte: true },
+ select: {
+ id: true,
+ displayName: true,
+ fte: true,
+ availability: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true } },
+ metroCity: { select: { name: true } },
+ },
});
}
if (!resource) return { error: `Resource not found: ${params.resourceId}` };
@@ -3137,19 +3896,66 @@ const executors = {
select: { type: true, startDate: true, endDate: true, isHalfDay: true },
}),
]);
-
- const totalHoursBooked = allocations.reduce((sum, a) => sum + a.hoursPerDay, 0);
- const maxHours = resource.fte * 8;
- const availableHoursPerDay = Math.max(0, maxHours - totalHoursBooked);
+ const availability = resource.availability as unknown as WeekdayAvailability;
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ [{
+ id: resource.id,
+ availability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ }],
+ start,
+ end,
+ );
+ const context = contexts.get(resource.id);
+ const periodAvailableHours = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
+ const periodBookedHours = allocations.reduce(
+ (sum, allocation) =>
+ sum + calculateEffectiveBookedHours({
+ availability,
+ startDate: allocation.startDate,
+ endDate: allocation.endDate,
+ hoursPerDay: allocation.hoursPerDay,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ }),
+ 0,
+ );
+ const workingDays = countEffectiveWorkingDays({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
+ const availableHoursPerDay = averagePerWorkingDay(
+ Math.max(0, periodAvailableHours - periodBookedHours),
+ workingDays,
+ );
+ const bookedHoursPerDay = averagePerWorkingDay(periodBookedHours, workingDays);
+ const maxHoursPerDay = averagePerWorkingDay(periodAvailableHours, workingDays);
return {
resource: resource.displayName,
period: `${params.startDate} to ${params.endDate}`,
fte: resource.fte,
- maxHoursPerDay: maxHours,
- currentBookedHoursPerDay: totalHoursBooked,
+ workingDays,
+ periodAvailableHours: round1(periodAvailableHours),
+ periodBookedHours: round1(periodBookedHours),
+ periodRemainingHours: round1(Math.max(0, periodAvailableHours - periodBookedHours)),
+ maxHoursPerDay,
+ currentBookedHoursPerDay: bookedHoursPerDay,
availableHoursPerDay,
- isFullyAvailable: totalHoursBooked === 0 && vacations.length === 0,
+ isFullyAvailable: periodBookedHours === 0 && vacations.length === 0,
existingAllocations: allocations.map((a) => ({
project: `${a.project.name} (${a.project.shortCode})`,
hoursPerDay: a.hoursPerDay,
@@ -3197,6 +4003,12 @@ const executors = {
where: resourceWhere,
select: {
id: true, displayName: true, eid: true, fte: true, lcrCents: true,
+ availability: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true } },
+ metroCity: { select: { name: true } },
areaRole: { select: { name: true } },
chapter: true,
assignments: {
@@ -3205,16 +4017,55 @@ const executors = {
startDate: { lte: end },
endDate: { gte: start },
},
- select: { hoursPerDay: true },
+ select: { hoursPerDay: true, startDate: true, endDate: true },
},
},
take: 50,
});
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ resources.map((resource) => ({
+ id: resource.id,
+ availability: resource.availability as unknown as WeekdayAvailability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ })),
+ start,
+ end,
+ );
// Score by availability
const scored = resources.map((r) => {
- const bookedHours = r.assignments.reduce((sum, a) => sum + a.hoursPerDay, 0);
- const maxHours = r.fte * 8;
+ const availability = r.availability as unknown as WeekdayAvailability;
+ const context = contexts.get(r.id);
+ const workingDays = countEffectiveWorkingDays({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
+ const maxHours = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
+ const bookedHours = r.assignments.reduce(
+ (sum, assignment) =>
+ sum + calculateEffectiveBookedHours({
+ availability,
+ startDate: assignment.startDate,
+ endDate: assignment.endDate,
+ hoursPerDay: assignment.hoursPerDay,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ }),
+ 0,
+ );
const available = Math.max(0, maxHours - bookedHours);
return {
id: r.id,
@@ -3224,12 +4075,15 @@ const executors = {
chapter: r.chapter,
fte: r.fte,
lcr: fmtEur(r.lcrCents),
- availableHoursPerDay: Math.round(available * 10) / 10,
+ workingDays,
+ availableHours: round1(available),
+ bookedHours: round1(bookedHours),
+ availableHoursPerDay: averagePerWorkingDay(available, workingDays),
utilization: maxHours > 0 ? Math.round((bookedHours / maxHours) * 100) : 0,
};
})
- .filter((r) => r.availableHoursPerDay > 0)
- .sort((a, b) => b.availableHoursPerDay - a.availableHoursPerDay)
+ .filter((r) => r.availableHours > 0)
+ .sort((a, b) => b.availableHours - a.availableHours)
.slice(0, limit);
return {
@@ -3262,6 +4116,12 @@ const executors = {
where,
select: {
id: true, displayName: true, eid: true, fte: true,
+ availability: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true } },
+ metroCity: { select: { name: true } },
areaRole: { select: { name: true } },
chapter: true,
assignments: {
@@ -3270,27 +4130,69 @@ const executors = {
startDate: { lte: end },
endDate: { gte: start },
},
- select: { hoursPerDay: true },
+ select: { hoursPerDay: true, startDate: true, endDate: true },
},
},
take: 100,
});
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ resources.map((resource) => ({
+ id: resource.id,
+ availability: resource.availability as unknown as WeekdayAvailability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ })),
+ start,
+ end,
+ );
const available = resources
.map((r) => {
- const booked = r.assignments.reduce((sum, a) => sum + a.hoursPerDay, 0);
- const maxH = r.fte * 8;
+ const availability = r.availability as unknown as WeekdayAvailability;
+ const context = contexts.get(r.id);
+ const workingDays = countEffectiveWorkingDays({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
+ const maxH = calculateEffectiveAvailableHours({
+ availability,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ });
+ const booked = r.assignments.reduce(
+ (sum, assignment) =>
+ sum + calculateEffectiveBookedHours({
+ availability,
+ startDate: assignment.startDate,
+ endDate: assignment.endDate,
+ hoursPerDay: assignment.hoursPerDay,
+ periodStart: start,
+ periodEnd: end,
+ context,
+ }),
+ 0,
+ );
+ const remaining = Math.max(0, maxH - booked);
return {
id: r.id,
name: r.displayName,
eid: r.eid,
role: r.areaRole?.name ?? null,
chapter: r.chapter,
- availableHoursPerDay: Math.round((maxH - booked) * 10) / 10,
+ workingDays,
+ availableHours: round1(remaining),
+ availableHoursPerDay: averagePerWorkingDay(remaining, workingDays),
};
})
.filter((r) => r.availableHoursPerDay >= minHours)
- .sort((a, b) => b.availableHoursPerDay - a.availableHoursPerDay)
+ .sort((a, b) => b.availableHours - a.availableHours)
.slice(0, limit);
return {
@@ -3651,6 +4553,7 @@ const executors = {
},
async list_users(params: { limit?: number }, ctx: ToolContext) {
+ assertPermission(ctx, PermissionKey.MANAGE_USERS);
const limit = Math.min(params.limit ?? 50, 100);
const users = await ctx.db.user.findMany({
select: {
@@ -3673,7 +4576,7 @@ const executors = {
async list_notifications(params: { unreadOnly?: boolean; limit?: number }, ctx: ToolContext) {
const limit = Math.min(params.limit ?? 20, 50);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const where: Record = {};
+ const where: Record = { userId: ctx.userId };
if (params.unreadOnly) where.readAt = null;
const notifications = await ctx.db.notification.findMany({
where,
@@ -3695,6 +4598,13 @@ const executors = {
},
async mark_notification_read(params: { notificationId: string }, ctx: ToolContext) {
+ const notification = await ctx.db.notification.findUnique({
+ where: { id: params.notificationId },
+ select: { id: true, userId: true },
+ });
+ if (!notification || notification.userId !== ctx.userId) {
+ return { error: "Access denied: this notification does not belong to you" };
+ }
await ctx.db.notification.update({
where: { id: params.notificationId },
data: { readAt: new Date() },
@@ -3710,18 +4620,33 @@ const executors = {
const result: Record = {};
if (section === "all" || section === "peak_times") {
- // Peak allocation months
- const allocs = await ctx.db.assignment.findMany({
+ const allocations = await ctx.db.assignment.findMany({
where: { status: { not: "CANCELLED" } },
select: { startDate: true, endDate: true, hoursPerDay: true },
});
- const monthCounts: Record = {};
- for (const a of allocs) {
- const month = `${a.startDate.getFullYear()}-${String(a.startDate.getMonth() + 1).padStart(2, "0")}`;
- monthCounts[month] = (monthCounts[month] ?? 0) + a.hoursPerDay;
+
+ if (allocations.length === 0) {
+ result.peakTimes = [];
+ } else {
+ const rangeStart = new Date(Math.min(...allocations.map((allocation) => allocation.startDate.getTime())));
+ const rangeEnd = new Date(Math.max(...allocations.map((allocation) => allocation.endDate.getTime())));
+ const peakTimes = await getDashboardPeakTimes(ctx.db, {
+ startDate: rangeStart,
+ endDate: rangeEnd,
+ granularity: "month",
+ groupBy: "project",
+ });
+
+ result.peakTimes = [...peakTimes]
+ .sort((left, right) => right.totalHours - left.totalHours)
+ .slice(0, 6)
+ .map((entry) => ({
+ month: entry.period,
+ totalHours: Math.round(entry.totalHours * 10) / 10,
+ totalHoursPerDay: Math.round(entry.totalHours * 10) / 10,
+ capacityHours: Math.round(entry.capacityHours * 10) / 10,
+ }));
}
- const sorted = Object.entries(monthCounts).sort((a, b) => b[1] - a[1]).slice(0, 6);
- result.peakTimes = sorted.map(([m, h]) => ({ month: m, totalHoursPerDay: Math.round(h * 10) / 10 }));
}
if (section === "all" || section === "top_resources") {
@@ -4653,86 +5578,35 @@ const executors = {
async get_budget_forecast(_params: Record, ctx: ToolContext) {
assertPermission(ctx, "viewCosts" as PermissionKey);
- const now = new Date();
+ const forecasts = await getDashboardBudgetForecast(ctx.db);
- function countBizDays(start: Date, end: Date): number {
- let count = 0;
- const d = new Date(start);
- while (d <= end) {
- const dow = d.getDay();
- if (dow !== 0 && dow !== 6) count++;
- d.setDate(d.getDate() + 1);
- }
- return count;
- }
-
- const projects = await ctx.db.project.findMany({
- where: {
- status: { in: ["ACTIVE", "DRAFT"] },
- budgetCents: { gt: 0 },
- },
- include: {
- assignments: {
- where: { status: { not: "CANCELLED" } },
- select: {
- dailyCostCents: true,
- startDate: true,
- endDate: true,
- },
- },
- },
- });
-
- const forecasts = projects.map((project) => {
- const totalDays = countBizDays(project.startDate, project.endDate);
- const elapsedDays = countBizDays(project.startDate, now < project.endDate ? now : project.endDate);
-
- // Cost spent so far (up to now)
- const spentCents = project.assignments.reduce((s, a) => {
- const aStart = a.startDate < project.startDate ? project.startDate : a.startDate;
- const aEnd = a.endDate > now ? now : a.endDate;
- if (aEnd < aStart) return s;
- return s + a.dailyCostCents * countBizDays(aStart, aEnd);
- }, 0);
-
- // Total projected cost (full assignment durations)
- const projectedCostCents = project.assignments.reduce((s, a) => {
- return s + a.dailyCostCents * countBizDays(a.startDate, a.endDate);
- }, 0);
-
- const budgetCents = project.budgetCents;
- const utilization = budgetCents > 0 ? Math.round((spentCents / budgetCents) * 100) : 0;
- const timelinePct = totalDays > 0 ? Math.round((elapsedDays / totalDays) * 100) : 0;
-
- const burnStatus = timelinePct > 0
- ? utilization > timelinePct * 1.2
+ return {
+ forecasts: forecasts.map((forecast) => ({
+ projectName: forecast.projectName,
+ shortCode: forecast.shortCode,
+ budget: fmtEur(forecast.budgetCents),
+ budgetCents: forecast.budgetCents,
+ spent: fmtEur(forecast.spentCents),
+ spentCents: forecast.spentCents,
+ remaining: fmtEur(forecast.budgetCents - forecast.spentCents),
+ remainingCents: forecast.budgetCents - forecast.spentCents,
+ projected: forecast.burnRate > 0
+ ? fmtEur(forecast.spentCents + Math.max(0, forecast.budgetCents - forecast.spentCents))
+ : fmtEur(forecast.spentCents),
+ projectedCents: forecast.burnRate > 0
+ ? Math.max(forecast.spentCents, forecast.budgetCents)
+ : forecast.spentCents,
+ burnRate: fmtEur(forecast.burnRate),
+ burnRateCents: forecast.burnRate,
+ utilization: `${forecast.pctUsed}%`,
+ estimatedExhaustionDate: forecast.estimatedExhaustionDate,
+ burnStatus: forecast.pctUsed >= 100
? "ahead"
- : utilization < timelinePct * 0.8
- ? "behind"
- : "on_track"
- : "not_started";
-
- return {
- projectId: project.id,
- projectName: project.name,
- shortCode: project.shortCode,
- budget: fmtEur(budgetCents),
- budgetCents,
- spent: fmtEur(spentCents),
- spentCents,
- remaining: fmtEur(budgetCents - spentCents),
- remainingCents: budgetCents - spentCents,
- projected: fmtEur(projectedCostCents),
- projectedCents: projectedCostCents,
- utilization: `${utilization}%`,
- timelineProgress: `${timelinePct}%`,
- burnStatus,
- };
- });
-
- forecasts.sort((a, b) => b.spentCents - a.spentCents);
-
- return { forecasts };
+ : forecast.burnRate > 0
+ ? "on_track"
+ : "not_started",
+ })),
+ };
},
async get_insights_summary(_params: Record, ctx: ToolContext) {
@@ -5147,17 +6021,116 @@ const executors = {
where: { projectId: params.projectId, status: { not: "CANCELLED" } },
include: {
resource: {
- select: { id: true, displayName: true, lcrCents: true, availability: true, chargeabilityTarget: true },
+ select: {
+ id: true,
+ displayName: true,
+ lcrCents: true,
+ availability: true,
+ chargeabilityTarget: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true, dailyWorkingHours: true } },
+ metroCity: { select: { name: true } },
+ },
},
},
});
+ // Collect resource IDs
+ const resourceIds = new Set();
+ for (const c of params.changes) { if (c.resourceId) resourceIds.add(c.resourceId); }
+ for (const a of currentAssignments) { if (a.resourceId) resourceIds.add(a.resourceId); }
+
+ const resources = await ctx.db.resource.findMany({
+ where: { id: { in: [...resourceIds] } },
+ select: {
+ id: true,
+ displayName: true,
+ lcrCents: true,
+ availability: true,
+ chargeabilityTarget: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
+ country: { select: { code: true, dailyWorkingHours: true } },
+ metroCity: { select: { name: true } },
+ },
+ });
+ const resourceMap = new Map(resources.map((r) => [r.id, r]));
+
+ const scenarioRangeStarts = [
+ ...currentAssignments.map((assignment) => assignment.startDate),
+ ...params.changes.map((change) => new Date(change.startDate)),
+ ];
+ const scenarioRangeEnds = [
+ ...currentAssignments.map((assignment) => assignment.endDate),
+ ...params.changes.map((change) => new Date(change.endDate)),
+ ];
+ const scenarioPeriodStart = scenarioRangeStarts.length > 0
+ ? new Date(Math.min(...scenarioRangeStarts.map((date) => date.getTime())))
+ : project.startDate;
+ const scenarioPeriodEnd = scenarioRangeEnds.length > 0
+ ? new Date(Math.max(...scenarioRangeEnds.map((date) => date.getTime())))
+ : project.endDate;
+ const contexts = resourceIds.size > 0
+ ? await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ resources.map((resource) => ({
+ id: resource.id,
+ availability: (resource.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ })),
+ scenarioPeriodStart,
+ scenarioPeriodEnd,
+ )
+ : new Map();
+
+ function calculateScenarioEntryTotals(input: {
+ resourceId: string | null;
+ lcrCents: number;
+ hoursPerDay: number;
+ startDate: Date;
+ endDate: Date;
+ availability: typeof DEFAULT_AVAILABILITY;
+ }): { totalHours: number; totalCostCents: number } {
+ const context = input.resourceId ? contexts.get(input.resourceId) : undefined;
+ const totalHours = calculateEffectiveBookedHours({
+ availability: input.availability,
+ startDate: input.startDate,
+ endDate: input.endDate,
+ hoursPerDay: input.hoursPerDay,
+ periodStart: input.startDate,
+ periodEnd: input.endDate,
+ context,
+ });
+
+ return {
+ totalHours,
+ totalCostCents: Math.round(totalHours * input.lcrCents),
+ };
+ }
+
// Compute baseline
let baselineCostCents = 0;
let baselineHours = 0;
for (const a of currentAssignments) {
- const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY;
- const result = calculateAllocation({
+ const fallbackDailyHours = a.resource?.country?.dailyWorkingHours ?? 8;
+ const availability = (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? {
+ monday: fallbackDailyHours,
+ tuesday: fallbackDailyHours,
+ wednesday: fallbackDailyHours,
+ thursday: fallbackDailyHours,
+ friday: fallbackDailyHours,
+ saturday: 0,
+ sunday: 0,
+ };
+ const result = calculateScenarioEntryTotals({
+ resourceId: a.resourceId,
lcrCents: a.resource?.lcrCents ?? 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
@@ -5168,17 +6141,6 @@ const executors = {
baselineHours += result.totalHours;
}
- // Collect resource IDs
- const resourceIds = new Set();
- for (const c of params.changes) { if (c.resourceId) resourceIds.add(c.resourceId); }
- for (const a of currentAssignments) { if (a.resourceId) resourceIds.add(a.resourceId); }
-
- const resources = await ctx.db.resource.findMany({
- where: { id: { in: [...resourceIds] } },
- select: { id: true, displayName: true, lcrCents: true, availability: true, chargeabilityTarget: true },
- });
- const resourceMap = new Map(resources.map((r) => [r.id, r]));
-
// Build scenario entries
const removedIds = new Set(params.changes.filter((c) => c.remove && c.assignmentId).map((c) => c.assignmentId!));
const modifiedIds = new Set(params.changes.filter((c) => !c.remove && c.assignmentId).map((c) => c.assignmentId!));
@@ -5194,26 +6156,44 @@ const executors = {
for (const a of currentAssignments) {
if (removedIds.has(a.id) || modifiedIds.has(a.id)) continue;
+ const fallbackDailyHours = a.resource?.country?.dailyWorkingHours ?? 8;
scenarioEntries.push({
resourceId: a.resourceId,
lcrCents: a.resource?.lcrCents ?? 0,
hoursPerDay: a.hoursPerDay,
startDate: a.startDate,
endDate: a.endDate,
- availability: (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
+ availability: (a.resource?.availability as typeof DEFAULT_AVAILABILITY) ?? {
+ monday: fallbackDailyHours,
+ tuesday: fallbackDailyHours,
+ wednesday: fallbackDailyHours,
+ thursday: fallbackDailyHours,
+ friday: fallbackDailyHours,
+ saturday: 0,
+ sunday: 0,
+ },
});
}
for (const c of params.changes) {
if (c.remove) continue;
const resource = c.resourceId ? resourceMap.get(c.resourceId) : null;
+ const fallbackDailyHours = resource?.country?.dailyWorkingHours ?? 8;
scenarioEntries.push({
resourceId: c.resourceId ?? null,
lcrCents: resource?.lcrCents ?? 0,
hoursPerDay: c.hoursPerDay,
startDate: new Date(c.startDate),
endDate: new Date(c.endDate),
- availability: (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? DEFAULT_AVAILABILITY,
+ availability: (resource?.availability as typeof DEFAULT_AVAILABILITY) ?? {
+ monday: fallbackDailyHours,
+ tuesday: fallbackDailyHours,
+ wednesday: fallbackDailyHours,
+ thursday: fallbackDailyHours,
+ friday: fallbackDailyHours,
+ saturday: 0,
+ sunday: 0,
+ },
});
}
@@ -5221,13 +6201,7 @@ const executors = {
let scenarioCostCents = 0;
let scenarioHours = 0;
for (const entry of scenarioEntries) {
- const result = calculateAllocation({
- lcrCents: entry.lcrCents,
- hoursPerDay: entry.hoursPerDay,
- startDate: entry.startDate,
- endDate: entry.endDate,
- availability: entry.availability,
- });
+ const result = calculateScenarioEntryTotals(entry);
scenarioCostCents += result.totalCostCents;
scenarioHours += result.totalHours;
}
@@ -5566,7 +6540,14 @@ const executors = {
const assignments = await ctx.db.assignment.findMany({
where: { projectId: project.id, status: { not: "CANCELLED" } },
- include: { resource: { include: { country: { select: { code: true } } } } },
+ include: {
+ resource: {
+ include: {
+ country: { select: { code: true } },
+ metroCity: { select: { id: true, name: true } },
+ },
+ },
+ },
});
if (assignments.length === 0) {
@@ -5575,16 +6556,54 @@ const executors = {
const { calculateShoringRatio: calcShoring } = await import("@capakraken/engine/allocation");
- const mapped = assignments.map((a) => {
- const start = new Date(a.startDate);
- const end = new Date(a.endDate);
- const diffDays = Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1);
- const workingDays = Math.max(1, Math.round(diffDays / 7 * 5));
+ const resourcesById = new Map(
+ assignments.map((assignment) => [
+ assignment.resourceId,
+ {
+ id: assignment.resourceId,
+ availability: assignment.resource.availability as unknown as WeekdayAvailability,
+ countryId: assignment.resource.countryId,
+ countryCode: assignment.resource.country?.code,
+ federalState: assignment.resource.federalState,
+ metroCityId: assignment.resource.metroCityId,
+ metroCityName: assignment.resource.metroCity?.name,
+ },
+ ]),
+ );
+ const periodStart = assignments.reduce(
+ (min, assignment) => assignment.startDate < min ? assignment.startDate : min,
+ assignments[0]!.startDate,
+ );
+ const periodEnd = assignments.reduce(
+ (max, assignment) => assignment.endDate > max ? assignment.endDate : max,
+ assignments[0]!.endDate,
+ );
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ [...resourcesById.values()],
+ periodStart,
+ periodEnd,
+ );
+ const mapped = assignments.flatMap((a) => {
+ const availability = a.resource.availability as unknown as WeekdayAvailability;
+ const context = contexts.get(a.resourceId);
+ const bookedHours = calculateEffectiveBookedHours({
+ availability,
+ startDate: a.startDate,
+ endDate: a.endDate,
+ hoursPerDay: a.hoursPerDay,
+ periodStart,
+ periodEnd,
+ context,
+ });
+ if (bookedHours <= 0 || a.hoursPerDay <= 0) {
+ return [];
+ }
return {
resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay,
- workingDays,
+ workingDays: bookedHours / a.hoursPerDay,
};
});
@@ -5619,6 +6638,7 @@ export interface ToolAction {
export interface ToolResult {
content: string;
action?: ToolAction;
+ data?: unknown;
}
export async function executeTool(
@@ -5652,6 +6672,7 @@ export async function executeTool(
const desc = (actionResult.description as string | undefined) ?? url;
return {
content: JSON.stringify({ description: desc }),
+ data: { description: desc },
action: { type: "navigate", url, description: desc },
};
}
@@ -5663,6 +6684,7 @@ export async function executeTool(
const content = JSON.stringify(rest);
return {
content: content.length > 4000 ? content.slice(0, 4000) + '..."' : content,
+ data: rest,
action: { type: "invalidate", scope },
};
}
@@ -5670,7 +6692,10 @@ export async function executeTool(
// Cap tool result size to prevent oversized OpenAI conversation payloads
const content = typeof result === "string" ? result : JSON.stringify(result);
- return { content: content.length > 8000 ? content.slice(0, 8000) + '..."' : content };
+ return {
+ content: content.length > 8000 ? content.slice(0, 8000) + '..."' : content,
+ ...(typeof result === "string" ? {} : { data: result }),
+ };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: JSON.stringify({ error: msg }) };
diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts
index bd103f3..6766333 100644
--- a/packages/api/src/router/assistant.ts
+++ b/packages/api/src/router/assistant.ts
@@ -5,10 +5,11 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
-import { resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
+import { PermissionKey, resolvePermissions, type PermissionOverrides, type SystemRole } from "@capakraken/shared";
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
import { createAiClient, isAiConfigured, loggedAiCall, parseAiError } from "../ai-client.js";
-import { TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
+import { ADVANCED_ASSISTANT_TOOLS, TOOL_DEFINITIONS, executeTool, type ToolContext, type ToolAction } from "./assistant-tools.js";
+import { buildAssistantInsight, type AssistantInsight } from "./assistant-insights.js";
import { checkPromptInjection } from "../lib/prompt-guard.js";
import { checkAiOutput } from "../lib/content-filter.js";
import { createAuditEntry } from "../lib/audit.js";
@@ -20,7 +21,7 @@ const SYSTEM_PROMPT = `Du bist der CapaKraken-Assistent — ein hilfreicher AI-A
Deine Fähigkeiten:
- Fragen über Ressourcen, Projekte, Allokationen, Budget, Urlaub, Estimates, Org-Struktur, Rollen, Blueprints, Rate Cards beantworten
-- Chargeability-Analysen, Urlaubsübersichten, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
+- Chargeability-Analysen, Urlaubsübersichten, Feiertagskalender nach Land/Bundesland/Stadt, Budget-Analysen, Staffing-Vorschläge, Kapazitätssuche
- Ressourcen erstellen/aktualisieren/deaktivieren, Projekte erstellen/aktualisieren/löschen
- Allokationen erstellen/stornieren, Demands erstellen/besetzen, Staffing-Vorschläge abrufen
- Urlaub erstellen/genehmigen/ablehnen/stornieren, Ansprüche verwalten
@@ -40,6 +41,12 @@ Wichtige Regeln:
- Sei KURZ und DIREKT. Keine langen Erklärungen wenn nicht nötig. Antworte knapp und präzise.
- Rufe Tools PARALLEL auf wenn möglich (z.B. search_resources + list_allocations gleichzeitig)
- Fasse Ergebnisse kompakt zusammen — keine unnötigen Wiederholungen der Tool-Ergebnisse
+- Wenn Feiertage, SAH, Chargeability, Verfügbarkeit oder Ressourcenauswahl relevant sind, erkläre IMMER transparent:
+ 1. Standortkontext (Land/Bundesland/Stadt falls relevant)
+ 2. Feiertagsbasis bzw. Feiertagsanzahl
+ 3. Abzüge durch Feiertage/Abwesenheiten
+ 4. resultierende verfügbare Stunden / Zielstunden / Restkapazität
+- Wenn strukturierte UI-Karten vorhanden sind, wiederhole dort gezeigte Zahlen NICHT vollständig im Freitext. Gib nur die Kernaussage und die wichtigste Begründung an.
- Wenn eine Suche keine Treffer ergibt, versuche einzelne Wörter aus der Anfrage als Suchbegriffe. Die Tools unterstützen automatisch wort-basierte Fuzzy-Suche — zeige dem User die Vorschläge wenn welche gefunden werden
Datenmodell:
@@ -48,10 +55,12 @@ Datenmodell:
- Allokationen (Assignments): resourceId + projectId, hoursPerDay, dailyCostCents, Zeitraum, Status (PROPOSED/CONFIRMED/ACTIVE/COMPLETED/CANCELLED)
- Chargeability = gebuchte/verfügbare Stunden × 100%
- Urlaub: Typen VACATION/SICK/PARENTAL/SPECIAL/PUBLIC_HOLIDAY, Status PENDING/APPROVED/REJECTED/CANCELLED
+- Feiertage: können je nach Land, Bundesland und Stadt unterschiedlich sein; nutze Feiertags-Tools statt zu raten
`;
/** Map tool names to the permission required to use them */
const TOOL_PERMISSION_MAP: Record = {
+ list_users: PermissionKey.MANAGE_USERS,
// Resource management
update_resource: "manageResources",
create_resource: "manageResources",
@@ -89,7 +98,36 @@ const TOOL_PERMISSION_MAP: Record = {
};
/** Tools that require cost visibility */
-const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail"]);
+const COST_TOOLS = new Set(["get_budget_status", "get_chargeability", "resolve_rate", "list_rate_cards", "get_estimate_detail", "find_best_project_resource"]);
+
+export function getAvailableAssistantTools(permissions: Set) {
+ return TOOL_DEFINITIONS.filter((tool) => {
+ const toolName = tool.function.name;
+ const requiredPerm = TOOL_PERMISSION_MAP[toolName];
+
+ if (requiredPerm && !permissions.has(requiredPerm as PermissionKey)) {
+ return false;
+ }
+ if (COST_TOOLS.has(toolName) && !permissions.has(PermissionKey.VIEW_COSTS)) {
+ return false;
+ }
+ if (ADVANCED_ASSISTANT_TOOLS.has(toolName) && !permissions.has(PermissionKey.USE_ASSISTANT_ADVANCED_TOOLS)) {
+ return false;
+ }
+
+ return true;
+ });
+}
+
+function mergeInsights(existing: AssistantInsight[], next: AssistantInsight): AssistantInsight[] {
+ const duplicateIndex = existing.findIndex((item) => item.kind === next.kind && item.title === next.title && item.subtitle === next.subtitle);
+ if (duplicateIndex >= 0) {
+ const copy = [...existing];
+ copy[duplicateIndex] = next;
+ return copy;
+ }
+ return [...existing, next].slice(-6);
+}
export const assistantRouter = createTRPCRouter({
chat: protectedProcedure
@@ -176,26 +214,12 @@ export const assistantRouter = createTRPCRouter({
}
// 4. Filter tools based on granular permissions
- const availableTools = TOOL_DEFINITIONS.filter((t) => {
- const toolName = t.function.name;
-
- // Check write permission
- const requiredPerm = TOOL_PERMISSION_MAP[toolName];
- if (requiredPerm && !permissions.has(requiredPerm as import("@capakraken/shared").PermissionKey)) {
- return false;
- }
-
- // Hide cost/budget tools if user lacks viewCosts
- if (COST_TOOLS.has(toolName) && !permissions.has("viewCosts" as import("@capakraken/shared").PermissionKey)) {
- return false;
- }
-
- return true;
- });
+ const availableTools = getAvailableAssistantTools(permissions);
// 5. Function calling loop
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
const collectedActions: ToolAction[] = [];
+ let collectedInsights: AssistantInsight[] = [];
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -240,6 +264,11 @@ export const assistantRouter = createTRPCRouter({
toolCtx,
);
+ const insight = buildAssistantInsight(toolCall.function.name, result.data);
+ if (insight) {
+ collectedInsights = mergeInsights(collectedInsights, insight);
+ }
+
// Collect any actions (e.g. navigation)
if (result.action) {
collectedActions.push(result.action);
@@ -298,6 +327,7 @@ export const assistantRouter = createTRPCRouter({
return {
content: finalContent,
role: "assistant" as const,
+ ...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}
@@ -306,6 +336,7 @@ export const assistantRouter = createTRPCRouter({
return {
content: "I had to stop after too many tool calls. Please try a simpler question.",
role: "assistant" as const,
+ ...(collectedInsights.length > 0 ? { insights: collectedInsights } : {}),
...(collectedActions.length > 0 ? { actions: collectedActions } : {}),
};
}),
diff --git a/packages/api/src/router/chargeability-report.ts b/packages/api/src/router/chargeability-report.ts
index df85443..f43c616 100644
--- a/packages/api/src/router/chargeability-report.ts
+++ b/packages/api/src/router/chargeability-report.ts
@@ -5,19 +5,18 @@ import {
sumFte,
getMonthRange,
getMonthKeys,
- countWorkingDaysInOverlap,
- calculateSAH,
- calculateAllocation,
- DEFAULT_CALCULATION_RULES,
type AssignmentSlice,
} from "@capakraken/engine";
-import type { CalculationRule, AbsenceDay } from "@capakraken/shared";
-import type { SpainScheduleRule } from "@capakraken/shared";
+import type { WeekdayAvailability } from "@capakraken/shared";
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
-import { VacationStatus } from "@capakraken/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js";
+import {
+ calculateEffectiveAvailableHours,
+ calculateEffectiveBookedHours,
+ loadResourceDailyAvailabilityContexts,
+} from "../lib/resource-capacity.js";
export const chargeabilityReportRouter = createTRPCRouter({
getReport: controllerProcedure
@@ -59,6 +58,10 @@ export const chargeabilityReportRouter = createTRPCRouter({
eid: true,
displayName: true,
fte: true,
+ availability: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
chargeabilityTarget: true,
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
orgUnit: { select: { id: true, name: true } },
@@ -90,6 +93,20 @@ export const chargeabilityReportRouter = createTRPCRouter({
endDate: rangeEnd,
resourceIds,
});
+ const availabilityContexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ resources.map((resource) => ({
+ id: resource.id,
+ availability: resource.availability as unknown as WeekdayAvailability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ })),
+ rangeStart,
+ rangeEnd,
+ );
// Enrich with utilization category — fetch project util categories in bulk
const projectIds = [...new Set(allBookings.map((b) => b.projectId))];
@@ -118,152 +135,59 @@ export const chargeabilityReportRouter = createTRPCRouter({
},
}));
- // Fetch vacations/absences in the range (including type for rules engine)
- const vacations = await ctx.db.vacation.findMany({
- where: {
- resourceId: { in: resourceIds },
- status: VacationStatus.APPROVED,
- startDate: { lte: rangeEnd },
- endDate: { gte: rangeStart },
- },
- select: {
- resourceId: true,
- startDate: true,
- endDate: true,
- type: true,
- isHalfDay: true,
- },
- });
-
- // Load calculation rules for chargeability adjustments
- let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
- try {
- const dbRules = await ctx.db.calculationRule.findMany({
- where: { isActive: true },
- orderBy: [{ priority: "desc" }],
- });
- if (dbRules.length > 0) {
- calcRules = dbRules as unknown as CalculationRule[];
- }
- } catch {
- // table may not exist yet
- }
-
// Build per-resource, per-month forecasts
- const resourceRows = resources.map((resource) => {
+ const resourceRows = await Promise.all(resources.map(async (resource) => {
const resourceAssignments = assignments.filter((a) => a.resourceId === resource.id);
- const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
// Prefer mgmt level group target; fall back to legacy chargeabilityTarget (0-100 → 0-1)
const targetPct = resource.managementLevelGroup?.targetPercentage
?? (resource.chargeabilityTarget / 100);
- const dailyHours = resource.country?.dailyWorkingHours ?? 8;
- const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
+ const availability = resource.availability as unknown as WeekdayAvailability;
+ const context = availabilityContexts.get(resource.id);
- const months = monthKeys.map((key) => {
+ const months = await Promise.all(monthKeys.map(async (key) => {
const [y, m] = key.split("-").map(Number) as [number, number];
const { start: monthStart, end: monthEnd } = getMonthRange(y, m);
-
- // Compute absence days for SAH
- const absenceDates: string[] = [];
- for (const v of resourceVacations) {
- const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
- const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
- if (vStart > vEnd) continue;
- const cursor = new Date(vStart);
- cursor.setUTCHours(0, 0, 0, 0);
- const endNorm = new Date(vEnd);
- endNorm.setUTCHours(0, 0, 0, 0);
- while (cursor <= endNorm) {
- absenceDates.push(cursor.toISOString().slice(0, 10));
- cursor.setUTCDate(cursor.getUTCDate() + 1);
- }
- }
-
- // Calculate SAH for this resource+month
- const sahResult = calculateSAH({
- dailyWorkingHours: dailyHours,
- scheduleRules,
- fte: resource.fte,
+ const availableHours = calculateEffectiveAvailableHours({
+ availability,
periodStart: monthStart,
periodEnd: monthEnd,
- publicHolidays: [], // TODO: integrate public holidays from country
- absenceDays: absenceDates,
+ context,
});
-
- // Build typed absence days for this resource in this month
- const monthAbsenceDays: AbsenceDay[] = [];
- for (const v of resourceVacations) {
- const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
- const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
- if (vStart > vEnd) continue;
- const absCursor = new Date(vStart);
- absCursor.setUTCHours(0, 0, 0, 0);
- const absEndNorm = new Date(vEnd);
- absEndNorm.setUTCHours(0, 0, 0, 0);
- const triggerType = v.type === "SICK" ? "SICK" as const
- : v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
- : "VACATION" as const;
- while (absCursor <= absEndNorm) {
- monthAbsenceDays.push({
- date: new Date(absCursor),
- type: triggerType,
- ...(v.isHalfDay ? { isHalfDay: true } : {}),
- });
- absCursor.setUTCDate(absCursor.getUTCDate() + 1);
+ const slices: AssignmentSlice[] = resourceAssignments.flatMap((a) => {
+ const totalChargeableHours = calculateEffectiveBookedHours({
+ availability,
+ startDate: a.startDate,
+ endDate: a.endDate,
+ hoursPerDay: a.hoursPerDay,
+ periodStart: monthStart,
+ periodEnd: monthEnd,
+ context,
+ });
+ if (totalChargeableHours <= 0) {
+ return [];
}
- }
- // Build assignment slices for this month, using rules to compute chargeable hours
- const slices: AssignmentSlice[] = [];
- for (const a of resourceAssignments) {
- const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
- if (workingDays <= 0) continue;
-
- const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
-
- // If there are absences and rules, compute rules-adjusted chargeable hours
- if (monthAbsenceDays.length > 0) {
- const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
- const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
-
- const calcResult = calculateAllocation({
- lcrCents: 0, // we only need hours, not costs
- hoursPerDay: a.hoursPerDay,
- startDate: overlapStart,
- endDate: overlapEnd,
- availability: { monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours, thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0 },
- absenceDays: monthAbsenceDays,
- calculationRules: calcRules,
- });
-
- slices.push({
- hoursPerDay: a.hoursPerDay,
- workingDays,
- categoryCode,
- ...(calcResult.totalChargeableHours !== undefined ? { totalChargeableHours: calcResult.totalChargeableHours } : {}),
- });
- } else {
- slices.push({
- hoursPerDay: a.hoursPerDay,
- workingDays,
- categoryCode,
- });
- }
- }
+ return {
+ hoursPerDay: a.hoursPerDay,
+ workingDays: 0,
+ categoryCode: a.project.utilizationCategory?.code ?? "Chg",
+ totalChargeableHours,
+ };
+ });
const forecast = deriveResourceForecast({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
- sah: sahResult.standardAvailableHours,
+ sah: availableHours,
});
return {
monthKey: key,
- sah: sahResult.standardAvailableHours,
+ sah: availableHours,
...forecast,
};
- });
+ }));
return {
id: resource.id,
@@ -278,7 +202,7 @@ export const chargeabilityReportRouter = createTRPCRouter({
targetPct,
months,
};
- });
+ }));
// Compute group totals per month
const groupTotals = monthKeys.map((key, monthIdx) => {
diff --git a/packages/api/src/router/computation-graph.ts b/packages/api/src/router/computation-graph.ts
index e31b59e..eddbc78 100644
--- a/packages/api/src/router/computation-graph.ts
+++ b/packages/api/src/router/computation-graph.ts
@@ -4,18 +4,27 @@ import {
deriveResourceForecast,
computeBudgetStatus,
getMonthRange,
- countWorkingDaysInOverlap,
DEFAULT_CALCULATION_RULES,
summarizeEstimateDemandLines,
computeEvenSpread,
distributeHoursToWeeks,
type AssignmentSlice,
} from "@capakraken/engine";
-import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
+import type { CalculationRule, SpainScheduleRule, WeekdayAvailability } from "@capakraken/shared";
import { VacationStatus } from "@capakraken/db";
import { z } from "zod";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
import { fmtEur } from "../lib/format-utils.js";
+import {
+ asHolidayResolverDb,
+ collectHolidayAvailability,
+ getResolvedCalendarHolidays,
+} from "../lib/holiday-availability.js";
+import {
+ calculateEffectiveAvailableHours,
+ countEffectiveWorkingDays,
+ loadResourceDailyAvailabilityContexts,
+} from "../lib/resource-capacity.js";
// ─── Graph Types (mirrored from client for API response) ────────────────────
@@ -62,6 +71,21 @@ function fmtNum(v: number, decimals = 1): string {
return v.toFixed(decimals);
}
+function getAvailabilityHoursForDate(
+ availability: WeekdayAvailability,
+ date: Date,
+): number {
+ const dayKey = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"][date.getUTCDay()] as keyof WeekdayAvailability;
+ return availability[dayKey] ?? 0;
+}
+
+function sumAvailabilityHoursForDates(
+ availability: WeekdayAvailability,
+ dates: Date[],
+): number {
+ return dates.reduce((sum, date) => sum + getAvailabilityHoursForDate(availability, date), 0);
+}
+
// ─── Router ─────────────────────────────────────────────────────────────────
export const computationGraphRouter = createTRPCRouter({
@@ -88,8 +112,12 @@ export const computationGraphRouter = createTRPCRouter({
fte: true,
lcrCents: true,
chargeabilityTarget: true,
+ countryId: true,
+ federalState: true,
+ metroCityId: true,
availability: true,
- country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
+ country: { select: { id: true, code: true, name: true, dailyWorkingHours: true, scheduleRules: true } },
+ metroCity: { select: { id: true, name: true } },
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
},
});
@@ -133,7 +161,7 @@ export const computationGraphRouter = createTRPCRouter({
},
});
- // ── 3. Load absences ──
+ // ── 3. Load absences + holiday context ──
const vacations = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
@@ -143,45 +171,47 @@ export const computationGraphRouter = createTRPCRouter({
},
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
});
+ const resolvedHolidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
+ periodStart: monthStart,
+ periodEnd: monthEnd,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ });
+ const holidayAvailability = collectHolidayAvailability({
+ vacations,
+ periodStart: monthStart,
+ periodEnd: monthEnd,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityName: resource.metroCity?.name,
+ resolvedHolidayStrings: resolvedHolidays.map((holiday) => holiday.date),
+ });
+ const publicHolidayStrings = holidayAvailability.publicHolidayStrings;
+ const absenceDateStrings = holidayAvailability.absenceDateStrings;
+ const absenceDays = holidayAvailability.absenceDays;
+ const halfDayCount = absenceDays.filter((absence) => absence.isHalfDay).length;
+ const vacationDayCount = absenceDays.filter((absence) => absence.type === "VACATION").length;
+ const sickDayCount = absenceDays.filter((absence) => absence.type === "SICK").length;
+ const publicHolidayCount = resolvedHolidays.length;
- // Build absence dates for SAH (ISO strings), separating public holidays
- const publicHolidayStrings: string[] = [];
- const absenceDateStrings: string[] = [];
- const absenceDays: AbsenceDay[] = [];
- let halfDayCount = 0;
- let vacationDayCount = 0;
- let sickDayCount = 0;
- let publicHolidayCount = 0;
- for (const v of vacations) {
- const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
- const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
- if (vStart > vEnd) continue;
- const cursor = new Date(vStart);
- cursor.setUTCHours(0, 0, 0, 0);
- const endNorm = new Date(vEnd);
- endNorm.setUTCHours(0, 0, 0, 0);
- const triggerType = v.type === "SICK" ? "SICK" as const
- : v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
- : "VACATION" as const;
- while (cursor <= endNorm) {
- const isoDate = cursor.toISOString().slice(0, 10);
- if (triggerType === "PUBLIC_HOLIDAY") {
- publicHolidayStrings.push(isoDate);
- publicHolidayCount++;
- } else {
- absenceDateStrings.push(isoDate);
- if (triggerType === "VACATION") vacationDayCount++;
- if (triggerType === "SICK") sickDayCount++;
- }
- absenceDays.push({
- date: new Date(cursor),
- type: triggerType,
- ...(v.isHalfDay ? { isHalfDay: true } : {}),
- });
- if (v.isHalfDay) halfDayCount++;
- cursor.setUTCDate(cursor.getUTCDate() + 1);
- }
- }
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ [{
+ id: resource.id,
+ availability: weeklyAvailability,
+ countryId: resource.countryId,
+ countryCode: resource.country?.code,
+ federalState: resource.federalState,
+ metroCityId: resource.metroCityId,
+ metroCityName: resource.metroCity?.name,
+ }],
+ monthStart,
+ monthEnd,
+ );
+ const availabilityContext = contexts.get(resource.id);
// ── 4. Load calculation rules ──
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
@@ -197,7 +227,7 @@ export const computationGraphRouter = createTRPCRouter({
// table may not exist yet
}
- // ── 5. Calculate SAH ──
+ // ── 5. Calculate SAH / effective capacity ──
const sahResult = calculateSAH({
dailyWorkingHours: dailyHours,
scheduleRules,
@@ -207,6 +237,60 @@ export const computationGraphRouter = createTRPCRouter({
publicHolidays: publicHolidayStrings,
absenceDays: absenceDateStrings,
});
+ const baseWorkingDays = countEffectiveWorkingDays({
+ availability: weeklyAvailability,
+ periodStart: monthStart,
+ periodEnd: monthEnd,
+ context: undefined,
+ });
+ const effectiveWorkingDays = countEffectiveWorkingDays({
+ availability: weeklyAvailability,
+ periodStart: monthStart,
+ periodEnd: monthEnd,
+ context: availabilityContext,
+ });
+ const baseAvailableHours = calculateEffectiveAvailableHours({
+ availability: weeklyAvailability,
+ periodStart: monthStart,
+ periodEnd: monthEnd,
+ context: undefined,
+ });
+ const effectiveAvailableHours = calculateEffectiveAvailableHours({
+ availability: weeklyAvailability,
+ periodStart: monthStart,
+ periodEnd: monthEnd,
+ context: availabilityContext,
+ });
+ const publicHolidayDates = resolvedHolidays.map((holiday) => new Date(`${holiday.date}T00:00:00.000Z`));
+ const publicHolidayWorkdayCount = publicHolidayDates.reduce((count, date) => (
+ count + (getAvailabilityHoursForDate(weeklyAvailability, date) > 0 ? 1 : 0)
+ ), 0);
+ const publicHolidayHoursDeduction = sumAvailabilityHoursForDates(
+ weeklyAvailability,
+ publicHolidayDates,
+ );
+ const absenceHoursDeduction = absenceDays.reduce((sum, absence) => {
+ if (absence.type === "PUBLIC_HOLIDAY") {
+ return sum;
+ }
+ const baseHours = getAvailabilityHoursForDate(weeklyAvailability, absence.date);
+ return sum + baseHours * (absence.isHalfDay ? 0.5 : 1);
+ }, 0);
+ const effectiveHoursPerWorkingDay = effectiveWorkingDays > 0
+ ? effectiveAvailableHours / effectiveWorkingDays
+ : 0;
+ const holidayScopeSummary = [
+ resource.country?.code ?? "—",
+ resource.federalState ?? "—",
+ resource.metroCity?.name ?? "—",
+ ].join(" / ");
+ const holidayExamples = resolvedHolidays.length > 0
+ ? resolvedHolidays.slice(0, 4).map((holiday) => `${holiday.date} ${holiday.name}`).join(", ")
+ : "none";
+ const holidayScopeBreakdown = resolvedHolidays.reduce>((counts, holiday) => {
+ counts[holiday.scope] = (counts[holiday.scope] ?? 0) + 1;
+ return counts;
+ }, {});
// ── 6. Calculate allocations + chargeability slices ──
const slices: AssignmentSlice[] = [];
@@ -217,9 +301,6 @@ export const computationGraphRouter = createTRPCRouter({
let hasRulesEffect = false;
for (const a of assignments) {
- const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
- if (workingDays <= 0) continue;
-
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
@@ -233,6 +314,7 @@ export const computationGraphRouter = createTRPCRouter({
absenceDays,
calculationRules: calcRules,
});
+ if (calcResult.workingDays <= 0 && calcResult.totalHours <= 0) continue;
totalAllocHours += calcResult.totalHours;
totalAllocCostCents += calcResult.totalCostCents;
@@ -247,7 +329,7 @@ export const computationGraphRouter = createTRPCRouter({
slices.push({
hoursPerDay: a.hoursPerDay,
- workingDays,
+ workingDays: calcResult.workingDays,
categoryCode,
...(calcResult.totalChargeableHours !== undefined
? { totalChargeableHours: calcResult.totalChargeableHours }
@@ -260,7 +342,7 @@ export const computationGraphRouter = createTRPCRouter({
fte: resource.fte,
targetPercentage: targetPct,
assignments: slices,
- sah: sahResult.standardAvailableHours,
+ sah: effectiveAvailableHours,
});
// ── 8. Build budget status for first project with budget ──
@@ -319,7 +401,18 @@ export const computationGraphRouter = createTRPCRouter({
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
: 0;
const totalWorkingDaysInMonth = assignments.reduce((sum, a) => {
- return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
+ const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
+ const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
+ const calcResult = calculateAllocation({
+ lcrCents: resource.lcrCents,
+ hoursPerDay: a.hoursPerDay,
+ startDate: overlapStart,
+ endDate: overlapEnd,
+ availability: weeklyAvailability,
+ absenceDays,
+ calculationRules: calcRules,
+ });
+ return sum + calcResult.workingDays;
}, 0);
// Format weekly availability for display
@@ -332,9 +425,10 @@ export const computationGraphRouter = createTRPCRouter({
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
// Derived utilization ratio
- const utilizationPct = sahResult.standardAvailableHours > 0
- ? (totalAllocHours / sahResult.standardAvailableHours) * 100
+ const utilizationPct = effectiveAvailableHours > 0
+ ? (totalAllocHours / effectiveAvailableHours) * 100
: 0;
+ const chargeableHours = forecast.chg * effectiveAvailableHours;
// Has schedule rules (Spain variable hours)?
const hasScheduleRules = !!scheduleRules;
@@ -342,6 +436,11 @@ export const computationGraphRouter = createTRPCRouter({
const nodes: GraphNode[] = [
// INPUT
n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0),
+ n("input.country", "Country", resource.country?.name ?? resource.country?.code ?? "—", "text", "INPUT", "Country used for base working-time and national holiday rules", 0),
+ n("input.state", "State", resource.federalState ?? "—", "text", "INPUT", "Federal state / region used for regional holidays", 0),
+ n("input.city", "City", resource.metroCity?.name ?? "—", "text", "INPUT", "City / metro used for local holidays", 0),
+ n("input.holidayContext", "Holiday Context", holidayScopeSummary, "text", "INPUT", "Resolved holiday scope chain: country / state / city", 0),
+ n("input.holidayExamples", "Holiday Dates", holidayExamples, "text", "INPUT", `Resolved holidays in ${input.month}; scopes: COUNTRY ${holidayScopeBreakdown.COUNTRY ?? 0}, STATE ${holidayScopeBreakdown.STATE ?? 0}, CITY ${holidayScopeBreakdown.CITY ?? 0}`, 0),
n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0),
...(hasScheduleRules ? [
n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0),
@@ -350,7 +449,7 @@ export const computationGraphRouter = createTRPCRouter({
n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0),
n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0),
n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0),
- n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0),
+ n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Resolved holidays in ${input.month}; ${publicHolidayWorkdayCount} hit configured working days`, 0),
n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0),
n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0),
n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0),
@@ -358,12 +457,15 @@ export const computationGraphRouter = createTRPCRouter({
// SAH
n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1),
n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1),
- n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"),
- n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1),
- n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1),
- n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"),
- n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"),
- n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"),
+ n("sah.grossWorkingDays", "Gross Work Days", `${baseWorkingDays}`, "days", "SAH", "Working days from the resource-specific weekly availability before holidays/absences", 1, "count(availability > 0)"),
+ n("sah.baseHours", "Base Hours", fmtNum(baseAvailableHours), "hours", "SAH", "Available hours from weekly availability before holiday/absence deductions", 1, "Σ(daily availability)"),
+ n("sah.publicHolidayDays", "Holiday Ded.", `${publicHolidayWorkdayCount}`, "days", "SAH", "Holiday workdays deducted after applying country/state/city scope and weekday availability", 1),
+ n("sah.publicHolidayHours", "Holiday Hrs Ded.", fmtNum(publicHolidayHoursDeduction), "hours", "SAH", "Hours removed by resolved public holidays", 1, "Σ(availability on holiday dates)"),
+ n("sah.absenceDays", "Absence Ded.", `${absenceDateStrings.length}`, "days", "SAH", "Vacation/sick days that hit working days and are not already public holidays", 1),
+ n("sah.absenceHours", "Absence Hrs Ded.", fmtNum(absenceHoursDeduction), "hours", "SAH", "Hours removed by vacation/sick absences", 1, "Σ(availability × absence fraction)"),
+ n("sah.netWorkingDays", "Net Work Days", `${effectiveWorkingDays}`, "days", "SAH", "Remaining working days after holiday and absence deductions", 2, "gross - holidays - absences"),
+ n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(effectiveHoursPerWorkingDay), "hours", "SAH", "Average effective hours per remaining working day", 2, "SAH / net work days"),
+ n("sah.sah", "SAH", fmtNum(effectiveAvailableHours), "hours", "SAH", "Effective available hours after weekly availability, local holidays and absences", 2, "base hours - holiday hours - absence hours"),
// ALLOCATION
n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"),
@@ -387,24 +489,24 @@ export const computationGraphRouter = createTRPCRouter({
] : []),
// CHARGEABILITY — full breakdown from deriveResourceForecast
- n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"),
+ n("chg.chgHours", "Chg Hours", fmtNum(chargeableHours), "hours", "CHARGEABILITY", "Total chargeable hours against effective SAH", 2, "chargeability × SAH"),
n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"),
...(forecast.bd > 0 ? [
- n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"),
+ n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * effectiveAvailableHours)}h`, 3, "bdHours / SAH"),
] : []),
...(forecast.mdi > 0 ? [
- n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"),
+ n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * effectiveAvailableHours)}h`, 3, "mdiHours / SAH"),
] : []),
...(forecast.mo > 0 ? [
- n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"),
+ n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * effectiveAvailableHours)}h`, 3, "moHours / SAH"),
] : []),
...(forecast.pdr > 0 ? [
- n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"),
+ n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * effectiveAvailableHours)}h`, 3, "pdrHours / SAH"),
] : []),
...(forecast.absence > 0 ? [
- n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"),
+ n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * effectiveAvailableHours)}h`, 3, "absenceHours / SAH"),
] : []),
- n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
+ n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * effectiveAvailableHours)}h of ${fmtNum(effectiveAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"),
n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3),
n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"),
@@ -414,7 +516,16 @@ export const computationGraphRouter = createTRPCRouter({
const links: GraphLink[] = [
// INPUT → SAH
+ l("input.country", "input.holidayContext", "holiday base", 1),
+ l("input.state", "input.holidayContext", "regional scope", 1),
+ l("input.city", "input.holidayContext", "local scope", 1),
+ l("input.holidayContext", "input.holidayExamples", "resolve holidays", 1),
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
+ l("input.weeklyAvail", "sah.grossWorkingDays", "working-day pattern", 2),
+ l("input.weeklyAvail", "sah.baseHours", "sum by weekday", 2),
+ l("input.holidayExamples", "sah.publicHolidayDays", "resolved dates", 2),
+ l("input.holidayExamples", "sah.publicHolidayHours", "remove matching day hours", 2),
+ l("input.absences", "sah.absenceHours", "remove absence fractions", 1),
...(hasScheduleRules ? [
l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1),
] : []),
@@ -422,14 +533,14 @@ export const computationGraphRouter = createTRPCRouter({
l("sah.weekendDays", "sah.grossWorkingDays", "−", 1),
l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1),
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
- l("sah.grossWorkingDays", "sah.netWorkingDays", "−", 2),
+ l("sah.grossWorkingDays", "sah.netWorkingDays", "− holiday/absence days", 2),
l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1),
l("sah.absenceDays", "sah.netWorkingDays", "−", 1),
- l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
- l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
+ l("sah.baseHours", "sah.sah", "start from base capacity", 2),
+ l("sah.publicHolidayHours", "sah.sah", "− holiday hours", 2),
+ l("sah.absenceHours", "sah.sah", "− absence hours", 2),
+ l("sah.sah", "sah.effectiveHoursPerDay", "÷", 1),
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
- l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
- l("sah.netWorkingDays", "sah.sah", "×", 2),
// INPUT → ALLOCATION
l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2),
@@ -489,6 +600,30 @@ export const computationGraphRouter = createTRPCRouter({
resourceEid: resource.eid,
month: input.month,
assignmentCount: assignments.length,
+ countryCode: resource.country?.code ?? null,
+ countryName: resource.country?.name ?? null,
+ federalState: resource.federalState ?? null,
+ metroCityName: resource.metroCity?.name ?? null,
+ resolvedHolidays: resolvedHolidays.map((holiday) => ({
+ date: holiday.date,
+ name: holiday.name,
+ scope: holiday.scope,
+ calendarName: holiday.calendarName,
+ })),
+ factors: {
+ weeklyAvailability,
+ baseWorkingDays,
+ effectiveWorkingDays,
+ baseAvailableHours,
+ effectiveAvailableHours,
+ publicHolidayCount,
+ publicHolidayWorkdayCount,
+ publicHolidayHoursDeduction,
+ absenceDayCount: absenceDateStrings.length,
+ absenceHoursDeduction,
+ chargeableHours,
+ utilizationPct,
+ },
},
};
}),
diff --git a/packages/api/src/router/entitlement.ts b/packages/api/src/router/entitlement.ts
index 8f896fa..4e43de8 100644
--- a/packages/api/src/router/entitlement.ts
+++ b/packages/api/src/router/entitlement.ts
@@ -9,19 +9,19 @@ import { z } from "zod";
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { createAuditEntry } from "../lib/audit.js";
+import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
+import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
-/**
- * Count calendar days between two dates (inclusive).
- * Half-day vacations count as 0.5.
- */
-function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number {
- if (isHalfDay) return 0.5;
- const ms = endDate.getTime() - startDate.getTime();
- return Math.round(ms / 86_400_000) + 1;
-}
+type EntitlementSnapshot = {
+ id: string;
+ entitledDays: number;
+ carryoverDays: number;
+ usedDays: number;
+ pendingDays: number;
+};
/**
* Get or create an entitlement record, applying carryover from previous year if needed.
@@ -61,6 +61,14 @@ async function getOrCreateEntitlement(
return entitlement;
}
+function calculateCarryoverDays(entitlement: {
+ entitledDays: number;
+ usedDays: number;
+ pendingDays: number;
+}): number {
+ return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
+}
+
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
@@ -69,14 +77,57 @@ async function syncEntitlement(
resourceId: string,
year: number,
defaultDays: number,
-) {
+ visitedYears: Set = new Set(),
+): Promise {
+ if (visitedYears.has(year)) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Detected recursive entitlement sync for year ${year}`,
+ });
+ }
+ visitedYears.add(year);
+
+ let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
+ where: { resourceId_year: { resourceId, year: year - 1 } },
+ });
+
+ if (previousYearEntitlement) {
+ previousYearEntitlement = await syncEntitlement(
+ db,
+ resourceId,
+ year - 1,
+ defaultDays,
+ visitedYears,
+ );
+ }
+
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
+ const carryoverDays = previousYearEntitlement
+ ? calculateCarryoverDays(previousYearEntitlement)
+ : 0;
+ const expectedEntitledDays = defaultDays + carryoverDays;
+ const entitlementWithCarryover = (
+ entitlement.carryoverDays !== carryoverDays
+ || entitlement.entitledDays !== expectedEntitledDays
+ )
+ ? await db.vacationEntitlement.update({
+ where: { id: entitlement.id },
+ data: {
+ carryoverDays,
+ entitledDays: expectedEntitledDays,
+ },
+ })
+ : entitlement;
+ const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
+ const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
+ const holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
- startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) },
+ startDate: { lte: yearEnd },
+ endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: { startDate: true, endDate: true, status: true, isHalfDay: true },
@@ -86,13 +137,22 @@ async function syncEntitlement(
let pendingDays = 0;
for (const v of vacations) {
- const days = countDays(v.startDate, v.endDate, v.isHalfDay);
+ const days = countVacationChargeableDays({
+ vacation: v,
+ periodStart: yearStart,
+ periodEnd: yearEnd,
+ countryCode: holidayContext.countryCode,
+ federalState: holidayContext.federalState,
+ metroCityName: holidayContext.metroCityName,
+ calendarHolidayStrings: holidayContext.calendarHolidayStrings,
+ publicHolidayStrings: holidayContext.publicHolidayStrings,
+ });
if (v.status === VacationStatus.APPROVED) usedDays += days;
else pendingDays += days;
}
return db.vacationEntitlement.update({
- where: { id: entitlement.id },
+ where: { id: entitlementWithCarryover.id },
data: { usedDays, pendingDays },
});
}
@@ -134,17 +194,23 @@ export const entitlementRouter = createTRPCRouter({
const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
// Also count sick days (informational)
- const sickVacations = await ctx.db.vacation.findMany({
+ const sickVacationsResult = await ctx.db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
- startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) },
+ startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
+ endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
+ const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
- (sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay),
+ (sum, v) => sum + countCalendarDaysInPeriod(
+ v,
+ new Date(`${input.year}-01-01T00:00:00.000Z`),
+ new Date(`${input.year}-12-31T00:00:00.000Z`),
+ ),
0,
);
@@ -171,7 +237,7 @@ export const entitlementRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
- return getOrCreateEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
+ return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays);
}),
/**
diff --git a/packages/api/src/router/holiday-calendar.ts b/packages/api/src/router/holiday-calendar.ts
new file mode 100644
index 0000000..e0d3fff
--- /dev/null
+++ b/packages/api/src/router/holiday-calendar.ts
@@ -0,0 +1,471 @@
+import {
+ CreateHolidayCalendarEntrySchema,
+ CreateHolidayCalendarSchema,
+ type HolidayCalendarScopeInput,
+ PreviewResolvedHolidaysSchema,
+ UpdateHolidayCalendarEntrySchema,
+ UpdateHolidayCalendarSchema,
+} from "@capakraken/shared";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { findUniqueOrThrow } from "../db/helpers.js";
+import { createAuditEntry } from "../lib/audit.js";
+import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
+import { createTRPCRouter, adminProcedure, protectedProcedure, type TRPCContext } from "../trpc.js";
+
+type HolidayCalendarScope = HolidayCalendarScopeInput;
+
+const HOLIDAY_SCOPE = {
+ COUNTRY: "COUNTRY",
+ STATE: "STATE",
+ CITY: "CITY",
+} as const satisfies Record;
+
+type HolidayCalendarDb = TRPCContext["db"] & {
+ holidayCalendar: {
+ findFirst: (args: unknown) => Promise<{ id: string } | null>;
+ findMany: (args: unknown) => Promise;
+ findUnique: (args: unknown) => Promise;
+ create: (args: unknown) => Promise;
+ update: (args: unknown) => Promise;
+ delete: (args: unknown) => Promise;
+ };
+ holidayCalendarEntry: {
+ findFirst: (args: unknown) => Promise<{ id: string } | null>;
+ findUnique: (args: unknown) => Promise;
+ create: (args: unknown) => Promise;
+ update: (args: unknown) => Promise;
+ delete: (args: unknown) => Promise;
+ };
+};
+
+function asHolidayCalendarDb(db: TRPCContext["db"]): HolidayCalendarDb {
+ return db as unknown as HolidayCalendarDb;
+}
+
+function clampDate(date: Date): Date {
+ const value = new Date(date);
+ value.setUTCHours(0, 0, 0, 0);
+ return value;
+}
+
+async function assertEntryDateAvailable(
+ db: HolidayCalendarDb,
+ input: {
+ holidayCalendarId: string;
+ date: Date;
+ },
+ ignoreId?: string,
+) {
+ const existing = await db.holidayCalendarEntry.findFirst({
+ where: {
+ holidayCalendarId: input.holidayCalendarId,
+ date: clampDate(input.date),
+ ...(ignoreId ? { id: { not: ignoreId } } : {}),
+ },
+ select: { id: true },
+ });
+
+ if (existing) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "A holiday entry for this calendar and date already exists",
+ });
+ }
+}
+
+async function assertScopeConsistency(
+ db: HolidayCalendarDb,
+ input: {
+ scopeType: HolidayCalendarScope;
+ countryId: string;
+ stateCode?: string | null;
+ metroCityId?: string | null;
+ },
+ ignoreId?: string,
+) {
+ if (input.scopeType === HOLIDAY_SCOPE.COUNTRY) {
+ if (input.stateCode || input.metroCityId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Country calendars may not define a state or metro city",
+ });
+ }
+ }
+
+ if (input.scopeType === HOLIDAY_SCOPE.STATE) {
+ if (!input.stateCode) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "State calendars require a state code",
+ });
+ }
+ if (input.metroCityId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "State calendars may not define a metro city",
+ });
+ }
+ }
+
+ if (input.scopeType === HOLIDAY_SCOPE.CITY) {
+ if (!input.metroCityId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "City calendars require a metro city",
+ });
+ }
+
+ const metroCity = await findUniqueOrThrow(
+ db.metroCity.findUnique({
+ where: { id: input.metroCityId },
+ select: { id: true, countryId: true },
+ }),
+ "Metro city",
+ );
+
+ if (metroCity.countryId !== input.countryId) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Metro city must belong to the selected country",
+ });
+ }
+ }
+
+ const existing = await db.holidayCalendar.findFirst({
+ where: {
+ countryId: input.countryId,
+ scopeType: input.scopeType,
+ ...(input.scopeType === HOLIDAY_SCOPE.STATE ? { stateCode: input.stateCode ?? null } : {}),
+ ...(input.scopeType === HOLIDAY_SCOPE.CITY ? { metroCityId: input.metroCityId ?? null } : {}),
+ ...(ignoreId ? { id: { not: ignoreId } } : {}),
+ },
+ select: { id: true },
+ });
+
+ if (existing) {
+ throw new TRPCError({
+ code: "CONFLICT",
+ message: "A holiday calendar for this exact scope already exists",
+ });
+ }
+}
+
+export const holidayCalendarRouter = createTRPCRouter({
+ listCalendars: protectedProcedure
+ .input(z.object({ includeInactive: z.boolean().optional() }).optional())
+ .query(async ({ ctx, input }) => {
+ const db = asHolidayCalendarDb(ctx.db);
+ const where = input?.includeInactive ? undefined : { isActive: true };
+
+ return db.holidayCalendar.findMany({
+ ...(where ? { where } : {}),
+ include: {
+ country: { select: { id: true, code: true, name: true } },
+ metroCity: { select: { id: true, name: true } },
+ _count: { select: { entries: true } },
+ entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
+ },
+ orderBy: [
+ { country: { name: "asc" } },
+ { scopeType: "asc" },
+ { priority: "desc" },
+ { name: "asc" },
+ ],
+ });
+ }),
+
+ getCalendarById: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .query(async ({ ctx, input }) => {
+ const db = asHolidayCalendarDb(ctx.db);
+
+ return findUniqueOrThrow(
+ db.holidayCalendar.findUnique({
+ where: { id: input.id },
+ include: {
+ country: { select: { id: true, code: true, name: true } },
+ metroCity: { select: { id: true, name: true } },
+ entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
+ },
+ }),
+ "Holiday calendar",
+ );
+ }),
+
+ createCalendar: adminProcedure
+ .input(CreateHolidayCalendarSchema)
+ .mutation(async ({ ctx, input }) => {
+ const db = asHolidayCalendarDb(ctx.db);
+
+ await findUniqueOrThrow(
+ ctx.db.country.findUnique({
+ where: { id: input.countryId },
+ select: { id: true, name: true },
+ }),
+ "Country",
+ );
+
+ await assertScopeConsistency(db, {
+ scopeType: input.scopeType,
+ countryId: input.countryId,
+ stateCode: input.stateCode?.trim().toUpperCase() ?? null,
+ metroCityId: input.metroCityId ?? null,
+ });
+
+ const created = await db.holidayCalendar.create({
+ data: {
+ name: input.name,
+ scopeType: input.scopeType,
+ countryId: input.countryId,
+ ...(input.stateCode ? { stateCode: input.stateCode.trim().toUpperCase() } : {}),
+ ...(input.metroCityId ? { metroCityId: input.metroCityId } : {}),
+ isActive: input.isActive ?? true,
+ priority: input.priority ?? 0,
+ },
+ include: {
+ country: { select: { id: true, code: true, name: true } },
+ metroCity: { select: { id: true, name: true } },
+ entries: true,
+ },
+ });
+
+ void createAuditEntry({
+ db: ctx.db,
+ entityType: "HolidayCalendar",
+ entityId: created.id,
+ entityName: created.name,
+ action: "CREATE",
+ userId: ctx.dbUser?.id,
+ after: created as unknown as Record,
+ source: "ui",
+ });
+
+ return created;
+ }),
+
+ updateCalendar: adminProcedure
+ .input(z.object({ id: z.string(), data: UpdateHolidayCalendarSchema }))
+ .mutation(async ({ ctx, input }) => {
+ const db = asHolidayCalendarDb(ctx.db);
+ const existing = await findUniqueOrThrow(
+ db.holidayCalendar.findUnique({ where: { id: input.id } }),
+ "Holiday calendar",
+ );
+
+ const stateCode = input.data.stateCode === undefined
+ ? existing.stateCode
+ : input.data.stateCode?.trim().toUpperCase() ?? null;
+ const metroCityId = input.data.metroCityId === undefined
+ ? existing.metroCityId
+ : input.data.metroCityId ?? null;
+
+ await assertScopeConsistency(db, {
+ scopeType: existing.scopeType,
+ countryId: existing.countryId,
+ stateCode,
+ metroCityId,
+ }, existing.id);
+
+ const updated = await db.holidayCalendar.update({
+ where: { id: input.id },
+ data: {
+ ...(input.data.name !== undefined ? { name: input.data.name } : {}),
+ ...(input.data.stateCode !== undefined ? { stateCode } : {}),
+ ...(input.data.metroCityId !== undefined ? { metroCityId } : {}),
+ ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}),
+ ...(input.data.priority !== undefined ? { priority: input.data.priority } : {}),
+ },
+ include: {
+ country: { select: { id: true, code: true, name: true } },
+ metroCity: { select: { id: true, name: true } },
+ entries: { orderBy: [{ date: "asc" }, { name: "asc" }] },
+ },
+ });
+
+ void createAuditEntry({
+ db: ctx.db,
+ entityType: "HolidayCalendar",
+ entityId: updated.id,
+ entityName: updated.name,
+ action: "UPDATE",
+ userId: ctx.dbUser?.id,
+ before: existing as unknown as Record,
+ after: updated as unknown as Record,
+ source: "ui",
+ });
+
+ return updated;
+ }),
+
+ deleteCalendar: adminProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = asHolidayCalendarDb(ctx.db);
+ const existing = await findUniqueOrThrow(
+ db.holidayCalendar.findUnique({
+ where: { id: input.id },
+ include: { entries: true },
+ }),
+ "Holiday calendar",
+ );
+
+ await db.holidayCalendar.delete({ where: { id: input.id } });
+
+ void createAuditEntry({
+ db: ctx.db,
+ entityType: "HolidayCalendar",
+ entityId: existing.id,
+ entityName: existing.name,
+ action: "DELETE",
+ userId: ctx.dbUser?.id,
+ before: existing as unknown as Record,
+ source: "ui",
+ });
+
+ return { success: true };
+ }),
+
+ createEntry: adminProcedure
+ .input(CreateHolidayCalendarEntrySchema)
+ .mutation(async ({ ctx, input }) => {
+ const db = asHolidayCalendarDb(ctx.db);
+
+ await findUniqueOrThrow(
+ db.holidayCalendar.findUnique({
+ where: { id: input.holidayCalendarId },
+ select: { id: true, name: true },
+ }),
+ "Holiday calendar",
+ );
+
+ await assertEntryDateAvailable(db, {
+ holidayCalendarId: input.holidayCalendarId,
+ date: input.date,
+ });
+
+ const created = await db.holidayCalendarEntry.create({
+ data: {
+ holidayCalendarId: input.holidayCalendarId,
+ date: clampDate(input.date),
+ name: input.name,
+ isRecurringAnnual: input.isRecurringAnnual ?? false,
+ ...(input.source ? { source: input.source } : {}),
+ },
+ });
+
+ void createAuditEntry({
+ db: ctx.db,
+ entityType: "HolidayCalendarEntry",
+ entityId: created.id,
+ entityName: created.name,
+ action: "CREATE",
+ userId: ctx.dbUser?.id,
+ after: created as unknown as Record,
+ source: "ui",
+ });
+
+ return created;
+ }),
+
+ updateEntry: adminProcedure
+ .input(z.object({ id: z.string(), data: UpdateHolidayCalendarEntrySchema }))
+ .mutation(async ({ ctx, input }) => {
+ const db = asHolidayCalendarDb(ctx.db);
+ const existing = await findUniqueOrThrow(
+ db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
+ "Holiday calendar entry",
+ );
+ const nextDate = input.data.date !== undefined ? clampDate(input.data.date) : existing.date;
+
+ await assertEntryDateAvailable(db, {
+ holidayCalendarId: existing.holidayCalendarId,
+ date: nextDate,
+ }, existing.id);
+
+ const updated = await db.holidayCalendarEntry.update({
+ where: { id: input.id },
+ data: {
+ ...(input.data.date !== undefined ? { date: nextDate } : {}),
+ ...(input.data.name !== undefined ? { name: input.data.name } : {}),
+ ...(input.data.isRecurringAnnual !== undefined ? { isRecurringAnnual: input.data.isRecurringAnnual } : {}),
+ ...(input.data.source !== undefined ? { source: input.data.source ?? null } : {}),
+ },
+ });
+
+ void createAuditEntry({
+ db: ctx.db,
+ entityType: "HolidayCalendarEntry",
+ entityId: updated.id,
+ entityName: updated.name,
+ action: "UPDATE",
+ userId: ctx.dbUser?.id,
+ before: existing as unknown as Record,
+ after: updated as unknown as Record,
+ source: "ui",
+ });
+
+ return updated;
+ }),
+
+ deleteEntry: adminProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const db = asHolidayCalendarDb(ctx.db);
+ const existing = await findUniqueOrThrow(
+ db.holidayCalendarEntry.findUnique({ where: { id: input.id } }),
+ "Holiday calendar entry",
+ );
+
+ await db.holidayCalendarEntry.delete({ where: { id: input.id } });
+
+ void createAuditEntry({
+ db: ctx.db,
+ entityType: "HolidayCalendarEntry",
+ entityId: existing.id,
+ entityName: existing.name,
+ action: "DELETE",
+ userId: ctx.dbUser?.id,
+ before: existing as unknown as Record,
+ source: "ui",
+ });
+
+ return { success: true };
+ }),
+
+ previewResolvedHolidays: protectedProcedure
+ .input(PreviewResolvedHolidaysSchema)
+ .query(async ({ ctx, input }) => {
+ const country = await findUniqueOrThrow(
+ ctx.db.country.findUnique({
+ where: { id: input.countryId },
+ select: { code: true },
+ }),
+ "Country",
+ );
+
+ const metroCity = input.metroCityId
+ ? await ctx.db.metroCity.findUnique({
+ where: { id: input.metroCityId },
+ select: { name: true },
+ })
+ : null;
+
+ const resolved = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
+ periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
+ periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
+ countryId: input.countryId,
+ countryCode: country.code,
+ federalState: input.stateCode?.trim().toUpperCase() ?? null,
+ metroCityId: input.metroCityId ?? null,
+ metroCityName: metroCity?.name ?? null,
+ });
+
+ return resolved.map((holiday) => ({
+ date: holiday.date,
+ name: holiday.name,
+ scopeType: holiday.scope,
+ calendarName: holiday.calendarName,
+ }));
+ }),
+});
diff --git a/packages/api/src/router/index.ts b/packages/api/src/router/index.ts
index 6564a96..3add205 100644
--- a/packages/api/src/router/index.ts
+++ b/packages/api/src/router/index.ts
@@ -15,6 +15,7 @@ import { effortRuleRouter } from "./effort-rule.js";
import { experienceMultiplierRouter } from "./experience-multiplier.js";
import { estimateRouter } from "./estimate.js";
import { entitlementRouter } from "./entitlement.js";
+import { holidayCalendarRouter } from "./holiday-calendar.js";
import { importExportRouter } from "./import-export.js";
import { insightsRouter } from "./insights.js";
import { managementLevelRouter } from "./management-level.js";
@@ -55,6 +56,7 @@ export const appRouter = createTRPCRouter({
insights: insightsRouter,
vacation: vacationRouter,
entitlement: entitlementRouter,
+ holidayCalendar: holidayCalendarRouter,
notification: notificationRouter,
settings: settingsRouter,
country: countryRouter,
diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts
index 1f474a1..33e3c73 100644
--- a/packages/api/src/router/project.ts
+++ b/packages/api/src/router/project.ts
@@ -2,6 +2,7 @@ import {
countPlanningEntries,
listAssignmentBookings,
} from "@capakraken/application";
+import type { WeekdayAvailability } from "@capakraken/shared";
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -17,6 +18,10 @@ import { generateGeminiImage, isGeminiConfigured, parseGeminiError } from "../ge
import { invalidateDashboardCache } from "../lib/cache.js";
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
import { validateImageDataUrl } from "../lib/image-validation.js";
+import {
+ calculateEffectiveBookedHours,
+ loadResourceDailyAvailabilityContexts,
+} from "../lib/resource-capacity.js";
const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload)
@@ -127,20 +132,53 @@ export const projectRouter = createTRPCRouter({
const assignments = await ctx.db.assignment.findMany({
where: { projectId: input.projectId, status: { not: "CANCELLED" } },
- include: { resource: { include: { country: { select: { code: true } } } } },
+ include: {
+ resource: {
+ include: {
+ country: { select: { id: true, code: true } },
+ metroCity: { select: { id: true, name: true } },
+ },
+ },
+ },
});
+ const periodStart = assignments.length > 0
+ ? new Date(Math.min(...assignments.map((assignment) => assignment.startDate.getTime())))
+ : new Date();
+ const periodEnd = assignments.length > 0
+ ? new Date(Math.max(...assignments.map((assignment) => assignment.endDate.getTime())))
+ : new Date();
+ const contexts = await loadResourceDailyAvailabilityContexts(
+ ctx.db,
+ assignments.map((assignment) => ({
+ id: assignment.resource.id,
+ availability: assignment.resource.availability as unknown as WeekdayAvailability,
+ countryId: assignment.resource.country?.id ?? assignment.resource.countryId,
+ countryCode: assignment.resource.country?.code,
+ federalState: assignment.resource.federalState,
+ metroCityId: assignment.resource.metroCity?.id ?? assignment.resource.metroCityId,
+ metroCityName: assignment.resource.metroCity?.name,
+ })),
+ periodStart,
+ periodEnd,
+ );
const mapped: ShoringAssignment[] = assignments.map((a) => {
- const start = new Date(a.startDate);
- const end = new Date(a.endDate);
- const diffMs = end.getTime() - start.getTime();
- const diffDays = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24)) + 1);
- const workingDays = Math.round(diffDays / 7 * 5);
+ const workingDays = a.hoursPerDay > 0
+ ? calculateEffectiveBookedHours({
+ availability: a.resource.availability as unknown as WeekdayAvailability,
+ startDate: a.startDate,
+ endDate: a.endDate,
+ hoursPerDay: a.hoursPerDay,
+ periodStart,
+ periodEnd,
+ context: contexts.get(a.resourceId ?? a.resource.id),
+ }) / a.hoursPerDay
+ : 0;
return {
resourceId: a.resourceId,
countryCode: a.resource.country?.code ?? null,
hoursPerDay: a.hoursPerDay,
- workingDays: Math.max(1, workingDays),
+ workingDays: Math.max(0, workingDays),
};
});
diff --git a/packages/api/src/router/report.ts b/packages/api/src/router/report.ts
index 2060b37..5cf1faf 100644
--- a/packages/api/src/router/report.ts
+++ b/packages/api/src/router/report.ts
@@ -1,6 +1,20 @@
-import { z } from "zod";
+import { Prisma } from "@capakraken/db";
+import {
+ isChargeabilityActualBooking,
+ isChargeabilityRelevantProject,
+ listAssignmentBookings,
+} from "@capakraken/application";
+import type { WeekdayAvailability } from "@capakraken/shared";
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
+import {
+ calculateEffectiveAvailableHours,
+ calculateEffectiveBookedHours,
+ countEffectiveWorkingDays,
+ getAvailabilityHoursForDate,
+ loadResourceDailyAvailabilityContexts,
+} from "../lib/resource-capacity.js";
import { TRPCError } from "@trpc/server";
+import { z } from "zod";
// ─── Column Definitions ──────────────────────────────────────────────────────
@@ -30,6 +44,7 @@ const RESOURCE_COLUMNS: ColumnDef[] = [
{ key: "departed", label: "Departed", dataType: "boolean" },
{ key: "postalCode", label: "Postal Code", dataType: "string" },
{ key: "federalState", label: "Federal State", dataType: "string" },
+ { key: "country.code", label: "Country Code", dataType: "string", prismaPath: "country" },
{ key: "country.name", label: "Country", dataType: "string", prismaPath: "country" },
{ key: "metroCity.name", label: "Metro City", dataType: "string", prismaPath: "metroCity" },
{ key: "orgUnit.name", label: "Org Unit", dataType: "string", prismaPath: "orgUnit" },
@@ -49,6 +64,7 @@ const PROJECT_COLUMNS: ColumnDef[] = [
{ key: "status", label: "Status", dataType: "string" },
{ key: "winProbability", label: "Win Probability (%)", dataType: "number" },
{ key: "budgetCents", label: "Budget (cents)", dataType: "number" },
+ { key: "clientId", label: "Client ID", dataType: "string" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "responsiblePerson", label: "Responsible Person", dataType: "string" },
@@ -61,10 +77,19 @@ const PROJECT_COLUMNS: ColumnDef[] = [
const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "id", label: "ID", dataType: "string" },
+ { key: "resourceId", label: "Resource ID", dataType: "string" },
+ { key: "projectId", label: "Project ID", dataType: "string" },
{ key: "resource.displayName", label: "Resource", dataType: "string", prismaPath: "resource" },
{ key: "resource.eid", label: "Resource EID", dataType: "string", prismaPath: "resource" },
+ { key: "resource.chapter", label: "Resource Chapter", dataType: "string", prismaPath: "resource" },
+ { key: "resource.country.code", label: "Resource Country Code", dataType: "string", prismaPath: "resource" },
+ { key: "resource.federalState", label: "Resource State", dataType: "string", prismaPath: "resource" },
+ { key: "resource.country.name", label: "Resource Country", dataType: "string", prismaPath: "resource" },
+ { key: "resource.metroCity.name", label: "Resource City", dataType: "string", prismaPath: "resource" },
{ key: "project.name", label: "Project", dataType: "string", prismaPath: "project" },
{ key: "project.shortCode", label: "Project Code", dataType: "string", prismaPath: "project" },
+ { key: "project.status", label: "Project Status", dataType: "string", prismaPath: "project" },
+ { key: "project.client.name", label: "Project Client", dataType: "string", prismaPath: "project" },
{ key: "startDate", label: "Start Date", dataType: "date" },
{ key: "endDate", label: "End Date", dataType: "date" },
{ key: "hoursPerDay", label: "Hours/Day", dataType: "number" },
@@ -77,10 +102,55 @@ const ASSIGNMENT_COLUMNS: ColumnDef[] = [
{ key: "updatedAt", label: "Updated At", dataType: "date" },
];
+const RESOURCE_MONTH_COLUMNS: ColumnDef[] = [
+ { key: "id", label: "Row ID", dataType: "string" },
+ { key: "resourceId", label: "Resource ID", dataType: "string" },
+ { key: "monthKey", label: "Month", dataType: "string" },
+ { key: "periodStart", label: "Period Start", dataType: "date" },
+ { key: "periodEnd", label: "Period End", dataType: "date" },
+ { key: "eid", label: "Employee ID", dataType: "string" },
+ { key: "displayName", label: "Name", dataType: "string" },
+ { key: "email", label: "Email", dataType: "string" },
+ { key: "chapter", label: "Chapter", dataType: "string" },
+ { key: "resourceType", label: "Resource Type", dataType: "string" },
+ { key: "isActive", label: "Active", dataType: "boolean" },
+ { key: "chgResponsibility", label: "Chg Responsibility", dataType: "boolean" },
+ { key: "rolledOff", label: "Rolled Off", dataType: "boolean" },
+ { key: "departed", label: "Departed", dataType: "boolean" },
+ { key: "countryCode", label: "Country Code", dataType: "string" },
+ { key: "countryName", label: "Country", dataType: "string" },
+ { key: "federalState", label: "Federal State", dataType: "string" },
+ { key: "metroCityName", label: "Metro City", dataType: "string" },
+ { key: "orgUnitName", label: "Org Unit", dataType: "string" },
+ { key: "managementLevelGroupName", label: "Mgmt Level Group", dataType: "string" },
+ { key: "managementLevelName", label: "Mgmt Level", dataType: "string" },
+ { key: "fte", label: "FTE", dataType: "number" },
+ { key: "lcrCents", label: "LCR (cents)", dataType: "number" },
+ { key: "ucrCents", label: "UCR (cents)", dataType: "number" },
+ { key: "currency", label: "Currency", dataType: "string" },
+ { key: "monthlyChargeabilityTargetPct", label: "Target Chargeability (%)", dataType: "number" },
+ { key: "monthlyTargetHours", label: "Target Hours", dataType: "number" },
+ { key: "monthlyBaseWorkingDays", label: "Base Working Days", dataType: "number" },
+ { key: "monthlyEffectiveWorkingDays", label: "Effective Working Days", dataType: "number" },
+ { key: "monthlyBaseAvailableHours", label: "Base Available Hours", dataType: "number" },
+ { key: "monthlySahHours", label: "SAH", dataType: "number" },
+ { key: "monthlyPublicHolidayCount", label: "Holiday Dates", dataType: "number" },
+ { key: "monthlyPublicHolidayWorkdayCount", label: "Holiday Workdays", dataType: "number" },
+ { key: "monthlyPublicHolidayHoursDeduction", label: "Holiday Hours Deduction", dataType: "number" },
+ { key: "monthlyAbsenceDayEquivalent", label: "Absence Day Equivalent", dataType: "number" },
+ { key: "monthlyAbsenceHoursDeduction", label: "Absence Hours Deduction", dataType: "number" },
+ { key: "monthlyActualBookedHours", label: "Actual Booked Hours", dataType: "number" },
+ { key: "monthlyExpectedBookedHours", label: "Expected Booked Hours", dataType: "number" },
+ { key: "monthlyActualChargeabilityPct", label: "Actual Chargeability (%)", dataType: "number" },
+ { key: "monthlyExpectedChargeabilityPct", label: "Expected Chargeability (%)", dataType: "number" },
+ { key: "monthlyUnassignedHours", label: "Unassigned Hours", dataType: "number" },
+];
+
const COLUMN_MAP: Record = {
resource: RESOURCE_COLUMNS,
project: PROJECT_COLUMNS,
assignment: ASSIGNMENT_COLUMNS,
+ resource_month: RESOURCE_MONTH_COLUMNS,
};
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -89,6 +159,7 @@ const ENTITY_MAP = {
resource: "resource",
project: "project",
assignment: "assignment",
+ resource_month: "resource_month",
} as const;
type EntityKey = keyof typeof ENTITY_MAP;
@@ -110,6 +181,7 @@ const ALLOWED_SCALAR_FIELDS: Record> = {
"id", "startDate", "endDate", "hoursPerDay", "percentage",
"role", "dailyCostCents", "status", "createdAt", "updatedAt",
]),
+ resource_month: new Set(RESOURCE_MONTH_COLUMNS.map((column) => column.key)),
};
function getValidScalarField(entity: EntityKey, field: string): string | null {
@@ -132,15 +204,14 @@ function buildSelect(entity: EntityKey, columns: string[]): Record select: { country: { select: { name: true } } }
const relationName = def.prismaPath ?? colKey.split(".")[0]!;
- const fieldName = colKey.split(".").slice(1).join(".");
const existing = select[relationName];
- if (existing && typeof existing === "object" && existing !== null && "select" in existing) {
- (existing as { select: Record }).select[fieldName] = true;
- } else {
- select[relationName] = { select: { [fieldName]: true } };
- }
+ const fieldSegments = colKey.split(".").slice(1);
+ const relationSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
+ ? (existing as { select: Record }).select
+ : {};
+ mergeSelectPath(relationSelect, fieldSegments);
+ select[relationName] = { select: relationSelect };
} else {
select[colKey] = true;
}
@@ -149,6 +220,29 @@ function buildSelect(entity: EntityKey, columns: string[]): Record,
+ segments: string[],
+): void {
+ const [head, ...tail] = segments;
+ if (!head) {
+ return;
+ }
+
+ if (tail.length === 0) {
+ target[head] = true;
+ return;
+ }
+
+ const existing = target[head];
+ const nestedSelect = existing && typeof existing === "object" && existing !== null && "select" in existing
+ ? (existing as { select: Record }).select
+ : {};
+
+ mergeSelectPath(nestedSelect, tail);
+ target[head] = { select: nestedSelect };
+}
+
/**
* Build a Prisma `where` from the filter array.
* Only scalar top-level fields are allowed for safety.
@@ -246,6 +340,8 @@ function csvEscape(value: unknown): string {
// ─── Input Schema ───────────────────────────────────────────────────────────
+const reportEntitySchema = z.enum(["resource", "project", "assignment", "resource_month"]);
+
const FilterSchema = z.object({
field: z.string().min(1),
op: z.enum(["eq", "neq", "gt", "lt", "gte", "lte", "contains", "in"]),
@@ -253,24 +349,171 @@ const FilterSchema = z.object({
});
const ReportInputSchema = z.object({
- entity: z.enum(["resource", "project", "assignment"]),
+ entity: reportEntitySchema,
columns: z.array(z.string()).min(1),
filters: z.array(FilterSchema).default([]),
groupBy: z.string().optional(),
sortBy: z.string().optional(),
sortDir: z.enum(["asc", "desc"]).default("asc"),
+ periodMonth: z.string().regex(/^\d{4}-\d{2}$/).optional(),
limit: z.number().int().min(1).max(5000).default(50),
offset: z.number().int().min(0).default(0),
});
+const ReportTemplateConfigSchema = ReportInputSchema.omit({ limit: true, offset: true });
+
+const ReportTemplateEntity = {
+ RESOURCE: "RESOURCE",
+ PROJECT: "PROJECT",
+ ASSIGNMENT: "ASSIGNMENT",
+ RESOURCE_MONTH: "RESOURCE_MONTH",
+} as const;
+
+type ReportTemplateEntity = (typeof ReportTemplateEntity)[keyof typeof ReportTemplateEntity];
+
+type ReportTemplateRecord = {
+ id: string;
+ name: string;
+ description: string | null;
+ entity: ReportTemplateEntity;
+ config: unknown;
+ isShared: boolean;
+ ownerId: string;
+ updatedAt: Date;
+};
+
+function getReportTemplateDelegate(db: unknown) {
+ return (db as {
+ reportTemplate: {
+ findMany: (args: unknown) => Promise;
+ findUnique: (args: unknown) => Promise<{ ownerId: string } | null>;
+ update: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
+ upsert: (args: unknown) => Promise<{ id: string; updatedAt: Date }>;
+ delete: (args: unknown) => Promise;
+ };
+ }).reportTemplate;
+}
+
// ─── Router ──────────────────────────────────────────────────────────────────
export const reportRouter = createTRPCRouter({
+ listTemplates: controllerProcedure.query(async ({ ctx }) => {
+ const reportTemplate = getReportTemplateDelegate(ctx.db);
+ const templates = await reportTemplate.findMany({
+ where: {
+ OR: [
+ { ownerId: ctx.dbUser!.id },
+ { isShared: true },
+ ],
+ },
+ orderBy: [{ name: "asc" }],
+ select: {
+ id: true,
+ name: true,
+ description: true,
+ entity: true,
+ config: true,
+ isShared: true,
+ ownerId: true,
+ updatedAt: true,
+ },
+ });
+
+ return templates.map((template: ReportTemplateRecord) => ({
+ id: template.id,
+ name: template.name,
+ description: template.description,
+ entity: fromTemplateEntity(template.entity),
+ config: ReportTemplateConfigSchema.parse(template.config),
+ isShared: template.isShared,
+ isOwner: template.ownerId === ctx.dbUser!.id,
+ updatedAt: template.updatedAt,
+ }));
+ }),
+
+ saveTemplate: controllerProcedure
+ .input(z.object({
+ id: z.string().optional(),
+ name: z.string().trim().min(1).max(120),
+ description: z.string().trim().max(500).optional(),
+ isShared: z.boolean().default(false),
+ config: ReportTemplateConfigSchema,
+ }))
+ .mutation(async ({ ctx, input }) => {
+ const reportTemplate = getReportTemplateDelegate(ctx.db);
+ const payload = input.config as unknown as Prisma.InputJsonValue;
+ const entity = toTemplateEntity(input.config.entity);
+
+ if (input.id) {
+ const existing = await reportTemplate.findUnique({
+ where: { id: input.id },
+ select: { ownerId: true },
+ });
+
+ if (!existing || existing.ownerId !== ctx.dbUser!.id) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be updated" });
+ }
+
+ return reportTemplate.update({
+ where: { id: input.id },
+ data: {
+ name: input.name,
+ description: input.description,
+ entity,
+ config: payload,
+ isShared: input.isShared,
+ },
+ select: { id: true, updatedAt: true },
+ });
+ }
+
+ return reportTemplate.upsert({
+ where: {
+ ownerId_name: {
+ ownerId: ctx.dbUser!.id,
+ name: input.name,
+ },
+ },
+ update: {
+ description: input.description,
+ entity,
+ config: payload,
+ isShared: input.isShared,
+ },
+ create: {
+ ownerId: ctx.dbUser!.id,
+ name: input.name,
+ description: input.description,
+ entity,
+ config: payload,
+ isShared: input.isShared,
+ },
+ select: { id: true, updatedAt: true },
+ });
+ }),
+
+ deleteTemplate: controllerProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const reportTemplate = getReportTemplateDelegate(ctx.db);
+ const existing = await reportTemplate.findUnique({
+ where: { id: input.id },
+ select: { ownerId: true },
+ });
+
+ if (!existing || existing.ownerId !== ctx.dbUser!.id) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "Template cannot be deleted" });
+ }
+
+ await reportTemplate.delete({ where: { id: input.id } });
+ return { ok: true };
+ }),
+
/**
* Return available columns for a given entity type.
*/
getAvailableColumns: controllerProcedure
- .input(z.object({ entity: z.enum(["resource", "project", "assignment"]) }))
+ .input(z.object({ entity: reportEntitySchema }))
.query(({ input }) => {
const columns = COLUMN_MAP[input.entity];
if (!columns) {
@@ -285,40 +528,7 @@ export const reportRouter = createTRPCRouter({
getReportData: controllerProcedure
.input(ReportInputSchema)
.query(async ({ ctx, input }) => {
- const { entity, columns, filters, sortBy, sortDir, limit, offset } = input;
-
- const select = buildSelect(entity, columns);
- const where = buildWhere(entity, filters);
-
- // Build orderBy (only scalar fields)
- let orderBy: Record | undefined;
- if (sortBy) {
- const validField = getValidScalarField(entity, sortBy);
- if (validField) {
- orderBy = { [validField]: sortDir };
- }
- }
-
- const modelDelegate = getModelDelegate(ctx.db, entity);
-
- const [rawRows, totalCount] = await Promise.all([
- (modelDelegate as any).findMany({
- select,
- where,
- ...(orderBy ? { orderBy } : {}),
- take: limit,
- skip: offset,
- }),
- (modelDelegate as any).count({ where }),
- ]);
-
- // Flatten nested relations into dot-notation keys
- const rows = (rawRows as Record[]).map((row) => flattenRow(row));
-
- // Ensure column order matches request (plus id)
- const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
-
- return { rows, columns: outputColumns, totalCount };
+ return executeReportQuery(ctx.db, input);
}),
/**
@@ -329,33 +539,12 @@ export const reportRouter = createTRPCRouter({
limit: z.number().int().min(1).max(50000).default(5000),
}))
.mutation(async ({ ctx, input }) => {
- const { entity, columns, filters, sortBy, sortDir, limit } = input;
-
- const select = buildSelect(entity, columns);
- const where = buildWhere(entity, filters);
-
- let orderBy: Record | undefined;
- if (sortBy) {
- const validField = getValidScalarField(entity, sortBy);
- if (validField) {
- orderBy = { [validField]: sortDir };
- }
- }
-
- const modelDelegate = getModelDelegate(ctx.db, entity);
-
- const rawRows = await (modelDelegate as any).findMany({
- select,
- where,
- ...(orderBy ? { orderBy } : {}),
- take: limit,
- });
-
- const rows = (rawRows as Record[]).map((row) => flattenRow(row));
- const outputColumns = ["id", ...columns.filter((c) => c !== "id")];
+ const result = await executeReportQuery(ctx.db, { ...input, offset: 0 });
+ const rows = result.rows;
+ const outputColumns = result.columns;
// Build CSV
- const entityColumns = COLUMN_MAP[entity];
+ const entityColumns = COLUMN_MAP[input.entity];
const headerLabels = outputColumns.map((key) => {
const def = entityColumns.find((c) => c.key === key);
return def?.label ?? key;
@@ -372,6 +561,385 @@ export const reportRouter = createTRPCRouter({
}),
});
+type ReportInput = z.infer;
+type FilterInput = z.infer