Gowrisankar Cursor commited on
Commit
872046b
·
1 Parent(s): 20df2a6

Align Projects planner with Runn: thin timeline, phases, and row menus.

Browse files

Adds project phases API, milestone/phase popovers on calendar click, per-project kebab menu with end chevron, Phases & Milestones view, and New Project only at the list bottom.

Co-authored-by: Cursor <cursoragent@cursor.com>

backend/alembic/versions/0005_project_phases.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from alembic import op
2
+ import sqlalchemy as sa
3
+ from sqlalchemy import inspect
4
+
5
+ revision = "0005_project_phases"
6
+ down_revision = "0004_drop_project_client_pricing"
7
+ branch_labels = None
8
+ depends_on = None
9
+
10
+
11
+ def upgrade() -> None:
12
+ bind = op.get_bind()
13
+ if "project_phases" in inspect(bind).get_table_names():
14
+ return
15
+ op.create_table(
16
+ "project_phases",
17
+ sa.Column("id", sa.Integer(), primary_key=True),
18
+ sa.Column("project_id", sa.Integer(), sa.ForeignKey("projects.id"), nullable=False),
19
+ sa.Column("name", sa.String(), nullable=False),
20
+ sa.Column("start_date", sa.Date(), nullable=False),
21
+ sa.Column("end_date", sa.Date(), nullable=False),
22
+ sa.Column("color", sa.String(), nullable=False, server_default="#14b8a6"),
23
+ )
24
+ op.create_index("ix_project_phases_id", "project_phases", ["id"])
25
+ op.create_index("ix_project_phases_project_id", "project_phases", ["project_id"])
26
+
27
+
28
+ def downgrade() -> None:
29
+ bind = op.get_bind()
30
+ if "project_phases" not in inspect(bind).get_table_names():
31
+ return
32
+ op.drop_index("ix_project_phases_project_id", table_name="project_phases")
33
+ op.drop_index("ix_project_phases_id", table_name="project_phases")
34
+ op.drop_table("project_phases")
backend/models.py CHANGED
@@ -139,10 +139,24 @@ class Project(Base):
139
  owner: Mapped[Person | None] = relationship(back_populates="owned_projects")
140
  team: Mapped["Team | None"] = relationship()
141
  milestones: Mapped[list["Milestone"]] = relationship(back_populates="project", cascade="all, delete-orphan")
 
142
  allocations: Mapped[list["Allocation"]] = relationship(back_populates="project")
143
  tags: Mapped[list[Tag]] = relationship(secondary=project_tags, lazy="selectin")
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  class Milestone(Base):
147
  __tablename__ = "milestones"
148
 
 
139
  owner: Mapped[Person | None] = relationship(back_populates="owned_projects")
140
  team: Mapped["Team | None"] = relationship()
141
  milestones: Mapped[list["Milestone"]] = relationship(back_populates="project", cascade="all, delete-orphan")
142
+ phases: Mapped[list["ProjectPhase"]] = relationship(back_populates="project", cascade="all, delete-orphan")
143
  allocations: Mapped[list["Allocation"]] = relationship(back_populates="project")
144
  tags: Mapped[list[Tag]] = relationship(secondary=project_tags, lazy="selectin")
145
 
146
 
147
+ class ProjectPhase(Base):
148
+ __tablename__ = "project_phases"
149
+
150
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
151
+ project_id: Mapped[int] = mapped_column(ForeignKey("projects.id"), nullable=False)
152
+ name: Mapped[str] = mapped_column(String, nullable=False)
153
+ start_date: Mapped[date] = mapped_column(Date, nullable=False)
154
+ end_date: Mapped[date] = mapped_column(Date, nullable=False)
155
+ color: Mapped[str] = mapped_column(String, default="#14b8a6", nullable=False)
156
+
157
+ project: Mapped[Project] = relationship(back_populates="phases")
158
+
159
+
160
  class Milestone(Base):
161
  __tablename__ = "milestones"
162
 
backend/routers/projects.py CHANGED
@@ -4,8 +4,17 @@ from sqlalchemy.orm import Session, selectinload
4
 
5
  from auth import get_current_user, require_manager
6
  from database import get_db
7
- from models import Milestone, Project, Tag
8
- from schemas import MilestoneCreate, MilestoneRead, MilestoneUpdate, ProjectCreate, ProjectRead, ProjectUpdate
 
 
 
 
 
 
 
 
 
9
 
10
  router = APIRouter(dependencies=[Depends(get_current_user)])
11
 
@@ -33,7 +42,10 @@ def list_projects(
33
  include_archived: bool = False,
34
  db: Session = Depends(get_db),
35
  ) -> list[Project]:
36
- query = select(Project).options(selectinload(Project.milestones))
 
 
 
37
  if not include_archived:
38
  query = query.where(Project.is_active.is_(True))
39
  if status:
@@ -120,3 +132,32 @@ def update_milestone(
120
  db.commit()
121
  db.refresh(milestone)
122
  return milestone
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  from auth import get_current_user, require_manager
6
  from database import get_db
7
+ from models import Milestone, Project, ProjectPhase, Tag
8
+ from schemas import (
9
+ MilestoneCreate,
10
+ MilestoneRead,
11
+ MilestoneUpdate,
12
+ ProjectCreate,
13
+ ProjectPhaseCreate,
14
+ ProjectPhaseRead,
15
+ ProjectRead,
16
+ ProjectUpdate,
17
+ )
18
 
19
  router = APIRouter(dependencies=[Depends(get_current_user)])
20
 
 
42
  include_archived: bool = False,
43
  db: Session = Depends(get_db),
44
  ) -> list[Project]:
45
+ query = select(Project).options(
46
+ selectinload(Project.milestones),
47
+ selectinload(Project.phases),
48
+ )
49
  if not include_archived:
50
  query = query.where(Project.is_active.is_(True))
51
  if status:
 
132
  db.commit()
133
  db.refresh(milestone)
134
  return milestone
