feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user