feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -1,3 +1,4 @@
import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEditor.js";
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
@@ -8,10 +9,40 @@ export default function AdminVacationsPage() {
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage public holidays, entitlements, and year summaries</p>
<p className="mt-1 text-sm text-gray-500">
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
</p>
</div>
<PublicHolidayBatch />
<EntitlementManager />
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
<p className="text-sm text-gray-600">
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
</p>
</div>
<HolidayCalendarEditor />
</section>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
<p className="text-sm text-gray-600">
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
</p>
</div>
<PublicHolidayBatch />
</section>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
<p className="text-sm text-gray-600">
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
</p>
</div>
<EntitlementManager />
</section>
</div>
);
}
@@ -33,6 +33,7 @@ import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
type ModalState =
@@ -85,68 +86,22 @@ function FilterDropdown({
tooltipContent?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
});
}, []);
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<HTMLDivElement>({
open: isOpen,
onClose: () => setIsOpen(false),
matchTriggerWidth: true,
});
return (
<div ref={dropdownRef} className="relative">
<div ref={triggerRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
onClick={() => {
const nextOpen = !isOpen;
handleOpenChange(nextOpen);
setIsOpen(nextOpen);
}}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
@@ -160,9 +115,9 @@ function FilterDropdown({
ref={panelRef}
style={{
position: "fixed",
top: panelPosition.top,
left: panelPosition.left,
minWidth: panelPosition.minWidth,
top: position.top,
left: position.left,
minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
+1 -2
View File
@@ -1,6 +1,5 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";
export default function GlobalError({
@@ -11,7 +10,7 @@ export default function GlobalError({
reset: () => void;
}) {
useEffect(() => {
Sentry.captureException(error);
console.error(error);
}, [error]);
return (
@@ -9,6 +9,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs",
useAssistantAdvancedTools: "Assistant Advanced Tools",
exportData: "Export Data",
importData: "Import Data",
approveVacations: "Approve Vacations",
@@ -24,6 +25,7 @@ const PERMISSION_LABELS: Record<string, string> = {
const PERMISSION_DESCRIPTIONS: Record<string, string> = {
viewCosts: "Access to cost data, budget views, and financial reports",
useAssistantAdvancedTools: "Unlocks advanced AI assistant workflows for complex cross-entity analyses",
exportData: "Export data to Excel, CSV, or PDF formats",
importData: "Import data from external sources (Dispo, Excel)",
approveVacations: "Approve or reject vacation requests",
@@ -97,6 +99,8 @@ export function SystemRolesClient() {
staleTime: 10_000,
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore TS2589: tRPC infers union type too deeply for the role config update payload
const updateMutation = trpc.systemRoleConfig.update.useMutation({
onSuccess: async () => {
await utils.systemRoleConfig.list.invalidate();
@@ -15,6 +15,7 @@ const ALL_PERMISSION_KEYS = Object.values(PermissionKey);
const PERMISSION_LABELS: Record<string, string> = {
viewCosts: "View Costs",
useAssistantAdvancedTools: "Assistant Advanced Tools",
exportData: "Export Data",
importData: "Import Data",
approveVacations: "Approve Vacations",
@@ -25,6 +26,7 @@ const PERMISSION_LABELS: Record<string, string> = {
manageAllocations: "Manage Allocations",
manageRoles: "Manage Roles",
manageUsers: "Manage Users",
viewScores: "View Scores",
};
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
@@ -11,6 +11,50 @@ import ComputationGraph3D from "~/components/analytics/ComputationGraph3D";
type Dimension = "2d" | "3d";
interface ResourceHolidayMeta {
date: string;
name: string;
scope: string;
calendarName: string | null;
}
interface ResourceFactorMeta {
weeklyAvailability?: Record<string, number>;
baseWorkingDays?: number;
effectiveWorkingDays?: number;
baseAvailableHours?: number;
effectiveAvailableHours?: number;
publicHolidayCount?: number;
publicHolidayWorkdayCount?: number;
publicHolidayHoursDeduction?: number;
absenceDayCount?: number;
absenceHoursDeduction?: number;
chargeableHours?: number;
utilizationPct?: number;
}
interface ResourceGraphMeta {
resourceName?: string;
resourceEid?: string;
month?: string;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
resolvedHolidays?: ResourceHolidayMeta[];
factors?: ResourceFactorMeta;
}
function formatNumber(value: number | undefined, digits = 1): string {
if (typeof value !== "number" || Number.isNaN(value)) {
return "—";
}
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value);
}
export default function ComputationGraphClient() {
const state = useComputationGraphData();
const [dimension, setDimension] = useState<Dimension>("2d");
@@ -24,10 +68,34 @@ export default function ComputationGraphClient() {
isLoading,
activeDomains,
graphData,
rawData,
highlightedNodes, setHighlightedNodes,
domainFilter, toggleDomain,
} = state;
const resourceMeta = viewMode === "resource"
? (rawData?.meta as ResourceGraphMeta | undefined)
: undefined;
const resourceFactors = resourceMeta?.factors;
const weeklyAvailabilityEntries: Array<[string, number | undefined]> = resourceFactors?.weeklyAvailability
? [
["Mo", resourceFactors.weeklyAvailability.monday],
["Di", resourceFactors.weeklyAvailability.tuesday],
["Mi", resourceFactors.weeklyAvailability.wednesday],
["Do", resourceFactors.weeklyAvailability.thursday],
["Fr", resourceFactors.weeklyAvailability.friday],
["Sa", resourceFactors.weeklyAvailability.saturday],
["So", resourceFactors.weeklyAvailability.sunday],
]
: [];
const weeklyAvailability = resourceFactors?.weeklyAvailability
? weeklyAvailabilityEntries
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && entry[1] > 0)
.map(([label, hours]) => `${label} ${formatNumber(hours, 1)}h`)
.join(" · ")
: "—";
const topHolidays = resourceMeta?.resolvedHolidays?.slice(0, 6) ?? [];
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
{/* ── Header Bar ── */}
@@ -173,6 +241,102 @@ export default function ComputationGraphClient() {
<ComputationGraph3D state={state} />
)}
</div>
{viewMode === "resource" && resourceMeta && (
<aside className="w-[24rem] overflow-y-auto border-l border-zinc-200 bg-white/90 p-4 dark:border-zinc-700 dark:bg-zinc-950/90">
<div className="space-y-4">
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Bezugsgroessen</div>
<div className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{resourceMeta.resourceName ?? "Resource"}
</div>
<div className="text-sm text-zinc-500">{resourceMeta.resourceEid ?? "—"} · {resourceMeta.month ?? month}</div>
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Land</div>
<div>{resourceMeta.countryName ?? resourceMeta.countryCode ?? "—"}{resourceMeta.countryCode ? ` (${resourceMeta.countryCode})` : ""}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Bundesland / Region</div>
<div>{resourceMeta.federalState ?? "—"}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Ort / Metro</div>
<div>{resourceMeta.metroCityName ?? "—"}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Wochenverfuegbarkeit</div>
<div>{weeklyAvailability}</div>
</div>
</div>
</section>
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Feiertagsbasis</div>
<div className="text-xs text-zinc-500">
{resourceFactors?.publicHolidayCount ?? 0} Feiertage, {resourceFactors?.publicHolidayWorkdayCount ?? 0} wirksam
</div>
</div>
<div className="mt-3 space-y-2">
{topHolidays.length > 0 ? topHolidays.map((holiday) => (
<div
key={`${holiday.date}-${holiday.name}`}
className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-950"
>
<div className="font-medium text-zinc-900 dark:text-zinc-100">{holiday.name}</div>
<div className="text-xs text-zinc-500">
{holiday.date} · {holiday.scope} · {holiday.calendarName ?? "Kalender"}
</div>
</div>
)) : (
<div className="rounded-lg border border-dashed border-zinc-200 px-3 py-2 text-sm text-zinc-500 dark:border-zinc-800">
Keine aufgeloesten Feiertage im gewaehlten Monat.
</div>
)}
</div>
</section>
<section className="rounded-xl border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-800 dark:bg-zinc-900">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">Herleitung</div>
<div className="mt-3 space-y-2">
<div className="rounded-lg bg-white px-3 py-2 text-sm dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">SAH Formel</div>
<div className="font-medium text-zinc-900 dark:text-zinc-100">
{formatNumber(resourceFactors?.baseAvailableHours)}h - {formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h - {formatNumber(resourceFactors?.absenceHoursDeduction)}h = {formatNumber(resourceFactors?.effectiveAvailableHours)}h
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Basistage</div>
<div>{formatNumber(resourceFactors?.baseWorkingDays, 0)}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Effektive Tage</div>
<div>{formatNumber(resourceFactors?.effectiveWorkingDays, 0)}</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Feiertagsabzug</div>
<div>{formatNumber(resourceFactors?.publicHolidayHoursDeduction)}h</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Abwesenheitsabzug</div>
<div>{formatNumber(resourceFactors?.absenceHoursDeduction)}h</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Chargeable Hours</div>
<div>{formatNumber(resourceFactors?.chargeableHours)}h</div>
</div>
<div className="rounded-lg bg-white px-3 py-2 dark:bg-zinc-950">
<div className="text-xs uppercase text-zinc-500">Auslastung</div>
<div>{formatNumber(resourceFactors?.utilizationPct)}%</div>
</div>
</div>
</div>
</section>
</div>
</aside>
)}
</div>
</div>
);
@@ -6,12 +6,19 @@ import {
RESOURCE_VIEW_DOMAINS,
PROJECT_VIEW_DOMAINS,
type Domain,
type GraphLink,
type GraphNode,
} from "./domain-colors";
import { buildForceGraphData, getConnectedNodeIds, type PositionedNode, type ForceGraphData } from "./graph-data";
export type ViewMode = "resource" | "project";
export interface ComputationGraphResponse {
nodes: GraphNode[];
links: GraphLink[];
meta?: Record<string, unknown>;
}
export interface ComputationGraphState {
viewMode: ViewMode;
setViewMode: (m: ViewMode) => void;
@@ -26,6 +33,7 @@ export interface ComputationGraphState {
isLoading: boolean;
activeDomains: Domain[];
graphData: ForceGraphData;
rawData: ComputationGraphResponse | null;
highlightedNodes: Set<string> | null;
setHighlightedNodes: (s: Set<string> | null) => void;
hoveredNode: PositionedNode | null;
@@ -144,6 +152,7 @@ export function useComputationGraphData(): ComputationGraphState {
isLoading,
activeDomains,
graphData,
rawData: (rawData as ComputationGraphResponse | undefined) ?? null,
highlightedNodes,
setHighlightedNodes,
hoveredNode,
+100 -21
View File
@@ -1,16 +1,33 @@
"use client";
import { useMemo } from "react";
import { clsx } from "clsx";
interface AssistantInsightMetric {
label: string;
value: string;
tone?: "neutral" | "good" | "warn" | "danger" | "info";
}
interface AssistantInsightSection {
title: string;
metrics: AssistantInsightMetric[];
}
interface AssistantInsight {
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
title: string;
subtitle?: string;
metrics: AssistantInsightMetric[];
sections?: AssistantInsightSection[];
}
interface ChatMessageProps {
role: "user" | "assistant";
content: string;
insights?: AssistantInsight[];
}
/**
* Lightweight inline markdown renderer — handles bold, italic, code,
* bullet lists, and numbered lists without a full markdown library.
*/
function renderMarkdown(text: string) {
const lines = text.split("\n");
const elements: React.ReactNode[] = [];
@@ -21,7 +38,7 @@ function renderMarkdown(text: string) {
if (listItems.length > 0 && listType) {
const Tag = listType;
elements.push(
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "list-disc pl-4 my-1 space-y-0.5" : "list-decimal pl-4 my-1 space-y-0.5"}>
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "my-1 list-disc space-y-0.5 pl-4" : "my-1 list-decimal space-y-0.5 pl-4"}>
{listItems}
</Tag>,
);
@@ -31,7 +48,6 @@ function renderMarkdown(text: string) {
};
for (const [i, line] of lines.entries()) {
// Bullet list: "- item" or "* item"
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
if (bulletMatch?.[1]) {
if (listType !== "ul") flushList();
@@ -40,7 +56,6 @@ function renderMarkdown(text: string) {
continue;
}
// Numbered list: "1. item"
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
if (numMatch?.[1]) {
if (listType !== "ol") flushList();
@@ -49,54 +64,46 @@ function renderMarkdown(text: string) {
continue;
}
// Not a list item — flush any pending list
flushList();
// Empty line → spacing
if (line.trim() === "") {
elements.push(<div key={`br-${i}`} className="h-2" />);
continue;
}
// Regular paragraph
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
}
flushList();
return elements;
}
/** Parse inline formatting: **bold**, *italic*, `code` */
function inlineFormat(text: string): React.ReactNode {
// Split by inline patterns, preserving delimiters
const parts: React.ReactNode[] = [];
// Regex: **bold**, *italic*, `code`
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Text before this match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[2]) {
// **bold**
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) {
// *italic*
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
} else if (match[4]) {
// `code`
parts.push(
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
{match[4]}
</code>,
);
}
lastIndex = match.index + match[0].length;
}
// Remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
@@ -104,7 +111,72 @@ function inlineFormat(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function ChatMessage({ role, content }: ChatMessageProps) {
function metricToneClasses(tone: AssistantInsightMetric["tone"] | undefined): string {
switch (tone) {
case "good":
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300";
case "warn":
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300";
case "danger":
return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300";
case "info":
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300";
default:
return "border-gray-200 bg-white text-gray-700 dark:border-slate-700 dark:bg-slate-900/60 dark:text-gray-200";
}
}
function InsightMetric({ metric }: { metric: AssistantInsightMetric }) {
return (
<div className={clsx("rounded-xl border px-2.5 py-2", metricToneClasses(metric.tone))}>
<div className="text-[10px] font-medium uppercase tracking-[0.08em] opacity-70">{metric.label}</div>
<div className="mt-1 text-sm font-semibold leading-tight">{metric.value}</div>
</div>
);
}
function InsightCard({ insight }: { insight: AssistantInsight }) {
return (
<div className="rounded-2xl border border-slate-200 bg-white/90 p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/85">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{insight.title}</div>
{insight.subtitle && (
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{insight.subtitle}</div>
)}
</div>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
{insight.kind.replace("_", " ")}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{insight.metrics.map((metric, index) => (
<InsightMetric key={`${insight.kind}-${metric.label}-${index}`} metric={metric} />
))}
</div>
{insight.sections && insight.sections.length > 0 && (
<div className="mt-3 space-y-2">
{insight.sections.map((section, sectionIndex) => (
<div key={`${insight.kind}-${section.title}-${sectionIndex}`} className="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 p-2.5 dark:border-slate-700 dark:bg-slate-800/60">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
{section.title}
</div>
<div className="grid grid-cols-2 gap-2">
{section.metrics.map((metric, metricIndex) => (
<InsightMetric key={`${section.title}-${metric.label}-${metricIndex}`} metric={metric} />
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
export function ChatMessage({ role, content, insights }: ChatMessageProps) {
const isUser = role === "user";
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]);
@@ -121,12 +193,19 @@ export function ChatMessage({ role, content }: ChatMessageProps) {
<span className="whitespace-pre-wrap break-words">{content}</span>
) : (
<>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 mb-1.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span className="mb-1.5 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Generated
</span>
{insights && insights.length > 0 && (
<div className="mb-2 space-y-2">
{insights.map((insight, index) => (
<InsightCard key={`${insight.kind}-${insight.title}-${index}`} insight={insight} />
))}
</div>
)}
<div className="space-y-0.5 break-words">{rendered}</div>
</>
)}
@@ -38,6 +38,26 @@ function resolvePageContext(pathname: string): string {
interface Message {
role: "user" | "assistant";
content: string;
insights?: AssistantInsight[];
}
interface AssistantInsightMetric {
label: string;
value: string;
tone?: "neutral" | "good" | "warn" | "danger" | "info";
}
interface AssistantInsightSection {
title: string;
metrics: AssistantInsightMetric[];
}
interface AssistantInsight {
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
title: string;
subtitle?: string;
metrics: AssistantInsightMetric[];
sections?: AssistantInsightSection[];
}
const STORAGE_KEY = "capakraken-chat-messages";
@@ -47,7 +67,23 @@ function loadPersistedMessages(): Message[] {
if (typeof window === "undefined") return [];
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as Message[];
if (raw) {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return parsed
.filter((item): item is Partial<Message> & { role: Message["role"]; content: string } => (
typeof item === "object"
&& item !== null
&& (item.role === "user" || item.role === "assistant")
&& typeof item.content === "string"
))
.map((item) => ({
role: item.role,
content: item.content,
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
}));
}
}
} catch { /* ignore corrupt data */ }
return [];
}
@@ -101,10 +137,23 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
});
setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]);
const typedReply = reply as {
content: string;
role: "assistant";
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
insights?: AssistantInsight[];
};
setMessages((prev) => [
...prev,
{
role: "assistant",
content: typedReply.content,
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
},
]);
// Handle actions from the AI (navigation, data invalidation)
const actions = (reply as { actions?: Array<{ type: string; url?: string; scope?: string[] }> }).actions;
const actions = typedReply.actions;
if (actions) {
for (const action of actions) {
if (action.type === "navigate" && action.url) {
@@ -230,7 +279,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
</div>
)}
{messages.map((msg, i) => (
<ChatMessage key={i} role={msg.role} content={msg.content} />
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
{...(msg.insights ? { insights: msg.insights } : {})}
/>
))}
{isLoading && <TypingIndicator />}
{error && (
@@ -158,6 +158,12 @@ export function DashboardClient() {
<WidgetContainer
title={widget.title ?? getWidget(widget.type).label}
description={getWidget(widget.type).description}
showDetails={widget.config.showDetails === true}
onToggleDetails={() =>
updateWidgetConfig(widget.id, {
showDetails: widget.config.showDetails !== true,
})
}
onRemove={() => removeWidget(widget.id)}
>
{renderWidget(widget.type, widget.config, (update) =>
@@ -8,9 +8,19 @@ interface WidgetContainerProps {
onRemove: () => void;
children: React.ReactNode;
isDragging?: boolean;
showDetails?: boolean;
onToggleDetails?: () => void;
}
export function WidgetContainer({ title, description, onRemove, children, isDragging }: WidgetContainerProps) {
export function WidgetContainer({
title,
description,
onRemove,
children,
isDragging,
showDetails = false,
onToggleDetails,
}: WidgetContainerProps) {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
@@ -19,14 +29,12 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
className={`flex flex-col h-full rounded-xl border overflow-hidden transition-all duration-200 ${
isDragging
? "shadow-xl border-brand-400 dark:border-brand-500 scale-[1.01] ring-2 ring-brand-400/30"
: "bg-white dark:bg-gray-900 border-gray-200/80 dark:border-gray-700/60 shadow-sm hover:shadow-md hover:border-gray-300 dark:hover:border-gray-600"
: "border-gray-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95),rgba(255,255,255,0.98))] shadow-sm hover:shadow-md hover:border-gray-300 dark:border-gray-700/60 dark:bg-[linear-gradient(180deg,rgba(17,24,39,0.96),rgba(17,24,39,0.92))] dark:hover:border-gray-600"
}`}
>
{/* Header — clean, no background separation */}
<div className="flex items-center justify-between px-4 pt-3.5 pb-2 shrink-0 cursor-grab active:cursor-grabbing widget-drag-handle group">
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3 shrink-0 widget-drag-handle group">
<div className="min-w-0 flex-1 cursor-grab active:cursor-grabbing">
<div className="flex items-center gap-2">
{/* Drag grip dots */}
<svg
className="w-3.5 h-5 text-gray-300 dark:text-gray-600 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
viewBox="0 0 14 20"
@@ -39,32 +47,58 @@ export function WidgetContainer({ title, description, onRemove, children, isDrag
<circle cx="4" cy="16" r="1.5" />
<circle cx="10" cy="16" r="1.5" />
</svg>
<span className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{title}</span>
<span className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">
{title}
</span>
{showDetails ? (
<span className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-brand-700 dark:bg-brand-500/10 dark:text-brand-300">
Details
</span>
) : null}
</div>
{description && (
<p className="text-[11px] text-gray-400 dark:text-gray-500 truncate mt-0.5 ml-[22px]">{description}</p>
<p className="ml-[22px] mt-1 line-clamp-2 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{description}
</p>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="ml-2 p-1.5 text-gray-300 dark:text-gray-600 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors shrink-0 opacity-0 group-hover:opacity-100"
title="Remove widget"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-center gap-2 shrink-0">
{onToggleDetails ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleDetails();
}}
className={`rounded-xl border px-3 py-1.5 text-[11px] font-semibold transition ${
showDetails
? "border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 dark:border-brand-500/30 dark:bg-brand-500/10 dark:text-brand-300"
: "border-gray-200 bg-white/80 text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-400 dark:hover:text-gray-200"
}`}
title={showDetails ? "Hide details" : "Show details"}
>
{showDetails ? "Details on" : "Details off"}
</button>
) : null}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="rounded-lg p-1.5 text-gray-300 transition-colors hover:bg-red-50 hover:text-red-500 dark:text-gray-600 dark:hover:bg-red-950/30 dark:hover:text-red-400"
title="Remove widget"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Subtle separator */}
<div className="mx-4 border-t border-gray-100 dark:border-gray-800" />
<div className="mx-4 border-t border-gray-200/80 dark:border-gray-800" />
{/* Body */}
<div className="flex-1 overflow-auto p-4">{children}</div>
<div className="flex-1 overflow-auto p-4 pt-3">{children}</div>
</motion.div>
);
}
@@ -19,7 +19,67 @@ function textColorClass(pct: number): string {
return "text-green-700";
}
type BudgetForecastLocation = {
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
activeAssignmentCount?: number;
burnRateCents?: number;
};
type BudgetForecastRow = {
projectId?: string;
projectName: string;
shortCode: string;
clientId: string | null;
clientName: string | null;
budgetCents: number;
spentCents: number;
remainingCents?: number;
burnRate: number;
estimatedExhaustionDate: string | null;
pctUsed: number;
activeAssignmentCount?: number;
calendarLocations?: BudgetForecastLocation[];
};
function formatCurrency(cents: number | undefined): string {
if (cents === undefined) return "—";
return `${(cents / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} €`;
}
function formatLocation(location: BudgetForecastLocation): string {
const parts = [
location.countryCode ?? location.countryName ?? null,
location.federalState ?? null,
location.metroCityName ?? null,
].filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
function SummaryCard({
label,
value,
helper,
}: {
label: string;
value: string;
helper: string;
}) {
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/40">
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{label}
</div>
<div className="mt-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{value}</div>
<div className="mt-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">{helper}</div>
</div>
);
}
export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
@@ -39,7 +99,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
const clientId = (config.clientId as string) ?? "";
const rows = useMemo(() => {
const all = data ?? [];
const all = (data ?? []) as BudgetForecastRow[];
return all.filter((r) => {
if (search && !r.projectName.toLowerCase().includes(search) && !r.shortCode.toLowerCase().includes(search)) return false;
if (clientId && r.clientId !== clientId) return false;
@@ -47,6 +107,21 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
});
}, [data, search, clientId]);
const totals = useMemo(() => rows.reduce((acc, row) => {
acc.budgetCents += row.budgetCents;
acc.spentCents += row.spentCents;
acc.remainingCents += row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents);
acc.burnRate += row.burnRate;
acc.activeAssignmentCount += row.activeAssignmentCount ?? 0;
return acc;
}, {
budgetCents: 0,
spentCents: 0,
remainingCents: 0,
burnRate: 0,
activeAssignmentCount: 0,
}), [rows]);
if (isLoading && !data) {
return (
<div className="flex flex-col gap-1 pt-1">
@@ -75,6 +150,28 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
return (
<div className="flex flex-col h-full overflow-hidden">
<WidgetFilterBar filters={filters} values={config} onChange={onConfigChange ?? (() => {})} />
<div className="mb-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<SummaryCard
label="Projects"
value={String(rows.length)}
helper={`${totals.activeAssignmentCount} active assignments in scope`}
/>
<SummaryCard
label="Budget"
value={formatCurrency(totals.budgetCents)}
helper={`${formatCurrency(totals.spentCents)} spent`}
/>
<SummaryCard
label="Remaining"
value={formatCurrency(totals.remainingCents)}
helper={`${rows.filter((row) => row.remainingCents !== undefined && row.remainingCents <= 0).length} exhausted`}
/>
<SummaryCard
label="Burn / Month"
value={formatCurrency(totals.burnRate)}
helper="Holiday- and absence-adjusted active burn"
/>
</div>
<div className="overflow-auto flex-1">
<table className="w-full text-xs">
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
@@ -86,7 +183,7 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
Budget Usage <InfoTooltip content="Percentage of total budget consumed by current allocations" />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Burn/mo <InfoTooltip content="Monthly burn rate based on currently active allocations" />
Burn/mo <InfoTooltip content="Current-month burn rate based on active allocations, adjusted for regional holidays and approved absences where resource calendars are available." />
</th>
<th className="px-3 py-2 text-right font-medium text-gray-500 dark:text-gray-400">
Exhaustion <InfoTooltip content="Projected date when budget will be fully consumed at the current burn rate" />
@@ -96,11 +193,41 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[140px] truncate">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[260px] align-top">
<div>
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</div>
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{row.clientName ?? "No client"}
{!showDetails && row.calendarLocations && row.calendarLocations.length > 0
? ` · ${formatLocation(row.calendarLocations[0]!)}`
: ""}
</div>
{showDetails ? (
<div className="mt-1 space-y-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
<div>Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}</div>
</div>
<div className="flex flex-wrap gap-1">
{row.calendarLocations && row.calendarLocations.length > 0 ? (
row.calendarLocations.slice(0, 4).map((location) => (
<span
key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}
className="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 dark:border-gray-700 dark:bg-gray-900/70"
>
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
</span>
))
) : (
<span>No active calendar basis in the current month</span>
)}
</div>
</div>
) : null}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 align-top">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
@@ -112,14 +239,37 @@ export function BudgetForecastWidget({ config, onConfigChange }: WidgetProps) {
{row.pctUsed}%
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
{formatCurrency(row.spentCents)} / {formatCurrency(row.budgetCents)}
</div>
{showDetails ? (
<div className="text-[10px] leading-4 text-gray-500 dark:text-gray-400">
Remaining {formatCurrency(row.remainingCents ?? Math.max(0, row.budgetCents - row.spentCents))}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums">
{row.burnRate > 0
? `${(row.burnRate / 100).toLocaleString("de-DE", { maximumFractionDigits: 0 })} \u20AC`
: "\u2014"}
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-300 tabular-nums align-top">
<div>
{row.burnRate > 0 ? formatCurrency(row.burnRate) : "\u2014"}
</div>
{showDetails ? (
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
<div>{row.activeAssignmentCount ?? 0} active assignments</div>
{(row.calendarLocations ?? []).slice(0, 3).map((location) => (
<div key={`${location.countryCode ?? location.countryName ?? "na"}:${location.federalState ?? "na"}:${location.metroCityName ?? "na"}`}>
{formatLocation(location)} · {location.activeAssignmentCount ?? 0}x · {formatCurrency(location.burnRateCents)}
</div>
))}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums">
{row.estimatedExhaustionDate ?? "\u2014"}
<td className="px-3 py-2 text-right text-gray-500 dark:text-gray-400 tabular-nums align-top">
<div>{row.estimatedExhaustionDate ?? "\u2014"}</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
at {formatCurrency(row.burnRate)} / month
</div>
) : null}
</td>
</tr>
))}
@@ -36,8 +36,91 @@ type ChargeabilityRow = {
chargeabilityTarget: number;
actualChargeability: number;
expectedChargeability: number;
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
derivation?: {
weeklyAvailabilityHours: number;
baseWorkingDays: number;
effectiveWorkingDayEquivalent: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
publicHolidayCount: number;
publicHolidayWorkdayCount: number;
publicHolidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
actualBookedHours: number;
expectedBookedHours: number;
targetBookedHours: number;
unassignedHours: number;
};
};
function formatHours(value: number | undefined): string {
if (value === undefined) return "—";
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
}
function formatDayEquivalent(value: number | undefined): string {
if (value === undefined) return "—";
return Number.isInteger(value) ? `${value}` : value.toFixed(1);
}
function MetricPill({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2 py-0.5 text-[10px] font-medium text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<span className="text-gray-400 dark:text-gray-500">{label}</span>
<span className="text-gray-700 dark:text-gray-200">{value}</span>
</span>
);
}
function formatLocation(row: ChargeabilityRow): string {
const parts = [row.countryCode ?? row.countryName ?? null, row.federalState ?? null, row.metroCityName ?? null]
.filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
function ChargeabilityContextLine({ row }: { row: ChargeabilityRow }) {
const derivation = row.derivation;
if (!derivation) {
return null;
}
return (
<div className="mt-1.5 space-y-1.5 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
<div className="flex flex-wrap gap-1">
<MetricPill label="Loc" value={formatLocation(row)} />
<MetricPill label="Week" value={formatHours(derivation.weeklyAvailabilityHours)} />
<MetricPill label="Target" value={formatHours(derivation.targetBookedHours)} />
</div>
<div className="grid gap-x-3 gap-y-0.5 sm:grid-cols-2">
<div>
Days {formatDayEquivalent(derivation.baseWorkingDays)} {"->"} {formatDayEquivalent(derivation.effectiveWorkingDayEquivalent)}
</div>
<div>
Holidays {derivation.publicHolidayWorkdayCount}/{derivation.publicHolidayCount} ({formatHours(derivation.publicHolidayHoursDeduction)})
</div>
<div>
Base {formatHours(derivation.baseAvailableHours)} {"->"} Effective {formatHours(derivation.effectiveAvailableHours)}
</div>
<div>
Absence {formatDayEquivalent(derivation.absenceDayEquivalent)} ({formatHours(derivation.absenceHoursDeduction)})
</div>
<div>
Actual {formatHours(derivation.actualBookedHours)} · Expected {formatHours(derivation.expectedBookedHours)}
</div>
<div>
Free {formatHours(derivation.unassignedHours)}
</div>
</div>
</div>
);
}
function FilterDropdown({ label, children }: { label: string; children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
@@ -74,7 +157,13 @@ function FilterDropdown({ label, children }: { label: string; children: ReactNod
}
export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetProps) {
const config = _config as { topN?: number; watchlistThreshold?: number; chapter?: string; includeProposed?: boolean };
const config = _config as {
topN?: number;
watchlistThreshold?: number;
chapter?: string;
includeProposed?: boolean;
showDetails?: boolean;
};
const { chapters } = useWidgetFilterOptions();
const widgetFilters = useMemo<WidgetFilter[]>(
@@ -86,6 +175,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
);
const includeProposed = !!config.includeProposed;
const showDetails = !!config.showDetails;
const chapterFilter = (config.chapter as string) ?? "";
const [showDeparted, setShowDeparted] = useState(false);
const [selectedCountryIds, setSelectedCountryIds] = useState<string[]>([]);
@@ -266,7 +356,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
<p className="text-xs text-gray-400 flex items-center gap-1">
Period: {month}
<InfoTooltip
content="Chargeability is calculated for the current calendar month. Available hours are based on each person's weekly schedule (WeekdayAvailability). Watchlist threshold: 15 percentage points below target."
content="Chargeability is calculated for the current calendar month. Available hours are derived from each person's weekly schedule and reduced by regional public holidays plus approved absences. Watchlist threshold: 15 percentage points below target."
width="w-72"
/>
</p>
@@ -330,7 +420,7 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider px-1 mb-1 sticky top-0 bg-white flex items-center">
Top Chargeability
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours / total available hours." />
<InfoTooltip content="Resources ranked by highest actual chargeability this month. Chargeability = chargeable booked hours divided by holiday- and absence-adjusted available hours." />
<span className="ml-1 font-normal normal-case text-gray-400">
{visibleTop.length}/{top.length}
</span>
@@ -390,18 +480,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
{visibleTop.map((r, i) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
<td className="px-2 py-1 text-gray-400">{i + 1}</td>
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[120px]">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
<div className="truncate">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</div>
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
<UtilizationBar percent={r.actualChargeability} />
</td>
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400">
<AnimatedNumber value={r.actualChargeability} suffix="%" />
<td className="px-2 py-1 text-right font-semibold text-green-700 dark:text-green-400 align-top">
<div>
<AnimatedNumber value={r.actualChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
<td className="px-2 py-1 text-right text-gray-400">
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
<td className="px-2 py-1 text-right text-gray-400 align-top">
<div>
<AnimatedNumber value={r.expectedChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.expectedBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
</tr>
))}
@@ -473,18 +578,33 @@ export function ChargeabilityWidget({ config: _config, onConfigChange }: WidgetP
<tbody className="divide-y divide-gray-50">
{visibleWatchlist.map((r) => (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/40">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[140px]">
<td className="px-2 py-1 text-gray-800 dark:text-gray-200 max-w-[240px] align-top">
<div className="truncate">
<span title={r.displayName}>{r.displayName}</span>
{r.chapter && <span className="text-gray-400 ml-1">· {r.chapter}</span>}
</div>
{showDetails ? <ChargeabilityContextLine row={r} /> : null}
<UtilizationBar percent={r.actualChargeability} />
</td>
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400">
<AnimatedNumber value={r.actualChargeability} suffix="%" />
<td className="px-2 py-1 text-right font-semibold text-red-600 dark:text-red-400 align-top">
<div>
<AnimatedNumber value={r.actualChargeability} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] font-normal leading-4 text-gray-500 dark:text-gray-400">
{formatHours(r.derivation?.actualBookedHours)} / {formatHours(r.derivation?.effectiveAvailableHours)}
</div>
) : null}
</td>
<td className="px-2 py-1 text-right text-gray-400">
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
<td className="px-2 py-1 text-right text-gray-400 align-top">
<div>
<AnimatedNumber value={r.chargeabilityTarget} suffix="%" />
</div>
{showDetails ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500 dark:text-gray-400">
Target {formatHours(r.derivation?.targetBookedHours)} · Free {formatHours(r.derivation?.unassignedHours)}
</div>
) : null}
</td>
</tr>
))}
@@ -8,7 +8,53 @@ import { ProgressRing } from "~/components/ui/ProgressRing.js";
type GroupBy = "project" | "person" | "chapter";
type DemandRow = {
id: string;
name: string;
shortCode: string;
allocatedHours: number;
requiredFTEs: number;
resourceCount: number;
derivation?: {
periodStart: string;
periodEnd: string;
periodWorkingHoursBase: number;
requiredHours: number | null;
requiredFTEs: number;
fillPct: number | null;
demandSource: "DEMAND_REQUIREMENTS" | "PROJECT_STAFFING_REQS" | "NONE";
calendarLocations: Array<{
countryCode: string | null;
federalState: string | null;
metroCityName: string | null;
resourceCount: number;
allocatedHours: number;
}>;
};
};
type DemandDerivation = NonNullable<DemandRow["derivation"]>;
type DemandCalendarLocation = DemandDerivation["calendarLocations"][number];
function formatHours(value: number | null | undefined): string {
if (value == null) return "—";
return `${Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1)}h`;
}
function formatLocation(location: DemandCalendarLocation): string {
const parts = [location.countryCode, location.federalState, location.metroCityName]
.filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
function formatDemandSource(source: DemandDerivation["demandSource"] | undefined): string {
if (source === "DEMAND_REQUIREMENTS") return "Source: Demand requirements";
if (source === "PROJECT_STAFFING_REQS") return "Source: Project staffing reqs";
return "No demand basis";
}
export function DemandWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const groupBy = (config.groupBy as GroupBy) || "project";
type SortKey = "name" | "allocatedHours" | "requiredFTEs" | "resourceCount";
@@ -48,7 +94,7 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
);
}
const rows = data ?? [];
const rows = (data ?? []) as DemandRow[];
const sorted = [...rows].sort((a, b) => {
const mult = sortDir === "asc" ? 1 : -1;
@@ -144,37 +190,84 @@ export function DemandWidget({ config, onConfigChange }: WidgetProps) {
<tbody className="divide-y divide-gray-100">
{sorted.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900 max-w-[200px] truncate">
{groupBy === "project" ? (
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
) : (
row.name
)}
<td className="px-3 py-2 text-gray-900 max-w-[280px] align-top">
<div className="font-medium truncate">
{groupBy === "project" ? (
<span><span className="font-mono text-gray-500 mr-1">{row.shortCode}</span>{row.name}</span>
) : (
row.name
)}
</div>
{showDetails && groupBy === "project" && row.derivation ? (
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
<div>
{row.derivation.periodStart} to {row.derivation.periodEnd}
</div>
<div>
{row.derivation.calendarLocations.length > 0
? row.derivation.calendarLocations
.slice(0, 2)
.map((location) =>
`${formatLocation(location)} (${formatHours(location.allocatedHours)})`,
)
.join(" · ")
: "No location-based booking basis"}
</div>
{row.derivation.calendarLocations.length > 2 ? (
<div>+ {row.derivation.calendarLocations.length - 2} more calendar contexts</div>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-2 text-right align-top">
<div className="text-gray-700">{row.allocatedHours}h</div>
{showDetails && groupBy === "project" && row.derivation ? (
<div className="mt-1 space-y-0.5 text-[10px] leading-4 text-gray-500">
<div>{row.derivation.calendarLocations.length} calendar basis{row.derivation.calendarLocations.length === 1 ? "" : "es"}</div>
<div>{row.resourceCount} resource{row.resourceCount === 1 ? "" : "s"} in scope</div>
</div>
) : null}
</td>
<td className="px-3 py-2 text-right text-gray-700">{row.allocatedHours}h</td>
{groupBy === "project" && (
<td className="px-3 py-2 text-right text-gray-700">
<td className="px-3 py-2 text-right align-top text-gray-700">
{(() => {
const ftes = row.requiredFTEs as unknown as number;
if (ftes <= 0) return "—";
const requiredHours = ftes * 22 * 3 * 8;
const fillPct = Math.min(100, Math.round((row.allocatedHours / requiredHours) * 100));
const isBelowTarget = row.allocatedHours / 8 < ftes * 22 * 3;
const requiredHours = row.derivation?.requiredHours ?? null;
const rawFillPct = row.derivation?.fillPct ?? null;
const fillPct = Math.min(100, rawFillPct ?? 0);
const isBelowTarget = rawFillPct !== null ? rawFillPct < 100 : false;
const ringColor = isBelowTarget
? "var(--color-red-500, #ef4444)"
: "var(--color-green-500, #22c55e)";
return (
<span className="inline-flex items-center gap-1.5">
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE
<div className="inline-flex flex-col items-end gap-1">
<span className="inline-flex items-center gap-1.5">
<ProgressRing value={fillPct} size={22} strokeWidth={2.5} color={ringColor} />
<span className={isBelowTarget ? "text-red-600 font-semibold" : "text-green-700"}>
{ftes} FTE
</span>
</span>
</span>
{showDetails ? (
<div className="space-y-0.5 text-[10px] leading-4 text-gray-500">
<div>{formatHours(row.allocatedHours)} / {formatHours(requiredHours)}</div>
<div>{rawFillPct == null ? "—" : `${rawFillPct}% coverage`} · {formatHours(row.derivation?.periodWorkingHoursBase)} per 1.0 FTE</div>
<div>{formatDemandSource(row.derivation?.demandSource)}</div>
</div>
) : null}
</div>
);
})()}
</td>
)}
<td className="px-3 py-2 text-right text-gray-500">{row.resourceCount}</td>
<td className="px-3 py-2 text-right align-top text-gray-500">
<div>{row.resourceCount}</div>
{showDetails && groupBy === "project" && row.derivation?.calendarLocations.length ? (
<div className="mt-1 text-[10px] leading-4 text-gray-500">
{row.derivation.calendarLocations.reduce((sum, location) => sum + location.resourceCount, 0)} resource entries across locations
</div>
) : null}
</td>
</tr>
))}
</tbody>
@@ -1,55 +1,172 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Legend,
} from "recharts";
import { useMemo, useState } from "react";
const COLORS = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#84cc16", "#f97316", "#ec4899", "#14b8a6",
];
type PeakTimesChartRow = {
period: string;
label: string;
bookedHours: number;
capacityHours: number;
utilizationPct: number;
remainingHours: number;
overbookedHours: number;
isCurrentPeriod: boolean;
};
interface PeakTimesChartProps {
chartData: Record<string, number | string>[];
groups: string[];
rows: PeakTimesChartRow[];
selectedPeriod: string | null;
onSelectedPeriodChange?: (period: string) => void;
}
export default function PeakTimesChart({ chartData, groups }: PeakTimesChartProps) {
if (chartData.length === 0) {
function formatHours(value: number): string {
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
}
function utilizationBarTone(utilizationPct: number): string {
if (utilizationPct > 100) return "bg-red-500";
if (utilizationPct > 75) return "bg-emerald-500";
if (utilizationPct >= 50) return "bg-amber-400";
return "bg-rose-400";
}
function utilizationTextTone(utilizationPct: number): string {
if (utilizationPct > 100) return "text-red-600 dark:text-red-300";
if (utilizationPct > 75) return "text-emerald-600 dark:text-emerald-300";
if (utilizationPct >= 50) return "text-amber-600 dark:text-amber-300";
return "text-rose-600 dark:text-rose-300";
}
export default function PeakTimesChart({
rows,
selectedPeriod,
onSelectedPeriodChange,
}: PeakTimesChartProps) {
const [hoveredPeriod, setHoveredPeriod] = useState<string | null>(null);
const fallbackPeriod = selectedPeriod && rows.some((row) => row.period === selectedPeriod)
? selectedPeriod
: rows[0]?.period ?? null;
const activePeriod = hoveredPeriod ?? fallbackPeriod;
const activeRow = useMemo(
() => rows.find((row) => row.period === activePeriod) ?? rows[0] ?? null,
[activePeriod, rows],
);
const chartMaxPct = useMemo(() => {
const maxUtilization = Math.max(100, ...rows.map((row) => row.utilizationPct));
return Math.max(120, Math.ceil(maxUtilization / 20) * 20);
}, [rows]);
const tickValues = useMemo(() => {
const base = [0, 50, 100];
return chartMaxPct > 100 ? [...base, chartMaxPct] : base;
}, [chartMaxPct]);
const referenceLineBottom = (100 / chartMaxPct) * 100;
if (rows.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-gray-400">
No allocation data in selected period.
<div className="flex h-full items-center justify-center rounded-[22px] border border-dashed border-slate-200 bg-slate-50/80 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
No allocation data in the selected horizon.
</div>
);
}
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="period" tick={{ fontSize: 10 }} />
<YAxis tick={{ fontSize: 10 }} />
<Tooltip contentStyle={{ fontSize: 11 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<ReferenceLine
{...({ dataKey: "capacity" } as any)}
stroke="#ef4444"
strokeDasharray="5 5"
label={{ value: "Capacity", fontSize: 10, fill: "#ef4444" }}
/>
{groups.map((g, i) => (
<Bar key={g} dataKey={g} stackId="a" fill={COLORS[i % COLORS.length]} />
))}
</BarChart>
</ResponsiveContainer>
<div className="flex h-full min-h-[15rem] flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Overall Utilization
</div>
{activeRow ? (
<div className="min-w-0 text-right">
<div className={`truncate text-sm font-semibold ${utilizationTextTone(activeRow.utilizationPct)}`}>
{activeRow.label} · {activeRow.utilizationPct}%
</div>
<div className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{formatHours(activeRow.bookedHours)}h / {formatHours(activeRow.capacityHours)}h
</div>
</div>
) : null}
</div>
<div className="mt-3 flex min-h-[12rem] flex-1 gap-2">
<div className="flex w-8 shrink-0 flex-col justify-between pb-6 text-right text-[9px] font-medium text-slate-400">
{[...tickValues].reverse().map((tick) => (
<span key={tick}>{tick}%</span>
))}
</div>
<div className="relative min-w-0 flex-1">
<div className="pointer-events-none absolute inset-0 bottom-6">
{[...tickValues].reverse().map((tick) => {
const bottom = (tick / chartMaxPct) * 100;
return (
<div
key={tick}
className="absolute left-0 right-0 border-t border-dashed border-slate-200/80 dark:border-slate-700/50"
style={{ bottom: `${bottom}%` }}
/>
);
})}
<div
className="absolute left-0 right-0 border-t border-slate-300/90 dark:border-slate-500/80"
style={{ bottom: `${referenceLineBottom}%` }}
/>
</div>
<div
className="grid h-full items-end gap-1.5 pb-6 sm:gap-2"
style={{ gridTemplateColumns: `repeat(${rows.length}, minmax(0, 1fr))` }}
>
{rows.map((row) => {
const height = Math.min((row.utilizationPct / chartMaxPct) * 100, 100);
const isActive = row.period === activePeriod;
const isPinned = row.period === fallbackPeriod;
return (
<button
key={row.period}
type="button"
className="group flex h-full min-w-0 flex-col items-center rounded-2xl px-1 text-left transition-colors"
title={`${row.label}: ${row.utilizationPct}% utilization, ${formatHours(row.bookedHours)}h booked, ${formatHours(row.capacityHours)}h capacity, ${formatHours(row.remainingHours)}h free, ${formatHours(row.overbookedHours)}h overbooked`}
onMouseEnter={() => setHoveredPeriod(row.period)}
onMouseLeave={() => setHoveredPeriod((current) => (current === row.period ? null : current))}
onClick={() => onSelectedPeriodChange?.(row.period)}
style={{
backgroundColor: isPinned
? "rgba(14, 165, 233, 0.08)"
: isActive
? "rgba(148, 163, 184, 0.08)"
: "transparent",
}}
>
<div className="relative flex min-h-0 flex-1 w-full items-end justify-center px-0.5">
<div className="relative h-full w-full max-w-[34px] sm:max-w-[42px]">
<div className="absolute inset-x-0 bottom-0 h-full rounded-t-xl bg-slate-100 dark:bg-slate-800/80" />
<div
className={`absolute inset-x-0 bottom-0 rounded-t-xl transition-all duration-150 ${utilizationBarTone(row.utilizationPct)} ${
isActive ? "opacity-100" : "opacity-80 group-hover:opacity-100"
}`}
style={{ height: `${Math.max(height, row.utilizationPct > 0 ? 6 : 0)}%` }}
/>
</div>
</div>
<div className="mt-2 min-w-0 shrink-0">
<div className="truncate text-center text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
{row.label}
</div>
</div>
</button>
);
})}
</div>
</div>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
"use client";
import { useMemo } from "react";
import dynamic from "next/dynamic";
import { trpc } from "~/lib/trpc/client.js";
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
@@ -10,84 +11,249 @@ const PeakTimesChart = dynamic(
{ ssr: false, loading: () => <div className="flex-1 shimmer-skeleton rounded-xl" /> },
);
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const granularity = (config.granularity as "week" | "month") || "month";
const groupBy = (config.groupBy as "project" | "chapter" | "resource") || "project";
type PeakDepartmentRow = {
name: string;
hours: number;
capacityHours: number;
remainingHours: number;
overbookedHours: number;
utilizationPct: number;
};
type PeakPeriodRow = {
period: string;
label: string;
bookedHours: number;
capacityHours: number;
remainingHours: number;
overbookedHours: number;
utilizationPct: number;
isCurrentPeriod: boolean;
groups: PeakDepartmentRow[];
};
function formatHours(value: number): string {
return new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(value);
}
function formatMonthLabel(periodStart: string | undefined, fallback: string): string {
if (!periodStart) {
return fallback;
}
const date = new Date(`${periodStart}T00:00:00.000Z`);
if (Number.isNaN(date.getTime())) {
return fallback;
}
return new Intl.DateTimeFormat("en-US", {
month: "short",
year: "2-digit",
timeZone: "UTC",
}).format(date);
}
function utilizationTone(utilizationPct: number): string {
if (utilizationPct >= 100) return "bg-red-500";
if (utilizationPct >= 85) return "bg-amber-400";
return "bg-emerald-500";
}
function utilizationTextTone(utilizationPct: number): string {
if (utilizationPct >= 100) return "text-red-600 dark:text-red-300";
if (utilizationPct >= 85) return "text-amber-600 dark:text-amber-300";
return "text-emerald-600 dark:text-emerald-300";
}
function aggregateDepartmentRows(rows: PeakDepartmentRow[], limit = 6): PeakDepartmentRow[] {
if (rows.length <= limit) {
return rows;
}
const visibleRows = rows.slice(0, limit - 1);
const hiddenRows = rows.slice(limit - 1);
const hiddenHours = hiddenRows.reduce((sum, row) => sum + row.hours, 0);
const hiddenCapacityHours = hiddenRows.reduce((sum, row) => sum + row.capacityHours, 0);
const hiddenRemainingHours = hiddenRows.reduce((sum, row) => sum + row.remainingHours, 0);
const hiddenOverbookedHours = hiddenRows.reduce((sum, row) => sum + row.overbookedHours, 0);
return [
...visibleRows,
{
name: `Other (${hiddenRows.length})`,
hours: hiddenHours,
capacityHours: hiddenCapacityHours,
remainingHours: hiddenRemainingHours,
overbookedHours: hiddenOverbookedHours,
utilizationPct:
hiddenCapacityHours > 0 ? Math.round((hiddenHours / hiddenCapacityHours) * 100) : 0,
},
];
}
export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
const now = new Date();
const startDate = new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString();
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0).toISOString();
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
const endDate = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 12, 0, 23, 59, 59, 999),
).toISOString();
const currentPeriodKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
const persistedPeriod = typeof config.activePeriod === "string" ? config.activePeriod : null;
const { data, isLoading } = trpc.dashboard.getPeakTimes.useQuery(
{ startDate, endDate, granularity, groupBy },
{ startDate, endDate, granularity: "month", groupBy: "chapter" },
{ staleTime: 120_000, placeholderData: (prev) => prev },
);
if (isLoading) {
const periods = useMemo<PeakPeriodRow[]>(
() =>
(data ?? []).map((period) => {
const derivation = period.derivation;
const bookedHours = period.bookedHours ?? derivation.bookedHours ?? period.totalHours;
const capacityHours = period.capacityHours ?? derivation.capacityHours ?? 0;
const remainingHours =
period.remainingHours ??
derivation.remainingCapacityHours ??
Math.max(capacityHours - bookedHours, 0);
const overbookedHours =
period.overbookedHours ??
derivation.overbookedHours ??
Math.max(bookedHours - capacityHours, 0);
const utilizationPct =
period.utilizationPct ??
derivation.utilizationPct ??
(capacityHours > 0 ? Math.round((bookedHours / capacityHours) * 100) : 0);
return {
period: period.period,
label: formatMonthLabel(period.periodStart ?? derivation.periodStart, period.period),
bookedHours,
capacityHours,
remainingHours,
overbookedHours,
utilizationPct,
isCurrentPeriod: period.period === currentPeriodKey,
groups: (period.groups ?? [])
.map((group) => {
const groupCapacityHours = group.capacityHours ?? 0;
const groupRemainingHours =
group.remainingHours ?? Math.max(groupCapacityHours - group.hours, 0);
const groupOverbookedHours =
group.overbookedHours ?? Math.max(group.hours - groupCapacityHours, 0);
const groupUtilizationPct =
group.utilizationPct ??
(groupCapacityHours > 0 ? Math.round((group.hours / groupCapacityHours) * 100) : 0);
return {
name: group.name,
hours: group.hours,
capacityHours: groupCapacityHours,
remainingHours: groupRemainingHours,
overbookedHours: groupOverbookedHours,
utilizationPct: groupUtilizationPct,
};
})
.sort(
(left, right) =>
right.utilizationPct - left.utilizationPct ||
right.hours - left.hours ||
left.name.localeCompare(right.name),
),
};
}),
[currentPeriodKey, data],
);
const selectedPeriod =
(persistedPeriod && periods.some((period) => period.period === persistedPeriod) ? persistedPeriod : null) ??
(periods.some((period) => period.period === currentPeriodKey) ? currentPeriodKey : periods[0]?.period ?? null);
const selectedPeriodRow =
periods.find((period) => period.period === selectedPeriod) ?? periods[0] ?? null;
const currentPeriodRow =
periods.find((period) => period.period === currentPeriodKey) ?? selectedPeriodRow;
const peakPeriodRow = useMemo(
() =>
[...periods].sort(
(left, right) =>
right.utilizationPct - left.utilizationPct || right.bookedHours - left.bookedHours,
)[0] ?? null,
[periods],
);
const departmentRows = useMemo(
() => aggregateDepartmentRows(selectedPeriodRow?.groups ?? []),
[selectedPeriodRow],
);
if (isLoading && periods.length === 0) {
return (
<div className="flex flex-col gap-3 h-full pt-2">
<div className="flex gap-2">
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
<div className="h-7 w-28 shimmer-skeleton rounded-lg" />
</div>
<div className="flex items-end gap-1 flex-1 px-2">
{[...Array(12)].map((_, i) => (
<div
key={i}
className="flex-1 shimmer-skeleton rounded-t"
style={{ height: `${30 + Math.random() * 50}%` }}
/>
<div className="flex h-full flex-col gap-3 pt-2">
<div className="grid grid-cols-3 gap-2">
{[...Array(3)].map((_, index) => (
<div key={index} className="h-14 rounded-2xl shimmer-skeleton" />
))}
</div>
<div className="flex-1 rounded-[22px] shimmer-skeleton" />
<div className="h-32 rounded-[22px] shimmer-skeleton" />
</div>
);
}
const periods = data ?? [];
// Collect all group names
const allGroups = new Set<string>();
for (const p of periods) {
for (const g of p.groups) allGroups.add(g.name);
}
const groups = [...allGroups].slice(0, 10);
// Build recharts data
const chartData = periods.map((p) => {
const row: Record<string, number | string> = { period: p.period, capacity: p.capacityHours };
for (const g of p.groups) {
row[g.name] = g.hours;
}
return row;
});
return (
<div className="flex flex-col h-full gap-3">
{/* Controls + info */}
<div className="flex gap-2 items-center">
<select
value={granularity}
onChange={(e) => onConfigChange?.({ granularity: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="month">Monthly</option>
<option value="week">Weekly</option>
</select>
<select
value={groupBy}
onChange={(e) => onConfigChange?.({ groupBy: e.target.value })}
className="px-2 py-1 text-xs border border-gray-300 rounded-lg bg-white"
>
<option value="project">By Project</option>
<option value="chapter">By Chapter</option>
<option value="resource">By Resource</option>
</select>
<div className="flex h-full flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between gap-3">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2">
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Current
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(currentPeriodRow?.utilizationPct ?? 0)}`}>
{currentPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{currentPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Selected
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(selectedPeriodRow?.utilizationPct ?? 0)}`}>
{selectedPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "Hover or pin"}
</span>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white/80 px-3 py-2 shadow-sm dark:border-slate-700/70 dark:bg-slate-900/60">
<div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Peak
</div>
<div className="mt-0.5 flex items-baseline justify-between gap-3">
<span className={`text-base font-semibold ${utilizationTextTone(peakPeriodRow?.utilizationPct ?? 0)}`}>
{peakPeriodRow?.utilizationPct ?? 0}%
</span>
<span className="truncate text-[11px] text-slate-500 dark:text-slate-400">
{peakPeriodRow?.label ?? "No data"}
</span>
</div>
</div>
</div>
<InfoTooltip
content={
<span>
Stacked bars = booked hours per group per period (last 2 months to next 6 months).<br />
Red dashed line = total capacity estimate (all active resources × available hours per day × working days).<br />
Bars exceeding the capacity line indicate over-allocation risk.
The top chart shows total booked load against effective capacity.<br />
The current month is marked with a blue accent.<br />
Hover any month to inspect details and click to pin the department breakdown.
</span>
}
width="w-80"
@@ -95,9 +261,72 @@ export function PeakTimesWidget({ config, onConfigChange }: WidgetProps) {
/>
</div>
{/* Chart */}
<div className="flex-1 min-h-0">
<PeakTimesChart chartData={chartData} groups={groups} />
<div className="min-h-0 flex-1 lg:grid lg:grid-cols-[minmax(0,1.85fr)_minmax(18rem,0.95fr)] lg:gap-3">
<div className="min-h-0">
<PeakTimesChart
rows={periods}
selectedPeriod={selectedPeriod}
onSelectedPeriodChange={(period) => onConfigChange?.({ activePeriod: period })}
/>
</div>
<div className="mt-2 min-h-0 lg:mt-0">
<div className="flex h-full flex-col rounded-[22px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] p-3 shadow-sm dark:border-slate-700/70 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.98))]">
<div className="flex flex-wrap items-start justify-between gap-2 border-b border-slate-200/70 pb-2 dark:border-slate-700/60">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">
Department Utilization
</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{selectedPeriodRow?.label ?? "No active month"}
</div>
</div>
<div className="text-right text-[11px] text-slate-500 dark:text-slate-400">
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.bookedHours)}h booked` : "No load"}</div>
<div>{selectedPeriodRow ? `${formatHours(selectedPeriodRow.capacityHours)}h capacity` : ""}</div>
</div>
</div>
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-auto pr-1">
{departmentRows.length > 0 ? (
departmentRows.map((group) => {
const barWidth = Math.min(group.utilizationPct, 100);
return (
<div key={group.name} className="space-y-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 truncate text-xs font-medium text-slate-700 dark:text-slate-200">
{group.name}
</div>
<div className={`shrink-0 text-[11px] font-semibold ${utilizationTextTone(group.utilizationPct)}`}>
{group.utilizationPct}%
</div>
</div>
<div
className="relative h-2.5 overflow-visible rounded-full bg-slate-100 dark:bg-slate-800/80"
title={`${group.name}: ${group.utilizationPct}% utilization, ${formatHours(group.hours)}h booked, ${formatHours(group.capacityHours)}h capacity, ${formatHours(group.remainingHours)}h free, ${formatHours(group.overbookedHours)}h overbooked`}
>
<div
className={`h-full rounded-full ${utilizationTone(group.utilizationPct)}`}
style={{ width: `${barWidth}%` }}
/>
{group.overbookedHours > 0 ? (
<div
className="absolute right-0 top-0 h-full rounded-full bg-red-600/85"
style={{ width: `${Math.min(22, Math.max(8, group.utilizationPct - 100))}%` }}
/>
) : null}
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/80 px-3 py-4 text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40">
No department data in the selected month.
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
@@ -8,6 +8,7 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ShoringBadge } from "~/components/projects/ShoringIndicator.js";
import { WidgetFilterBar, type WidgetFilter } from "~/components/dashboard/WidgetFilterBar.js";
import { useWidgetFilterOptions } from "~/hooks/useWidgetFilterOptions.js";
import { formatMoney } from "~/lib/format.js";
function healthDot(value: number): string {
if (value >= 70) return "bg-green-500";
@@ -21,7 +22,55 @@ function scoreBadge(score: number): string {
return "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300";
}
function formatShortDate(value?: string | Date | null): string {
if (!value) {
return "No end date";
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return "No end date";
}
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
function formatTimeline(daysUntilEndDate?: number | null, timelineStatus?: string | null): string {
if (timelineStatus === "UNSCHEDULED" || daysUntilEndDate == null) {
return "No end date";
}
if (daysUntilEndDate < 0) {
return `${Math.abs(daysUntilEndDate)} days overdue`;
}
if (daysUntilEndDate === 0) {
return "Due today";
}
return `${daysUntilEndDate} days left`;
}
function formatLocation(location: {
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
}): string {
const parts = [
location.countryCode ?? location.countryName ?? null,
location.federalState ?? null,
location.metroCityName ?? null,
].filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" / ") : "No calendar context";
}
export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
const showDetails = config.showDetails === true;
const { clients } = useWidgetFilterOptions();
const filters = useMemo<WidgetFilter[]>(
@@ -87,10 +136,10 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<thead className="bg-gray-50 dark:bg-gray-800/50 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-500 dark:text-gray-400">
Project <InfoTooltip content="Active projects scored across three health dimensions" />
Project <InfoTooltip content="Active projects scored across three health dimensions including visible budget, staffing, and timeline basis." />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demands), Timeline health (within end date)" />
B / S / T <InfoTooltip content="Budget health (spent vs budget), Staffing health (filled vs total demanded headcount), Timeline health (end date and remaining horizon)." />
</th>
<th className="px-3 py-2 text-center font-medium text-gray-500 dark:text-gray-400">
Shoring <InfoTooltip content="Offshore staffing ratio: percentage of hours from non-onshore resources. Color indicates threshold status." />
@@ -103,26 +152,66 @@ export function ProjectHealthWidget({ config, onConfigChange }: WidgetProps) {
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row) => (
<tr key={row.shortCode} className="hover:bg-gray-50 dark:hover:bg-gray-800/30">
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100 max-w-[160px] truncate">
<Link href={`/projects/${(row as any).id}`} className="hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
<td className="px-3 py-2 text-gray-900 dark:text-gray-100 max-w-[320px]">
<Link href={`/projects/${(row as any).id}`} className="block hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
<div className="truncate font-medium">
<span className="font-mono text-gray-500 dark:text-gray-400 mr-1">{row.shortCode}</span>
{row.projectName}
</div>
</Link>
{showDetails ? (
<div className="mt-1 space-y-0.5 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
<div>
Budget: {formatMoney(row.spentCents ?? 0)} spent
{row.budgetCents != null ? ` / ${formatMoney(row.budgetCents)} budget` : " / no budget"}
{row.remainingBudgetCents != null ? ` / ${formatMoney(row.remainingBudgetCents)} remaining` : ""}
</div>
<div>
Staffing: {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} HC
{typeof row.demandHeadcountOpen === "number" ? `, ${row.demandHeadcountOpen} open` : ""}
{typeof row.demandRequirementCount === "number" ? ` across ${row.demandRequirementCount} demands` : ""}
</div>
<div>
Timeline: {formatShortDate(row.plannedEndDate)} · {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
</div>
{(row.calendarLocations ?? []).length > 0 ? (
<div>
Calendar basis: {(row.calendarLocations ?? [])
.slice(0, 2)
.map((location) => `${formatLocation(location)} (${formatMoney(location.spentCents)} / ${location.assignmentCount} assign.)`)
.join(" · ")}
{(row.calendarLocations ?? []).length > 2
? ` · +${(row.calendarLocations ?? []).length - 2} more`
: ""}
</div>
) : null}
</div>
) : null}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
<div className="flex flex-col items-center justify-center gap-1 text-[11px] text-gray-500 dark:text-gray-400">
<div className="flex items-center justify-center gap-2">
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.budgetHealth)}`}
title={`Budget: ${row.budgetHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.staffingHealth)}`}
title={`Staffing: ${row.staffingHealth}%`}
/>
<span
className={`inline-block w-3 h-3 rounded-full ${healthDot(row.timelineHealth)}`}
title={`Timeline: ${row.timelineHealth}%`}
/>
</div>
<div className="text-center tabular-nums">
B {row.budgetUtilizationPercent ?? 0}% used
</div>
{showDetails ? (
<div className="text-center tabular-nums">
S {row.demandHeadcountFilled ?? 0}/{row.demandHeadcountTotal ?? 0} · T {formatTimeline(row.daysUntilEndDate, row.timelineStatus)}
</div>
) : null}
</div>
</td>
<td className="px-3 py-2 text-center">
@@ -19,6 +19,8 @@ function StatCard({
value,
suffix,
sub,
details,
showDetails = false,
info,
accentColor,
delay = 0,
@@ -28,6 +30,8 @@ function StatCard({
value: number;
suffix?: string;
sub?: string;
details?: string[];
showDetails?: boolean;
info?: React.ReactNode;
accentColor?: "green" | "amber" | "red";
delay?: number;
@@ -66,13 +70,37 @@ function StatCard({
</div>
)}
{sub && <p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{sub}</p>}
{showDetails && details && details.length > 0 ? (
<div className="mt-2 space-y-1 text-[11px] leading-4 text-gray-500 dark:text-gray-400">
{details.map((detail) => (
<p key={detail}>{detail}</p>
))}
</div>
) : null}
</div>
</FadeIn>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
function formatShortDate(value?: string | Date | null): string {
if (!value) {
return "n/a";
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return "n/a";
}
return new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
export function StatCardsWidget(props: Partial<WidgetProps> = {}) {
const showDetails = props.config?.showDetails === true;
const { data, isLoading } = trpc.dashboard.getOverview.useQuery(undefined, {
staleTime: 60_000,
placeholderData: (prev) => prev,
@@ -104,21 +132,33 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
<StatCard
label="Total Resources"
value={data.totalResources}
sub={`${data.activeResources} active`}
info="All resources in the system. Sub-line shows active resources only."
sub={`${data.activeResources} active / ${data.inactiveResources ?? Math.max(data.totalResources - data.activeResources, 0)} inactive`}
details={[
"Basis: all resource master records",
]}
showDetails={showDetails}
info="All resources in the system. Sub-line shows active versus inactive records."
delay={0}
/>
<StatCard
label="Active Projects"
value={data.activeProjects}
sub={`${data.totalProjects} total`}
sub={`${data.totalProjects} total / ${data.inactiveProjects ?? Math.max(data.totalProjects - data.activeProjects, 0)} non-active`}
details={[
"Basis: project status on the dashboard snapshot",
]}
showDetails={showDetails}
info="Projects with status ACTIVE. Total includes all statuses (DRAFT, ON_HOLD, COMPLETED, CANCELLED)."
delay={0.05}
/>
<StatCard
label="Total Allocations"
value={data.totalAllocations}
sub={`${data.activeAllocations} not cancelled`}
sub={`${data.activeAllocations} not cancelled / ${data.cancelledAllocations ?? Math.max(data.totalAllocations - data.activeAllocations, 0)} cancelled`}
details={[
"Basis: split allocation read model across explicit and legacy rows",
]}
showDetails={showDetails}
info="All allocation records ever created. 'Not cancelled' excludes allocations with status CANCELLED."
delay={0.1}
/>
@@ -127,7 +167,13 @@ export function StatCardsWidget(_props: Partial<WidgetProps> = {}) {
value={budgetPct}
suffix="%"
sub={`${formatMoney(data.budgetSummary.totalCostCents)} of ${formatMoney(data.budgetSummary.totalBudgetCents)}`}
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost = resource LCR × booked hours."
details={[
`Remaining: ${formatMoney(data.budgetBasis?.remainingBudgetCents ?? (data.budgetSummary.totalBudgetCents - data.budgetSummary.totalCostCents))}`,
`Basis: ${data.budgetBasis?.trackedAssignmentCount ?? 0} non-cancelled assignments across ${data.budgetBasis?.budgetedProjects ?? 0} budgeted projects`,
`Window: ${formatShortDate(data.budgetBasis?.windowStart)} - ${formatShortDate(data.budgetBasis?.windowEnd)}`,
]}
showDetails={showDetails}
info="Sum of costs across non-cancelled allocations divided by total project budgets. Cost uses the effective allocation cost basis including holiday-adjusted working capacity where available."
accentColor={budgetAccent}
delay={0.15}
ring={{ value: budgetPct, color: ACCENT_COLORS[budgetAccent] }}
@@ -231,6 +231,7 @@ const adminNavEntries: AdminEntry[] = [
],
},
{ href: "/admin/calculation-rules", label: "Calc. Rules", icon: <CalcRulesIcon /> },
{ href: "/admin/vacations", label: "Vacations & Holidays", icon: <VacationIcon /> },
{ href: "/admin/users", label: "Users", icon: <UsersIcon /> },
{ href: "/admin/system-roles", label: "System Roles", icon: <SystemRolesIcon /> },
{ href: "/admin/settings", label: "Settings", icon: <SettingsIcon /> },
@@ -1,11 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useSession } from "next-auth/react";
import Link from "next/link";
import type { Route } from "next";
import { motion, useAnimationControls } from "framer-motion";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js";
function relativeTime(date: Date): string {
@@ -28,12 +29,16 @@ type TabKey = "all" | "tasks" | "reminders";
export function NotificationBell() {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<TabKey>("all");
const ref = useRef<HTMLDivElement>(null);
const bellRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
const { data: session, status } = useSession();
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
const { panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
open,
onClose: () => setOpen(false),
side: "right",
crossAlign: "start",
triggerRef: bellRef,
});
const badgeControls = useAnimationControls();
const prevUnreadRef = useRef<number | null>(null);
@@ -96,39 +101,6 @@ export function NotificationBell() {
},
});
// Compute dropdown position when opening
const updatePosition = useCallback(() => {
if (!bellRef.current) return;
const rect = bellRef.current.getBoundingClientRect();
const panelHeight = 440; // approximate max height
let top = rect.top;
// If it would overflow the bottom, flip upward
if (top + panelHeight > window.innerHeight) {
top = Math.max(8, window.innerHeight - panelHeight - 8);
}
setDropdownPos({ top, left: rect.right + 8 });
}, []);
useEffect(() => {
if (open) updatePosition();
}, [open, updatePosition]);
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
const target = e.target as Node;
if (
ref.current && !ref.current.contains(target) &&
dropdownRef.current && !dropdownRef.current.contains(target)
) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
function handleMarkAllRead() {
if (!isAuthenticated) return;
markRead.mutate({});
@@ -150,12 +122,18 @@ export function NotificationBell() {
];
return (
<div ref={ref} className="relative">
<div className="relative">
{/* Bell button */}
<button
ref={bellRef}
type="button"
onClick={() => setOpen((v) => !v)}
onClick={() => {
setOpen((current) => {
const nextOpen = !current;
handleOpenChange(nextOpen);
return nextOpen;
});
}}
className="relative p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Notifications"
>
@@ -193,12 +171,12 @@ export function NotificationBell() {
{/* Dropdown panel — rendered via portal to escape sidebar overflow */}
{open && createPortal(
<motion.div
ref={dropdownRef}
ref={panelRef}
initial={{ opacity: 0, scaleY: 0.95, scaleX: 0.98 }}
animate={{ opacity: 1, scaleY: 1, scaleX: 1 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="fixed w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-[9999] overflow-hidden origin-top"
style={{ top: dropdownPos.top, left: dropdownPos.left }}
style={{ top: position.top, left: position.left }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
+390 -11
View File
@@ -7,7 +7,7 @@ import { clsx } from "clsx";
// ─── Types ──────────────────────────────────────────────────────────────────
type EntityType = "resource" | "project" | "assignment";
type EntityType = "resource" | "project" | "assignment" | "resource_month";
type FilterOp = "eq" | "neq" | "gt" | "lt" | "gte" | "lte" | "contains" | "in";
interface FilterRow {
@@ -17,10 +17,50 @@ interface FilterRow {
value: string;
}
interface AvailableColumn {
key: string;
label: string;
dataType: "string" | "number" | "date" | "boolean";
}
interface TemplateConfig {
entity: EntityType;
columns: string[];
filters: Omit<FilterRow, "id">[];
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
periodMonth?: string;
}
interface ReportTemplateSummary {
id: string;
name: string;
description?: string | null;
entity: EntityType;
config: TemplateConfig;
isShared: boolean;
isOwner: boolean;
updatedAt: string | Date;
}
interface ReportBlueprint {
id: string;
label: string;
description: string;
entity: EntityType;
columns: string[];
groupBy?: string;
sortBy?: string;
sortDir?: "asc" | "desc";
templateName: string;
}
const ENTITY_OPTIONS: { value: EntityType; label: string }[] = [
{ value: "resource", label: "Resources" },
{ value: "project", label: "Projects" },
{ value: "assignment", label: "Assignments" },
{ value: "resource_month", label: "Resource Months" },
];
const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
@@ -36,10 +76,120 @@ const OPERATOR_OPTIONS: { value: FilterOp; label: string }[] = [
const PAGE_SIZE = 50;
const RESOURCE_MONTH_RECOMMENDED_COLUMNS = [
"displayName",
"eid",
"chapter",
"countryCode",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
] as const;
const REPORT_BLUEPRINTS: ReportBlueprint[] = [
{
id: "resource-month-sah-transparency",
label: "SAH transparency",
description: "Explains how monthly SAH is reduced by holidays and absences per person.",
entity: "resource_month",
templateName: "Monthly SAH transparency",
columns: [
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyEffectiveWorkingDays",
"monthlyBaseAvailableHours",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceDayEquivalent",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
],
sortBy: "displayName",
sortDir: "asc",
},
{
id: "resource-month-chargeability-audit",
label: "Chargeability audit",
description: "Shows the full path from monthly SAH to booked, target and unassigned hours.",
entity: "resource_month",
templateName: "Monthly chargeability audit",
columns: [
"displayName",
"eid",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlySahHours",
"monthlyChargeabilityTargetPct",
"monthlyTargetHours",
"monthlyActualBookedHours",
"monthlyExpectedBookedHours",
"monthlyActualChargeabilityPct",
"monthlyExpectedChargeabilityPct",
"monthlyUnassignedHours",
"lcrCents",
"currency",
],
sortBy: "monthlyActualChargeabilityPct",
sortDir: "desc",
},
{
id: "resource-month-location-comparison",
label: "Location comparison",
description: "Compares holiday impact across country, state and city contexts for the same month.",
entity: "resource_month",
templateName: "Monthly holiday comparison by location",
columns: [
"displayName",
"chapter",
"countryName",
"federalState",
"metroCityName",
"monthlyBaseWorkingDays",
"monthlyPublicHolidayWorkdayCount",
"monthlyPublicHolidayHoursDeduction",
"monthlyAbsenceHoursDeduction",
"monthlySahHours",
"monthlyActualChargeabilityPct",
],
groupBy: "federalState",
sortBy: "monthlyPublicHolidayHoursDeduction",
sortDir: "desc",
},
];
function generateId(): string {
return Math.random().toString(36).slice(2, 10);
}
function getCurrentPeriodMonth(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
// ─── Component ──────────────────────────────────────────────────────────────
export function ReportBuilder() {
@@ -50,6 +200,9 @@ export function ReportBuilder() {
const [groupBy, setGroupBy] = useState<string>("");
const [sortBy, setSortBy] = useState<string>("");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [periodMonth, setPeriodMonth] = useState<string>(getCurrentPeriodMonth());
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [templateName, setTemplateName] = useState<string>("");
const [page, setPage] = useState(0);
const [runQuery, setRunQuery] = useState(false);
@@ -59,7 +212,21 @@ export function ReportBuilder() {
{ placeholderData: keepPreviousData },
);
const availableColumns = columnsQuery.data ?? [];
const availableColumns: AvailableColumn[] = columnsQuery.data ?? [];
const templatesQuery = trpc.report.listTemplates.useQuery();
const saveTemplateMutation = trpc.report.saveTemplate.useMutation({
onSuccess: async (result) => {
setSelectedTemplateId(result.id);
await templatesQuery.refetch();
},
});
const deleteTemplateMutation = trpc.report.deleteTemplate.useMutation({
onSuccess: async () => {
setSelectedTemplateId("");
setTemplateName("");
await templatesQuery.refetch();
},
});
// Scalar columns (for filter/sort/group — only non-relation columns)
const scalarColumns = useMemo(
@@ -76,12 +243,13 @@ export function ReportBuilder() {
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
};
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page]);
}, [runQuery, entity, selectedColumns, filters, groupBy, sortBy, sortDir, page, periodMonth]);
// Fetch report data
const reportQuery = trpc.report.getReportData.useQuery(
@@ -99,6 +267,40 @@ export function ReportBuilder() {
setFilters([]);
setGroupBy("");
setSortBy("");
if (newEntity === "resource_month") {
setPeriodMonth((current) => current || getCurrentPeriodMonth());
}
setRunQuery(false);
setPage(0);
}, []);
const applyTemplate = useCallback((template: ReportTemplateSummary) => {
const config = template.config;
setSelectedTemplateId(template.id);
setTemplateName(template.name);
setEntity(config.entity);
setSelectedColumns(new Set(config.columns));
setFilters(config.filters.map((filter: Omit<FilterRow, "id">) => ({ id: generateId(), ...filter })));
setGroupBy(config.groupBy ?? "");
setSortBy(config.sortBy ?? "");
setSortDir(config.sortDir ?? "asc");
setPeriodMonth(config.periodMonth ?? getCurrentPeriodMonth());
setRunQuery(false);
setPage(0);
}, [templatesQuery.data]);
const applyBlueprint = useCallback((blueprint: ReportBlueprint) => {
setSelectedTemplateId("");
setTemplateName(blueprint.templateName);
setEntity(blueprint.entity);
setSelectedColumns(new Set(blueprint.columns));
setFilters([]);
setGroupBy(blueprint.groupBy ?? "");
setSortBy(blueprint.sortBy ?? "");
setSortDir(blueprint.sortDir ?? "asc");
if (blueprint.entity === "resource_month") {
setPeriodMonth((current) => current || getCurrentPeriodMonth());
}
setRunQuery(false);
setPage(0);
}, []);
@@ -163,6 +365,7 @@ export function ReportBuilder() {
filters: filters
.filter((f) => f.field && f.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
limit: 5000,
@@ -179,7 +382,42 @@ export function ReportBuilder() {
} catch {
// Error handled by tRPC
}
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation]);
}, [entity, selectedColumns, filters, groupBy, sortBy, sortDir, exportMutation, periodMonth]);
const handleSaveTemplate = useCallback(async () => {
if (!templateName.trim() || selectedColumns.size === 0) return;
await saveTemplateMutation.mutateAsync({
...(selectedTemplateId ? { id: selectedTemplateId } : {}),
name: templateName.trim(),
config: {
entity,
columns: Array.from(selectedColumns),
filters: filters
.filter((filter) => filter.field && filter.value)
.map(({ field, op, value }) => ({ field, op, value })),
...(entity === "resource_month" ? { periodMonth } : {}),
...(groupBy ? { groupBy } : {}),
...(sortBy ? { sortBy, sortDir } : {}),
},
});
}, [
entity,
filters,
groupBy,
periodMonth,
saveTemplateMutation,
selectedColumns,
selectedTemplateId,
sortBy,
sortDir,
templateName,
]);
const handleDeleteTemplate = useCallback(async () => {
if (!selectedTemplateId) return;
await deleteTemplateMutation.mutateAsync({ id: selectedTemplateId });
}, [deleteTemplateMutation, selectedTemplateId]);
// ─── Derived ──────────────────────────────────────────────────────────
@@ -188,6 +426,15 @@ export function ReportBuilder() {
const outputColumns = reportQuery.data?.columns ?? [];
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const isLoading = reportQuery.isFetching;
const templates = templatesQuery.data ?? [];
const resourceMonthBlueprints = useMemo(
() => REPORT_BLUEPRINTS.filter((blueprint) => blueprint.entity === entity),
[entity],
);
const recommendedColumnSet = useMemo(
() => entity === "resource_month" ? new Set<string>(RESOURCE_MONTH_RECOMMENDED_COLUMNS) : new Set<string>(),
[entity],
);
// Column label lookup
const columnLabelMap = useMemo(() => {
@@ -212,6 +459,61 @@ export function ReportBuilder() {
{/* Config Panel */}
<div className="space-y-5 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-slate-800 dark:bg-slate-900/60 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Template
</label>
<select
value={selectedTemplateId}
onChange={(e) => {
const nextId = e.target.value;
setSelectedTemplateId(nextId);
const template = templates.find((entry: ReportTemplateSummary) => entry.id === nextId);
if (template) {
applyTemplate(template);
}
}}
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
>
<option value="">Unsaved view</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}{template.isShared && !template.isOwner ? " · shared" : ""}
</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Template name
</label>
<input
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="Monthly SAH by location"
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-400 focus:border-brand-500 focus:ring-brand-500 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300"
/>
</div>
<button
type="button"
onClick={() => void handleSaveTemplate()}
disabled={!templateName.trim() || selectedColumns.size === 0 || saveTemplateMutation.isPending}
className="self-end rounded-xl bg-brand-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveTemplateMutation.isPending ? "Saving..." : selectedTemplateId ? "Update template" : "Save template"}
</button>
<button
type="button"
onClick={() => void handleDeleteTemplate()}
disabled={!selectedTemplateId || deleteTemplateMutation.isPending}
className="self-end rounded-xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-950 dark:text-gray-300 dark:hover:bg-slate-900"
>
{deleteTemplateMutation.isPending ? "Deleting..." : "Delete"}
</button>
</div>
{/* Entity Selector */}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -234,6 +536,73 @@ export function ReportBuilder() {
</button>
))}
</div>
{entity === "resource_month" && (
<div className="mt-4 space-y-4 rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200">
Period month
</label>
<input
type="month"
value={periodMonth}
onChange={(e) => setPeriodMonth(e.target.value)}
className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300"
/>
</div>
<p className="max-w-2xl text-sm text-emerald-900/80 dark:text-emerald-200/80">
Resource Months uses the CapaKraken holiday and absence logic directly. SAH, booked hours and chargeability are calculated per resource and month with country, state and city context.
</p>
</div>
<div className="grid gap-3 lg:grid-cols-3">
{resourceMonthBlueprints.map((blueprint) => (
<button
key={blueprint.id}
type="button"
onClick={() => applyBlueprint(blueprint)}
className="rounded-2xl border border-emerald-200 bg-white/80 p-4 text-left transition hover:border-emerald-400 hover:bg-white dark:border-emerald-900/70 dark:bg-slate-950/60 dark:hover:border-emerald-700"
>
<div className="text-sm font-semibold text-emerald-950 dark:text-emerald-100">
{blueprint.label}
</div>
<p className="mt-1 text-xs leading-5 text-emerald-900/75 dark:text-emerald-200/75">
{blueprint.description}
</p>
</button>
))}
</div>
<div className="rounded-2xl border border-emerald-200/80 bg-white/60 p-4 dark:border-emerald-900/60 dark:bg-slate-950/40">
<div className="text-sm font-medium text-emerald-950 dark:text-emerald-100">
Recommended transparency columns
</div>
<div className="mt-2 flex flex-wrap gap-2">
{RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => (
<button
key={column}
type="button"
onClick={() => toggleColumn(column)}
className={clsx(
"rounded-full border px-3 py-1 text-xs font-medium transition",
selectedColumns.has(column)
? "border-emerald-500 bg-emerald-500 text-white"
: "border-emerald-200 bg-white text-emerald-900 hover:border-emerald-400 dark:border-emerald-900 dark:bg-slate-950 dark:text-emerald-200 dark:hover:border-emerald-700",
)}
>
{columnLabelMap.get(column) ?? column}
</button>
))}
</div>
<p className="mt-3 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Formula reference: base available hours - holiday deduction - absence deduction = monthly SAH. Chargeability uses booked hours divided by monthly SAH.
</p>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Export recommendation: include both basis columns and computed metrics in the CSV. That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product.
</p>
</div>
</div>
)}
</div>
{/* Column Picker */}
@@ -276,6 +645,11 @@ export function ReportBuilder() {
className="h-3.5 w-3.5 rounded border-gray-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600"
/>
<span className="text-gray-700 dark:text-gray-300">{col.label}</span>
{recommendedColumnSet.has(col.key) && (
<span className="rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.14em] text-emerald-700 dark:bg-emerald-950/60 dark:text-emerald-300">
Rec
</span>
)}
<span className="ml-auto text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-600">
{col.dataType}
</span>
@@ -428,13 +802,18 @@ export function ReportBuilder() {
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
{/* Results Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-slate-800">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
<div className="space-y-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-50">Results</h2>
{!isLoading && (
<span className="rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 dark:bg-slate-800 dark:text-gray-400">
{totalCount.toLocaleString()} row{totalCount !== 1 ? "s" : ""}
</span>
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400">
CSV exports include the selected basis columns and computed CapaKraken metrics exactly as shown here.
</p>
</div>
<button
type="button"
@@ -209,17 +209,74 @@ interface SuggestionLike {
resourceName: string;
eid: string;
score: number;
valueScore?: number;
scoreBreakdown: {
skillScore: number;
availabilityScore: number;
costScore: number;
utilizationScore: number;
total?: number;
};
matchedSkills: string[];
missingSkills: string[];
availabilityConflicts: string[];
estimatedDailyCostCents: number;
currentUtilization: number;
remainingHours?: number;
remainingHoursPerDay?: number;
baseAvailableHours?: number;
effectiveAvailableHours?: number;
holidayHoursDeduction?: number;
location?: {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
label: string;
};
capacity?: {
requestedHoursPerDay: number;
requestedHoursTotal: number;
baseWorkingDays: number;
effectiveWorkingDays: number;
baseAvailableHours: number;
effectiveAvailableHours: number;
bookedHours: number;
remainingHours: number;
remainingHoursPerDay: number;
holidayCount: number;
holidayWorkdayCount: number;
holidayHoursDeduction: number;
absenceDayEquivalent: number;
absenceHoursDeduction: number;
};
conflicts?: {
count: number;
conflictDays: string[];
details: Array<{
date: string;
baseHours: number;
effectiveHours: number;
allocatedHours: number;
remainingHours: number;
requestedHours: number;
shortageHours: number;
absenceFraction: number;
isHoliday: boolean;
}>;
};
ranking?: {
rank: number;
baseRank: number;
tieBreakerApplied: boolean;
tieBreakerReason: string | null;
model: string;
components: Array<{
key: string;
label: string;
score: number;
}>;
};
}
interface SuggestionCardProps {
@@ -231,10 +288,24 @@ interface SuggestionCardProps {
}
function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) {
const [expanded, setExpanded] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [showAssignForm, setShowAssignForm] = useState(false);
const locationLabel = suggestion.location?.label
|| [suggestion.location?.countryCode, suggestion.location?.federalState, suggestion.location?.metroCityName]
.filter(Boolean)
.join(" / ")
|| "No location";
const capacity = suggestion.capacity;
const conflicts = suggestion.conflicts;
const conflictCount = conflicts?.count ?? suggestion.availabilityConflicts.length;
const remainingHours = capacity?.remainingHours ?? suggestion.remainingHours ?? 0;
const remainingHoursPerDay = capacity?.remainingHoursPerDay ?? suggestion.remainingHoursPerDay ?? 0;
const baseAvailableHours = capacity?.baseAvailableHours ?? suggestion.baseAvailableHours ?? 0;
const effectiveAvailableHours = capacity?.effectiveAvailableHours ?? suggestion.effectiveAvailableHours ?? 0;
const holidayHoursDeduction = capacity?.holidayHoursDeduction ?? suggestion.holidayHoursDeduction ?? 0;
return (
<div className="app-surface p-5">
<div data-suggestion className="app-surface p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-100 font-display text-lg font-semibold text-brand-700 dark:bg-brand-900/40 dark:text-brand-200">
@@ -243,15 +314,23 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
<div>
<div className="text-base font-semibold text-gray-900 dark:text-gray-100">{suggestion.resourceName}</div>
<div className="text-sm text-gray-500">{suggestion.eid}</div>
<div className="mt-1 text-xs text-gray-500">{locationLabel}</div>
</div>
</div>
<div className="flex items-start gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setShowDetails((prev) => !prev)}
>
{showDetails ? "Hide Details" : "Details"}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => setExpanded((prev) => !prev)}
onClick={() => setShowAssignForm((prev) => !prev)}
>
{expanded ? "Cancel" : "Assign"}
{showAssignForm ? "Close Assign" : "Assign"}
</Button>
<div className="rounded-2xl border border-brand-200 bg-brand-50 px-4 py-3 text-right dark:border-brand-900/50 dark:bg-brand-900/20">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-brand-700 dark:text-brand-200 inline-flex items-center gap-0.5">Match Score<InfoTooltip content="Composite score (0-100) blending skill fit, free capacity, cost efficiency, and current utilization." /></div>
@@ -260,13 +339,6 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
</div>
<div className="mt-4 flex flex-wrap gap-2">
{suggestion.matchedSkills.map((skill) => (
<span key={skill} className="rounded-full bg-green-50 px-2.5 py-1 text-xs font-medium text-green-700 dark:bg-green-950/30 dark:text-green-300">
@@ -280,24 +352,144 @@ function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError
))}
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard
label="Free / Workday"
value={formatHours(remainingHoursPerDay)}
tone={remainingHoursPerDay >= searchCriteria.hoursPerDay ? "good" : "warn"}
helper={`${formatHours(remainingHours)} total in window`}
/>
<StatCard
label="Capacity"
value={`${formatHours(effectiveAvailableHours)} effective`}
helper={`${formatHours(baseAvailableHours)} base`}
/>
<StatCard
label="Holiday Deduction"
value={holidayHoursDeduction > 0 ? formatHours(holidayHoursDeduction) : "0h"}
tone={holidayHoursDeduction > 0 ? "warn" : "neutral"}
helper={capacity ? `${capacity.holidayWorkdayCount} affected workdays` : "No local holiday impact"}
/>
<StatCard
label="Conflicts"
value={String(conflictCount)}
tone={conflictCount > 0 ? "warn" : "good"}
helper={conflictCount > 0 ? `${conflictCount} overloaded day${conflictCount === 1 ? "" : "s"}` : "No day-level overloads"}
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-gray-500">
<span>LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h</span>
<span>Utilization: {Math.round(suggestion.currentUtilization)}%</span>
{suggestion.availabilityConflicts.length > 0 && (
{suggestion.valueScore != null && (
<span>Value Score: {suggestion.valueScore}</span>
)}
{conflictCount > 0 && (
<span className="font-medium text-amber-600 dark:text-amber-300">
{suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"}
{conflictCount} scheduling conflict{conflictCount === 1 ? "" : "s"}
</span>
)}
</div>
{expanded && (
{showDetails && (
<div className="mt-5 space-y-4 rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-800 dark:bg-gray-950/40">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<ScoreBar label="Skills" value={suggestion.scoreBreakdown.skillScore} tooltip="Quality of skill overlap with the requested stack, weighted by proficiency level." />
<ScoreBar label="Availability" value={suggestion.scoreBreakdown.availabilityScore} tooltip="Free capacity during the selected period, accounting for existing bookings and vacations." />
<ScoreBar label="Cost" value={suggestion.scoreBreakdown.costScore} tooltip="Cost efficiency based on the resource's LCR relative to the team average." />
<ScoreBar label="Utilization" value={suggestion.scoreBreakdown.utilizationScore} tooltip="Current workload. Higher score means more capacity available (lower utilization)." />
</div>
<div className="grid gap-4 xl:grid-cols-[1.15fr_1fr]">
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Capacity Basis</div>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<MetricLine label="Requested load" value={`${formatHours(capacity?.requestedHoursPerDay ?? searchCriteria.hoursPerDay)} / day`} />
<MetricLine label="Requested total" value={formatHours(capacity?.requestedHoursTotal ?? 0)} />
<MetricLine label="Base working days" value={String(capacity?.baseWorkingDays ?? 0)} />
<MetricLine label="Effective working days" value={String(capacity?.effectiveWorkingDays ?? 0)} />
<MetricLine label="Base available hours" value={formatHours(baseAvailableHours)} />
<MetricLine label="Effective available hours" value={formatHours(effectiveAvailableHours)} />
<MetricLine label="Booked hours" value={formatHours(capacity?.bookedHours ?? 0)} />
<MetricLine label="Remaining hours" value={formatHours(remainingHours)} />
<MetricLine label="Holiday deduction" value={formatHours(holidayHoursDeduction)} />
<MetricLine label="Absence deduction" value={formatHours(capacity?.absenceHoursDeduction ?? 0)} />
</div>
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Ranking Basis</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{suggestion.ranking?.model ?? "Composite ranking across skill fit, availability, cost, and utilization."}
</p>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
{(suggestion.ranking?.components ?? []).map((component) => (
<MetricLine key={component.key} label={component.label} value={`${component.score}`} />
))}
{suggestion.ranking?.tieBreakerReason && (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
{suggestion.ranking.tieBreakerReason}
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Location + Calendar</div>
<div className="mt-3 space-y-2 text-sm text-gray-600 dark:text-gray-300">
<MetricLine label="Location" value={locationLabel} />
<MetricLine label="Holiday dates" value={String(capacity?.holidayCount ?? 0)} />
<MetricLine label="Holiday workdays" value={String(capacity?.holidayWorkdayCount ?? 0)} />
<MetricLine label="Absence days" value={String(capacity?.absenceDayEquivalent ?? 0)} />
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-950/60">
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-500">Conflict Check</div>
<div className="text-xs text-gray-500">
Requested {formatHours(searchCriteria.hoursPerDay)} / day between {searchCriteria.startDate} and {searchCriteria.endDate}
</div>
</div>
{conflictCount === 0 ? (
<p className="mt-3 text-sm text-green-700 dark:text-green-300">
No overloaded working days in the selected window.
</p>
) : (
<div className="mt-3 space-y-2">
{(conflicts?.details ?? []).slice(0, 6).map((item) => (
<div key={item.date} className="rounded-xl border border-amber-200 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-medium">{item.date}</span>
<span>Short by {formatHours(item.shortageHours)}</span>
</div>
<div className="mt-1 text-xs">
Base {formatHours(item.baseHours)} | Effective {formatHours(item.effectiveHours)} | Already booked {formatHours(item.allocatedHours)} | Remaining {formatHours(item.remainingHours)}
</div>
</div>
))}
{conflictCount > 6 && (
<p className="text-xs text-gray-500">
+{conflictCount - 6} more conflict day{conflictCount - 6 === 1 ? "" : "s"}
</p>
)}
</div>
)}
</div>
</div>
)}
{showAssignForm && (
<AssignForm
resourceId={suggestion.resourceId}
resourceName={suggestion.resourceName}
searchCriteria={searchCriteria}
onAssigned={() => onAssigned(suggestion.resourceId, suggestion.resourceName)}
onError={onError}
onCancel={() => setExpanded(false)}
onCancel={() => setShowAssignForm(false)}
/>
)}
</div>
@@ -499,3 +691,45 @@ function ScoreBar({ label, value, tooltip }: { label: string; value: number; too
</div>
);
}
function formatHours(value: number): string {
const rounded = Math.round(value * 10) / 10;
return `${rounded}h`;
}
function MetricLine({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-gray-100 pb-2 text-sm last:border-b-0 last:pb-0 dark:border-gray-800">
<span className="text-gray-500 dark:text-gray-400">{label}</span>
<span className="text-right font-medium text-gray-900 dark:text-gray-100">{value}</span>
</div>
);
}
function StatCard({
label,
value,
helper,
tone = "neutral",
}: {
label: string;
value: string;
helper?: string;
tone?: "neutral" | "good" | "warn";
}) {
const toneClass = tone === "good"
? "border-green-200 bg-green-50/70 dark:border-green-900/40 dark:bg-green-950/20"
: tone === "warn"
? "border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20"
: "border-gray-200 bg-gray-50/70 dark:border-gray-700 dark:bg-gray-900/40";
return (
<div className={`rounded-2xl border p-3 ${toneClass}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">{label}</div>
<div className="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">{value}</div>
{helper && (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{helper}</div>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
@@ -28,9 +29,14 @@ export function AllocationPopover({
anchorX,
anchorY,
}: AllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX, y: anchorY },
width: 300,
estimatedHeight: 360,
onClose,
});
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
{ projectId },
@@ -63,17 +69,6 @@ export function AllocationPopover({
},
});
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
function toDateInput(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -93,18 +88,9 @@ export function AllocationPopover({
});
}
// Position popover so it stays on screen
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(anchorX, window.innerWidth - 320),
top: Math.min(anchorY + 8, window.innerHeight - 360),
zIndex: 50,
width: 300,
};
if (isLoading || !allocation) {
return (
<div ref={ref} style={popoverStyle} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
Loading...
</div>
);
@@ -115,7 +101,7 @@ export function AllocationPopover({
return (
<div
ref={ref}
style={popoverStyle}
style={style}
className="bg-white border border-gray-200 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useRef } from "react";
import type { TimelineDemandEntry } from "./TimelineContext.js";
import { formatCents, formatDateLong } from "~/lib/format.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface DemandPopoverProps {
demand: TimelineDemandEntry;
@@ -21,17 +21,12 @@ export function DemandPopover({
anchorX,
anchorY,
}: DemandPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX, y: anchorY },
width: 300,
estimatedHeight: 340,
onClose,
});
const roleName = demand.roleEntity?.name ?? demand.role ?? "Unspecified";
const roleColor = demand.roleEntity?.color ?? "#f59e0b";
@@ -41,18 +36,10 @@ export function DemandPopover({
const totalHours = demand.hoursPerDay * days;
const budgetCents = demand.dailyCostCents * days;
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(anchorX, window.innerWidth - 320),
top: Math.min(anchorY + 8, window.innerHeight - 340),
zIndex: 50,
width: 300,
};
return (
<div
ref={ref}
style={popoverStyle}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
>
{/* Header */}
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js";
interface NewAllocationPopoverProps {
@@ -36,7 +37,12 @@ export function NewAllocationPopover({
onClose,
onCreated,
}: NewAllocationPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX - 10, y: anchorY },
width: 320,
estimatedHeight: 440,
onClose,
});
const invalidateTimeline = useInvalidateTimeline();
const [search, setSearch] = useState("");
@@ -67,17 +73,6 @@ export function NewAllocationPopover({
},
});
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose]);
function handleCreate() {
if (!selectedProjectId) return;
createMutation.mutate({
@@ -93,13 +88,10 @@ export function NewAllocationPopover({
const canCreate = !!selectedProjectId && !!start && !!end && hoursPerDay > 0;
const left = Math.min(anchorX - 10, typeof window !== "undefined" ? window.innerWidth - 340 : anchorX);
const top = Math.min(anchorY + 8, typeof window !== "undefined" ? window.innerHeight - 440 : anchorY);
return (
<div
ref={ref}
style={{ position: "fixed", left, top, zIndex: 60, width: 320 }}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-2xl dark:shadow-black/40 overflow-hidden"
>
{/* Header */}
@@ -1,9 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { formatCents } from "~/lib/format.js";
import type { SkillEntry } from "@capakraken/shared";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
interface ResourceHoverCardProps {
resourceId: string;
@@ -12,34 +12,20 @@ interface ResourceHoverCardProps {
}
export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHoverCardProps) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ left: 0, top: 0 });
const { ref, style } = useViewportPopover({
anchor: { kind: "element", element: anchorEl },
width: 280,
estimatedHeight: 320,
onClose,
side: "right",
ignoreElements: [anchorEl],
});
const { data, isLoading } = trpc.resource.getHoverCard.useQuery(
{ id: resourceId },
{ staleTime: 60_000 },
);
// Position relative to anchor element
useEffect(() => {
const rect = anchorEl.getBoundingClientRect();
setPos({
left: rect.right + 8,
top: Math.min(rect.top, window.innerHeight - 320),
});
}, [anchorEl]);
// Close on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [onClose, anchorEl]);
const skills = (data?.skills ?? []) as unknown as SkillEntry[];
const mainSkills = skills.filter((s) => s.isMainSkill);
const topSkills = skills
@@ -47,19 +33,11 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
.sort((a, b) => b.proficiency - a.proficiency)
.slice(0, 6);
const popoverStyle: React.CSSProperties = {
position: "fixed",
left: Math.min(pos.left, window.innerWidth - 300),
top: pos.top,
zIndex: 50,
width: 280,
};
return (
<div
ref={ref}
data-resource-hover-card="true"
style={popoverStyle}
style={style}
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-xl shadow-xl overflow-hidden"
onMouseLeave={onClose}
>
@@ -113,6 +113,16 @@ export type VacationEntry = {
halfDayPart?: string | null;
};
export type HolidayOverlayEntry = {
id: string;
resourceId: string;
type: string;
status: string;
startDate: Date | string;
endDate: Date | string;
note?: string | null;
};
// ─── Context shape ──────────────────────────────────────────────────────────
export interface TimelineContextValue {
@@ -314,9 +324,43 @@ export function TimelineProvider({
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const { data: holidayOverlayEntries = [] } = trpc.timeline.getHolidayOverlays.useQuery(
{
startDate: viewStart,
endDate: viewEnd,
...(filters.clientIds.length > 0 ? { clientIds: filters.clientIds } : {}),
...(filters.projectIds.length > 0 ? { projectIds: filters.projectIds } : {}),
...(filters.chapters.length > 0 ? { chapters: filters.chapters } : {}),
...(filters.eids.length > 0 ? { eids: filters.eids } : {}),
...(filters.countryCodes.length > 0 ? { countryCodes: filters.countryCodes } : {}),
},
{ placeholderData: (prev) => prev, refetchOnWindowFocus: false, staleTime: 90_000 },
);
const vacationsByResource = useMemo(() => {
const map = new Map<string, VacationEntry[]>();
for (const vacation of vacationEntries as VacationEntry[]) {
const mergedEntries = [...(vacationEntries as VacationEntry[])];
const existingKeys = new Set(
mergedEntries.map((vacation) => {
const start = new Date(vacation.startDate).toISOString().slice(0, 10);
const end = new Date(vacation.endDate).toISOString().slice(0, 10);
return `${vacation.resourceId}:${vacation.type}:${start}:${end}`;
}),
);
for (const holiday of holidayOverlayEntries as HolidayOverlayEntry[]) {
const start = new Date(holiday.startDate).toISOString().slice(0, 10);
const end = new Date(holiday.endDate).toISOString().slice(0, 10);
const key = `${holiday.resourceId}:${holiday.type}:${start}:${end}`;
if (existingKeys.has(key)) {
continue;
}
existingKeys.add(key);
mergedEntries.push(holiday as VacationEntry);
}
for (const vacation of mergedEntries) {
const existing = map.get(vacation.resourceId);
if (existing) {
existing.push(vacation);
@@ -325,7 +369,7 @@ export function TimelineProvider({
}
}
return map;
}, [vacationEntries]);
}, [holidayOverlayEntries, vacationEntries]);
// When EID filter is active, explicitly fetch those resources.
const { data: eidFilterData } = trpc.resource.list.useQuery(
@@ -2,7 +2,8 @@
import { clsx } from "clsx";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { useRef, useState, type RefObject } from "react";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js";
export interface TimelineFilters {
@@ -159,55 +160,12 @@ export function TimelineFilter({
isOpen,
onClose,
}: TimelineFilterProps) {
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = anchorRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? 320;
const viewportPadding = 16;
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
setPanelPosition({
top: rect.bottom + 8,
left: Math.max(viewportPadding, Math.min(rect.right - panelWidth, maxLeft)),
});
}, [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<HTMLDivElement>({
open: isOpen,
onClose,
align: "end",
triggerRef: anchorRef,
});
if (!isOpen) return null;
@@ -221,7 +179,7 @@ export function TimelineFilter({
return createPortal(
<div
ref={panelRef}
style={{ position: "fixed", top: panelPosition.top, left: panelPosition.left }}
style={{ position: "fixed", top: position.top, left: position.left }}
className="z-[9998] w-80 rounded-2xl border border-gray-200 bg-white p-4 shadow-xl dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
@@ -188,8 +188,10 @@ function TimelineProjectPanelInner({
} | null>(null);
const heatmapTooltipRef = useRef<HTMLDivElement | null>(null);
const vacationTooltipRef = useRef<HTMLDivElement | null>(null);
const demandTooltipRef = useRef<HTMLDivElement | null>(null);
const heatmapTooltipPosRef = useRef({ left: 0, top: 0 });
const vacationTooltipPosRef = useRef({ left: 0, top: 0 });
const demandTooltipPosRef = useRef({ left: 0, top: 0 });
const [heatmapHover, setHeatmapHover] = useState<{
date: Date;
@@ -206,6 +208,22 @@ function TimelineProjectPanelInner({
approvedBy?: { name?: string | null; email: string } | null;
approvedAt?: Date | string | null;
}>(null);
const [demandHover, setDemandHover] = useState<null | {
roleName: string;
roleColor: string;
projectName: string;
projectShortCode?: string | null;
requestedHeadcount: number;
unfilledHeadcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
totalHours: number;
percentage?: number;
status?: string;
totalCostCents?: number;
dailyCostCents?: number;
}>(null);
const { resourceHeatmapById, resourceTotalHoursById } = useMemo(() => {
const dateIndexByTime = new Map<number, number>();
@@ -472,6 +490,7 @@ function TimelineProjectPanelInner({
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
date.setHours(0, 0, 0, 0);
const time = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
@@ -507,18 +526,58 @@ function TimelineProjectPanelInner({
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
const shouldClearDemand = demandHover !== null;
lastHeatmapDayRef.current = -1;
lastHeatmapResourceRef.current = null;
hoveredVacationKeyRef.current = null;
if (shouldClearHeatmap || shouldClearVacation) {
if (shouldClearHeatmap || shouldClearVacation || shouldClearDemand) {
startTransition(() => {
if (shouldClearHeatmap) setHeatmapHover(null);
if (shouldClearVacation) setVacationHover(null);
if (shouldClearDemand) setDemandHover(null);
});
}
}, []);
}, [demandHover]);
const handleDemandHoverMove = useCallback(
(e: React.MouseEvent, demand: TimelineDemandEntry) => {
demandTooltipPosRef.current = { left: e.clientX + 16, top: e.clientY - 36 };
if (demandTooltipRef.current) {
demandTooltipRef.current.style.left = `${demandTooltipPosRef.current.left}px`;
demandTooltipRef.current.style.top = `${demandTooltipPosRef.current.top}px`;
}
const startDate = new Date(demand.startDate);
const endDate = new Date(demand.endDate);
const days = Math.max(1, Math.round((endDate.getTime() - startDate.getTime()) / 86_400_000) + 1);
startTransition(() => {
setDemandHover({
roleName: demand.roleEntity?.name ?? demand.role ?? "Open demand",
roleColor: demand.roleEntity?.color ?? "#f59e0b",
projectName: demand.project.name,
projectShortCode: demand.project.shortCode,
requestedHeadcount: demand.requestedHeadcount,
unfilledHeadcount: demand.unfilledHeadcount,
startDate: demand.startDate,
endDate: demand.endDate,
hoursPerDay: demand.hoursPerDay,
totalHours: demand.hoursPerDay * days,
percentage: demand.percentage,
status: demand.status,
...(demand.dailyCostCents > 0
? {
totalCostCents: demand.dailyCostCents * days,
dailyCostCents: demand.dailyCostCents,
}
: {}),
});
});
},
[],
);
useEffect(
() => () => {
@@ -672,6 +731,8 @@ function TimelineProjectPanelInner({
onAllocMouseDown,
onAllocTouchStart,
onAllocationContextMenu,
handleDemandHoverMove,
clearHoverTooltips,
multiSelectState,
allocDragState,
)
@@ -699,6 +760,9 @@ function TimelineProjectPanelInner({
</div>
<div
data-testid="timeline-project-resource-row-canvas"
data-project-id={row.project.id}
data-resource-id={row.resource.id}
className="relative overflow-hidden touch-none"
style={{
width: totalCanvasWidth,
@@ -792,8 +856,11 @@ function TimelineProjectPanelInner({
heatmapTooltipPos={heatmapTooltipPosRef.current}
vacationTooltipRef={vacationTooltipRef}
vacationTooltipPos={vacationTooltipPosRef.current}
demandTooltipRef={demandTooltipRef}
demandTooltipPos={demandTooltipPosRef.current}
heatmapHover={heatmapHover}
vacationHover={vacationHover}
demandHover={demandHover}
/>
</div>
);
@@ -852,6 +919,8 @@ function renderOpenDemandRow(
anchorX: number,
anchorY: number,
) => void,
onDemandHoverMove: (e: React.MouseEvent, demand: TimelineDemandEntry) => void,
onClearHoverTooltips: () => void,
multiSelectState: MultiSelectState,
allocDragState: AllocDragState,
) {
@@ -889,6 +958,7 @@ function renderOpenDemandRow(
<div
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
style={{ width: totalCanvasWidth, height: rowHeight }}
onMouseLeave={onClearHoverTooltips}
>
{rowGridLines}
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
@@ -962,7 +1032,6 @@ function renderOpenDemandRow(
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
)}
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} ${formatDateLong(allocEnd)}`}
style={{
left: left + 2,
width: width - 4,
@@ -986,6 +1055,7 @@ function renderOpenDemandRow(
e.clientY,
);
}}
onMouseMove={(e) => onDemandHoverMove(e, alloc)}
>
{/* Left resize handle */}
<div
@@ -1,8 +1,9 @@
"use client";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { trpc } from "~/lib/trpc/client.js";
import type { TimelineFilters } from "./TimelineFilter.js";
@@ -20,68 +21,22 @@ function TimelineFilterDropdown({
tooltipContent?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
});
}, []);
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<HTMLDivElement>({
open: isOpen,
onClose: () => setIsOpen(false),
matchTriggerWidth: true,
});
return (
<div ref={dropdownRef} className="relative">
<div ref={triggerRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
onClick={() => {
const nextOpen = !isOpen;
handleOpenChange(nextOpen);
setIsOpen(nextOpen);
}}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
@@ -95,9 +50,9 @@ function TimelineFilterDropdown({
ref={panelRef}
style={{
position: "fixed",
top: panelPosition.top,
left: panelPosition.left,
minWidth: panelPosition.minWidth,
top: position.top,
left: position.left,
minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>
@@ -359,6 +359,7 @@ function TimelineResourcePanelInner({
vacationHoverRafRef.current = requestAnimationFrame(() => {
vacationHoverRafRef.current = null;
const date = xToDate(clientX, rect);
date.setHours(0, 0, 0, 0);
const t = date.getTime();
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
const hit =
@@ -494,6 +495,10 @@ function TimelineResourcePanelInner({
{/* Row canvas */}
<div
data-testid="timeline-resource-row-canvas"
data-resource-id={resource.id}
data-resource-eid={resource.eid}
data-resource-name={resource.displayName}
className="relative overflow-hidden touch-none"
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
onMouseDown={(e) => {
@@ -542,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)}
@@ -1,6 +1,13 @@
"use client";
import { formatDateLong } from "~/lib/format.js";
import { formatCents, formatDateLong } from "~/lib/format.js";
function getVacationTitle(vacation: VacationHoverData): string {
if (vacation.type === "PUBLIC_HOLIDAY" && vacation.note) {
return vacation.note;
}
return vacation.type.replaceAll("_", " ");
}
export type HeatmapHoverData = {
date: Date;
@@ -30,6 +37,23 @@ export type VacationHoverData = {
approvedAt?: Date | string | null;
};
export type DemandHoverData = {
roleName: string;
roleColor: string;
projectName: string;
projectShortCode?: string | null;
requestedHeadcount: number;
unfilledHeadcount: number;
startDate: Date | string;
endDate: Date | string;
hoursPerDay: number;
totalHours: number;
percentage?: number;
status?: string;
totalCostCents?: number;
dailyCostCents?: number;
};
interface TimelineTooltipProps {
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
heatmapTooltipPos: { left: number; top: number };
@@ -37,6 +61,9 @@ interface TimelineTooltipProps {
vacationTooltipPos: { left: number; top: number };
heatmapHover: HeatmapHoverData | null;
vacationHover: VacationHoverData | null;
demandTooltipRef?: React.RefObject<HTMLDivElement | null>;
demandTooltipPos?: { left: number; top: number };
demandHover?: DemandHoverData | null;
}
export function TimelineTooltip({
@@ -46,7 +73,87 @@ export function TimelineTooltip({
vacationTooltipPos,
heatmapHover,
vacationHover,
demandTooltipRef,
demandTooltipPos,
demandHover,
}: TimelineTooltipProps) {
const vacationTitle = vacationHover ? getVacationTitle(vacationHover) : null;
if (demandHover && demandTooltipRef && demandTooltipPos) {
return (
<div
ref={demandTooltipRef}
style={{
left: demandTooltipPos.left,
top: demandTooltipPos.top,
backgroundColor: "rgba(3, 7, 18, 0.96)",
}}
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full flex-shrink-0"
style={{ backgroundColor: demandHover.roleColor }}
/>
<span className="truncate font-semibold">{demandHover.roleName}</span>
</div>
<div className="truncate text-[11px] text-gray-400">
{demandHover.projectShortCode ? `${demandHover.projectShortCode} · ` : ""}
{demandHover.projectName}
</div>
</div>
{demandHover.status ? (
<span className="text-[10px] uppercase tracking-wide text-amber-300">
{demandHover.status}
</span>
) : null}
</div>
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div>
<div className="text-gray-500">Requested</div>
<div className="font-medium text-gray-100">{demandHover.requestedHeadcount}</div>
</div>
<div>
<div className="text-gray-500">Open</div>
<div className="font-medium text-amber-300">{demandHover.unfilledHeadcount}</div>
</div>
<div>
<div className="text-gray-500">Range</div>
<div className="font-medium text-gray-100">
{formatDateLong(demandHover.startDate)} to {formatDateLong(demandHover.endDate)}
</div>
</div>
<div>
<div className="text-gray-500">Load</div>
<div className="font-medium text-gray-100">
{demandHover.hoursPerDay}h/day · {demandHover.totalHours}h
</div>
</div>
{typeof demandHover.percentage === "number" && demandHover.percentage > 0 ? (
<div>
<div className="text-gray-500">Allocation</div>
<div className="font-medium text-gray-100">{demandHover.percentage}%</div>
</div>
) : null}
{typeof demandHover.totalCostCents === "number" && demandHover.totalCostCents > 0 ? (
<div>
<div className="text-gray-500">Cost</div>
<div className="font-medium text-gray-100">
{formatCents(demandHover.totalCostCents)} EUR
{typeof demandHover.dailyCostCents === "number" && demandHover.dailyCostCents > 0
? ` · ${formatCents(demandHover.dailyCostCents)}/d`
: ""}
</div>
</div>
) : null}
</div>
</div>
);
}
// When both are active, render a single merged tooltip using the heatmap position
if (heatmapHover && vacationHover) {
return (
@@ -114,14 +221,12 @@ export function TimelineTooltip({
<div className="mt-2 pt-2 border-t border-amber-700/40">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500 flex-shrink-0" />
<span className="font-semibold text-amber-300">
{vacationHover.type.replaceAll("_", " ")}
</span>
<span className="font-semibold text-amber-300">{vacationTitle}</span>
</div>
<div className="mt-0.5 text-[11px] text-amber-200/80">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-1 text-[11px] text-amber-200/60">{vacationHover.note}</div>
) : null}
</div>
@@ -200,11 +305,11 @@ export function TimelineTooltip({
}}
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
>
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
<div className="font-semibold">{vacationTitle}</div>
<div className="mt-1 text-[11px] text-amber-100/90">
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
</div>
{vacationHover.note ? (
{vacationHover.note && vacationHover.type !== "PUBLIC_HOLIDAY" ? (
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
) : null}
</div>
@@ -35,6 +35,11 @@ export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: num
return (
<div
key={`vac-${v.id}`}
data-testid="timeline-vacation-block"
data-vacation-id={v.id}
data-vacation-type={v.type}
data-vacation-status={v.status}
data-vacation-note={v.note ?? ""}
className={clsx(
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
colorClass,
+118 -90
View File
@@ -1,7 +1,9 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { useState, useRef, useCallback } from "react";
import type { ColumnDef } from "@capakraken/shared";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
interface ColumnTogglePanelProps {
allColumns: ColumnDef[];
@@ -17,18 +19,11 @@ export function ColumnTogglePanel({
defaultKeys,
}: ColumnTogglePanelProps) {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLButtonElement>({
open,
onClose: () => setOpen(false),
align: "end",
});
const dragKey = useRef<string | null>(null);
@@ -59,11 +54,20 @@ export function ColumnTogglePanel({
const builtins = allColumns.filter((c) => !c.isCustom);
const customs = allColumns.filter((c) => c.isCustom);
const handleToggleOpen = useCallback(() => {
setOpen((current) => {
const nextOpen = !current;
handleOpenChange(nextOpen);
return nextOpen;
});
}, [handleOpenChange]);
return (
<div className="relative" ref={panelRef}>
<div className="relative">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((o) => !o)}
onClick={handleToggleOpen}
title="Toggle columns"
className={`p-1.5 rounded-lg border text-sm transition-colors ${
open
@@ -80,83 +84,107 @@ export function ColumnTogglePanel({
</svg>
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 w-52 bg-white border border-gray-200 rounded-xl shadow-xl py-2">
<div className="px-3 pb-1 flex items-center justify-between">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Columns</span>
<button
type="button"
onClick={reset}
className="text-xs text-brand-600 hover:text-brand-800"
>
Reset
</button>
</div>
{open &&
createPortal(
<div
ref={panelRef}
className="fixed z-[9998] w-52 rounded-xl border border-gray-200 bg-white py-2 shadow-xl dark:border-gray-700 dark:bg-gray-900"
style={{ top: position.top, left: position.left }}
>
<div className="flex items-center justify-between px-3 pb-1">
<span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Columns
</span>
<button
type="button"
onClick={reset}
className="text-xs text-brand-600 hover:text-brand-800"
>
Reset
</button>
</div>
<div className="max-h-72 overflow-y-auto">
{builtins.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={col.hideable && isVisible}
onDragStart={() => { dragKey.current = col.key; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 ${
!col.hideable ? "opacity-50" : "cursor-grab"
}`}
>
{col.hideable && isVisible && (
<span className="text-gray-300 text-xs select-none"></span>
)}
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
disabled={!col.hideable}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{col.label}</span>
</label>
</div>
);
})}
<div className="max-h-72 overflow-y-auto">
{builtins.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={col.hideable && isVisible}
onDragStart={() => {
dragKey.current = col.key;
}}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={() => {
if (dragKey.current) reorder(dragKey.current, col.key);
dragKey.current = null;
}}
className={`flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800 ${
!col.hideable ? "opacity-50" : "cursor-grab"
}`}
>
{col.hideable && isVisible && (
<span className="select-none text-xs text-gray-300"></span>
)}
<label className="flex flex-1 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
disabled={!col.hideable}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-200">{col.label}</span>
</label>
</div>
);
})}
{customs.length > 0 && (
<>
<div className="my-1 border-t border-gray-100" />
<p className="px-3 py-1 text-xs text-gray-400 font-medium">Custom Fields</p>
{customs.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={isVisible}
onDragStart={() => { dragKey.current = col.key; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => { if (dragKey.current) reorder(dragKey.current, col.key); dragKey.current = null; }}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-gray-50 cursor-grab"
>
{isVisible && <span className="text-gray-300 text-xs select-none"></span>}
<label className="flex items-center gap-2 flex-1 cursor-pointer">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{col.label}</span>
</label>
</div>
);
})}
</>
)}
</div>
</div>
)}
{customs.length > 0 && (
<>
<div className="my-1 border-t border-gray-100 dark:border-gray-800" />
<p className="px-3 py-1 text-xs font-medium text-gray-400">Custom Fields</p>
{customs.map((col) => {
const isVisible = visibleKeys.includes(col.key);
return (
<div
key={col.key}
draggable={isVisible}
onDragStart={() => {
dragKey.current = col.key;
}}
onDragOver={(event) => {
event.preventDefault();
}}
onDrop={() => {
if (dragKey.current) reorder(dragKey.current, col.key);
dragKey.current = null;
}}
className="flex cursor-grab items-center gap-2 px-3 py-1.5 hover:bg-gray-50 dark:hover:bg-gray-800"
>
{isVisible && <span className="select-none text-xs text-gray-300"></span>}
<label className="flex flex-1 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={isVisible}
onChange={() => toggle(col.key)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-200">
{col.label}
</span>
</label>
</div>
);
})}
</>
)}
</div>
</div>,
document.body,
)}
</div>
);
}
@@ -0,0 +1,865 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
type ScopeType = "COUNTRY" | "STATE" | "CITY";
type CalendarRow = {
id: string;
name: string;
scopeType: ScopeType;
stateCode: string | null;
metroCityId: string | null;
isActive: boolean;
priority: number;
country: { id: string; code: string; name: string };
metroCity: { id: string; name: string } | null;
entries: Array<{
id: string;
date: string | Date;
name: string;
isRecurringAnnual: boolean;
source: string | null;
}>;
_count?: { entries: number };
};
type CountryRow = {
id: string;
code: string;
name: string;
metroCities: { id: string; name: string }[];
};
const SCOPE_LABELS: Record<ScopeType, string> = {
COUNTRY: "Land",
STATE: "Bundesland/Region",
CITY: "Stadt",
};
function formatDate(value: string | Date): string {
return new Date(value).toISOString().slice(0, 10);
}
export function HolidayCalendarEditor() {
const utils = trpc.useUtils();
const [selectedCalendarId, setSelectedCalendarId] = useState<string | null>(null);
const [scopeType, setScopeType] = useState<ScopeType>("COUNTRY");
const [countryId, setCountryId] = useState("");
const [stateCode, setStateCode] = useState("");
const [metroCityId, setMetroCityId] = useState("");
const [name, setName] = useState("");
const [priority, setPriority] = useState(0);
const [entryDate, setEntryDate] = useState("");
const [entryName, setEntryName] = useState("");
const [entryRecurring, setEntryRecurring] = useState(false);
const [entrySource, setEntrySource] = useState("");
const [previewYear, setPreviewYear] = useState(new Date().getFullYear());
const [error, setError] = useState<string | null>(null);
const [calendarDraft, setCalendarDraft] = useState({
name: "",
priority: 0,
stateCode: "",
metroCityId: "",
isActive: true,
});
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
const [entryDraft, setEntryDraft] = useState({
date: "",
name: "",
isRecurringAnnual: false,
source: "",
});
const { data: countries } = trpc.country.list.useQuery();
const { data: calendars } = trpc.holidayCalendar.listCalendars.useQuery({ includeInactive: true });
const selectedCalendar = ((calendars ?? []) as unknown as CalendarRow[]).find((calendar) => calendar.id === selectedCalendarId) ?? null;
const selectedCountry = useMemo(() => {
const rows = (countries ?? []) as unknown as CountryRow[];
return rows.find((country) => country.id === countryId) ?? null;
}, [countries, countryId]);
const selectedCalendarCountry = useMemo(() => {
const rows = (countries ?? []) as unknown as CountryRow[];
return rows.find((country) => country.id === selectedCalendar?.country.id) ?? null;
}, [countries, selectedCalendar]);
const previewQuery = trpc.holidayCalendar.previewResolvedHolidays.useQuery(
{
countryId: selectedCalendar?.country.id ?? countryId,
year: previewYear,
...(selectedCalendar?.stateCode ? { stateCode: selectedCalendar.stateCode } : {}),
...(selectedCalendar?.metroCityId ? { metroCityId: selectedCalendar.metroCityId } : {}),
},
{
enabled: Boolean(selectedCalendar?.country.id ?? countryId),
staleTime: 30_000,
},
);
const invalidate = async () => {
await Promise.all([
utils.holidayCalendar.listCalendars.invalidate(),
utils.holidayCalendar.getCalendarById.invalidate(),
utils.holidayCalendar.previewResolvedHolidays.invalidate(),
]);
};
const createCalendar = trpc.holidayCalendar.createCalendar.useMutation({
onSuccess: async (calendar) => {
await invalidate();
setSelectedCalendarId(calendar.id);
setName("");
setStateCode("");
setMetroCityId("");
setPriority(0);
setError(null);
},
onError: (mutationError) => setError(mutationError.message),
});
const updateCalendar = trpc.holidayCalendar.updateCalendar.useMutation({
onSuccess: async () => {
await invalidate();
setError(null);
},
onError: (mutationError) => setError(mutationError.message),
});
const deleteCalendar = trpc.holidayCalendar.deleteCalendar.useMutation({
onSuccess: async () => {
await invalidate();
setSelectedCalendarId(null);
setError(null);
},
onError: (mutationError) => setError(mutationError.message),
});
const createEntry = trpc.holidayCalendar.createEntry.useMutation({
onSuccess: async () => {
await invalidate();
setEntryDate("");
setEntryName("");
setEntryRecurring(false);
setEntrySource("");
setError(null);
},
onError: (mutationError) => setError(mutationError.message),
});
const updateEntry = trpc.holidayCalendar.updateEntry.useMutation({
onSuccess: async () => {
await invalidate();
setEditingEntryId(null);
setError(null);
},
onError: (mutationError) => setError(mutationError.message),
});
const deleteEntry = trpc.holidayCalendar.deleteEntry.useMutation({
onSuccess: async () => {
await invalidate();
setError(null);
},
onError: (mutationError) => setError(mutationError.message),
});
const countryRows = (countries ?? []) as unknown as CountryRow[];
const calendarRows = (calendars ?? []) as unknown as CalendarRow[];
const isCreateScopeValid = scopeType === "COUNTRY"
? Boolean(countryId && name.trim())
: scopeType === "STATE"
? Boolean(countryId && name.trim() && stateCode.trim())
: Boolean(countryId && name.trim() && metroCityId);
const isCalendarDirty = selectedCalendar !== null && (
calendarDraft.name !== selectedCalendar.name
|| calendarDraft.priority !== selectedCalendar.priority
|| calendarDraft.isActive !== selectedCalendar.isActive
|| calendarDraft.stateCode !== (selectedCalendar.stateCode ?? "")
|| calendarDraft.metroCityId !== (selectedCalendar.metroCityId ?? "")
);
useEffect(() => {
if (!selectedCalendar) {
setCalendarDraft({
name: "",
priority: 0,
stateCode: "",
metroCityId: "",
isActive: true,
});
return;
}
setCalendarDraft({
name: selectedCalendar.name,
priority: selectedCalendar.priority,
stateCode: selectedCalendar.stateCode ?? "",
metroCityId: selectedCalendar.metroCityId ?? "",
isActive: selectedCalendar.isActive,
});
setEditingEntryId(null);
}, [selectedCalendar]);
function handleCreateCalendar(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!isCreateScopeValid) {
setError("Bitte alle Pflichtfelder fuer den gewaehlten Scope ausfuellen.");
return;
}
createCalendar.mutate({
name: name.trim(),
scopeType,
countryId,
...(scopeType === "STATE" && stateCode.trim() ? { stateCode: stateCode.trim().toUpperCase() } : {}),
...(scopeType === "CITY" && metroCityId ? { metroCityId } : {}),
priority,
isActive: true,
});
}
function handleAddEntry(e: React.FormEvent) {
e.preventDefault();
if (!selectedCalendarId) return;
if (!entryDate || !entryName.trim()) {
setError("Datum und Feiertagsname sind erforderlich.");
return;
}
createEntry.mutate({
holidayCalendarId: selectedCalendarId,
date: new Date(`${entryDate}T00:00:00.000Z`),
name: entryName.trim(),
isRecurringAnnual: entryRecurring,
...(entrySource.trim() ? { source: entrySource.trim() } : {}),
});
}
function resetCalendarDraft() {
if (!selectedCalendar) {
return;
}
setCalendarDraft({
name: selectedCalendar.name,
priority: selectedCalendar.priority,
stateCode: selectedCalendar.stateCode ?? "",
metroCityId: selectedCalendar.metroCityId ?? "",
isActive: selectedCalendar.isActive,
});
}
function handleUpdateCalendar(e: React.FormEvent) {
e.preventDefault();
if (!selectedCalendar) {
return;
}
setError(null);
const normalizedStateCode = calendarDraft.stateCode.trim().toUpperCase();
if (selectedCalendar.scopeType === "STATE" && !normalizedStateCode) {
setError("State-Kalender benoetigen einen Regionscode.");
return;
}
if (selectedCalendar.scopeType === "CITY" && !calendarDraft.metroCityId) {
setError("City-Kalender benoetigen eine Stadtzuordnung.");
return;
}
updateCalendar.mutate({
id: selectedCalendar.id,
data: {
name: calendarDraft.name.trim(),
priority: calendarDraft.priority,
isActive: calendarDraft.isActive,
...(selectedCalendar.scopeType === "STATE" ? { stateCode: normalizedStateCode } : {}),
...(selectedCalendar.scopeType === "CITY" ? { metroCityId: calendarDraft.metroCityId } : {}),
},
});
}
function startEditingEntry(entry: CalendarRow["entries"][number]) {
setEditingEntryId(entry.id);
setEntryDraft({
date: formatDate(entry.date),
name: entry.name,
isRecurringAnnual: entry.isRecurringAnnual,
source: entry.source ?? "",
});
}
function handleUpdateEntry(entryId: string) {
if (!entryDraft.date || !entryDraft.name.trim()) {
setError("Ein Feiertagseintrag braucht Datum und Name.");
return;
}
setError(null);
updateEntry.mutate({
id: entryId,
data: {
date: new Date(`${entryDraft.date}T00:00:00.000Z`),
name: entryDraft.name.trim(),
isRecurringAnnual: entryDraft.isRecurringAnnual,
source: entryDraft.source.trim() || null,
},
});
}
function handleDeleteCalendar(calendar: CalendarRow) {
if (deleteCalendar.isPending) {
return;
}
const confirmed = globalThis.confirm(
`Feiertagskalender "${calendar.name}" wirklich loeschen? Alle Eintraege gehen dabei verloren.`,
);
if (!confirmed) {
return;
}
setError(null);
deleteCalendar.mutate({ id: calendar.id });
}
function handleDeleteEntry(entry: CalendarRow["entries"][number]) {
if (deleteEntry.isPending) {
return;
}
const confirmed = globalThis.confirm(
`Feiertag "${entry.name}" am ${formatDate(entry.date)} wirklich entfernen?`,
);
if (!confirmed) {
return;
}
setError(null);
deleteEntry.mutate({ id: entry.id });
}
return (
<div
data-testid="holiday-calendar-editor"
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5"
>
<div>
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Holiday Calendar Editor</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
Pflege Feiertagskalender pro Land, Bundesland/Region oder Stadt. Die Vorschau zeigt den effektiv aufgeloesten Kalender fuer den gewaelten Scope.
</p>
</div>
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
{error}
</div>
)}
<div className="grid gap-5 lg:grid-cols-[1.1fr_1.4fr]">
<form onSubmit={handleCreateCalendar} className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</span>
<input
data-testid="holiday-calendar-name-input"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
placeholder="Bayern Feiertage"
required
/>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Scope</span>
<select
data-testid="holiday-calendar-scope-select"
value={scopeType}
onChange={(e) => setScopeType(e.target.value as ScopeType)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
>
{Object.entries(SCOPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Land</span>
<select
data-testid="holiday-calendar-country-select"
value={countryId}
onChange={(e) => {
setCountryId(e.target.value);
setMetroCityId("");
}}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
required
>
<option value="">Land waehlen</option>
{countryRows.map((country) => (
<option key={country.id} value={country.id}>{country.name} ({country.code})</option>
))}
</select>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
<input
data-testid="holiday-calendar-priority-input"
type="number"
value={priority}
onChange={(e) => setPriority(parseInt(e.target.value, 10) || 0)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
/>
</label>
</div>
{scopeType === "STATE" && (
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
<input
data-testid="holiday-calendar-state-input"
value={stateCode}
onChange={(e) => setStateCode(e.target.value.toUpperCase())}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
placeholder="BY"
required
/>
</label>
)}
{scopeType === "CITY" && (
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
<select
data-testid="holiday-calendar-city-select"
value={metroCityId}
onChange={(e) => setMetroCityId(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
required
>
<option value="">Stadt waehlen</option>
{(selectedCountry?.metroCities ?? []).map((city) => (
<option key={city.id} value={city.id}>{city.name}</option>
))}
</select>
</label>
)}
<button
data-testid="holiday-calendar-create-button"
type="submit"
disabled={createCalendar.isPending || !isCreateScopeValid}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{createCalendar.isPending ? "Speichert..." : "Kalender anlegen"}
</button>
</form>
<div className="space-y-4">
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Kalender</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Scope</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Zuordnung</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Eintraege</th>
</tr>
</thead>
<tbody>
{calendarRows.length === 0 && (
<tr>
<td colSpan={4} className="px-3 py-6 text-center text-sm text-gray-400">Noch keine Feiertagskalender vorhanden.</td>
</tr>
)}
{calendarRows.map((calendar) => (
<tr
key={calendar.id}
data-testid={`holiday-calendar-row-${calendar.id}`}
className={`cursor-pointer border-t border-gray-200 dark:border-gray-700 ${selectedCalendarId === calendar.id ? "bg-brand-50 dark:bg-brand-950/20" : "hover:bg-gray-50 dark:hover:bg-gray-900/40"}`}
onClick={() => setSelectedCalendarId(calendar.id)}
>
<td className="px-3 py-2">
<div className="font-medium text-gray-900 dark:text-gray-100">{calendar.name}</div>
<div className="text-xs text-gray-500">{calendar.country.name}</div>
</td>
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{SCOPE_LABELS[calendar.scopeType]}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
{calendar.scopeType === "COUNTRY" && calendar.country.code}
{calendar.scopeType === "STATE" && calendar.stateCode}
{calendar.scopeType === "CITY" && calendar.metroCity?.name}
</td>
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-400">{calendar._count?.entries ?? calendar.entries.length}</td>
</tr>
))}
</tbody>
</table>
</div>
{selectedCalendar && (
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{selectedCalendar.name}</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{SCOPE_LABELS[selectedCalendar.scopeType]} · {selectedCalendar.country.name}
{selectedCalendar.stateCode ? ` · ${selectedCalendar.stateCode}` : ""}
{selectedCalendar.metroCity?.name ? ` · ${selectedCalendar.metroCity.name}` : ""}
</p>
</div>
<div className="flex gap-2">
<button
data-testid="holiday-calendar-toggle-active-button"
type="button"
onClick={() => updateCalendar.mutate({
id: selectedCalendar.id,
data: { isActive: !selectedCalendar.isActive },
})}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
>
{selectedCalendar.isActive ? "Deaktivieren" : "Aktivieren"}
</button>
<button
data-testid="holiday-calendar-delete-button"
type="button"
onClick={() => handleDeleteCalendar(selectedCalendar)}
disabled={deleteCalendar.isPending}
className="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950/30"
>
Loeschen
</button>
</div>
</div>
<form onSubmit={handleUpdateCalendar} className="grid gap-3 md:grid-cols-2 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Kalendername</span>
<input
data-testid="holiday-calendar-draft-name-input"
value={calendarDraft.name}
onChange={(e) => setCalendarDraft((current) => ({ ...current, name: e.target.value }))}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
required
/>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
<input
data-testid="holiday-calendar-draft-priority-input"
type="number"
value={calendarDraft.priority}
onChange={(e) => setCalendarDraft((current) => ({
...current,
priority: parseInt(e.target.value, 10) || 0,
}))}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
/>
</label>
{selectedCalendar.scopeType === "STATE" && (
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
<input
data-testid="holiday-calendar-draft-state-input"
value={calendarDraft.stateCode}
onChange={(e) => setCalendarDraft((current) => ({
...current,
stateCode: e.target.value.toUpperCase(),
}))}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
placeholder="BY"
required
/>
</label>
)}
{selectedCalendar.scopeType === "CITY" && (
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
<select
data-testid="holiday-calendar-draft-city-select"
value={calendarDraft.metroCityId}
onChange={(e) => setCalendarDraft((current) => ({
...current,
metroCityId: e.target.value,
}))}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
required
>
<option value="">Stadt waehlen</option>
{(selectedCalendarCountry?.metroCities ?? []).map((city) => (
<option key={city.id} value={city.id}>{city.name}</option>
))}
</select>
</label>
)}
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input
type="checkbox"
checked={calendarDraft.isActive}
onChange={(e) => setCalendarDraft((current) => ({
...current,
isActive: e.target.checked,
}))}
className="rounded border-gray-300 dark:border-gray-600"
/>
Kalender aktiv
</label>
<div className="flex items-end justify-end gap-2 md:col-span-2">
<button
data-testid="holiday-calendar-reset-button"
type="button"
onClick={resetCalendarDraft}
disabled={!isCalendarDirty || updateCalendar.isPending}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
>
Zuruecksetzen
</button>
<button
data-testid="holiday-calendar-save-button"
type="submit"
disabled={!isCalendarDirty || updateCalendar.isPending}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{updateCalendar.isPending ? "Speichert..." : "Kalender speichern"}
</button>
</div>
</form>
<form onSubmit={handleAddEntry} className="grid gap-3 md:grid-cols-[1fr_1.25fr_1fr_auto]">
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</span>
<input
data-testid="holiday-entry-date-input"
type="date"
value={entryDate}
onChange={(e) => setEntryDate(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
required
/>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Feiertagsname</span>
<input
data-testid="holiday-entry-name-input"
value={entryName}
onChange={(e) => setEntryName(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
placeholder="Augsburger Friedensfest"
required
/>
</label>
<label className="text-sm text-gray-600 dark:text-gray-300">
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Quelle</span>
<input
data-testid="holiday-entry-source-input"
value={entrySource}
onChange={(e) => setEntrySource(e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
placeholder="Kommunale Satzung"
/>
</label>
<button
data-testid="holiday-entry-create-button"
type="submit"
disabled={createEntry.isPending || !entryDate || !entryName.trim()}
className="self-end rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
Hinzufuegen
</button>
</form>
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
<input
data-testid="holiday-entry-recurring-checkbox"
type="checkbox"
checked={entryRecurring}
onChange={(e) => setEntryRecurring(e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
Jaehrlich wiederkehrend
</label>
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Typ</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Aktion</th>
</tr>
</thead>
<tbody>
{selectedCalendar.entries.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-sm text-gray-400">Keine Eintraege vorhanden.</td>
</tr>
)}
{selectedCalendar.entries.map((entry) => (
<tr key={entry.id} className="border-t border-gray-200 dark:border-gray-700">
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">
{editingEntryId === entry.id ? (
<input
data-testid={`holiday-entry-edit-date-${entry.id}`}
type="date"
value={entryDraft.date}
onChange={(e) => setEntryDraft((current) => ({ ...current, date: e.target.value }))}
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
/>
) : formatDate(entry.date)}
</td>
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">
{editingEntryId === entry.id ? (
<input
data-testid={`holiday-entry-edit-name-${entry.id}`}
value={entryDraft.name}
onChange={(e) => setEntryDraft((current) => ({ ...current, name: e.target.value }))}
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
/>
) : entry.name}
</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
{editingEntryId === entry.id ? (
<label className="flex items-center gap-2">
<input
data-testid={`holiday-entry-edit-recurring-${entry.id}`}
type="checkbox"
checked={entryDraft.isRecurringAnnual}
onChange={(e) => setEntryDraft((current) => ({
...current,
isRecurringAnnual: e.target.checked,
}))}
className="rounded border-gray-300 dark:border-gray-600"
/>
Jaehrlich
</label>
) : entry.isRecurringAnnual ? "jaehrlich" : "fix"}
</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
{editingEntryId === entry.id ? (
<input
data-testid={`holiday-entry-edit-source-${entry.id}`}
value={entryDraft.source}
onChange={(e) => setEntryDraft((current) => ({ ...current, source: e.target.value }))}
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
placeholder="Quelle"
/>
) : entry.source ?? "System/ohne Quelle"}
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-3">
{editingEntryId === entry.id ? (
<>
<button
data-testid={`holiday-entry-save-${entry.id}`}
type="button"
onClick={() => handleUpdateEntry(entry.id)}
disabled={updateEntry.isPending || !entryDraft.date || !entryDraft.name.trim()}
className="text-xs font-medium text-brand-600 hover:text-brand-700 disabled:opacity-50"
>
Speichern
</button>
<button
data-testid={`holiday-entry-cancel-${entry.id}`}
type="button"
onClick={() => setEditingEntryId(null)}
disabled={updateEntry.isPending}
className="text-xs font-medium text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
Abbrechen
</button>
</>
) : (
<button
data-testid={`holiday-entry-edit-${entry.id}`}
type="button"
onClick={() => startEditingEntry(entry)}
className="text-xs font-medium text-brand-600 hover:text-brand-700"
>
Bearbeiten
</button>
)}
<button
data-testid={`holiday-entry-delete-${entry.id}`}
type="button"
onClick={() => handleDeleteEntry(entry)}
disabled={deleteEntry.isPending}
className="text-xs font-medium text-red-600 hover:text-red-700"
>
Entfernen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="space-y-3 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Vorschau</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">Effektiv aufgeloeste Feiertage fuer den gewaehlten Scope.</p>
</div>
<input
data-testid="holiday-preview-year-input"
type="number"
value={previewYear}
onChange={(e) => setPreviewYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
className="w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
/>
</div>
<div className="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table data-testid="holiday-preview-table" className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
</tr>
</thead>
<tbody>
{(previewQuery.data ?? []).length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-6 text-center text-sm text-gray-400">
{previewQuery.isLoading ? "Laedt Vorschau..." : "Keine Feiertage fuer diese Auswahl vorhanden."}
</td>
</tr>
)}
{(previewQuery.data ?? []).map((entry) => (
<tr key={`${entry.date}-${entry.name}`} className="border-t border-gray-200 dark:border-gray-700">
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{entry.date}</td>
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">{entry.name}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{entry.calendarName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import Link from "next/link";
import { VacationStatus, VacationType } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { VacationModal } from "./VacationModal.js";
@@ -137,6 +138,13 @@ export function VacationClient() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Regional public holidays are maintained in{" "}
<Link href="/admin/vacations" className="font-medium text-brand-700 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300">
Holiday Calendars
</Link>
.
</p>
</div>
<button
type="button"
@@ -10,6 +10,34 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
const VACATION_TYPES = Object.values(VacationType);
const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter((type) => type !== VacationType.PUBLIC_HOLIDAY);
const HOLIDAY_SOURCE_LABELS = {
CALENDAR: "Calendar",
LEGACY_PUBLIC_HOLIDAY: "Legacy import",
CALENDAR_AND_LEGACY: "Calendar + legacy",
} as const;
type VacationPreviewData = {
requestedDays: number;
effectiveDays: number;
deductedDays: number;
publicHolidayDates: string[];
holidayDetails: Array<{
date: string;
source: string;
}>;
holidayContext: {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
sources: {
hasCalendarHolidays: boolean;
hasLegacyPublicHolidayEntries: boolean;
};
};
};
interface VacationModalProps {
resourceId?: string;
@@ -17,13 +45,34 @@ interface VacationModalProps {
onSuccess: () => void;
}
function toDateInputValue(date: Date | string | null | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
function toUtcInputDate(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
}
function buildHolidayBasisLabel(preview: VacationPreviewData): string[] {
const parts = [];
if (preview.holidayContext.countryName || preview.holidayContext.countryCode) {
parts.push(preview.holidayContext.countryName ?? preview.holidayContext.countryCode ?? "");
}
if (preview.holidayContext.federalState) {
parts.push(preview.holidayContext.federalState);
}
if (preview.holidayContext.metroCityName) {
parts.push(preview.holidayContext.metroCityName);
}
return parts.filter(Boolean);
}
function getHolidaySourceLabel(source: string): string {
if (source in HOLIDAY_SOURCE_LABELS) {
return HOLIDAY_SOURCE_LABELS[source as keyof typeof HOLIDAY_SOURCE_LABELS];
}
return source;
}
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
@@ -70,6 +119,24 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
);
const previewQuery = trpc.vacation.previewRequest.useQuery(
{
resourceId,
type,
startDate: toUtcInputDate(debouncedStart || "1970-01-01"),
endDate: toUtcInputDate(debouncedEnd || "1970-01-01"),
...(isHalfDay ? { isHalfDay: true } : {}),
},
{
enabled:
!!resourceId
&& !!debouncedStart
&& !!debouncedEnd
&& (!isHalfDay || debouncedStart === debouncedEnd),
staleTime: 10_000,
},
);
const utils = trpc.useUtils();
const createMutation = trpc.vacation.create.useMutation({
@@ -166,7 +233,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
{/* Type */}
<div>
<label htmlFor="vac-type" className={labelClass}>
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · PUBLIC_HOLIDAY = national/regional holiday · OTHER = special leave." />
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." />
</label>
<select
id="vac-type"
@@ -174,7 +241,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
onChange={(e) => setType(e.target.value as VacationType)}
className={inputClass}
>
{VACATION_TYPES.map((t) => (
{REQUESTABLE_VACATION_TYPES.map((t) => (
<option key={t} value={t}>
{VACATION_TYPE_LABELS[t]}
</option>
@@ -282,6 +349,81 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
</div>
)}
{!!resourceId && !!startDate && !!endDate && (
<div
data-testid="vacation-preview-card"
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900"
>
<div className="flex items-center justify-between gap-3">
<strong>Leave preview</strong>
{previewQuery.isLoading && (
<span className="text-xs text-emerald-700">Calculating</span>
)}
</div>
{previewQuery.data && (
<div className="mt-2 space-y-2">
<div className="grid grid-cols-3 gap-2 text-xs sm:text-sm">
<div className="rounded-md bg-white/70 px-3 py-2">
<div className="text-emerald-700">Requested</div>
<div data-testid="vacation-preview-requested-days" className="font-semibold">
{previewQuery.data.requestedDays}
</div>
</div>
<div className="rounded-md bg-white/70 px-3 py-2">
<div className="text-emerald-700">Effective</div>
<div data-testid="vacation-preview-effective-days" className="font-semibold">
{previewQuery.data.effectiveDays}
</div>
</div>
<div className="rounded-md bg-white/70 px-3 py-2">
<div className="text-emerald-700">Deducted</div>
<div data-testid="vacation-preview-deducted-days" className="font-semibold">
{previewQuery.data.deductedDays}
</div>
</div>
</div>
{buildHolidayBasisLabel(previewQuery.data).length > 0 && (
<div data-testid="vacation-preview-holiday-basis" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
<span className="font-medium">Holiday basis:</span>{" "}
{buildHolidayBasisLabel(previewQuery.data).join(" / ")}
</div>
)}
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays || previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
<div data-testid="vacation-preview-holiday-sources" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
<span className="font-medium">Sources:</span>{" "}
{[
previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null,
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null,
].filter(Boolean).join(" + ")}
</div>
)}
{previewQuery.data.publicHolidayDates.length > 0 && (
<div data-testid="vacation-preview-public-holidays" className="text-xs sm:text-sm">
<span className="font-medium">Excluded public holidays:</span>{" "}
{previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")}
</div>
)}
{previewQuery.data.requestedDays !== previewQuery.data.deductedDays && (
<div className="text-xs sm:text-sm text-emerald-800">
Public holidays in the selected range are excluded from deducted leave days.
</div>
)}
</div>
)}
{previewQuery.error && (
<div className="mt-2 text-xs text-red-700">
{previewQuery.error.message}
</div>
)}
</div>
)}
{/* Note */}
<div>
<label htmlFor="vac-note" className={labelClass}>
+155
View File
@@ -0,0 +1,155 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
type HorizontalAlign = "start" | "end" | "center";
type VerticalAlign = "start" | "end" | "center";
type OverlaySide = "bottom" | "right";
interface UseAnchoredOverlayOptions<TTrigger extends HTMLElement> {
open: boolean;
onClose: () => void;
offset?: number;
viewportPadding?: number;
side?: OverlaySide;
align?: HorizontalAlign;
crossAlign?: VerticalAlign;
matchTriggerWidth?: boolean;
triggerRef?: RefObject<TTrigger | null>;
}
interface OverlayPosition {
top: number;
left: number;
minWidth?: number;
}
export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
open,
onClose,
offset = 8,
viewportPadding = 16,
side = "bottom",
align = "start",
crossAlign = "start",
matchTriggerWidth = false,
triggerRef: externalTriggerRef,
}: UseAnchoredOverlayOptions<TTrigger>) {
const internalTriggerRef = useRef<TTrigger | null>(null);
const triggerRef = externalTriggerRef ?? internalTriggerRef;
const panelRef = useRef<HTMLDivElement | null>(null);
const [position, setPosition] = useState<OverlayPosition>({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
const trigger = triggerRef.current;
if (!trigger) {
return;
}
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const panelHeight = panelRef.current?.offsetHeight ?? 0;
let nextTop = rect.bottom + offset;
let nextLeft = rect.left;
if (side === "right") {
nextLeft = rect.right + offset;
if (crossAlign === "center") {
nextTop = rect.top + rect.height / 2 - panelHeight / 2;
} else if (crossAlign === "end") {
nextTop = rect.bottom - panelHeight;
} else {
nextTop = rect.top;
}
} else {
if (align === "end") {
nextLeft = rect.right - panelWidth;
} else if (align === "center") {
nextLeft = rect.left + rect.width / 2 - panelWidth / 2;
}
nextTop = rect.bottom + offset;
const nextBottom = nextTop + panelHeight;
const flippedTop = rect.top - panelHeight - offset;
if (panelHeight > 0 && nextBottom > window.innerHeight - viewportPadding && flippedTop >= viewportPadding) {
nextTop = flippedTop;
}
}
const boundedLeft = Math.min(
Math.max(nextLeft, viewportPadding),
Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding),
);
const boundedTop = Math.min(
Math.max(nextTop, viewportPadding),
Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding),
);
setPosition({
top: boundedTop,
left: boundedLeft,
...(matchTriggerWidth ? { minWidth: rect.width } : {}),
});
}, [align, crossAlign, matchTriggerWidth, offset, side, triggerRef, viewportPadding]);
useEffect(() => {
if (!open) {
return;
}
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
onClose();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [onClose, open]);
useEffect(() => {
if (!open) {
return;
}
updatePosition();
const rafId = window.requestAnimationFrame(updatePosition);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [open, updatePosition]);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (nextOpen) {
updatePosition();
return;
}
onClose();
}, [onClose, updatePosition]);
return {
triggerRef,
panelRef,
position,
updatePosition,
handleOpenChange,
};
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
type PopoverAnchor =
| { kind: "point"; x: number; y: number }
| { kind: "element"; element: HTMLElement };
type PopoverSide = "bottom" | "right";
type PopoverAlign = "start" | "end" | "center";
interface UseViewportPopoverOptions {
anchor: PopoverAnchor;
width: number;
estimatedHeight: number;
onClose: () => void;
side?: PopoverSide;
align?: PopoverAlign;
offset?: number;
viewportPadding?: number;
ignoreElements?: Array<HTMLElement | null>;
}
export function useViewportPopover({
anchor,
width,
estimatedHeight,
onClose,
side = "bottom",
align = "start",
offset = 8,
viewportPadding = 16,
ignoreElements = [],
}: UseViewportPopoverOptions) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (ref.current?.contains(target)) {
return;
}
if (ignoreElements.some((element) => element?.contains(target))) {
return;
}
onClose();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("mousedown", handlePointerDown);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
window.removeEventListener("keydown", handleEscape);
};
}, [ignoreElements, onClose]);
const style = useMemo<CSSProperties>(() => {
let left = 0;
let top = 0;
if (anchor.kind === "element") {
const rect = anchor.element.getBoundingClientRect();
if (side === "right") {
left = rect.right + offset;
if (align === "end") {
top = rect.bottom - estimatedHeight;
} else if (align === "center") {
top = rect.top + rect.height / 2 - estimatedHeight / 2;
} else {
top = rect.top;
}
} else {
left = rect.left;
if (align === "end") {
left = rect.right - width;
} else if (align === "center") {
left = rect.left + rect.width / 2 - width / 2;
}
top = rect.bottom + offset;
}
} else {
left = anchor.x;
top = anchor.y + offset;
if (align === "end") {
left = anchor.x - width;
} else if (align === "center") {
left = anchor.x - width / 2;
}
}
const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedHeight - viewportPadding);
return {
position: "fixed",
left: Math.min(Math.max(left, viewportPadding), maxLeft),
top: Math.min(Math.max(top, viewportPadding), maxTop),
width,
zIndex: 60,
};
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width]);
return { ref, style };
}
+5 -2
View File
@@ -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({