refactor(web): decompose TimelineView, ReportBuilder, and ResourceModal into focused components

Extract overlay/popover JSX from TimelineView (1268→1037 lines) into TimelineDragOverlays and
TimelinePopovers. Extract ResourceMonthConfigSection from ReportBuilder (1132→1018 lines).
Extract ResourceSkillsEditor and ResourceOrgClassification from ResourceModal (1035→714 lines).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 23:16:38 +02:00
parent 5a4836d292
commit bfcadd2c52
8 changed files with 1224 additions and 785 deletions
+15 -129
View File
@@ -10,6 +10,7 @@ import {
type ReportExplainability, type ReportExplainability,
} from "./reportBuilderExplainability.js"; } from "./reportBuilderExplainability.js";
import { ReportResultsPanel } from "./ReportResultsPanel.js"; import { ReportResultsPanel } from "./ReportResultsPanel.js";
import { ResourceMonthConfigSection } from "./ResourceMonthConfigSection.js";
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@@ -753,135 +754,20 @@ export function ReportBuilder() {
))} ))}
</div> </div>
{entity === "resource_month" && ( {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"> <ResourceMonthConfigSection
<div className="flex flex-wrap items-end gap-4"> periodMonth={periodMonth}
<div> onPeriodMonthChange={setPeriodMonth}
<label className="mb-1 block text-sm font-medium text-emerald-900 dark:text-emerald-200"> blueprints={resourceMonthBlueprints}
Period month onApplyBlueprint={applyBlueprint}
</label> completeness={displayedResourceMonthCompleteness}
<input selectedTemplate={selectedTemplate}
type="month" hasTemplateDraftChanges={hasTemplateDraftChanges}
value={periodMonth} selectedColumns={selectedColumns}
onChange={(e) => setPeriodMonth(e.target.value)} onToggleColumn={toggleColumn}
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" columnLabelMap={columnLabelMap}
/> recommendedColumns={RESOURCE_MONTH_RECOMMENDED_COLUMNS}
</div> summarizeMissing={summarizeMissingColumns}
<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">
{displayedResourceMonthCompleteness ? (
<div className="mb-4 rounded-2xl border border-emerald-200/80 bg-emerald-50/80 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-center gap-2">
<span
className={clsx(
"rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
displayedResourceMonthCompleteness.isAuditReady
? "bg-emerald-500 text-white"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200",
)}
>
{displayedResourceMonthCompleteness.isAuditReady
? "Audit ready"
: "Audit gap"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{displayedResourceMonthCompleteness.selectedMinimumAuditColumnCount}/
{displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit
columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{displayedResourceMonthCompleteness.selectedRecommendedColumnCount}/
{displayedResourceMonthCompleteness.recommendedColumnCount} recommended
columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] text-emerald-900/80 dark:bg-slate-950 dark:text-emerald-200/80">
{selectedTemplate && !hasTemplateDraftChanges
? "Saved template status"
: "Current builder status"}
</span>
</div>
{displayedResourceMonthCompleteness.missingMinimumAuditColumns.length > 0 ? (
<p className="mt-3 text-xs text-amber-800 dark:text-amber-200">
Missing audit/export basis columns:{" "}
{summarizeMissingColumns(
displayedResourceMonthCompleteness.missingMinimumAuditColumns,
columnLabelMap,
)}
</p>
) : displayedResourceMonthCompleteness.missingRecommendedColumns.length > 0 ? (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
Audit-ready, but still missing recommended basis columns:{" "}
{summarizeMissingColumns(
displayedResourceMonthCompleteness.missingRecommendedColumns,
columnLabelMap,
)}
</p>
) : (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
This view includes the full recommended audit/export basis set for monthly
SAH and chargeability checks.
</p>
)}
</div>
) : null}
<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>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Minimum audit set: month, location context, SAH, holiday deductions, absence
deductions, target hours, booked hours and unassigned hours.
</p>
</div>
</div>
)} )}
</div> </div>
@@ -0,0 +1,168 @@
import { clsx } from "clsx";
interface ResourceMonthTemplateCompleteness {
scope: "resource_month";
isAuditReady: boolean;
isRecommendedComplete: boolean;
recommendedColumnCount: number;
selectedRecommendedColumnCount: number;
minimumAuditColumnCount: number;
selectedMinimumAuditColumnCount: number;
missingRecommendedColumns: string[];
missingMinimumAuditColumns: string[];
}
interface ResourceMonthConfigSectionProps<
TBlueprint extends { id: string; label: string; description: string },
> {
periodMonth: string;
onPeriodMonthChange: (value: string) => void;
blueprints: TBlueprint[];
onApplyBlueprint: (blueprint: TBlueprint) => void;
completeness: ResourceMonthTemplateCompleteness | null;
selectedTemplate: { isShared?: boolean; isOwner?: boolean } | null;
hasTemplateDraftChanges: boolean;
selectedColumns: Set<string>;
onToggleColumn: (column: string) => void;
columnLabelMap: Map<string, string>;
recommendedColumns: readonly string[];
summarizeMissing: (columns: string[], labelMap: Map<string, string>) => string;
}
export function ResourceMonthConfigSection<
TBlueprint extends { id: string; label: string; description: string },
>({
periodMonth,
onPeriodMonthChange,
blueprints,
onApplyBlueprint,
completeness,
selectedTemplate,
hasTemplateDraftChanges,
selectedColumns,
onToggleColumn,
columnLabelMap,
recommendedColumns,
summarizeMissing,
}: ResourceMonthConfigSectionProps<TBlueprint>) {
return (
<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) => onPeriodMonthChange(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">
{blueprints.map((blueprint) => (
<button
key={blueprint.id}
type="button"
onClick={() => onApplyBlueprint(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">
{completeness ? (
<div className="mb-4 rounded-2xl border border-emerald-200/80 bg-emerald-50/80 p-4 dark:border-emerald-900/60 dark:bg-emerald-950/20">
<div className="flex flex-wrap items-center gap-2">
<span
className={clsx(
"rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em]",
completeness.isAuditReady
? "bg-emerald-500 text-white"
: "bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-200",
)}
>
{completeness.isAuditReady ? "Audit ready" : "Audit gap"}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{completeness.selectedMinimumAuditColumnCount}/
{completeness.minimumAuditColumnCount} minimum audit columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-900 dark:bg-slate-950 dark:text-emerald-100">
{completeness.selectedRecommendedColumnCount}/{completeness.recommendedColumnCount}{" "}
recommended columns
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] text-emerald-900/80 dark:bg-slate-950 dark:text-emerald-200/80">
{selectedTemplate && !hasTemplateDraftChanges
? "Saved template status"
: "Current builder status"}
</span>
</div>
{completeness.missingMinimumAuditColumns.length > 0 ? (
<p className="mt-3 text-xs text-amber-800 dark:text-amber-200">
Missing audit/export basis columns:{" "}
{summarizeMissing(completeness.missingMinimumAuditColumns, columnLabelMap)}
</p>
) : completeness.missingRecommendedColumns.length > 0 ? (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
Audit-ready, but still missing recommended basis columns:{" "}
{summarizeMissing(completeness.missingRecommendedColumns, columnLabelMap)}
</p>
) : (
<p className="mt-3 text-xs text-emerald-900/80 dark:text-emerald-200/80">
This view includes the full recommended audit/export basis set for monthly SAH and
chargeability checks.
</p>
)}
</div>
) : null}
<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">
{recommendedColumns.map((column) => (
<button
key={column}
type="button"
onClick={() => onToggleColumn(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>
<p className="mt-2 text-xs text-emerald-900/75 dark:text-emerald-200/75">
Minimum audit set: month, location context, SAH, holiday deductions, absence deductions,
target hours, booked hours and unassigned hours.
</p>
</div>
</div>
);
}
@@ -2,11 +2,12 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import type { Resource, SkillEntry } from "@capakraken/shared"; import type { Resource, SkillEntry, ResourceType } from "@capakraken/shared";
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js"; import { usePermissions } from "~/hooks/usePermissions.js";
import { ResourceOrgClassification } from "./ResourceOrgClassification.js";
import { ResourceSkillsEditor } from "./ResourceSkillsEditor.js";
interface RoleAssignment { interface RoleAssignment {
roleId: string; roleId: string;
@@ -105,10 +106,14 @@ function resourceToFormState(resource: Resource): FormState {
countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "", countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "",
metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "", metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "",
orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "", orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "",
managementLevelGroupId: (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ?? "", managementLevelGroupId:
managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "", (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ??
"",
managementLevelId:
(resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "",
resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE", resourceType: (resource as unknown as { resourceType?: string }).resourceType ?? "EMPLOYEE",
chgResponsibility: (resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true, chgResponsibility:
(resource as unknown as { chgResponsibility?: boolean }).chgResponsibility ?? true,
rolledOff: (resource as unknown as { rolledOff?: boolean }).rolledOff ?? false, rolledOff: (resource as unknown as { rolledOff?: boolean }).rolledOff ?? false,
departed: (resource as unknown as { departed?: boolean }).departed ?? false, departed: (resource as unknown as { departed?: boolean }).departed ?? false,
enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "", enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "",
@@ -154,7 +159,14 @@ function defaultFormState(): FormState {
} }
function defaultSkillRow(): SkillRow { function defaultSkillRow(): SkillRow {
return { skill: "", proficiency: 3, yearsExperience: "", category: "", certified: false, isMainSkill: false }; return {
skill: "",
proficiency: 3,
yearsExperience: "",
category: "",
certified: false,
isMainSkill: false,
};
} }
interface ResourceModalProps { interface ResourceModalProps {
@@ -167,7 +179,8 @@ interface ResourceModalProps {
const INPUT_CLASS = const INPUT_CLASS =
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100"; "w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100";
const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; const LABEL_CLASS = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
const SECTION_HEADER_CLASS = "text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-4"; const SECTION_HEADER_CLASS =
"text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-4";
const PRIMARY_BTN = const PRIMARY_BTN =
"px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"; "px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50";
@@ -211,7 +224,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
const { data: countries } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 }); const { data: countries } = trpc.country.list.useQuery(undefined, { staleTime: 60_000 });
const { data: orgUnits } = trpc.orgUnit.list.useQuery(undefined, { staleTime: 60_000 }); const { data: orgUnits } = trpc.orgUnit.list.useQuery(undefined, { staleTime: 60_000 });
const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, { staleTime: 60_000 }); const { data: mgmtGroups } = trpc.managementLevel.listGroups.useQuery(undefined, {
staleTime: 60_000,
});
const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 }); const { data: clients } = trpc.clientEntity.list.useQuery(undefined, { staleTime: 60_000 });
const roleOptions = (availableRoles ?? []) as unknown as RoleOption[]; const roleOptions = (availableRoles ?? []) as unknown as RoleOption[];
@@ -220,14 +235,6 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
const managementGroupOptions = (mgmtGroups ?? []) as unknown as ManagementGroupOption[]; const managementGroupOptions = (mgmtGroups ?? []) as unknown as ManagementGroupOption[];
const clientOptions = (clients ?? []) as unknown as ClientOption[]; const clientOptions = (clients ?? []) as unknown as ClientOption[];
// Derive metro cities from selected country
const selectedCountry = countryOptions.find((c) => c.id === form.countryId);
const metroCities = selectedCountry?.metroCities ?? [];
// Derive levels from selected group
const selectedGroup = managementGroupOptions.find((g) => g.id === form.managementLevelGroupId);
const mgmtLevels = selectedGroup?.levels ?? [];
const createMutation = trpc.resource.create.useMutation(); const createMutation = trpc.resource.create.useMutation();
const updateMutation = trpc.resource.update.useMutation(); const updateMutation = trpc.resource.update.useMutation();
const hardDeleteMutation = trpc.resource.hardDelete.useMutation({ const hardDeleteMutation = trpc.resource.hardDelete.useMutation({
@@ -240,7 +247,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
}, },
}); });
const isMutating = createMutation.isPending || updateMutation.isPending || hardDeleteMutation.isPending; const isMutating =
createMutation.isPending || updateMutation.isPending || hardDeleteMutation.isPending;
function setField<K extends keyof FormState>(key: K, value: FormState[K]) { function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value })); setForm((prev) => ({ ...prev, [key]: value }));
@@ -306,7 +314,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
...(form.countryId ? { countryId: form.countryId } : {}), ...(form.countryId ? { countryId: form.countryId } : {}),
...(form.metroCityId ? { metroCityId: form.metroCityId } : {}), ...(form.metroCityId ? { metroCityId: form.metroCityId } : {}),
...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}), ...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}),
...(form.managementLevelGroupId ? { managementLevelGroupId: form.managementLevelGroupId } : {}), ...(form.managementLevelGroupId
? { managementLevelGroupId: form.managementLevelGroupId }
: {}),
...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}), ...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}),
resourceType: form.resourceType as ResourceType, resourceType: form.resourceType as ResourceType,
chgResponsibility: form.chgResponsibility, chgResponsibility: form.chgResponsibility,
@@ -345,14 +355,6 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
} }
} }
const proficiencyLabels: Record<number, string> = {
1: "1 Beginner",
2: "2 Elementary",
3: "3 Intermediate",
4: "4 Advanced",
5: "5 Expert",
};
return ( return (
<div <div
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8" className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
@@ -363,7 +365,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<div <div
ref={panelRef} ref={panelRef}
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4" className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-2xl mx-4"
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }} onKeyDown={(e) => {
if (e.key === "Escape") onClose();
}}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
@@ -376,7 +380,13 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors" className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Close modal" aria-label="Close modal"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -391,7 +401,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-eid"> <label className={LABEL_CLASS} htmlFor="rm-eid">
Employee ID <span className="text-red-500">*</span><InfoTooltip content="Unique employee identifier (e.g. EMP-042). Used for imports and cross-referencing." /> Employee ID <span className="text-red-500">*</span>
<InfoTooltip content="Unique employee identifier (e.g. EMP-042). Used for imports and cross-referencing." />
</label> </label>
<input <input
id="rm-eid" id="rm-eid"
@@ -405,7 +416,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-displayName"> <label className={LABEL_CLASS} htmlFor="rm-displayName">
Display Name <span className="text-red-500">*</span><InfoTooltip content="Full name shown in the timeline, reports, and staffing views." /> Display Name <span className="text-red-500">*</span>
<InfoTooltip content="Full name shown in the timeline, reports, and staffing views." />
</label> </label>
<input <input
id="rm-displayName" id="rm-displayName"
@@ -433,7 +445,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-chapter"> <label className={LABEL_CLASS} htmlFor="rm-chapter">
Chapter <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span> Chapter{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label> </label>
<input <input
id="rm-chapter" id="rm-chapter"
@@ -445,7 +458,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
list="rm-chapter-list" list="rm-chapter-list"
/> />
<datalist id="rm-chapter-list"> <datalist id="rm-chapter-list">
{chapters?.map((c) => <option key={c} value={c} />)} {chapters?.map((c) => (
<option key={c} value={c} />
))}
</datalist> </datalist>
</div> </div>
</div> </div>
@@ -454,7 +469,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<div className="grid grid-cols-2 gap-4 mt-4"> <div className="grid grid-cols-2 gap-4 mt-4">
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-portfolioUrl"> <label className={LABEL_CLASS} htmlFor="rm-portfolioUrl">
Portfolio URL <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span> Portfolio URL{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</label> </label>
<input <input
id="rm-portfolioUrl" id="rm-portfolioUrl"
@@ -467,7 +483,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-roleId"> <label className={LABEL_CLASS} htmlFor="rm-roleId">
Area of Expertise <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="The resource's primary area role. Used for skill matrix grouping and AI summary generation." /> Area of Expertise{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
<InfoTooltip content="The resource's primary area role. Used for skill matrix grouping and AI summary generation." />
</label> </label>
<select <select
id="rm-roleId" id="rm-roleId"
@@ -477,241 +495,25 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
> >
<option value=""> Not specified </option> <option value=""> Not specified </option>
{roleOptions.map((r) => ( {roleOptions.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option> <option key={r.id} value={r.id}>
{r.name}
</option>
))} ))}
</select> </select>
</div> </div>
</div> </div>
{/* Postal Code & Federal State */} <ResourceOrgClassification
<div className="grid grid-cols-2 gap-4 mt-4"> form={form}
<div> onSetField={setField as (key: string, value: string | boolean) => void}
<label className={LABEL_CLASS} htmlFor="rm-postalCode"> countryOptions={countryOptions}
Postal Code (PLZ) <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="German postal code. Used to auto-derive the federal state for public holiday calculations." /> orgUnitOptions={orgUnitOptions}
</label> clientOptions={clientOptions}
<input managementGroupOptions={managementGroupOptions}
id="rm-postalCode" inputClass={INPUT_CLASS}
type="text" labelClass={LABEL_CLASS}
className={INPUT_CLASS} sectionHeaderClass={SECTION_HEADER_CLASS}
placeholder="80331" />
maxLength={5}
value={form.postalCode}
onChange={(e) => {
const plz = e.target.value;
setField("postalCode", plz);
if (/^\d{5}$/.test(plz)) {
const inferred = inferStateFromPostalCode(plz);
if (inferred && !form.federalState) {
setField("federalState", inferred);
}
}
}}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-federalState">
Federal State <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="Determines which public holidays apply (e.g. Bavaria has extra holidays). Auto-derived from postal code." />
</label>
<select
id="rm-federalState"
className={INPUT_CLASS}
value={form.federalState}
onChange={(e) => setField("federalState", e.target.value)}
>
<option value=""> Not specified </option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>{name} ({abbr})</option>
))}
</select>
</div>
</div>
{/* Section: Organization & Classification */}
<p className={SECTION_HEADER_CLASS}>Organization &amp; Classification</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-enterpriseId">
Enterprise ID <span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span><InfoTooltip content="Corporate directory ID for cross-system integration (e.g. a.kasperovich)." />
</label>
<input
id="rm-enterpriseId"
type="text"
className={INPUT_CLASS}
placeholder="a.kasperovich"
value={form.enterpriseId}
onChange={(e) => setField("enterpriseId", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-fte">
FTE<InfoTooltip content="Full-Time Equivalent (0.01-1.0). A value of 0.5 means the resource works 50% of standard hours." />
</label>
<input
id="rm-fte"
type="number"
min="0.01"
max="1"
step="0.01"
className={INPUT_CLASS}
placeholder="1.0"
value={form.fte}
onChange={(e) => setField("fte", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-countryId">Country</label>
<select
id="rm-countryId"
className={INPUT_CLASS}
value={form.countryId}
onChange={(e) => {
setField("countryId", e.target.value);
setField("metroCityId", ""); // reset city when country changes
}}
>
<option value=""> Not specified </option>
{countryOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-metroCityId">Metro City</label>
<select
id="rm-metroCityId"
className={INPUT_CLASS}
value={form.metroCityId}
onChange={(e) => setField("metroCityId", e.target.value)}
disabled={!form.countryId}
>
<option value=""> Not specified </option>
{metroCities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-orgUnitId">Org Unit (L7 Team)</label>
<select
id="rm-orgUnitId"
className={INPUT_CLASS}
value={form.orgUnitId}
onChange={(e) => setField("orgUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{orgUnitOptions
.filter((u) => u.level === 7 && u.isActive)
.map((u) => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-clientUnitId">Client Unit</label>
<select
id="rm-clientUnitId"
className={INPUT_CLASS}
value={form.clientUnitId}
onChange={(e) => setField("clientUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{clientOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtGroupId">Management Level Group<InfoTooltip content="Seniority grouping (e.g. Associate, Manager, Director). Determines the available management levels." /></label>
<select
id="rm-mgmtGroupId"
className={INPUT_CLASS}
value={form.managementLevelGroupId}
onChange={(e) => {
setField("managementLevelGroupId", e.target.value);
setField("managementLevelId", ""); // reset level when group changes
}}
>
<option value=""> Not specified </option>
{managementGroupOptions.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor="rm-mgmtLevelId">Management Level<InfoTooltip content="Specific seniority level within the group. Used in chargeability reports and cost analysis." /></label>
<select
id="rm-mgmtLevelId"
className={INPUT_CLASS}
value={form.managementLevelId}
onChange={(e) => setField("managementLevelId", e.target.value)}
disabled={!form.managementLevelGroupId}
>
<option value=""> Not specified </option>
{mgmtLevels.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-4">
<div>
<label className={LABEL_CLASS} htmlFor="rm-resourceType">Resource Type<InfoTooltip content="Employee, contractor, or freelancer. Affects cost attribution rules." /></label>
<select
id="rm-resourceType"
className={INPUT_CLASS}
value={form.resourceType}
onChange={(e) => setField("resourceType", e.target.value)}
>
{Object.values(ResourceType).map((t) => (
<option key={t} value={t}>{t.charAt(0) + t.slice(1).toLowerCase()}</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.chgResponsibility}
onChange={(e) => setField("chgResponsibility", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Chg Responsibility
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.rolledOff}
onChange={(e) => setField("rolledOff", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Rolled Off
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.departed}
onChange={(e) => setField("departed", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Departed
</label>
</div>
</div>
{/* Section 2: Cost & Chargeability */} {/* Section 2: Cost & Chargeability */}
<p className={SECTION_HEADER_CLASS}>Cost &amp; Chargeability</p> <p className={SECTION_HEADER_CLASS}>Cost &amp; Chargeability</p>
@@ -719,7 +521,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-lcr"> <label className={LABEL_CLASS} htmlFor="rm-lcr">
LCR &euro;/h <span className="text-red-500">*</span><InfoTooltip content="Loaded Cost Rate in EUR per hour. E.g. 85 = 85.00 EUR/h. Stored internally as integer cents (8500)." /> LCR &euro;/h <span className="text-red-500">*</span>
<InfoTooltip content="Loaded Cost Rate in EUR per hour. E.g. 85 = 85.00 EUR/h. Stored internally as integer cents (8500)." />
</label> </label>
<input <input
id="rm-lcr" id="rm-lcr"
@@ -735,7 +538,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-ucr"> <label className={LABEL_CLASS} htmlFor="rm-ucr">
UCR &euro;/h <span className="text-red-500">*</span><InfoTooltip content="Unit Cost Rate in EUR per hour. The rate billed to the project or client." /> UCR &euro;/h <span className="text-red-500">*</span>
<InfoTooltip content="Unit Cost Rate in EUR per hour. The rate billed to the project or client." />
</label> </label>
<input <input
id="rm-ucr" id="rm-ucr"
@@ -766,7 +570,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
</div> </div>
<div> <div>
<label className={LABEL_CLASS} htmlFor="rm-chargeability"> <label className={LABEL_CLASS} htmlFor="rm-chargeability">
Chargeability Target %<InfoTooltip content="Target % of working time on chargeable projects. E.g. 80 means 80% of hours should be billable." /> Chargeability Target %
<InfoTooltip content="Target % of working time on chargeable projects. E.g. 80 means 80% of hours should be billable." />
</label> </label>
<input <input
id="rm-chargeability" id="rm-chargeability"
@@ -815,103 +620,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
{/* Section 4: Skills */} {/* Section 4: Skills */}
<p className={SECTION_HEADER_CLASS}>Skills</p> <p className={SECTION_HEADER_CLASS}>Skills</p>
<div className="space-y-3"> <ResourceSkillsEditor
{form.skills.map((skillRow, idx) => { skills={form.skills}
const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length; onSetSkillField={setSkillField}
const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2; onAddSkill={addSkill}
return ( onRemoveSkill={removeSkill}
<div inputClass={INPUT_CLASS}
key={idx} labelClass={LABEL_CLASS}
className={`grid gap-2 items-end border rounded-lg p-3 ${skillRow.isMainSkill ? "border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20" : "border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"}`} />
>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-2 items-end">
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-name-${idx}`}>
Skill
</label>
<input
id={`rm-skill-name-${idx}`}
type="text"
className={INPUT_CLASS}
placeholder="e.g. 3ds Max"
value={skillRow.skill}
onChange={(e) => setSkillField(idx, "skill", e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-prof-${idx}`}>
Proficiency
</label>
<select
id={`rm-skill-prof-${idx}`}
className={INPUT_CLASS}
value={skillRow.proficiency}
onChange={(e) =>
setSkillField(idx, "proficiency", parseInt(e.target.value, 10) as 1 | 2 | 3 | 4 | 5)
}
>
{[1, 2, 3, 4, 5].map((p) => (
<option key={p} value={p}>
{proficiencyLabels[p]}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS} htmlFor={`rm-skill-years-${idx}`}>
Years
</label>
<input
id={`rm-skill-years-${idx}`}
type="number"
min="0"
max="50"
step="1"
className={INPUT_CLASS}
placeholder="—"
value={skillRow.yearsExperience}
onChange={(e) => setSkillField(idx, "yearsExperience", e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none"> Main</span>
<input
type="checkbox"
checked={skillRow.isMainSkill}
disabled={!canToggleMain}
title={!canToggleMain ? "Max 2 main skills" : "Mark as main skill"}
onChange={(e) => setSkillField(idx, "isMainSkill", e.target.checked)}
className="rounded border-gray-300 disabled:opacity-40"
/>
</div>
<div className="flex items-end pb-0.5">
<button
type="button"
onClick={() => removeSkill(idx)}
className="px-2 py-2 text-red-400 hover:text-red-600 transition-colors"
aria-label={`Remove skill ${idx + 1}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={addSkill}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add skill
</button>
</div>
{/* Section 5: Roles */} {/* Section 5: Roles */}
<p className={SECTION_HEADER_CLASS}>Roles</p> <p className={SECTION_HEADER_CLASS}>Roles</p>
@@ -931,7 +647,10 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
if (e.target.checked) { if (e.target.checked) {
setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]); setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]);
} else { } else {
setField("roles", form.roles.filter((r) => r.roleId !== role.id)); setField(
"roles",
form.roles.filter((r) => r.roleId !== role.id),
);
} }
}} }}
className="rounded border-gray-300" className="rounded border-gray-300"
@@ -940,7 +659,10 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
className="w-3 h-3 rounded-full flex-shrink-0" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }} style={{ backgroundColor: role.color ?? "#6366f1" }}
/> />
<label htmlFor={`role-${role.id}`} className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1"> <label
htmlFor={`role-${role.id}`}
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1"
>
{role.name} {role.name}
</label> </label>
{isChecked && ( {isChecked && (
@@ -950,11 +672,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
name="primary-role" name="primary-role"
checked={assignment?.isPrimary ?? false} checked={assignment?.isPrimary ?? false}
onChange={() => { onChange={() => {
setField("roles", form.roles.map((r) => setField(
r.roleId === role.id "roles",
? { ...r, isPrimary: true } form.roles.map((r) =>
: { ...r, isPrimary: false }, r.roleId === role.id
)); ? { ...r, isPrimary: true }
: { ...r, isPrimary: false },
),
);
}} }}
className="border-gray-300" className="border-gray-300"
/> />
@@ -965,7 +690,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
); );
})} })}
{roleOptions.length === 0 && ( {roleOptions.length === 0 && (
<p className="text-sm text-gray-400 italic">No roles defined yet. Create roles on the Roles page.</p> <p className="text-sm text-gray-400 italic">
No roles defined yet. Create roles on the Roles page.
</p>
)} )}
</div> </div>
@@ -980,10 +707,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl"> <div className="flex items-center justify-between gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-xl">
<div> <div>
{mode === "edit" && canManageUsers && resource && ( {mode === "edit" &&
confirmDelete ? ( canManageUsers &&
resource &&
(confirmDelete ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-red-600 dark:text-red-400 font-medium">Permanently delete this resource?</span> <span className="text-xs text-red-600 dark:text-red-400 font-medium">
Permanently delete this resource?
</span>
<button <button
type="button" type="button"
onClick={() => void hardDeleteMutation.mutateAsync({ id: resource.id })} onClick={() => void hardDeleteMutation.mutateAsync({ id: resource.id })}
@@ -1010,8 +741,7 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
> >
Delete Resource Delete Resource
</button> </button>
) ))}
)}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
@@ -0,0 +1,325 @@
import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@capakraken/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
type CountryOption = { id: string; name: string; metroCities: { id: string; name: string }[] };
type OrgUnitOption = { id: string; name: string; level: number; isActive: boolean };
type ClientOption = { id: string; name: string };
type ManagementGroupOption = { id: string; name: string; levels: { id: string; name: string }[] };
interface ResourceOrgClassificationProps {
form: {
postalCode: string;
federalState: string;
countryId: string;
metroCityId: string;
orgUnitId: string;
clientUnitId: string;
managementLevelGroupId: string;
managementLevelId: string;
resourceType: string;
chgResponsibility: boolean;
rolledOff: boolean;
departed: boolean;
enterpriseId: string;
fte: string;
};
onSetField: (key: string, value: string | boolean) => void;
countryOptions: CountryOption[];
orgUnitOptions: OrgUnitOption[];
clientOptions: ClientOption[];
managementGroupOptions: ManagementGroupOption[];
inputClass: string;
labelClass: string;
sectionHeaderClass: string;
}
export function ResourceOrgClassification({
form,
onSetField,
countryOptions,
orgUnitOptions,
clientOptions,
managementGroupOptions,
inputClass,
labelClass,
sectionHeaderClass,
}: ResourceOrgClassificationProps) {
const selectedCountry = countryOptions.find((c) => c.id === form.countryId);
const metroCities = selectedCountry?.metroCities ?? [];
const selectedGroup = managementGroupOptions.find((g) => g.id === form.managementLevelGroupId);
const mgmtLevels = selectedGroup?.levels ?? [];
return (
<>
{/* Postal Code & Federal State */}
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-postalCode">
Postal Code (PLZ){" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
<InfoTooltip content="German postal code. Used to auto-derive the federal state for public holiday calculations." />
</label>
<input
id="rm-postalCode"
type="text"
className={inputClass}
placeholder="80331"
maxLength={5}
value={form.postalCode}
onChange={(e) => {
const plz = e.target.value;
onSetField("postalCode", plz);
if (/^\d{5}$/.test(plz)) {
const inferred = inferStateFromPostalCode(plz);
if (inferred && !form.federalState) {
onSetField("federalState", inferred);
}
}
}}
/>
</div>
<div>
<label className={labelClass} htmlFor="rm-federalState">
Federal State{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
<InfoTooltip content="Determines which public holidays apply (e.g. Bavaria has extra holidays). Auto-derived from postal code." />
</label>
<select
id="rm-federalState"
className={inputClass}
value={form.federalState}
onChange={(e) => onSetField("federalState", e.target.value)}
>
<option value=""> Not specified </option>
{Object.entries(GERMAN_FEDERAL_STATES).map(([abbr, name]) => (
<option key={abbr} value={abbr}>
{name} ({abbr})
</option>
))}
</select>
</div>
</div>
{/* Section: Organization & Classification */}
<p className={sectionHeaderClass}>Organization &amp; Classification</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelClass} htmlFor="rm-enterpriseId">
Enterprise ID{" "}
<span className="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
<InfoTooltip content="Corporate directory ID for cross-system integration (e.g. a.kasperovich)." />
</label>
<input
id="rm-enterpriseId"
type="text"
className={inputClass}
placeholder="a.kasperovich"
value={form.enterpriseId}
onChange={(e) => onSetField("enterpriseId", e.target.value)}
/>
</div>
<div>
<label className={labelClass} htmlFor="rm-fte">
FTE
<InfoTooltip content="Full-Time Equivalent (0.01-1.0). A value of 0.5 means the resource works 50% of standard hours." />
</label>
<input
id="rm-fte"
type="number"
min="0.01"
max="1"
step="0.01"
className={inputClass}
placeholder="1.0"
value={form.fte}
onChange={(e) => onSetField("fte", e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-countryId">
Country
</label>
<select
id="rm-countryId"
className={inputClass}
value={form.countryId}
onChange={(e) => {
onSetField("countryId", e.target.value);
onSetField("metroCityId", "");
}}
>
<option value=""> Not specified </option>
{countryOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="rm-metroCityId">
Metro City
</label>
<select
id="rm-metroCityId"
className={inputClass}
value={form.metroCityId}
onChange={(e) => onSetField("metroCityId", e.target.value)}
disabled={!form.countryId}
>
<option value=""> Not specified </option>
{metroCities.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-orgUnitId">
Org Unit (L7 Team)
</label>
<select
id="rm-orgUnitId"
className={inputClass}
value={form.orgUnitId}
onChange={(e) => onSetField("orgUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{orgUnitOptions
.filter((u) => u.level === 7 && u.isActive)
.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="rm-clientUnitId">
Client Unit
</label>
<select
id="rm-clientUnitId"
className={inputClass}
value={form.clientUnitId}
onChange={(e) => onSetField("clientUnitId", e.target.value)}
>
<option value=""> Not specified </option>
{clientOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-mgmtGroupId">
Management Level Group
<InfoTooltip content="Seniority grouping (e.g. Associate, Manager, Director). Determines the available management levels." />
</label>
<select
id="rm-mgmtGroupId"
className={inputClass}
value={form.managementLevelGroupId}
onChange={(e) => {
onSetField("managementLevelGroupId", e.target.value);
onSetField("managementLevelId", "");
}}
>
<option value=""> Not specified </option>
{managementGroupOptions.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor="rm-mgmtLevelId">
Management Level
<InfoTooltip content="Specific seniority level within the group. Used in chargeability reports and cost analysis." />
</label>
<select
id="rm-mgmtLevelId"
className={inputClass}
value={form.managementLevelId}
onChange={(e) => onSetField("managementLevelId", e.target.value)}
disabled={!form.managementLevelGroupId}
>
<option value=""> Not specified </option>
{mgmtLevels.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-4 gap-4 mt-4">
<div>
<label className={labelClass} htmlFor="rm-resourceType">
Resource Type
<InfoTooltip content="Employee, contractor, or freelancer. Affects cost attribution rules." />
</label>
<select
id="rm-resourceType"
className={inputClass}
value={form.resourceType}
onChange={(e) => onSetField("resourceType", e.target.value)}
>
{Object.values(ResourceType).map((t) => (
<option key={t} value={t}>
{t.charAt(0) + t.slice(1).toLowerCase()}
</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.chgResponsibility}
onChange={(e) => onSetField("chgResponsibility", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Chg Responsibility
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.rolledOff}
onChange={(e) => onSetField("rolledOff", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Rolled Off
</label>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer">
<input
type="checkbox"
checked={form.departed}
onChange={(e) => onSetField("departed", e.target.checked)}
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
Departed
</label>
</div>
</div>
</>
);
}
@@ -0,0 +1,152 @@
interface SkillRow {
skill: string;
proficiency: 1 | 2 | 3 | 4 | 5;
yearsExperience: string;
category: string;
certified: boolean;
isMainSkill: boolean;
}
const proficiencyLabels: Record<number, string> = {
1: "1 \u2013 Beginner",
2: "2 \u2013 Elementary",
3: "3 \u2013 Intermediate",
4: "4 \u2013 Advanced",
5: "5 \u2013 Expert",
};
interface ResourceSkillsEditorProps {
skills: SkillRow[];
onSetSkillField: (index: number, key: keyof SkillRow, value: string | number | boolean) => void;
onAddSkill: () => void;
onRemoveSkill: (index: number) => void;
inputClass: string;
labelClass: string;
}
export function ResourceSkillsEditor({
skills,
onSetSkillField,
onAddSkill,
onRemoveSkill,
inputClass,
labelClass,
}: ResourceSkillsEditorProps) {
return (
<div className="space-y-3">
{skills.map((skillRow, idx) => {
const mainSkillCount = skills.filter((s) => s.isMainSkill).length;
const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2;
return (
<div
key={idx}
className={`grid gap-2 items-end border rounded-lg p-3 ${skillRow.isMainSkill ? "border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20" : "border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900"}`}
>
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-2 items-end">
<div>
<label className={labelClass} htmlFor={`rm-skill-name-${idx}`}>
Skill
</label>
<input
id={`rm-skill-name-${idx}`}
type="text"
className={inputClass}
placeholder="e.g. 3ds Max"
value={skillRow.skill}
onChange={(e) => onSetSkillField(idx, "skill", e.target.value)}
/>
</div>
<div>
<label className={labelClass} htmlFor={`rm-skill-prof-${idx}`}>
Proficiency
</label>
<select
id={`rm-skill-prof-${idx}`}
className={inputClass}
value={skillRow.proficiency}
onChange={(e) =>
onSetSkillField(
idx,
"proficiency",
parseInt(e.target.value, 10) as 1 | 2 | 3 | 4 | 5,
)
}
>
{[1, 2, 3, 4, 5].map((p) => (
<option key={p} value={p}>
{proficiencyLabels[p]}
</option>
))}
</select>
</div>
<div>
<label className={labelClass} htmlFor={`rm-skill-years-${idx}`}>
Years
</label>
<input
id={`rm-skill-years-${idx}`}
type="number"
min="0"
max="50"
step="1"
className={inputClass}
placeholder="\u2014"
value={skillRow.yearsExperience}
onChange={(e) => onSetSkillField(idx, "yearsExperience", e.target.value)}
/>
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[10px] text-gray-500 dark:text-gray-400 leading-none">
\u2605 Main
</span>
<input
type="checkbox"
checked={skillRow.isMainSkill}
disabled={!canToggleMain}
title={!canToggleMain ? "Max 2 main skills" : "Mark as main skill"}
onChange={(e) => onSetSkillField(idx, "isMainSkill", e.target.checked)}
className="rounded border-gray-300 disabled:opacity-40"
/>
</div>
<div className="flex items-end pb-0.5">
<button
type="button"
onClick={() => onRemoveSkill(idx)}
className="px-2 py-2 text-red-400 hover:text-red-600 transition-colors"
aria-label={`Remove skill ${idx + 1}`}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
})}
<button
type="button"
onClick={onAddSkill}
className="flex items-center gap-1.5 text-sm text-brand-600 hover:text-brand-800 font-medium transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add skill
</button>
</div>
);
}
@@ -0,0 +1,147 @@
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import type { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
import { formatDateShort } from "~/lib/format.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
interface TimelineDragOverlaysProps {
dragState: ReturnType<typeof useTimelineDrag>["dragState"];
allocDragState: ReturnType<typeof useTimelineDrag>["allocDragState"];
rangeState: ReturnType<typeof useTimelineDrag>["rangeState"];
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
shiftPreview: ReturnType<typeof useTimelineDrag>["shiftPreview"];
isPreviewLoading: boolean;
isApplying: boolean;
isAllocSaving: boolean;
mousePosRef: React.RefObject<{ x: number; y: number }>;
dragTooltipRef: React.RefObject<HTMLDivElement | null>;
allocTooltipRef: React.RefObject<HTMLDivElement | null>;
rangeHintRef: React.RefObject<HTMLDivElement | null>;
multiDragTooltipRef: React.RefObject<HTMLDivElement | null>;
today: Date;
}
export function TimelineDragOverlays({
dragState,
allocDragState,
rangeState,
multiSelectState,
shiftPreview,
isPreviewLoading,
isApplying,
isAllocSaving,
mousePosRef,
dragTooltipRef,
allocTooltipRef,
rangeHintRef,
multiDragTooltipRef,
today,
}: TimelineDragOverlaysProps) {
return (
<>
{/* Multi-select rectangle overlay */}
{multiSelectState.isSelecting && (
<div
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded"
style={{
left: Math.min(multiSelectState.startX, multiSelectState.currentX),
top: Math.min(multiSelectState.startY, multiSelectState.currentY),
width: Math.abs(multiSelectState.currentX - multiSelectState.startX),
height: Math.abs(multiSelectState.currentY - multiSelectState.startY),
}}
/>
)}
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
)}
{/* Drag preview tooltip */}
{dragState.isDragging && dragState.daysDelta !== 0 && (
<div
ref={dragTooltipRef}
className="fixed z-50 pointer-events-none"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
isLoading={isPreviewLoading}
/>
</div>
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
<div
ref={rangeHintRef}
className="fixed z-40 bg-brand-700 text-white text-xs px-2 py-1 rounded-lg pointer-events-none shadow"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 28 }}
>
{(() => {
const end = rangeState.currentDate;
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / MILLISECONDS_PER_DAY) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
</div>
)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start"
? "Start "
: multiSelectState.multiDragMode === "resize-end"
? "End "
: ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "}
allocations)
</div>
)}
</>
);
}
@@ -0,0 +1,262 @@
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { InlineAllocationEditor } from "./InlineAllocationEditor.js";
import { KeyboardShortcutOverlay } from "./KeyboardShortcutOverlay.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry, TimelineAssignmentEntry } from "./TimelineContext.js";
import type { OpenDemandAssignment } from "./TimelineProjectPanel.js";
import type { useTimelineDrag } from "~/hooks/useTimelineDrag.js";
interface TimelinePopoversProps {
isSelfServiceTimeline: boolean;
hasActivePointerOverlay: boolean;
popover: {
allocationId: string;
projectId: string;
allocation?: TimelineAssignmentEntry | null;
x: number;
y: number;
contextDate?: Date;
} | null;
setPopover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["popover"]>>;
demandPopover: { demand: TimelineDemandEntry; x: number; y: number } | null;
setDemandPopover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["demandPopover"]>>;
newAllocPopover: {
resourceId: string;
startDate: Date;
endDate: Date;
suggestedProjectId: string | null;
anchorX: number;
anchorY: number;
selectionResourceId: string;
selectionStart: Date;
selectionEnd: Date;
} | null;
setNewAllocPopover: React.Dispatch<
React.SetStateAction<TimelinePopoversProps["newAllocPopover"]>
>;
enrichedSuggestedProjectId: string | null;
openPanelProjectId: string | null;
setOpenPanelProjectId: React.Dispatch<React.SetStateAction<string | null>>;
openDemandToAssign: OpenDemandAssignment | null;
setOpenDemandToAssign: React.Dispatch<React.SetStateAction<OpenDemandAssignment | null>>;
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
multiSelectState: ReturnType<typeof useTimelineDrag>["multiSelectState"];
clearMultiSelect: ReturnType<typeof useTimelineDrag>["clearMultiSelect"];
handleBatchDelete: () => void;
handleShowBatchAssign: () => void;
isDeleting: boolean;
showBatchAssign: boolean;
setShowBatchAssign: React.Dispatch<React.SetStateAction<boolean>>;
resourceHover: { resourceId: string; anchorEl: HTMLElement } | null;
setResourceHover: React.Dispatch<React.SetStateAction<TimelinePopoversProps["resourceHover"]>>;
inlineEditTarget: {
allocationId: string;
startDate: Date;
endDate: Date;
hoursPerDay: number;
barRect: DOMRect;
} | null;
setInlineEditTarget: React.Dispatch<
React.SetStateAction<TimelinePopoversProps["inlineEditTarget"]>
>;
showShortcuts: boolean;
setShowShortcuts: React.Dispatch<React.SetStateAction<boolean>>;
}
function buildDemandAssignment(d: TimelineDemandEntry): OpenDemandAssignment {
return {
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
};
}
export function TimelinePopovers({
isSelfServiceTimeline,
hasActivePointerOverlay,
popover,
setPopover,
demandPopover,
setDemandPopover,
newAllocPopover,
setNewAllocPopover,
enrichedSuggestedProjectId,
openPanelProjectId,
setOpenPanelProjectId,
openDemandToAssign,
setOpenDemandToAssign,
openDemandsByProject,
scrollContainerRef,
multiSelectState,
clearMultiSelect,
handleBatchDelete,
handleShowBatchAssign,
isDeleting,
showBatchAssign,
setShowBatchAssign,
resourceHover,
setResourceHover,
inlineEditTarget,
setInlineEditTarget,
showShortcuts,
setShowShortcuts,
}: TimelinePopoversProps) {
return (
<>
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline &&
!hasActivePointerOverlay &&
popover &&
(() => {
const clickedDemand = openDemandsByProject
.get(popover.projectId)
?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign(buildDemandAssignment(d));
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
{/* Demand popover (context menu path) */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setDemandPopover(null);
setOpenDemandToAssign(buildDemandAssignment(d));
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* New allocation popover */}
{!isSelfServiceTimeline && newAllocPopover && (
<NewAllocationPopover
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
onCreated={() => setNewAllocPopover(null)}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* Project side panel */}
{!isSelfServiceTimeline && openPanelProjectId && (
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
{!isSelfServiceTimeline && openDemandToAssign && (
<FillOpenDemandModal
allocation={openDemandToAssign}
onClose={() => setOpenDemandToAssign(null)}
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
{/* Multi-select floating action bar + batch assign */}
{showBatchAssign && multiSelectState.dateRange && (
<BatchAssignPopover
resourceIds={multiSelectState.selectedResourceIds}
startDate={multiSelectState.dateRange.start}
endDate={multiSelectState.dateRange.end}
onClose={() => setShowBatchAssign(false)}
onCreated={() => {
setShowBatchAssign(false);
clearMultiSelect();
}}
/>
)}
{/* Resource hover card */}
{!hasActivePointerOverlay && resourceHover && (
<ResourceHoverCard
resourceId={resourceHover.resourceId}
anchorEl={resourceHover.anchorEl}
onClose={() => setResourceHover(null)}
/>
)}
{/* Inline allocation editor */}
{inlineEditTarget && (
<InlineAllocationEditor
allocationId={inlineEditTarget.allocationId}
initialStartDate={inlineEditTarget.startDate}
initialEndDate={inlineEditTarget.endDate}
initialHoursPerDay={inlineEditTarget.hoursPerDay}
barRect={inlineEditTarget.barRect}
onClose={() => setInlineEditTarget(null)}
onSaved={() => setInlineEditTarget(null)}
/>
)}
{/* Keyboard shortcut overlay */}
{showShortcuts && <KeyboardShortcutOverlay onClose={() => setShowShortcuts(false)} />}
{/* Keyboard shortcut hint button */}
<button
type="button"
onClick={() => setShowShortcuts((prev) => !prev)}
title="Keyboard shortcuts (?)"
className="fixed bottom-6 right-6 z-40 rounded-full w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-sm font-medium"
>
?
</button>
</>
);
}
+48 -279
View File
@@ -1,6 +1,5 @@
"use client"; "use client";
import { MILLISECONDS_PER_DAY } from "@capakraken/shared";
import { clsx } from "clsx"; import { clsx } from "clsx";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -11,21 +10,14 @@ import { useTimelineLayout } from "~/hooks/useTimelineLayout.js";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
import { DemandPopover } from "./DemandPopover.js";
import { ResourceHoverCard } from "./ResourceHoverCard.js";
import type { TimelineDemandEntry } from "./TimelineContext.js"; import type { TimelineDemandEntry } from "./TimelineContext.js";
import { BatchAssignPopover } from "./BatchAssignPopover.js";
import { FloatingActionBar } from "./FloatingActionBar.js"; import { FloatingActionBar } from "./FloatingActionBar.js";
import { NewAllocationPopover } from "./NewAllocationPopover.js"; import { TimelineDragOverlays } from "./TimelineDragOverlays.js";
import { ProjectPanel } from "./ProjectPanel.js";
import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js";
import { TimelineHeader } from "./TimelineHeader.js"; import { TimelineHeader } from "./TimelineHeader.js";
import { TimelinePopovers } from "./TimelinePopovers.js";
import { TimelineToolbar } from "./TimelineToolbar.js"; import { TimelineToolbar } from "./TimelineToolbar.js";
import { addDays } from "./utils.js"; import { addDays } from "./utils.js";
import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js"; import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js";
import { formatDateShort } from "~/lib/format.js";
import { import {
TimelineProvider, TimelineProvider,
useTimelineData, useTimelineData,
@@ -984,228 +976,23 @@ function TimelineViewContent({
)} )}
</div> </div>
{/* Multi-select rectangle overlay */} <TimelineDragOverlays
{multiSelectState.isSelecting && ( dragState={dragState}
<div allocDragState={allocDragState}
className="fixed border-2 border-sky-500 bg-sky-500/10 pointer-events-none z-30 rounded" rangeState={rangeState}
style={{ multiSelectState={multiSelectState}
left: Math.min(multiSelectState.startX, multiSelectState.currentX), shiftPreview={shiftPreview}
top: Math.min(multiSelectState.startY, multiSelectState.currentY), isPreviewLoading={isPreviewLoading}
width: Math.abs(multiSelectState.currentX - multiSelectState.startX), isApplying={isApplying}
height: Math.abs(multiSelectState.currentY - multiSelectState.startY), isAllocSaving={isAllocSaving}
}} mousePosRef={mousePosRef}
/> dragTooltipRef={dragTooltipRef}
)} allocTooltipRef={allocTooltipRef}
rangeHintRef={rangeHintRef}
multiDragTooltipRef={multiDragTooltipRef}
today={today}
/>
{/* Saving indicators */}
{(isApplying || isAllocSaving) && (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center rounded-2xl bg-white/50 dark:bg-gray-950/50">
<div className="app-surface px-5 py-3 text-sm font-medium text-gray-700 dark:text-gray-200">
{isApplying ? "Applying shift…" : "Saving…"}
</div>
</div>
)}
{/* Drag preview tooltip */}
{dragState.isDragging && dragState.daysDelta !== 0 && (
<div
ref={dragTooltipRef}
className="fixed z-50 pointer-events-none"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 8 }}
>
<ShiftPreviewTooltip
preview={
shiftPreview ?? {
valid: true,
deltaCents: 0,
wouldExceedBudget: false,
budgetUtilizationAfter: 0,
conflictCount: 0,
errors: [],
warnings: [],
}
}
projectName={dragState.projectName ?? ""}
newStartDate={dragState.currentStartDate ?? today}
newEndDate={dragState.currentEndDate ?? today}
isLoading={isPreviewLoading}
/>
</div>
)}
{/* Alloc drag tooltip */}
{allocDragState.isActive &&
allocDragState.daysDelta !== 0 &&
allocDragState.currentStartDate &&
allocDragState.currentEndDate && (
<div
ref={allocTooltipRef}
className="fixed z-40 bg-gray-800 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg space-y-0.5"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
<div className="font-semibold">{allocDragState.projectName}</div>
<div className="opacity-80">
{formatDateShort(allocDragState.currentStartDate)}
{" "}
{formatDateShort(allocDragState.currentEndDate)}
</div>
</div>
)}
{/* Range-select hint */}
{rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && (
<div
ref={rangeHintRef}
className="fixed z-40 bg-brand-700 text-white text-xs px-2 py-1 rounded-lg pointer-events-none shadow"
style={{ left: mousePosRef.current.x + 12, top: mousePosRef.current.y - 28 }}
>
{(() => {
const end = rangeState.currentDate;
const [s, e] =
rangeState.startDate <= end
? [rangeState.startDate, end]
: [end, rangeState.startDate];
const days = Math.round((e.getTime() - s.getTime()) / MILLISECONDS_PER_DAY) + 1;
return `${days} day${days !== 1 ? "s" : ""}`;
})()}
</div>
)}
{/* Multi-drag tooltip */}
{multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && (
<div
ref={multiDragTooltipRef}
className="fixed z-50 bg-sky-700 text-white text-xs px-2.5 py-1.5 rounded-lg pointer-events-none shadow-lg font-medium"
style={{ left: mousePosRef.current.x + 14, top: mousePosRef.current.y - 36 }}
>
{multiSelectState.multiDragMode === "resize-start"
? "Start "
: multiSelectState.multiDragMode === "resize-end"
? "End "
: ""}
{multiSelectState.multiDragDaysDelta > 0 ? "+" : ""}
{multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "}
allocations)
</div>
)}
{/* Allocation / Demand popover (click path) */}
{!isSelfServiceTimeline &&
!hasActivePointerOverlay &&
popover &&
(() => {
// Check if clicked allocation is actually a demand
const clickedDemand = openDemandsByProject
.get(popover.projectId)
?.find((d) => d.id === popover.allocationId);
if (clickedDemand) {
return (
<DemandPopover
demand={clickedDemand}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
);
}
return (
<AllocationPopover
allocationId={popover.allocationId}
projectId={popover.projectId}
initialAllocation={popover.allocation ?? null}
onClose={() => setPopover(null)}
onOpenPanel={(pid) => {
setPopover(null);
setOpenPanelProjectId(pid);
}}
anchorX={popover.x}
anchorY={popover.y}
ignoreScrollContainers={[scrollContainerRef]}
{...(popover.contextDate ? { contextDate: popover.contextDate } : {})}
/>
);
})()}
{/* Demand popover */}
{!isSelfServiceTimeline && !hasActivePointerOverlay && demandPopover && (
<DemandPopover
demand={demandPopover.demand}
onClose={() => setDemandPopover(null)}
onOpenPanel={(pid) => {
setDemandPopover(null);
setOpenPanelProjectId(pid);
}}
onFillDemand={(d) => {
setDemandPopover(null);
setOpenDemandToAssign({
id: d.id,
projectId: d.projectId,
roleId: d.roleId,
role: d.role,
headcount: d.requestedHeadcount,
startDate: new Date(d.startDate),
endDate: new Date(d.endDate),
hoursPerDay: d.hoursPerDay,
...(d.roleEntity !== undefined ? { roleEntity: d.roleEntity } : {}),
...(d.project !== undefined ? { project: d.project } : {}),
});
}}
anchorX={demandPopover.x}
anchorY={demandPopover.y}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* New allocation popover */}
{!isSelfServiceTimeline && newAllocPopover && (
<NewAllocationPopover
resourceId={newAllocPopover.resourceId}
startDate={newAllocPopover.startDate}
endDate={newAllocPopover.endDate}
suggestedProjectId={enrichedSuggestedProjectId}
anchorX={newAllocPopover.anchorX}
anchorY={newAllocPopover.anchorY}
onClose={() => setNewAllocPopover(null)}
onCreated={() => setNewAllocPopover(null)}
ignoreScrollContainers={[scrollContainerRef]}
/>
)}
{/* Project side panel */}
{!isSelfServiceTimeline && openPanelProjectId && (
<ProjectPanel projectId={openPanelProjectId} onClose={() => setOpenPanelProjectId(null)} />
)}
{/* Open-demand assignment modal */}
{!isSelfServiceTimeline && openDemandToAssign && (
<FillOpenDemandModal
allocation={openDemandToAssign}
onClose={() => setOpenDemandToAssign(null)}
onSuccess={() => setOpenDemandToAssign(null)}
/>
)}
{/* Multi-select floating action bar */}
<FloatingActionBar <FloatingActionBar
selectedAllocationCount={multiSelectState.selectedAllocationIds.length} selectedAllocationCount={multiSelectState.selectedAllocationIds.length}
selectedResourceCount={multiSelectState.selectedResourceIds.length} selectedResourceCount={multiSelectState.selectedResourceIds.length}
@@ -1215,54 +1002,36 @@ function TimelineViewContent({
isDeleting={batchDeleteMutation.isPending} isDeleting={batchDeleteMutation.isPending}
/> />
{/* Batch assign popover */} <TimelinePopovers
{showBatchAssign && multiSelectState.dateRange && ( isSelfServiceTimeline={isSelfServiceTimeline}
<BatchAssignPopover hasActivePointerOverlay={hasActivePointerOverlay}
resourceIds={multiSelectState.selectedResourceIds} popover={popover}
startDate={multiSelectState.dateRange.start} setPopover={setPopover}
endDate={multiSelectState.dateRange.end} demandPopover={demandPopover}
onClose={() => setShowBatchAssign(false)} setDemandPopover={setDemandPopover}
onCreated={() => { newAllocPopover={newAllocPopover}
setShowBatchAssign(false); setNewAllocPopover={setNewAllocPopover}
clearMultiSelect(); enrichedSuggestedProjectId={enrichedSuggestedProjectId}
}} openPanelProjectId={openPanelProjectId}
/> setOpenPanelProjectId={setOpenPanelProjectId}
)} openDemandToAssign={openDemandToAssign}
setOpenDemandToAssign={setOpenDemandToAssign}
{/* Resource hover card */} openDemandsByProject={openDemandsByProject}
{!hasActivePointerOverlay && resourceHover && ( scrollContainerRef={scrollContainerRef}
<ResourceHoverCard multiSelectState={multiSelectState}
resourceId={resourceHover.resourceId} clearMultiSelect={clearMultiSelect}
anchorEl={resourceHover.anchorEl} handleBatchDelete={handleBatchDelete}
onClose={() => setResourceHover(null)} handleShowBatchAssign={handleShowBatchAssign}
/> isDeleting={batchDeleteMutation.isPending}
)} showBatchAssign={showBatchAssign}
setShowBatchAssign={setShowBatchAssign}
{/* Inline allocation editor */} resourceHover={resourceHover}
{inlineEditTarget && ( setResourceHover={setResourceHover}
<InlineAllocationEditor inlineEditTarget={inlineEditTarget}
allocationId={inlineEditTarget.allocationId} setInlineEditTarget={setInlineEditTarget}
initialStartDate={inlineEditTarget.startDate} showShortcuts={showShortcuts}
initialEndDate={inlineEditTarget.endDate} setShowShortcuts={setShowShortcuts}
initialHoursPerDay={inlineEditTarget.hoursPerDay} />
barRect={inlineEditTarget.barRect}
onClose={() => setInlineEditTarget(null)}
onSaved={() => setInlineEditTarget(null)}
/>
)}
{/* Keyboard shortcut overlay */}
{showShortcuts && <KeyboardShortcutOverlay onClose={() => setShowShortcuts(false)} />}
{/* Keyboard shortcut hint button */}
<button
type="button"
onClick={() => setShowShortcuts((prev) => !prev)}
title="Keyboard shortcuts (?)"
className="fixed bottom-6 right-6 z-40 rounded-full w-8 h-8 flex items-center justify-center bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-sm font-medium"
>
?
</button>
</div> </div>
); );
} }