resource-portal / frontend /src /components /projects /AllocationCreatePopover.tsx
Gowrisankar
Add Runn-style allocation menu actions and multi-select.
eed056f
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,
);
}