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