289 lines
9.9 KiB
TypeScript
289 lines
9.9 KiB
TypeScript
import { useState } from "react"
|
|
import { Navigate } from "react-router-dom"
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Search, UserPlus } from "lucide-react"
|
|
import { systemMemberService } from "@/services"
|
|
import { useAdminRole } from "@/hooks/use-admin-role"
|
|
import { AdminRole, type AdminRoleValue } from "@/lib/admin-roles"
|
|
import { toast } from "sonner"
|
|
|
|
export default function SystemMembersPage() {
|
|
const { canAccessSystemMembers, canManageSystem } = useAdminRole()
|
|
const queryClient = useQueryClient()
|
|
const [page, setPage] = useState(1)
|
|
const [search, setSearch] = useState("")
|
|
const [open, setOpen] = useState(false)
|
|
const [form, setForm] = useState<{
|
|
email: string
|
|
firstName: string
|
|
lastName: string
|
|
password: string
|
|
role: AdminRoleValue
|
|
}>({
|
|
email: "",
|
|
firstName: "",
|
|
lastName: "",
|
|
password: "",
|
|
role: AdminRole.CUSTOMER_SUPPORT,
|
|
})
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ["admin", "system-members", page, search],
|
|
queryFn: () =>
|
|
systemMemberService.list({
|
|
page,
|
|
limit: 10,
|
|
search: search.trim() || undefined,
|
|
}),
|
|
enabled: canAccessSystemMembers,
|
|
})
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: () => systemMemberService.create(form),
|
|
onSuccess: () => {
|
|
toast.success("System user created")
|
|
queryClient.invalidateQueries({ queryKey: ["admin", "system-members"] })
|
|
setOpen(false)
|
|
setForm({
|
|
email: "",
|
|
firstName: "",
|
|
lastName: "",
|
|
password: "",
|
|
role: AdminRole.CUSTOMER_SUPPORT,
|
|
})
|
|
},
|
|
onError: () => toast.error("Failed to create user"),
|
|
})
|
|
|
|
if (!canAccessSystemMembers) {
|
|
return <Navigate to="/admin/dashboard" replace />
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
|
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
|
|
System users
|
|
</h1>
|
|
<p className="text-gray-500 mt-1 max-w-xl">
|
|
Internal staff who can access this panel. Actors: System Admin (full
|
|
access), Admin (view & edit), Customer Support (view-only on most
|
|
areas; cannot manage this list).
|
|
</p>
|
|
</div>
|
|
{canManageSystem && (
|
|
<Button
|
|
className="rounded-none gap-2"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
<UserPlus className="h-4 w-4" />
|
|
Add system user
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<Card className="border shadow-none rounded-none">
|
|
<CardHeader className="border-b flex flex-row items-center justify-between space-y-0 pb-4">
|
|
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
|
|
Directory
|
|
</CardTitle>
|
|
<div className="relative w-64">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<Input
|
|
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
|
|
placeholder="Search name or email…"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value)
|
|
setPage(1)
|
|
}}
|
|
/>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{error && (
|
|
<p className="p-6 text-sm text-amber-700 bg-amber-50 border-b">
|
|
Could not reach{" "}
|
|
<code className="text-xs">GET /admin/system-members</code>. Add this
|
|
route on your API to populate the table.
|
|
</p>
|
|
)}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left">
|
|
<thead className="bg-gray-50 border-b">
|
|
<tr>
|
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
Name
|
|
</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
Email
|
|
</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
|
|
Panel role
|
|
</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
|
|
Status
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{isLoading ? (
|
|
<tr>
|
|
<td
|
|
colSpan={4}
|
|
className="px-6 py-16 text-center text-gray-400 animate-pulse"
|
|
>
|
|
Loading…
|
|
</td>
|
|
</tr>
|
|
) : data?.data?.length ? (
|
|
data.data.map((m) => (
|
|
<tr key={m.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 text-sm font-semibold text-gray-900">
|
|
{m.firstName} {m.lastName}
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-gray-600">
|
|
{m.email}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<Badge variant="outline" className="rounded-none text-[10px]">
|
|
{m.role}
|
|
</Badge>
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-xs text-gray-600">
|
|
{m.isActive ? "Active" : "Disabled"}
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td
|
|
colSpan={4}
|
|
className="px-6 py-16 text-center text-gray-400 italic text-sm"
|
|
>
|
|
No system users loaded.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="rounded-none max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Add system user</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-3 py-2">
|
|
<div className="grid gap-1">
|
|
<Label htmlFor="sm-email">Email</Label>
|
|
<Input
|
|
id="sm-email"
|
|
value={form.email}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, email: e.target.value }))
|
|
}
|
|
className="rounded-none"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="grid gap-1">
|
|
<Label htmlFor="sm-fn">First name</Label>
|
|
<Input
|
|
id="sm-fn"
|
|
value={form.firstName}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, firstName: e.target.value }))
|
|
}
|
|
className="rounded-none"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-1">
|
|
<Label htmlFor="sm-ln">Last name</Label>
|
|
<Input
|
|
id="sm-ln"
|
|
value={form.lastName}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, lastName: e.target.value }))
|
|
}
|
|
className="rounded-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-1">
|
|
<Label htmlFor="sm-pw">Temporary password</Label>
|
|
<Input
|
|
id="sm-pw"
|
|
type="password"
|
|
value={form.password}
|
|
onChange={(e) =>
|
|
setForm((f) => ({ ...f, password: e.target.value }))
|
|
}
|
|
className="rounded-none"
|
|
/>
|
|
</div>
|
|
<div className="grid gap-1">
|
|
<Label>Role</Label>
|
|
<Select
|
|
value={form.role}
|
|
onValueChange={(role) =>
|
|
setForm((f) => ({ ...f, role: role as AdminRoleValue }))
|
|
}
|
|
>
|
|
<SelectTrigger className="rounded-none">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={AdminRole.SUPER_ADMIN}>
|
|
System Admin — full access
|
|
</SelectItem>
|
|
<SelectItem value={AdminRole.ADMIN}>
|
|
Admin — view & edit
|
|
</SelectItem>
|
|
<SelectItem value={AdminRole.CUSTOMER_SUPPORT}>
|
|
Customer Support — view (no member management)
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" className="rounded-none" onClick={() => setOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
className="rounded-none"
|
|
disabled={createMutation.isPending}
|
|
onClick={() => createMutation.mutate()}
|
|
>
|
|
Create
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|