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