Spaces:
Running
Running
| 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<HTMLDivElement>(null); | |
| const menuRef = useRef<HTMLDivElement>(null); | |
| const isEdit = Boolean(allocation); | |
| const dragWorkDays = visibleWeekdayCount(dayDates, startDate, endDate); | |
| const [personId, setPersonId] = useState<number | "">(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<EffortUnit>("hours_per_day"); | |
| const [effortInput, setEffortInput] = useState("8:00"); | |
| const [totalUnit, setTotalUnit] = useState<TotalEffortUnit>("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( | |
| <div className="runn-allocation-popover" ref={popoverRef} style={style}> | |
| {!fixedPerson ? ( | |
| <select | |
| className="runn-allocation-person" | |
| onChange={(event) => handlePersonChange(Number(event.target.value))} | |
| value={personId} | |
| > | |
| {people.map((person) => ( | |
| <option key={person.id} value={person.id}> | |
| {person.name} | |
| </option> | |
| ))} | |
| </select> | |
| ) : null} | |
| <div className="runn-effort-row"> | |
| <label className="runn-field"> | |
| <span className="runn-field-label"> | |
| Effort <HelpCircle aria-hidden size={13} /> | |
| </span> | |
| <div className="runn-split"> | |
| <input | |
| aria-label="Effort" | |
| onChange={(event) => setEffortInput(event.target.value)} | |
| type="text" | |
| value={effortInput} | |
| /> | |
| <select | |
| aria-label="Effort unit" | |
| onChange={(event) => handleEffortUnitChange(event.target.value as EffortUnit)} | |
| value={effortUnit} | |
| > | |
| {EFFORT_UNITS.map((option) => ( | |
| <option key={option.value} value={option.value}> | |
| {option.label} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </label> | |
| <label className="runn-field"> | |
| <span className="runn-field-label">Work Days</span> | |
| <input | |
| aria-label="Work days" | |
| className="runn-workdays" | |
| min={1} | |
| onChange={(event) => setWorkDays(Math.max(1, Number(event.target.value) || 1))} | |
| type="number" | |
| value={workDays} | |
| /> | |
| </label> | |
| <label className="runn-field"> | |
| <span className="runn-field-label">Total Effort</span> | |
| <div className="runn-split"> | |
| <input | |
| aria-label="Total effort" | |
| readOnly | |
| type="text" | |
| value={totalUnit === "hours" ? formatHoursClock(totalHours) : `${totalDays}`} | |
| /> | |
| <select | |
| aria-label="Total unit" | |
| onChange={(event) => setTotalUnit(event.target.value as TotalEffortUnit)} | |
| value={totalUnit} | |
| > | |
| <option value="hours">Hours</option> | |
| <option value="days">Days</option> | |
| </select> | |
| </div> | |
| </label> | |
| </div> | |
| {!isEdit && repeatEnabled ? ( | |
| <div className="runn-repeat-hint muted-text"> | |
| Repeats weekly until{" "} | |
| {repeatEndMode === "on" ? format(new Date(repeatEndOn), "d MMM yyyy") : `${repeatOccurrences} times`} | |
| </div> | |
| ) : null} | |
| <button className="runn-note-toggle" onClick={() => setNoteOpen((open) => !open)} type="button"> | |
| <ChevronRight className={noteOpen ? "is-open" : ""} size={14} /> | |
| Note | |
| </button> | |
| {noteOpen ? ( | |
| <textarea | |
| className="runn-note-input" | |
| onChange={(event) => setNote(event.target.value)} | |
| placeholder="Add a note…" | |
| rows={2} | |
| value={note} | |
| /> | |
| ) : null} | |
| {datesOpen ? ( | |
| <div className="runn-date-row"> | |
| <label> | |
| Start | |
| <input onChange={(event) => setRangeStart(event.target.value)} type="date" value={rangeStart} /> | |
| </label> | |
| <label> | |
| End | |
| <input onChange={(event) => setRangeEnd(event.target.value)} type="date" value={rangeEnd} /> | |
| </label> | |
| </div> | |
| ) : null} | |
| <div className="runn-popover-footer"> | |
| {isEdit && onDelete ? ( | |
| <button | |
| className="runn-delete" | |
| onClick={() => { | |
| if (window.confirm("Delete this assignment?")) onDelete(); | |
| }} | |
| type="button" | |
| > | |
| Delete | |
| </button> | |
| ) : ( | |
| <button className="runn-delete" onClick={onCancel} type="button"> | |
| Cancel | |
| </button> | |
| )} | |
| <div className="runn-footer-actions"> | |
| <button | |
| aria-label="Dates" | |
| className={`runn-icon-pill${datesOpen ? " is-active" : ""}`} | |
| onClick={() => { | |
| setDatesOpen((open) => !open); | |
| setMenuOpen(false); | |
| setTransferOpen(false); | |
| }} | |
| type="button" | |
| > | |
| <Calendar size={18} /> | |
| </button> | |
| <div className="runn-menu-wrap" ref={menuRef}> | |
| <button | |
| aria-expanded={menuOpen} | |
| aria-label="More options" | |
| className={`runn-icon-pill${menuOpen ? " is-active" : ""}`} | |
| onClick={() => setMenuOpen((open) => !open)} | |
| type="button" | |
| > | |
| <MoreHorizontal size={18} /> | |
| </button> | |
| {menuOpen ? ( | |
| <div className="runn-menu runn-menu-actions"> | |
| {isEdit ? ( | |
| <> | |
| <button | |
| className="runn-menu-action" | |
| onClick={() => setTransferOpen((open) => !open)} | |
| type="button" | |
| > | |
| Transfer | |
| </button> | |
| {transferOpen && transferPeople.length > 0 ? ( | |
| <div className="runn-menu-sub"> | |
| <select | |
| onChange={(event) => { | |
| const id = Number(event.target.value); | |
| if (id) onTransfer?.(id); | |
| setMenuOpen(false); | |
| setTransferOpen(false); | |
| }} | |
| value="" | |
| > | |
| <option value="">Choose person…</option> | |
| {transferPeople.map((person) => ( | |
| <option key={person.id} value={person.id}> | |
| {person.name} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| ) : null} | |
| <button | |
| className="runn-menu-action" | |
| onClick={() => { | |
| onClone?.(); | |
| setMenuOpen(false); | |
| }} | |
| type="button" | |
| > | |
| Clone | |
| </button> | |
| <button | |
| className="runn-menu-action" | |
| onClick={() => { | |
| const last = dayDates.length | |
| ? format(dayDates[dayDates.length - 1], "yyyy-MM-dd") | |
| : rangeEnd; | |
| setRangeEnd(last); | |
| onSelectAllToRight?.(); | |
| setMenuOpen(false); | |
| }} | |
| type="button" | |
| > | |
| Select All to Right | |
| </button> | |
| <button | |
| className="runn-menu-action" | |
| onClick={() => { | |
| onToggleMultiSelect?.(); | |
| setMenuOpen(false); | |
| }} | |
| type="button" | |
| > | |
| {multiSelectActive ? "Disable Multi-Select Mode" : "Enable Multi-Select Mode"} | |
| </button> | |
| </> | |
| ) : ( | |
| <> | |
| <label className="runn-menu-item"> | |
| <input | |
| checked={repeatEnabled} | |
| onChange={(event) => setRepeatEnabled(event.target.checked)} | |
| type="checkbox" | |
| /> | |
| Repeat weekly | |
| </label> | |
| {repeatEnabled ? ( | |
| <> | |
| <label className="runn-menu-item"> | |
| End on | |
| <input | |
| onChange={(event) => { | |
| setRepeatEndMode("on"); | |
| setRepeatEndOn(event.target.value); | |
| }} | |
| type="date" | |
| value={repeatEndOn} | |
| /> | |
| </label> | |
| <label className="runn-menu-item"> | |
| After | |
| <input | |
| min={1} | |
| onChange={(event) => { | |
| setRepeatEndMode("after"); | |
| setRepeatOccurrences(Math.max(1, Number(event.target.value) || 1)); | |
| }} | |
| type="number" | |
| value={repeatOccurrences} | |
| /> | |
| times | |
| </label> | |
| </> | |
| ) : null} | |
| </> | |
| )} | |
| </div> | |
| ) : null} | |
| </div> | |
| <button | |
| className="runn-save" | |
| disabled={isSaving || !selectedPerson || personId === ""} | |
| onClick={handleSave} | |
| type="button" | |
| > | |
| Save | |
| </button> | |
| </div> | |
| </div> | |
| </div>, | |
| document.body, | |
| ); | |
| } | |