CAPS04-FE-REFACTOR / resources /js /components /admin /admin-user-management.tsx
wepee's picture
fix: last
2d1d3d3
Raw
History Blame Contribute Delete
28.5 kB
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>
);
}