Spaces:
Sleeping
Sleeping
| import { Eye, EyeOff, Plus, Trash2 } from 'lucide-react'; | |
| import type { FormEvent } from 'react'; | |
| import { useMemo, useState } from 'react'; | |
| import { toast } from 'sonner'; | |
| import { ConfigError, Field, InfoHint } from '@/components/admin/admin-form-parts'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Button } from '@/components/ui/button'; | |
| import { | |
| Card, | |
| CardContent, | |
| CardDescription, | |
| CardHeader, | |
| CardTitle, | |
| } from '@/components/ui/card'; | |
| import { Checkbox } from '@/components/ui/checkbox'; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from '@/components/ui/dialog'; | |
| import { Input } from '@/components/ui/input'; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select'; | |
| import { Spinner } from '@/components/ui/spinner'; | |
| import { | |
| emptyUserForm, | |
| formatDate, | |
| isUserActive, | |
| roleLabel, | |
| userToForm, | |
| } from '@/lib/admin'; | |
| import { | |
| createAdminUser, | |
| deleteAdminUser, | |
| updateAdminUser, | |
| } from '@/lib/rag-client'; | |
| import type { | |
| AdminRole, | |
| AdminUser, | |
| UserFilterForm, | |
| UserForm, | |
| } from '@/types/admin'; | |
| export function AdminUserCreateCard({ | |
| onCreated, | |
| }: { | |
| onCreated?: (user: AdminUser) => void; | |
| }) { | |
| const [formData, setFormData] = useState<UserForm>(emptyUserForm); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [error, setError] = useState<string | undefined>(); | |
| function setField<K extends keyof UserForm>( | |
| key: K, | |
| value: UserForm[K], | |
| ): void { | |
| setFormData((prev) => ({ ...prev, [key]: value })); | |
| } | |
| async function handleSubmit( | |
| event: FormEvent<HTMLFormElement>, | |
| ): Promise<void> { | |
| event.preventDefault(); | |
| setIsSubmitting(true); | |
| setError(undefined); | |
| try { | |
| const payload = buildCreatePayload(formData); | |
| const created = await createAdminUser(payload); | |
| setFormData(emptyUserForm); | |
| onCreated?.(created); | |
| } catch (e) { | |
| setError( | |
| e instanceof Error ? e.message : 'Gagal membuat user.', | |
| ); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| } | |
| return ( | |
| <Card className="border-(--lecturer-border) bg-(--lecturer-surface)"> | |
| <CardHeader> | |
| <CardTitle className="flex items-center gap-2"> | |
| <Plus className="size-5 text-(--lecturer-primary)" /> | |
| Tambah User | |
| </CardTitle> | |
| <CardDescription> | |
| Payload mengikuti schema UpsertUser dari API. | |
| </CardDescription> | |
| </CardHeader> | |
| <CardContent> | |
| <UserFormFields | |
| data={formData} | |
| error={error} | |
| isSubmitting={isSubmitting} | |
| setField={setField} | |
| submitLabel="Buat User" | |
| onSubmit={(e) => { | |
| void handleSubmit(e); | |
| }} | |
| /> | |
| </CardContent> | |
| </Card> | |
| ); | |
| } | |
| export function AdminUserManagement({ | |
| users: initialUsers = [], | |
| }: { | |
| users?: AdminUser[]; | |
| }) { | |
| const [users, setUsers] = useState<AdminUser[]>(initialUsers); | |
| const [isCreating, setIsCreating] = useState(false); | |
| const [createFormData, setCreateFormData] = useState<UserForm>(emptyUserForm); | |
| const [isSubmittingCreate, setIsSubmittingCreate] = useState(false); | |
| const [createError, setCreateError] = useState<string | undefined>(); | |
| const [editingUser, setEditingUser] = useState<AdminUser | null>(null); | |
| const [editFormData, setEditFormData] = useState<UserForm>(emptyUserForm); | |
| const [isEditing, setIsEditing] = useState(false); | |
| const [editError, setEditError] = useState<string | undefined>(); | |
| const [filterForm, setFilterForm] = useState<UserFilterForm>({ | |
| email: '', | |
| name: '', | |
| role: 'all', | |
| }); | |
| const userStats = useMemo(() => { | |
| const adminCount = users.filter((user) => user.role === 'admin').length; | |
| return { | |
| adminCount, | |
| total: users.length, | |
| }; | |
| }, [users]); | |
| const filteredUsers = useMemo(() => { | |
| return users.filter((user) => { | |
| const nameMatch = | |
| filterForm.name === '' || | |
| (user.name ?? '').toLowerCase().includes(filterForm.name.toLowerCase()); | |
| const emailMatch = | |
| filterForm.email === '' || | |
| (user.email ?? '').toLowerCase().includes(filterForm.email.toLowerCase()); | |
| const roleMatch = | |
| filterForm.role === 'all' || user.role === filterForm.role; | |
| return nameMatch && emailMatch && roleMatch; | |
| }); | |
| }, [users, filterForm]); | |
| function setCreateField<K extends keyof UserForm>( | |
| key: K, | |
| value: UserForm[K], | |
| ): void { | |
| setCreateFormData((prev) => ({ ...prev, [key]: value })); | |
| } | |
| function setEditField<K extends keyof UserForm>( | |
| key: K, | |
| value: UserForm[K], | |
| ): void { | |
| setEditFormData((prev) => ({ ...prev, [key]: value })); | |
| } | |
| function openEditUserDialog(user: AdminUser): void { | |
| setEditingUser(user); | |
| setEditFormData(userToForm(user)); | |
| setEditError(undefined); | |
| } | |
| async function handleCreateUser( | |
| event: FormEvent<HTMLFormElement>, | |
| ): Promise<void> { | |
| event.preventDefault(); | |
| setIsSubmittingCreate(true); | |
| setCreateError(undefined); | |
| try { | |
| const payload = buildCreatePayload(createFormData); | |
| const created = await createAdminUser(payload); | |
| setUsers((prev) => [created, ...prev]); | |
| setCreateFormData(emptyUserForm); | |
| setIsCreating(false); | |
| } catch (e) { | |
| setCreateError( | |
| e instanceof Error ? e.message : 'Gagal membuat user.', | |
| ); | |
| } finally { | |
| setIsSubmittingCreate(false); | |
| } | |
| } | |
| async function handleUpdateUser( | |
| event: FormEvent<HTMLFormElement>, | |
| ): Promise<void> { | |
| event.preventDefault(); | |
| if (!editingUser) { | |
| return; | |
| } | |
| setIsEditing(true); | |
| setEditError(undefined); | |
| try { | |
| const payload = buildUpdatePayload(editFormData); | |
| const updated = await updateAdminUser(editingUser.id, payload); | |
| setUsers((prev) => | |
| prev.map((u) => (u.id === updated.id ? updated : u)), | |
| ); | |
| setEditingUser(null); | |
| } catch (e) { | |
| setEditError( | |
| e instanceof Error ? e.message : 'Gagal memperbarui user.', | |
| ); | |
| } finally { | |
| setIsEditing(false); | |
| } | |
| } | |
| const [deletingUser, setDeletingUser] = useState<AdminUser | null>(null); | |
| const [isDeleting, setIsDeleting] = useState(false); | |
| async function handleDeleteUser(): Promise<void> { | |
| if (!deletingUser) return; | |
| setIsDeleting(true); | |
| try { | |
| await deleteAdminUser(deletingUser.id); | |
| window.location.reload(); | |
| } catch (e) { | |
| toast.error( | |
| e instanceof Error ? e.message : 'Gagal menghapus user.', | |
| ); | |
| setIsDeleting(false); | |
| } | |
| } | |
| return ( | |
| <> | |
| <Card className="border-(--lecturer-border) bg-(--lecturer-surface)"> | |
| <CardHeader> | |
| <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> | |
| <div> | |
| <CardTitle>User Management</CardTitle> | |
| <CardDescription> | |
| Total {userStats.total} user,{' '} | |
| {userStats.adminCount} admin. | |
| </CardDescription> | |
| </div> | |
| <div className="flex flex-col gap-2"> | |
| <div className="flex justify-end"> | |
| <Button | |
| onClick={() => { | |
| setCreateFormData(emptyUserForm); | |
| setCreateError(undefined); | |
| setIsCreating(true); | |
| }} | |
| type="button" | |
| > | |
| <Plus className="size-4" /> | |
| Tambah User | |
| </Button> | |
| </div> | |
| <div className="grid gap-2 sm:grid-cols-[1fr_1fr_160px]"> | |
| <Input | |
| placeholder="Cari nama..." | |
| value={filterForm.name} | |
| onChange={(event) => | |
| setFilterForm((prev) => ({ | |
| ...prev, | |
| name: event.target.value, | |
| })) | |
| } | |
| /> | |
| <Input | |
| placeholder="Cari email..." | |
| value={filterForm.email} | |
| onChange={(event) => | |
| setFilterForm((prev) => ({ | |
| ...prev, | |
| email: event.target.value, | |
| })) | |
| } | |
| /> | |
| <Select | |
| value={filterForm.role} | |
| onValueChange={(value) => | |
| setFilterForm((prev) => ({ | |
| ...prev, | |
| role: value as UserFilterForm['role'], | |
| })) | |
| } | |
| > | |
| <SelectTrigger className="w-full"> | |
| <SelectValue placeholder="Role" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="all"> | |
| Semua role | |
| </SelectItem> | |
| <SelectItem value="student"> | |
| Mahasiswa | |
| </SelectItem> | |
| <SelectItem value="lecturer"> | |
| Dosen | |
| </SelectItem> | |
| <SelectItem value="admin">Admin</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="overflow-hidden rounded-xl border border-(--lecturer-border)"> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full min-w-190 text-sm"> | |
| <thead className="bg-muted/50 text-left text-muted-foreground"> | |
| <tr> | |
| <th className="px-4 py-3">Nama</th> | |
| <th className="px-4 py-3">Role</th> | |
| <th className="px-4 py-3">Status</th> | |
| <th className="px-4 py-3"> | |
| Superuser | |
| </th> | |
| <th className="px-4 py-3">Dibuat</th> | |
| <th className="px-4 py-3 text-right"> | |
| Aksi | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredUsers.map((user) => ( | |
| <tr | |
| className="border-t border-(--lecturer-border)" | |
| key={user.id} | |
| > | |
| <td className="px-4 py-3"> | |
| <div className="font-medium"> | |
| {user.name ?? '-'} | |
| </div> | |
| <div className="text-muted-foreground"> | |
| {user.email ?? '-'} | |
| </div> | |
| </td> | |
| <td className="px-4 py-3"> | |
| <Badge variant="outline"> | |
| {roleLabel(user.role)} | |
| </Badge> | |
| </td> | |
| <td className="px-4 py-3"> | |
| <Badge | |
| className={ | |
| isUserActive(user) | |
| ? 'bg-(--lecturer-primary-soft) text-(--lecturer-primary)' | |
| : '' | |
| } | |
| variant={ | |
| isUserActive(user) | |
| ? 'secondary' | |
| : 'outline' | |
| } | |
| > | |
| {isUserActive(user) | |
| ? 'Aktif' | |
| : 'Nonaktif'} | |
| </Badge> | |
| </td> | |
| <td className="px-4 py-3"> | |
| {user.is_superuser | |
| ? 'Ya' | |
| : 'Tidak'} | |
| </td> | |
| <td className="px-4 py-3"> | |
| {formatDate(user.created_at)} | |
| </td> | |
| <td className="px-4 py-3"> | |
| <div className="flex justify-end gap-2"> | |
| <Button | |
| onClick={() => | |
| openEditUserDialog( | |
| user, | |
| ) | |
| } | |
| size="sm" | |
| type="button" | |
| variant="outline" | |
| > | |
| Edit | |
| </Button> | |
| <Button | |
| className="text-destructive hover:text-destructive" | |
| onClick={() => setDeletingUser(user)} | |
| size="sm" | |
| type="button" | |
| variant="outline" | |
| > | |
| <Trash2 className="size-4" /> | |
| </Button> | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| {filteredUsers.length === 0 ? ( | |
| <tr> | |
| <td | |
| className="px-4 py-8 text-center text-muted-foreground" | |
| colSpan={6} | |
| > | |
| {users.length === 0 | |
| ? 'Tidak ada user dari response backend.' | |
| : 'Tidak ada user yang cocok dengan filter.'} | |
| </td> | |
| </tr> | |
| ) : null} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Dialog onOpenChange={setIsCreating} open={isCreating}> | |
| <DialogContent className="max-w-2xl"> | |
| <DialogHeader> | |
| <DialogTitle>Tambah User</DialogTitle> | |
| <DialogDescription> | |
| Payload mengikuti schema UpsertUser dari API. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <UserFormFields | |
| data={createFormData} | |
| error={createError} | |
| isSubmitting={isSubmittingCreate} | |
| setField={setCreateField} | |
| submitLabel="Buat User" | |
| onSubmit={(e) => { | |
| void handleCreateUser(e); | |
| }} | |
| /> | |
| </DialogContent> | |
| </Dialog> | |
| <Dialog | |
| onOpenChange={(open) => setEditingUser(open ? editingUser : null)} | |
| open={editingUser !== null} | |
| > | |
| <DialogContent className="max-w-2xl"> | |
| <DialogHeader> | |
| <DialogTitle>Edit User</DialogTitle> | |
| <DialogDescription> | |
| Perubahan dikirim ke endpoint update user backend. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <UserFormFields | |
| data={editFormData} | |
| error={editError} | |
| isSubmitting={isEditing} | |
| mode="edit" | |
| setField={setEditField} | |
| submitLabel="Simpan User" | |
| onSubmit={(e) => { | |
| void handleUpdateUser(e); | |
| }} | |
| /> | |
| </DialogContent> | |
| </Dialog> | |
| <Dialog | |
| open={deletingUser !== null} | |
| onOpenChange={(open) => { if (!open) setDeletingUser(null); }} | |
| > | |
| <DialogContent> | |
| <DialogHeader> | |
| <DialogTitle>Hapus user?</DialogTitle> | |
| <DialogDescription> | |
| Akun <span className="font-medium">{deletingUser?.email ?? '-'}</span> akan dihapus permanen dan tidak bisa dikembalikan. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <DialogFooter> | |
| <Button | |
| onClick={() => setDeletingUser(null)} | |
| type="button" | |
| variant="outline" | |
| disabled={isDeleting} | |
| > | |
| Batal | |
| </Button> | |
| <Button | |
| className="bg-destructive text-white hover:bg-destructive/90" | |
| disabled={isDeleting} | |
| onClick={() => { void handleDeleteUser(); }} | |
| type="button" | |
| > | |
| {isDeleting ? <Spinner className="size-4" /> : null} | |
| Hapus | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| </> | |
| ); | |
| } | |
| function buildCreatePayload(form: UserForm): Record<string, unknown> { | |
| // POST /auth/register — RegisterRequest schema | |
| const payload: Record<string, unknown> = { role: form.role }; | |
| if (form.email) payload.email = form.email; | |
| if (form.name) payload.name = form.name; | |
| if (form.password) payload.password = form.password; | |
| if (form.identity_number) payload.identity_number = form.identity_number; | |
| if (form.phone) payload.phone = form.phone; | |
| return payload; | |
| } | |
| function buildUpdatePayload(form: UserForm): Record<string, unknown> { | |
| // PATCH /user/{id} — PatchUser schema (additionalProperties: false) | |
| // Only: email, name, phone, role, is_active, password | |
| const payload: Record<string, unknown> = { | |
| is_active: form.is_active, | |
| role: form.role, | |
| }; | |
| if (form.email) payload.email = form.email; | |
| if (form.name) payload.name = form.name; | |
| if (form.phone) payload.phone = form.phone; | |
| if (form.password) payload.password = form.password; | |
| return payload; | |
| } | |
| function UserFormFields({ | |
| data, | |
| error, | |
| isSubmitting, | |
| mode = 'create', | |
| onSubmit, | |
| setField, | |
| submitLabel, | |
| }: { | |
| data: UserForm; | |
| error?: string; | |
| isSubmitting: boolean; | |
| mode?: 'create' | 'edit'; | |
| onSubmit: (event: FormEvent<HTMLFormElement>) => void; | |
| setField: <K extends keyof UserForm>(key: K, value: UserForm[K]) => void; | |
| submitLabel: string; | |
| }) { | |
| const [showPassword, setShowPassword] = useState(false); | |
| return ( | |
| <form className="grid gap-4" onSubmit={onSubmit}> | |
| <ConfigError message={error} /> | |
| <div className="grid gap-3 md:grid-cols-2"> | |
| <Field label="Nama"> | |
| <Input | |
| value={data.name} | |
| onChange={(event) => | |
| setField('name', event.target.value) | |
| } | |
| /> | |
| </Field> | |
| <Field label="Email"> | |
| <Input | |
| type="email" | |
| value={data.email} | |
| onChange={(event) => | |
| setField('email', event.target.value) | |
| } | |
| /> | |
| </Field> | |
| <Field | |
| hint={ | |
| mode === 'edit' | |
| ? 'Kosongkan jika tidak ingin mengubah password.' | |
| : 'Password untuk login user.' | |
| } | |
| label="Password" | |
| > | |
| <div className="relative"> | |
| <Input | |
| autoComplete="off" | |
| className="pr-10" | |
| placeholder={mode === 'edit' ? '(tidak diubah)' : '••••••••'} | |
| type={showPassword ? 'text' : 'password'} | |
| value={data.password} | |
| onChange={(event) => | |
| setField('password', event.target.value) | |
| } | |
| /> | |
| <button | |
| className="text-muted-foreground hover:text-foreground absolute right-3 top-1/2 -translate-y-1/2" | |
| type="button" | |
| onClick={() => setShowPassword((v) => !v)} | |
| > | |
| {showPassword ? ( | |
| <EyeOff className="size-4" /> | |
| ) : ( | |
| <Eye className="size-4" /> | |
| )} | |
| </button> | |
| </div> | |
| </Field> | |
| <Field label="Nomor Identitas"> | |
| <Input | |
| value={data.identity_number} | |
| onChange={(event) => | |
| setField('identity_number', event.target.value) | |
| } | |
| /> | |
| </Field> | |
| <Field label="Telepon"> | |
| <Input | |
| value={data.phone} | |
| onChange={(event) => | |
| setField('phone', event.target.value) | |
| } | |
| /> | |
| </Field> | |
| <Field label="Role"> | |
| <Select | |
| value={data.role} | |
| onValueChange={(value) => | |
| setField('role', value as AdminRole) | |
| } | |
| > | |
| <SelectTrigger className="w-full"> | |
| <SelectValue placeholder="Pilih role" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="student">Mahasiswa</SelectItem> | |
| <SelectItem value="lecturer">Dosen</SelectItem> | |
| <SelectItem value="admin">Admin</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </Field> | |
| </div> | |
| <div className="grid gap-3 sm:grid-cols-2"> | |
| <label className="flex items-center gap-3 rounded-lg border border-(--lecturer-border) p-3 text-sm"> | |
| <Checkbox | |
| checked={data.is_active} | |
| onCheckedChange={(value) => | |
| setField('is_active', value === true) | |
| } | |
| /> | |
| <span className="flex items-center gap-1.5"> | |
| User aktif | |
| <InfoHint> | |
| User dapat login dan menggunakan sistem. Nonaktifkan untuk memblokir akses tanpa menghapus akun. | |
| </InfoHint> | |
| </span> | |
| </label> | |
| <label className="flex items-center gap-3 rounded-lg border border-(--lecturer-border) p-3 text-sm"> | |
| <Checkbox | |
| checked={data.is_superuser} | |
| onCheckedChange={(value) => | |
| setField('is_superuser', value === true) | |
| } | |
| /> | |
| <span className="flex items-center gap-1.5"> | |
| Superuser | |
| <InfoHint> | |
| Memiliki hak akses penuh ke semua fitur sistem tanpa pembatasan role. | |
| </InfoHint> | |
| </span> | |
| </label> | |
| </div> | |
| <div className="flex justify-end"> | |
| <Button disabled={isSubmitting} type="submit"> | |
| {isSubmitting ? <Spinner className="size-4" /> : null} | |
| {submitLabel} | |
| </Button> | |
| </div> | |
| </form> | |
| ); | |
| } | |