135
+
136
+
137
+ @router.post(
138
+ "/projects/{project_id}/phases",
139
+ response_model=ProjectPhaseRead,
140
+ dependencies=[Depends(require_manager)],
141
+ )
142
+ def create_phase(project_id: int, payload: ProjectPhaseCreate, db: Session = Depends(get_db)) -> ProjectPhase:
143
+ _project_or_404(db, project_id)
144
+ if payload.start_date > payload.end_date:
145
+ raise HTTPException(status_code=400, detail="Phase start date must be before end date")
146
+ phase = ProjectPhase(project_id=project_id, **payload.model_dump())
147
+ db.add(phase)
148
+ db.commit()
149
+ db.refresh(phase)
150
+ return phase
151
+
152
+
153
+ @router.delete(
154
+ "/projects/{project_id}/phases/{phase_id}",
155
+ dependencies=[Depends(require_manager)],
156
+ )
157
+ def delete_phase(project_id: int, phase_id: int, db: Session = Depends(get_db)) -> dict:
158
+ phase = db.get(ProjectPhase, phase_id)
159
+ if not phase or phase.project_id != project_id:
160
+ raise HTTPException(status_code=404, detail="Phase not found")
161
+ db.delete(phase)
162
+ db.commit()
163
+ return {"deleted": True}
backend/schemas.py CHANGED
@@ -167,6 +167,24 @@ class MilestoneRead(MilestoneBase):
167
  project_id: int
168
 
169
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  class ProjectBase(BaseModel):
171
  name: str
172
  description: str | None = None
@@ -205,6 +223,7 @@ class ProjectRead(ProjectBase):
205
  is_active: bool
206
  created_at: datetime
207
  milestones: list[MilestoneRead] = []
 
208
  tags: list[TagRead] = []
209
 
210
 
 
167
  project_id: int
168
 
169
 
170
+ class ProjectPhaseBase(BaseModel):
171
+ name: str
172
+ start_date: date
173
+ end_date: date
174
+ color: str = "#14b8a6"
175
+
176
+
177
+ class ProjectPhaseCreate(ProjectPhaseBase):
178
+ pass
179
+
180
+
181
+ class ProjectPhaseRead(ProjectPhaseBase):
182
+ model_config = ConfigDict(from_attributes=True)
183
+
184
+ id: int
185
+ project_id: int
186
+
187
+
188
  class ProjectBase(BaseModel):
189
  name: str
190
  description: str | None = None
 
223
  is_active: bool
224
  created_at: datetime
225
  milestones: list[MilestoneRead] = []
226
+ phases: list[ProjectPhaseRead] = []
227
  tags: list[TagRead] = []
228
 
229
 
frontend/src/api/projects.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { api } from "./client";
2
- import type { Milestone, MilestoneInput, Project, ProjectInput } from "../types";
3
 
4
  export const listProjects = async (includeArchived = false) =>
