feat(261): areas edit+archive, users reset-password + multi-role

This commit is contained in:
2026-04-01 07:55:51 +00:00
parent d16b96920f
commit f22c8d9b71

View File

@@ -238,6 +238,8 @@ function AreasSettings() {
const [areaName, setAreaName] = useState("");
const [zoneName, setZoneName] = useState("");
const [zoneAreaId, setZoneAreaId] = useState("");
const [editArea, setEditArea] = useState<Area | null>(null);
const [editAreaName, setEditAreaName] = useState("");
const { data: areas, isLoading, refetch } = useQuery<Area[]>({
queryKey: ["areas"],
@@ -256,26 +258,31 @@ function AreasSettings() {
mutationFn: async () => {
await api.post("/api/v1/areas", { name: areaName });
},
onSuccess: () => {
toast.success("Area added");
setAreaName("");
setShowAddArea(false);
refetch();
},
onSuccess: () => { toast.success("Area added"); setAreaName(""); setShowAddArea(false); refetch(); },
onError: () => toast.error("Failed to add area"),
});
const updateAreaMutation = useMutation({
mutationFn: async () => {
await api.patch(`/api/v1/areas/${editArea!.id}`, { name: editAreaName });
},
onSuccess: () => { toast.success("Area updated"); setEditArea(null); refetch(); },
onError: () => toast.error("Failed to update area"),
});
const deleteAreaMutation = useMutation({
mutationFn: async (id: string) => {
await api.delete(`/api/v1/areas/${id}`);
},
onSuccess: () => { toast.success("Area archived"); refetch(); },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to archive area"),
});
const addZoneMutation = useMutation({
mutationFn: async () => {
await api.post("/api/v1/zones", { name: zoneName, areaId: zoneAreaId });
},
onSuccess: () => {
toast.success("Zone added");
setZoneName("");
setZoneAreaId("");
setShowAddZone(false);
refetch();
},
onSuccess: () => { toast.success("Zone added"); setZoneName(""); setZoneAreaId(""); setShowAddZone(false); refetch(); },
onError: () => toast.error("Failed to add zone"),
});
@@ -287,21 +294,14 @@ function AreasSettings() {
<CardHeader>
<CardTitle>Areas & Zones</CardTitle>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => setShowAddArea(true)}>
+ Area
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAddZone(true)}>
+ Zone
</Button>
<Button size="sm" variant="outline" onClick={() => setShowAddArea(true)}>+ Area</Button>
<Button size="sm" variant="outline" onClick={() => setShowAddZone(true)}>+ Zone</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHead>
<TableRow>
<Th>Area Name</Th>
<Th>Zones</Th>
</TableRow>
<TableRow><Th>Area Name</Th><Th>Zones</Th><Th>Actions</Th></TableRow>
</TableHead>
<TableBody>
{isLoading ? (
@@ -309,6 +309,7 @@ function AreasSettings() {
<TableRow key={i}>
<Td><div className="h-4 w-32 animate-pulse bg-gray-100 rounded" /></Td>
<Td><div className="h-4 w-24 animate-pulse bg-gray-100 rounded" /></Td>
<Td><div className="h-4 w-20 animate-pulse bg-gray-100 rounded" /></Td>
</TableRow>
))
) : areaList.length === 0 ? (
@@ -318,9 +319,13 @@ function AreasSettings() {
<TableRow key={a.id}>
<Td className="font-medium">{a.name}</Td>
<Td className="text-sm text-gray-500">
{a.zones?.length
? a.zones.map((z) => z.name).join(", ")
: <span className="text-gray-300">No zones</span>}
{a.zones?.length ? a.zones.map(z => z.name).join(", ") : <span className="text-gray-300">No zones</span>}
</Td>
<Td>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => { setEditArea(a); setEditAreaName(a.name); }}>Edit</Button>
<Button size="sm" variant="ghost" onClick={() => { if (confirm(`Archive area "${a.name}"?`)) deleteAreaMutation.mutate(a.id); }}>Archive</Button>
</div>
</Td>
</TableRow>
))
@@ -330,20 +335,24 @@ function AreasSettings() {
</CardContent>
</Card>
{/* Edit Area Modal */}
<Modal isOpen={!!editArea} onClose={() => setEditArea(null)} title="Edit Area">
<form onSubmit={(e) => { e.preventDefault(); updateAreaMutation.mutate(); }} className="space-y-4">
<Input label="Area Name" value={editAreaName} onChange={(e) => setEditAreaName(e.target.value)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setEditArea(null)}>Cancel</Button>
<Button type="submit" size="sm" isLoading={updateAreaMutation.isPending} disabled={!editAreaName.trim()}>Save</Button>
</div>
</form>
</Modal>
{/* Add Area Modal */}
<Modal isOpen={showAddArea} onClose={() => setShowAddArea(false)} title="Add Area">
<form onSubmit={(e) => { e.preventDefault(); addAreaMutation.mutate(); }} className="space-y-4">
<Input
label="Area Name"
value={areaName}
onChange={(e) => setAreaName(e.target.value)}
placeholder="e.g. North Sector"
/>
<Input label="Area Name" value={areaName} onChange={(e) => setAreaName(e.target.value)} placeholder="e.g. North Sector" />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddArea(false)}>Cancel</Button>
<Button type="submit" size="sm" isLoading={addAreaMutation.isPending} disabled={!areaName.trim()}>
Add Area
</Button>
<Button type="submit" size="sm" isLoading={addAreaMutation.isPending} disabled={!areaName.trim()}>Add Area</Button>
</div>
</form>
</Modal>
@@ -353,33 +362,16 @@ function AreasSettings() {
<form onSubmit={(e) => { e.preventDefault(); addZoneMutation.mutate(); }} className="space-y-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Area</label>
<select
className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={zoneAreaId}
onChange={(e) => setZoneAreaId(e.target.value)}
>
<select className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
value={zoneAreaId} onChange={(e) => setZoneAreaId(e.target.value)}>
<option value="">Select area</option>
{areaList.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
{areaList.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
<Input
label="Zone Name"
value={zoneName}
onChange={(e) => setZoneName(e.target.value)}
placeholder="e.g. Zone 1"
/>
<Input label="Zone Name" value={zoneName} onChange={(e) => setZoneName(e.target.value)} placeholder="e.g. Zone 1" />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setShowAddZone(false)}>Cancel</Button>
<Button
type="submit"
size="sm"
isLoading={addZoneMutation.isPending}
disabled={!zoneName.trim() || !zoneAreaId}
>
Add Zone
</Button>
<Button type="submit" size="sm" isLoading={addZoneMutation.isPending} disabled={!zoneName.trim() || !zoneAreaId}>Add Zone</Button>
</div>
</form>
</Modal>
@@ -636,6 +628,8 @@ const ROLES = ["ADMIN", "STAFF", "COLLECTOR", "TECHNICIAN"];
function UsersSettings() {
const qc = useQueryClient();
const [showAdd, setShowAdd] = useState(false);
const [resetPwUser, setResetPwUser] = useState<UserItem | null>(null);
const [newPassword, setNewPassword] = useState("");
const [form, setForm] = useState({ firstName: "", lastName: "", email: "", password: "", phone: "", role: "STAFF" });
const { data, isLoading, refetch } = useQuery<UserItem[]>({
@@ -671,6 +665,14 @@ function UsersSettings() {
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed"),
});
const resetPassword = useMutation({
mutationFn: async () => {
await api.patch(`/api/v1/users/${resetPwUser!.id}/password`, { newPassword });
},
onSuccess: () => { toast.success("Password reset!"); setResetPwUser(null); setNewPassword(""); },
onError: (e: any) => toast.error(e.response?.data?.message ?? "Failed to reset password"),
});
const users = data ?? [];
return (
@@ -693,17 +695,29 @@ function UsersSettings() {
) : users.length === 0 ? (
<EmptyState message="No users found" />
) : users.map(u => {
const role = u.roleAssignments?.[0]?.role ?? "—";
const roles = u.roleAssignments?.map(r => r.role) ?? [];
const primaryRole = roles[0] ?? "—";
return (
<TableRow key={u.id}>
<Td className="font-medium">{u.firstName} {u.lastName}<div className="text-xs text-gray-400">{u.phone ?? ""}</div></Td>
<Td className="text-sm text-gray-600">{u.email}</Td>
<Td><Badge variant={role === "ADMIN" ? "danger" : role === "STAFF" ? "default" as any : "muted"}>{role}</Badge></Td>
<Td>
<div className="flex flex-wrap gap-1">
{roles.length > 0 ? roles.map(r => (
<Badge key={r} variant={r === "ADMIN" ? "danger" : r === "STAFF" ? "default" as any : "muted"}>{r}</Badge>
)) : <Badge variant="muted"></Badge>}
</div>
</Td>
<Td><Badge variant={u.isActive ? "success" : "muted"}>{u.isActive ? "Active" : "Inactive"}</Badge></Td>
<Td>
<Button size="sm" variant="ghost" onClick={() => toggleActive.mutate({ id: u.id, isActive: u.isActive })}>
{u.isActive ? "Deactivate" : "Activate"}
</Button>
<div className="flex gap-1 flex-wrap">
<Button size="sm" variant="ghost" onClick={() => { setResetPwUser(u); setNewPassword(""); }}>
Reset PW
</Button>
<Button size="sm" variant="ghost" onClick={() => toggleActive.mutate({ id: u.id, isActive: u.isActive })}>
{u.isActive ? "Deactivate" : "Activate"}
</Button>
</div>
</Td>
</TableRow>
);
@@ -713,6 +727,21 @@ function UsersSettings() {
</CardContent>
</Card>
{/* Reset Password Modal */}
<Modal isOpen={!!resetPwUser} onClose={() => setResetPwUser(null)} title={`Reset Password — ${resetPwUser ? `${resetPwUser.firstName} ${resetPwUser.lastName}` : ""}`}>
<div className="space-y-4">
<p className="text-sm text-gray-500">Enter a new password for this user. They will need to use this to log in.</p>
<Input label="New Password" type="password" value={newPassword}
onChange={e => setNewPassword(e.target.value)} hint="Minimum 8 characters" />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setResetPwUser(null)}>Cancel</Button>
<Button onClick={() => resetPassword.mutate()} isLoading={resetPassword.isPending} disabled={newPassword.length < 8}>
Reset Password
</Button>
</div>
</div>
</Modal>
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add New User">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">