diff --git a/apps/web/src/components/reports/ReportBuilder.tsx b/apps/web/src/components/reports/ReportBuilder.tsx index 8f3ff74..7d332a1 100644 --- a/apps/web/src/components/reports/ReportBuilder.tsx +++ b/apps/web/src/components/reports/ReportBuilder.tsx @@ -10,6 +10,7 @@ import { type ReportExplainability, } from "./reportBuilderExplainability.js"; import { ReportResultsPanel } from "./ReportResultsPanel.js"; +import { ResourceMonthConfigSection } from "./ResourceMonthConfigSection.js"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -753,135 +754,20 @@ export function ReportBuilder() { ))} {entity === "resource_month" && ( -
-
-
- - setPeriodMonth(e.target.value)} - className="rounded-xl border border-emerald-300 bg-white px-3 py-2 text-sm text-gray-700 focus:border-emerald-500 focus:ring-emerald-500 dark:border-emerald-900 dark:bg-slate-950 dark:text-gray-300" - /> -
-

- Resource Months uses the CapaKraken holiday and absence logic directly. SAH, - booked hours and chargeability are calculated per resource and month with country, - state and city context. -

-
- -
- {resourceMonthBlueprints.map((blueprint) => ( - - ))} -
- -
- {displayedResourceMonthCompleteness ? ( -
-
- - {displayedResourceMonthCompleteness.isAuditReady - ? "Audit ready" - : "Audit gap"} - - - {displayedResourceMonthCompleteness.selectedMinimumAuditColumnCount}/ - {displayedResourceMonthCompleteness.minimumAuditColumnCount} minimum audit - columns - - - {displayedResourceMonthCompleteness.selectedRecommendedColumnCount}/ - {displayedResourceMonthCompleteness.recommendedColumnCount} recommended - columns - - - {selectedTemplate && !hasTemplateDraftChanges - ? "Saved template status" - : "Current builder status"} - -
- {displayedResourceMonthCompleteness.missingMinimumAuditColumns.length > 0 ? ( -

- Missing audit/export basis columns:{" "} - {summarizeMissingColumns( - displayedResourceMonthCompleteness.missingMinimumAuditColumns, - columnLabelMap, - )} -

- ) : displayedResourceMonthCompleteness.missingRecommendedColumns.length > 0 ? ( -

- Audit-ready, but still missing recommended basis columns:{" "} - {summarizeMissingColumns( - displayedResourceMonthCompleteness.missingRecommendedColumns, - columnLabelMap, - )} -

- ) : ( -

- This view includes the full recommended audit/export basis set for monthly - SAH and chargeability checks. -

- )} -
- ) : null} -
- Recommended transparency columns -
-
- {RESOURCE_MONTH_RECOMMENDED_COLUMNS.map((column) => ( - - ))} -
-

- Formula reference: base available hours - holiday deduction - absence deduction = - monthly SAH. Chargeability uses booked hours divided by monthly SAH. -

-

- Export recommendation: include both basis columns and computed metrics in the CSV. - That keeps Excel as a review layer instead of rebuilding CapaKraken logic outside - the product. -

-

- Minimum audit set: month, location context, SAH, holiday deductions, absence - deductions, target hours, booked hours and unassigned hours. -

-
-
+ )} diff --git a/apps/web/src/components/reports/ResourceMonthConfigSection.tsx b/apps/web/src/components/reports/ResourceMonthConfigSection.tsx new file mode 100644 index 0000000..60e4636 --- /dev/null +++ b/apps/web/src/components/reports/ResourceMonthConfigSection.tsx @@ -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; + onToggleColumn: (column: string) => void; + columnLabelMap: Map; + recommendedColumns: readonly string[]; + summarizeMissing: (columns: string[], labelMap: Map) => 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) { + return ( +
+
+
+ + 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" + /> +
+

+ 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. +

+
+ +
+ {blueprints.map((blueprint) => ( + + ))} +
+ +
+ {completeness ? ( +
+
+ + {completeness.isAuditReady ? "Audit ready" : "Audit gap"} + + + {completeness.selectedMinimumAuditColumnCount}/ + {completeness.minimumAuditColumnCount} minimum audit columns + + + {completeness.selectedRecommendedColumnCount}/{completeness.recommendedColumnCount}{" "} + recommended columns + + + {selectedTemplate && !hasTemplateDraftChanges + ? "Saved template status" + : "Current builder status"} + +
+ {completeness.missingMinimumAuditColumns.length > 0 ? ( +

+ Missing audit/export basis columns:{" "} + {summarizeMissing(completeness.missingMinimumAuditColumns, columnLabelMap)} +

+ ) : completeness.missingRecommendedColumns.length > 0 ? ( +

+ Audit-ready, but still missing recommended basis columns:{" "} + {summarizeMissing(completeness.missingRecommendedColumns, columnLabelMap)} +

+ ) : ( +

+ This view includes the full recommended audit/export basis set for monthly SAH and + chargeability checks. +

+ )} +
+ ) : null} +
+ Recommended transparency columns +
+
+ {recommendedColumns.map((column) => ( + + ))} +
+

+ Formula reference: base available hours - holiday deduction - absence deduction = monthly + SAH. Chargeability uses booked hours divided by monthly SAH. +

+

+ Export recommendation: include both basis columns and computed metrics in the CSV. That + keeps Excel as a review layer instead of rebuilding CapaKraken logic outside the product. +

+

+ Minimum audit set: month, location context, SAH, holiday deductions, absence deductions, + target hours, booked hours and unassigned hours. +

+
+
+ ); +} diff --git a/apps/web/src/components/resources/ResourceModal.tsx b/apps/web/src/components/resources/ResourceModal.tsx index 8ef6220..275e7cd 100644 --- a/apps/web/src/components/resources/ResourceModal.tsx +++ b/apps/web/src/components/resources/ResourceModal.tsx @@ -2,11 +2,12 @@ import { useRef, useState } from "react"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; -import type { Resource, SkillEntry } from "@capakraken/shared"; -import { GERMAN_FEDERAL_STATES, inferStateFromPostalCode, ResourceType } from "@capakraken/shared"; +import type { Resource, SkillEntry, ResourceType } from "@capakraken/shared"; import { trpc } from "~/lib/trpc/client.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { usePermissions } from "~/hooks/usePermissions.js"; +import { ResourceOrgClassification } from "./ResourceOrgClassification.js"; +import { ResourceSkillsEditor } from "./ResourceSkillsEditor.js"; interface RoleAssignment { roleId: string; @@ -105,10 +106,14 @@ function resourceToFormState(resource: Resource): FormState { countryId: (resource as unknown as { countryId?: string | null }).countryId ?? "", metroCityId: (resource as unknown as { metroCityId?: string | null }).metroCityId ?? "", orgUnitId: (resource as unknown as { orgUnitId?: string | null }).orgUnitId ?? "", - managementLevelGroupId: (resource as unknown as { managementLevelGroupId?: string | null }).managementLevelGroupId ?? "", - managementLevelId: (resource as unknown as { managementLevelId?: string | null }).managementLevelId ?? "", + managementLevelGroupId: + (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", - 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, departed: (resource as unknown as { departed?: boolean }).departed ?? false, enterpriseId: (resource as unknown as { enterpriseId?: string | null }).enterpriseId ?? "", @@ -154,7 +159,14 @@ function defaultFormState(): FormState { } 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 { @@ -167,7 +179,8 @@ interface ResourceModalProps { 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"; 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 = "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: 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 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 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 updateMutation = trpc.resource.update.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(key: K, value: FormState[K]) { setForm((prev) => ({ ...prev, [key]: value })); @@ -306,7 +314,9 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo ...(form.countryId ? { countryId: form.countryId } : {}), ...(form.metroCityId ? { metroCityId: form.metroCityId } : {}), ...(form.orgUnitId ? { orgUnitId: form.orgUnitId } : {}), - ...(form.managementLevelGroupId ? { managementLevelGroupId: form.managementLevelGroupId } : {}), + ...(form.managementLevelGroupId + ? { managementLevelGroupId: form.managementLevelGroupId } + : {}), ...(form.managementLevelId ? { managementLevelId: form.managementLevelId } : {}), resourceType: form.resourceType as ResourceType, chgResponsibility: form.chgResponsibility, @@ -345,14 +355,6 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo } } - const proficiencyLabels: Record = { - 1: "1 – Beginner", - 2: "2 – Elementary", - 3: "3 – Intermediate", - 4: "4 – Advanced", - 5: "5 – Expert", - }; - return (
{ if (e.key === "Escape") onClose(); }} + onKeyDown={(e) => { + if (e.key === "Escape") onClose(); + }} > {/* Header */}
@@ -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" aria-label="Close modal" > - + @@ -391,7 +401,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
- {chapters?.map((c) =>
@@ -454,7 +469,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
- {/* Postal Code & Federal State */} -
-
- - { - const plz = e.target.value; - setField("postalCode", plz); - if (/^\d{5}$/.test(plz)) { - const inferred = inferStateFromPostalCode(plz); - if (inferred && !form.federalState) { - setField("federalState", inferred); - } - } - }} - /> -
-
- - -
-
- - {/* Section: Organization & Classification */} -

Organization & Classification

- -
-
- - setField("enterpriseId", e.target.value)} - /> -
-
- - setField("fte", e.target.value)} - /> -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- -
-
- -
-
+ void} + countryOptions={countryOptions} + orgUnitOptions={orgUnitOptions} + clientOptions={clientOptions} + managementGroupOptions={managementGroupOptions} + inputClass={INPUT_CLASS} + labelClass={LABEL_CLASS} + sectionHeaderClass={SECTION_HEADER_CLASS} + /> {/* Section 2: Cost & Chargeability */}

