fix(web): keep segmented timeline allocations actionable
This commit is contained in:
+177
-32
@@ -307,6 +307,14 @@ async function openAllocationContextMenuAtOffset(
|
||||
await page.mouse.click(box.x + xOffset, box.y + box.height / 2, { button: "right" });
|
||||
}
|
||||
|
||||
async function openContextMenuAtCenter(
|
||||
page: Page,
|
||||
locator: ReturnType<Page["locator"]>,
|
||||
) {
|
||||
const box = await readBoundingBox(locator);
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: "right" });
|
||||
}
|
||||
|
||||
async function releaseMouse(page: Page) {
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(120);
|
||||
@@ -416,15 +424,43 @@ async function findVisibleTimelineEntryId(
|
||||
}, minimumWidth);
|
||||
}
|
||||
|
||||
async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
||||
type VisibleAllocationSegment = {
|
||||
allocationId: string;
|
||||
segmentStart: string | null;
|
||||
segmentEnd: string | null;
|
||||
};
|
||||
|
||||
function allocationSegmentSelector(segment: VisibleAllocationSegment) {
|
||||
const parts = [
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${segment.allocationId}']`,
|
||||
];
|
||||
if (segment.segmentStart) {
|
||||
parts.push(`[data-allocation-segment-start='${segment.segmentStart}']`);
|
||||
}
|
||||
if (segment.segmentEnd) {
|
||||
parts.push(`[data-allocation-segment-end='${segment.segmentEnd}']`);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
async function findVisibleAllocationSegmentForResize(
|
||||
page: Page,
|
||||
selector: string,
|
||||
): Promise<VisibleAllocationSegment | null> {
|
||||
return page.locator(selector).evaluateAll((elements) => {
|
||||
const candidates: Array<{ id: string; score: number }> = [];
|
||||
let fallbackId: string | null = null;
|
||||
const candidates: Array<{
|
||||
allocationId: string;
|
||||
segmentStart: string | null;
|
||||
segmentEnd: string | null;
|
||||
score: number;
|
||||
}> = [];
|
||||
let fallback: { allocationId: string; segmentStart: string | null; segmentEnd: string | null } | null =
|
||||
null;
|
||||
|
||||
for (const element of elements) {
|
||||
if (!(element instanceof HTMLElement)) continue;
|
||||
const id = element.dataset.allocationId;
|
||||
if (!id) continue;
|
||||
const allocationId = element.dataset.allocationId;
|
||||
if (!allocationId) continue;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
const isVisible =
|
||||
@@ -439,7 +475,11 @@ async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fallbackId ??= id;
|
||||
fallback ??= {
|
||||
allocationId,
|
||||
segmentStart: element.dataset.allocationSegmentStart ?? null,
|
||||
segmentEnd: element.dataset.allocationSegmentEnd ?? null,
|
||||
};
|
||||
|
||||
const isPreferred =
|
||||
rect.width >= 36 &&
|
||||
@@ -454,11 +494,16 @@ async function findVisibleAllocationIdForResize(page: Page, selector: string) {
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const widthPenalty = Math.abs(rect.width - 96);
|
||||
const centerPenalty = Math.abs(centerX - window.innerWidth / 2) / 4;
|
||||
candidates.push({ id, score: widthPenalty + centerPenalty });
|
||||
candidates.push({
|
||||
allocationId,
|
||||
segmentStart: element.dataset.allocationSegmentStart ?? null,
|
||||
segmentEnd: element.dataset.allocationSegmentEnd ?? null,
|
||||
score: widthPenalty + centerPenalty,
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
return candidates[0]?.id ?? fallbackId;
|
||||
return candidates[0] ?? fallback;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -782,9 +827,13 @@ test.describe("Timeline", () => {
|
||||
.first();
|
||||
await allocation.click({ button: "right" });
|
||||
|
||||
await expect(page.getByText("Loading...")).not.toBeVisible({ timeout: 2000 });
|
||||
await expect(page.getByText("Hours / day")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
|
||||
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0, { timeout: 2_000 });
|
||||
const popover = page.getByTestId("timeline-allocation-popover");
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
|
||||
await expect(popover.getByText("Hours / day")).toBeVisible();
|
||||
await expect(popover.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("right clicking a project header strip opens the project panel", async ({ page }) => {
|
||||
@@ -852,16 +901,14 @@ test.describe("Timeline", () => {
|
||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const allocation = page.locator(
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
);
|
||||
await allocation.click({ button: "right" });
|
||||
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
|
||||
await openContextMenuAtCenter(page, allocation);
|
||||
|
||||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||||
await expect(cancelButton).toBeVisible();
|
||||
@@ -905,16 +952,14 @@ test.describe("Timeline", () => {
|
||||
await page.mouse.move(8, 8);
|
||||
await expect(hoverCard).not.toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const allocation = page.locator(
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
);
|
||||
await allocation.click({ button: "right" });
|
||||
const allocation = page.locator(allocationSegmentSelector(allocationSegment!));
|
||||
await openContextMenuAtCenter(page, allocation);
|
||||
|
||||
const cancelButton = page.getByRole("button", { name: "Cancel" }).last();
|
||||
await expect(cancelButton).toBeVisible();
|
||||
@@ -956,15 +1001,15 @@ test.describe("Timeline", () => {
|
||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const result = await measureAllocationDragGap(
|
||||
page,
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
allocationSegmentSelector(allocationSegment!),
|
||||
);
|
||||
|
||||
expect(result.movedDistance).toBeGreaterThan(56);
|
||||
@@ -981,15 +1026,15 @@ test.describe("Timeline", () => {
|
||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const result = await measureAllocationResizeGap(
|
||||
page,
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
allocationSegmentSelector(allocationSegment!),
|
||||
);
|
||||
|
||||
expect(result.widthGain).toBeGreaterThan(64);
|
||||
@@ -1006,15 +1051,15 @@ test.describe("Timeline", () => {
|
||||
page.locator("[data-timeline-entry-type='allocation'][data-allocation-id]").first(),
|
||||
).toBeVisible();
|
||||
|
||||
const allocationId = await findVisibleAllocationIdForResize(
|
||||
const allocationSegment = await findVisibleAllocationSegmentForResize(
|
||||
page,
|
||||
"[data-timeline-entry-type='allocation'][data-allocation-id]",
|
||||
);
|
||||
expect(allocationId).toBeTruthy();
|
||||
expect(allocationSegment).toBeTruthy();
|
||||
|
||||
const result = await measureAllocationResizeStartGap(
|
||||
page,
|
||||
`[data-timeline-entry-type='allocation'][data-allocation-id='${allocationId}']`,
|
||||
allocationSegmentSelector(allocationSegment!),
|
||||
);
|
||||
|
||||
expect(result.widthGain).toBeGreaterThan(48);
|
||||
@@ -1447,6 +1492,106 @@ test.describe("Timeline", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("segmented allocations stay actionable after switching to project view and reloading", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const scenario = createTimelineSegmentScenario(suffix);
|
||||
|
||||
try {
|
||||
await page.goto(`/timeline?startDate=2026-04-01&days=30&eids=${scenario.resourceEid}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const resourceRow = page
|
||||
.locator(
|
||||
`[data-testid="timeline-resource-row-canvas"][data-resource-eid="${scenario.resourceEid}"]`,
|
||||
)
|
||||
.first();
|
||||
await expect(resourceRow).toBeVisible();
|
||||
|
||||
const baseSegment = resourceRow.locator(
|
||||
`[data-allocation-id="${scenario.assignmentId}"][data-allocation-segment-start="2026-04-06"][data-allocation-segment-end="2026-04-10"]`,
|
||||
);
|
||||
await expect(baseSegment).toBeVisible();
|
||||
|
||||
const baseSegmentBox = await readBoundingBox(baseSegment);
|
||||
const dayWidth = Math.round((baseSegmentBox.width + 4) / 5);
|
||||
expect(dayWidth).toBeGreaterThan(8);
|
||||
|
||||
await openAllocationContextMenuAtOffset(page, baseSegment, dayWidth * 2.5);
|
||||
const carveDateInputs = page.locator('input[placeholder="dd/mm/yyyy"]');
|
||||
await carveDateInputs.nth(2).fill("08/04/2026");
|
||||
await carveDateInputs.nth(3).fill("08/04/2026");
|
||||
await page.getByRole("button", { name: "Remove Selected Range" }).click();
|
||||
|
||||
await waitForScenarioAssignments(scenario.projectId, [
|
||||
{ startDate: "2026-04-06", endDate: "2026-04-07" },
|
||||
{ startDate: "2026-04-09", endDate: "2026-04-17" },
|
||||
]);
|
||||
|
||||
const mondaySegment = resourceRow.locator(
|
||||
'[data-allocation-segment-start="2026-04-13"][data-allocation-segment-end="2026-04-17"]',
|
||||
).first();
|
||||
await expect(mondaySegment).toBeVisible();
|
||||
|
||||
await page.getByText("Project view").click();
|
||||
await expect(page.getByText(/projects/)).toBeVisible();
|
||||
|
||||
let mondayAssignment: { id: string; startDate: string; endDate: string } | null = null;
|
||||
await expect
|
||||
.poll(() => {
|
||||
mondayAssignment =
|
||||
listScenarioAssignments(scenario.projectId).find(
|
||||
(entry) => entry.startDate === "2026-04-09" && entry.endDate === "2026-04-17",
|
||||
) ?? null;
|
||||
return mondayAssignment?.id ?? null;
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
const projectRow = page.locator(
|
||||
`[data-testid="timeline-project-resource-row-canvas"][data-project-id="${scenario.projectId}"][data-resource-id="${scenario.resourceId}"]`,
|
||||
).first();
|
||||
await expect(projectRow).toBeVisible();
|
||||
|
||||
const projectAllocation = projectRow.locator(
|
||||
`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`,
|
||||
);
|
||||
await expect(projectAllocation).toBeVisible();
|
||||
await openContextMenuAtCenter(page, projectAllocation);
|
||||
|
||||
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0);
|
||||
const popover = page.getByTestId("timeline-allocation-popover");
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
|
||||
await expect(popover.getByText("Hours / day")).toBeVisible();
|
||||
await expect(popover.getByRole("button", { name: "Open Project Panel →" })).toBeVisible();
|
||||
await popover.getByRole("button", { name: "Cancel" }).click();
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.getByText("Project view").click();
|
||||
await expect(page.getByText(scenario.projectName).first()).toBeVisible();
|
||||
|
||||
const projectAllocationAfterReload = page
|
||||
.locator(`[data-timeline-entry-type="allocation"][data-allocation-id="${mondayAssignment!.id}"]`)
|
||||
.first();
|
||||
await expect(projectAllocationAfterReload).toBeVisible();
|
||||
await openContextMenuAtCenter(page, projectAllocationAfterReload);
|
||||
|
||||
await expect(page.getByTestId("timeline-allocation-popover-loading")).toHaveCount(0);
|
||||
const popoverAfterReload = page.getByTestId("timeline-allocation-popover");
|
||||
await expect(popoverAfterReload).toBeVisible();
|
||||
await expect(page.getByTestId("timeline-allocation-popover-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("timeline-allocation-popover-unavailable")).toHaveCount(0);
|
||||
await expect(popoverAfterReload.getByText("Hours / day")).toBeVisible();
|
||||
} finally {
|
||||
cleanupTimelineSegmentScenario(scenario.projectId, scenario.resourceId);
|
||||
}
|
||||
});
|
||||
|
||||
test("project view demand bars expose hover details", async ({ page }) => {
|
||||
await page.getByText("Project view").click();
|
||||
await expect(page.getByText(/projects/)).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AllocationPopover } from "./AllocationPopover.js";
|
||||
|
||||
const mockUseQuery = vi.fn();
|
||||
const mockUseMutation = vi.fn(() => ({ mutate: vi.fn() }));
|
||||
const mockUseUtils = vi.fn(() => ({
|
||||
allocation: {
|
||||
getAssignmentById: { invalidate: vi.fn() },
|
||||
listView: { invalidate: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/lib/trpc/client.js", () => ({
|
||||
trpc: {
|
||||
useUtils: () => mockUseUtils(),
|
||||
allocation: {
|
||||
getAssignmentById: {
|
||||
useQuery: (...args: unknown[]) => mockUseQuery(...args),
|
||||
},
|
||||
},
|
||||
timeline: {
|
||||
updateAllocationInline: {
|
||||
useMutation: () => mockUseMutation(),
|
||||
},
|
||||
carveAllocationRange: {
|
||||
useMutation: () => mockUseMutation(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/hooks/useInvalidatePlanningViews.js", () => ({
|
||||
useInvalidateTimeline: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/hooks/useViewportPopover.js", () => ({
|
||||
useViewportPopover: () => ({
|
||||
ref: { current: null },
|
||||
style: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AllocationPopover", () => {
|
||||
beforeEach(() => {
|
||||
mockUseQuery.mockReset();
|
||||
mockUseMutation.mockClear();
|
||||
mockUseUtils.mockClear();
|
||||
});
|
||||
|
||||
it("renders an error state when the allocation lookup fails", () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error("Assignment not found"),
|
||||
});
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AllocationPopover
|
||||
allocationId="assignment_missing"
|
||||
projectId="project_1"
|
||||
anchorX={120}
|
||||
anchorY={40}
|
||||
onClose={() => {}}
|
||||
onOpenPanel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("data-testid=\"timeline-allocation-popover-error\"");
|
||||
expect(html).toContain("The selected booking could not be loaded right now.");
|
||||
expect(html).toContain("Assignment not found");
|
||||
});
|
||||
|
||||
it("renders an unavailable state when the lookup returns no allocation", () => {
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<AllocationPopover
|
||||
allocationId="assignment_missing"
|
||||
projectId="project_1"
|
||||
anchorX={120}
|
||||
anchorY={40}
|
||||
onClose={() => {}}
|
||||
onOpenPanel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("data-testid=\"timeline-allocation-popover-unavailable\"");
|
||||
expect(html).toContain("The selected booking could not be resolved from the current timeline data.");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { AllocationLike, AllocationReadModel, Assignment } from "@capakraken/shared";
|
||||
import type { AllocationLike, Assignment } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { useViewportPopover } from "~/hooks/useViewportPopover.js";
|
||||
@@ -43,16 +44,19 @@ export function AllocationPopover({
|
||||
onClose,
|
||||
});
|
||||
|
||||
const { data: allocationView, isLoading } = trpc.allocation.listView.useQuery(
|
||||
{ projectId },
|
||||
{ staleTime: 10_000, enabled: !initialAllocation },
|
||||
) as { data: AllocationReadModel<AllocationLike> | undefined; isLoading: boolean };
|
||||
const allocation = initialAllocation ?? allocationView?.assignments.find((entry) => (
|
||||
entry.id === allocationId
|
||||
|| entry.entityId === allocationId
|
||||
|| entry.sourceAllocationId === allocationId
|
||||
|| getPlanningEntryMutationId(entry) === allocationId
|
||||
)) as AllocationPopoverAssignment | undefined;
|
||||
const shouldLoadAllocation = !initialAllocation;
|
||||
const allocationQuery = trpc.allocation.getAssignmentById.useQuery(
|
||||
{ id: allocationId },
|
||||
{
|
||||
staleTime: 10_000,
|
||||
enabled: shouldLoadAllocation,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
const fetchedAllocation = allocationQuery.data as AllocationPopoverAssignment | undefined;
|
||||
const allocation = initialAllocation ?? fetchedAllocation;
|
||||
const isLoading = shouldLoadAllocation && allocationQuery.isLoading;
|
||||
const allocationError = shouldLoadAllocation ? allocationQuery.error : null;
|
||||
|
||||
const [hoursPerDay, setHoursPerDay] = useState<number | null>(null);
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
@@ -79,6 +83,7 @@ export function AllocationPopover({
|
||||
const updateMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidateTimeline();
|
||||
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
|
||||
void utils.allocation.listView.invalidate();
|
||||
onClose();
|
||||
},
|
||||
@@ -87,6 +92,7 @@ export function AllocationPopover({
|
||||
const carveMutation = trpc.timeline.carveAllocationRange.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidateTimeline();
|
||||
void utils.allocation.getAssignmentById.invalidate({ id: allocationId });
|
||||
void utils.allocation.listView.invalidate();
|
||||
onClose();
|
||||
},
|
||||
@@ -122,18 +128,48 @@ export function AllocationPopover({
|
||||
|
||||
if (isLoading) {
|
||||
const loadingPopover = (
|
||||
<div ref={ref} style={style} className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500">
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-testid="timeline-allocation-popover-loading"
|
||||
className="bg-white border border-gray-200 rounded-xl shadow-xl p-4 text-sm text-gray-500"
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
return typeof document === "undefined" ? loadingPopover : createPortal(loadingPopover, document.body);
|
||||
}
|
||||
|
||||
if (allocationError) {
|
||||
const errorPopover = (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-testid="timeline-allocation-popover-error"
|
||||
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-red-600">{allocationError.message}</p>
|
||||
<button
|
||||
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);
|
||||
}
|
||||
|
||||
if (!allocation) {
|
||||
const missingPopover = (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-testid="timeline-allocation-popover-unavailable"
|
||||
className="flex max-w-[300px] flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-xl"
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-800">Allocation unavailable</div>
|
||||
@@ -160,6 +196,8 @@ export function AllocationPopover({
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-testid="timeline-allocation-popover"
|
||||
data-allocation-id={allocationId}
|
||||
className="flex max-h-[calc(100vh-32px)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl"
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
@@ -1146,7 +1146,10 @@ function renderProjectDragHandles(
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
{
|
||||
allocationId: getPlanningEntryMutationId(alloc),
|
||||
projectId: alloc.projectId,
|
||||
},
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
|
||||
@@ -812,7 +812,7 @@ function renderAllocBlocksFromData(
|
||||
if (suppressHoverInteractions) return;
|
||||
onAllocationContextMenu(
|
||||
{
|
||||
allocationId: alloc.id,
|
||||
allocationId: getPlanningEntryMutationId(alloc),
|
||||
projectId: alloc.projectId,
|
||||
contextDate: resolveSegmentContextDate(
|
||||
e.clientX,
|
||||
@@ -1151,7 +1151,11 @@ function renderDailyBars(
|
||||
e.stopPropagation();
|
||||
if (suppressHoverInteractions) return;
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId, contextDate: new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) },
|
||||
{
|
||||
allocationId: getPlanningEntryMutationId(alloc),
|
||||
projectId: alloc.projectId,
|
||||
contextDate: new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())),
|
||||
},
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user