feat: timeline multi-select, demand popover, resource hover card, merged tooltips, dark mode fixes
Major timeline enhancements: - Right-click drag multi-selection with floating action bar (batch delete/assign) - DemandPopover for demand strip details (replaces broken "Loading" modal) - ResourceHoverCard on name hover showing skills, rates, role, chapter - Merged heatmap+vacation tooltips into unified TimelineTooltip component - Fixed overbooking blink animation (date normalization, z-index ordering) - Fixed dark mode sticky column bleed-through in project view - System roles admin page, notification task management, performance review docs Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
import { heatmapColor } from "./heatmapUtils.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||
import { formatDateLong } from "~/lib/format.js";
|
||||
import { TimelineTooltip } from "./TimelineTooltip.js";
|
||||
import {
|
||||
ROW_HEIGHT,
|
||||
SUB_LANE_HEIGHT,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
PROJECT_HEADER_HEIGHT,
|
||||
ORDER_TYPE_COLORS,
|
||||
} from "./timelineConstants.js";
|
||||
import type { DragState, AllocDragState, RangeState } from "~/hooks/useTimelineDrag.js";
|
||||
import type { DragState, AllocDragState, RangeState, MultiSelectState } from "~/hooks/useTimelineDrag.js";
|
||||
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
@@ -42,6 +43,7 @@ interface TimelineProjectPanelProps {
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void;
|
||||
multiSelectState: MultiSelectState;
|
||||
// Layout from useTimelineLayout
|
||||
CELL_WIDTH: number;
|
||||
dates: Date[];
|
||||
@@ -185,6 +187,7 @@ export function TimelineProjectPanel({
|
||||
onOpenPanel,
|
||||
onOpenDemandClick,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
CELL_WIDTH,
|
||||
dates,
|
||||
totalCanvasWidth,
|
||||
@@ -201,6 +204,7 @@ export function TimelineProjectPanel({
|
||||
filters,
|
||||
displayMode,
|
||||
heatmapScheme,
|
||||
blinkOverbookedDays,
|
||||
activeFilterCount,
|
||||
today,
|
||||
} = useTimelineContext();
|
||||
@@ -411,7 +415,7 @@ export function TimelineProjectPanel({
|
||||
const laneCount = assignDemandLanes(row.openDemands).size > 0
|
||||
? Math.max(...assignDemandLanes(row.openDemands).values()) + 1
|
||||
: 1;
|
||||
return Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
|
||||
return Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
}
|
||||
return ROW_HEIGHT;
|
||||
},
|
||||
@@ -602,7 +606,7 @@ export function TimelineProjectPanel({
|
||||
const colors = ORDER_TYPE_COLORS[project.orderType] ?? {
|
||||
bg: "bg-gray-400",
|
||||
text: "text-white",
|
||||
light: "bg-gray-50 border-gray-200",
|
||||
light: "bg-gray-50 border-gray-200 dark:bg-gray-800 dark:border-gray-700",
|
||||
};
|
||||
const isThisProjectShifting =
|
||||
dragState.isDragging && dragState.projectId === project.id;
|
||||
@@ -620,12 +624,12 @@ export function TimelineProjectPanel({
|
||||
return (
|
||||
<div
|
||||
data-project-group="true"
|
||||
className={clsx("flex border-b border-gray-200 group/proj", colors.light)}
|
||||
className={clsx("flex border-b border-gray-200 dark:border-gray-700 group/proj", colors.light)}
|
||||
style={{ height: PROJECT_HEADER_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex-shrink-0 border-r border-gray-300 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
|
||||
"flex-shrink-0 border-r border-gray-300 dark:border-gray-600 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
|
||||
colors.light,
|
||||
)}
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
@@ -694,31 +698,39 @@ export function TimelineProjectPanel({
|
||||
) : row.type === "open-demand" ? (
|
||||
renderOpenDemandRow(
|
||||
row.openDemands,
|
||||
row.projectId,
|
||||
CELL_WIDTH,
|
||||
totalCanvasWidth,
|
||||
toLeft,
|
||||
toWidth,
|
||||
resourceRowGridStyle,
|
||||
onOpenDemandClick,
|
||||
onAllocMouseDown,
|
||||
onAllocTouchStart,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
allocDragState,
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
data-project-resource-row="true"
|
||||
className="flex border-b border-gray-100 hover:bg-blue-50/20 group"
|
||||
data-project-id={row.project.id}
|
||||
data-resource-id={row.resource.id}
|
||||
className="flex border-b border-gray-100 dark:border-gray-800 hover:bg-blue-50/20 dark:hover:bg-gray-800/30 group"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-gray-200 flex items-center pl-8 pr-4 gap-2 bg-white sticky left-0 z-30 group-hover:bg-blue-50"
|
||||
className="flex-shrink-0 border-r border-gray-200 dark:border-gray-700 flex items-center pl-8 pr-4 gap-2 bg-white dark:bg-gray-900 sticky left-0 z-30 group-hover:bg-blue-50 dark:group-hover:bg-gray-800"
|
||||
style={{ width: LABEL_WIDTH }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-600 flex-shrink-0">
|
||||
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-[10px] font-bold text-gray-600 dark:text-gray-300 flex-shrink-0">
|
||||
{row.resource.displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-gray-800 truncate">
|
||||
<div className="min-w-0" data-resource-hover-id={row.resource.id}>
|
||||
<div className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate cursor-pointer hover:text-brand-600 dark:hover:text-brand-400 transition-colors">
|
||||
{row.resource.displayName}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-400 truncate">{row.resource.eid}</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-gray-500 truncate">{row.resource.eid}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -771,6 +783,7 @@ export function TimelineProjectPanel({
|
||||
onAllocMouseDown,
|
||||
onAllocTouchStart,
|
||||
onAllocationContextMenu,
|
||||
multiSelectState,
|
||||
)}
|
||||
{renderVacationBlocksForProjectRow(
|
||||
vacationsByResource.get(row.resource.id) ?? [],
|
||||
@@ -781,6 +794,12 @@ export function TimelineProjectPanel({
|
||||
totalCanvasWidth,
|
||||
filters.showVacations,
|
||||
)}
|
||||
{blinkOverbookedDays &&
|
||||
renderOverbookingBlinkProject(
|
||||
allocsByResource.get(row.resource.id) ?? [],
|
||||
dates,
|
||||
CELL_WIDTH,
|
||||
)}
|
||||
{renderRangeOverlayProject(
|
||||
rangeState,
|
||||
row.resource.id,
|
||||
@@ -796,7 +815,7 @@ export function TimelineProjectPanel({
|
||||
);
|
||||
})}
|
||||
|
||||
<ProjectPanelTooltips
|
||||
<TimelineTooltip
|
||||
heatmapTooltipRef={heatmapTooltipRef}
|
||||
heatmapTooltipPos={heatmapTooltipPosRef.current}
|
||||
vacationTooltipRef={vacationTooltipRef}
|
||||
@@ -808,111 +827,7 @@ export function TimelineProjectPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectPanelTooltips({
|
||||
heatmapTooltipRef,
|
||||
heatmapTooltipPos,
|
||||
vacationTooltipRef,
|
||||
vacationTooltipPos,
|
||||
heatmapHover,
|
||||
vacationHover,
|
||||
}: {
|
||||
heatmapTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
heatmapTooltipPos: { left: number; top: number };
|
||||
vacationTooltipRef: React.RefObject<HTMLDivElement | null>;
|
||||
vacationTooltipPos: { left: number; top: number };
|
||||
heatmapHover: {
|
||||
date: Date;
|
||||
totalH: number;
|
||||
pct: number;
|
||||
breakdown: {
|
||||
projectId: string;
|
||||
shortCode: string;
|
||||
projectName: string;
|
||||
orderType: string;
|
||||
hoursPerDay: number;
|
||||
responsiblePerson?: string | null;
|
||||
}[];
|
||||
} | null;
|
||||
vacationHover: {
|
||||
type: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
note?: string | null;
|
||||
requestedBy?: { name?: string | null; email: string } | null;
|
||||
approvedBy?: { name?: string | null; email: string } | null;
|
||||
approvedAt?: Date | string | null;
|
||||
} | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{heatmapHover ? (
|
||||
<div
|
||||
ref={heatmapTooltipRef}
|
||||
style={{
|
||||
left: heatmapTooltipPos.left,
|
||||
top: heatmapTooltipPos.top,
|
||||
backgroundColor: "rgba(3, 7, 18, 0.96)",
|
||||
}}
|
||||
className="fixed z-40 max-w-sm pointer-events-none rounded-xl border border-gray-800 bg-gray-950/96 px-3 py-2 text-xs text-white shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">{formatDateLong(heatmapHover.date)}</span>
|
||||
<span className="text-[11px] text-gray-300">
|
||||
{heatmapHover.totalH.toFixed(1)}h · {Math.round(heatmapHover.pct)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{heatmapHover.breakdown.length > 0 ? (
|
||||
heatmapHover.breakdown.slice(0, 6).map((entry) => (
|
||||
<div
|
||||
key={`${entry.projectId}-${entry.shortCode}`}
|
||||
className="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-white">
|
||||
{entry.shortCode ? `${entry.shortCode} · ` : ""}
|
||||
{entry.projectName}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-gray-400">
|
||||
{entry.responsiblePerson
|
||||
? `Lead: ${entry.responsiblePerson}`
|
||||
: entry.orderType}
|
||||
</div>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-[11px] font-semibold text-gray-200">
|
||||
{entry.hoursPerDay}h
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-[11px] text-gray-400">No bookings on this day.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{vacationHover ? (
|
||||
<div
|
||||
ref={vacationTooltipRef}
|
||||
style={{
|
||||
left: vacationTooltipPos.left,
|
||||
top: vacationTooltipPos.top,
|
||||
backgroundColor: "rgba(120, 53, 15, 0.95)",
|
||||
}}
|
||||
className="fixed z-40 max-w-xs pointer-events-none rounded-xl border border-amber-700/50 bg-amber-950/95 px-3 py-2 text-xs text-amber-50 shadow-2xl"
|
||||
>
|
||||
<div className="font-semibold">{vacationHover.type.replaceAll("_", " ")}</div>
|
||||
<div className="mt-1 text-[11px] text-amber-100/90">
|
||||
{formatDateLong(vacationHover.startDate)} to {formatDateLong(vacationHover.endDate)}
|
||||
</div>
|
||||
{vacationHover.note ? (
|
||||
<div className="mt-2 text-[11px] text-amber-100/80">{vacationHover.note}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
// ProjectPanelTooltips removed — now uses shared TimelineTooltip component
|
||||
|
||||
// ─── Pure render functions ──────────────────────────────────────────────────
|
||||
|
||||
@@ -949,55 +864,97 @@ function assignDemandLanes(
|
||||
return laneMap;
|
||||
}
|
||||
|
||||
const DEMAND_LANE_HEIGHT = 30;
|
||||
const DEMAND_LANE_GAP = 2;
|
||||
|
||||
function renderOpenDemandRow(
|
||||
openDemands: TimelineDemandEntry[],
|
||||
projectId: string,
|
||||
CELL_WIDTH: number,
|
||||
totalCanvasWidth: number,
|
||||
toLeft: (d: Date) => number,
|
||||
toWidth: (s: Date, e: Date) => number,
|
||||
rowGridStyle: CSSProperties,
|
||||
onOpenDemandClick: (demand: OpenDemandAssignment) => void,
|
||||
_onOpenDemandClick: (demand: OpenDemandAssignment) => void,
|
||||
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||
onAllocationContextMenu: (
|
||||
info: { allocationId: string; projectId: string },
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
allocDragState: AllocDragState,
|
||||
) {
|
||||
if (openDemands.length === 0) return null;
|
||||
|
||||
const laneMap = assignDemandLanes(openDemands);
|
||||
const laneCount = laneMap.size > 0 ? Math.max(...laneMap.values()) + 1 : 1;
|
||||
const rowHeight = Math.max(ROW_HEIGHT, laneCount * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP) + 8);
|
||||
const rowHeight = Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
|
||||
style={{ minHeight: rowHeight }}
|
||||
data-project-demand-row="true"
|
||||
data-project-id={projectId}
|
||||
className="group relative isolate flex border-b border-dashed border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-slate-950 hover:bg-amber-100/80 dark:hover:bg-slate-900"
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30"
|
||||
style={{ width: LABEL_WIDTH, minHeight: rowHeight }}
|
||||
className="sticky left-0 z-30 flex flex-shrink-0 items-center gap-2 border-r border-amber-200 bg-amber-50 pl-8 pr-4 dark:border-amber-800 dark:bg-slate-950"
|
||||
style={{ width: LABEL_WIDTH, height: rowHeight }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400">
|
||||
?
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-amber-700 truncate">Open demand</div>
|
||||
<div className="text-[10px] text-amber-500 truncate">
|
||||
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
|
||||
<div className="pointer-events-none absolute inset-0 bg-amber-50 dark:bg-slate-950" />
|
||||
<div className="relative z-10 flex items-center gap-2 min-w-0">
|
||||
<div className="w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center text-[10px] font-bold text-amber-600 dark:text-amber-400 flex-shrink-0 border border-dashed border-amber-400 dark:border-amber-600">
|
||||
?
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-amber-700 dark:text-amber-400 truncate">Open demand</div>
|
||||
<div className="text-[10px] text-amber-500 dark:text-amber-600 truncate">
|
||||
{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative overflow-hidden"
|
||||
style={{ width: totalCanvasWidth, minHeight: rowHeight, ...rowGridStyle }}
|
||||
className="relative overflow-hidden bg-amber-50 touch-none dark:bg-slate-950"
|
||||
style={{ width: totalCanvasWidth, height: rowHeight, ...rowGridStyle }}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-x-0 inset-y-0 border-y border-dashed border-amber-200/70 dark:border-amber-800/80" />
|
||||
<div className="pointer-events-none absolute inset-x-0 inset-y-1 rounded-md bg-amber-100/25 dark:bg-amber-950/35" />
|
||||
{openDemands.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
const allocEnd = new Date(alloc.endDate);
|
||||
const left = toLeft(allocStart);
|
||||
const width = Math.max(CELL_WIDTH, toWidth(allocStart, allocEnd));
|
||||
|
||||
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
||||
const dispStart =
|
||||
isAllocDragged && allocDragState.currentStartDate
|
||||
? allocDragState.currentStartDate
|
||||
: allocStart;
|
||||
const dispEnd =
|
||||
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
|
||||
|
||||
// Multi-drag visual offset
|
||||
const isMultiDragTarget =
|
||||
multiSelectState.isMultiDragging &&
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id);
|
||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
||||
const multiDragMode = multiSelectState.multiDragMode;
|
||||
|
||||
let left = toLeft(dispStart);
|
||||
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
// Clamp negative left (bar starts before view) to avoid extending outside canvas
|
||||
if (left < 0) {
|
||||
width += left;
|
||||
left = 0;
|
||||
}
|
||||
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||
|
||||
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
||||
left += multiDragPx;
|
||||
width = Math.max(CELL_WIDTH, width - multiDragPx);
|
||||
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
||||
width = Math.max(CELL_WIDTH, width + multiDragPx);
|
||||
}
|
||||
|
||||
const roleEntity = (
|
||||
alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }
|
||||
).roleEntity;
|
||||
@@ -1006,39 +963,99 @@ function renderOpenDemandRow(
|
||||
const roleColor = roleEntity?.color ?? "#f59e0b";
|
||||
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
|
||||
const lane = laneMap.get(alloc.id) ?? 0;
|
||||
const top = 4 + lane * (DEMAND_LANE_HEIGHT + DEMAND_LANE_GAP);
|
||||
const top = 8 + lane * SUB_LANE_HEIGHT;
|
||||
const blockHeight = SUB_LANE_HEIGHT - 8;
|
||||
|
||||
const HANDLE_W = width >= 48 ? 8 : 6;
|
||||
|
||||
const allocInfo: AllocMouseDownInfo = {
|
||||
mode: "move",
|
||||
allocationId: alloc.id,
|
||||
mutationAllocationId: getPlanningEntryMutationId(alloc),
|
||||
projectId: alloc.projectId,
|
||||
projectName: alloc.project.name,
|
||||
resourceId: null,
|
||||
startDate: allocStart,
|
||||
endDate: allocEnd,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alloc.id}
|
||||
className="absolute rounded-md flex items-center px-2 gap-1 overflow-hidden cursor-pointer hover:ring-2 hover:ring-amber-400 hover:ring-offset-1 z-[10]"
|
||||
className={clsx(
|
||||
"absolute rounded-md flex items-stretch overflow-hidden z-[10] group/demand",
|
||||
isAllocDragged
|
||||
? "ring-2 ring-amber-500 z-20"
|
||||
: "hover:ring-2 hover:ring-amber-400 hover:ring-offset-1",
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||
)}
|
||||
title={`${roleName}${headcount > 1 ? ` x${headcount}` : ""} · ${alloc.hoursPerDay}h/day · ${formatDateLong(allocStart)} – ${formatDateLong(allocEnd)}`}
|
||||
style={{
|
||||
left: left + 2,
|
||||
width: width - 4,
|
||||
top,
|
||||
height: DEMAND_LANE_HEIGHT,
|
||||
backgroundColor: `${roleColor}33`,
|
||||
border: `2px dashed ${roleColor}99`,
|
||||
height: blockHeight,
|
||||
backgroundColor: `${roleColor}4D`,
|
||||
border: `2px dashed ${roleColor}B3`,
|
||||
...(multiDragPx && multiDragMode === "move"
|
||||
? { transform: `translateX(${multiDragPx}px)` }
|
||||
: {}),
|
||||
}}
|
||||
onClick={() => {
|
||||
onOpenDemandClick({
|
||||
id: getPlanningEntryMutationId(alloc),
|
||||
projectId: alloc.projectId,
|
||||
roleId: (alloc as { roleId?: string | null }).roleId ?? null,
|
||||
role: (alloc as { role?: string | null }).role ?? null,
|
||||
headcount,
|
||||
startDate: allocStart,
|
||||
endDate: allocEnd,
|
||||
hoursPerDay: alloc.hoursPerDay,
|
||||
roleEntity: roleEntity ?? null,
|
||||
project: alloc.project as { id: string; name: string; shortCode: string },
|
||||
});
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onAllocationContextMenu(
|
||||
{ allocationId: alloc.id, projectId: alloc.projectId },
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
||||
{roleName}
|
||||
{headcount > 1 ? ` x${headcount}` : ""}
|
||||
</span>
|
||||
{/* Left resize handle */}
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Center — move + click */}
|
||||
<div
|
||||
className={clsx(
|
||||
"flex-1 min-w-0 flex items-center px-1 gap-1",
|
||||
isAllocDragged ? "cursor-grabbing" : "cursor-grab",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocMouseDown(e, allocInfo);
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, allocInfo);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
||||
{roleName}
|
||||
{headcount > 1 ? ` x${headcount}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right resize handle */}
|
||||
<div
|
||||
className="flex-shrink-0 cursor-ew-resize hover:bg-black/10"
|
||||
style={{ width: HANDLE_W }}
|
||||
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||
onTouchStart={(e) => {
|
||||
e.stopPropagation();
|
||||
onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1157,6 +1174,7 @@ function renderProjectDragHandles(
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
) => void,
|
||||
multiSelectState: MultiSelectState,
|
||||
) {
|
||||
return allocs.map((alloc) => {
|
||||
const allocStart = new Date(alloc.startDate);
|
||||
@@ -1170,10 +1188,24 @@ function renderProjectDragHandles(
|
||||
const dispEnd =
|
||||
isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
|
||||
|
||||
const left = toLeft(dispStart);
|
||||
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
let left = toLeft(dispStart);
|
||||
let width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||
|
||||
// Multi-drag visual offset
|
||||
const isMultiDragTarget =
|
||||
multiSelectState.isMultiDragging &&
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id);
|
||||
const multiDragPx = isMultiDragTarget ? multiSelectState.multiDragDaysDelta * CELL_WIDTH : 0;
|
||||
const multiDragMode = multiSelectState.multiDragMode;
|
||||
|
||||
if (isMultiDragTarget && multiDragMode === "resize-start") {
|
||||
left += multiDragPx;
|
||||
width = Math.max(CELL_WIDTH, width - multiDragPx);
|
||||
} else if (isMultiDragTarget && multiDragMode === "resize-end") {
|
||||
width = Math.max(CELL_WIDTH, width + multiDragPx);
|
||||
}
|
||||
|
||||
// Always show resize handles — for narrow bars, use overlapping handles
|
||||
const HANDLE_W = width >= 48 ? 8 : 6;
|
||||
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
||||
@@ -1198,8 +1230,20 @@ function renderProjectDragHandles(
|
||||
isAllocDragged
|
||||
? "ring-2 ring-brand-400 z-20"
|
||||
: "hover:ring-1 hover:ring-brand-300/70 z-[15]",
|
||||
multiSelectState.selectedAllocationIds.includes(alloc.id) && "ring-2 ring-sky-500 ring-offset-1 z-20",
|
||||
)}
|
||||
style={{ left: left + 2, width: width - 4, top: 2, bottom: 2 }}
|
||||
style={{
|
||||
left: left + 2,
|
||||
width: width - 4,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
...(multiDragPx && multiDragMode === "move"
|
||||
? { transform: `translateX(${multiDragPx}px)` }
|
||||
: {}),
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2) e.stopPropagation();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1315,6 +1359,40 @@ function renderVacationBlocksForProjectRow(
|
||||
|
||||
// ─── Range overlay for project view ─────────────────────────────────────────
|
||||
|
||||
function renderOverbookingBlinkProject(
|
||||
allocs: TimelineAssignmentEntry[],
|
||||
dates: Date[],
|
||||
CELL_WIDTH: number,
|
||||
) {
|
||||
const REF_H = 8;
|
||||
const overbooked: number[] = [];
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const d = new Date(dates[i]!);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const t = d.getTime();
|
||||
let totalH = 0;
|
||||
for (const a of allocs) {
|
||||
const s = new Date(a.startDate);
|
||||
s.setHours(0, 0, 0, 0);
|
||||
const e = new Date(a.endDate);
|
||||
e.setHours(0, 0, 0, 0);
|
||||
if (t >= s.getTime() && t <= e.getTime()) totalH += a.hoursPerDay;
|
||||
}
|
||||
if (totalH > REF_H) overbooked.push(i);
|
||||
}
|
||||
|
||||
if (overbooked.length === 0) return null;
|
||||
|
||||
return overbooked.map((i) => (
|
||||
<div
|
||||
key={`ob-${i}`}
|
||||
className="absolute top-0 bottom-0 pointer-events-none z-[15] animate-overbooking-blink"
|
||||
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH }}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
function renderRangeOverlayProject(
|
||||
rangeState: RangeState,
|
||||
resourceId: string,
|
||||
|
||||
Reference in New Issue
Block a user