import { addWeeks, format, parseISO } from "date-fns"; import { Calendar, ChevronRight, HelpCircle, MoreHorizontal } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { allocationPctFromEffort, effortValueFromPct, endDateFromWeekdayCount, formatHoursClock, parseHoursClock, totalEffortDays, totalEffortHours, type AllocationRepeatConfig, type EffortUnit, type TotalEffortUnit, } from "../../planner/allocationRepeat"; import { hoursPerDayFromAllocation, roundToHalfHour, visibleWeekdayCount, } from "../../planner/allocationTimeline"; import type { Allocation, Person } from "../../types"; export interface AllocationDragDraft { person_id: number; start_date: string; end_date: string; allocation_pct: number; hours_per_day: number; work_days: number; note?: string; repeat?: AllocationRepeatConfig; } interface AllocationCreatePopoverProps { anchor: { top: number; left: number }; dayDates: Date[]; startDate: string; endDate: string; people: Person[]; fixedPerson?: Person; defaultHoursPerDay?: number; allocation?: Allocation; onCancel: () => void; onSave: (draft: AllocationDragDraft) => void; onUpdate?: (draft: AllocationDragDraft) => void; onDelete?: () => void; transferPeople?: Person[]; onTransfer?: (personId: number) => void; onClone?: () => void; onSelectAllToRight?: () => void; onToggleMultiSelect?: () => void; multiSelectActive?: boolean; isSaving?: boolean; } const EFFORT_UNITS: Array<{ value: EffortUnit; label: string }> = [ { value: "hours_per_day", label: "h/d" }, { value: "hours_per_week", label: "h/wk" }, { value: "capacity_pct", label: "%" }, { value: "fte", label: "FTE" }, ]; export function AllocationCreatePopover({ anchor, dayDates, startDate, endDate, people, fixedPerson, defaultHoursPerDay, allocation, onCancel, onSave, onUpdate, onDelete, transferPeople = [], onTransfer, onClone, onSelectAllToRight, onToggleMultiSelect, multiSelectActive = false, isSaving = false, }: AllocationCreatePopoverProps) { const popoverRef = useRef(null); const menuRef = useRef(null); const isEdit = Boolean(allocation); const dragWorkDays = visibleWeekdayCount(dayDates, startDate, endDate); const [personId, setPersonId] = useState(fixedPerson?.id ?? people[0]?.id ?? ""); const [rangeStart, setRangeStart] = useState(startDate); const [rangeEnd, setRangeEnd] = useState(endDate); const [workDays, setWorkDays] = useState(Math.max(1, dragWorkDays)); const [effortUnit, setEffortUnit] = useState("hours_per_day"); const [effortInput, setEffortInput] = useState("8:00"); const [totalUnit, setTotalUnit] = useState("hours"); const [repeatEnabled, setRepeatEnabled] = useState(false); const [repeatEndMode, setRepeatEndMode] = useState<"on" | "after">("on"); const [repeatEndOn, setRepeatEndOn] = useState(format(addWeeks(parseISO(startDate), 4), "yyyy-MM-dd")); const [repeatOccurrences, setRepeatOccurrences] = useState(4); const [noteOpen, setNoteOpen] = useState(false); const [note, setNote] = useState(""); const [datesOpen, setDatesOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false); const selectedPerson = fixedPerson ?? people.find((person) => person.id === Number(personId)); const capacityDaily = selectedPerson ? roundToHalfHour(selectedPerson.weekly_capacity_hrs / 5) : 8; useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onCancel(); }; const onDown = (event: MouseEvent) => { const target = event.target as Node; if (popoverRef.current?.contains(target)) return; if (menuRef.current?.contains(target)) return; onCancel(); }; window.addEventListener("keydown", onKey); document.addEventListener("mousedown", onDown); return () => { window.removeEventListener("keydown", onKey); document.removeEventListener("mousedown", onDown); }; }, [onCancel]); useEffect(() => { setPersonId(fixedPerson?.id ?? people[0]?.id ?? ""); setDatesOpen(false); setMenuOpen(false); setTransferOpen(false); const editPerson = allocation ? (fixedPerson ?? people.find((person) => person.id === allocation.person_id)) : undefined; if (allocation && editPerson) { const savedWorkDays = visibleWeekdayCount( dayDates, allocation.start_date, allocation.end_date, ); const savedHoursPerDay = hoursPerDayFromAllocation( allocation.allocation_pct, editPerson.weekly_capacity_hrs, ); setRangeStart(allocation.start_date); setRangeEnd(allocation.end_date); setWorkDays(Math.max(1, savedWorkDays)); setRepeatEnabled(false); setNote(allocation.note ?? ""); setNoteOpen(Boolean(allocation.note)); setEffortUnit("hours_per_day"); setEffortInput(formatHoursClock(savedHoursPerDay)); return; } setRangeStart(startDate); setRangeEnd(endDate); setWorkDays(Math.max(1, dragWorkDays)); setRepeatEndOn(format(addWeeks(parseISO(startDate), 4), "yyyy-MM-dd")); setRepeatOccurrences(4); setRepeatEnabled(false); setNote(""); setNoteOpen(false); setEffortUnit("hours_per_day"); setEffortInput(formatHoursClock(defaultHoursPerDay ?? capacityDaily)); }, [ allocation, fixedPerson, people, defaultHoursPerDay, capacityDaily, startDate, endDate, dragWorkDays, dayDates, ]); useEffect(() => { setRangeEnd(endDateFromWeekdayCount(rangeStart, workDays)); }, [rangeStart, workDays]); const effortValue = useMemo(() => { if (effortUnit === "capacity_pct" || effortUnit === "fte") { return Number(effortInput) || 0; } return parseHoursClock(effortInput); }, [effortInput, effortUnit]); const allocationPct = selectedPerson ? allocationPctFromEffort(effortUnit, effortValue, selectedPerson.weekly_capacity_hrs) : 50; const hoursPerDay = selectedPerson ? roundToHalfHour((allocationPct / 100) * (selectedPerson.weekly_capacity_hrs / 5)) : roundToHalfHour(effortValue); const totalHours = totalEffortHours(hoursPerDay, workDays); const totalDays = selectedPerson ? totalEffortDays(hoursPerDay, workDays, selectedPerson.weekly_capacity_hrs) : workDays; const handleEffortUnitChange = (unit: EffortUnit) => { if (!selectedPerson) { setEffortUnit(unit); return; } const nextValue = effortValueFromPct(unit, allocationPct, selectedPerson.weekly_capacity_hrs); setEffortUnit(unit); if (unit === "capacity_pct") setEffortInput(String(Math.round(nextValue))); else if (unit === "fte") setEffortInput(String(roundToHalfHour(nextValue * 10) / 10)); else setEffortInput(formatHoursClock(nextValue)); }; const handlePersonChange = (nextPersonId: number) => { setPersonId(nextPersonId); const person = people.find((item) => item.id === nextPersonId); if (!person) return; setEffortUnit("hours_per_day"); setEffortInput(formatHoursClock(roundToHalfHour(person.weekly_capacity_hrs / 5))); }; const buildDraft = (): AllocationDragDraft => ({ person_id: selectedPerson!.id, start_date: rangeStart, end_date: rangeEnd, allocation_pct: allocationPct, hours_per_day: hoursPerDay, work_days: workDays, note: note.trim() || undefined, repeat: { enabled: !isEdit && repeatEnabled, interval: "week", endMode: repeatEndMode, endOn: repeatEndOn, occurrences: Math.max(1, repeatOccurrences), }, }); const handleSave = () => { if (!selectedPerson || personId === "") return; const draft = buildDraft(); if (isEdit && onUpdate) { onUpdate(draft); return; } onSave(draft); }; const style = { top: Math.min(anchor.top, window.innerHeight - 320), left: Math.min(Math.max(8, anchor.left - 200), window.innerWidth - 580), }; return createPortal(
{!fixedPerson ? ( ) : null}
{!isEdit && repeatEnabled ? (
Repeats weekly until{" "} {repeatEndMode === "on" ? format(new Date(repeatEndOn), "d MMM yyyy") : `${repeatOccurrences} times`}
) : null} {noteOpen ? (