Cost & Chargeability

@@ -719,7 +521,8 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo
Skills

-
- {form.skills.map((skillRow, idx) => { - const mainSkillCount = form.skills.filter((s) => s.isMainSkill).length; - const canToggleMain = skillRow.isMainSkill || mainSkillCount < 2; - return ( -
-
-
- - setSkillField(idx, "skill", e.target.value)} - /> -
-
- - -
-
- - setSkillField(idx, "yearsExperience", e.target.value)} - /> -
-
- ★ Main - setSkillField(idx, "isMainSkill", e.target.checked)} - className="rounded border-gray-300 disabled:opacity-40" - /> -
-
- -
-
-
- ); - })} - - -
+ {/* Section 5: Roles */}

Roles

@@ -931,7 +647,10 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo if (e.target.checked) { setField("roles", [...form.roles, { roleId: role.id, isPrimary: false }]); } 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" @@ -940,7 +659,10 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: role.color ?? "#6366f1" }} /> -
@@ -980,10 +707,14 @@ export function ResourceModal({ mode, resource, onClose, onSuccess }: ResourceMo {/* Footer */}
- {mode === "edit" && canManageUsers && resource && ( - confirmDelete ? ( + {mode === "edit" && + canManageUsers && + resource && + (confirmDelete ? (
- Permanently delete this resource? + + Permanently delete this resource? + - ) - )} + ))}
+
+
+
+ ); + })} + + +
+ ); +} diff --git a/apps/web/src/components/timeline/TimelineDragOverlays.tsx b/apps/web/src/components/timeline/TimelineDragOverlays.tsx new file mode 100644 index 0000000..b99d654 --- /dev/null +++ b/apps/web/src/components/timeline/TimelineDragOverlays.tsx @@ -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["dragState"]; + allocDragState: ReturnType["allocDragState"]; + rangeState: ReturnType["rangeState"]; + multiSelectState: ReturnType["multiSelectState"]; + shiftPreview: ReturnType["shiftPreview"]; + isPreviewLoading: boolean; + isApplying: boolean; + isAllocSaving: boolean; + mousePosRef: React.RefObject<{ x: number; y: number }>; + dragTooltipRef: React.RefObject; + allocTooltipRef: React.RefObject; + rangeHintRef: React.RefObject; + multiDragTooltipRef: React.RefObject; + 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 && ( +
+ )} + + {/* Saving indicators */} + {(isApplying || isAllocSaving) && ( +
+
+ {isApplying ? "Applying shift…" : "Saving…"} +
+
+ )} + + {/* Drag preview tooltip */} + {dragState.isDragging && dragState.daysDelta !== 0 && ( +
+ +
+ )} + + {/* Alloc drag tooltip */} + {allocDragState.isActive && + allocDragState.daysDelta !== 0 && + allocDragState.currentStartDate && + allocDragState.currentEndDate && ( +
+
{allocDragState.projectName}
+
+ {formatDateShort(allocDragState.currentStartDate)} + {" – "} + {formatDateShort(allocDragState.currentEndDate)} +
+
+ )} + + {/* Range-select hint */} + {rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && ( +
+ {(() => { + 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" : ""}`; + })()} +
+ )} + + {/* Multi-drag tooltip */} + {multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && ( +
+ {multiSelectState.multiDragMode === "resize-start" + ? "Start " + : multiSelectState.multiDragMode === "resize-end" + ? "End " + : ""} + {multiSelectState.multiDragDaysDelta > 0 ? "+" : ""} + {multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "} + allocations) +
+ )} + + ); +} diff --git a/apps/web/src/components/timeline/TimelinePopovers.tsx b/apps/web/src/components/timeline/TimelinePopovers.tsx new file mode 100644 index 0000000..6460385 --- /dev/null +++ b/apps/web/src/components/timeline/TimelinePopovers.tsx @@ -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>; + demandPopover: { demand: TimelineDemandEntry; x: number; y: number } | null; + setDemandPopover: React.Dispatch>; + 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 + >; + enrichedSuggestedProjectId: string | null; + openPanelProjectId: string | null; + setOpenPanelProjectId: React.Dispatch>; + openDemandToAssign: OpenDemandAssignment | null; + setOpenDemandToAssign: React.Dispatch>; + openDemandsByProject: Map; + scrollContainerRef: React.RefObject; + multiSelectState: ReturnType["multiSelectState"]; + clearMultiSelect: ReturnType["clearMultiSelect"]; + handleBatchDelete: () => void; + handleShowBatchAssign: () => void; + isDeleting: boolean; + showBatchAssign: boolean; + setShowBatchAssign: React.Dispatch>; + resourceHover: { resourceId: string; anchorEl: HTMLElement } | null; + setResourceHover: React.Dispatch>; + inlineEditTarget: { + allocationId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + barRect: DOMRect; + } | null; + setInlineEditTarget: React.Dispatch< + React.SetStateAction + >; + showShortcuts: boolean; + setShowShortcuts: React.Dispatch>; +} + +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 ( + setPopover(null)} + onOpenPanel={(pid) => { + setPopover(null); + setOpenPanelProjectId(pid); + }} + onFillDemand={(d) => { + setPopover(null); + setOpenDemandToAssign(buildDemandAssignment(d)); + }} + anchorX={popover.x} + anchorY={popover.y} + ignoreScrollContainers={[scrollContainerRef]} + /> + ); + } + return ( + 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 && ( + 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 && ( + setNewAllocPopover(null)} + onCreated={() => setNewAllocPopover(null)} + ignoreScrollContainers={[scrollContainerRef]} + /> + )} + + {/* Project side panel */} + {!isSelfServiceTimeline && openPanelProjectId && ( + setOpenPanelProjectId(null)} /> + )} + + {/* Open-demand assignment modal */} + {!isSelfServiceTimeline && openDemandToAssign && ( + setOpenDemandToAssign(null)} + onSuccess={() => setOpenDemandToAssign(null)} + /> + )} + + {/* Multi-select floating action bar + batch assign */} + {showBatchAssign && multiSelectState.dateRange && ( + setShowBatchAssign(false)} + onCreated={() => { + setShowBatchAssign(false); + clearMultiSelect(); + }} + /> + )} + + {/* Resource hover card */} + {!hasActivePointerOverlay && resourceHover && ( + setResourceHover(null)} + /> + )} + + {/* Inline allocation editor */} + {inlineEditTarget && ( + setInlineEditTarget(null)} + onSaved={() => setInlineEditTarget(null)} + /> + )} + + {/* Keyboard shortcut overlay */} + {showShortcuts && setShowShortcuts(false)} />} + + {/* Keyboard shortcut hint button */} + + + ); +} diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 3542ab1..d515a11 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -1,6 +1,5 @@ "use client"; -import { MILLISECONDS_PER_DAY } from "@capakraken/shared"; import { clsx } from "clsx"; import { useSession } from "next-auth/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 { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.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 { BatchAssignPopover } from "./BatchAssignPopover.js"; import { FloatingActionBar } from "./FloatingActionBar.js"; -import { NewAllocationPopover } from "./NewAllocationPopover.js"; -import { ProjectPanel } from "./ProjectPanel.js"; -import { ShiftPreviewTooltip } from "./ShiftPreviewTooltip.js"; +import { TimelineDragOverlays } from "./TimelineDragOverlays.js"; import { TimelineHeader } from "./TimelineHeader.js"; +import { TimelinePopovers } from "./TimelinePopovers.js"; import { TimelineToolbar } from "./TimelineToolbar.js"; import { addDays } from "./utils.js"; import { HEADER_DAY_HEIGHT, HEADER_MONTH_HEIGHT, LABEL_WIDTH } from "./timelineConstants.js"; -import { formatDateShort } from "~/lib/format.js"; import { TimelineProvider, useTimelineData, @@ -984,228 +976,23 @@ function TimelineViewContent({ )}
- {/* Multi-select rectangle overlay */} - {multiSelectState.isSelecting && ( -
- )} + - {/* Saving indicators */} - {(isApplying || isAllocSaving) && ( -
-
- {isApplying ? "Applying shift…" : "Saving…"} -
-
- )} - - {/* Drag preview tooltip */} - {dragState.isDragging && dragState.daysDelta !== 0 && ( -
- -
- )} - - {/* Alloc drag tooltip */} - {allocDragState.isActive && - allocDragState.daysDelta !== 0 && - allocDragState.currentStartDate && - allocDragState.currentEndDate && ( -
-
{allocDragState.projectName}
-
- {formatDateShort(allocDragState.currentStartDate)} - {" – "} - {formatDateShort(allocDragState.currentEndDate)} -
-
- )} - - {/* Range-select hint */} - {rangeState.isSelecting && rangeState.startDate && rangeState.currentDate && ( -
- {(() => { - 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" : ""}`; - })()} -
- )} - - {/* Multi-drag tooltip */} - {multiSelectState.isMultiDragging && multiSelectState.multiDragDaysDelta !== 0 && ( -
- {multiSelectState.multiDragMode === "resize-start" - ? "Start " - : multiSelectState.multiDragMode === "resize-end" - ? "End " - : ""} - {multiSelectState.multiDragDaysDelta > 0 ? "+" : ""} - {multiSelectState.multiDragDaysDelta}d ({multiSelectState.selectedAllocationIds.length}{" "} - allocations) -
- )} - - {/* 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 ( - 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 ( - 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 && ( - 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 && ( - setNewAllocPopover(null)} - onCreated={() => setNewAllocPopover(null)} - ignoreScrollContainers={[scrollContainerRef]} - /> - )} - - {/* Project side panel */} - {!isSelfServiceTimeline && openPanelProjectId && ( - setOpenPanelProjectId(null)} /> - )} - - {/* Open-demand assignment modal */} - {!isSelfServiceTimeline && openDemandToAssign && ( - setOpenDemandToAssign(null)} - onSuccess={() => setOpenDemandToAssign(null)} - /> - )} - - {/* Multi-select floating action bar */} - {/* Batch assign popover */} - {showBatchAssign && multiSelectState.dateRange && ( - setShowBatchAssign(false)} - onCreated={() => { - setShowBatchAssign(false); - clearMultiSelect(); - }} - /> - )} - - {/* Resource hover card */} - {!hasActivePointerOverlay && resourceHover && ( - setResourceHover(null)} - /> - )} - - {/* Inline allocation editor */} - {inlineEditTarget && ( - setInlineEditTarget(null)} - onSaved={() => setInlineEditTarget(null)} - /> - )} - - {/* Keyboard shortcut overlay */} - {showShortcuts && setShowShortcuts(false)} />} - - {/* Keyboard shortcut hint button */} - +
); }