resource-portal / frontend /src /components /projects /AllocationCloneMode.tsx
Gowrisankar
Add Runn-style clone mode with pill menu and target picker.
74fb13d
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);
}