Gowrisankar Cursor commited on
Commit
eed056f
·
1 Parent(s): c5eff7f

Add Runn-style allocation menu actions and multi-select.

Browse files

Wire Transfer, Clone, extend-to-visible-range, and bulk delete from the allocation popover and timeline.

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

frontend/src/components/projects/AllocationCreatePopover.tsx CHANGED
@@ -46,6 +46,12 @@ interface AllocationCreatePopoverProps {
46
  onSave: (draft: AllocationDragDraft) => void;
47
  onUpdate?: (draft: AllocationDragDraft) => void;
48
  onDelete?: () => void;
 
 
 
 
 
 
49
  isSaving?: boolean;
50
  }
51
 
@@ -69,6 +75,12 @@ export function AllocationCreatePopover({
69
  onSave,
70
  onUpdate,
71
  onDelete,
 
 
 
 
 
 
72
  isSaving = false,
73
  }: AllocationCreatePopoverProps) {
74
  const popoverRef = useRef<HTMLDivElement>(null);
@@ -91,6 +103,7 @@ export function AllocationCreatePopover({
91
  const [note, setNote] = useState("");
92
  const [datesOpen, setDatesOpen] = useState(false);
93
  const [menuOpen, setMenuOpen] = useState(false);
 
94
 
95
  const selectedPerson =
96
  fixedPerson ?? people.find((person) => person.id === Number(personId));
@@ -118,6 +131,7 @@ export function AllocationCreatePopover({
118
  setPersonId(fixedPerson?.id ?? people[0]?.id ?? "");
119
  setDatesOpen(false);
120
  setMenuOpen(false);
 
121
 
122
  const editPerson = allocation
123
  ? (fixedPerson ?? people.find((person) => person.id === allocation.person_id))
@@ -375,6 +389,7 @@ export function AllocationCreatePopover({
375
  onClick={() => {
376
  setDatesOpen((open) => !open);
377
  setMenuOpen(false);
 
378
  }}
379
  type="button"
380
  >
@@ -392,45 +407,111 @@ export function AllocationCreatePopover({
392
  <MoreHorizontal size={18} />
393
  </button>
394
  {menuOpen ? (
395
- <div className="runn-menu">
396
- {!isEdit ? (
397
- <label className="runn-menu-item">
398
- <input
399
- checked={repeatEnabled}
400
- onChange={(event) => setRepeatEnabled(event.target.checked)}
401
- type="checkbox"
402
- />
403
- Repeat weekly
404
- </label>
405
- ) : null}
406
- {repeatEnabled && !isEdit ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  <>
408
  <label className="runn-menu-item">
409
- End on
410
- <input
411
- onChange={(event) => {
412
- setRepeatEndMode("on");
413
- setRepeatEndOn(event.target.value);
414
- }}
415
- type="date"
416
- value={repeatEndOn}
417
- />
418
- </label>
419
- <label className="runn-menu-item">
420
- After
421
  <input
422
- min={1}
423
- onChange={(event) => {
424
- setRepeatEndMode("after");
425
- setRepeatOccurrences(Math.max(1, Number(event.target.value) || 1));
426
- }}
427
- type="number"
428
- value={repeatOccurrences}
429
  />
430
- times
431
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  </>
433
- ) : null}
434
  </div>
435
  ) : null}
436
  </div>
 
46
  onSave: (draft: AllocationDragDraft) => void;
47
  onUpdate?: (draft: AllocationDragDraft) => void;
48
  onDelete?: () => void;
49
+ transferPeople?: Person[];
50
+ onTransfer?: (personId: number) => void;
51
+ onClone?: () => void;
52
+ onSelectAllToRight?: () => void;
53
+ onToggleMultiSelect?: () => void;
54
+ multiSelectActive?: boolean;
55
  isSaving?: boolean;
56
  }
57
 
 
75
  onSave,
76
  onUpdate,
77
  onDelete,
78
+ transferPeople = [],
79
+ onTransfer,
80
+ onClone,
81
+ onSelectAllToRight,
82
+ onToggleMultiSelect,
83
+ multiSelectActive = false,
84
  isSaving = false,
85
  }: AllocationCreatePopoverProps) {
86
  const popoverRef = useRef<HTMLDivElement>(null);
 
103
  const [note, setNote] = useState("");
104
  const [datesOpen, setDatesOpen] = useState(false);
105
  const [menuOpen, setMenuOpen] = useState(false);
106
+ const [transferOpen, setTransferOpen] = useState(false);
107
 
108
  const selectedPerson =
109
  fixedPerson ?? people.find((person) => person.id === Number(personId));
 
131
  setPersonId(fixedPerson?.id ?? people[0]?.id ?? "");
132
  setDatesOpen(false);
133
  setMenuOpen(false);
134
+ setTransferOpen(false);
135
 
136
  const editPerson = allocation
137
  ? (fixedPerson ?? people.find((person) => person.id === allocation.person_id))
 
389
  onClick={() => {
390
  setDatesOpen((open) => !open);
391
  setMenuOpen(false);
392
+ setTransferOpen(false);
393
  }}
394
  type="button"
395
  >
 
407
  <MoreHorizontal size={18} />
408
  </button>
409
  {menuOpen ? (
410
+ <div className="runn-menu runn-menu-actions">
411
+ {isEdit ? (
412
+ <>
413
+ <button
414
+ className="runn-menu-action"
415
+ onClick={() => setTransferOpen((open) => !open)}
416
+ type="button"
417
+ >
418
+ Transfer
419
+ </button>
420
+ {transferOpen && transferPeople.length > 0 ? (
421
+ <div className="runn-menu-sub">
422
+ <select
423
+ onChange={(event) => {
424
+ const id = Number(event.target.value);
425
+ if (id) onTransfer?.(id);
426
+ setMenuOpen(false);
427
+ setTransferOpen(false);
428
+ }}
429
+ value=""
430
+ >
431
+ <option value="">Choose person…</option>
432
+ {transferPeople.map((person) => (
433
+ <option key={person.id} value={person.id}>
434
+ {person.name}
435
+ </option>
436
+ ))}
437
+ </select>
438
+ </div>
439
+ ) : null}
440
+ <button
441
+ className="runn-menu-action"
442
+ onClick={() => {
443
+ onClone?.();
444
+ setMenuOpen(false);
445
+ }}
446
+ type="button"
447
+ >
448
+ Clone
449
+ </button>
450
+ <button
451
+ className="runn-menu-action"
452
+ onClick={() => {
453
+ const last = dayDates.length
454
+ ? format(dayDates[dayDates.length - 1], "yyyy-MM-dd")
455
+ : rangeEnd;
456
+ setRangeEnd(last);
457
+ onSelectAllToRight?.();
458
+ setMenuOpen(false);
459
+ }}
460
+ type="button"
461
+ >
462
+ Select All to Right
463
+ </button>
464
+ <button
465
+ className="runn-menu-action"
466
+ onClick={() => {
467
+ onToggleMultiSelect?.();
468
+ setMenuOpen(false);
469
+ }}
470
+ type="button"
471
+ >
472
+ {multiSelectActive ? "Disable Multi-Select Mode" : "Enable Multi-Select Mode"}
473
+ </button>
474
+ </>
475
+ ) : (
476
  <>
477
  <label className="runn-menu-item">
 
 
 
 
 
 
 
 
 
 
 
 
478
  <input
479
+ checked={repeatEnabled}
480
+ onChange={(event) => setRepeatEnabled(event.target.checked)}
481
+ type="checkbox"
 
 
 
 
482
  />
483
+ Repeat weekly
484
  </label>
485
+ {repeatEnabled ? (
486
+ <>
487
+ <label className="runn-menu-item">
488
+ End on
489
+ <input
490
+ onChange={(event) => {
491
+ setRepeatEndMode("on");
492
+ setRepeatEndOn(event.target.value);
493
+ }}
494
+ type="date"
495
+ value={repeatEndOn}
496
+ />
497
+ </label>
498
+ <label className="runn-menu-item">
499
+ After
500
+ <input
501
+ min={1}
502
+ onChange={(event) => {
503
+ setRepeatEndMode("after");
504
+ setRepeatOccurrences(Math.max(1, Number(event.target.value) || 1));
505
+ }}
506
+ type="number"
507
+ value={repeatOccurrences}
508
+ />
509
+ times
510
+ </label>
511
+ </>
512
+ ) : null}
513
  </>
514
+ )}
515
  </div>
516
  ) : null}
517
  </div>
frontend/src/components/projects/AllocationDragTimeline.tsx CHANGED
@@ -19,12 +19,20 @@ interface AllocationDragTimelineProps {
19
  color: string;
20
  fixedPerson?: Person;
21
  candidatePeople?: Person[];
 
22
  allocation?: Allocation;
23
  weeklyCapacityHrs?: number;
24
  allocationPct?: number;
25
  onSave: (draft: AllocationDragDraft) => Promise<Allocation | undefined> | Allocation | undefined;
26
  onUpdate?: (draft: AllocationDragDraft) => void;
27
  onDelete?: (allocation: Allocation) => void;
 
 
 
 
 
 
 
28
  isSaving?: boolean;
29
  effortMode?: EffortDisplayMode;
30
  }
@@ -40,6 +48,14 @@ export function AllocationDragTimeline({
40
  onSave,
41
  onUpdate,
42
  onDelete,
 
 
 
 
 
 
 
 
43
  isSaving = false,
44
  effortMode = "hours",
45
  }: AllocationDragTimelineProps) {
@@ -79,6 +95,10 @@ export function AllocationDragTimeline({
79
  const openEditPopover = (event: React.MouseEvent<HTMLButtonElement>) => {
80
  if (!allocation) return;
81
  event.stopPropagation();
 
 
 
 
82
  const rect = event.currentTarget.getBoundingClientRect();
83
  openPopoverAt(
84
  allocation.start_date,
@@ -164,7 +184,7 @@ export function AllocationDragTimeline({
164
  ) : null}
165
  {existingSegment && weeklyCapacityHrs !== undefined && allocationPct !== undefined && allocation ? (
166
  <button
167
- className={`${existingSegment.className}${visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date) <= 1 ? " is-pill" : ""}`}
168
  onClick={openEditPopover}
169
  onPointerDown={(event) => event.stopPropagation()}
170
  onPointerUp={(event) => event.stopPropagation()}
@@ -237,6 +257,40 @@ export function AllocationDragTimeline({
237
  defaultHoursPerDay={
238
  fixedPerson ? roundToHalfHour(fixedPerson.weekly_capacity_hrs / 5) : undefined
239
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  />
241
  ) : null}
242
  </>
 
19
  color: string;
20
  fixedPerson?: Person;
21
  candidatePeople?: Person[];
22
+ transferPeople?: Person[];
23
  allocation?: Allocation;
24
  weeklyCapacityHrs?: number;
25
  allocationPct?: number;
26
  onSave: (draft: AllocationDragDraft) => Promise<Allocation | undefined> | Allocation | undefined;
27
  onUpdate?: (draft: AllocationDragDraft) => void;
28
  onDelete?: (allocation: Allocation) => void;
29
+ onTransfer?: (personId: number) => void;
30
+ onClone?: () => void;
31
+ onSelectAllToRight?: () => void;
32
+ onToggleMultiSelect?: () => void;
33
+ multiSelectActive?: boolean;
34
+ selected?: boolean;
35
+ onToggleSelect?: () => void;
36
  isSaving?: boolean;
37
  effortMode?: EffortDisplayMode;
38
  }
 
48
  onSave,
49
  onUpdate,
50
  onDelete,
51
+ transferPeople = [],
52
+ onTransfer,
53
+ onClone,
54
+ onSelectAllToRight,
55
+ onToggleMultiSelect,
56
+ multiSelectActive = false,
57
+ selected = false,
58
+ onToggleSelect,
59
  isSaving = false,
60
  effortMode = "hours",
61
  }: AllocationDragTimelineProps) {
 
95
  const openEditPopover = (event: React.MouseEvent<HTMLButtonElement>) => {
96
  if (!allocation) return;
97
  event.stopPropagation();
98
+ if (multiSelectActive && onToggleSelect) {
99
+ onToggleSelect();
100
+ return;
101
+ }
102
  const rect = event.currentTarget.getBoundingClientRect();
103
  openPopoverAt(
104
  allocation.start_date,
 
184
  ) : null}
185
  {existingSegment && weeklyCapacityHrs !== undefined && allocationPct !== undefined && allocation ? (
186
  <button
187
+ className={`${existingSegment.className}${visibleWeekdayCount(dayDates, allocation.start_date, allocation.end_date) <= 1 ? " is-pill" : ""}${selected ? " is-selected" : ""}`}
188
  onClick={openEditPopover}
189
  onPointerDown={(event) => event.stopPropagation()}
190
  onPointerUp={(event) => event.stopPropagation()}
 
257
  defaultHoursPerDay={
258
  fixedPerson ? roundToHalfHour(fixedPerson.weekly_capacity_hrs / 5) : undefined
259
  }
260
+ multiSelectActive={multiSelectActive}
261
+ onClone={
262
+ editingAllocation && onClone
263
+ ? () => {
264
+ onClone();
265
+ setPopover(null);
266
+ }
267
+ : undefined
268
+ }
269
+ onSelectAllToRight={
270
+ editingAllocation && onSelectAllToRight
271
+ ? () => {
272
+ onSelectAllToRight();
273
+ setPopover(null);
274
+ }
275
+ : undefined
276
+ }
277
+ onToggleMultiSelect={
278
+ onToggleMultiSelect
279
+ ? () => {
280
+ onToggleMultiSelect();
281
+ setPopover(null);
282
+ }
283
+ : undefined
284
+ }
285
+ onTransfer={
286
+ editingAllocation && onTransfer
287
+ ? (personId) => {
288
+ onTransfer(personId);
289
+ setPopover(null);
290
+ }
291
+ : undefined
292
+ }
293
+ transferPeople={transferPeople}
294
  />
295
  ) : null}
296
  </>
frontend/src/components/projects/ProjectPlannerBlock.tsx CHANGED
@@ -36,6 +36,13 @@ interface ProjectPlannerBlockProps {
36
  onCreateAllocation: (draft: AllocationDragDraft) => Promise<Allocation | undefined>;
37
  onUpdateAllocation: (allocation: Allocation, draft: AllocationDragDraft) => void;
38
  onDeleteAllocation: (allocation: Allocation) => void;
 
 
 
 
 
 
 
39
  onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
40
  onCreatePhase: (input: {
41
  name: string;
@@ -69,6 +76,13 @@ export function ProjectPlannerBlock({
69
  onCreateAllocation,
70
  onUpdateAllocation,
71
  onDeleteAllocation,
 
 
 
 
 
 
 
72
  onCreateMilestone,
73
  onCreatePhase,
74
  onToggleTentative,
@@ -278,6 +292,9 @@ export function ProjectPlannerBlock({
278
  {group.allocations.map((allocation) => {
279
  const person = peopleById.get(allocation.person_id);
280
  if (!person) return null;
 
 
 
281
  return (
282
  <div className="planner-row planner-project-subrow planner-member-row" key={allocation.id}>
283
  <div className="planner-row-label nested member">
@@ -296,9 +313,27 @@ export function ProjectPlannerBlock({
296
  effortMode={effortMode}
297
  fixedPerson={person}
298
  isSaving={isSavingAllocation}
 
 
299
  onDelete={(item) => onDeleteAllocation(item)}
300
  onSave={onCreateAllocation}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  onUpdate={(draft) => onUpdateAllocation(allocation, draft)}
 
 
302
  weeklyCapacityHrs={person.weekly_capacity_hrs}
303
  />
304
  </div>
 
36
  onCreateAllocation: (draft: AllocationDragDraft) => Promise<Allocation | undefined>;
37
  onUpdateAllocation: (allocation: Allocation, draft: AllocationDragDraft) => void;
38
  onDeleteAllocation: (allocation: Allocation) => void;
39
+ onTransferAllocation?: (allocation: Allocation, personId: number) => void;
40
+ onCloneAllocation?: (allocation: Allocation) => void;
41
+ onExtendAllocationRight?: (allocation: Allocation) => void;
42
+ multiSelectMode?: boolean;
43
+ selectedAllocationIds?: Set<number>;
44
+ onToggleAllocationSelect?: (allocationId: number) => void;
45
+ onToggleMultiSelectMode?: () => void;
46
  onCreateMilestone: (dueDate: string, name: string, note?: string) => void;
47
  onCreatePhase: (input: {
48
  name: string;
 
76
  onCreateAllocation,
77
  onUpdateAllocation,
78
  onDeleteAllocation,
79
+ onTransferAllocation,
80
+ onCloneAllocation,
81
+ onExtendAllocationRight,
82
+ multiSelectMode = false,
83
+ selectedAllocationIds,
84
+ onToggleAllocationSelect,
85
+ onToggleMultiSelectMode,
86
  onCreateMilestone,
87
  onCreatePhase,
88
  onToggleTentative,
 
292
  {group.allocations.map((allocation) => {
293
  const person = peopleById.get(allocation.person_id);
294
  if (!person) return null;
295
+ const transferCandidates = people.filter(
296
+ (candidate) => candidate.is_active && candidate.id !== allocation.person_id,
297
+ );
298
  return (
299
  <div className="planner-row planner-project-subrow planner-member-row" key={allocation.id}>
300
  <div className="planner-row-label nested member">
 
313
  effortMode={effortMode}
314
  fixedPerson={person}
315
  isSaving={isSavingAllocation}
316
+ multiSelectActive={multiSelectMode}
317
+ onClone={onCloneAllocation ? () => onCloneAllocation(allocation) : undefined}
318
  onDelete={(item) => onDeleteAllocation(item)}
319
  onSave={onCreateAllocation}
320
+ onSelectAllToRight={
321
+ onExtendAllocationRight ? () => onExtendAllocationRight(allocation) : undefined
322
+ }
323
+ onToggleMultiSelect={onToggleMultiSelectMode}
324
+ onToggleSelect={
325
+ onToggleAllocationSelect
326
+ ? () => onToggleAllocationSelect(allocation.id)
327
+ : undefined
328
+ }
329
+ onTransfer={
330
+ onTransferAllocation
331
+ ? (personId) => onTransferAllocation(allocation, personId)
332
+ : undefined
333
+ }
334
  onUpdate={(draft) => onUpdateAllocation(allocation, draft)}
335
+ selected={selectedAllocationIds?.has(allocation.id) ?? false}
336
+ transferPeople={transferCandidates}
337
  weeklyCapacityHrs={person.weekly_capacity_hrs}
338
  />
339
  </div>
frontend/src/pages/Projects.tsx CHANGED
@@ -13,6 +13,7 @@ import {
13
  updateProject,
14
  } from "../api/projects";
15
  import type { AllocationDragDraft } from "../components/projects/AllocationCreatePopover";
 
16
  import { expandWeeklyRepeat } from "../planner/allocationRepeat";
17
  import { SelectorDropdown, Toggle } from "../components/planner/PlannerControls";
18
  import { AddProjectMemberModal } from "../components/projects/AddProjectMemberModal";
@@ -104,6 +105,9 @@ export function ProjectsPage() {
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;
109
  const endDate = addWeeks(startDate, rangeWeeks);
@@ -295,6 +299,7 @@ export function ProjectsPage() {
295
  end_date: draft.end_date,
296
  allocation_pct: draft.allocation_pct,
297
  note: draft.note ?? null,
 
298
  }),
299
  onSuccess: () => {
300
  invalidate();
@@ -320,6 +325,58 @@ export function ProjectsPage() {
320
  deleteAllocationMutation.mutate(allocation.id);
321
  };
322
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
323
  const isSavingAllocation =
324
  createAllocationMutation.isPending ||
325
  updateAllocationMutation.isPending ||
@@ -643,6 +700,24 @@ export function ProjectsPage() {
643
  });
644
  }}
645
  onDeleteAllocation={(allocation) => handleDeleteAllocation(project.id, allocation)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  onUpdateAllocation={(allocation, draft) =>
647
  handleUpdateAllocation(project.id, allocation, draft)
648
  }
@@ -686,6 +761,37 @@ export function ProjectsPage() {
686
  </button>
687
  <div className="planner-row-track" />
688
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
  </div>
690
  </article>
691
  )}
 
13
  updateProject,
14
  } from "../api/projects";
15
  import type { AllocationDragDraft } from "../components/projects/AllocationCreatePopover";
16
+ import { draftFromAllocation, endDateThroughVisibleRange } from "../planner/allocationActions";
17
  import { expandWeeklyRepeat } from "../planner/allocationRepeat";
18
  import { SelectorDropdown, Toggle } from "../components/planner/PlannerControls";
19
  import { AddProjectMemberModal } from "../components/projects/AddProjectMemberModal";
 
105
  localStorage.setItem("projects-effort-mode", effortMode);
106
  }, [effortMode]);
107
 
108
+ const [multiSelectMode, setMultiSelectMode] = useState(false);
109
+ const [selectedAllocationIds, setSelectedAllocationIds] = useState<Set<number>>(new Set());
110
+
111
  const rangeWeeks = RANGE_OPTIONS.find((option) => option.value === range)?.weeks ?? 5;
112
  const startDate = anchor;
113
  const endDate = addWeeks(startDate, rangeWeeks);
 
299
  end_date: draft.end_date,
300
  allocation_pct: draft.allocation_pct,
301
  note: draft.note ?? null,
302
+ ...(draft.person_id !== undefined ? { person_id: draft.person_id } : {}),
303
  }),
304
  onSuccess: () => {
305
  invalidate();
 
325
  deleteAllocationMutation.mutate(allocation.id);
326
  };
327
 
328
+ const handleTransferAllocation = (
329
+ _projectId: number,
330
+ allocation: Allocation,
331
+ personId: number,
332
+ ) => {
333
+ updateAllocationMutation.mutate({
334
+ id: allocation.id,
335
+ draft: { ...draftFromAllocation(allocation), person_id: personId } as AllocationDragDraft,
336
+ });
337
+ };
338
+
339
+ const handleCloneAllocation = async (projectId: number, allocation: Allocation) => {
340
+ const base = draftFromAllocation(allocation);
341
+ await createAllocation({
342
+ person_id: base.person_id,
343
+ project_id: projectId,
344
+ start_date: base.start_date,
345
+ end_date: base.end_date,
346
+ allocation_pct: base.allocation_pct,
347
+ note: base.note ?? null,
348
+ });
349
+ invalidate();
350
+ push("Assignment cloned", "success");
351
+ };
352
+
353
+ const handleExtendAllocationRight = (projectId: number, allocation: Allocation) => {
354
+ const end = endDateThroughVisibleRange(plannerDays, allocation.start_date);
355
+ updateAllocationMutation.mutate({
356
+ id: allocation.id,
357
+ draft: { ...draftFromAllocation(allocation), end_date: end } as AllocationDragDraft,
358
+ });
359
+ };
360
+
361
+ const toggleAllocationSelection = (allocationId: number) => {
362
+ setSelectedAllocationIds((prev) => {
363
+ const next = new Set(prev);
364
+ if (next.has(allocationId)) next.delete(allocationId);
365
+ else next.add(allocationId);
366
+ return next;
367
+ });
368
+ };
369
+
370
+ const deleteSelectedAllocations = async () => {
371
+ for (const id of selectedAllocationIds) {
372
+ await deleteAllocation(id);
373
+ }
374
+ setSelectedAllocationIds(new Set());
375
+ setMultiSelectMode(false);
376
+ invalidate();
377
+ push("Selected assignments deleted", "success");
378
+ };
379
+
380
  const isSavingAllocation =
381
  createAllocationMutation.isPending ||
382
  updateAllocationMutation.isPending ||
 
700
  });
701
  }}
702
  onDeleteAllocation={(allocation) => handleDeleteAllocation(project.id, allocation)}
703
+ onCloneAllocation={(allocation) => {
704
+ void handleCloneAllocation(project.id, allocation);
705
+ }}
706
+ onExtendAllocationRight={(allocation) =>
707
+ handleExtendAllocationRight(project.id, allocation)
708
+ }
709
+ onTransferAllocation={(allocation, personId) =>
710
+ handleTransferAllocation(project.id, allocation, personId)
711
+ }
712
+ multiSelectMode={multiSelectMode}
713
+ onToggleAllocationSelect={toggleAllocationSelection}
714
+ onToggleMultiSelectMode={() => {
715
+ setMultiSelectMode((active) => {
716
+ if (active) setSelectedAllocationIds(new Set());
717
+ return !active;
718
+ });
719
+ }}
720
+ selectedAllocationIds={selectedAllocationIds}
721
  onUpdateAllocation={(allocation, draft) =>
722
  handleUpdateAllocation(project.id, allocation, draft)
723
  }
 
761
  </button>
762
  <div className="planner-row-track" />
763
  </div>
764
+
765
+ {multiSelectMode ? (
766
+ <div className="planner-multi-select-bar">
767
+ <span>
768
+ {selectedAllocationIds.size === 0
769
+ ? "Multi-select: click assignments to select"
770
+ : `${selectedAllocationIds.size} selected`}
771
+ </span>
772
+ <div className="planner-multi-select-actions">
773
+ {selectedAllocationIds.size > 0 ? (
774
+ <button
775
+ className="ghost-button danger"
776
+ onClick={() => void deleteSelectedAllocations()}
777
+ type="button"
778
+ >
779
+ Delete selected
780
+ </button>
781
+ ) : null}
782
+ <button
783
+ className="ghost-button"
784
+ onClick={() => {
785
+ setMultiSelectMode(false);
786
+ setSelectedAllocationIds(new Set());
787
+ }}
788
+ type="button"
789
+ >
790
+ Done
791
+ </button>
792
+ </div>
793
+ </div>
794
+ ) : null}
795
  </div>
796
  </article>
797
  )}
frontend/src/planner/allocationActions.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { format } from "date-fns";
2
+
3
+ import type { AllocationDragDraft } from "../components/projects/AllocationCreatePopover";
4
+ import type { Allocation } from "../types";
5
+
6
+ export function draftFromAllocation(allocation: Allocation): Pick<
7
+ AllocationDragDraft,
8
+ "person_id" | "start_date" | "end_date" | "allocation_pct" | "note"
9
+ > {
10
+ return {
11
+ person_id: allocation.person_id,
12
+ start_date: allocation.start_date,
13
+ end_date: allocation.end_date,
14
+ allocation_pct: allocation.allocation_pct,
15
+ note: allocation.note ?? undefined,
16
+ };
17
+ }
18
+
19
+ /** Extend assignment through the last visible planner day. */
20
+ export function endDateThroughVisibleRange(dayDates: Date[], startDate: string): string {
21
+ if (dayDates.length === 0) return startDate;
22
+ const last = format(dayDates[dayDates.length - 1], "yyyy-MM-dd");
23
+ return last >= startDate ? last : startDate;
24
+ }
frontend/src/styles.css CHANGED
@@ -2573,6 +2573,71 @@ th {
2573
  gap: 8px;
2574
  }
2575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2576
  .runn-save {
2577
  background: #2563eb;
2578
  border: 0;
 
2573
  gap: 8px;
2574
  }
2575
 
2576
+ .runn-menu-actions {
2577
+ gap: 0;
2578
+ min-width: 220px;
2579
+ padding: 4px 0;
2580
+ }
2581
+
2582
+ .runn-menu-action {
2583
+ background: transparent;
2584
+ border: 0;
2585
+ color: #1e40af;
2586
+ cursor: pointer;
2587
+ font-size: 14px;
2588
+ font-weight: 600;
2589
+ padding: 10px 14px;
2590
+ text-align: left;
2591
+ width: 100%;
2592
+ }
2593
+
2594
+ .runn-menu-action:hover {
2595
+ background: #f1f5f9;
2596
+ }
2597
+
2598
+ .runn-menu-sub {
2599
+ border-top: 1px solid var(--line);
2600
+ padding: 8px 12px 10px;
2601
+ }
2602
+
2603
+ .runn-menu-sub select {
2604
+ font-size: 13px;
2605
+ width: 100%;
2606
+ }
2607
+
2608
+ .project-bar.allocation-bar.is-selected {
2609
+ box-shadow: 0 0 0 2px #fff, 0 0 0 4px #2563eb;
2610
+ outline: none;
2611
+ }
2612
+
2613
+ .planner-multi-select-bar {
2614
+ align-items: center;
2615
+ background: #eff6ff;
2616
+ border-top: 1px solid #bfdbfe;
2617
+ display: flex;
2618
+ gap: 16px;
2619
+ justify-content: space-between;
2620
+ padding: 10px 16px;
2621
+ position: sticky;
2622
+ bottom: 0;
2623
+ z-index: 5;
2624
+ }
2625
+
2626
+ .planner-multi-select-bar span {
2627
+ color: #1e3a8a;
2628
+ font-size: 14px;
2629
+ font-weight: 600;
2630
+ }
2631
+
2632
+ .planner-multi-select-actions {
2633
+ display: flex;
2634
+ gap: 8px;
2635
+ }
2636
+
2637
+ .ghost-button.danger {
2638
+ color: #b91c1c;
2639
+ }
2640
+
2641
  .runn-save {
2642
  background: #2563eb;
2643
  border: 0;