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; onToggleExpand: () => void; onEditProject: () => void; onAddPerson: () => void; onCreateAllocation: (draft: AllocationDragDraft) => Promise; 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; 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(); 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(); 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 (
{expanded ? ( <> {showPhasesMilestones ? (
Phases
) : null}
100 ? " is-over" : ""}`}>{effortSummary}
{roleGroups.map((group) => (
👤
{group.roleName} {group.allocations.length}/{Math.max(group.allocations.length, 1)}
{candidatePeople.length > 0 ? ( ) : null}
{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 (
{person.name} {person.role.name}
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} />
); })}
))} {project.is_active ? (
{candidatePeople.length > 0 ? ( ) : null}
) : null} ) : null}
); }