feat: web sprint 250-262 — plans/clients/tickets/audit/profile/settings/leads/reports #26

Merged
kibin merged 11 commits from feat/web-sprint-250-262 into main 2026-04-01 08:09:45 +00:00
Showing only changes of commit f22c8d9b71 - Show all commits

View File

@@ -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">