Spaces:
Running
Running
| 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> | |
| ); | |
| } | |