resource-portal / frontend /src /components /projects /AddProjectMemberModal.tsx
Gowrisankar
Remove project schedule dates from UI and tie effort to allocations.
489aea9
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>
);
}