From e7b74f13bd7dd34a03d25d34c484358692603e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 19 Mar 2026 00:10:08 +0100 Subject: [PATCH] refactor: consolidate duplicated code across web and API packages - Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx - Centralize status badge styles and vacation color maps into status-styles.ts - Extract dragMath.ts utility from useTimelineDrag for reuse - Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries) - Adopt findUniqueOrThrow() and Prisma select constants across API routers - Add shared fmtEur() helper for API-side money formatting - Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo - Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal - 38 files changed, reducing ~400 lines of duplicated code Co-Authored-By: claude-flow --- .../src/app/(app)/projects/ProjectsClient.tsx | 4 +- .../app/(app)/resources/ResourcesClient.tsx | 3 +- .../allocations/FillOpenDemandModal.tsx | 25 +-- .../dashboard/widgets/ProjectTableWidget.tsx | 5 +- .../projects/ProjectAssignmentsTable.tsx | 4 +- .../projects/ProjectDemandsTable.tsx | 8 +- .../src/components/projects/ProjectWizard.tsx | 7 +- .../components/resources/ResourceDetail.tsx | 8 +- .../components/timeline/AllocationPopover.tsx | 7 +- .../timeline/BatchAssignPopover.tsx | 8 +- .../src/components/timeline/DemandPopover.tsx | 6 +- .../timeline/NewAllocationPopover.tsx | 8 +- .../src/components/timeline/ProjectPanel.tsx | 22 +-- .../components/timeline/ResourceHoverCard.tsx | 5 +- .../timeline/TimelineProjectPanel.tsx | 165 +++-------------- .../timeline/TimelineResourcePanel.tsx | 138 ++------------- .../src/components/timeline/TimelineView.tsx | 152 +++------------- apps/web/src/components/timeline/dragMath.ts | 51 ++++++ .../src/components/timeline/renderHelpers.tsx | 121 +++++++++++++ .../src/components/vacations/TeamCalendar.tsx | 14 +- .../components/vacations/VacationCalendar.tsx | 12 +- .../components/vacations/VacationModal.tsx | 10 +- apps/web/src/hooks/useAllocationHistory.ts | 24 +-- .../src/hooks/useInvalidatePlanningViews.ts | 13 +- .../src/hooks/useMultiSelectIntersection.ts | 166 ++++++++++++++++++ apps/web/src/hooks/useTimelineDrag.ts | 52 +++--- apps/web/src/lib/status-styles.ts | 30 ++++ packages/api/src/lib/format-utils.ts | 3 + packages/api/src/router/assistant-tools.ts | 5 +- packages/api/src/router/calculation-rules.ts | 36 ++-- packages/api/src/router/computation-graph.ts | 5 +- packages/api/src/router/entitlement.ts | 3 +- packages/api/src/router/notification.ts | 12 +- .../src/router/project-planning-read-model.ts | 3 +- packages/api/src/router/resource.ts | 76 ++++---- packages/api/src/router/role.ts | 3 +- packages/api/src/router/timeline.ts | 64 ++++--- packages/api/src/router/vacation.ts | 11 +- 38 files changed, 637 insertions(+), 652 deletions(-) create mode 100644 apps/web/src/components/timeline/dragMath.ts create mode 100644 apps/web/src/components/timeline/renderHelpers.tsx create mode 100644 apps/web/src/hooks/useMultiSelectIntersection.ts create mode 100644 packages/api/src/lib/format-utils.ts diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 2aa3064..20af9d0 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { createPortal } from "react-dom"; -import { formatDate } from "~/lib/format.js"; +import { formatDate, formatMoney } from "~/lib/format.js"; import type { Project, ColumnDef } from "@planarchy/shared"; import { ProjectStatus, PROJECT_COLUMNS, BlueprintTarget } from "@planarchy/shared"; import Link from "next/link"; @@ -400,7 +400,7 @@ export function ProjectsClient() { return (
- {(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} € + {formatMoney(project.budgetCents)}
diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index d7a4568..02d76a3 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -7,6 +7,7 @@ import type { Resource, SkillEntry } from "@planarchy/shared"; import { RESOURCE_COLUMNS } from "@planarchy/shared"; import { BlueprintTarget, ResourceType } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; +import { formatMoney } from "~/lib/format.js"; import { ResourceModal } from "~/components/resources/ResourceModal.js"; import { ImportModal } from "~/components/resources/ImportModal.js"; import { BulkEditModal } from "~/components/resources/BulkEditModal.js"; @@ -1175,7 +1176,7 @@ export function ResourcesClient() { key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100" > - {(resource.lcrCents / 100).toFixed(0)} {resource.currency} + {formatMoney(resource.lcrCents, resource.currency)} ); case "chargeability": { diff --git a/apps/web/src/components/allocations/FillOpenDemandModal.tsx b/apps/web/src/components/allocations/FillOpenDemandModal.tsx index b35334b..e65dbc1 100644 --- a/apps/web/src/components/allocations/FillOpenDemandModal.tsx +++ b/apps/web/src/components/allocations/FillOpenDemandModal.tsx @@ -1,11 +1,12 @@ "use client"; -import { useRef, useState, useMemo, useCallback } from "react"; +import { useRef, useState, useMemo } from "react"; import { AllocationStatus } from "@planarchy/shared"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; -import { formatDateMedium } from "~/lib/format.js"; +import { formatCents, formatDateMedium } from "~/lib/format.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; interface OpenDemandAllocation { @@ -69,15 +70,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 200); } - const utils = trpc.useUtils(); - const invalidatePlanningViews = useCallback(async () => { - await utils.allocation.list.invalidate(); - await utils.allocation.listView.invalidate(); - await utils.timeline.getEntries.invalidate(); - await utils.timeline.getEntriesView.invalidate(); - await utils.timeline.getProjectContext.invalidate(); - await utils.timeline.getBudgetStatus.invalidate(); - }, [utils]); + const invalidatePlanningViews = useInvalidatePlanningViews(); const { data: resources } = trpc.resource.list.useQuery( { isActive: true, search: debouncedSearch || undefined, limit: 50 }, @@ -211,7 +204,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
{allocation.hoursPerDay}h/day · {totalDemandHours.toLocaleString()}h total - {allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR` : ""} + {allocation.budgetCents && allocation.budgetCents > 0 ? ` · Budget: ${formatCents(allocation.budgetCents)} EUR` : ""}
@@ -414,7 +407,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen {r.hoursPerDay}h {Math.round(r.availableHours)}h - {(r.estimatedCostCents / 100).toLocaleString("de-DE")} EUR + {formatCents(r.estimatedCostCents)} EUR = 100 ? "text-green-600" : r.coveragePercent >= 50 ? "text-amber-600" : "text-red-600"}`}> {r.coveragePercent}% @@ -431,7 +424,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen {Math.round(consumedHours)}h / {totalDemandHours}h - {(planned.reduce((s, r) => s + r.estimatedCostCents, 0) / 100).toLocaleString("de-DE")} EUR + {formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR {totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}% @@ -441,7 +434,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen Role Budget: - {(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR + {formatCents(allocation.budgetCents)} EUR {(() => { @@ -449,7 +442,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen const remain = allocation.budgetCents! - totalCost; return ( - {remain < 0 ? `${(Math.abs(remain) / 100).toLocaleString("de-DE")} over` : `${(remain / 100).toLocaleString("de-DE")} left`} + {remain < 0 ? `${formatCents(Math.abs(remain))} over` : `${formatCents(remain)} left`} ); })()} diff --git a/apps/web/src/components/dashboard/widgets/ProjectTableWidget.tsx b/apps/web/src/components/dashboard/widgets/ProjectTableWidget.tsx index 58177e9..e87d87f 100644 --- a/apps/web/src/components/dashboard/widgets/ProjectTableWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/ProjectTableWidget.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { trpc } from "~/lib/trpc/client.js"; import type { WidgetProps } from "~/components/dashboard/widget-registry.js"; +import { formatCents, formatMoney } from "~/lib/format.js"; import { ProjectStatus } from "@planarchy/shared/types"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js"; @@ -271,7 +272,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) { - {(p.totalCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} € + {formatCents(p.totalCostCents)} € {p.totalPersonDays}d @@ -280,7 +281,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) { {p.budgetCents > 0 ? (
- {(p.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} € + {formatMoney(p.budgetCents)} = 80 ? "bg-amber-500" : "bg-green-500"}`} diff --git a/apps/web/src/components/projects/ProjectAssignmentsTable.tsx b/apps/web/src/components/projects/ProjectAssignmentsTable.tsx index e8540ba..ef9006c 100644 --- a/apps/web/src/components/projects/ProjectAssignmentsTable.tsx +++ b/apps/web/src/components/projects/ProjectAssignmentsTable.tsx @@ -2,7 +2,7 @@ import { useState, useMemo } from "react"; import { useRouter } from "next/navigation"; -import { formatDate, formatMoney } from "~/lib/format.js"; +import { formatCents, formatDate, formatMoney } from "~/lib/format.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { ALLOCATION_STATUS_BADGE } from "~/lib/status-styles.js"; import { usePermissions } from "~/hooks/usePermissions.js"; @@ -127,7 +127,7 @@ export function ProjectAssignmentsTable({ assignments }: ProjectAssignmentsTable {assignment.hoursPerDay}h - {(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} € + {formatCents(assignment.dailyCostCents)} € {(countWorkingDays(assignment.startDate, assignment.endDate) * assignment.hoursPerDay).toLocaleString("de-DE", { minimumFractionDigits: 1 })}h diff --git a/apps/web/src/components/projects/ProjectDemandsTable.tsx b/apps/web/src/components/projects/ProjectDemandsTable.tsx index ef97448..1d456b0 100644 --- a/apps/web/src/components/projects/ProjectDemandsTable.tsx +++ b/apps/web/src/components/projects/ProjectDemandsTable.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { formatDate } from "~/lib/format.js"; +import { formatCents, formatDate } from "~/lib/format.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js"; import { AllocationModal } from "~/components/allocations/AllocationModal.js"; @@ -132,11 +132,11 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro return (
- {(demand.budgetCents! / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR + {formatCents(demand.budgetCents!)} EUR
- {bookedCents > 0 ? `${(bookedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} booked` : ""} - {remainCents < 0 ? ` (${(Math.abs(remainCents) / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} over)` : ""} + {bookedCents > 0 ? `${formatCents(bookedCents)} booked` : ""} + {remainCents < 0 ? ` (${formatCents(Math.abs(remainCents))} over)` : ""}
); diff --git a/apps/web/src/components/projects/ProjectWizard.tsx b/apps/web/src/components/projects/ProjectWizard.tsx index cbf9348..0940e72 100644 --- a/apps/web/src/components/projects/ProjectWizard.tsx +++ b/apps/web/src/components/projects/ProjectWizard.tsx @@ -10,6 +10,7 @@ import { DateInput } from "~/components/ui/DateInput.js"; import { SkillTagInput } from "~/components/ui/SkillTagInput.js"; import { usePermissions } from "~/hooks/usePermissions.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; +import { formatCents } from "~/lib/format.js"; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -506,9 +507,9 @@ function Step3({ state, onChange }: Step3Props) {
- Project: {(projectBudgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR - Allocated: {(allocatedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR - Remaining: {(remainingCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR + Project: {formatCents(projectBudgetCents)} EUR + Allocated: {formatCents(allocatedCents)} EUR + Remaining: {formatCents(remainingCents)} EUR
); diff --git a/apps/web/src/components/resources/ResourceDetail.tsx b/apps/web/src/components/resources/ResourceDetail.tsx index b32c673..faed6c9 100644 --- a/apps/web/src/components/resources/ResourceDetail.tsx +++ b/apps/web/src/components/resources/ResourceDetail.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import type { AllocationLike, AllocationReadModel, AllocationWithDetails, Resource, SkillEntry } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; -import { formatDate } from "~/lib/format.js"; +import { formatDate, formatMoney } from "~/lib/format.js"; import { ResourceModal } from "./ResourceModal.js"; import { SkillRadarChart } from "./SkillRadarChart.js"; import { AiSummaryCard } from "./AiSummaryCard.js"; @@ -276,14 +276,14 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { {canViewCosts && ( )} {canViewCosts && ( )} @@ -518,7 +518,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) { {canViewCosts && ( {a.dailyCostCents > 0 - ? `${(a.dailyCostCents / 100).toFixed(0)}/d` + ? `${formatMoney(a.dailyCostCents)}/d` : "—"} )} diff --git a/apps/web/src/components/timeline/AllocationPopover.tsx b/apps/web/src/components/timeline/AllocationPopover.tsx index d90747a..5553e94 100644 --- a/apps/web/src/components/timeline/AllocationPopover.tsx +++ b/apps/web/src/components/timeline/AllocationPopover.tsx @@ -4,6 +4,7 @@ import { clsx } from "clsx"; import { useEffect, useRef, useState } from "react"; import type { AllocationLike, AllocationReadModel, Assignment } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { DateInput } from "~/components/ui/DateInput.js"; @@ -29,6 +30,7 @@ export function AllocationPopover({ }: AllocationPopoverProps) { const ref = useRef(null); const utils = trpc.useUtils(); + const invalidateTimeline = useInvalidateTimeline(); const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery( { projectId }, @@ -55,10 +57,7 @@ export function AllocationPopover({ const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: () => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); + invalidateTimeline(); void utils.allocation.listView.invalidate(); onClose(); }, diff --git a/apps/web/src/components/timeline/BatchAssignPopover.tsx b/apps/web/src/components/timeline/BatchAssignPopover.tsx index 1be7e9b..6199ede 100644 --- a/apps/web/src/components/timeline/BatchAssignPopover.tsx +++ b/apps/web/src/components/timeline/BatchAssignPopover.tsx @@ -4,6 +4,7 @@ import { clsx } from "clsx"; import { useEffect, useRef, useState } from "react"; import { AllocationStatus } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; interface BatchAssignPopoverProps { resourceIds: string[]; @@ -29,7 +30,7 @@ export function BatchAssignPopover({ onCreated, }: BatchAssignPopoverProps) { const ref = useRef(null); - const utils = trpc.useUtils(); + const invalidateTimeline = useInvalidateTimeline(); const [search, setSearch] = useState(""); const [selectedProjectId, setSelectedProjectId] = useState( @@ -51,10 +52,7 @@ export function BatchAssignPopover({ const batchMutation = trpc.timeline.batchQuickAssign.useMutation({ onSuccess: () => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); + invalidateTimeline(); onCreated(); onClose(); }, diff --git a/apps/web/src/components/timeline/DemandPopover.tsx b/apps/web/src/components/timeline/DemandPopover.tsx index fc81b6d..18ba71c 100644 --- a/apps/web/src/components/timeline/DemandPopover.tsx +++ b/apps/web/src/components/timeline/DemandPopover.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from "react"; import type { TimelineDemandEntry } from "./TimelineContext.js"; -import { formatDateLong } from "~/lib/format.js"; +import { formatCents, formatDateLong } from "~/lib/format.js"; interface DemandPopoverProps { demand: TimelineDemandEntry; @@ -143,13 +143,13 @@ export function DemandPopover({
Daily cost
- {(demand.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR + {formatCents(demand.dailyCostCents)} EUR
Total cost
- {(budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR + {formatCents(budgetCents)} EUR
diff --git a/apps/web/src/components/timeline/NewAllocationPopover.tsx b/apps/web/src/components/timeline/NewAllocationPopover.tsx index e1cb262..5e081f6 100644 --- a/apps/web/src/components/timeline/NewAllocationPopover.tsx +++ b/apps/web/src/components/timeline/NewAllocationPopover.tsx @@ -4,6 +4,7 @@ import { clsx } from "clsx"; import { useEffect, useRef, useState } from "react"; import { AllocationStatus } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { DateInput } from "~/components/ui/DateInput.js"; interface NewAllocationPopoverProps { @@ -36,7 +37,7 @@ export function NewAllocationPopover({ onCreated, }: NewAllocationPopoverProps) { const ref = useRef(null); - const utils = trpc.useUtils(); + const invalidateTimeline = useInvalidateTimeline(); const [search, setSearch] = useState(""); const [selectedProjectId, setSelectedProjectId] = useState( @@ -60,10 +61,7 @@ export function NewAllocationPopover({ const createMutation = trpc.timeline.quickAssign.useMutation({ onSuccess: () => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); + invalidateTimeline(); onCreated(); onClose(); }, diff --git a/apps/web/src/components/timeline/ProjectPanel.tsx b/apps/web/src/components/timeline/ProjectPanel.tsx index 9a334c6..0c93f88 100644 --- a/apps/web/src/components/timeline/ProjectPanel.tsx +++ b/apps/web/src/components/timeline/ProjectPanel.tsx @@ -4,6 +4,7 @@ import { clsx } from "clsx"; import { useEffect, useState } from "react"; import { AllocationStatus, type StaffingRequirement } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { DateInput } from "~/components/ui/DateInput.js"; @@ -89,7 +90,7 @@ function normalizeRole(value: string | null | undefined): string { } export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { - const utils = trpc.useUtils(); + const invalidateTimeline = useInvalidateTimeline(); const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery( { projectId }, @@ -102,29 +103,16 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) { ); const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ - onSuccess: () => { - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - }, + onSuccess: invalidateTimeline, }); const deleteMutation = trpc.allocation.deleteAssignment.useMutation({ - onSuccess: () => { - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - }, + onSuccess: invalidateTimeline, }); const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({ onSuccess: () => { - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); + invalidateTimeline(); setAddingMember(false); setResourceSearch(""); }, diff --git a/apps/web/src/components/timeline/ResourceHoverCard.tsx b/apps/web/src/components/timeline/ResourceHoverCard.tsx index 42c2c40..ff3dcc1 100644 --- a/apps/web/src/components/timeline/ResourceHoverCard.tsx +++ b/apps/web/src/components/timeline/ResourceHoverCard.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { trpc } from "~/lib/trpc/client.js"; +import { formatCents } from "~/lib/format.js"; import type { SkillEntry } from "@planarchy/shared"; interface ResourceHoverCardProps { @@ -120,14 +121,14 @@ export function ResourceHoverCard({ resourceId, anchorEl, onClose }: ResourceHov
LCR
- {(data.lcrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h + {formatCents(data.lcrCents)} {data.currency}/h
UCR
- {(data.ucrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h + {formatCents(data.ucrCents)} {data.currency}/h
diff --git a/apps/web/src/components/timeline/TimelineProjectPanel.tsx b/apps/web/src/components/timeline/TimelineProjectPanel.tsx index 2113a31..60c10b0 100644 --- a/apps/web/src/components/timeline/TimelineProjectPanel.tsx +++ b/apps/web/src/components/timeline/TimelineProjectPanel.tsx @@ -1,7 +1,7 @@ "use client"; import { clsx } from "clsx"; -import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import type { CSSProperties } from "react"; import { @@ -22,6 +22,12 @@ import { } from "./timelineConstants.js"; import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js"; import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js"; +import { + renderVacationBlocks, + renderRangeOverlay, + renderOverbookingBlink, + type VacationBlockInfo, +} from "./renderHelpers.js"; // ─── Props ────────────────────────────────────────────────────────────────── @@ -173,7 +179,7 @@ function buildProjectRowGridBackground(dates: Date[], CELL_WIDTH: number, today: // ─── Component ────────────────────────────────────────────────────────────── -export function TimelineProjectPanel({ +function TimelineProjectPanelInner({ scrollContainerRef, dragState, allocDragState, @@ -785,22 +791,30 @@ export function TimelineProjectPanel({ onAllocationContextMenu, multiSelectState, )} - {renderVacationBlocksForProjectRow( - vacationsByResource.get(row.resource.id) ?? [], - ROW_HEIGHT, - toLeft, - toWidth, - CELL_WIDTH, - totalCanvasWidth, - filters.showVacations, - )} + {filters.showVacations && + renderVacationBlocks( + (vacationsByResource.get(row.resource.id) ?? []).reduce( + (acc, v) => { + const vStart = new Date(v.startDate); + const vEnd = new Date(v.endDate); + const left = toLeft(vStart); + const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd)); + if (width > 0 && left < totalCanvasWidth) { + acc.push({ vacation: v, left, width }); + } + return acc; + }, + [], + ), + ROW_HEIGHT, + )} {blinkOverbookedDays && - renderOverbookingBlinkProject( + renderOverbookingBlink( allocsByResource.get(row.resource.id) ?? [], dates, CELL_WIDTH, )} - {renderRangeOverlayProject( + {renderRangeOverlay( rangeState, row.resource.id, ROW_HEIGHT, @@ -1294,127 +1308,4 @@ function renderProjectDragHandles( }); } -// ─── Vacation blocks for project view rows ────────────────────────────────── - -const TYPE_COLORS: Record = { - ANNUAL: "bg-orange-400/40", - SICK: "bg-red-500/40", - PUBLIC_HOLIDAY: "bg-violet-400/40", - OTHER: "bg-amber-400/40", -}; -const TYPE_BORDER: Record = { - ANNUAL: "border-orange-500", - SICK: "border-red-600", - PUBLIC_HOLIDAY: "border-violet-500", - OTHER: "border-amber-500", -}; -const TYPE_LABELS_SHORT: Record = { - ANNUAL: "Annual", - SICK: "Sick", - PUBLIC_HOLIDAY: "Holiday", - OTHER: "Other", -}; - -function renderVacationBlocksForProjectRow( - vacations: { id: string; type: string; startDate: Date | string; endDate: Date | string }[], - rowHeight: number, - toLeft: (d: Date) => number, - toWidth: (s: Date, e: Date) => number, - CELL_WIDTH: number, - totalCanvasWidth: number, - showVacations: boolean, -) { - if (!showVacations || vacations.length === 0) return null; - - return vacations.map((v) => { - const vStart = new Date(v.startDate); - const vEnd = new Date(v.endDate); - const left = toLeft(vStart); - const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd)); - if (width <= 0 || left >= totalCanvasWidth) return null; - - const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40"; - const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500"; - const label = TYPE_LABELS_SHORT[v.type] ?? v.type; - - return ( -
- {width > 40 && ( - - 🏖 {label} - - )} -
- ); - }); -} - -// ─── Range overlay for project view ───────────────────────────────────────── - -function renderOverbookingBlinkProject( - allocs: TimelineAssignmentEntry[], - dates: Date[], - CELL_WIDTH: number, -) { - const REF_H = 8; - const overbooked: number[] = []; - - for (let i = 0; i < dates.length; i++) { - const d = new Date(dates[i]!); - d.setHours(0, 0, 0, 0); - const t = d.getTime(); - let totalH = 0; - for (const a of allocs) { - const s = new Date(a.startDate); - s.setHours(0, 0, 0, 0); - const e = new Date(a.endDate); - e.setHours(0, 0, 0, 0); - if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay; - } - if (totalH > REF_H) overbooked.push(i); - } - - if (overbooked.length === 0) return null; - - return overbooked.map((i) => ( -
- )); -} - -function renderRangeOverlayProject( - rangeState: RangeState, - resourceId: string, - rowHeight: number, - toLeft: (d: Date) => number, - toWidth: (s: Date, e: Date) => number, - CELL_WIDTH: number, -) { - if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) { - return null; - } - const end = rangeState.currentDate ?? rangeState.startDate; - const [selStart, selEnd] = - rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate]; - - const left = toLeft(selStart); - const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd)); - - return ( -
- ); -} +export const TimelineProjectPanel = memo(TimelineProjectPanelInner); diff --git a/apps/web/src/components/timeline/TimelineResourcePanel.tsx b/apps/web/src/components/timeline/TimelineResourcePanel.tsx index 7d083a9..f01b05e 100644 --- a/apps/web/src/components/timeline/TimelineResourcePanel.tsx +++ b/apps/web/src/components/timeline/TimelineResourcePanel.tsx @@ -1,12 +1,11 @@ "use client"; import { clsx } from "clsx"; -import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useTimelineContext, type TimelineAssignmentEntry, - type VacationEntry, } from "./TimelineContext.js"; import { ConflictOverlay } from "./ConflictOverlay.js"; import { computeSubLanes } from "./utils.js"; @@ -27,6 +26,12 @@ import type { MultiSelectState, } from "~/hooks/useTimelineDrag.js"; import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js"; +import { + renderVacationBlocks, + renderRangeOverlay, + renderOverbookingBlink, + type VacationBlockInfo, +} from "./renderHelpers.js"; // ─── Props ────────────────────────────────────────────────────────────────── @@ -76,7 +81,7 @@ export interface RowMouseDownInfo { // ─── Component ────────────────────────────────────────────────────────────── -export function TimelineResourcePanel({ +function TimelineResourcePanelInner({ scrollContainerRef, dragState, allocDragState, @@ -487,7 +492,7 @@ export function TimelineResourcePanel({ onAllocationContextMenu, multiSelectState, )} - {renderVacationBlocksForRow( + {renderVacationBlocks( vacationBlocksByResource.get(resource.id) ?? [], rowHeight, )} @@ -547,12 +552,6 @@ export function TimelineResourcePanel({ // ─── Helper types ─────────────────────────────────────────────────────────── -interface VacationBlockInfo { - vacation: VacationEntry; - left: number; - width: number; -} - interface AllocBlockData { alloc: TimelineAssignmentEntry; lane: number; @@ -560,81 +559,6 @@ interface AllocBlockData { // ─── Pure render functions (no hooks, extracted from TimelineView) ─────────── -const TYPE_COLORS: Record = { - ANNUAL: "bg-orange-400/40", - SICK: "bg-red-500/40", - PUBLIC_HOLIDAY: "bg-violet-400/40", - OTHER: "bg-amber-400/40", -}; -const TYPE_BORDER: Record = { - ANNUAL: "border-orange-500", - SICK: "border-red-600", - PUBLIC_HOLIDAY: "border-violet-500", - OTHER: "border-amber-500", -}; -const TYPE_LABELS_SHORT: Record = { - ANNUAL: "Annual", - SICK: "Sick", - PUBLIC_HOLIDAY: "Holiday", - OTHER: "Other", -}; - -function renderVacationBlocksForRow(blocks: VacationBlockInfo[], rowHeight: number) { - if (blocks.length === 0) return null; - - return blocks.map(({ vacation: v, left, width }) => { - const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40"; - const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500"; - const label = TYPE_LABELS_SHORT[v.type] ?? v.type; - const isPending = v.status === "PENDING"; - - return ( -
- {width > 40 && ( - - {isPending ? "\u23F3" : "\uD83C\uDFD6"} {label} - - )} -
- ); - }); -} - -function renderRangeOverlay( - rangeState: RangeState, - resourceId: string, - rowHeight: number, - toLeft: (d: Date) => number, - toWidth: (s: Date, e: Date) => number, - CELL_WIDTH: number, -) { - if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) { - return null; - } - const end = rangeState.currentDate ?? rangeState.startDate; - const [selStart, selEnd] = - rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate]; - - const left = toLeft(selStart); - const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd)); - - return ( -
- ); -} - function renderAllocBlocksFromData( blockData: AllocBlockData[], _allocs: TimelineAssignmentEntry[], @@ -890,45 +814,6 @@ function renderHeatmapOverlay( }); } -// ─── Overbooking blink overlay ─────────────────────────────────────────────── - -function renderOverbookingBlink( - allocs: TimelineAssignmentEntry[], - dates: Date[], - CELL_WIDTH: number, -) { - const REF_H = 8; - const overbooked: number[] = []; - - for (let i = 0; i < dates.length; i++) { - const d = new Date(dates[i]!); - d.setHours(0, 0, 0, 0); - const t = d.getTime(); - let totalH = 0; - for (const a of allocs) { - const s = new Date(a.startDate); - s.setHours(0, 0, 0, 0); - const e = new Date(a.endDate); - e.setHours(0, 0, 0, 0); - if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay; - } - if (totalH > REF_H) overbooked.push(i); - } - - if (overbooked.length === 0) return null; - - return overbooked.map((i) => ( -
- )); -} - // ─── Bar-mode: stacked daily bars ──────────────────────────────────────────── function renderDailyBars( @@ -1108,5 +993,8 @@ function renderDailyBars( }); } +export const TimelineResourcePanel = memo(TimelineResourcePanelInner); + // ─── Re-export tooltip types for the parent ───────────────────────────────── -export type { VacationBlockInfo, AllocBlockData }; +export type { AllocBlockData }; +export type { VacationBlockInfo } from "./renderHelpers.js"; diff --git a/apps/web/src/components/timeline/TimelineView.tsx b/apps/web/src/components/timeline/TimelineView.tsx index 6c2e511..e7c56f7 100644 --- a/apps/web/src/components/timeline/TimelineView.tsx +++ b/apps/web/src/components/timeline/TimelineView.tsx @@ -7,6 +7,7 @@ import { useProjectDragContext } from "~/hooks/useProjectDragContext.js"; import { useTimelineDrag } from "~/hooks/useTimelineDrag.js"; import { useTimelineLayout } from "~/hooks/useTimelineLayout.js"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js"; import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js"; import { AllocationPopover } from "./AllocationPopover.js"; import { DemandPopover } from "./DemandPopover.js"; @@ -29,6 +30,7 @@ import { } from "./TimelineContext.js"; import { TimelineResourcePanel } from "./TimelineResourcePanel.js"; import { TimelineProjectPanel, type OpenDemandAssignment } from "./TimelineProjectPanel.js"; +import { useMultiSelectIntersection } from "~/hooks/useMultiSelectIntersection.js"; // ─── Entry point ──────────────────────────────────────────────────────────── // Two-layer mount: the outer shell creates drag state + project context, @@ -67,14 +69,9 @@ export function TimelineView() { // We start with 40 (day zoom default) and update via a ref. const cellWidthRef = useRef(40); - const outerUtils = trpc.useUtils(); + const invalidateTimeline = useInvalidateTimeline(); const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({ - onSuccess: () => { - void outerUtils.timeline.getEntries.invalidate(); - void outerUtils.timeline.getEntriesView.invalidate(); - void outerUtils.timeline.getProjectContext.invalidate(); - void outerUtils.timeline.getBudgetStatus.invalidate(); - }, + onSuccess: invalidateTimeline, }); const { @@ -327,13 +324,10 @@ function TimelineViewContent({ } | null>(null); const resourceHoverTimerRef = useRef | null>(null); - const utils = trpc.useUtils(); + const invalidateTimelineInner = useInvalidateTimeline(); const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({ onSuccess: () => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); + invalidateTimelineInner(); clearMultiSelect(); }, }); @@ -570,124 +564,22 @@ function TimelineViewContent({ }; // ─── Multi-select intersection computation ──────────────────────────────── - useEffect(() => { - // Only compute when drag just ended (isSelecting false but has coordinates) - if (multiSelectState.isSelecting) return; - if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return; - if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return; - - const canvasEl = canvasRef.current; - if (!canvasEl) return; - - // Selection rectangle in viewport coordinates (same coordinate space as - // getBoundingClientRect). Using viewport coords directly avoids any - // coordinate transformation errors from sticky headers or virtualizer offsets. - const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY); - const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY); - const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX); - const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX); - - // For X-axis: convert viewport X to canvas-relative X for allocation matching. - // Query any row element to find the actual canvas area position. - const canvasRect = canvasEl.getBoundingClientRect(); - const canvasXOffset = canvasRect.left + LABEL_WIDTH; - const toCanvasX = (clientX: number) => clientX - canvasXOffset; - - const selLeftCanvas = toCanvasX(selLeft); - const selRightCanvas = toCanvasX(selRight); - - // Derive date range from pixel X positions - const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH))); - const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH))); - const startDate = dates[colIndexStart] ?? today; - const endDate = dates[colIndexEnd] ?? today; - - // Find allocations within the rectangle by querying actual DOM positions. - // This avoids any mismatch between computed row positions and actual rendering. - const selectedIds: string[] = []; - const selectedResIds: string[] = []; - - // Query all rendered row elements (virtualizer only renders visible + overscan rows) - const rowElements = canvasEl.querySelectorAll("[data-index]"); - - if (viewMode === "resource") { - rowElements.forEach((rowEl) => { - const idx = Number(rowEl.dataset.index); - const resource = resources[idx]; - if (!resource) return; - - const rowRect = rowEl.getBoundingClientRect(); - // Compare directly in viewport coordinates - if (rowRect.bottom < selTop || rowRect.top > selBottom) return; - selectedResIds.push(resource.id); - - const allocs = allocsByResource.get(resource.id) ?? []; - for (const alloc of allocs) { - const allocLeft = toLeft(new Date(alloc.startDate)); - const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate)); - if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) { - selectedIds.push(alloc.id); - } - } - }); - } else if (viewMode === "project") { - // Project view: query actual resource row DOM elements by data attribute. - // Each row carries data-project-id and data-resource-id for alloc lookup. - const projectRowEls = canvasEl.querySelectorAll("[data-project-resource-row]"); - projectRowEls.forEach((rowEl) => { - const rowRect = rowEl.getBoundingClientRect(); - if (rowRect.bottom < selTop || rowRect.top > selBottom) return; - - const projectId = rowEl.dataset.projectId; - const resourceId = rowEl.dataset.resourceId; - if (!projectId || !resourceId) return; - - // Find matching group and row - const group = projectGroups.find((g) => g.id === projectId); - if (!group) return; - const row = group.resourceRows.find((r) => r.resource.id === resourceId); - if (!row) return; - - for (const alloc of row.allocs) { - const allocLeft = toLeft(new Date(alloc.startDate)); - const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate)); - if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) { - selectedIds.push(alloc.id); - } - } - }); - - // Also check demand rows for open demand selection - const demandRowEls = canvasEl.querySelectorAll("[data-project-demand-row]"); - demandRowEls.forEach((rowEl) => { - const rowRect = rowEl.getBoundingClientRect(); - if (rowRect.bottom < selTop || rowRect.top > selBottom) return; - - const projectId = rowEl.dataset.projectId; - if (!projectId) return; - - const demands = openDemandsByProject.get(projectId) ?? []; - for (const demand of demands) { - const allocLeft = toLeft(new Date(demand.startDate)); - const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate)); - if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) { - selectedIds.push(demand.id); - } - } - }); - } - - if (selectedIds.length > 0 || selectedResIds.length > 0) { - setMultiSelectState(prev => ({ - ...prev, - selectedAllocationIds: selectedIds, - selectedResourceIds: selectedResIds, - dateRange: { start: startDate, end: endDate }, - })); - } else { - clearMultiSelect(); - } - }, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps + useMultiSelectIntersection({ + multiSelectState, + setMultiSelectState, + clearMultiSelect, + canvasRef, + viewMode, + resources, + allocsByResource, + projectGroups, + openDemandsByProject, + dates, + today, + CELL_WIDTH, + toLeft, + toWidth, + }); return (
diff --git a/apps/web/src/components/timeline/dragMath.ts b/apps/web/src/components/timeline/dragMath.ts new file mode 100644 index 0000000..9735c08 --- /dev/null +++ b/apps/web/src/components/timeline/dragMath.ts @@ -0,0 +1,51 @@ +/** + * Pure math utilities for timeline drag operations. + * Extracted from useTimelineDrag to make the conversion logic testable + * and reusable across different drag modes. + */ + +/** Convert a pixel delta to a number of whole days based on cell width. */ +export function pixelsToDays(deltaX: number, cellWidth: number): number { + return Math.round(deltaX / cellWidth); +} + +/** + * Shift a date by a given number of days, returning a new Date. + * Does not mutate the input. + */ +export function shiftDate(date: Date, daysDelta: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + daysDelta); + return result; +} + +/** + * Compute new start/end dates for a drag operation. + * Handles move, resize-start, and resize-end modes with clamping + * to prevent start from crossing past end (or vice versa). + */ +export function computeDragDates( + mode: "move" | "resize-start" | "resize-end", + originalStart: Date, + originalEnd: Date, + daysDelta: number, +): { start: Date; end: Date } { + const newStart = new Date(originalStart); + const newEnd = new Date(originalEnd); + + if (mode === "move") { + newStart.setDate(newStart.getDate() + daysDelta); + newEnd.setDate(newEnd.getDate() + daysDelta); + } else if (mode === "resize-start") { + newStart.setDate(newStart.getDate() + daysDelta); + // Clamp: allow same-day but prevent crossing + if (newStart > newEnd) newStart.setTime(newEnd.getTime()); + } else { + // resize-end + newEnd.setDate(newEnd.getDate() + daysDelta); + // Clamp: allow same-day but prevent crossing + if (newEnd < newStart) newEnd.setTime(newStart.getTime()); + } + + return { start: newStart, end: newEnd }; +} diff --git a/apps/web/src/components/timeline/renderHelpers.tsx b/apps/web/src/components/timeline/renderHelpers.tsx new file mode 100644 index 0000000..23ed8fe --- /dev/null +++ b/apps/web/src/components/timeline/renderHelpers.tsx @@ -0,0 +1,121 @@ +/** + * Shared pure render functions used by both TimelineResourcePanel and TimelineProjectPanel. + * Extracted to avoid duplication of identical vacation blocks, range overlay, and overbooking blink logic. + */ + +import { clsx } from "clsx"; +import { + VACATION_TIMELINE_COLORS, + VACATION_TIMELINE_BORDER, + VACATION_TYPE_LABELS_SHORT, +} from "~/lib/status-styles.js"; +import type { RangeState } from "~/hooks/useTimelineDrag.js"; +import type { TimelineAssignmentEntry } from "./TimelineContext.js"; +import type { VacationEntry } from "./TimelineContext.js"; + +// ─── Shared types ───────────────────────────────────────────────────────────── + +export interface VacationBlockInfo { + vacation: VacationEntry; + left: number; + width: number; +} + +// ─── Vacation block overlays ───────────────────────────────────────────────── + +export function renderVacationBlocks(blocks: VacationBlockInfo[], rowHeight: number) { + if (blocks.length === 0) return null; + + return blocks.map(({ vacation: v, left, width }) => { + const colorClass = VACATION_TIMELINE_COLORS[v.type] ?? "bg-orange-400/40"; + const borderClass = VACATION_TIMELINE_BORDER[v.type] ?? "border-orange-500"; + const label = VACATION_TYPE_LABELS_SHORT[v.type] ?? v.type; + const isPending = v.status === "PENDING"; + + return ( +
+ {width > 40 && ( + + {isPending ? "\u23F3" : "\uD83C\uDFD6"} {label} + + )} +
+ ); + }); +} + +// ─── Range selection overlay ───────────────────────────────────────────────── + +export function renderRangeOverlay( + rangeState: RangeState, + resourceId: string, + rowHeight: number, + toLeft: (d: Date) => number, + toWidth: (s: Date, e: Date) => number, + CELL_WIDTH: number, +) { + if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) { + return null; + } + const end = rangeState.currentDate ?? rangeState.startDate; + const [selStart, selEnd] = + rangeState.startDate <= end ? [rangeState.startDate, end] : [end, rangeState.startDate]; + + const left = toLeft(selStart); + const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd)); + + return ( +
+ ); +} + +// ─── Overbooking blink overlay ─────────────────────────────────────────────── + +export function renderOverbookingBlink( + allocs: TimelineAssignmentEntry[], + dates: Date[], + CELL_WIDTH: number, +) { + const REF_H = 8; + const overbooked: number[] = []; + + for (let i = 0; i < dates.length; i++) { + const d = new Date(dates[i]!); + d.setHours(0, 0, 0, 0); + const t = d.getTime(); + let totalH = 0; + for (const a of allocs) { + const s = new Date(a.startDate); + s.setHours(0, 0, 0, 0); + const e = new Date(a.endDate); + e.setHours(0, 0, 0, 0); + if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay; + } + if (totalH > REF_H) overbooked.push(i); + } + + if (overbooked.length === 0) return null; + + return overbooked.map((i) => ( +
+ )); +} diff --git a/apps/web/src/components/vacations/TeamCalendar.tsx b/apps/web/src/components/vacations/TeamCalendar.tsx index 3022217..a55326a 100644 --- a/apps/web/src/components/vacations/TeamCalendar.tsx +++ b/apps/web/src/components/vacations/TeamCalendar.tsx @@ -4,13 +4,7 @@ import { useState } from "react"; import { VacationStatus } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; - -const TYPE_COLOR: Record = { - ANNUAL: "bg-brand-500", - SICK: "bg-red-400", - PUBLIC_HOLIDAY: "bg-emerald-500", - OTHER: "bg-purple-400", -}; +import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js"; const MONTH_NAMES = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", @@ -49,7 +43,7 @@ export function TeamCalendar() { const { data: allChapters } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 }); const chapters = allChapters ?? []; - const resourceList = resources?.resources ?? []; + const resourceList: { id: string; displayName: string }[] = resources?.resources ?? []; const vacationList = (vacations ?? []).filter( (v) => v.status !== VacationStatus.CANCELLED && v.status !== VacationStatus.REJECTED, ); @@ -155,7 +149,7 @@ export function TeamCalendar() { let cellClass = "w-7 h-7"; if (vac) { - const color = TYPE_COLOR[vac.type] ?? "bg-gray-400"; + const color = VACATION_CALENDAR_COLORS[vac.type] ?? "bg-gray-400"; const opacity = vac.status === "PENDING" ? "opacity-50" : ""; cellClass += ` ${color} ${opacity}`; } else if (isWeekend) { @@ -186,7 +180,7 @@ export function TeamCalendar() { {/* Legend */}
- {Object.entries(TYPE_COLOR).map(([type, color]) => ( + {Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => ( {type.replace("_", " ")} diff --git a/apps/web/src/components/vacations/VacationCalendar.tsx b/apps/web/src/components/vacations/VacationCalendar.tsx index 269e881..babd7b7 100644 --- a/apps/web/src/components/vacations/VacationCalendar.tsx +++ b/apps/web/src/components/vacations/VacationCalendar.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { VacationStatus, VacationType } from "@planarchy/shared"; +import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js"; interface VacationEntry { id: string; @@ -18,13 +19,6 @@ interface VacationCalendarProps { initialMonth?: number; // 0-indexed } -const TYPE_COLOR: Record = { - ANNUAL: "bg-brand-500", - SICK: "bg-red-400", - PUBLIC_HOLIDAY: "bg-emerald-400", - OTHER: "bg-purple-400", -}; - const STATUS_OPACITY: Record = { APPROVED: "opacity-100", PENDING: "opacity-60", @@ -145,7 +139,7 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
{dayVacations.slice(0, 3).map((v) => { - const colorClass = TYPE_COLOR[v.type] ?? "bg-gray-400"; + const colorClass = VACATION_CALENDAR_COLORS[v.type] ?? "bg-gray-400"; const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100"; const name = v.resource?.displayName ?? "—"; return ( @@ -169,7 +163,7 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i {/* Legend */}
- {Object.entries(TYPE_COLOR).map(([type, color]) => ( + {Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => ( {type.replace("_", " ")} diff --git a/apps/web/src/components/vacations/VacationModal.tsx b/apps/web/src/components/vacations/VacationModal.tsx index 1e13f21..db81a75 100644 --- a/apps/web/src/components/vacations/VacationModal.tsx +++ b/apps/web/src/components/vacations/VacationModal.tsx @@ -7,16 +7,10 @@ import { trpc } from "~/lib/trpc/client.js"; import { DateInput } from "~/components/ui/DateInput.js"; import { useDebounce } from "~/hooks/useDebounce.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; +import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js"; const VACATION_TYPES = Object.values(VacationType); -const VACATION_TYPE_LABELS: Record = { - ANNUAL: "Annual Leave", - SICK: "Sick Leave", - PUBLIC_HOLIDAY: "Public Holiday", - OTHER: "Other", -}; - interface VacationModalProps { resourceId?: string; onClose: () => void; @@ -118,7 +112,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces const inputClass = "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 dark:bg-gray-900 dark:text-gray-100"; const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; - const resourceList = resources?.resources ?? []; + const resourceList: { id: string; displayName: string; eid: string }[] = resources?.resources ?? []; return (
([]); const future = useRef([]); - const utils = trpc.useUtils(); + const invalidateTimeline = useInvalidateTimeline(); // Configurable max steps from system settings const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, { @@ -27,21 +28,11 @@ export function useAllocationHistory() { const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY; const updateMutation = trpc.timeline.updateAllocationInline.useMutation({ - onSuccess: () => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); - }, + onSuccess: invalidateTimeline, }); const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({ - onSuccess: () => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); - }, + onSuccess: invalidateTimeline, }); const push = useCallback((snapshot: AllocationMovedSnapshot) => { @@ -58,13 +49,6 @@ export function useAllocationHistory() { setCanRedo(false); }, [maxHistory]); - const invalidateAll = useCallback(() => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); - }, [utils]); - const undo = useCallback(async () => { const last = past.current[past.current.length - 1]; if (!last) return; diff --git a/apps/web/src/hooks/useInvalidatePlanningViews.ts b/apps/web/src/hooks/useInvalidatePlanningViews.ts index 985e9ad..02c2bdb 100644 --- a/apps/web/src/hooks/useInvalidatePlanningViews.ts +++ b/apps/web/src/hooks/useInvalidatePlanningViews.ts @@ -1,8 +1,19 @@ import { trpc } from "~/lib/trpc/client.js"; +/** Invalidates just the 4 timeline queries */ +export function useInvalidateTimeline() { + const utils = trpc.useUtils(); + return () => { + void utils.timeline.getEntries.invalidate(); + void utils.timeline.getEntriesView.invalidate(); + void utils.timeline.getProjectContext.invalidate(); + void utils.timeline.getBudgetStatus.invalidate(); + }; +} + +/** Invalidates all 8 planning-related queries (4 timeline + 4 allocation) */ export function useInvalidatePlanningViews() { const utils = trpc.useUtils(); - return () => { void utils.allocation.list.invalidate(); void ( diff --git a/apps/web/src/hooks/useMultiSelectIntersection.ts b/apps/web/src/hooks/useMultiSelectIntersection.ts new file mode 100644 index 0000000..1182366 --- /dev/null +++ b/apps/web/src/hooks/useMultiSelectIntersection.ts @@ -0,0 +1,166 @@ +/** + * Computes which allocations/resources fall within the multi-select rectangle + * after the user finishes a right-click drag selection. + */ + +import { useEffect } from "react"; +import { LABEL_WIDTH } from "~/components/timeline/timelineConstants.js"; +import type { MultiSelectState, AllocDragMode } from "~/hooks/useTimelineDrag.js"; +import type { TimelineAssignmentEntry } from "~/components/timeline/TimelineContext.js"; +import type { ViewMode, ResourceBrief } from "~/components/timeline/TimelineContext.js"; + +interface ProjectGroup { + id: string; + resourceRows: { + resource: { id: string }; + allocs: TimelineAssignmentEntry[]; + }[]; +} + +interface DemandEntry { + id: string; + startDate: Date | string; + endDate: Date | string; +} + +export function useMultiSelectIntersection({ + multiSelectState, + setMultiSelectState, + clearMultiSelect, + canvasRef, + viewMode, + resources, + allocsByResource, + projectGroups, + openDemandsByProject, + dates, + today, + CELL_WIDTH, + toLeft, + toWidth, +}: { + multiSelectState: MultiSelectState; + setMultiSelectState: React.Dispatch>; + clearMultiSelect: () => void; + canvasRef: React.RefObject; + viewMode: ViewMode; + resources: ResourceBrief[]; + allocsByResource: Map; + projectGroups: ProjectGroup[]; + openDemandsByProject: Map; + dates: Date[]; + today: Date; + CELL_WIDTH: number; + toLeft: (d: Date) => number; + toWidth: (s: Date, e: Date) => number; +}) { + useEffect(() => { + // Only compute when drag just ended (isSelecting false but has coordinates) + if (multiSelectState.isSelecting) return; + if (multiSelectState.startX === 0 && multiSelectState.startY === 0) return; + if (multiSelectState.selectedAllocationIds.length > 0 || multiSelectState.selectedResourceIds.length > 0) return; + + const canvasEl = canvasRef.current; + if (!canvasEl) return; + + // Selection rectangle in viewport coordinates + const selTop = Math.min(multiSelectState.startY, multiSelectState.currentY); + const selBottom = Math.max(multiSelectState.startY, multiSelectState.currentY); + const selLeft = Math.min(multiSelectState.startX, multiSelectState.currentX); + const selRight = Math.max(multiSelectState.startX, multiSelectState.currentX); + + // Convert viewport X to canvas-relative X for allocation matching + const canvasRect = canvasEl.getBoundingClientRect(); + const canvasXOffset = canvasRect.left + LABEL_WIDTH; + const toCanvasX = (clientX: number) => clientX - canvasXOffset; + + const selLeftCanvas = toCanvasX(selLeft); + const selRightCanvas = toCanvasX(selRight); + + // Derive date range from pixel X positions + const colIndexStart = Math.max(0, Math.min(dates.length - 1, Math.floor(selLeftCanvas / CELL_WIDTH))); + const colIndexEnd = Math.max(0, Math.min(dates.length - 1, Math.floor(selRightCanvas / CELL_WIDTH))); + const startDate = dates[colIndexStart] ?? today; + const endDate = dates[colIndexEnd] ?? today; + + const selectedIds: string[] = []; + const selectedResIds: string[] = []; + + // Query all rendered row elements (virtualizer only renders visible + overscan rows) + const rowElements = canvasEl.querySelectorAll("[data-index]"); + + if (viewMode === "resource") { + rowElements.forEach((rowEl) => { + const idx = Number(rowEl.dataset.index); + const resource = resources[idx]; + if (!resource) return; + + const rowRect = rowEl.getBoundingClientRect(); + if (rowRect.bottom < selTop || rowRect.top > selBottom) return; + selectedResIds.push(resource.id); + + const allocs = allocsByResource.get(resource.id) ?? []; + for (const alloc of allocs) { + const allocLeft = toLeft(new Date(alloc.startDate)); + const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate)); + if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) { + selectedIds.push(alloc.id); + } + } + }); + } else if (viewMode === "project") { + const projectRowEls = canvasEl.querySelectorAll("[data-project-resource-row]"); + projectRowEls.forEach((rowEl) => { + const rowRect = rowEl.getBoundingClientRect(); + if (rowRect.bottom < selTop || rowRect.top > selBottom) return; + + const projectId = rowEl.dataset.projectId; + const resourceId = rowEl.dataset.resourceId; + if (!projectId || !resourceId) return; + + const group = projectGroups.find((g) => g.id === projectId); + if (!group) return; + const row = group.resourceRows.find((r) => r.resource.id === resourceId); + if (!row) return; + + for (const alloc of row.allocs) { + const allocLeft = toLeft(new Date(alloc.startDate)); + const allocRight = allocLeft + toWidth(new Date(alloc.startDate), new Date(alloc.endDate)); + if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) { + selectedIds.push(alloc.id); + } + } + }); + + // Also check demand rows for open demand selection + const demandRowEls = canvasEl.querySelectorAll("[data-project-demand-row]"); + demandRowEls.forEach((rowEl) => { + const rowRect = rowEl.getBoundingClientRect(); + if (rowRect.bottom < selTop || rowRect.top > selBottom) return; + + const projectId = rowEl.dataset.projectId; + if (!projectId) return; + + const demands = openDemandsByProject.get(projectId) ?? []; + for (const demand of demands) { + const allocLeft = toLeft(new Date(demand.startDate)); + const allocRight = allocLeft + toWidth(new Date(demand.startDate), new Date(demand.endDate)); + if (allocRight >= selLeftCanvas && allocLeft <= selRightCanvas) { + selectedIds.push(demand.id); + } + } + }); + } + + if (selectedIds.length > 0 || selectedResIds.length > 0) { + setMultiSelectState(prev => ({ + ...prev, + selectedAllocationIds: selectedIds, + selectedResourceIds: selectedResIds, + dateRange: { start: startDate, end: endDate }, + })); + } else { + clearMultiSelect(); + } + }, [multiSelectState.isSelecting, multiSelectState.startX, multiSelectState.startY]); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 5d7f195..034f383 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -2,6 +2,8 @@ import { useCallback, useRef, useState } from "react"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; +import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js"; // ─── Project-shift drag state ─────────────────────────────────────────────── @@ -214,6 +216,7 @@ export function useTimelineDrag({ onMultiDragCompleteRef.current = onMultiDragComplete; const utils = trpc.useUtils(); + const invalidateTimeline = useInvalidateTimeline(); // Project-shift preview const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery( @@ -235,9 +238,7 @@ export function useTimelineDrag({ // eslint-disable-next-line @typescript-eslint/no-explicit-any const applyShiftMutation = (trpc.timeline.applyShift.useMutation as any)({ onSuccess: (data: { project: { id: string } }) => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); + invalidateTimeline(); void utils.project.list.invalidate(); onShiftApplied?.(data.project.id); }, @@ -251,10 +252,7 @@ export function useTimelineDrag({ const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({ onSuccess: () => { - void utils.timeline.getEntries.invalidate(); - void utils.timeline.getEntriesView.invalidate(); - void utils.timeline.getProjectContext.invalidate(); - void utils.timeline.getBudgetStatus.invalidate(); + invalidateTimeline(); const snap = pendingSnapshotRef.current; if (snap) { onAllocationMovedRef.current?.(snap); @@ -378,7 +376,7 @@ export function useTimelineDrag({ function handleMultiMove(ev: MouseEvent) { const deltaX = ev.clientX - startMouseX; - const daysDelta = Math.round(deltaX / cellWidthRef.current); + const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); if (daysDelta === currentDaysDelta) return; currentDaysDelta = daysDelta; @@ -432,25 +430,15 @@ export function useTimelineDrag({ if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return; const deltaX = ev.clientX - alloc.startMouseX; - const daysDelta = Math.round(deltaX / cellWidthRef.current); + const daysDelta = pixelsToDays(deltaX, cellWidthRef.current); if (daysDelta === alloc.daysDelta) return; - const newStart = new Date(alloc.originalStartDate); - const newEnd = new Date(alloc.originalEndDate); - - if (alloc.mode === "move") { - newStart.setDate(newStart.getDate() + daysDelta); - newEnd.setDate(newEnd.getDate() + daysDelta); - } else if (alloc.mode === "resize-start") { - newStart.setDate(newStart.getDate() + daysDelta); - // Allow same-day (single day booking), prevent crossing - if (newStart > newEnd) newStart.setTime(newEnd.getTime()); - } else { - // resize-end - newEnd.setDate(newEnd.getDate() + daysDelta); - // Allow same-day (single day booking), prevent crossing - if (newEnd < newStart) newEnd.setTime(newStart.getTime()); - } + const { start: newStart, end: newEnd } = computeDragDates( + alloc.mode, + alloc.originalStartDate, + alloc.originalEndDate, + daysDelta, + ); const updated: AllocDragState = { ...alloc, @@ -545,12 +533,14 @@ export function useTimelineDrag({ const drag = dragStateRef.current; if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) { const deltaX = e.clientX - drag.startMouseX; - const daysDelta = Math.round(deltaX / cellWidth); + const daysDelta = pixelsToDays(deltaX, cellWidth); if (daysDelta !== drag.daysDelta) { - const newStart = new Date(drag.originalStartDate); - newStart.setDate(newStart.getDate() + daysDelta); - const newEnd = new Date(drag.originalEndDate); - newEnd.setDate(newEnd.getDate() + daysDelta); + const { start: newStart, end: newEnd } = computeDragDates( + "move", + drag.originalStartDate, + drag.originalEndDate, + daysDelta, + ); const updated: DragState = { ...drag, currentStartDate: newStart, @@ -567,7 +557,7 @@ export function useTimelineDrag({ const range = rangeStateRef.current; if (range.isSelecting && range.startDate) { const deltaX = e.clientX - range.startClientX; - const daysDelta = Math.round(deltaX / cellWidth); + const daysDelta = pixelsToDays(deltaX, cellWidth); const currentDate = new Date(range.startDate); currentDate.setDate(currentDate.getDate() + daysDelta); diff --git a/apps/web/src/lib/status-styles.ts b/apps/web/src/lib/status-styles.ts index e8dff18..48ef64d 100644 --- a/apps/web/src/lib/status-styles.ts +++ b/apps/web/src/lib/status-styles.ts @@ -46,3 +46,33 @@ export const ORDER_TYPE_BADGE: Record = { INTERNAL: "bg-blue-100 text-blue-700", OVERHEAD: "bg-gray-100 text-gray-700", }; + +/** Vacation overlay colors for timeline bars */ +export const VACATION_TIMELINE_COLORS: Record = { + ANNUAL: "bg-orange-400/40", + SICK: "bg-red-500/40", + PUBLIC_HOLIDAY: "bg-violet-400/40", + OTHER: "bg-amber-400/40", +}; + +export const VACATION_TIMELINE_BORDER: Record = { + ANNUAL: "border-orange-500", + SICK: "border-red-600", + PUBLIC_HOLIDAY: "border-violet-500", + OTHER: "border-amber-500", +}; + +export const VACATION_TYPE_LABELS_SHORT: Record = { + ANNUAL: "Annual", + SICK: "Sick", + PUBLIC_HOLIDAY: "Holiday", + OTHER: "Other", +}; + +/** Vacation calendar dot/bar colors */ +export const VACATION_CALENDAR_COLORS: Record = { + ANNUAL: "bg-brand-500", + SICK: "bg-red-400", + PUBLIC_HOLIDAY: "bg-emerald-500", + OTHER: "bg-purple-400", +}; diff --git a/packages/api/src/lib/format-utils.ts b/packages/api/src/lib/format-utils.ts new file mode 100644 index 0000000..71caf55 --- /dev/null +++ b/packages/api/src/lib/format-utils.ts @@ -0,0 +1,3 @@ +export function fmtEur(cents: number): string { + return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`; +} diff --git a/packages/api/src/router/assistant-tools.ts b/packages/api/src/router/assistant-tools.ts index bfe594c..a8fc8bb 100644 --- a/packages/api/src/router/assistant-tools.ts +++ b/packages/api/src/router/assistant-tools.ts @@ -9,6 +9,7 @@ import type { PermissionKey } from "@planarchy/shared"; import { parseTaskAction } from "@planarchy/shared"; import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js"; import { getTaskAction } from "../lib/task-actions.js"; +import { fmtEur } from "../lib/format-utils.js"; import { resolveRecipients } from "../lib/notification-targeting.js"; import { emitNotificationCreated, @@ -41,10 +42,6 @@ type ToolExecutor = (params: any, ctx: ToolContext) => Promise; // ─── Helpers ──────────────────────────────────────────────────────────────── -function fmtEur(cents: number): string { - return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`; -} - function fmtDate(d: Date | null | undefined): string | null { return d ? d.toISOString().slice(0, 10) : null; } diff --git a/packages/api/src/router/calculation-rules.ts b/packages/api/src/router/calculation-rules.ts index 30b4417..cf8e790 100644 --- a/packages/api/src/router/calculation-rules.ts +++ b/packages/api/src/router/calculation-rules.ts @@ -2,29 +2,29 @@ import { CreateCalculationRuleSchema, UpdateCalculationRuleSchema, } from "@planarchy/shared"; -import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { findUniqueOrThrow } from "../db/helpers.js"; +import { PROJECT_BRIEF_SELECT } from "../db/selects.js"; import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js"; export const calculationRuleRouter = createTRPCRouter({ list: controllerProcedure.query(async ({ ctx }) => { return ctx.db.calculationRule.findMany({ orderBy: [{ priority: "desc" }, { name: "asc" }], - include: { project: { select: { id: true, name: true, shortCode: true } } }, + include: { project: { select: PROJECT_BRIEF_SELECT } }, }); }), getById: controllerProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { - const rule = await ctx.db.calculationRule.findUnique({ - where: { id: input.id }, - include: { project: { select: { id: true, name: true, shortCode: true } } }, - }); - if (!rule) { - throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" }); - } - return rule; + return findUniqueOrThrow( + ctx.db.calculationRule.findUnique({ + where: { id: input.id }, + include: { project: { select: PROJECT_BRIEF_SELECT } }, + }), + "CalculationRule", + ); }), /** Get all active rules (optimized for engine use — no project include) */ @@ -58,10 +58,10 @@ export const calculationRuleRouter = createTRPCRouter({ .input(UpdateCalculationRuleSchema) .mutation(async ({ ctx, input }) => { const { id, ...data } = input; - const existing = await ctx.db.calculationRule.findUnique({ where: { id } }); - if (!existing) { - throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" }); - } + await findUniqueOrThrow( + ctx.db.calculationRule.findUnique({ where: { id } }), + "CalculationRule", + ); // Build update data using exactOptionalPropertyTypes pattern const updateData: Record = {}; @@ -85,10 +85,10 @@ export const calculationRuleRouter = createTRPCRouter({ delete: managerProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.calculationRule.findUnique({ where: { id: input.id } }); - if (!existing) { - throw new TRPCError({ code: "NOT_FOUND", message: "Calculation rule not found" }); - } + await findUniqueOrThrow( + ctx.db.calculationRule.findUnique({ where: { id: input.id } }), + "CalculationRule", + ); await ctx.db.calculationRule.delete({ where: { id: input.id } }); return { success: true }; }), diff --git a/packages/api/src/router/computation-graph.ts b/packages/api/src/router/computation-graph.ts index d35a78d..c91352a 100644 --- a/packages/api/src/router/computation-graph.ts +++ b/packages/api/src/router/computation-graph.ts @@ -12,6 +12,7 @@ import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailabilit import { VacationStatus } from "@planarchy/db"; import { z } from "zod"; import { createTRPCRouter, controllerProcedure } from "../trpc.js"; +import { fmtEur } from "../lib/format-utils.js"; // ─── Graph Types (mirrored from client for API response) ──────────────────── @@ -50,10 +51,6 @@ function l(source: string, target: string, formula: string, weight = 1): GraphLi return { source, target, formula, weight }; } -function fmtEur(cents: number): string { - return `${(cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR`; -} - function fmtPct(ratio: number): string { return `${(ratio * 100).toFixed(1)}%`; } diff --git a/packages/api/src/router/entitlement.ts b/packages/api/src/router/entitlement.ts index 4477dad..dbed422 100644 --- a/packages/api/src/router/entitlement.ts +++ b/packages/api/src/router/entitlement.ts @@ -6,6 +6,7 @@ import { VacationType, VacationStatus } from "@planarchy/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; +import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; /** Types that consume from annual leave balance */ @@ -266,7 +267,7 @@ export const entitlementRouter = createTRPCRouter({ isActive: true, ...(input.chapter ? { chapter: input.chapter } : {}), }, - select: { id: true, displayName: true, eid: true, chapter: true }, + select: { ...RESOURCE_BRIEF_SELECT, chapter: true }, orderBy: [{ chapter: "asc" }, { displayName: "asc" }], }); diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index ed2dacb..dc8b40e 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; +import { findUniqueOrThrow } from "../db/helpers.js"; import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; import { emitNotificationCreated, @@ -598,13 +599,10 @@ export const notificationRouter = createTRPCRouter({ assignTask: managerProcedure .input(z.object({ id: z.string(), assigneeId: z.string() })) .mutation(async ({ ctx, input }) => { - const existing = await ctx.db.notification.findUnique({ - where: { id: input.id }, - }); - - if (!existing) { - throw new TRPCError({ code: "NOT_FOUND", message: "Task not found" }); - } + const existing = await findUniqueOrThrow( + ctx.db.notification.findUnique({ where: { id: input.id } }), + "Task", + ); if (existing.category !== "TASK" && existing.category !== "APPROVAL") { throw new TRPCError({ diff --git a/packages/api/src/router/project-planning-read-model.ts b/packages/api/src/router/project-planning-read-model.ts index 90e1218..4c8af80 100644 --- a/packages/api/src/router/project-planning-read-model.ts +++ b/packages/api/src/router/project-planning-read-model.ts @@ -1,6 +1,7 @@ import { buildSplitAllocationReadModel } from "@planarchy/application"; import type { PrismaClient } from "@planarchy/db"; import { AllocationStatus } from "@planarchy/shared"; +import { ROLE_BRIEF_SELECT } from "../db/selects.js"; export const PROJECT_PLANNING_ALLOCATION_INCLUDE = { resource: { @@ -31,7 +32,7 @@ export const PROJECT_PLANNING_ALLOCATION_INCLUDE = { }, }, roleEntity: { - select: { id: true, name: true, color: true }, + select: ROLE_BRIEF_SELECT, }, } as const; diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts index 1c9f29f..4a6d6f6 100644 --- a/packages/api/src/router/resource.ts +++ b/packages/api/src/router/resource.ts @@ -295,30 +295,30 @@ export const resourceRouter = createTRPCRouter({ getHoverCard: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { - const resource = await ctx.db.resource.findUnique({ - where: { id: input.id }, - select: { - id: true, - displayName: true, - eid: true, - email: true, - chapter: true, - lcrCents: true, - ucrCents: true, - currency: true, - chargeabilityTarget: true, - skills: true, - availability: true, - isActive: true, - areaRole: { select: { id: true, name: true, color: true } }, - country: { select: { name: true, code: true } }, - managementLevel: { select: { name: true } }, - resourceType: true, - }, - }); - if (!resource) { - throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); - } + const resource = await findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.id }, + select: { + id: true, + displayName: true, + eid: true, + email: true, + chapter: true, + lcrCents: true, + ucrCents: true, + currency: true, + chargeabilityTarget: true, + skills: true, + availability: true, + isActive: true, + areaRole: { select: ROLE_BRIEF_SELECT }, + country: { select: { name: true, code: true } }, + managementLevel: { select: { name: true } }, + resourceType: true, + }, + }), + "Resource", + ); const directory = await getAnonymizationDirectory(ctx.db); const anon = anonymizeResource(resource, directory); return { @@ -633,11 +633,14 @@ export const resourceRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { // Find the resource linked to this user - const user = await ctx.db.user.findUnique({ - where: { email: ctx.session.user?.email ?? "" }, - include: { resource: true }, - }); - if (!user?.resource) { + const user = await findUniqueOrThrow( + ctx.db.user.findUnique({ + where: { email: ctx.session.user?.email ?? "" }, + include: { resource: true }, + }), + "User", + ); + if (!user.resource) { throw new TRPCError({ code: "NOT_FOUND", message: "No resource linked to your account" }); } const resourceId = user.resource.id; @@ -748,17 +751,16 @@ export const resourceRouter = createTRPCRouter({ .input(z.object({ resourceId: z.string() })) .mutation(async ({ ctx, input }) => { const [resource, settings] = await Promise.all([ - ctx.db.resource.findUnique({ - where: { id: input.resourceId }, - include: { areaRole: { select: { name: true } } }, - }), + findUniqueOrThrow( + ctx.db.resource.findUnique({ + where: { id: input.resourceId }, + include: { areaRole: { select: { name: true } } }, + }), + "Resource", + ), ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }), ]); - if (!resource) { - throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" }); - } - if (!isAiConfigured(settings)) { throw new TRPCError({ code: "PRECONDITION_FAILED", diff --git a/packages/api/src/router/role.ts b/packages/api/src/router/role.ts index 50a3f12..508ada6 100644 --- a/packages/api/src/router/role.ts +++ b/packages/api/src/router/role.ts @@ -3,6 +3,7 @@ import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/sh import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; +import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; @@ -89,7 +90,7 @@ export const roleRouter = createTRPCRouter({ _count: { select: { resourceRoles: true } }, resourceRoles: { include: { - resource: { select: { id: true, displayName: true, eid: true } }, + resource: { select: RESOURCE_BRIEF_SELECT }, }, }, }, diff --git a/packages/api/src/router/timeline.ts b/packages/api/src/router/timeline.ts index 49f1f43..2d8979f 100644 --- a/packages/api/src/router/timeline.ts +++ b/packages/api/src/router/timeline.ts @@ -143,23 +143,22 @@ async function loadTimelineEntriesReadModel( async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) { const [project, planningRead] = await Promise.all([ - db.project.findUnique({ - where: { id: projectId }, - select: { - id: true, - budgetCents: true, - winProbability: true, - startDate: true, - endDate: true, - }, - }), + findUniqueOrThrow( + db.project.findUnique({ + where: { id: projectId }, + select: { + id: true, + budgetCents: true, + winProbability: true, + startDate: true, + endDate: true, + }, + }), + "Project", + ), loadProjectPlanningReadModel(db, { projectId, activeOnly: true }), ]); - if (!project) { - throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); - } - const { demandRequirements, assignments, readModel: projectReadModel } = planningRead; const resourceIds = getAssignmentResourceIds(projectReadModel); @@ -337,31 +336,30 @@ export const timelineRouter = createTRPCRouter({ .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const [project, planningRead] = await Promise.all([ - ctx.db.project.findUnique({ - where: { id: input.projectId }, - select: { - id: true, - name: true, - shortCode: true, - orderType: true, - budgetCents: true, - winProbability: true, - status: true, - startDate: true, - endDate: true, - staffingReqs: true, - }, - }), + findUniqueOrThrow( + ctx.db.project.findUnique({ + where: { id: input.projectId }, + select: { + id: true, + name: true, + shortCode: true, + orderType: true, + budgetCents: true, + winProbability: true, + status: true, + startDate: true, + endDate: true, + staffingReqs: true, + }, + }), + "Project", + ), loadProjectPlanningReadModel(ctx.db, { projectId: input.projectId, activeOnly: true, }), ]); - if (!project) { - throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" }); - } - const resourceIds = getAssignmentResourceIds(planningRead.readModel); const allResourceAllocations = resourceIds.length === 0 diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 32fc6ba..3b340ae 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -3,6 +3,7 @@ import { VacationStatus, VacationType } from "@planarchy/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; +import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emitTaskAssigned } from "../sse/event-bus.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { sendEmail } from "../lib/email.js"; @@ -99,7 +100,7 @@ export const vacationRouter = createTRPCRouter({ ...(input.endDate ? { startDate: { lte: input.endDate } } : {}), }, include: { - resource: { select: { id: true, displayName: true, eid: true } }, + resource: { select: RESOURCE_BRIEF_SELECT }, requestedBy: { select: { id: true, name: true, email: true } }, approvedBy: { select: { id: true, name: true, email: true } }, }, @@ -120,7 +121,7 @@ export const vacationRouter = createTRPCRouter({ ctx.db.vacation.findUnique({ where: { id: input.id }, include: { - resource: { select: { id: true, displayName: true, eid: true } }, + resource: { select: RESOURCE_BRIEF_SELECT }, requestedBy: { select: { id: true, name: true, email: true } }, approvedBy: { select: { id: true, name: true, email: true } }, }, @@ -210,7 +211,7 @@ export const vacationRouter = createTRPCRouter({ : {}), }, include: { - resource: { select: { id: true, displayName: true, eid: true } }, + resource: { select: RESOURCE_BRIEF_SELECT }, requestedBy: { select: { id: true, name: true, email: true } }, }, }); @@ -539,7 +540,7 @@ export const vacationRouter = createTRPCRouter({ return ctx.db.vacation.findMany({ where: { status: VacationStatus.PENDING }, include: { - resource: { select: { id: true, displayName: true, eid: true } }, + resource: { select: RESOURCE_BRIEF_SELECT }, requestedBy: { select: { id: true, name: true, email: true } }, }, orderBy: { startDate: "asc" }, @@ -576,7 +577,7 @@ export const vacationRouter = createTRPCRouter({ endDate: { gte: input.startDate }, }, include: { - resource: { select: { id: true, displayName: true, eid: true } }, + resource: { select: RESOURCE_BRIEF_SELECT }, }, orderBy: { startDate: "asc" }, take: 20,