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:
@@ -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,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user