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 <ruv@ruv.net>
This commit is contained in:
@@ -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 (
|
||||
<td key={col.key} className="px-4 py-3 min-w-[120px]">
|
||||
<div className="mb-0.5 text-sm text-gray-900 dark:text-gray-100">
|
||||
{(project.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} €
|
||||
{formatMoney(project.budgetCents)}
|
||||
</div>
|
||||
<BudgetBar utilizationPercent={project.utilizationPercent ?? 0} budgetCents={project.budgetCents} />
|
||||
</td>
|
||||
|
||||
@@ -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)}
|
||||
</td>
|
||||
);
|
||||
case "chargeability": {
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{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` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,7 +407,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{r.hoursPerDay}h</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{Math.round(r.availableHours)}h</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{(r.estimatedCostCents / 100).toLocaleString("de-DE")} EUR</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-300">{formatCents(r.estimatedCostCents)} EUR</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<span className={`font-medium ${r.coveragePercent >= 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
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{(planned.reduce((s, r) => s + r.estimatedCostCents, 0) / 100).toLocaleString("de-DE")} EUR
|
||||
{formatCents(planned.reduce((s, r) => s + r.estimatedCostCents, 0))} EUR
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{totalDemandHours > 0 ? Math.round((consumedHours / totalDemandHours) * 100) : 0}%
|
||||
@@ -441,7 +434,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-1.5 text-right text-xs text-gray-500 dark:text-gray-400">Role Budget:</td>
|
||||
<td className="px-3 py-1.5 text-right text-xs font-semibold text-gray-700 dark:text-gray-300">
|
||||
{(allocation.budgetCents / 100).toLocaleString("de-DE")} EUR
|
||||
{formatCents(allocation.budgetCents)} EUR
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-xs">
|
||||
{(() => {
|
||||
@@ -449,7 +442,7 @@ export function FillOpenDemandModal({ allocation, onClose, onSuccess }: FillOpen
|
||||
const remain = allocation.budgetCents! - totalCost;
|
||||
return (
|
||||
<span className={remain < 0 ? "text-red-600 font-medium" : "text-green-600"}>
|
||||
{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`}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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) {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||||
{(p.totalCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} €
|
||||
{formatCents(p.totalCostCents)} €
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700 dark:text-gray-200">
|
||||
{p.totalPersonDays}d
|
||||
@@ -280,7 +281,7 @@ export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
{p.budgetCents > 0 ? (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<span className="text-gray-700 dark:text-gray-200">
|
||||
{(p.budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 0 })} €
|
||||
{formatMoney(p.budgetCents)}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${overBudget ? "bg-red-500" : util >= 80 ? "bg-amber-500" : "bg-green-500"}`}
|
||||
|
||||
@@ -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
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">{assignment.hoursPerDay}h</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
|
||||
{(assignment.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} €
|
||||
{formatCents(assignment.dailyCostCents)} €
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-right text-gray-900 dark:text-gray-100">
|
||||
{(countWorkingDays(assignment.startDate, assignment.endDate) * assignment.hoursPerDay).toLocaleString("de-DE", { minimumFractionDigits: 1 })}h
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="text-gray-900 dark:text-gray-100">
|
||||
{(demand.budgetCents! / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
|
||||
{formatCents(demand.budgetCents!)} EUR
|
||||
</div>
|
||||
<div className={`text-xs ${remainCents < 0 ? "text-red-500" : "text-gray-400"}`}>
|
||||
{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)` : ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
<div className={`h-full rounded-full transition-all ${remainingCents < 0 ? "bg-red-500" : remainingCents === 0 ? "bg-green-500" : "bg-amber-500"}`} style={{ width: `${Math.min(100, pct)}%` }} />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Project: {(projectBudgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
|
||||
<span>Allocated: {(allocatedCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
|
||||
<span className="font-semibold">Remaining: {(remainingCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR</span>
|
||||
<span>Project: {formatCents(projectBudgetCents)} EUR</span>
|
||||
<span>Allocated: {formatCents(allocatedCents)} EUR</span>
|
||||
<span className="font-semibold">Remaining: {formatCents(remainingCents)} EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
<StatCard
|
||||
label="LCR"
|
||||
value={`${(resource.lcrCents / 100).toFixed(0)} ${resource.currency}/h`}
|
||||
value={`${formatMoney(resource.lcrCents, resource.currency)}/h`}
|
||||
tooltip="Loaded Cost Rate: fully-loaded hourly cost including salary, benefits, and overhead. Used in budget calculations."
|
||||
/>
|
||||
)}
|
||||
{canViewCosts && (
|
||||
<StatCard
|
||||
label="UCR"
|
||||
value={`${(resource.ucrCents / 100).toFixed(0)} ${resource.currency}/h`}
|
||||
value={`${formatMoney(resource.ucrCents, resource.currency)}/h`}
|
||||
tooltip="Unit Cost Rate: the rate charged to the client or project for this resource's time."
|
||||
/>
|
||||
)}
|
||||
@@ -518,7 +518,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
|
||||
{canViewCosts && (
|
||||
<td className="px-4 py-3 text-right text-gray-700">
|
||||
{a.dailyCostCents > 0
|
||||
? `${(a.dailyCostCents / 100).toFixed(0)}/d`
|
||||
? `${formatMoney(a.dailyCostCents)}/d`
|
||||
: "—"}
|
||||
</td>
|
||||
)}
|
||||
|
||||
@@ -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<HTMLDivElement>(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();
|
||||
},
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Daily cost</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{(demand.dailyCostCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
|
||||
{formatCents(demand.dailyCostCents)} EUR
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 mb-0.5">Total cost</div>
|
||||
<div className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{(budgetCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} EUR
|
||||
{formatCents(budgetCents)} EUR
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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("");
|
||||
},
|
||||
|
||||
@@ -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
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">LCR</div>
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{(data.lcrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
|
||||
{formatCents(data.lcrCents)} {data.currency}/h
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
|
||||
<div>
|
||||
<div className="text-gray-400 dark:text-gray-500 text-[10px] uppercase tracking-wider">UCR</div>
|
||||
<div className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{(data.ucrCents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2 })} {data.currency}/h
|
||||
{formatCents(data.ucrCents)} {data.currency}/h
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-gray-200 dark:bg-gray-600" />
|
||||
|
||||
@@ -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<VacationBlockInfo[]>(
|
||||
(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<string, string> = {
|
||||
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<string, string> = {
|
||||
ANNUAL: "border-orange-500",
|
||||
SICK: "border-red-600",
|
||||
PUBLIC_HOLIDAY: "border-violet-500",
|
||||
OTHER: "border-amber-500",
|
||||
};
|
||||
const TYPE_LABELS_SHORT: Record<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
key={`vac-${v.id}`}
|
||||
className={clsx(
|
||||
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden border-t-2 pointer-events-none",
|
||||
colorClass,
|
||||
borderClass,
|
||||
)}
|
||||
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
|
||||
>
|
||||
{width > 40 && (
|
||||
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
|
||||
🏖 {label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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) => (
|
||||
<div
|
||||
key={`ob-${i}`}
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
|
||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
|
||||
style={{ left, width, top: 4, height: rowHeight - 8 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export const TimelineProjectPanel = memo(TimelineProjectPanelInner);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
ANNUAL: "border-orange-500",
|
||||
SICK: "border-red-600",
|
||||
PUBLIC_HOLIDAY: "border-violet-500",
|
||||
OTHER: "border-amber-500",
|
||||
};
|
||||
const TYPE_LABELS_SHORT: Record<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
key={`vac-${v.id}`}
|
||||
className={clsx(
|
||||
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
|
||||
colorClass,
|
||||
isPending ? "border-t-2 border-dashed opacity-60" : "border-t-2",
|
||||
isPending ? "" : borderClass,
|
||||
)}
|
||||
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
|
||||
>
|
||||
{width > 40 && (
|
||||
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
|
||||
{isPending ? "\u23F3" : "\uD83C\uDFD6"} {label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
|
||||
style={{ left, width, top: 4, height: rowHeight - 8 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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) => (
|
||||
<div
|
||||
key={`ob-${i}`}
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
|
||||
style={{
|
||||
left: i * CELL_WIDTH,
|
||||
width: CELL_WIDTH,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
// ─── 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";
|
||||
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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 (
|
||||
<div className="relative flex flex-1 flex-col gap-4 min-h-0">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
key={`vac-${v.id}`}
|
||||
className={clsx(
|
||||
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden pointer-events-none",
|
||||
colorClass,
|
||||
isPending ? "border-t-2 border-dashed opacity-60" : "border-t-2",
|
||||
isPending ? "" : borderClass,
|
||||
)}
|
||||
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
|
||||
>
|
||||
{width > 40 && (
|
||||
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
|
||||
{isPending ? "\u23F3" : "\uD83C\uDFD6"} {label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div
|
||||
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
|
||||
style={{ left, width, top: 4, height: rowHeight - 8 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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) => (
|
||||
<div
|
||||
key={`ob-${i}`}
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
|
||||
style={{
|
||||
left: i * CELL_WIDTH,
|
||||
width: CELL_WIDTH,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 */}
|
||||
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
|
||||
{Object.entries(TYPE_COLOR).map(([type, color]) => (
|
||||
{Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => (
|
||||
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
|
||||
{type.replace("_", " ")}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
ANNUAL: "bg-brand-500",
|
||||
SICK: "bg-red-400",
|
||||
PUBLIC_HOLIDAY: "bg-emerald-400",
|
||||
OTHER: "bg-purple-400",
|
||||
};
|
||||
|
||||
const STATUS_OPACITY: Record<string, string> = {
|
||||
APPROVED: "opacity-100",
|
||||
PENDING: "opacity-60",
|
||||
@@ -145,7 +139,7 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
{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 */}
|
||||
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
|
||||
{Object.entries(TYPE_COLOR).map(([type, color]) => (
|
||||
{Object.entries(VACATION_CALENDAR_COLORS).map(([type, color]) => (
|
||||
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
|
||||
{type.replace("_", " ")}
|
||||
|
||||
@@ -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<VacationType, string> = {
|
||||
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 (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
||||
import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
|
||||
|
||||
export type { AllocationMovedSnapshot };
|
||||
@@ -18,7 +19,7 @@ export function useAllocationHistory() {
|
||||
const past = useRef<HistoryEntry[]>([]);
|
||||
const future = useRef<HistoryEntry[]>([]);
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<React.SetStateAction<MultiSelectState>>;
|
||||
clearMultiSelect: () => void;
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
viewMode: ViewMode;
|
||||
resources: ResourceBrief[];
|
||||
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
|
||||
projectGroups: ProjectGroup[];
|
||||
openDemandsByProject: Map<string, DemandEntry[]>;
|
||||
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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -46,3 +46,33 @@ export const ORDER_TYPE_BADGE: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
ANNUAL: "border-orange-500",
|
||||
SICK: "border-red-600",
|
||||
PUBLIC_HOLIDAY: "border-violet-500",
|
||||
OTHER: "border-amber-500",
|
||||
};
|
||||
|
||||
export const VACATION_TYPE_LABELS_SHORT: Record<string, string> = {
|
||||
ANNUAL: "Annual",
|
||||
SICK: "Sick",
|
||||
PUBLIC_HOLIDAY: "Holiday",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
/** Vacation calendar dot/bar colors */
|
||||
export const VACATION_CALENDAR_COLORS: Record<string, string> = {
|
||||
ANNUAL: "bg-brand-500",
|
||||
SICK: "bg-red-400",
|
||||
PUBLIC_HOLIDAY: "bg-emerald-500",
|
||||
OTHER: "bg-purple-400",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user