Spaces:
Running
Running
| import { useMutation } from "@tanstack/react-query"; | |
| import { addWeeks, format } from "date-fns"; | |
| import { useEffect, useState } from "react"; | |
| import { createAllocation, deleteAllocation, updateAllocation } from "../../api/allocations"; | |
| import { formatHoursPerDay } from "../../planner/allocationTimeline"; | |
| import { Modal } from "../ui/Modal"; | |
| import { toastFromError, useToast } from "../ui/Toast"; | |
| import type { Allocation, Person, Project } from "../../types"; | |
| interface AddProjectMemberModalProps { | |
| open: boolean; | |
| project: Project | null; | |
| people: Person[]; | |
| allocation: Allocation | null; | |
| assignedPersonIds: number[]; | |
| onClose: () => void; | |
| onSaved: () => void; | |
| } | |
| export function AddProjectMemberModal({ | |
| open, | |
| project, | |
| people, | |
| allocation, | |
| assignedPersonIds, | |
| onClose, | |
| onSaved, | |
| }: AddProjectMemberModalProps) { | |
| const { push } = useToast(); | |
| const [personId, setPersonId] = useState<number | "">(""); | |
| const [startDate, setStartDate] = useState(""); | |
| const [endDate, setEndDate] = useState(""); | |
| const [allocationPct, setAllocationPct] = useState(50); | |
| useEffect(() => { | |
| if (!open || !project) return; | |
| if (allocation) { | |
| setPersonId(allocation.person_id); | |
| setStartDate(allocation.start_date); | |
| setEndDate(allocation.end_date); | |
| setAllocationPct(allocation.allocation_pct); | |
| return; | |
| } | |
| const available = people.find((person) => !assignedPersonIds.includes(person.id)); | |
| setPersonId(available?.id ?? ""); | |
| setStartDate(format(new Date(), "yyyy-MM-dd")); | |
| setEndDate(format(addWeeks(new Date(), 1), "yyyy-MM-dd")); | |
| setAllocationPct(50); | |
| }, [open, project, allocation, people, assignedPersonIds]); | |
| const createMutation = useMutation({ | |
| mutationFn: createAllocation, | |
| onSuccess: () => { | |
| push("Developer added to project", "success"); | |
| onSaved(); | |
| onClose(); | |
| }, | |
| onError: (error) => push(toastFromError(error, "Could not add developer"), "error"), | |
| }); | |
| const updateMutation = useMutation({ | |
| mutationFn: ({ id, input }: { id: number; input: { start_date: string; end_date: string; allocation_pct: number } }) => | |
| updateAllocation(id, input), | |
| onSuccess: () => { | |
| push("Allocation updated", "success"); | |
| onSaved(); | |
| onClose(); | |
| }, | |
| onError: (error) => push(toastFromError(error, "Could not update allocation"), "error"), | |
| }); | |
| const deleteMutation = useMutation({ | |
| mutationFn: deleteAllocation, | |
| onSuccess: () => { | |
| push("Developer removed from project", "success"); | |
| onSaved(); | |
| onClose(); | |
| }, | |
| onError: (error) => push(toastFromError(error, "Could not remove developer"), "error"), | |
| }); | |
| const isSaving = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending; | |
| const availablePeople = allocation | |
| ? people.filter((person) => person.id === allocation.person_id) | |
| : people.filter((person) => !assignedPersonIds.includes(person.id)); | |
| const handleSave = () => { | |
| if (!project || personId === "") return; | |
| if (allocation) { | |
| updateMutation.mutate({ | |
| id: allocation.id, | |
| input: { start_date: startDate, end_date: endDate, allocation_pct: allocationPct }, | |
| }); | |
| return; | |
| } | |
| createMutation.mutate({ | |
| person_id: Number(personId), | |
| project_id: project.id, | |
| start_date: startDate, | |
| end_date: endDate, | |
| allocation_pct: allocationPct, | |
| }); | |
| }; | |
| return ( | |
| <Modal | |
| open={open && project !== null} | |
| onClose={onClose} | |
| size="md" | |
| title={allocation ? "Edit assignment" : `Add person — ${project?.name ?? ""}`} | |
| footer={ | |
| <> | |
| {allocation ? ( | |
| <button | |
| className="ghost-button danger" | |
| disabled={isSaving} | |
| onClick={() => { | |
| if (window.confirm("Remove this person from the project?")) { | |
| deleteMutation.mutate(allocation.id); | |
| } | |
| }} | |
| type="button" | |
| > | |
| Remove | |
| </button> | |
| ) : null} | |
| <span style={{ flex: 1 }} /> | |
| <button className="secondary-button" onClick={onClose} type="button"> | |
| Cancel | |
| </button> | |
| <button | |
| className="primary-button" | |
| disabled={isSaving || personId === "" || !startDate || !endDate} | |
| onClick={handleSave} | |
| type="button" | |
| > | |
| Save | |
| </button> | |
| </> | |
| } | |
| > | |
| {!project ? null : ( | |
| <div className="form-grid"> | |
| <label className="span-2"> | |
| Person | |
| <select | |
| disabled={!!allocation} | |
| onChange={(event) => setPersonId(event.target.value ? Number(event.target.value) : "")} | |
| value={personId} | |
| > | |
| <option value="">Select developer</option> | |
| {availablePeople.map((person) => ( | |
| <option key={person.id} value={person.id}> | |
| {person.name} — {person.role.name} | |
| </option> | |
| ))} | |
| </select> | |
| </label> | |
| <label> | |
| Start date | |
| <input onChange={(event) => setStartDate(event.target.value)} type="date" value={startDate} /> | |
| </label> | |
| <label> | |
| End date | |
| <input onChange={(event) => setEndDate(event.target.value)} type="date" value={endDate} /> | |
| </label> | |
| <label className="span-2"> | |
| Allocation | |
| <div className="row gap"> | |
| <input | |
| max={100} | |
| min={5} | |
| onChange={(event) => setAllocationPct(Number(event.target.value))} | |
| step={5} | |
| type="range" | |
| value={allocationPct} | |
| /> | |
| <span className="badge">{allocationPct}%</span> | |
| {personId !== "" ? ( | |
| <span className="muted-text"> | |
| {formatHoursPerDay( | |
| allocationPct, | |
| people.find((person) => person.id === Number(personId))?.weekly_capacity_hrs ?? 40, | |
| )} | |
| </span> | |
| ) : null} | |
| </div> | |
| </label> | |
| </div> | |
| )} | |
| </Modal> | |
| ); | |
| } | |