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:
@@ -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">×</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-lg leading-none"
|
||||
>
|
||||
×
|
||||
</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 →
|
||||
|
||||
Reference in New Issue
Block a user