Spaces:
Running
Running
Gowrisankar Cursor commited on
Commit ·
b00cff7
1
Parent(s): bcf0657
Add Hours/Days effort toggle and simplify project timeline row.
Browse filesLet planners switch allocation labels and totals between hours and days, and keep schedule dates in the drawer instead of drawing a span line on the project row.
Co-authored-by: Cursor <cursoragent@cursor.com>
- frontend/src/components/projects/AllocationDragTimeline.tsx +18 -4
- frontend/src/components/projects/EffortDisplaySelect.tsx +28 -0
- frontend/src/components/projects/ProjectCalendarTrack.tsx +7 -1
- frontend/src/components/projects/ProjectPlannerBlock.tsx +48 -21
- frontend/src/pages/Projects.tsx +12 -1
- frontend/src/planner/allocationTimeline.ts +24 -0
- frontend/src/styles.css +30 -0
frontend/src/components/projects/AllocationDragTimeline.tsx
CHANGED
|
@@ -5,9 +5,11 @@ import {
|
|
| 5 |
buildAllocationBarSegment,
|
| 6 |
dateStringsFromDayIndices,
|
| 7 |
dayIndexFromPointer,
|
| 8 |
-
|
| 9 |
projectBarStyle,
|
| 10 |
roundToHalfHour,
|
|
|
|
|
|
|
| 11 |
} from "../../planner/allocationTimeline";
|
| 12 |
import type { Allocation, Person } from "../../types";
|
| 13 |
import { AllocationCreatePopover, type AllocationDragDraft } from "./AllocationCreatePopover";
|
|
@@ -25,6 +27,7 @@ interface AllocationDragTimelineProps {
|
|
| 25 |
onEditAllocation?: () => void;
|
| 26 |
onSave: (draft: AllocationDragDraft) => void;
|
| 27 |
isSaving?: boolean;
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
export function AllocationDragTimeline({
|
|
@@ -38,6 +41,7 @@ export function AllocationDragTimeline({
|
|
| 38 |
onEditAllocation,
|
| 39 |
onSave,
|
| 40 |
isSaving = false,
|
|
|
|
| 41 |
}: AllocationDragTimelineProps) {
|
| 42 |
const trackRef = useRef<HTMLDivElement>(null);
|
| 43 |
const [dragRange, setDragRange] = useState<{ start: number; end: number } | null>(null);
|
|
@@ -142,7 +146,7 @@ export function AllocationDragTimeline({
|
|
| 142 |
}}
|
| 143 |
/>
|
| 144 |
) : null}
|
| 145 |
-
{existingSegment && weeklyCapacityHrs !== undefined && allocationPct !== undefined ? (
|
| 146 |
<button
|
| 147 |
className={existingSegment.className}
|
| 148 |
onClick={(event) => {
|
|
@@ -155,11 +159,21 @@ export function AllocationDragTimeline({
|
|
| 155 |
background: color,
|
| 156 |
borderTopColor: color,
|
| 157 |
}}
|
| 158 |
-
title={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
type="button"
|
| 160 |
>
|
| 161 |
<span className="planner-bar-label">
|
| 162 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
</span>
|
| 164 |
</button>
|
| 165 |
) : null}
|
|
|
|
| 5 |
buildAllocationBarSegment,
|
| 6 |
dateStringsFromDayIndices,
|
| 7 |
dayIndexFromPointer,
|
| 8 |
+
formatAllocationEffort,
|
| 9 |
projectBarStyle,
|
| 10 |
roundToHalfHour,
|
| 11 |
+
visibleWeekdayCount,
|
| 12 |
+
type EffortDisplayMode,
|
| 13 |
} from "../../planner/allocationTimeline";
|
| 14 |
import type { Allocation, Person } from "../../types";
|
| 15 |
import { AllocationCreatePopover, type AllocationDragDraft } from "./AllocationCreatePopover";
|
|
|
|
| 27 |
onEditAllocation?: () => void;
|
| 28 |
onSave: (draft: AllocationDragDraft) => void;
|
| 29 |
isSaving?: boolean;
|
| 30 |
+
effortMode?: EffortDisplayMode;
|
| 31 |
}
|
| 32 |
|
| 33 |
export function AllocationDragTimeline({
|
|
|
|
| 41 |
onEditAllocation,
|
| 42 |
onSave,
|
| 43 |
isSaving = false,
|
| 44 |
+
effortMode = "hours",
|
| 45 |
}: AllocationDragTimelineProps) {
|
| 46 |
const trackRef = useRef<HTMLDivElement>(null);
|
| 47 |
const [dragRange, setDragRange] = useState<{ start: number; end: number } | null>(null);
|
|
|
|
| 146 |
}}
|
| 147 |
/>
|
| 148 |
) : null}
|
| 149 |
+
{existingSegment && weeklyCapacityHrs !== undefined && allocationPct !== undefined && allocation ? (
|
| 150 |
<button
|
| 151 |
className={existingSegment.className}
|
| 152 |
onClick={(event) => {
|
|
|
|
| 159 |
background: color,
|
| 160 |
borderTopColor: color,
|
| 161 |
}}
|
| 162 |
+
title={formatAllocationEffort(
|
| 163 |
+
effortMode,
|
| 164 |
+
allocationPct,
|
| 165 |
+
weeklyCapacityHrs,
|
| 166 |
+
visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date),
|
| 167 |
+
)}
|
| 168 |
type="button"
|
| 169 |
>
|
| 170 |
<span className="planner-bar-label">
|
| 171 |
+
{formatAllocationEffort(
|
| 172 |
+
effortMode,
|
| 173 |
+
allocationPct,
|
| 174 |
+
weeklyCapacityHrs,
|
| 175 |
+
visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date),
|
| 176 |
+
)}
|
| 177 |
</span>
|
| 178 |
</button>
|
| 179 |
) : null}
|
frontend/src/components/projects/EffortDisplaySelect.tsx
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { EffortDisplayMode } from "../../planner/allocationTimeline";
|
| 2 |
+
|
| 3 |
+
interface EffortDisplaySelectProps {
|
| 4 |
+
value: EffortDisplayMode;
|
| 5 |
+
onChange: (value: EffortDisplayMode) => void;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
const OPTIONS: Array<{ value: EffortDisplayMode; label: string }> = [
|
| 9 |
+
{ value: "days", label: "Days" },
|
| 10 |
+
{ value: "hours", label: "Hours" },
|
| 11 |
+
];
|
| 12 |
+
|
| 13 |
+
export function EffortDisplaySelect({ value, onChange }: EffortDisplaySelectProps) {
|
| 14 |
+
return (
|
| 15 |
+
<select
|
| 16 |
+
aria-label="Display effort as hours or days"
|
| 17 |
+
className="effort-display-select"
|
| 18 |
+
onChange={(event) => onChange(event.target.value as EffortDisplayMode)}
|
| 19 |
+
value={value}
|
| 20 |
+
>
|
| 21 |
+
{OPTIONS.map((option) => (
|
| 22 |
+
<option key={option.value} value={option.value}>
|
| 23 |
+
{option.label}
|
| 24 |
+
</option>
|
| 25 |
+
))}
|
| 26 |
+
</select>
|
| 27 |
+
);
|
| 28 |
+
}
|
frontend/src/components/projects/ProjectCalendarTrack.tsx
CHANGED
|
@@ -23,6 +23,8 @@ interface ProjectCalendarTrackProps {
|
|
| 23 |
project: Project;
|
| 24 |
dayDates: Date[];
|
| 25 |
showTentative: boolean;
|
|
|
|
|
|
|
| 26 |
enableMilestones?: boolean;
|
| 27 |
enablePhaseDrag?: boolean;
|
| 28 |
onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
|
|
@@ -40,6 +42,7 @@ export function ProjectCalendarTrack({
|
|
| 40 |
project,
|
| 41 |
dayDates,
|
| 42 |
showTentative,
|
|
|
|
| 43 |
enableMilestones = false,
|
| 44 |
enablePhaseDrag = false,
|
| 45 |
onCreateMilestone,
|
|
@@ -190,7 +193,10 @@ export function ProjectCalendarTrack({
|
|
| 190 |
</div>
|
| 191 |
))
|
| 192 |
: null}
|
| 193 |
-
{
|
|
|
|
|
|
|
|
|
|
| 194 |
<div
|
| 195 |
className={`project-span-line${project.is_tentative ? " tentative" : ""}`}
|
| 196 |
style={{
|
|
|
|
| 23 |
project: Project;
|
| 24 |
dayDates: Date[];
|
| 25 |
showTentative: boolean;
|
| 26 |
+
/** When false, the row shows milestones only (schedule dates live in the project drawer). */
|
| 27 |
+
showScheduleSpan?: boolean;
|
| 28 |
enableMilestones?: boolean;
|
| 29 |
enablePhaseDrag?: boolean;
|
| 30 |
onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
|
|
|
|
| 42 |
project,
|
| 43 |
dayDates,
|
| 44 |
showTentative,
|
| 45 |
+
showScheduleSpan = false,
|
| 46 |
enableMilestones = false,
|
| 47 |
enablePhaseDrag = false,
|
| 48 |
onCreateMilestone,
|
|
|
|
| 193 |
</div>
|
| 194 |
))
|
| 195 |
: null}
|
| 196 |
+
{showScheduleSpan &&
|
| 197 |
+
lineSegment &&
|
| 198 |
+
mode === "project-line" &&
|
| 199 |
+
(!project.is_tentative || showTentative) ? (
|
| 200 |
<div
|
| 201 |
className={`project-span-line${project.is_tentative ? " tentative" : ""}`}
|
| 202 |
style={{
|
frontend/src/components/projects/ProjectPlannerBlock.tsx
CHANGED
|
@@ -6,11 +6,15 @@ import { AllocationDragTimeline } from "./AllocationDragTimeline";
|
|
| 6 |
import type { AllocationDragDraft } from "./AllocationCreatePopover";
|
| 7 |
import { ProjectCalendarTrack } from "./ProjectCalendarTrack";
|
| 8 |
import { ProjectRowMenu } from "./ProjectRowMenu";
|
|
|
|
| 9 |
import {
|
|
|
|
| 10 |
allocatedHoursInRange,
|
|
|
|
| 11 |
formatHoursAmount,
|
| 12 |
roundToHalfHour,
|
| 13 |
visibleWeekdayCount,
|
|
|
|
| 14 |
} from "../../planner/allocationTimeline";
|
| 15 |
import type { Allocation, Person, Project } from "../../types";
|
| 16 |
|
|
@@ -43,6 +47,8 @@ interface ProjectPlannerBlockProps {
|
|
| 43 |
onArchive: () => void;
|
| 44 |
isSavingAllocation?: boolean;
|
| 45 |
isSavingPlanner?: boolean;
|
|
|
|
|
|
|
| 46 |
onRestore?: () => void;
|
| 47 |
}
|
| 48 |
|
|
@@ -68,6 +74,8 @@ export function ProjectPlannerBlock({
|
|
| 68 |
onArchive,
|
| 69 |
isSavingAllocation = false,
|
| 70 |
isSavingPlanner = false,
|
|
|
|
|
|
|
| 71 |
onRestore,
|
| 72 |
}: ProjectPlannerBlockProps) {
|
| 73 |
const showPhasesMilestones = plannerView === "phases_milestones";
|
|
@@ -89,30 +97,51 @@ export function ProjectPlannerBlock({
|
|
| 89 |
[dayDates, project.start_date, project.end_date],
|
| 90 |
);
|
| 91 |
|
| 92 |
-
const { allocatedHours, capacityHours, developerCount } = useMemo(() => {
|
| 93 |
-
let
|
|
|
|
| 94 |
const seen = new Set<number>();
|
| 95 |
for (const allocation of allocations) {
|
| 96 |
const person = peopleById.get(allocation.person_id);
|
| 97 |
if (!person) continue;
|
| 98 |
seen.add(person.id);
|
| 99 |
const days = visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date);
|
| 100 |
-
|
|
|
|
| 101 |
}
|
| 102 |
-
const
|
| 103 |
const person = peopleById.get(personId);
|
| 104 |
return sum + (person ? roundToHalfHour(person.weekly_capacity_hrs / 5) * weekdayCount : 0);
|
| 105 |
}, 0);
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
}, [allocations, peopleById, dayDates, weekdayCount]);
|
| 108 |
|
| 109 |
-
const
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
const roleGroups = useMemo(() => {
|
| 118 |
const map = new Map<string, { roleName: string; allocations: Allocation[] }>();
|
|
@@ -209,18 +238,13 @@ export function ProjectPlannerBlock({
|
|
| 209 |
) : null}
|
| 210 |
|
| 211 |
<div className="planner-row planner-project-subrow planner-hours-row">
|
| 212 |
-
<div className="planner-row-label nested">
|
| 213 |
-
<
|
| 214 |
-
|
| 215 |
-
</span>
|
| 216 |
-
<div className="person-meta">
|
| 217 |
-
<strong>Hours</strong>
|
| 218 |
-
<small>{hoursLabel}</small>
|
| 219 |
-
</div>
|
| 220 |
</div>
|
| 221 |
<div className="planner-row-track">
|
| 222 |
<div className="planner-hours-track">
|
| 223 |
-
<div className="planner-hours-bar" style={{ width: `${
|
| 224 |
</div>
|
| 225 |
</div>
|
| 226 |
</div>
|
|
@@ -245,6 +269,7 @@ export function ProjectPlannerBlock({
|
|
| 245 |
candidatePeople={candidatePeople}
|
| 246 |
color={project.color}
|
| 247 |
dayDates={dayDates}
|
|
|
|
| 248 |
isSaving={isSavingAllocation}
|
| 249 |
onSave={onCreateAllocation}
|
| 250 |
/>
|
|
@@ -270,6 +295,7 @@ export function ProjectPlannerBlock({
|
|
| 270 |
allocationPct={allocation.allocation_pct}
|
| 271 |
color={project.color}
|
| 272 |
dayDates={dayDates}
|
|
|
|
| 273 |
fixedPerson={person}
|
| 274 |
isSaving={isSavingAllocation}
|
| 275 |
onEditAllocation={() => onEditAllocation(allocation)}
|
|
@@ -294,6 +320,7 @@ export function ProjectPlannerBlock({
|
|
| 294 |
candidatePeople={candidatePeople}
|
| 295 |
color={project.color}
|
| 296 |
dayDates={dayDates}
|
|
|
|
| 297 |
isSaving={isSavingAllocation}
|
| 298 |
onSave={onCreateAllocation}
|
| 299 |
/>
|
|
|
|
| 6 |
import type { AllocationDragDraft } from "./AllocationCreatePopover";
|
| 7 |
import { ProjectCalendarTrack } from "./ProjectCalendarTrack";
|
| 8 |
import { ProjectRowMenu } from "./ProjectRowMenu";
|
| 9 |
+
import { EffortDisplaySelect } from "./EffortDisplaySelect";
|
| 10 |
import {
|
| 11 |
+
allocatedDaysInRange,
|
| 12 |
allocatedHoursInRange,
|
| 13 |
+
formatDaysAmount,
|
| 14 |
formatHoursAmount,
|
| 15 |
roundToHalfHour,
|
| 16 |
visibleWeekdayCount,
|
| 17 |
+
type EffortDisplayMode,
|
| 18 |
} from "../../planner/allocationTimeline";
|
| 19 |
import type { Allocation, Person, Project } from "../../types";
|
| 20 |
|
|
|
|
| 47 |
onArchive: () => void;
|
| 48 |
isSavingAllocation?: boolean;
|
| 49 |
isSavingPlanner?: boolean;
|
| 50 |
+
effortMode: EffortDisplayMode;
|
| 51 |
+
onEffortModeChange: (mode: EffortDisplayMode) => void;
|
| 52 |
onRestore?: () => void;
|
| 53 |
}
|
| 54 |
|
|
|
|
| 74 |
onArchive,
|
| 75 |
isSavingAllocation = false,
|
| 76 |
isSavingPlanner = false,
|
| 77 |
+
effortMode,
|
| 78 |
+
onEffortModeChange,
|
| 79 |
onRestore,
|
| 80 |
}: ProjectPlannerBlockProps) {
|
| 81 |
const showPhasesMilestones = plannerView === "phases_milestones";
|
|
|
|
| 97 |
[dayDates, project.start_date, project.end_date],
|
| 98 |
);
|
| 99 |
|
| 100 |
+
const { allocatedHours, capacityHours, allocatedDays, capacityDays, developerCount } = useMemo(() => {
|
| 101 |
+
let allocatedH = 0;
|
| 102 |
+
let allocatedD = 0;
|
| 103 |
const seen = new Set<number>();
|
| 104 |
for (const allocation of allocations) {
|
| 105 |
const person = peopleById.get(allocation.person_id);
|
| 106 |
if (!person) continue;
|
| 107 |
seen.add(person.id);
|
| 108 |
const days = visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date);
|
| 109 |
+
allocatedH += allocatedHoursInRange(allocation.allocation_pct, person.weekly_capacity_hrs, days);
|
| 110 |
+
allocatedD += allocatedDaysInRange(allocation.allocation_pct, days);
|
| 111 |
}
|
| 112 |
+
const capacityH = Array.from(seen).reduce((sum, personId) => {
|
| 113 |
const person = peopleById.get(personId);
|
| 114 |
return sum + (person ? roundToHalfHour(person.weekly_capacity_hrs / 5) * weekdayCount : 0);
|
| 115 |
}, 0);
|
| 116 |
+
const capacityD = seen.size * weekdayCount;
|
| 117 |
+
return {
|
| 118 |
+
allocatedHours: allocatedH,
|
| 119 |
+
capacityHours: capacityH,
|
| 120 |
+
allocatedDays: allocatedD,
|
| 121 |
+
capacityDays: capacityD,
|
| 122 |
+
developerCount: seen.size,
|
| 123 |
+
};
|
| 124 |
}, [allocations, peopleById, dayDates, weekdayCount]);
|
| 125 |
|
| 126 |
+
const effortPct =
|
| 127 |
+
effortMode === "days"
|
| 128 |
+
? capacityDays > 0
|
| 129 |
+
? Math.min(100, (allocatedDays / capacityDays) * 100)
|
| 130 |
+
: 0
|
| 131 |
+
: capacityHours > 0
|
| 132 |
+
? Math.min(100, (allocatedHours / capacityHours) * 100)
|
| 133 |
+
: 0;
|
| 134 |
+
|
| 135 |
+
const effortSummary =
|
| 136 |
+
developerCount === 0
|
| 137 |
+
? "No developers assigned"
|
| 138 |
+
: effortMode === "days"
|
| 139 |
+
? capacityDays > 0
|
| 140 |
+
? `${formatDaysAmount(allocatedDays)} / ${formatDaysAmount(capacityDays)}`
|
| 141 |
+
: `${formatDaysAmount(allocatedDays)} allocated`
|
| 142 |
+
: capacityHours > 0
|
| 143 |
+
? `${formatHoursAmount(allocatedHours)} / ${formatHoursAmount(capacityHours)}`
|
| 144 |
+
: `${formatHoursAmount(allocatedHours)} allocated`;
|
| 145 |
|
| 146 |
const roleGroups = useMemo(() => {
|
| 147 |
const map = new Map<string, { roleName: string; allocations: Allocation[] }>();
|
|
|
|
| 238 |
) : null}
|
| 239 |
|
| 240 |
<div className="planner-row planner-project-subrow planner-hours-row">
|
| 241 |
+
<div className="planner-row-label nested planner-effort-label">
|
| 242 |
+
<EffortDisplaySelect onChange={onEffortModeChange} value={effortMode} />
|
| 243 |
+
<span className={`planner-effort-total${effortPct > 100 ? " is-over" : ""}`}>{effortSummary}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
<div className="planner-row-track">
|
| 246 |
<div className="planner-hours-track">
|
| 247 |
+
<div className="planner-hours-bar" style={{ width: `${effortPct}%` }} />
|
| 248 |
</div>
|
| 249 |
</div>
|
| 250 |
</div>
|
|
|
|
| 269 |
candidatePeople={candidatePeople}
|
| 270 |
color={project.color}
|
| 271 |
dayDates={dayDates}
|
| 272 |
+
effortMode={effortMode}
|
| 273 |
isSaving={isSavingAllocation}
|
| 274 |
onSave={onCreateAllocation}
|
| 275 |
/>
|
|
|
|
| 295 |
allocationPct={allocation.allocation_pct}
|
| 296 |
color={project.color}
|
| 297 |
dayDates={dayDates}
|
| 298 |
+
effortMode={effortMode}
|
| 299 |
fixedPerson={person}
|
| 300 |
isSaving={isSavingAllocation}
|
| 301 |
onEditAllocation={() => onEditAllocation(allocation)}
|
|
|
|
| 320 |
candidatePeople={candidatePeople}
|
| 321 |
color={project.color}
|
| 322 |
dayDates={dayDates}
|
| 323 |
+
effortMode={effortMode}
|
| 324 |
isSaving={isSavingAllocation}
|
| 325 |
onSave={onCreateAllocation}
|
| 326 |
/>
|
frontend/src/pages/Projects.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
| 2 |
import { addWeeks, format, startOfWeek } from "date-fns";
|
| 3 |
-
import { useCallback, useMemo, useState, type CSSProperties, type FormEvent } from "react";
|
| 4 |
|
| 5 |
import { createAllocation } from "../api/allocations";
|
| 6 |
import {
|
|
@@ -21,6 +21,7 @@ import { EmptyState } from "../components/ui/EmptyState";
|
|
| 21 |
import { Modal } from "../components/ui/Modal";
|
| 22 |
import { toastFromError, useToast } from "../components/ui/Toast";
|
| 23 |
import { useAllocations, usePeople, useProjects, useTeams } from "../hooks/usePortalData";
|
|
|
|
| 24 |
import { overlapsVisibleRange } from "../planner/projectTimeline";
|
| 25 |
import { buildWeekdayRange, isToday, monthMarkersForDays } from "../planner/timeline";
|
| 26 |
import type { Allocation, Milestone, Person, Project, ProjectInput } from "../types";
|
|
@@ -94,6 +95,14 @@ export function ProjectsPage() {
|
|
| 94 |
const [memberModalProject, setMemberModalProject] = useState<Project | null>(null);
|
| 95 |
const [editingAllocation, setEditingAllocation] = useState<Allocation | null>(null);
|
| 96 |
const [plannerView, setPlannerView] = useState<PlannerViewMode>("phases_milestones");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
const rangeWeeks = RANGE_OPTIONS.find((option) => option.value === range)?.weeks ?? 5;
|
| 99 |
const startDate = anchor;
|
|
@@ -528,8 +537,10 @@ export function ProjectsPage() {
|
|
| 528 |
<ProjectPlannerBlock
|
| 529 |
allocations={projectAllocations}
|
| 530 |
dayDates={plannerDays}
|
|
|
|
| 531 |
expanded={expanded}
|
| 532 |
isSavingAllocation={createAllocationMutation.isPending}
|
|
|
|
| 533 |
isSavingPlanner={plannerSaveMutation.isPending}
|
| 534 |
key={project.id}
|
| 535 |
onAddPerson={() => {
|
|
|
|
| 1 |
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
| 2 |
import { addWeeks, format, startOfWeek } from "date-fns";
|
| 3 |
+
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
|
| 4 |
|
| 5 |
import { createAllocation } from "../api/allocations";
|
| 6 |
import {
|
|
|
|
| 21 |
import { Modal } from "../components/ui/Modal";
|
| 22 |
import { toastFromError, useToast } from "../components/ui/Toast";
|
| 23 |
import { useAllocations, usePeople, useProjects, useTeams } from "../hooks/usePortalData";
|
| 24 |
+
import type { EffortDisplayMode } from "../planner/allocationTimeline";
|
| 25 |
import { overlapsVisibleRange } from "../planner/projectTimeline";
|
| 26 |
import { buildWeekdayRange, isToday, monthMarkersForDays } from "../planner/timeline";
|
| 27 |
import type { Allocation, Milestone, Person, Project, ProjectInput } from "../types";
|
|
|
|
| 95 |
const [memberModalProject, setMemberModalProject] = useState<Project | null>(null);
|
| 96 |
const [editingAllocation, setEditingAllocation] = useState<Allocation | null>(null);
|
| 97 |
const [plannerView, setPlannerView] = useState<PlannerViewMode>("phases_milestones");
|
| 98 |
+
const [effortMode, setEffortMode] = useState<EffortDisplayMode>(() => {
|
| 99 |
+
const saved = localStorage.getItem("projects-effort-mode");
|
| 100 |
+
return saved === "days" ? "days" : "hours";
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
useEffect(() => {
|
| 104 |
+
localStorage.setItem("projects-effort-mode", effortMode);
|
| 105 |
+
}, [effortMode]);
|
| 106 |
|
| 107 |
const rangeWeeks = RANGE_OPTIONS.find((option) => option.value === range)?.weeks ?? 5;
|
| 108 |
const startDate = anchor;
|
|
|
|
| 537 |
<ProjectPlannerBlock
|
| 538 |
allocations={projectAllocations}
|
| 539 |
dayDates={plannerDays}
|
| 540 |
+
effortMode={effortMode}
|
| 541 |
expanded={expanded}
|
| 542 |
isSavingAllocation={createAllocationMutation.isPending}
|
| 543 |
+
onEffortModeChange={setEffortMode}
|
| 544 |
isSavingPlanner={plannerSaveMutation.isPending}
|
| 545 |
key={project.id}
|
| 546 |
onAddPerson={() => {
|
frontend/src/planner/allocationTimeline.ts
CHANGED
|
@@ -76,6 +76,30 @@ export function formatHoursPerDay(allocationPct: number, weeklyCapacityHrs: numb
|
|
| 76 |
return `${formatHoursAmount(daily)}/day`;
|
| 77 |
}
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
export function visibleWeekdayCount(dayDates: Date[], startDate: string, endDate: string): number {
|
| 80 |
return dayDates.filter((day) => {
|
| 81 |
const key = format(day, "yyyy-MM-dd");
|
|
|
|
| 76 |
return `${formatHoursAmount(daily)}/day`;
|
| 77 |
}
|
| 78 |
|
| 79 |
+
export type EffortDisplayMode = "hours" | "days";
|
| 80 |
+
|
| 81 |
+
export function formatDaysAmount(days: number): string {
|
| 82 |
+
return `${Math.round(days)}d`;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
export function allocatedDaysInRange(allocationPct: number, weekdayCount: number): number {
|
| 86 |
+
if (weekdayCount <= 0) return 0;
|
| 87 |
+
return Math.round((weekdayCount * allocationPct) / 100);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export function formatAllocationEffort(
|
| 91 |
+
mode: EffortDisplayMode,
|
| 92 |
+
allocationPct: number,
|
| 93 |
+
weeklyCapacityHrs: number,
|
| 94 |
+
weekdayCount: number,
|
| 95 |
+
): string {
|
| 96 |
+
if (mode === "days") {
|
| 97 |
+
const days = allocatedDaysInRange(allocationPct, weekdayCount);
|
| 98 |
+
return days > 0 ? formatDaysAmount(days) : "0d";
|
| 99 |
+
}
|
| 100 |
+
return formatHoursPerDay(allocationPct, weeklyCapacityHrs);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
export function visibleWeekdayCount(dayDates: Date[], startDate: string, endDate: string): number {
|
| 104 |
return dayDates.filter((day) => {
|
| 105 |
const key = format(day, "yyyy-MM-dd");
|
frontend/src/styles.css
CHANGED
|
@@ -2202,6 +2202,36 @@ th {
|
|
| 2202 |
width: 18px;
|
| 2203 |
}
|
| 2204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2205 |
.planner-hours-track {
|
| 2206 |
align-self: center;
|
| 2207 |
background: #ece9f5;
|
|
|
|
| 2202 |
width: 18px;
|
| 2203 |
}
|
| 2204 |
|
| 2205 |
+
.planner-effort-label {
|
| 2206 |
+
align-items: center;
|
| 2207 |
+
display: flex;
|
| 2208 |
+
flex-wrap: wrap;
|
| 2209 |
+
gap: 8px 10px;
|
| 2210 |
+
}
|
| 2211 |
+
|
| 2212 |
+
.effort-display-select {
|
| 2213 |
+
background: #fff;
|
| 2214 |
+
border: 2px solid #2563eb;
|
| 2215 |
+
border-radius: 8px;
|
| 2216 |
+
color: #1e40af;
|
| 2217 |
+
cursor: pointer;
|
| 2218 |
+
font-size: 13px;
|
| 2219 |
+
font-weight: 700;
|
| 2220 |
+
min-width: 72px;
|
| 2221 |
+
padding: 4px 8px;
|
| 2222 |
+
}
|
| 2223 |
+
|
| 2224 |
+
.planner-effort-total {
|
| 2225 |
+
color: var(--muted);
|
| 2226 |
+
font-size: 12px;
|
| 2227 |
+
font-weight: 700;
|
| 2228 |
+
margin-left: auto;
|
| 2229 |
+
}
|
| 2230 |
+
|
| 2231 |
+
.planner-effort-total.is-over {
|
| 2232 |
+
color: #dc2626;
|
| 2233 |
+
}
|
| 2234 |
+
|
| 2235 |
.planner-hours-track {
|
| 2236 |
align-self: center;
|
| 2237 |
background: #ece9f5;
|