resource-portal / frontend /src /components /projects /ProjectPlannerBlock.tsx
Gowrisankar
Fix clone View navigation and center allocation bar labels.
dae0e5e
import { ChevronRight } from "lucide-react";
import { useMemo } from "react";
import { Avatar } from "../ui/Avatar";
import { AllocationDragTimeline } from "./AllocationDragTimeline";
import type { AllocationDragDraft } from "./AllocationCreatePopover";
import { ProjectCalendarTrack } from "./ProjectCalendarTrack";
import { ProjectRowMenu } from "./ProjectRowMenu";
import { EffortDisplaySelect } from "./EffortDisplaySelect";
import {
allocatedDaysInRange,
allocatedHoursInRange,
formatDaysAmount,
formatHoursAmount,
roundToHalfHour,
visibleWeekdayCount,
type EffortDisplayMode,
} from "../../planner/allocationTimeline";
import type { Allocation, Person, Project } from "../../types";
export type PlannerViewMode = "timeline" | "phases_milestones";
interface ProjectPlannerBlockProps {
project: Project;
teamLabel: string;
dayDates: Date[];
plannerView: PlannerViewMode;
showTentative: boolean;
expanded: boolean;
allocations: Allocation[];
people: Person[];
peopleById: Map<number, Person>;
onToggleExpand: () => void;
onEditProject: () => void;
onAddPerson: () => void;
onCreateAllocation: (draft: AllocationDragDraft) => Promise<Allocation | undefined>;
onUpdateAllocation: (allocation: Allocation, draft: AllocationDragDraft) => void;
onDeleteAllocation: (allocation: Allocation) => void;
onTransferAllocation?: (allocation: Allocation, personId: number) => void;
onStartCloneMode?: (allocation: Allocation) => void;
cloneSourceAllocationId?: number;
onExtendAllocationRight?: (allocation: Allocation) => void;
multiSelectMode?: boolean;
selectedAllocationIds?: Set<number>;
onToggleAllocationSelect?: (allocationId: number) => void;
onToggleMultiSelectMode?: () => void;
onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
onCreatePhase: (input: {
name: string;
start_date: string;
end_date: string;
color?: string;
}) => void;
onToggleTentative: () => void;
onMilestones: () => void;
onArchive: () => void;
isSavingAllocation?: boolean;
isSavingPlanner?: boolean;
effortMode: EffortDisplayMode;
onEffortModeChange: (mode: EffortDisplayMode) => void;
onRestore?: () => void;
}
export function ProjectPlannerBlock({
project,
teamLabel,
dayDates,
plannerView,
showTentative,
expanded,
allocations,
people,
peopleById,
onToggleExpand,
onEditProject,
onAddPerson,
onCreateAllocation,
onUpdateAllocation,
onDeleteAllocation,
onTransferAllocation,
onStartCloneMode,
cloneSourceAllocationId,
onExtendAllocationRight,
multiSelectMode = false,
selectedAllocationIds,
onToggleAllocationSelect,
onToggleMultiSelectMode,
onCreateMilestone,
onCreatePhase,
onToggleTentative,
onMilestones,
onArchive,
isSavingAllocation = false,
isSavingPlanner = false,
effortMode,
onEffortModeChange,
onRestore,
}: ProjectPlannerBlockProps) {
const showPhasesMilestones = plannerView === "phases_milestones";
const assignedPersonIds = useMemo(
() => [...new Set(allocations.map((item) => item.person_id))],
[allocations],
);
const candidatePeople = useMemo(() => {
const active = people.filter((person) => person.is_active);
const teamFiltered = project.team_id
? active.filter((person) => person.team?.id === project.team_id)
: active;
return teamFiltered.filter((person) => !assignedPersonIds.includes(person.id));
}, [people, project.team_id, assignedPersonIds]);
const { allocatedHours, capacityHours, allocatedDays, capacityDays, developerCount } = useMemo(() => {
let allocatedH = 0;
let allocatedD = 0;
let capacityH = 0;
let capacityD = 0;
const seen = new Set<number>();
for (const allocation of allocations) {
const person = peopleById.get(allocation.person_id);
if (!person) continue;
seen.add(person.id);
const days = visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date);
const dailyCapacity = roundToHalfHour(person.weekly_capacity_hrs / 5);
allocatedH += allocatedHoursInRange(allocation.allocation_pct, person.weekly_capacity_hrs, days);
allocatedD += allocatedDaysInRange(allocation.allocation_pct, days);
capacityH += dailyCapacity * days;
capacityD += days;
}
return {
allocatedHours: allocatedH,
capacityHours: capacityH,
allocatedDays: allocatedD,
capacityDays: capacityD,
developerCount: seen.size,
};
}, [allocations, peopleById, dayDates]);
const effortPct =
effortMode === "days"
? capacityDays > 0
? Math.min(100, (allocatedDays / capacityDays) * 100)
: 0
: capacityHours > 0
? Math.min(100, (allocatedHours / capacityHours) * 100)
: 0;
const effortSummary =
developerCount === 0
? "No developers assigned"
: effortMode === "days"
? formatDaysAmount(allocatedDays)
: formatHoursAmount(allocatedHours);
const roleGroups = useMemo(() => {
const map = new Map<string, { roleName: string; allocations: Allocation[] }>();
for (const allocation of allocations) {
const person = peopleById.get(allocation.person_id);
const roleName = person?.role.name ?? "Developer";
if (!map.has(roleName)) map.set(roleName, { roleName, allocations: [] });
map.get(roleName)!.allocations.push(allocation);
}
if (map.size === 0) map.set("Developer", { roleName: "Developer", allocations: [] });
return Array.from(map.values()).sort((a, b) => a.roleName.localeCompare(b.roleName));
}, [allocations, peopleById]);
return (
<div className={`planner-project-block ${expanded ? "is-expanded" : ""}`} data-project-id={project.id}>
<div className={`planner-row planner-project-row ${project.is_active ? "" : "planner-row-archived"}`}>
<div className="planner-row-label person planner-project-label">
<button
aria-expanded={expanded}
className="planner-project-name"
onClick={onToggleExpand}
type="button"
>
<span className="project-swatch" style={{ background: project.color }} />
<div className="person-meta">
<strong>
{project.name}
{project.is_tentative ? (
<span className="badge muted" style={{ marginLeft: 6 }}>
tentative
</span>
) : null}
{!project.is_active ? (
<span className="badge muted" style={{ marginLeft: 6 }}>
archived
</span>
) : null}
</strong>
<small>{teamLabel}</small>
</div>
</button>
<div className="planner-project-actions">
<ProjectRowMenu
onArchive={onArchive}
onEditDetails={onEditProject}
onMilestones={onMilestones}
onRestore={onRestore}
onToggleTentative={onToggleTentative}
project={project}
/>
<button
aria-expanded={expanded}
aria-label={expanded ? "Collapse project" : "Expand project"}
className={`planner-expand-end${expanded ? " expanded" : ""}`}
onClick={onToggleExpand}
type="button"
>
<ChevronRight size={18} />
</button>
</div>
</div>
<div className="planner-row-track">
<ProjectCalendarTrack
dayDates={dayDates}
enableMilestones
isSaving={isSavingPlanner}
mode="project-line"
onCreateMilestone={onCreateMilestone}
onCreatePhase={onCreatePhase}
project={project}
showTentative={showTentative}
/>
</div>
</div>
{expanded ? (
<>
{showPhasesMilestones ? (
<div className="planner-row planner-project-subrow planner-phases-row">
<div className="planner-row-label nested">
<span className="planner-sub-icon" aria-hidden>
</span>
<strong>Phases</strong>
</div>
<div className="planner-row-track">
<ProjectCalendarTrack
dayDates={dayDates}
enablePhaseDrag
isSaving={isSavingPlanner}
mode="phases"
onCreateMilestone={onCreateMilestone}
onCreatePhase={onCreatePhase}
project={project}
showTentative={showTentative}
/>
</div>
</div>
) : null}
<div className="planner-row planner-project-subrow planner-hours-row">
<div className="planner-row-label nested planner-effort-label">
<EffortDisplaySelect onChange={onEffortModeChange} value={effortMode} />
<span className={`planner-effort-total${effortPct > 100 ? " is-over" : ""}`}>{effortSummary}</span>
</div>
<div className="planner-row-track">
<div className="planner-hours-track">
<div className="planner-hours-bar" style={{ width: `${effortPct}%` }} />
</div>
</div>
</div>
{roleGroups.map((group) => (
<div key={group.roleName}>
<div className="planner-row planner-project-subrow planner-role-row">
<div className="planner-row-label nested">
<span className="planner-sub-icon" aria-hidden>
👤
</span>
<div className="person-meta">
<strong>{group.roleName}</strong>
<small>
{group.allocations.length}/{Math.max(group.allocations.length, 1)}
</small>
</div>
</div>
<div className="planner-row-track">
{candidatePeople.length > 0 ? (
<AllocationDragTimeline
candidatePeople={candidatePeople}
color={project.color}
dayDates={dayDates}
effortMode={effortMode}
isSaving={isSavingAllocation}
onSave={onCreateAllocation}
/>
) : null}
</div>
</div>
{group.allocations.map((allocation) => {
const person = peopleById.get(allocation.person_id);
if (!person) return null;
const transferCandidates = people.filter(
(candidate) => candidate.is_active && candidate.id !== allocation.person_id,
);
return (
<div
className="planner-row planner-project-subrow planner-member-row"
data-allocation-id={allocation.id}
data-person-id={allocation.person_id}
key={allocation.id}
>
<div className="planner-row-label nested member">
<Avatar color={person.avatar_color} name={person.name} size={26} />
<div className="person-meta">
<strong>{person.name}</strong>
<small>{person.role.name}</small>
</div>
</div>
<div className="planner-row-track">
<AllocationDragTimeline
allocation={allocation}
allocationPct={allocation.allocation_pct}
color={project.color}
dayDates={dayDates}
effortMode={effortMode}
fixedPerson={person}
isSaving={isSavingAllocation}
multiSelectActive={multiSelectMode}
cloneSourceId={cloneSourceAllocationId}
onStartClone={onStartCloneMode ? () => onStartCloneMode(allocation) : undefined}
onDelete={(item) => onDeleteAllocation(item)}
onSave={onCreateAllocation}
onSelectAllToRight={
onExtendAllocationRight ? () => onExtendAllocationRight(allocation) : undefined
}
onToggleMultiSelect={onToggleMultiSelectMode}
onToggleSelect={
onToggleAllocationSelect
? () => onToggleAllocationSelect(allocation.id)
: undefined
}
onTransfer={
onTransferAllocation
? (personId) => onTransferAllocation(allocation, personId)
: undefined
}
onUpdate={(draft) => onUpdateAllocation(allocation, draft)}
selected={selectedAllocationIds?.has(allocation.id) ?? false}
transferPeople={transferCandidates}
weeklyCapacityHrs={person.weekly_capacity_hrs}
/>
</div>
</div>
);
})}
</div>
))}
{project.is_active ? (
<div className="planner-row planner-project-subrow planner-add-member-row">
<button className="planner-row-label nested add-member" onClick={onAddPerson} type="button">
<span className="plus">+</span> Add Person or Placeholder
</button>
<div className="planner-row-track">
{candidatePeople.length > 0 ? (
<AllocationDragTimeline
candidatePeople={candidatePeople}
color={project.color}
dayDates={dayDates}
effortMode={effortMode}
isSaving={isSavingAllocation}
onSave={onCreateAllocation}
/>
) : null}
</div>
</div>
) : null}
</>
) : null}
</div>
);
}