Spaces:
Running
Running
| import { Search, X } from "lucide-react"; | |
| import { useMemo, useState, type CSSProperties } from "react"; | |
| import { createPortal } from "react-dom"; | |
| import { | |
| allocatedHoursInRange, | |
| buildAllocationBarSegment, | |
| formatAllocationEffort, | |
| formatHoursAmount, | |
| hoursPerDayFromAllocation, | |
| projectBarStyle, | |
| roundToHalfHour, | |
| visibleWeekdayCount, | |
| type EffortDisplayMode, | |
| } from "../../planner/allocationTimeline"; | |
| import type { Allocation, Person, Project } from "../../types"; | |
| import { Avatar } from "../ui/Avatar"; | |
| export interface CloneModeContext { | |
| allocation: Allocation; | |
| projectId: number; | |
| projectName: string; | |
| } | |
| type CloneTargetMode = "people" | "projects"; | |
| interface AllocationCloneModeProps { | |
| context: CloneModeContext; | |
| people: Person[]; | |
| projects: Project[]; | |
| allocations: Allocation[]; | |
| dayDates: Date[]; | |
| effortMode: EffortDisplayMode; | |
| onCloneToPerson: (personId: number) => void; | |
| onCloneToProject: (projectId: number) => void; | |
| onExit: () => void; | |
| } | |
| function freeHoursLabel( | |
| person: Person, | |
| personAllocations: Allocation[], | |
| dayDates: Date[], | |
| rangeStart: string, | |
| rangeEnd: string, | |
| ): { label: string; tone: "free" | "over" | "off" } { | |
| const weekdayCount = visibleWeekdayCount(dayDates, rangeStart, rangeEnd); | |
| if (weekdayCount <= 0) return { label: "Off", tone: "off" }; | |
| const capacity = roundToHalfHour((person.weekly_capacity_hrs / 5) * weekdayCount); | |
| let allocated = 0; | |
| for (const allocation of personAllocations) { | |
| const overlapStart = allocation.start_date > rangeStart ? allocation.start_date : rangeStart; | |
| const overlapEnd = allocation.end_date < rangeEnd ? allocation.end_date : rangeEnd; | |
| if (overlapStart > overlapEnd) continue; | |
| const days = visibleWeekdayCount(dayDates, overlapStart, overlapEnd); | |
| allocated += allocatedHoursInRange(allocation.allocation_pct, person.weekly_capacity_hrs, days); | |
| } | |
| const free = roundToHalfHour(capacity - allocated); | |
| if (free < 0) return { label: `${formatHoursAmount(Math.abs(free))} over`, tone: "over" }; | |
| if (free === 0) return { label: "0h free", tone: "off" }; | |
| return { label: `${formatHoursAmount(free)} free`, tone: "free" }; | |
| } | |
| export function AllocationCloneMode({ | |
| context, | |
| people, | |
| projects, | |
| allocations, | |
| dayDates, | |
| effortMode, | |
| onCloneToPerson, | |
| onCloneToProject, | |
| onExit, | |
| }: AllocationCloneModeProps) { | |
| const [targetMode, setTargetMode] = useState<CloneTargetMode>("people"); | |
| const [search, setSearch] = useState(""); | |
| const sourcePerson = people.find((person) => person.id === context.allocation.person_id); | |
| const weekdayCount = visibleWeekdayCount( | |
| dayDates, | |
| context.allocation.start_date, | |
| context.allocation.end_date, | |
| ); | |
| const cloningLabel = sourcePerson | |
| ? formatAllocationEffort( | |
| effortMode, | |
| context.allocation.allocation_pct, | |
| sourcePerson.weekly_capacity_hrs, | |
| weekdayCount, | |
| ) | |
| : `${context.allocation.allocation_pct}%`; | |
| const assignmentLabel = | |
| weekdayCount === 1 | |
| ? "1 weekday assignment" | |
| : `${weekdayCount} weekday assignment${weekdayCount === 1 ? "" : "s"}`; | |
| const term = search.trim().toLowerCase(); | |
| const filteredPeople = useMemo( | |
| () => | |
| people.filter((person) => { | |
| if (!person.is_active) return false; | |
| if (!term) return true; | |
| return ( | |
| person.name.toLowerCase().includes(term) || | |
| person.role.name.toLowerCase().includes(term) || | |
| person.email.toLowerCase().includes(term) | |
| ); | |
| }), | |
| [people, term], | |
| ); | |
| const filteredProjects = useMemo( | |
| () => | |
| projects.filter((project) => { | |
| if (!project.is_active) return false; | |
| if (!term) return true; | |
| return project.name.toLowerCase().includes(term) || project.type.toLowerCase().includes(term); | |
| }), | |
| [projects, term], | |
| ); | |
| const allocationsByPerson = useMemo(() => { | |
| const map = new Map<number, Allocation[]>(); | |
| for (const allocation of allocations) { | |
| if (!map.has(allocation.person_id)) map.set(allocation.person_id, []); | |
| map.get(allocation.person_id)!.push(allocation); | |
| } | |
| return map; | |
| }, [allocations]); | |
| const totalDays = dayDates.length; | |
| const panel = ( | |
| <> | |
| <div className="clone-mode-banner"> | |
| <span> | |
| <strong>CLONE MODE</strong> | |
| {" | "} | |
| {assignmentLabel} from {sourcePerson?.name ?? "Unknown"} | |
| {sourcePerson ? ` | ${sourcePerson.role.name}` : ""} | |
| {" | "} | |
| Cloning {cloningLabel} | |
| </span> | |
| <button className="clone-mode-exit" onClick={onExit} type="button"> | |
| Exit | |
| </button> | |
| </div> | |
| <div className="clone-mode-panel"> | |
| <div className="clone-mode-panel-header"> | |
| <div className="clone-mode-panel-title"> | |
| <label className="clone-mode-label" htmlFor="clone-target-mode"> | |
| CLONE TO | |
| </label> | |
| <select | |
| id="clone-target-mode" | |
| onChange={(event) => setTargetMode(event.target.value as CloneTargetMode)} | |
| value={targetMode} | |
| > | |
| <option value="people">People</option> | |
| <option value="projects">Projects</option> | |
| </select> | |
| </div> | |
| <div className="clone-mode-search"> | |
| <Search aria-hidden size={16} /> | |
| <input | |
| onChange={(event) => setSearch(event.target.value)} | |
| placeholder={targetMode === "people" ? "Search people…" : "Search projects…"} | |
| type="search" | |
| value={search} | |
| /> | |
| </div> | |
| <button aria-label="Close clone panel" className="clone-mode-close" onClick={onExit} type="button"> | |
| <X size={20} /> | |
| </button> | |
| </div> | |
| <div className="clone-mode-list"> | |
| {targetMode === "people" | |
| ? filteredPeople.map((person) => { | |
| const personAllocations = allocationsByPerson.get(person.id) ?? []; | |
| const capacity = freeHoursLabel( | |
| person, | |
| personAllocations, | |
| dayDates, | |
| context.allocation.start_date, | |
| context.allocation.end_date, | |
| ); | |
| const isSource = person.id === context.allocation.person_id; | |
| return ( | |
| <div className="clone-mode-row" key={person.id}> | |
| <div className="clone-mode-row-person"> | |
| <Avatar color={person.avatar_color} name={person.name} size={32} /> | |
| <div className="person-meta"> | |
| <strong>{person.name}</strong> | |
| <small>{person.role.name}</small> | |
| </div> | |
| </div> | |
| <div className="clone-mode-row-track"> | |
| <div | |
| className="clone-mode-mini-timeline" | |
| style={{ "--planner-cols": totalDays } as CSSProperties} | |
| > | |
| <div className="planner-day-grid" /> | |
| {personAllocations.map((allocation) => { | |
| const segment = buildAllocationBarSegment( | |
| dayDates, | |
| allocation.start_date, | |
| allocation.end_date, | |
| ); | |
| if (!segment) return null; | |
| const project = projects.find((item) => item.id === allocation.project_id); | |
| return ( | |
| <div | |
| className="clone-mode-mini-bar" | |
| key={allocation.id} | |
| style={{ | |
| ...projectBarStyle(segment, totalDays), | |
| background: project?.color ?? "#94a3b8", | |
| }} | |
| title={project?.name} | |
| /> | |
| ); | |
| })} | |
| <span className={`clone-mode-capacity is-${capacity.tone}`}>{capacity.label}</span> | |
| </div> | |
| </div> | |
| <button | |
| className="clone-mode-clone-btn" | |
| disabled={isSource} | |
| onClick={() => onCloneToPerson(person.id)} | |
| title={isSource ? "Already assigned to this person" : undefined} | |
| type="button" | |
| > | |
| Clone | |
| </button> | |
| </div> | |
| ); | |
| }) | |
| : filteredProjects.map((project) => { | |
| const isSource = project.id === context.projectId; | |
| return ( | |
| <div className="clone-mode-row" key={project.id}> | |
| <div className="clone-mode-row-person"> | |
| <span className="clone-mode-project-dot" style={{ background: project.color }} /> | |
| <div className="person-meta"> | |
| <strong>{project.name}</strong> | |
| <small>{project.type}</small> | |
| </div> | |
| </div> | |
| <div className="clone-mode-row-track" /> | |
| <button | |
| className="clone-mode-clone-btn" | |
| disabled={isSource} | |
| onClick={() => onCloneToProject(project.id)} | |
| title={isSource ? "Already on this project" : undefined} | |
| type="button" | |
| > | |
| Clone | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| </> | |
| ); | |
| return createPortal(panel, document.body); | |
| } | |
| export function cloneModeEffortSummary(allocation: Allocation, person: Person): string { | |
| const daily = roundToHalfHour( | |
| hoursPerDayFromAllocation(allocation.allocation_pct, person.weekly_capacity_hrs), | |
| ); | |
| return formatHoursAmount(daily); | |
| } | |