5
  (
@@ -29,3 +29,8 @@ export const updateMilestone = async (
29
  input: Partial<MilestoneInput>,
30
  ) =>
31
  (await api.patch<Milestone>(`/projects/${projectId}/milestones/${milestoneId}`, input)).data;
 
 
 
 
 
 
1
  import { api } from "./client";
2
+ import type { Milestone, MilestoneInput, Project, ProjectInput, ProjectPhase } from "../types";
3
 
4
  export const listProjects = async (includeArchived = false) =>
5
  (
 
29
  input: Partial<MilestoneInput>,
30
  ) =>
31
  (await api.patch<Milestone>(`/projects/${projectId}/milestones/${milestoneId}`, input)).data;
32
+
33
+ export const createPhase = async (
34
+ projectId: number,
35
+ input: { name: string; start_date: string; end_date: string; color?: string },
36
+ ) => (await api.post<ProjectPhase>(`/projects/${projectId}/phases`, input)).data;
frontend/src/components/projects/ProjectCalendarTrack.tsx ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { format, parseISO } from "date-fns";
2
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
3
+ import { createPortal } from "react-dom";
4
+
5
+ import {
6
+ buildAllocationBarSegment,
7
+ dateStringsFromDayIndices,
8
+ dayIndexFromPointer,
9
+ projectBarStyle,
10
+ } from "../../planner/allocationTimeline";
11
+ import type { Milestone, Project, ProjectPhase } from "../../types";
12
+
13
+ type TrackMode = "project-line" | "phases";
14
+
15
+ interface ProjectCalendarTrackProps {
16
+ mode: TrackMode;
17
+ project: Project;
18
+ dayDates: Date[];
19
+ showTentative: boolean;
20
+ enableMilestones?: boolean;
21
+ enablePhaseDrag?: boolean;
22
+ onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
23
+ onCreatePhase: (input: { name: string; start_date: string; end_date: string }) => void;
24
+ isSaving?: boolean;
25
+ }
26
+
27
+ export function ProjectCalendarTrack({
28
+ mode,
29
+ project,
30
+ dayDates,
31
+ showTentative,
32
+ enableMilestones = false,
33
+ enablePhaseDrag = false,
34
+ onCreateMilestone,
35
+ onCreatePhase,
36
+ isSaving = false,
37
+ }: ProjectCalendarTrackProps) {
38
+ const trackRef = useRef<HTMLDivElement>(null);
39
+ const [dragRange, setDragRange] = useState<{ start: number; end: number } | null>(null);
40
+ const [popover, setPopover] = useState<
41
+ | { type: "milestone"; dueDate: string; anchor: { top: number; left: number } }
42
+ | { type: "phase"; startDate: string; endDate: string; anchor: { top: number; left: number } }
43
+ | null
44
+ >(null);
45
+ const [milestoneName, setMilestoneName] = useState("");
46
+ const [milestoneNote, setMilestoneNote] = useState("");
47
+ const [phaseName, setPhaseName] = useState("Phase");
48
+
49
+ const totalDays = dayDates.length;
50
+ const lineSegment =
51
+ mode === "project-line"
52
+ ? buildAllocationBarSegment(dayDates, project.start_date, project.end_date)
53
+ : null;
54
+
55
+ const previewSegment =
56
+ dragRange && totalDays > 0
57
+ ? {
58
+ startIndex: Math.min(dragRange.start, dragRange.end),
59
+ endIndex: Math.max(dragRange.start, dragRange.end),
60
+ }
61
+ : null;
62
+
63
+ const openMilestoneAt = (index: number, clientX: number, clientY: number) => {
64
+ const dueDate = format(dayDates[index], "yyyy-MM-dd");
65
+ setMilestoneName("");
66
+ setMilestoneNote("");
67
+ setPopover({ type: "milestone", dueDate, anchor: { top: clientY + 8, left: clientX - 120 } });
68
+ };
69
+
70
+ const finishDrag = useCallback(
71
+ (clientX: number, clientY: number) => {
72
+ if (!dragRange) return;
73
+ const { start_date, end_date } = dateStringsFromDayIndices(dayDates, dragRange.start, dragRange.end);
74
+ setDragRange(null);
75
+ setPhaseName("Phase");
76
+ setPopover({
77
+ type: "phase",
78
+ startDate: start_date,
79
+ endDate: end_date,
80
+ anchor: { top: clientY + 8, left: clientX - 120 },
81
+ });
82
+ },
83
+ [dragRange, dayDates],
84
+ );
85
+
86
+ const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
87
+ if (!trackRef.current || event.button !== 0) return;
88
+ if ((event.target as HTMLElement).closest(".milestone-pin, .phase-bar")) return;
89
+ const index = dayIndexFromPointer(event.clientX, trackRef.current.getBoundingClientRect(), totalDays);
90
+
91
+ if (enablePhaseDrag) {
92
+ setDragRange({ start: index, end: index });
93
+ trackRef.current.setPointerCapture(event.pointerId);
94
+ return;
95
+ }
96
+
97
+ if (enableMilestones && mode === "project-line" && !dragRange) {
98
+ openMilestoneAt(index, event.clientX, event.clientY);
99
+ }
100
+ };
101
+
102
+ const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
103
+ if (!dragRange || !trackRef.current) return;
104
+ const index = dayIndexFromPointer(event.clientX, trackRef.current.getBoundingClientRect(), totalDays);
105
+ setDragRange((prev) => (prev ? { ...prev, end: index } : null));
106
+ };
107
+
108
+ const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
109
+ if (!trackRef.current?.hasPointerCapture(event.pointerId)) return;
110
+ trackRef.current.releasePointerCapture(event.pointerId);
111
+ if (dragRange) finishDrag(event.clientX, event.clientY);
112
+ };
113
+
114
+ const phases = project.phases ?? [];
115
+ const milestones = project.milestones ?? [];
116
+
117
+ return (
118
+ <>
119
+ <div
120
+ className={`planner-timeline planner-calendar-track planner-calendar-${mode}${dragRange ? " is-dragging" : ""}`}
121
+ onPointerDown={onPointerDown}
122
+ onPointerMove={onPointerMove}
123
+ onPointerUp={onPointerUp}
124
+ ref={trackRef}
125
+ >
126
+ <div className="planner-day-grid" />
127
+ {lineSegment && mode === "project-line" && (!project.is_tentative || showTentative) ? (
128
+ <div
129
+ className={`project-span-line${project.is_tentative ? " tentative" : ""}`}
130
+ style={{
131
+ ...projectBarStyle(
132
+ {
133
+ startIndex: lineSegment.startIndex,
134
+ endIndex: lineSegment.endIndex,
135
+ className: "project-span-line",
136
+ label: "",
137
+ },
138
+ totalDays,
139
+ ),
140
+ background: project.color,
141
+ }}
142
+ title={project.name}
143
+ />
144
+ ) : null}
145
+
146
+ {mode === "phases"
147
+ ? phases.map((phase) => (
148
+ <PhaseBar key={phase.id} dayDates={dayDates} phase={phase} totalDays={totalDays} />
149
+ ))
150
+ : null}
151
+
152
+ {previewSegment && enablePhaseDrag ? (
153
+ <div
154
+ className="phase-bar preview"
155
+ style={{
156
+ ...projectBarStyle(
157
+ {
158
+ startIndex: previewSegment.startIndex,
159
+ endIndex: previewSegment.endIndex,
160
+ className: "phase-bar",
161
+ label: phaseName,
162
+ },
163
+ totalDays,
164
+ ),
165
+ }}
166
+ >
167
+ {phaseName}
168
+ </div>
169
+ ) : null}
170
+
171
+ {enableMilestones && mode === "project-line"
172
+ ? milestones.map((milestone) => (
173
+ <MilestonePin dayDates={dayDates} key={milestone.id} milestone={milestone} totalDays={totalDays} />
174
+ ))
175
+ : null}
176
+ </div>
177
+
178
+ {popover?.type === "milestone"
179
+ ? createPortal(
180
+ <QuickPopover
181
+ anchor={popover.anchor}
182
+ onCancel={() => setPopover(null)}
183
+ title={`Add milestone · ${format(parseISO(popover.dueDate), "d MMM yyyy")}`}
184
+ >
185
+ <label>
186
+ Name
187
+ <input
188
+ autoFocus
189
+ onChange={(event) => setMilestoneName(event.target.value)}
190
+ placeholder="Milestone name"
191
+ type="text"
192
+ value={milestoneName}
193
+ />
194
+ </label>
195
+ <label>
196
+ Note <small>(optional)</small>
197
+ <textarea
198
+ onChange={(event) => setMilestoneNote(event.target.value)}
199
+ rows={2}
200
+ value={milestoneNote}
201
+ />
202
+ </label>
203
+ <div className="quick-popover-actions">
204
+ <button className="secondary-button" onClick={() => setPopover(null)} type="button">
205
+ Cancel
206
+ </button>
207
+ <button
208
+ className="primary-button"
209
+ disabled={isSaving || !milestoneName.trim()}
210
+ onClick={() => {
211
+ onCreateMilestone(popover.dueDate, milestoneName.trim(), milestoneNote.trim() || undefined);
212
+ setPopover(null);
213
+ }}
214
+ type="button"
215
+ >
216
+ Create
217
+ </button>
218
+ </div>
219
+ </QuickPopover>,
220
+ document.body,
221
+ )
222
+ : null}
223
+
224
+ {popover?.type === "phase"
225
+ ? createPortal(
226
+ <QuickPopover anchor={popover.anchor} onCancel={() => setPopover(null)} title="Add phase">
227
+ <label>
228
+ Phase name
229
+ <input
230
+ autoFocus
231
+ onChange={(event) => setPhaseName(event.target.value)}
232
+ type="text"
233
+ value={phaseName}
234
+ />
235
+ </label>
236
+ <p className="muted-text" style={{ margin: 0, fontSize: 12 }}>
237
+ {format(parseISO(popover.startDate), "d MMM")} – {format(parseISO(popover.endDate), "d MMM yyyy")}
238
+ </p>
239
+ <div className="quick-popover-actions">
240
+ <button className="secondary-button" onClick={() => setPopover(null)} type="button">
241
+ Cancel
242
+ </button>
243
+ <button
244
+ className="primary-button"
245
+ disabled={isSaving || !phaseName.trim()}
246
+ onClick={() => {
247
+ onCreatePhase({
248
+ name: phaseName.trim(),
249
+ start_date: popover.startDate,
250
+ end_date: popover.endDate,
251
+ });
252
+ setPopover(null);
253
+ }}
254
+ type="button"
255
+ >
256
+ Save
257
+ </button>
258
+ </div>
259
+ </QuickPopover>,
260
+ document.body,
261
+ )
262
+ : null}
263
+ </>
264
+ );
265
+ }
266
+
267
+ function PhaseBar({
268
+ phase,
269
+ dayDates,
270
+ totalDays,
271
+ }: {
272
+ phase: ProjectPhase;
273
+ dayDates: Date[];
274
+ totalDays: number;
275
+ }) {
276
+ const segment = buildAllocationBarSegment(dayDates, phase.start_date, phase.end_date);
277
+ if (!segment) return null;
278
+ return (
279
+ <div
280
+ className="phase-bar"
281
+ style={{
282
+ ...projectBarStyle(segment, totalDays),
283
+ background: phase.color,
284
+ }}
285
+ title={phase.name}
286
+ >
287
+ {phase.name}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ function MilestonePin({
293
+ milestone,
294
+ dayDates,
295
+ totalDays,
296
+ }: {
297
+ milestone: Milestone;
298
+ dayDates: Date[];
299
+ totalDays: number;
300
+ }) {
301
+ const index = dayDates.findIndex((day) => format(day, "yyyy-MM-dd") === milestone.due_date);
302
+ if (index < 0) return null;
303
+ const left = ((index + 0.5) / totalDays) * 100;
304
+ return (
305
+ <span
306
+ className={`milestone-pin${milestone.is_completed ? " completed" : ""}`}
307
+ style={{ left: `${left}%` }}
308
+ title={`${milestone.name} · ${milestone.due_date}`}
309
+ />
310
+ );
311
+ }
312
+
313
+ function QuickPopover({
314
+ anchor,
315
+ title,
316
+ children,
317
+ onCancel,
318
+ }: {
319
+ anchor: { top: number; left: number };
320
+ title: string;
321
+ children: ReactNode;
322
+ onCancel: () => void;
323
+ }) {
324
+ useEffect(() => {
325
+ const onKey = (event: KeyboardEvent) => {
326
+ if (event.key === "Escape") onCancel();
327
+ };
328
+ window.addEventListener("keydown", onKey);
329
+ return () => window.removeEventListener("keydown", onKey);
330
+ }, [onCancel]);
331
+
332
+ return (
333
+ <div
334
+ className="allocation-drag-popover quick-popover"
335
+ style={{
336
+ top: Math.min(anchor.top, window.innerHeight - 280),
337
+ left: Math.min(Math.max(8, anchor.left), window.innerWidth - 280),
338
+ }}
339
+ >
340
+ <div className="allocation-drag-popover-header">
341
+ <strong>{title}</strong>
342
+ <button aria-label="Close" className="icon-button" onClick={onCancel} type="button">
343
+
344
+ </button>
345
+ </div>
346
+ <div className="allocation-drag-popover-body quick-popover-body">{children}</div>
347
+ </div>
348
+ );
349
+ }
frontend/src/components/projects/ProjectPlannerBlock.tsx CHANGED
@@ -1,9 +1,11 @@
 
1
  import { useMemo } from "react";
2
 
3
  import { Avatar } from "../ui/Avatar";
4
  import { AllocationDragTimeline } from "./AllocationDragTimeline";
5
  import type { AllocationDragDraft } from "./AllocationCreatePopover";
6
- import { ProjectTimelineRow } from "../planner/ProjectTimelineRow";
 
7
  import {
8
  allocatedHoursInRange,
9
  formatHoursAmount,
@@ -12,10 +14,13 @@ import {
12
  } from "../../planner/allocationTimeline";
13
  import type { Allocation, Person, Project } from "../../types";
14
 
 
 
15
  interface ProjectPlannerBlockProps {
16
  project: Project;
17
  teamLabel: string;
18
  dayDates: Date[];
 
19
  showTentative: boolean;
20
  expanded: boolean;
21
  allocations: Allocation[];
@@ -26,7 +31,13 @@ interface ProjectPlannerBlockProps {
26
  onAddPerson: () => void;
27
  onEditAllocation: (allocation: Allocation) => void;
28
  onCreateAllocation: (draft: AllocationDragDraft) => void;
 
 
 
 
 
29
  isSavingAllocation?: boolean;
 
30
  onRestore?: () => void;
31
  }
32
 
@@ -34,6 +45,7 @@ export function ProjectPlannerBlock({
34
  project,
35
  teamLabel,
36
  dayDates,
 
37
  showTentative,
38
  expanded,
39
  allocations,
@@ -44,9 +56,16 @@ export function ProjectPlannerBlock({
44
  onAddPerson,
45
  onEditAllocation,
46
  onCreateAllocation,
 
 
 
 
 
47
  isSavingAllocation = false,
 
48
  onRestore,
49
  }: ProjectPlannerBlockProps) {
 
50
  const assignedPersonIds = useMemo(
51
  () => [...new Set(allocations.map((item) => item.person_id))],
52
  [allocations],
@@ -59,6 +78,7 @@ export function ProjectPlannerBlock({
59
  : active;
60
  return teamFiltered.filter((person) => !assignedPersonIds.includes(person.id));
61
  }, [people, project.team_id, assignedPersonIds]);
 
62
  const weekdayCount = useMemo(
63
  () => visibleWeekdayCount(dayDates, project.start_date, project.end_date),
64
  [dayDates, project.start_date, project.end_date],
@@ -105,18 +125,6 @@ export function ProjectPlannerBlock({
105
  <div className={`planner-project-block ${expanded ? "is-expanded" : ""}`}>
106
  <div className={`planner-row planner-project-row ${project.is_active ? "" : "planner-row-archived"}`}>
107
  <div className="planner-row-label person planner-project-label">
108
- <button
109
- aria-expanded={expanded}
110
- aria-label={expanded ? "Collapse project" : "Expand project"}
111
- className="planner-expand-button"
112
- onClick={(event) => {
113
- event.stopPropagation();
114
- onToggleExpand();
115
- }}
116
- type="button"
117
- >
118
- <span className="caret">{expanded ? "▾" : "▸"}</span>
119
- </button>
120
  <button className="planner-project-name" onClick={onEditProject} type="button">
121
  <span className="project-swatch" style={{ background: project.color }} />
122
  <div className="person-meta">
@@ -136,35 +144,65 @@ export function ProjectPlannerBlock({
136
  <small>{teamLabel}</small>
137
  </div>
138
  </button>
139
- {!project.is_active && onRestore ? (
 
 
 
 
 
 
 
 
140
  <button
141
- className="ghost-button"
142
- onClick={(event) => {
143
- event.stopPropagation();
144
- onRestore();
145
- }}
146
  type="button"
147
  >
148
- Restore
149
  </button>
150
- ) : null}
151
  </div>
152
  <div className="planner-row-track">
153
- <ProjectTimelineRow
154
- color={project.color}
155
  dayDates={dayDates}
156
- endDate={project.end_date}
157
- name={project.name}
158
- onClick={onEditProject}
 
 
 
159
  showTentative={showTentative}
160
- startDate={project.start_date}
161
- tentative={project.is_tentative}
162
  />
163
  </div>
164
  </div>
165
 
166
  {expanded ? (
167
  <>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  <div className="planner-row planner-project-subrow planner-hours-row">
169
  <div className="planner-row-label nested">
170
  <span className="planner-sub-icon" aria-hidden>
 
1
+ import { ChevronRight } from "lucide-react";
2
  import { useMemo } from "react";
3
 
4
  import { Avatar } from "../ui/Avatar";
5
  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,
 
14
  } from "../../planner/allocationTimeline";
15
  import type { Allocation, Person, Project } from "../../types";
16
 
17
+ export type PlannerViewMode = "timeline" | "phases_milestones";
18
+
19
  interface ProjectPlannerBlockProps {
20
  project: Project;
21
  teamLabel: string;
22
  dayDates: Date[];
23
+ plannerView: PlannerViewMode;
24
  showTentative: boolean;
25
  expanded: boolean;
26
  allocations: Allocation[];
 
31
  onAddPerson: () => void;
32
  onEditAllocation: (allocation: Allocation) => void;
33
  onCreateAllocation: (draft: AllocationDragDraft) => void;
34
+ onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
35
+ onCreatePhase: (input: { name: string; start_date: string; end_date: string }) => void;
36
+ onToggleTentative: () => void;
37
+ onMilestones: () => void;
38
+ onArchive: () => void;
39
  isSavingAllocation?: boolean;
40
+ isSavingPlanner?: boolean;
41
  onRestore?: () => void;
42
  }
43
 
 
45
  project,
46
  teamLabel,
47
  dayDates,
48
+ plannerView,
49
  showTentative,
50
  expanded,
51
  allocations,
 
56
  onAddPerson,
57
  onEditAllocation,
58
  onCreateAllocation,
59
+ onCreateMilestone,
60
+ onCreatePhase,
61
+ onToggleTentative,
62
+ onMilestones,
63
+ onArchive,
64
  isSavingAllocation = false,
65
+ isSavingPlanner = false,
66
  onRestore,
67
  }: ProjectPlannerBlockProps) {
68
+ const showPhasesMilestones = plannerView === "phases_milestones";
69
  const assignedPersonIds = useMemo(
70
  () => [...new Set(allocations.map((item) => item.person_id))],
71
  [allocations],
 
78
  : active;
79
  return teamFiltered.filter((person) => !assignedPersonIds.includes(person.id));
80
  }, [people, project.team_id, assignedPersonIds]);
81
+
82
  const weekdayCount = useMemo(
83
  () => visibleWeekdayCount(dayDates, project.start_date, project.end_date),
84
  [dayDates, project.start_date, project.end_date],
 
125
  <div className={`planner-project-block ${expanded ? "is-expanded" : ""}`}>
126
  <div className={`planner-row planner-project-row ${project.is_active ? "" : "planner-row-archived"}`}>
127
  <div className="planner-row-label person planner-project-label">
 
 
 
 
 
 
 
 
 
 
 
 
128
  <button className="planner-project-name" onClick={onEditProject} type="button">
129
  <span className="project-swatch" style={{ background: project.color }} />
130
  <div className="person-meta">
 
144
  <small>{teamLabel}</small>
145
  </div>
146
  </button>
147
+ <div className="planner-project-actions">
148
+ <ProjectRowMenu
149
+ onArchive={onArchive}
150
+ onEditDetails={onEditProject}
151
+ onMilestones={onMilestones}
152
+ onRestore={onRestore}
153
+ onToggleTentative={onToggleTentative}
154
+ project={project}
155
+ />
156
  <button
157
+ aria-expanded={expanded}
158
+ aria-label={expanded ? "Collapse project" : "Expand project"}
159
+ className={`planner-expand-end${expanded ? " expanded" : ""}`}
160
+ onClick={onToggleExpand}
 
161
  type="button"
162
  >
163
+ <ChevronRight size={18} />
164
  </button>
165
+ </div>
166
  </div>
167
  <div className="planner-row-track">
168
+ <ProjectCalendarTrack
 
169
  dayDates={dayDates}
170
+ enableMilestones
171
+ isSaving={isSavingPlanner}
172
+ mode="project-line"
173
+ onCreateMilestone={onCreateMilestone}
174
+ onCreatePhase={onCreatePhase}
175
+ project={project}
176
  showTentative={showTentative}
 
 
177
  />
178
  </div>
179
  </div>
180
 
181
  {expanded ? (
182
  <>
183
+ {showPhasesMilestones ? (
184
+ <div className="planner-row planner-project-subrow planner-phases-row">
185
+ <div className="planner-row-label nested">
186
+ <span className="planner-sub-icon" aria-hidden>
187
+
188
+ </span>
189
+ <strong>Phases</strong>
190
+ </div>
191
+ <div className="planner-row-track">
192
+ <ProjectCalendarTrack
193
+ dayDates={dayDates}
194
+ enablePhaseDrag
195
+ isSaving={isSavingPlanner}
196
+ mode="phases"
197
+ onCreateMilestone={onCreateMilestone}
198
+ onCreatePhase={onCreatePhase}
199
+ project={project}
200
+ showTentative={showTentative}
201
+ />
202
+ </div>
203
+ </div>
204
+ ) : null}
205
+
206
  <div className="planner-row planner-project-subrow planner-hours-row">
207
  <div className="planner-row-label nested">
208
  <span className="planner-sub-icon" aria-hidden>
frontend/src/components/projects/ProjectRowMenu.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Archive, Copy, Flag, Pencil, Star } from "lucide-react";
2
+
3
+ import { RowMenu, type RowMenuItem } from "../../pages/manage/_shared";
4
+ import type { Project } from "../../types";
5
+
6
+ interface ProjectRowMenuProps {
7
+ project: Project;
8
+ onEditDetails: () => void;
9
+ onMilestones: () => void;
10
+ onToggleTentative: () => void;
11
+ onArchive: () => void;
12
+ onRestore?: () => void;
13
+ }
14
+
15
+ export function ProjectRowMenu({
16
+ project,
17
+ onEditDetails,
18
+ onMilestones,
19
+ onToggleTentative,
20
+ onArchive,
21
+ onRestore,
22
+ }: ProjectRowMenuProps) {
23
+ const items: RowMenuItem[] = [
24
+ { label: "Edit Details", icon: Pencil, onClick: onEditDetails },
25
+ { label: "Milestones", icon: Flag, onClick: onMilestones },
26
+ {
27
+ label: project.is_tentative ? "Set Confirmed" : "Set Tentative",
28
+ icon: Star,
29
+ onClick: onToggleTentative,
30
+ },
31
+ {
32
+ label: "Duplicate",
33
+ icon: Copy,
34
+ onClick: () => window.alert("Duplicate is not available yet."),
35
+ },
36
+ project.is_active
37
+ ? { label: "Archive", icon: Archive, danger: true, onClick: onArchive }
38
+ : { label: "Restore", icon: Archive, onClick: onRestore ?? (() => undefined) },
39
+ ];
40
+
41
+ return <RowMenu items={items} />;
42
+ }
frontend/src/pages/Projects.tsx CHANGED
@@ -6,6 +6,7 @@ import { createAllocation } from "../api/allocations";
6
  import {
7
  archiveProject,
8
  createMilestone,
 
9
  createProject,
10
  restoreProject,
11
  updateMilestone,
@@ -15,7 +16,7 @@ import type { AllocationDragDraft } from "../components/projects/AllocationCreat
15
  import { SelectorDropdown, Toggle } from "../components/planner/PlannerControls";
16
  import { AddProjectMemberModal } from "../components/projects/AddProjectMemberModal";
17
  import { ProjectDrawer } from "../components/projects/ProjectDrawer";
18
- import { ProjectPlannerBlock } from "../components/projects/ProjectPlannerBlock";
19
  import { EmptyState } from "../components/ui/EmptyState";
20
  import { Modal } from "../components/ui/Modal";
21
  import { toastFromError, useToast } from "../components/ui/Toast";
@@ -92,6 +93,7 @@ export function ProjectsPage() {
92
  const [expandedProjectIds, setExpandedProjectIds] = useState<Set<number>>(new Set());
93
  const [memberModalProject, setMemberModalProject] = useState<Project | null>(null);
94
  const [editingAllocation, setEditingAllocation] = useState<Allocation | null>(null);
 
95
 
96
  const rangeWeeks = RANGE_OPTIONS.find((option) => option.value === range)?.weeks ?? 5;
97
  const startDate = anchor;
@@ -228,6 +230,45 @@ export function ProjectsPage() {
228
  });
229
  };
230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  const openCreate = () => {
232
  setEditingId(null);
233
  setForm({ ...emptyForm, color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)] });
@@ -303,9 +344,12 @@ export function ProjectsPage() {
303
  <div className="planner-controls-row">
304
  <div className="planner-controls-left">
305
  <SelectorDropdown
306
- value="timeline"
307
- options={[{ value: "timeline", label: "Project Timeline" }]}
308
- onChange={() => undefined}
 
 
 
309
  />
310
  <SelectorDropdown
311
  value={groupBy}
@@ -410,10 +454,7 @@ export function ProjectsPage() {
410
  <article className="planner">
411
  <div className="planner-sync" style={plannerColsStyle}>
412
  <div className="planner-grid-header">
413
- <div className="planner-sidebar-head">
414
- <button className="primary-button add-button" onClick={openCreate} type="button">
415
- <span className="plus">+</span> New
416
- </button>
417
  <span className="planner-stat">
418
  <span className="dot" /> {sorted.length} {sorted.length === 1 ? "Project" : "Projects"}
419
  </span>
@@ -468,19 +509,39 @@ export function ProjectsPage() {
468
  allocations={projectAllocations}
469
  dayDates={plannerDays}
470
  expanded={expanded}
 
 
471
  key={project.id}
472
  onAddPerson={() => {
473
  setEditingAllocation(null);
474
  setMemberModalProject(project);
475
  }}
 
 
 
 
 
476
  onCreateAllocation={(draft) => handleCreateAllocation(project.id, draft)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  onEditAllocation={(allocation) => {
478
  setEditingAllocation(allocation);
479
  setMemberModalProject(project);
480
  }}
481
  onEditProject={() => openEdit(project)}
482
- isSavingAllocation={createAllocationMutation.isPending}
483
- people={people}
484
  onRestore={
485
  !project.is_active
486
  ? () => restoreMutation.mutate(project.id)
@@ -494,7 +555,15 @@ export function ProjectsPage() {
494
  return next;
495
  });
496
  }}
 
 
 
 
 
 
 
497
  peopleById={peopleById}
 
498
  project={project}
499
  showTentative={showTentative}
500
  teamLabel={teamLabel}
@@ -505,11 +574,11 @@ export function ProjectsPage() {
505
  );
506
  })}
507
 
508
- <div className="planner-row planner-add-row" onClick={openCreate} role="button" tabIndex={0}>
509
- <div className="planner-row-label add">
510
  <span className="plus">+</span> New Project
511
- </div>
512
- <div className="planner-row-track muted-text">Add another project to the timeline</div>
513
  </div>
514
  </div>
515
  </article>
 
6
  import {
7
  archiveProject,
8
  createMilestone,
9
+ createPhase,
10
  createProject,
11
  restoreProject,
12
  updateMilestone,
 
16
  import { SelectorDropdown, Toggle } from "../components/planner/PlannerControls";
17
  import { AddProjectMemberModal } from "../components/projects/AddProjectMemberModal";
18
  import { ProjectDrawer } from "../components/projects/ProjectDrawer";
19
+ import { ProjectPlannerBlock, type PlannerViewMode } from "../components/projects/ProjectPlannerBlock";
20
  import { EmptyState } from "../components/ui/EmptyState";
21
  import { Modal } from "../components/ui/Modal";
22
  import { toastFromError, useToast } from "../components/ui/Toast";
 
93
  const [expandedProjectIds, setExpandedProjectIds] = useState<Set<number>>(new Set());
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;
 
230
  });
231
  };
232
 
233
+ const plannerSaveMutation = useMutation({
234
+ mutationFn: async ({
235
+ projectId,
236
+ kind,
237
+ payload,
238
+ }: {
239
+ projectId: number;
240
+ kind: "milestone" | "phase";
241
+ payload: Record<string, string>;
242
+ }) => {
243
+ if (kind === "milestone") {
244
+ return createMilestone(projectId, {
245
+ name: payload.name,
246
+ due_date: payload.due_date,
247
+ });
248
+ }
249
+ return createPhase(projectId, {
250
+ name: payload.name,
251
+ start_date: payload.start_date,
252
+ end_date: payload.end_date,
253
+ });
254
+ },
255
+ onSuccess: () => {
256
+ invalidate();
257
+ push("Saved", "success");
258
+ },
259
+ onError: (error) => push(toastFromError(error, "Could not save"), "error"),
260
+ });
261
+
262
+ const toggleTentativeMutation = useMutation({
263
+ mutationFn: ({ id, is_tentative }: { id: number; is_tentative: boolean }) =>
264
+ updateProject(id, { is_tentative }),
265
+ onSuccess: () => {
266
+ invalidate();
267
+ push("Project updated", "success");
268
+ },
269
+ onError: (error) => push(toastFromError(error, "Could not update project"), "error"),
270
+ });
271
+
272
  const openCreate = () => {
273
  setEditingId(null);
274
  setForm({ ...emptyForm, color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)] });
 
344
  <div className="planner-controls-row">
345
  <div className="planner-controls-left">
346
  <SelectorDropdown
347
+ value={plannerView}
348
+ options={[
349
+ { value: "phases_milestones", label: "Phases & Milestones" },
350
+ { value: "timeline", label: "Project Timeline" },
351
+ ]}
352
+ onChange={(value) => setPlannerView(value as PlannerViewMode)}
353
  />
354
  <SelectorDropdown
355
  value={groupBy}
 
454
  <article className="planner">
455
  <div className="planner-sync" style={plannerColsStyle}>
456
  <div className="planner-grid-header">
457
+ <div className="planner-sidebar-head planner-sidebar-head-count">
 
 
 
458
  <span className="planner-stat">
459
  <span className="dot" /> {sorted.length} {sorted.length === 1 ? "Project" : "Projects"}
460
  </span>
 
509
  allocations={projectAllocations}
510
  dayDates={plannerDays}
511
  expanded={expanded}
512
+ isSavingAllocation={createAllocationMutation.isPending}
513
+ isSavingPlanner={plannerSaveMutation.isPending}
514
  key={project.id}
515
  onAddPerson={() => {
516
  setEditingAllocation(null);
517
  setMemberModalProject(project);
518
  }}
519
+ onArchive={() => {
520
+ if (window.confirm(`Archive ${project.name}?`)) {
521
+ archiveMutation.mutate(project.id);
522
+ }
523
+ }}
524
  onCreateAllocation={(draft) => handleCreateAllocation(project.id, draft)}
525
+ onCreateMilestone={(dueDate, name) => {
526
+ plannerSaveMutation.mutate({
527
+ projectId: project.id,
528
+ kind: "milestone",
529
+ payload: { name, due_date: dueDate },
530
+ });
531
+ }}
532
+ onCreatePhase={(input) => {
533
+ plannerSaveMutation.mutate({
534
+ projectId: project.id,
535
+ kind: "phase",
536
+ payload: input,
537
+ });
538
+ }}
539
  onEditAllocation={(allocation) => {
540
  setEditingAllocation(allocation);
541
  setMemberModalProject(project);
542
  }}
543
  onEditProject={() => openEdit(project)}
544
+ onMilestones={() => setMilestoneProject(project)}
 
545
  onRestore={
546
  !project.is_active
547
  ? () => restoreMutation.mutate(project.id)
 
555
  return next;
556
  });
557
  }}
558
+ onToggleTentative={() =>
559
+ toggleTentativeMutation.mutate({
560
+ id: project.id,
561
+ is_tentative: !project.is_tentative,
562
+ })
563
+ }
564
+ people={people}
565
  peopleById={peopleById}
566
+ plannerView={plannerView}
567
  project={project}
568
  showTentative={showTentative}
569
  teamLabel={teamLabel}
 
574
  );
575
  })}
576
 
577
+ <div className="planner-row planner-new-project-row">
578
+ <button className="planner-new-project-button" onClick={openCreate} type="button">
579
  <span className="plus">+</span> New Project
580
+ </button>
581
+ <div className="planner-row-track" />
582
  </div>
583
  </div>
584
  </article>
frontend/src/styles.css CHANGED
@@ -1275,15 +1275,34 @@ th {
1275
  margin-right: 8px;
1276
  }
1277
 
1278
- .planner-add-row {
1279
  background: #fbfaff;
 
 
 
 
 
 
 
 
 
1280
  cursor: pointer;
 
 
 
 
 
1281
  }
1282
 
1283
- .planner-add-row:hover {
1284
  background: var(--primary-soft);
1285
  }
1286
 
 
 
 
 
 
1287
  .planner-add-row .planner-row-grid {
1288
  align-items: center;
1289
  display: flex;
@@ -1889,20 +1908,47 @@ th {
1889
  min-height: 52px;
1890
  }
1891
 
 
 
 
 
 
1892
  .planner-project-label {
1893
  align-items: center;
1894
  display: flex;
1895
- gap: 6px;
1896
- padding: 8px 10px 8px 6px;
 
 
 
 
 
 
 
 
1897
  }
1898
 
1899
- .planner-expand-button {
 
1900
  background: none;
1901
  border: none;
1902
  color: var(--muted);
1903
  cursor: pointer;
1904
- flex-shrink: 0;
1905
- padding: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
1906
  }
1907
 
1908
  .planner-project-name {
@@ -2055,6 +2101,94 @@ th {
2055
  padding: 12px 14px;
2056
  }
2057
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2058
  .project-bar.allocation-bar {
2059
  border: none;
2060
  font-family: inherit;
 
1275
  margin-right: 8px;
1276
  }
1277
 
1278
+ .planner-new-project-row {
1279
  background: #fbfaff;
1280
+ border-top: 1px solid var(--line);
1281
+ }
1282
+
1283
+ .planner-new-project-button {
1284
+ align-items: center;
1285
+ background: white;
1286
+ border: 1px solid var(--primary);
1287
+ border-radius: 999px;
1288
+ color: var(--primary);
1289
  cursor: pointer;
1290
+ display: inline-flex;
1291
+ font-weight: 700;
1292
+ gap: 8px;
1293
+ margin: 12px 14px;
1294
+ padding: 8px 18px;
1295
  }
1296
 
1297
+ .planner-new-project-button:hover {
1298
  background: var(--primary-soft);
1299
  }
1300
 
1301
+ .planner-new-project-button .plus {
1302
+ font-size: 16px;
1303
+ line-height: 1;
1304
+ }
1305
+
1306
  .planner-add-row .planner-row-grid {
1307
  align-items: center;
1308
  display: flex;
 
1908
  min-height: 52px;
1909
  }
1910
 
1911
+ .planner-sidebar-head-count {
1912
+ justify-content: flex-start;
1913
+ padding: 12px 14px;
1914
+ }
1915
+
1916
  .planner-project-label {
1917
  align-items: center;
1918
  display: flex;
1919
+ gap: 8px;
1920
+ justify-content: space-between;
1921
+ padding: 8px 10px 8px 12px;
1922
+ }
1923
+
1924
+ .planner-project-actions {
1925
+ align-items: center;
1926
+ display: flex;
1927
+ flex-shrink: 0;
1928
+ gap: 2px;
1929
  }
1930
 
1931
+ .planner-expand-end {
1932
+ align-items: center;
1933
  background: none;
1934
  border: none;
1935
  color: var(--muted);
1936
  cursor: pointer;
1937
+ display: inline-flex;
1938
+ height: 32px;
1939
+ justify-content: center;
1940
+ transition: transform 0.15s ease;
1941
+ width: 32px;
1942
+ }
1943
+
1944
+ .planner-expand-end.expanded {
1945
+ transform: rotate(90deg);
1946
+ }
1947
+
1948
+ .planner-expand-end:hover {
1949
+ background: var(--primary-soft);
1950
+ border-radius: 6px;
1951
+ color: var(--primary);
1952
  }
1953
 
1954
  .planner-project-name {
 
2101
  padding: 12px 14px;
2102
  }
2103
 
2104
+ .planner-calendar-track {
2105
+ cursor: crosshair;
2106
+ min-height: 36px;
2107
+ }
2108
+
2109
+ .planner-calendar-project-line {
2110
+ min-height: 28px;
2111
+ }
2112
+
2113
+ .project-span-line {
2114
+ border-radius: 2px;
2115
+ box-sizing: border-box;
2116
+ height: 4px;
2117
+ min-width: 8px;
2118
+ pointer-events: none;
2119
+ position: absolute;
2120
+ top: 50%;
2121
+ transform: translateY(-50%);
2122
+ z-index: 2;
2123
+ }
2124
+
2125
+ .project-span-line.tentative {
2126
+ opacity: 0.45;
2127
+ }
2128
+
2129
+ .phase-bar {
2130
+ align-items: center;
2131
+ border-radius: 6px;
2132
+ box-sizing: border-box;
2133
+ color: #0f766e;
2134
+ display: flex;
2135
+ font-size: 11px;
2136
+ font-weight: 700;
2137
+ height: calc(100% - 10px);
2138
+ justify-content: flex-start;
2139
+ min-width: 0;
2140
+ overflow: hidden;
2141
+ padding: 0 8px;
2142
+ position: absolute;
2143
+ text-overflow: ellipsis;
2144
+ top: 5px;
2145
+ white-space: nowrap;
2146
+ z-index: 3;
2147
+ }
2148
+
2149
+ .phase-bar.preview {
2150
+ opacity: 0.6;
2151
+ pointer-events: none;
2152
+ }
2153
+
2154
+ .milestone-pin {
2155
+ background: #f59e0b;
2156
+ border: 2px solid white;
2157
+ border-radius: 2px;
2158
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
2159
+ height: 14px;
2160
+ margin-left: -7px;
2161
+ pointer-events: none;
2162
+ position: absolute;
2163
+ top: 50%;
2164
+ transform: translateY(-50%) rotate(45deg);
2165
+ width: 14px;
2166
+ z-index: 4;
2167
+ }
2168
+
2169
+ .milestone-pin.completed {
2170
+ background: var(--success);
2171
+ }
2172
+
2173
+ .quick-popover-body {
2174
+ gap: 10px;
2175
+ }
2176
+
2177
+ .quick-popover-actions {
2178
+ display: flex;
2179
+ gap: 8px;
2180
+ justify-content: flex-end;
2181
+ margin-top: 4px;
2182
+ }
2183
+
2184
+ .planner-phases-row .planner-row-track {
2185
+ min-height: 40px;
2186
+ }
2187
+
2188
+ .planner-project-row .planner-row-track {
2189
+ min-height: 32px;
2190
+ }
2191
+
2192
  .project-bar.allocation-bar {
2193
  border: none;
2194
  font-family: inherit;
frontend/src/types/index.ts CHANGED
@@ -75,6 +75,16 @@ export interface Project {
75
  owner_id?: number | null;
76
  team_id?: number | null;
77
  milestones: Milestone[];
 
 
 
 
 
 
 
 
 
 
78
  }
79
 
80
  export interface ProjectInput {
 
75
  owner_id?: number | null;
76
  team_id?: number | null;
77
  milestones: Milestone[];
78
+ phases?: ProjectPhase[];
79
+ }
80
+
81
+ export interface ProjectPhase {
82
+ id: number;
83
+ project_id: number;
84
+ name: string;
85
+ start_date: string;
86
+ end_date: string;
87
+ color: string;
88
  }
89
 
90
  export interface ProjectInput {