Spaces:
Running
Running
Gowrisankar Cursor commited on
Commit ·
eed056f
1
Parent(s): c5eff7f
Add Runn-style allocation menu actions and multi-select.
Browse filesWire 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 +114 -33
- frontend/src/components/projects/AllocationDragTimeline.tsx +55 -1
- frontend/src/components/projects/ProjectPlannerBlock.tsx +35 -0
- frontend/src/pages/Projects.tsx +106 -0
- frontend/src/planner/allocationActions.ts +24 -0
- frontend/src/styles.css +65 -0
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 |
-
{
|
| 397 |
-
<
|
| 398 |
-
<
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
type="
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 423 |
-
onChange={(event) =>
|
| 424 |
-
|
| 425 |
-
setRepeatOccurrences(Math.max(1, Number(event.target.value) || 1));
|
| 426 |
-
}}
|
| 427 |
-
type="number"
|
| 428 |
-
value={repeatOccurrences}
|
| 429 |
/>
|
| 430 |
-
|
| 431 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
</>
|
| 433 |
-
)
|
| 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;
|