fix(web): make invalidation hooks async with Promise.all and fix cross-view staleness

- useInvalidateTimeline and useInvalidatePlanningViews now return
  Promise.all instead of fire-and-forget void calls
- Timeline mutations now use useInvalidatePlanningViews to also
  invalidate allocation list views, preventing stale data
- AllocationsClient sequential awaits replaced with single
  invalidatePlanningViews() call (parallel invalidation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 08:24:33 +02:00
parent f18777c365
commit f3fa902773
11 changed files with 372 additions and 229 deletions
@@ -5,6 +5,7 @@ import { useUrlFilters } from "~/hooks/useUrlFilters.js";
import { useLocalStorage } from "~/hooks/useLocalStorage.js";
import { formatDate } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { AllocationModal } from "./AllocationModal.js";
import type {
AllocationLike,
@@ -171,6 +172,7 @@ export function AllocationsClient() {
const selection = useSelection();
const utils = trpc.useUtils();
const invalidatePlanningViews = useInvalidatePlanningViews();
const { canViewCosts } = usePermissions();
// ─── Column visibility ────────────────────────────────────────────────────
@@ -205,31 +207,23 @@ export function AllocationsClient() {
const allocationQueryFailure = isError ? getAllocationQueryFailure(error) : null;
const deleteDemandMutation = trpc.allocation.deleteDemandRequirement.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
},
onSuccess: () => invalidatePlanningViews(),
});
const deleteAssignmentMutation = trpc.allocation.deleteAssignment.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
},
onSuccess: () => invalidatePlanningViews(),
});
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
await invalidatePlanningViews();
selection.clear();
},
});
const batchStatusMutation = trpc.allocation.batchUpdateStatus.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
await invalidatePlanningViews();
selection.clear();
setShowStatusToast(true);
},
@@ -237,8 +231,7 @@ export function AllocationsClient() {
const batchDateShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: async () => {
await utils.allocation.list.invalidate();
await utils.allocation.listView.invalidate();
await invalidatePlanningViews();
selection.clear();
setShowDateShiftModal(false);
},
@@ -33,7 +33,7 @@ vi.mock("~/lib/trpc/client.js", () => ({
}));
vi.mock("~/hooks/useInvalidatePlanningViews.js", () => ({
useInvalidateTimeline: () => vi.fn(),
useInvalidatePlanningViews: () => vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/hooks/useViewportPopover.js", () => ({
@@ -6,7 +6,7 @@ import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { AllocationLike, Assignment } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
@@ -38,7 +38,7 @@ export function AllocationPopover({
ignoreScrollContainers,
}: AllocationPopoverProps) {
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
const { ref, style } = useViewportPopover({
anchor: { kind: "point", x: anchorX, y: anchorY },
width: 300,
@@ -85,18 +85,16 @@ export function AllocationPopover({
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
void utils.allocation.listView.invalidate();
onClose();
},
});
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
void utils.allocation.listView.invalidate();
onClose();
},
});
@@ -140,7 +138,9 @@ export function AllocationPopover({
Loading...
</div>
);
return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body);
return typeof document === "undefined"
? loadingPopover
: createPortal(loadingPopover, document.body);
}
if (allocationError) {
@@ -152,19 +152,22 @@ export function AllocationPopover({
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-red-200 bg-white p-4 shadow-xl"
>
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
<p className="text-xs text-gray-500">
The selected booking could not be loaded right now.
</p>
<p className="text-xs text-gray-500">The selected booking could not be loaded right now.</p>
<p className="text-xs text-red-600">{allocationError.message}</p>
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
onClick={() => {
onClose();
onOpenPanel(projectId);
}}
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Open Project Panel
</button>
</div>
);
return typeof document === "undefined" ? errorPopover : createPortal(errorPopover, document.body);
return typeof document === "undefined"
? errorPopover
: createPortal(errorPopover, document.body);
}
if (!allocation) {
@@ -180,17 +183,25 @@ export function AllocationPopover({
The selected booking could not be resolved from the current timeline data.
</p>
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
onClick={() => {
onClose();
onOpenPanel(projectId);
}}
className="w-full rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Open Project Panel
</button>
</div>
);
return typeof document === "undefined" ? missingPopover : createPortal(missingPopover, document.body);
return typeof document === "undefined"
? missingPopover
: createPortal(missingPopover, document.body);
}
const dailyCostEUR = ((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0) / 100).toFixed(2);
const dailyCostEUR = (
((hoursPerDay ?? allocation.hoursPerDay) * (allocation.resource?.lcrCents ?? 0)) /
100
).toFixed(2);
const carveDateRangeInvalid =
Boolean(carveStartDate && carveEndDate) && carveEndDate < carveStartDate;
@@ -208,14 +219,20 @@ export function AllocationPopover({
<div>
<span className="text-sm font-semibold text-gray-800">{role}</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 text-lg leading-none"
>
&times;
</button>
</div>
<div className="space-y-3 overflow-y-auto p-4">
{/* Resource */}
<div className="text-xs text-gray-500">
Resource: <span className="font-medium text-gray-700">{allocation.resource?.displayName}</span>
{" "}· <span className="text-gray-400">{allocation.resource?.eid}</span>
Resource:{" "}
<span className="font-medium text-gray-700">{allocation.resource?.displayName}</span> ·{" "}
<span className="text-gray-400">{allocation.resource?.eid}</span>
</div>
{/* Role */}
@@ -308,7 +325,9 @@ export function AllocationPopover({
<div>
<div className="text-xs font-medium text-gray-700">Remove Date Range</div>
<div className="text-[11px] text-gray-500">
{contextDate ? `Prefilled from ${toDateInput(contextDate)}` : "Create a gap or split this booking."}
{contextDate
? `Prefilled from ${toDateInput(contextDate)}`
: "Create a gap or split this booking."}
</div>
</div>
</div>
@@ -343,10 +362,7 @@ export function AllocationPopover({
<button
onClick={handleCarveRange}
disabled={
carveMutation.isPending ||
!carveStartDate ||
!carveEndDate ||
carveDateRangeInvalid
carveMutation.isPending || !carveStartDate || !carveEndDate || carveDateRangeInvalid
}
className="w-full py-1.5 rounded-lg text-sm font-medium transition-colors bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
@@ -356,7 +372,10 @@ export function AllocationPopover({
{/* Link to full panel */}
<button
onClick={() => { onClose(); onOpenPanel(projectId); }}
onClick={() => {
onClose();
onOpenPanel(projectId);
}}
className="w-full text-xs text-brand-600 hover:text-brand-800 text-center pt-1"
>
Open Project Panel
@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
const ENTITY_COMBOBOX_OVERLAY_SELECTOR = "[data-entity-combobox-overlay='true']";
@@ -34,16 +34,14 @@ export function BatchAssignPopover({
onCreated,
}: BatchAssignPopoverProps) {
const ref = useRef<HTMLDivElement>(null);
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null,
);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [hoursPerDay, setHoursPerDay] = useState(8);
const batchMutation = trpc.timeline.batchQuickAssign.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
onCreated();
onClose();
},
@@ -94,8 +92,7 @@ export function BatchAssignPopover({
});
}
const canAssign =
!!selectedProjectId && resourceIds.length > 0 && hoursPerDay > 0;
const canAssign = !!selectedProjectId && resourceIds.length > 0 && hoursPerDay > 0;
const popover = (
<div
@@ -104,9 +101,7 @@ export function BatchAssignPopover({
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
Batch Assign
</span>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Batch Assign</span>
<button
type="button"
onClick={onClose}
@@ -181,9 +176,7 @@ export function BatchAssignPopover({
{/* Error */}
{batchMutation.isError && (
<p className="text-xs text-red-600 dark:text-red-400">
{batchMutation.error.message}
</p>
<p className="text-xs text-red-600 dark:text-red-400">{batchMutation.error.message}</p>
)}
{/* Actions */}
@@ -198,9 +191,7 @@ export function BatchAssignPopover({
"disabled:opacity-40 disabled:cursor-not-allowed",
)}
>
{batchMutation.isPending
? "Assigning\u2026"
: `Assign All (${resourceIds.length})`}
{batchMutation.isPending ? "Assigning\u2026" : `Assign All (${resourceIds.length})`}
</button>
<button
type="button"
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
interface InlineAllocationEditorProps {
allocationId: string;
@@ -34,12 +34,12 @@ export function InlineAllocationEditor({
const [hoursPerDay, setHoursPerDay] = useState(initialHoursPerDay);
const [error, setError] = useState<string | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateMutation = (trpc.allocation.update.useMutation as any)({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
onSaved();
},
onError: (err: { message: string }) => {
@@ -95,7 +95,9 @@ export function InlineAllocationEditor({
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">Edit Allocation</div>
<form onSubmit={handleSave} className="space-y-2">
<div>
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">Start date</label>
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">
Start date
</label>
<input
type="date"
value={startDate}
@@ -105,7 +107,9 @@ export function InlineAllocationEditor({
/>
</div>
<div>
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">End date</label>
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">
End date
</label>
<input
type="date"
value={endDate}
@@ -115,7 +119,9 @@ export function InlineAllocationEditor({
/>
</div>
<div>
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">Hours / day</label>
<label className="block text-[11px] text-gray-500 dark:text-gray-400 mb-0.5">
Hours / day
</label>
<input
type="number"
value={hoursPerDay}
@@ -6,7 +6,7 @@ import { useState } from "react";
import { createPortal } from "react-dom";
import { AllocationStatus } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
import { DateInput } from "~/components/ui/DateInput.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
@@ -50,7 +50,7 @@ export function NewAllocationPopover({
ignoreSelectors: ["[data-entity-combobox-overlay='true']"],
...(ignoreScrollContainers ? { ignoreScrollContainers } : {}),
});
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
suggestedProjectId ?? null,
@@ -62,7 +62,7 @@ export function NewAllocationPopover({
const createMutation = trpc.timeline.quickAssign.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
onCreated();
onClose();
},
@@ -91,15 +91,24 @@ export function NewAllocationPopover({
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">Assign to Project</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none">&times;</button>
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
Assign to Project
</span>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 text-lg leading-none"
>
&times;
</button>
</div>
<div className="space-y-3 overflow-y-auto p-4">
{/* Date range */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Start</label>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Start
</label>
<DateInput
value={start}
onChange={setStart}
@@ -107,7 +116,9 @@ export function NewAllocationPopover({
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">End</label>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
End
</label>
<DateInput
value={end}
onChange={setEnd}
@@ -119,7 +130,9 @@ export function NewAllocationPopover({
{/* Project picker */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Project</label>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Project
</label>
<ProjectCombobox
value={selectedProjectId}
onChange={setSelectedProjectId}
@@ -130,7 +143,9 @@ export function NewAllocationPopover({
{/* Role */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Role</label>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Role
</label>
<input
type="text"
value={role}
@@ -141,7 +156,9 @@ export function NewAllocationPopover({
{/* Hours per day */}
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Hours / day</label>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">
Hours / day
</label>
<div className="flex items-center gap-2">
<input
type="number"
+105 -61
View File
@@ -4,7 +4,7 @@ import { clsx } from "clsx";
import { useEffect, useState } from "react";
import { AllocationStatus, type StaffingRequirement } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { DateInput } from "~/components/ui/DateInput.js";
@@ -90,7 +90,7 @@ function normalizeRole(value: string | null | undefined): string {
}
export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
const { data: ctx, isLoading } = trpc.timeline.getProjectContext.useQuery(
{ projectId },
@@ -103,16 +103,16 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
);
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: invalidateTimeline,
onSuccess: () => void invalidatePlanningViews(),
});
const deleteMutation = trpc.allocation.deleteAssignment.useMutation({
onSuccess: invalidateTimeline,
onSuccess: () => void invalidatePlanningViews(),
});
const createAssignmentMutation = trpc.allocation.createAssignment.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
setAddingMember(false);
setResourceSearch("");
},
@@ -121,7 +121,16 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
const [addingMember, setAddingMember] = useState(false);
const [resourceSearch, setResourceSearch] = useState("");
const [pendingEdits, setPendingEdits] = useState<
Record<string, { hoursPerDay?: number; startDate?: string; endDate?: string; includeSaturday?: boolean; role?: string }>
Record<
string,
{
hoursPerDay?: number;
startDate?: string;
endDate?: string;
includeSaturday?: boolean;
role?: string;
}
>
>({});
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
@@ -142,19 +151,20 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
const staffingReqs = (project.staffingReqs as unknown as StaffingRequirement[]) ?? [];
const effectiveAssignments = assignments as unknown as ProjectPanelAssignment[];
const projectDemands = demands as unknown as ProjectPanelDemand[];
const effectiveDemands: DemandSummary[] = projectDemands.length > 0
? projectDemands.map((demand) => ({
id: demand.id,
role: demand.roleEntity?.name ?? demand.role ?? "Unassigned",
hoursPerDay: demand.hoursPerDay,
requestedHeadcount: demand.requestedHeadcount,
}))
: staffingReqs.map((req, index) => ({
id: `staffing-${index}`,
role: req.role,
hoursPerDay: req.hoursPerDay,
requestedHeadcount: req.headcount,
}));
const effectiveDemands: DemandSummary[] =
projectDemands.length > 0
? projectDemands.map((demand) => ({
id: demand.id,
role: demand.roleEntity?.name ?? demand.role ?? "Unassigned",
hoursPerDay: demand.hoursPerDay,
requestedHeadcount: demand.requestedHeadcount,
}))
: staffingReqs.map((req, index) => ({
id: `staffing-${index}`,
role: req.role,
hoursPerDay: req.hoursPerDay,
requestedHeadcount: req.headcount,
}));
// Demand vs supply matching
const reqMatches = effectiveDemands.map((demand) => {
@@ -186,7 +196,7 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
return pendingEdits[id] ?? {};
}
function setEdit(id: string, patch: typeof pendingEdits[string]) {
function setEdit(id: string, patch: (typeof pendingEdits)[string]) {
setPendingEdits((prev) => ({ ...prev, [id]: { ...(prev[id] ?? {}), ...patch } }));
}
@@ -237,16 +247,18 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
<h2 className="text-base font-semibold text-gray-900">{project.name}</h2>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={clsx(
"px-2 py-0.5 rounded-full text-xs font-medium",
project.orderType === "CHARGEABLE"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
: project.orderType === "BD"
? "bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300"
: project.orderType === "INTERNAL"
? "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
)}>
<span
className={clsx(
"px-2 py-0.5 rounded-full text-xs font-medium",
project.orderType === "CHARGEABLE"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300"
: project.orderType === "BD"
? "bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300"
: project.orderType === "INTERNAL"
? "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300"
: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300",
)}
>
{project.orderType}
</span>
<span className="text-xs text-gray-500">{project.status}</span>
@@ -258,14 +270,17 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
</div>
<div className="overflow-y-auto flex-1 px-5 py-4 space-y-6">
{/* Budget section */}
<section>
<h3 className="text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">Budget</h3>
<h3 className="text-xs font-semibold text-gray-600 uppercase tracking-wider mb-2">
Budget
</h3>
<div className="bg-gray-50 rounded-xl p-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-300">Allocated</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">{allocatedEUR} / {budgetEUR}</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{allocatedEUR} / {budgetEUR}
</span>
</div>
<div className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
@@ -287,8 +302,8 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
w.level === "critical"
? "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300"
: w.level === "warning"
? "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
: "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
? "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
: "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
)}
>
{w.message}
@@ -306,33 +321,48 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
<div className="space-y-2">
{reqMatches.map(({ demand, matched, fulfilled, partial }) => (
<div key={demand.id} className="border border-gray-200 rounded-xl overflow-hidden">
<div className={clsx(
"flex items-center justify-between px-3 py-2",
fulfilled
? "bg-green-50 dark:bg-green-900/20"
: partial
? "bg-amber-50 dark:bg-amber-900/20"
: "bg-red-50 dark:bg-red-900/20",
)}>
<div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-100">{demand.role}</span>
<span className="ml-2 text-xs text-gray-500">{demand.requestedHeadcount} needed · {demand.hoursPerDay}h/day</span>
</div>
<span className={clsx(
"text-xs font-semibold",
<div
className={clsx(
"flex items-center justify-between px-3 py-2",
fulfilled
? "text-green-600 dark:text-green-400"
? "bg-green-50 dark:bg-green-900/20"
: partial
? "text-amber-600 dark:text-amber-400"
: "text-red-600 dark:text-red-400",
)}>
{fulfilled ? "✓ Filled" : partial ? `${matched.length}/${demand.requestedHeadcount}` : "Unfilled"}
? "bg-amber-50 dark:bg-amber-900/20"
: "bg-red-50 dark:bg-red-900/20",
)}
>
<div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-100">
{demand.role}
</span>
<span className="ml-2 text-xs text-gray-500">
{demand.requestedHeadcount} needed · {demand.hoursPerDay}h/day
</span>
</div>
<span
className={clsx(
"text-xs font-semibold",
fulfilled
? "text-green-600 dark:text-green-400"
: partial
? "text-amber-600 dark:text-amber-400"
: "text-red-600 dark:text-red-400",
)}
>
{fulfilled
? "✓ Filled"
: partial
? `${matched.length}/${demand.requestedHeadcount}`
: "Unfilled"}
</span>
</div>
{matched.length > 0 && (
<div className="px-3 py-1.5 bg-white border-t border-gray-100 space-y-0.5">
{matched.map((a) => (
<div key={a.id} className="flex items-center justify-between text-xs text-gray-700">
<div
key={a.id}
className="flex items-center justify-between text-xs text-gray-700"
>
<span>{a.resource?.displayName}</span>
<span className="text-gray-500">{a.hoursPerDay}h/day</span>
</div>
@@ -347,7 +377,9 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
<div className="text-xs text-gray-600 font-medium mb-1">Unmatched assignments</div>
{unmatchedAssignments.map((a) => (
<div key={a.id} className="text-xs text-gray-500 flex justify-between">
<span>{a.resource?.displayName} {a.role}</span>
<span>
{a.resource?.displayName} {a.role}
</span>
<span>{a.hoursPerDay}h/day</span>
</div>
))}
@@ -392,7 +424,10 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
</button>
))}
<button
onClick={() => { setAddingMember(false); setResourceSearch(""); }}
onClick={() => {
setAddingMember(false);
setResourceSearch("");
}}
className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
>
Cancel
@@ -415,14 +450,19 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
{/* Resource name + delete */}
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-100">{alloc.resource?.displayName}</span>
<span className="text-sm font-medium text-gray-800 dark:text-gray-100">
{alloc.resource?.displayName}
</span>
<span className="text-xs text-gray-500 ml-1.5">{alloc.resource?.eid}</span>
</div>
{confirmDelete === alloc.id ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-600 dark:text-red-400">Remove?</span>
<button
onClick={() => { deleteMutation.mutate({ id: getPlanningEntryMutationId(alloc) }); setConfirmDelete(null); }}
onClick={() => {
deleteMutation.mutate({ id: getPlanningEntryMutationId(alloc) });
setConfirmDelete(null);
}}
className="text-xs text-red-600 dark:text-red-400 font-medium hover:text-red-800 dark:hover:text-red-300"
>
Yes
@@ -479,7 +519,9 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
max={24}
step={0.5}
value={edit.hoursPerDay ?? alloc.hoursPerDay}
onChange={(e) => setEdit(alloc.id, { hoursPerDay: parseFloat(e.target.value) })}
onChange={(e) =>
setEdit(alloc.id, { hoursPerDay: parseFloat(e.target.value) })
}
className="w-full border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
@@ -493,7 +535,9 @@ export function ProjectPanel({ projectId, onClose }: ProjectPanelProps) {
onChange={(e) => setEdit(alloc.id, { includeSaturday: e.target.checked })}
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-400"
/>
<span className="text-xs text-gray-600 dark:text-gray-300">Include Saturdays</span>
<span className="text-xs text-gray-600 dark:text-gray-300">
Include Saturdays
</span>
</label>
{/* Save button */}
@@ -9,7 +9,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 { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
import { FillOpenDemandModal } from "~/components/allocations/FillOpenDemandModal.js";
import { AllocationPopover } from "./AllocationPopover.js";
@@ -95,9 +95,9 @@ export function TimelineView() {
// We start with 40 (day zoom default) and update via a ref.
const cellWidthRef = useRef(40);
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
const batchShiftMutationOuter = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: invalidateTimeline,
onSuccess: () => void invalidatePlanningViews(),
});
const [dragErrorToast, setDragErrorToast] = useState<string | null>(null);
@@ -389,10 +389,10 @@ function TimelineViewContent({
const resourceHoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previousViewModeRef = useRef(viewMode);
const invalidateTimelineInner = useInvalidateTimeline();
const invalidatePlanningViewsInner = useInvalidatePlanningViews();
const batchDeleteMutation = trpc.allocation.batchDelete.useMutation({
onSuccess: () => {
invalidateTimelineInner();
void invalidatePlanningViewsInner();
clearMultiSelect();
},
});
+41 -20
View File
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { useInvalidatePlanningViews } from "./useInvalidatePlanningViews.js";
import type { AllocationMovedSnapshot } from "./useTimelineDrag.js";
export type { AllocationMovedSnapshot };
@@ -9,7 +9,12 @@ export type { AllocationMovedSnapshot };
/** A single allocation move or a batch shift of multiple allocations */
export type HistoryEntry =
| { type: "single"; snapshot: AllocationMovedSnapshot }
| { type: "batch"; allocationIds: string[]; daysDelta: number; mode: "move" | "resize-start" | "resize-end" };
| {
type: "batch";
allocationIds: string[];
daysDelta: number;
mode: "move" | "resize-start" | "resize-end";
};
const DEFAULT_MAX_HISTORY = 50;
@@ -19,7 +24,7 @@ export function useAllocationHistory() {
const past = useRef<HistoryEntry[]>([]);
const future = useRef<HistoryEntry[]>([]);
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
// Configurable max steps from system settings
const { data: settings } = trpc.settings.getSystemSettings.useQuery(undefined, {
@@ -28,26 +33,39 @@ export function useAllocationHistory() {
const maxHistory = settings?.timelineUndoMaxSteps ?? DEFAULT_MAX_HISTORY;
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: invalidateTimeline,
onSuccess: () => void invalidatePlanningViews(),
});
const batchShiftMutation = trpc.timeline.batchShiftAllocations.useMutation({
onSuccess: invalidateTimeline,
onSuccess: () => void invalidatePlanningViews(),
});
const push = useCallback((snapshot: AllocationMovedSnapshot) => {
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "single", snapshot }];
future.current = [];
setCanUndo(true);
setCanRedo(false);
}, [maxHistory]);
const push = useCallback(
(snapshot: AllocationMovedSnapshot) => {
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "single", snapshot }];
future.current = [];
setCanUndo(true);
setCanRedo(false);
},
[maxHistory],
);
const pushBatch = useCallback((allocationIds: string[], daysDelta: number, mode: "move" | "resize-start" | "resize-end" = "move") => {
past.current = [...past.current.slice(-(maxHistory - 1)), { type: "batch", allocationIds, daysDelta, mode }];
future.current = [];
setCanUndo(true);
setCanRedo(false);
}, [maxHistory]);
const pushBatch = useCallback(
(
allocationIds: string[],
daysDelta: number,
mode: "move" | "resize-start" | "resize-end" = "move",
) => {
past.current = [
...past.current.slice(-(maxHistory - 1)),
{ type: "batch", allocationIds, daysDelta, mode },
];
future.current = [];
setCanUndo(true);
setCanRedo(false);
},
[maxHistory],
);
const undo = useCallback(async () => {
const last = past.current[past.current.length - 1];
@@ -65,9 +83,12 @@ export function useAllocationHistory() {
});
} else {
// Batch: reverse the shift (for resize modes, reverse means shifting the same edge back)
const reverseMode = last.mode === "resize-start" ? "resize-start"
: last.mode === "resize-end" ? "resize-end"
: "move";
const reverseMode =
last.mode === "resize-start"
? "resize-start"
: last.mode === "resize-end"
? "resize-end"
: "move";
await batchShiftMutation.mutateAsync({
allocationIds: last.allocationIds,
daysDelta: -last.daysDelta,
@@ -1,39 +1,41 @@
import { trpc } from "~/lib/trpc/client.js";
/** Invalidates just the 4 timeline queries */
/** Invalidates just the timeline queries (parallel). */
export function useInvalidateTimeline() {
const utils = trpc.useUtils();
return () => {
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getMyEntriesView.invalidate();
void utils.timeline.getHolidayOverlays.invalidate();
void utils.timeline.getMyHolidayOverlays.invalidate();
void utils.vacation.list.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
};
return () =>
Promise.all([
utils.timeline.getEntries.invalidate(),
utils.timeline.getEntriesView.invalidate(),
utils.timeline.getMyEntriesView.invalidate(),
utils.timeline.getHolidayOverlays.invalidate(),
utils.timeline.getMyHolidayOverlays.invalidate(),
utils.vacation.list.invalidate(),
utils.timeline.getProjectContext.invalidate(),
utils.timeline.getBudgetStatus.invalidate(),
]);
}
/** Invalidates all 8 planning-related queries (4 timeline + 4 allocation) */
/** Invalidates all planning-related queries (timeline + allocation, parallel). */
export function useInvalidatePlanningViews() {
const utils = trpc.useUtils();
return () => {
void utils.allocation.list.invalidate();
void (
utils as {
allocation: { listView: { invalidate: () => Promise<unknown> } };
}
).allocation.listView.invalidate();
void utils.allocation.listDemands.invalidate();
void utils.allocation.listAssignments.invalidate();
void utils.timeline.getEntries.invalidate();
void utils.timeline.getEntriesView.invalidate();
void utils.timeline.getMyEntriesView.invalidate();
void utils.timeline.getHolidayOverlays.invalidate();
void utils.timeline.getMyHolidayOverlays.invalidate();
void utils.vacation.list.invalidate();
void utils.timeline.getProjectContext.invalidate();
void utils.timeline.getBudgetStatus.invalidate();
};
return () =>
Promise.all([
utils.allocation.list.invalidate(),
(
utils as {
allocation: { listView: { invalidate: () => Promise<unknown> } };
}
).allocation.listView.invalidate(),
utils.allocation.listDemands.invalidate(),
utils.allocation.listAssignments.invalidate(),
utils.timeline.getEntries.invalidate(),
utils.timeline.getEntriesView.invalidate(),
utils.timeline.getMyEntriesView.invalidate(),
utils.timeline.getHolidayOverlays.invalidate(),
utils.timeline.getMyHolidayOverlays.invalidate(),
utils.vacation.list.invalidate(),
utils.timeline.getProjectContext.invalidate(),
utils.timeline.getBudgetStatus.invalidate(),
]);
}
+91 -41
View File
@@ -1,8 +1,15 @@
"use client";
import { useCallback, useDeferredValue, useEffect, useRef, useState, type MutableRefObject } from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useRef,
useState,
type MutableRefObject,
} from "react";
import { trpc } from "~/lib/trpc/client.js";
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
import { useInvalidatePlanningViews } from "./useInvalidatePlanningViews.js";
import { pixelsToDays } from "~/components/timeline/dragMath.js";
import {
clearLivePreview,
@@ -20,8 +27,14 @@ import { beginAllocationMultiDragSession } from "./timelineAllocationMultiDragSe
import { createAllocationDragState } from "./timelineAllocationDragState.js";
import { beginAllocationDragSession } from "./timelineAllocationDragSession.js";
import { finalizeAllocationReleaseEffects } from "./timelineAllocationReleaseEffects.js";
import { cancelTransientMultiSelectState, cleanupTimelineDragState } from "./timelineDragCleanup.js";
import { resolveAllocationDragPosition, resolveProjectDragPosition } from "./timelineDragPosition.js";
import {
cancelTransientMultiSelectState,
cleanupTimelineDragState,
} from "./timelineDragCleanup.js";
import {
resolveAllocationDragPosition,
resolveProjectDragPosition,
} from "./timelineDragPosition.js";
import { attachDocumentMouseDrag } from "./timelineDocumentDrag.js";
import { finalizeProjectDrag } from "./timelineProjectDragFinalize.js";
import { createProjectDragState } from "./timelineProjectDrag.js";
@@ -38,8 +51,14 @@ import {
} from "./timelineMultiSelect.js";
import { beginCanvasMultiSelectSession } from "./timelineMultiSelectSession.js";
import { reconcileOptimisticEntries } from "./timelineOptimisticAllocations.js";
import { createAllocationPreviewSession, createProjectPreviewSession } from "./timelinePreviewSession.js";
import { resolveRangeSelectionCancel, resolveRangeSelectionRelease } from "./timelineRangeRelease.js";
import {
createAllocationPreviewSession,
createProjectPreviewSession,
} from "./timelinePreviewSession.js";
import {
resolveRangeSelectionCancel,
resolveRangeSelectionRelease,
} from "./timelineRangeRelease.js";
import { createRangeSelectionState, updateRangeSelectionDraft } from "./timelineRangeSelection.js";
import { type TouchCanvasPointerEvent, type TouchMouseDownEvent } from "./timelineTouchAdapters.js";
@@ -284,28 +303,38 @@ export function useTimelineDrag({
onMutationErrorRef.current = onMutationError;
const utils = trpc.useUtils();
const invalidateTimeline = useInvalidateTimeline();
const invalidatePlanningViews = useInvalidatePlanningViews();
const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
clearLivePreview(projectPreviewRef.current);
projectPreviewRef.current = createProjectPreviewSession({
projectId,
currentTarget,
cellWidth: cellWidthRef.current,
});
}, []);
const setProjectPreviewTargets = useCallback(
(projectId: string, currentTarget?: EventTarget | null) => {
clearLivePreview(projectPreviewRef.current);
projectPreviewRef.current = createProjectPreviewSession({
projectId,
currentTarget,
cellWidth: cellWidthRef.current,
});
},
[],
);
const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = createAllocationPreviewSession({
currentTarget,
mode,
cellWidth: cellWidthRef.current,
});
}, []);
const setAllocationPreviewTarget = useCallback(
(currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
clearLivePreview(allocPreviewRef.current);
allocPreviewRef.current = createAllocationPreviewSession({
currentTarget,
mode,
cellWidth: cellWidthRef.current,
});
},
[],
);
const updateLivePreview = useCallback(
(previewRef: MutableRefObject<LivePreviewSession | null>, pointerDeltaX: number, daysDelta: number) => {
(
previewRef: MutableRefObject<LivePreviewSession | null>,
pointerDeltaX: number,
daysDelta: number,
) => {
const preview = previewRef.current;
if (!preview) return;
preview.cellWidth = cellWidthRef.current;
@@ -318,7 +347,11 @@ export function useTimelineDrag({
const updateProjectDragPosition = useCallback(
(clientX: number) => {
const result = resolveProjectDragPosition(dragStateRef.current, clientX, cellWidthRef.current);
const result = resolveProjectDragPosition(
dragStateRef.current,
clientX,
cellWidthRef.current,
);
if (!result.handled) return false;
updateLivePreview(projectPreviewRef, result.pointerDeltaX, result.daysDelta);
@@ -333,7 +366,11 @@ export function useTimelineDrag({
const updateAllocationDragPosition = useCallback(
(clientX: number) => {
const result = resolveAllocationDragPosition(allocDragRef.current, clientX, cellWidthRef.current);
const result = resolveAllocationDragPosition(
allocDragRef.current,
clientX,
cellWidthRef.current,
);
if (!result.handled) return false;
updateLivePreview(allocPreviewRef, result.pointerDeltaX, result.daysDelta);
@@ -415,7 +452,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 } }) => {
invalidateTimeline();
void invalidatePlanningViews();
void utils.project.list.invalidate();
onShiftApplied?.(data.project.id);
},
@@ -442,13 +479,13 @@ export function useTimelineDrag({
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
const [optimisticAllocations, setOptimisticAllocations] = useState<Map<string, OptimisticTimelineOverride>>(
() => new Map(),
);
const [optimisticAllocations, setOptimisticAllocations] = useState<
Map<string, OptimisticTimelineOverride>
>(() => new Map());
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
const snap = pendingSnapshotRef.current;
if (snap) {
onAllocationMovedRef.current?.(snap);
@@ -457,7 +494,8 @@ export function useTimelineDrag({
},
onError: (error) => {
console.error("[timeline] updateAllocationInline failed:", error);
const message = (error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden.";
const message =
(error as { message?: string }).message ?? "Zuweisung konnte nicht verschoben werden.";
onMutationErrorRef.current?.(message);
clearPendingOptimisticAllocation();
},
@@ -465,7 +503,7 @@ export function useTimelineDrag({
const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({
onSuccess: () => {
invalidateTimeline();
void invalidatePlanningViews();
},
});
@@ -486,13 +524,20 @@ export function useTimelineDrag({
pendingOptimisticAllocationIdRef.current = null;
}, []);
const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => {
setOptimisticAllocations((prev) => {
const result = reconcileOptimisticEntries(prev, entries, pendingOptimisticAllocationIdRef.current);
pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId;
return result.changed ? result.optimisticAllocations : prev;
});
}, []);
const reconcileOptimisticAllocations = useCallback(
(entries: readonly OptimisticTimelineEntry[]) => {
setOptimisticAllocations((prev) => {
const result = reconcileOptimisticEntries(
prev,
entries,
pendingOptimisticAllocationIdRef.current,
);
pendingOptimisticAllocationIdRef.current = result.pendingOptimisticAllocationId;
return result.changed ? result.optimisticAllocations : prev;
});
},
[],
);
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
@@ -740,7 +785,12 @@ export function useTimelineDrag({
}
// Range select
const release = resolveRangeSelectionRelease(rangeStateRef.current, e.clientX, e.clientY, INITIAL_RANGE_STATE);
const release = resolveRangeSelectionRelease(
rangeStateRef.current,
e.clientX,
e.clientY,
INITIAL_RANGE_STATE,
);
if (release.kind !== "complete") return;
onRangeSelected?.(release.selection);