resource-portal / frontend /src /components /projects /AllocationDragTimeline.tsx
Gowrisankar
Make allocation bar menu dots always visible on colored pills.
11d3514
import { format } from "date-fns";
import { useRef, useState } from "react";
import {
buildAllocationBarSegment,
dateStringsFromDayIndices,
dayIndexFromPointer,
formatAllocationEffort,
projectBarStyle,
roundToHalfHour,
visibleWeekdayCount,
type EffortDisplayMode,
} from "../../planner/allocationTimeline";
import type { Allocation, Person } from "../../types";
import { AllocationBarMenuTrigger } from "./AllocationBarMenu";
import { AllocationCreatePopover, type AllocationDragDraft } from "./AllocationCreatePopover";
interface AllocationDragTimelineProps {
dayDates: Date[];
color: string;
fixedPerson?: Person;
candidatePeople?: Person[];
transferPeople?: Person[];
allocation?: Allocation;
weeklyCapacityHrs?: number;
allocationPct?: number;
onSave: (draft: AllocationDragDraft) => Promise<Allocation | undefined> | Allocation | undefined;
onUpdate?: (draft: AllocationDragDraft) => void;
onDelete?: (allocation: Allocation) => void;
onTransfer?: (personId: number) => void;
onStartClone?: () => void;
cloneSourceId?: number;
onSelectAllToRight?: () => void;
onToggleMultiSelect?: () => void;
multiSelectActive?: boolean;
selected?: boolean;
onToggleSelect?: () => void;
isSaving?: boolean;
effortMode?: EffortDisplayMode;
}
export function AllocationDragTimeline({
dayDates,
color,
fixedPerson,
candidatePeople = [],
allocation,
weeklyCapacityHrs,
allocationPct,
onSave,
onUpdate,
onDelete,
transferPeople = [],
onTransfer,
onStartClone,
cloneSourceId,
onSelectAllToRight,
onToggleMultiSelect,
multiSelectActive = false,
selected = false,
onToggleSelect,
isSaving = false,
effortMode = "hours",
}: AllocationDragTimelineProps) {
const trackRef = useRef<HTMLDivElement>(null);
const barHostRef = useRef<HTMLDivElement>(null);
const dragPointerRef = useRef<{ x: number; y: number } | null>(null);
const [dragRange, setDragRange] = useState<{ start: number; end: number } | null>(null);
const [popover, setPopover] = useState<{
startDate: string;
endDate: string;
anchor: { top: number; left: number };
allocation?: Allocation;
} | null>(null);
const totalDays = dayDates.length;
const existingSegment =
allocation && weeklyCapacityHrs !== undefined && allocationPct !== undefined
? buildAllocationBarSegment(dayDates, allocation.start_date, allocation.end_date)
: null;
const previewSegment =
dragRange && totalDays > 0
? {
startIndex: Math.min(dragRange.start, dragRange.end),
endIndex: Math.max(dragRange.start, dragRange.end),
}
: null;
const openPopoverAt = (startDate: string, endDate: string, clientX: number, clientY: number, edit?: Allocation) => {
setPopover({
startDate,
endDate,
anchor: { top: clientY + 8, left: clientX - 180 },
allocation: edit,
});
};
const openEditPopover = (event?: React.MouseEvent<HTMLButtonElement>) => {
if (!allocation) return;
event?.stopPropagation();
if (multiSelectActive && onToggleSelect) {
onToggleSelect();
return;
}
const rect = event?.currentTarget.getBoundingClientRect() ?? barHostRef.current?.getBoundingClientRect();
if (!rect) return;
openPopoverAt(
allocation.start_date,
allocation.end_date,
rect.left + rect.width / 2,
rect.bottom,
allocation,
);
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!trackRef.current || event.button !== 0) return;
if ((event.target as HTMLElement).closest(".allocation-bar-host, .allocation-bar")) return;
dragPointerRef.current = { x: event.clientX, y: event.clientY };
const index = dayIndexFromPointer(event.clientX, trackRef.current.getBoundingClientRect(), totalDays);
setDragRange({ start: index, end: index });
trackRef.current.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRange || !trackRef.current) return;
const index = dayIndexFromPointer(event.clientX, trackRef.current.getBoundingClientRect(), totalDays);
setDragRange((prev) => (prev ? { ...prev, end: index } : null));
};
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!trackRef.current?.hasPointerCapture(event.pointerId)) return;
trackRef.current.releasePointerCapture(event.pointerId);
const pointerStart = dragPointerRef.current;
dragPointerRef.current = null;
const range = dragRange;
setDragRange(null);
if (!range) return;
const movedPx = pointerStart
? Math.hypot(event.clientX - pointerStart.x, event.clientY - pointerStart.y)
: 0;
const didDrag = range.start !== range.end || movedPx > 6;
if (!didDrag) return;
const { start_date, end_date } = dateStringsFromDayIndices(dayDates, range.start, range.end);
openPopoverAt(start_date, end_date, event.clientX, event.clientY);
};
const pickerPeople = fixedPerson ? [fixedPerson] : candidatePeople;
const editingAllocation = popover?.allocation;
return (
<>
<div
className={`planner-timeline planner-drag-timeline${dragRange ? " is-dragging" : ""}`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
ref={trackRef}
>
<div className="planner-day-grid" />
{dayDates.map((day, index) => (
<div
className={`planner-day-hit${format(day, "yyyy-MM-dd") === format(new Date(), "yyyy-MM-dd") ? " is-today" : ""}`}
key={day.toISOString()}
style={{
left: `${(index / totalDays) * 100}%`,
width: `${(1 / totalDays) * 100}%`,
}}
/>
))}
{previewSegment ? (
<div
className="planner-drag-preview"
style={{
...projectBarStyle(
{
startIndex: previewSegment.startIndex,
endIndex: previewSegment.endIndex,
className: "project-bar allocation-bar preview",
label: "",
},
totalDays,
),
background: color,
borderTopColor: color,
}}
/>
) : null}
{existingSegment && weeklyCapacityHrs !== undefined && allocationPct !== undefined && allocation ? (
(() => {
const weekdayCount = visibleWeekdayCount(
dayDates,
allocation.start_date,
allocation.end_date,
);
const daySpan = existingSegment.endIndex - existingSegment.startIndex + 1;
const widthPct = totalDays > 0 ? (daySpan / totalDays) * 100 : 100;
const isCompact = weekdayCount <= 1 || widthPct <= 18;
return (
<div
className={`allocation-bar-host project-bar${isCompact ? " is-compact" : " is-span"}${selected ? " is-selected" : ""}${cloneSourceId === allocation.id ? " is-clone-source" : ""}`}
ref={barHostRef}
style={{
...projectBarStyle(existingSegment, totalDays),
minWidth: 76,
}}
>
<button
className="allocation-bar-main project-bar allocation-bar"
onClick={openEditPopover}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
style={{
background: color,
borderTopColor: color,
}}
title={formatAllocationEffort(
effortMode,
allocationPct,
weeklyCapacityHrs,
weekdayCount,
)}
type="button"
>
<span className="planner-bar-label">
{formatAllocationEffort(
effortMode,
allocationPct,
weeklyCapacityHrs,
weekdayCount,
)}
</span>
</button>
<AllocationBarMenuTrigger
multiSelectActive={multiSelectActive}
onClone={() => onStartClone?.()}
onDelete={onDelete ? () => onDelete(allocation) : undefined}
onEdit={() => openEditPopover()}
onSelectAllToRight={onSelectAllToRight}
onToggleMultiSelect={onToggleMultiSelect}
onTransferPerson={onTransfer}
transferPeople={transferPeople}
/>
</div>
);
})()
) : null}
</div>
{popover && pickerPeople.length > 0 ? (
<AllocationCreatePopover
key={`${popover.startDate}-${popover.endDate}-${editingAllocation?.id ?? "new"}`}
allocation={editingAllocation}
anchor={popover.anchor}
dayDates={dayDates}
endDate={popover.endDate}
fixedPerson={fixedPerson}
isSaving={isSaving}
onCancel={() => setPopover(null)}
onDelete={
editingAllocation && onDelete
? () => {
onDelete(editingAllocation);
setPopover(null);
}
: undefined
}
onSave={async (draft) => {
const created = await onSave(draft);
if (created) {
setPopover({
startDate: created.start_date,
endDate: created.end_date,
anchor: popover.anchor,
allocation: created,
});
return;
}
setPopover(null);
}}
onUpdate={
editingAllocation && onUpdate
? (draft) => {
onUpdate(draft);
setPopover(null);
}
: undefined
}
people={pickerPeople}
startDate={popover.startDate}
defaultHoursPerDay={
fixedPerson ? roundToHalfHour(fixedPerson.weekly_capacity_hrs / 5) : undefined
}
multiSelectActive={multiSelectActive}
onClone={
editingAllocation && onStartClone
? () => {
onStartClone();
setPopover(null);
}
: undefined
}
onSelectAllToRight={
editingAllocation && onSelectAllToRight
? () => {
onSelectAllToRight();
setPopover(null);
}
: undefined
}
onToggleMultiSelect={
onToggleMultiSelect
? () => {
onToggleMultiSelect();
setPopover(null);
}
: undefined
}
onTransfer={
editingAllocation && onTransfer
? (personId) => {
onTransfer(personId);
setPopover(null);
}
: undefined
}
transferPeople={transferPeople}
/>
) : null}
</>
);
}