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