Spaces:
Running
Running
| 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} | |
| </> | |
| ); | |
| } | |