feat(dimentorin): backoffice admin panel (dashboard, user mgmt, session mgmt, content/roadmap, feedback, settings)

This commit is contained in:
asepharyana
2026-08-06 01:03:50 +07:00
parent 7901fc3f82
commit 567d237651
13 changed files with 1979 additions and 11 deletions
@@ -0,0 +1,234 @@
import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn } from '@imphnen-frontend-service/utils';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
BookOutlined,
FileTextOutlined,
FlagOutlined,
} from '@ant-design/icons';
import { toast } from 'sonner';
import {
useGetRoadmapList,
usePostCreateRoadmap,
usePatchUpdateRoadmap,
useDeleteRoadmap,
useGetMaterialsList,
} from '@imphnen-frontend-service/service';
import { TRoadmapRequest } from '@imphnen-frontend-service/service';
const STATUS_STYLE: Record<string, string> = {
upcoming: 'bg-warning-100 text-warning-800',
in_progress: 'bg-primary-100 text-primary-700',
shipped: 'bg-success-100 text-success-700',
};
const STATUS_OPTIONS = ['upcoming', 'in_progress', 'shipped'];
const emptyForm: TRoadmapRequest = { title: '', description: '', status: 'upcoming' };
const Components: FC = (): ReactElement => {
const { data: roadmap, isLoading: roadmapLoading, refetch } = useGetRoadmapList();
const { data: materials } = useGetMaterialsList();
const createRoadmap = usePostCreateRoadmap();
const updateRoadmap = usePatchUpdateRoadmap();
const deleteRoadmap = useDeleteRoadmap();
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<TRoadmapRequest>(emptyForm);
const [saving, setSaving] = useState(false);
const items = roadmap ?? [];
const materialList = Array.isArray(materials) ? materials : materials?.data ?? [];
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setModalOpen(true);
};
const openEdit = (r: (typeof items)[number]) => {
setEditingId(r.id);
setForm({ title: r.title, description: r.description, status: r.status });
setModalOpen(true);
};
const handleSave = async () => {
if (!form.title || !form.description) {
toast.error('Judul dan deskripsi wajib diisi');
return;
}
setSaving(true);
try {
if (editingId) {
await updateRoadmap.mutateAsync({ id: editingId, payload: form });
toast.success('Roadmap diperbarui');
} else {
await createRoadmap.mutateAsync(form);
toast.success('Roadmap ditambahkan');
}
setModalOpen(false);
await refetch();
} catch {
toast.error('Gagal menyimpan roadmap');
} finally {
setSaving(false);
}
};
const handleDelete = async (r: (typeof items)[number]) => {
if (!window.confirm(`Hapus roadmap "${r.title}"?`)) return;
try {
await deleteRoadmap.mutateAsync(r.id);
toast.success('Roadmap dihapus');
await refetch();
} catch {
toast.error('Gagal menghapus roadmap');
}
};
const inputCls =
'w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500';
return (
<div className="max-w-7xl mx-auto space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-2xl font-bold text-neutral-800">Content & Roadmap</h2>
<p className="text-neutral-500 mt-1">Kelola materi mentoring dan jalur belajar.</p>
</div>
<Button size="sm" variant="primary" onClick={openCreate}>
<span className="flex items-center gap-1.5"><PlusOutlined /> Tambah Roadmap</span>
</Button>
</div>
{/* Materials */}
<section className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-neutral-100 flex items-center gap-2">
<BookOutlined className="text-primary-500" />
<h3 className="font-semibold text-neutral-800">Materi Mentoring</h3>
<span className="ml-auto text-xs text-neutral-400">{materialList.length} materi</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-400 border-b border-neutral-100">
<th className="px-5 py-3 font-medium">Judul</th>
<th className="px-5 py-3 font-medium">Kategori</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium">Dibuat</th>
</tr>
</thead>
<tbody>
<Show condition={materialList.length === 0}>
<tr>
<td colSpan={4} className="px-5 py-10 text-center text-neutral-400">
Belum ada materi.
</td>
</tr>
</Show>
<For data={materialList}>
{(m) => (
<tr className="border-b border-neutral-50 hover:bg-neutral-50/50">
<td className="px-5 py-3 font-medium text-neutral-800">{m.title}</td>
<td className="px-5 py-3 text-neutral-600">{m.category}</td>
<td className="px-5 py-3">
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', m.isPublished ? 'bg-success-100 text-success-700' : 'bg-neutral-100 text-neutral-500')}>
{m.isPublished ? 'Published' : 'Draft'}
</span>
</td>
<td className="px-5 py-3 text-neutral-500">
{new Date(m.createdAt).toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' })}
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
{/* Roadmap */}
<section className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-neutral-100 flex items-center gap-2">
<FlagOutlined className="text-primary-500" />
<h3 className="font-semibold text-neutral-800">Roadmap / Jalur Belajar</h3>
</div>
<Show condition={roadmapLoading}>
<div className="p-10 text-center text-neutral-400">Memuat...</div>
</Show>
<div className="divide-y divide-neutral-50">
<Show condition={!roadmapLoading && items.length === 0}>
<div className="p-10 text-center text-neutral-400">Belum ada item roadmap.</div>
</Show>
<For data={items}>
{(r) => (
<div className="flex items-start gap-4 px-5 py-4 hover:bg-neutral-50/50">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-neutral-800">{r.title}</p>
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium capitalize', STATUS_STYLE[r.status] ?? 'bg-neutral-100 text-neutral-600')}>
{r.status.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-neutral-500 mt-1 line-clamp-2">{r.description}</p>
<p className="text-xs text-neutral-400 mt-1">{r.votes} votes</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button type="button" onClick={() => openEdit(r)} className="size-8 rounded-lg text-neutral-500 hover:bg-primary-50 hover:text-primary-500 flex items-center justify-center" title="Edit">
<EditOutlined />
</button>
<button type="button" onClick={() => handleDelete(r)} className="size-8 rounded-lg text-neutral-500 hover:bg-danger-50 hover:text-danger-500 flex items-center justify-center" title="Hapus">
<DeleteOutlined />
</button>
</div>
</div>
)}
</For>
</div>
</section>
<Show condition={modalOpen}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
<div className="px-6 py-4 border-b border-neutral-100 flex items-center justify-between">
<h3 className="font-semibold text-neutral-800">
{editingId ? 'Edit Roadmap' : 'Tambah Roadmap'}
</h3>
<button type="button" onClick={() => setModalOpen(false)} className="text-neutral-400 hover:text-neutral-600 text-xl leading-none">×</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Judul</label>
<input className={inputCls} value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Deskripsi</label>
<textarea rows={3} className={inputCls} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Status</label>
<select className={inputCls} value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
<For data={STATUS_OPTIONS}>
{(s) => <option key={s} value={s}>{s.replace('_', ' ')}</option>}
</For>
</select>
</div>
</div>
<div className="px-6 py-4 border-t border-neutral-100 flex justify-end gap-2">
<Button size="sm" variant="bordered" onClick={() => setModalOpen(false)}>Batal</Button>
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</div>
</div>
</Show>
</div>
);
};
export default Components;
@@ -0,0 +1,118 @@
import { FC, ReactElement, useMemo, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn } from '@imphnen-frontend-service/utils';
import { StarFilled, StarOutlined, MessageOutlined } from '@ant-design/icons';
import { useGetAdminSessions } from '@imphnen-frontend-service/service';
const PAGE_SIZE = 10;
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
};
const StarRating: FC<{ rating: number }> = ({ rating }): ReactElement => (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((n) =>
n <= rating ? (
<StarFilled key={n} className="text-warning-400 text-sm" />
) : (
<StarOutlined key={n} className="text-neutral-200 text-sm" />
)
)}
</div>
);
const Components: FC = (): ReactElement => {
const [page, setPage] = useState(1);
const { data, isLoading } = useGetAdminSessions({
page,
per_page: PAGE_SIZE,
});
const sessions = data?.data ?? [];
const total = data?.meta?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const withFeedback = useMemo(
() => sessions.filter((s) => s.feedback != null || s.rating != null),
[sessions]
);
return (
<div className="max-w-7xl mx-auto space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-800">Feedback & Review</h2>
<p className="text-neutral-500 mt-1">Ulasan mentee terhadap sesi mentoring yang sudah selesai.</p>
</div>
<div className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-neutral-100 flex items-center gap-2">
<MessageOutlined className="text-primary-500" />
<h3 className="font-semibold text-neutral-800">Riwayat Feedback</h3>
<span className="ml-auto text-xs text-neutral-400">{withFeedback.length} ulasan di halaman ini</span>
</div>
<Show condition={isLoading}>
<div className="p-10 text-center text-neutral-400">Memuat...</div>
</Show>
<div className="divide-y divide-neutral-50">
<Show condition={!isLoading && withFeedback.length === 0}>
<div className="p-10 text-center text-neutral-400">
Belum ada feedback dari mentee.
</div>
</Show>
<For data={withFeedback}>
{(s) => (
<div className="px-5 py-4 hover:bg-neutral-50/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="font-medium text-neutral-800">{s.topic}</p>
<p className="text-xs text-neutral-400">
{s.mentee_fullname || s.mentee_email || 'Mentee'} · {formatDate(s.scheduled_at)}
</p>
</div>
<Show condition={s.rating != null}>
<StarRating rating={s.rating ?? 0} />
</Show>
</div>
<Show condition={!!s.feedback}>
<p className="text-sm text-neutral-600 mt-2 bg-neutral-50 rounded-lg p-3 border border-neutral-100">
{s.feedback}
</p>
</Show>
<Show condition={!s.feedback}>
<p className="text-sm text-neutral-400 mt-2 italic">Tanpa komentar tertulis.</p>
</Show>
</div>
)}
</For>
</div>
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-100">
<p className="text-xs text-neutral-400">
{total} sesi total · halaman {page}/{totalPages}
</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="bordered" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Sebelumnya
</Button>
<Button size="sm" variant="bordered" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Berikutnya
</Button>
</div>
</div>
</div>
</div>
);
};
export default Components;
@@ -0,0 +1,147 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Link, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { cn, SessionToken } from '@imphnen-frontend-service/utils';
import {
DashboardOutlined,
TeamOutlined,
CalendarOutlined,
BookOutlined,
StarOutlined,
SettingOutlined,
LogoutOutlined,
} from '@ant-design/icons';
import { useGetUserMe } from '@imphnen-frontend-service/service';
type NavItem = {
label: string;
to: string;
icon: ReactElement;
end?: boolean;
};
const NAV: NavItem[] = [
{ label: 'Dashboard', to: '/mentoring/backoffice', icon: <DashboardOutlined />, end: true },
{ label: 'User Management', to: '/mentoring/backoffice/users', icon: <TeamOutlined /> },
{ label: 'Session Management', to: '/mentoring/backoffice/sessions', icon: <CalendarOutlined /> },
{ label: 'Content & Roadmap', to: '/mentoring/backoffice/content', icon: <BookOutlined /> },
{ label: 'Feedback & Review', to: '/mentoring/backoffice/feedback', icon: <StarOutlined /> },
{ label: 'Settings', to: '/mentoring/backoffice/settings', icon: <SettingOutlined /> },
];
const isAdmin = (roleName?: string) => {
const n = roleName?.toLowerCase() ?? '';
return n === 'admin' || n === 'administrator' || n === 'super admin';
};
const jwtPayload = (): { email?: string; name?: string } | null => {
const raw = SessionToken.get()?.token?.access_token;
if (!raw) return null;
try {
const part = raw.split('.')[1];
if (!part) return null;
const json = atob(part.replace(/-/g, '+').replace(/_/g, '/'));
const pad = json.length % 4 === 0 ? json : json + '='.repeat(4 - (json.length % 4));
return JSON.parse(pad);
} catch {
return null;
}
};
const BackofficeLayout: FC = (): ReactElement => {
const location = useLocation();
const navigate = useNavigate();
const { data: me, isLoading } = useGetUserMe();
const payload = jwtPayload();
const name = me?.fullname || payload?.name || payload?.email?.split('@')[0] || 'Admin';
const initial = name[0]?.toUpperCase() ?? 'A';
const isActive = (item: NavItem) => {
if (item.end) return location.pathname === item.to;
return location.pathname.startsWith(item.to);
};
const handleLogout = () => {
SessionToken.remove();
navigate('/auth/login');
};
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-primary-50/40">
<p className="text-neutral-500">Memuat Backoffice...</p>
</div>
);
}
if (!me) {
return <Navigate to="/auth/login" replace />;
}
if (!isAdmin(me.role.name)) {
return <Navigate to="/mentoring/dashboard" replace />;
}
return (
<div className="flex min-h-screen bg-primary-50/40">
<aside className="hidden lg:flex w-[240px] shrink-0 flex-col bg-white border-r border-neutral-100 sticky top-0 h-screen">
<div className="px-6 py-6">
<p className="text-[19px] font-bold">
<span className="text-primary-500">Dimentorin</span>.dev
</p>
<p className="text-xs text-neutral-400 mt-1">Backoffice Admin</p>
</div>
<nav className="flex-1 px-3 space-y-1">
{NAV.map((item) => (
<Link
key={item.label}
to={item.to}
className={cn(
'flex items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
isActive(item)
? 'bg-primary-100/70 text-primary-600'
: 'text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700'
)}
>
<span className="text-base">{item.icon}</span>
{item.label}
</Link>
))}
</nav>
<div className="p-3 border-t border-neutral-100">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-4 py-2.5 text-sm font-medium text-neutral-500 hover:bg-danger-50 hover:text-danger-500 transition-colors"
>
<LogoutOutlined /> Log Out
</button>
</div>
</aside>
<div className="flex-1 min-w-0 flex flex-col">
<header className="flex items-center justify-between gap-4 px-6 py-4 border-b border-neutral-100 bg-white/70 backdrop-blur sticky top-0 z-10">
<div>
<h1 className="text-[19px] font-bold text-neutral-800">Backoffice</h1>
<p className="text-xs text-neutral-400 hidden sm:block">Dimentorin.dev Admin Panel</p>
</div>
<div className="flex items-center gap-2">
<div className="size-9 rounded-full bg-primary-500 text-white flex items-center justify-center text-sm font-semibold">
{initial}
</div>
<div className="hidden sm:block">
<p className="text-sm font-semibold text-neutral-800 leading-tight">{name}</p>
<p className="text-xs text-neutral-400 capitalize">{me.role.name}</p>
</div>
</div>
</header>
<main className="flex-1 px-6 py-6 lg:px-8">
<Outlet />
</main>
</div>
</div>
);
};
export default BackofficeLayout;
@@ -0,0 +1,206 @@
import { FC, ReactElement, useMemo } from 'react';
import { Link } from 'react-router-dom';
import { For, Show, cn } from '@imphnen-frontend-service/utils';
import {
TeamOutlined,
CalendarOutlined,
BookOutlined,
StarOutlined,
UserAddOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import {
useGetUsersList,
useGetAdminSessions,
useGetMaterialsList,
} from '@imphnen-frontend-service/service';
const STATUS_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
confirmed: 'bg-success-100 text-success-700',
completed: 'bg-primary-100 text-primary-700',
cancelled: 'bg-danger-100 text-danger-600',
};
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
};
const StatCard: FC<{
icon: ReactElement;
label: string;
value: number | string;
accent?: string;
}> = ({ icon, label, value, accent = 'bg-primary-100 text-primary-500' }): ReactElement => (
<div className="bg-white rounded-xl p-5 border border-neutral-100 shadow-sm">
<div className="flex items-center gap-3">
<div className={cn('size-10 rounded-lg flex items-center justify-center text-lg', accent)}>
{icon}
</div>
<div>
<p className="text-[23px] font-bold text-neutral-800 leading-none">{value}</p>
<p className="text-xs text-neutral-500 mt-1">{label}</p>
</div>
</div>
</div>
);
const Components: FC = (): ReactElement => {
const { data: users } = useGetUsersList({ page: 1, per_page: 1 });
const { data: sessions } = useGetAdminSessions({ page: 1, per_page: 5 });
const { data: materials } = useGetMaterialsList();
const stats = useMemo(() => {
const all = sessions?.data ?? [];
const uniqueMentors = new Set(all.map((x) => x.mentor_id)).size;
const byStatus = (s: string) => all.filter((x) => x.status === s).length;
const withRating = all.filter((x) => x.rating != null).length;
const avgRating = withRating
? all.reduce((acc, x) => acc + (x.rating ?? 0), 0) / withRating
: 0;
return {
totalUsers: users?.meta?.total ?? 0,
totalMentors: uniqueMentors,
totalSessions: sessions?.meta?.total ?? 0,
pending: byStatus('pending'),
confirmed: byStatus('confirmed'),
completed: byStatus('completed'),
cancelled: byStatus('cancelled'),
avgRating,
materials: Array.isArray(materials) ? materials.length : materials?.data?.length ?? 0,
};
}, [users, sessions, materials]);
return (
<div className="max-w-7xl mx-auto space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-800">Dashboard Backoffice</h2>
<p className="text-neutral-500 mt-1">
Ringkasan pengguna, sesi mentoring, konten, dan rating di Dimentorin.
</p>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard icon={<TeamOutlined />} label="Total Pengguna" value={stats.totalUsers} />
<StatCard
icon={<UserAddOutlined />}
label="Mentor Terdaftar"
value={stats.totalMentors}
accent="bg-success-100 text-success-700"
/>
<StatCard
icon={<CalendarOutlined />}
label="Total Sesi"
value={stats.totalSessions}
accent="bg-warning-100 text-warning-800"
/>
<StatCard
icon={<StarOutlined />}
label="Rating Rata-rata"
value={stats.avgRating ? stats.avgRating.toFixed(1) : '—'}
accent="bg-danger-100 text-danger-600"
/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
<div className="bg-white rounded-xl p-4 border border-neutral-100 shadow-sm flex items-center gap-3">
<span className="size-9 rounded-lg bg-warning-100 text-warning-800 flex items-center justify-center"><ClockCircleOutlined /></span>
<div>
<p className="text-lg font-bold text-neutral-800 leading-none">{stats.pending}</p>
<p className="text-xs text-neutral-500 mt-1">Pending</p>
</div>
</div>
<div className="bg-white rounded-xl p-4 border border-neutral-100 shadow-sm flex items-center gap-3">
<span className="size-9 rounded-lg bg-success-100 text-success-700 flex items-center justify-center"><CheckCircleOutlined /></span>
<div>
<p className="text-lg font-bold text-neutral-800 leading-none">{stats.confirmed}</p>
<p className="text-xs text-neutral-500 mt-1">Confirmed</p>
</div>
</div>
<div className="bg-white rounded-xl p-4 border border-neutral-100 shadow-sm flex items-center gap-3">
<span className="size-9 rounded-lg bg-primary-100 text-primary-700 flex items-center justify-center"><BookOutlined /></span>
<div>
<p className="text-lg font-bold text-neutral-800 leading-none">{stats.completed}</p>
<p className="text-xs text-neutral-500 mt-1">Completed</p>
</div>
</div>
<div className="bg-white rounded-xl p-4 border border-neutral-100 shadow-sm flex items-center gap-3">
<span className="size-9 rounded-lg bg-danger-100 text-danger-600 flex items-center justify-center"><CloseCircleOutlined /></span>
<div>
<p className="text-lg font-bold text-neutral-800 leading-none">{stats.cancelled}</p>
<p className="text-xs text-neutral-500 mt-1">Cancelled</p>
</div>
</div>
<div className="bg-white rounded-xl p-4 border border-neutral-100 shadow-sm flex items-center gap-3">
<span className="size-9 rounded-lg bg-neutral-100 text-neutral-600 flex items-center justify-center"><BookOutlined /></span>
<div>
<p className="text-lg font-bold text-neutral-800 leading-none">{stats.materials}</p>
<p className="text-xs text-neutral-500 mt-1">Materi</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-neutral-100 flex items-center justify-between">
<h3 className="font-semibold text-neutral-800">Sesi Terbaru</h3>
<Link to="/mentoring/backoffice/sessions" className="text-sm text-primary-500 hover:text-primary-600 font-medium">
Lihat semua
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-400 border-b border-neutral-100">
<th className="px-5 py-3 font-medium">Topik</th>
<th className="px-5 py-3 font-medium">Mentee</th>
<th className="px-5 py-3 font-medium">Jadwal</th>
<th className="px-5 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
<Show condition={(sessions?.data?.length ?? 0) > 0}>
<For data={sessions?.data ?? []}>
{(session) => (
<tr className="border-b border-neutral-50 hover:bg-neutral-50/50">
<td className="px-5 py-3 font-medium text-neutral-800">{session.topic}</td>
<td className="px-5 py-3 text-neutral-600">
{session.mentee_fullname || session.mentee_email || '—'}
</td>
<td className="px-5 py-3 text-neutral-500">{formatDate(session.scheduled_at)}</td>
<td className="px-5 py-3">
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium capitalize', STATUS_STYLE[session.status] ?? 'bg-neutral-100 text-neutral-600')}>
{session.status}
</span>
</td>
</tr>
)}
</For>
</Show>
<Show condition={(sessions?.data?.length ?? 0) === 0}>
<tr>
<td colSpan={4} className="px-5 py-10 text-center text-neutral-400">
Belum ada sesi.
</td>
</tr>
</Show>
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Components;
@@ -0,0 +1,217 @@
import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn } from '@imphnen-frontend-service/utils';
import {
CheckOutlined,
CloseOutlined,
CalendarOutlined,
LinkOutlined,
} from '@ant-design/icons';
import { toast } from 'sonner';
import {
useGetAdminSessions,
usePutUpdateSessionStatus,
} from '@imphnen-frontend-service/service';
const PAGE_SIZE = 10;
const STATUS_STYLE: Record<string, string> = {
pending: 'bg-warning-100 text-warning-800',
confirmed: 'bg-success-100 text-success-700',
completed: 'bg-primary-100 text-primary-700',
cancelled: 'bg-danger-100 text-danger-600',
};
const STATUS_OPTIONS = ['pending', 'confirmed', 'completed', 'cancelled'];
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return iso;
}
};
const Components: FC = (): ReactElement => {
const [page, setPage] = useState(1);
const [status, setStatus] = useState('');
const { data, isLoading, refetch } = useGetAdminSessions({
page,
per_page: PAGE_SIZE,
status: status || undefined,
});
const updateStatus = usePutUpdateSessionStatus();
const sessions = data?.data ?? [];
const total = data?.meta?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const handleStatusChange = async (id: string, next: string, meetingLink?: string | null) => {
try {
await updateStatus.mutateAsync({
id,
payload: { status: next, meeting_link: meetingLink ?? null },
});
toast.success(`Status diubah ke ${next}`);
await refetch();
} catch {
toast.error('Gagal mengubah status sesi');
}
};
const goPage = (p: number) => {
setPage(p);
};
return (
<div className="max-w-7xl mx-auto space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-800">Session Management</h2>
<p className="text-neutral-500 mt-1">Pantau dan kelola seluruh sesi mentoring.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => { setStatus(''); setPage(1); }}
className={cn('px-3 py-1.5 rounded-full text-sm font-medium border', !status ? 'bg-primary-500 text-white border-primary-500' : 'bg-white text-neutral-600 border-neutral-200 hover:border-primary-300')}
>
Semua
</button>
<For data={STATUS_OPTIONS}>
{(s) => (
<button
key={s}
type="button"
onClick={() => { setStatus(s); setPage(1); }}
className={cn('px-3 py-1.5 rounded-full text-sm font-medium border capitalize', status === s ? 'bg-primary-500 text-white border-primary-500' : 'bg-white text-neutral-600 border-neutral-200 hover:border-primary-300')}
>
{s}
</button>
)}
</For>
</div>
<div className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-400 border-b border-neutral-100">
<th className="px-5 py-3 font-medium">Topik</th>
<th className="px-5 py-3 font-medium">Mentor</th>
<th className="px-5 py-3 font-medium">Mentee</th>
<th className="px-5 py-3 font-medium">Jadwal</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody>
<Show condition={isLoading}>
<tr>
<td colSpan={6} className="px-5 py-10 text-center text-neutral-400">Memuat...</td>
</tr>
</Show>
<Show condition={!isLoading && sessions.length === 0}>
<tr>
<td colSpan={6} className="px-5 py-10 text-center text-neutral-400">
Tidak ada sesi ditemukan.
</td>
</tr>
</Show>
<For data={sessions}>
{(s) => (
<tr className="border-b border-neutral-50 hover:bg-neutral-50/50">
<td className="px-5 py-3">
<p className="font-medium text-neutral-800">{s.topic}</p>
<p className="text-xs text-neutral-400">{s.session_type}</p>
</td>
<td className="px-5 py-3 text-neutral-600">{s.mentor_fullname || '—'}</td>
<td className="px-5 py-3 text-neutral-600">
{s.mentee_fullname || s.mentee_email || '—'}
</td>
<td className="px-5 py-3 text-neutral-500">{formatDate(s.scheduled_at)}</td>
<td className="px-5 py-3">
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium capitalize', STATUS_STYLE[s.status] ?? 'bg-neutral-100 text-neutral-600')}>
{s.status}
</span>
</td>
<td className="px-5 py-3">
<div className="flex items-center justify-end gap-1.5">
<Show condition={s.status === 'pending'}>
<button
type="button"
onClick={() => handleStatusChange(s.id, 'confirmed', s.meeting_link)}
className="size-8 rounded-lg text-success-600 hover:bg-success-50 flex items-center justify-center"
title="Konfirmasi"
>
<CheckOutlined />
</button>
</Show>
<Show condition={s.status === 'confirmed'}>
<button
type="button"
onClick={() => handleStatusChange(s.id, 'completed', s.meeting_link)}
className="size-8 rounded-lg text-primary-500 hover:bg-primary-50 flex items-center justify-center"
title="Selesaikan"
>
<CheckOutlined />
</button>
</Show>
<Show condition={s.status === 'pending' || s.status === 'confirmed'}>
<button
type="button"
onClick={() => handleStatusChange(s.id, 'cancelled', s.meeting_link)}
className="size-8 rounded-lg text-danger-500 hover:bg-danger-50 flex items-center justify-center"
title="Batalkan"
>
<CloseOutlined />
</button>
</Show>
<Show condition={s.status === 'cancelled' || s.status === 'completed'}>
<span className="text-xs text-neutral-300"></span>
</Show>
<Show condition={!!s.meeting_link}>
<a
href={s.meeting_link!}
target="_blank"
rel="noreferrer"
className="size-8 rounded-lg text-neutral-500 hover:bg-neutral-50 flex items-center justify-center"
title="Meeting link"
>
<LinkOutlined />
</a>
</Show>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-100">
<p className="text-xs text-neutral-400">
{total} sesi · halaman {page}/{totalPages}
</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="bordered" disabled={page <= 1} onClick={() => goPage(page - 1)}>
Sebelumnya
</Button>
<Button size="sm" variant="bordered" disabled={page >= totalPages} onClick={() => goPage(page + 1)}>
Berikutnya
</Button>
</div>
</div>
</div>
</div>
);
};
export default Components;
@@ -0,0 +1,189 @@
import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Show, cn } from '@imphnen-frontend-service/utils';
import {
UserOutlined,
LockOutlined,
LogoutOutlined,
SafetyCertificateOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { SessionToken } from '@imphnen-frontend-service/utils';
import { useGetUserMe, usePutUpdateUserMe } from '@imphnen-frontend-service/service';
const inputCls =
'w-full rounded-lg border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500';
const Components: FC = (): ReactElement => {
const navigate = useNavigate();
const { data: me } = useGetUserMe();
const updateMe = usePutUpdateUserMe();
const [fullname, setFullname] = useState(me?.fullname ?? '');
const [email, setEmail] = useState(me?.email ?? '');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [saving, setSaving] = useState(false);
const handleProfileSave = async () => {
if (!fullname || !email) {
toast.error('Nama dan email wajib diisi');
return;
}
setSaving(true);
try {
await updateMe.mutateAsync({ fullname, email });
toast.success('Profil diperbarui');
} catch {
toast.error('Gagal memperbarui profil');
} finally {
setSaving(false);
}
};
const handlePasswordChange = async () => {
if (newPassword.length < 8) {
toast.error('Password minimal 8 karakter');
return;
}
if (newPassword !== confirmPassword) {
toast.error('Konfirmasi password tidak cocok');
return;
}
setSaving(true);
try {
await updateMe.mutateAsync({ password: newPassword });
toast.success('Password berhasil diubah');
setNewPassword('');
setConfirmPassword('');
} catch {
toast.error('Gagal mengubah password');
} finally {
setSaving(false);
}
};
const handleLogout = () => {
SessionToken.remove();
navigate('/auth/login');
};
const roleName = me?.role?.name ?? '';
const isAdminRole =
['admin', 'administrator', 'super admin'].some((r) => roleName.toLowerCase().includes(r));
return (
<div className="max-w-3xl mx-auto space-y-6">
<div>
<h2 className="text-2xl font-bold text-neutral-800">Settings</h2>
<p className="text-neutral-500 mt-1">Preferensi akun, keamanan, dan akses.</p>
</div>
{/* Profile */}
<section className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-neutral-100 flex items-center gap-2">
<UserOutlined className="text-primary-500" />
<h3 className="font-semibold text-neutral-800">Detail Akun</h3>
</div>
<div className="p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="size-12 rounded-full bg-primary-100 text-primary-500 flex items-center justify-center text-xl">
<UserOutlined />
</div>
<div>
<p className="font-medium text-neutral-800">{me?.fullname || 'Admin'}</p>
<p className="text-sm text-neutral-400">{me?.email || '—'}</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Nama Lengkap</label>
<input className={inputCls} value={fullname} onChange={(e) => setFullname(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Email</label>
<input type="email" className={inputCls} value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="flex justify-end">
<Button size="sm" variant="primary" onClick={handleProfileSave} disabled={saving || !me}>
{saving ? 'Menyimpan...' : 'Simpan Profil'}
</Button>
</div>
</div>
</section>
{/* Password */}
<section className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-neutral-100 flex items-center gap-2">
<LockOutlined className="text-primary-500" />
<h3 className="font-semibold text-neutral-800">Reset Kata Sandi</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Password Baru</label>
<input type="password" className={inputCls} value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Konfirmasi Password</label>
<input type="password" className={inputCls} value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} />
</div>
<p className={cn('text-xs', newPassword.length >= 8 ? 'text-success-600' : 'text-neutral-400')}>
{newPassword.length >= 8 ? '✓ Minimal 8 karakter' : 'Minimal 8 karakter'}
</p>
<div className="flex justify-end">
<Button size="sm" variant="primary" onClick={handlePasswordChange} disabled={saving}>
{saving ? 'Menyimpan...' : 'Ubah Password'}
</Button>
</div>
</div>
</section>
{/* Access control */}
<section className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-neutral-100 flex items-center gap-2">
<SafetyCertificateOutlined className="text-primary-500" />
<h3 className="font-semibold text-neutral-800">Akses Kontrol</h3>
</div>
<div className="p-6">
<div className="flex items-center justify-between rounded-lg border border-neutral-100 bg-neutral-50/60 p-4">
<div>
<p className="text-sm font-medium text-neutral-800">Role Anda</p>
<p className="text-sm text-neutral-500 mt-0.5">
{isAdminRole
? 'Akses penuh ke seluruh modul Backoffice.'
: 'Akses terbatas ke panel admin.'}
</p>
</div>
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium capitalize', isAdminRole ? 'bg-success-100 text-success-700' : 'bg-warning-100 text-warning-800')}>
{roleName || '—'}
</span>
</div>
<Show condition={isAdminRole}>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-neutral-600">
<div className="flex items-center gap-2"><CheckCircleOutlined className="text-success-500" /> User Management</div>
<div className="flex items-center gap-2"><CheckCircleOutlined className="text-success-500" /> Session Management</div>
<div className="flex items-center gap-2"><CheckCircleOutlined className="text-success-500" /> Content & Roadmap</div>
<div className="flex items-center gap-2"><CheckCircleOutlined className="text-success-500" /> Feedback & Review</div>
</div>
</Show>
</div>
</section>
{/* Logout */}
<section className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="p-6 flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium text-neutral-800">Keluar dari Backoffice</p>
<p className="text-sm text-neutral-500 mt-0.5">Akhiri sesi admin saat ini.</p>
</div>
<Button size="sm" variant="danger" onClick={handleLogout}>
<span className="flex items-center gap-1.5"><LogoutOutlined /> Log Out</span>
</Button>
</div>
</section>
</div>
);
};
export default Components;
@@ -0,0 +1,364 @@
import { FC, ReactElement, useMemo, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { For, Show, cn } from '@imphnen-frontend-service/utils';
import {
SearchOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
CheckCircleOutlined,
StopOutlined,
UserOutlined,
} from '@ant-design/icons';
import { toast } from 'sonner';
import {
useGetUsersList,
useGetRolesList,
usePostCreateUser,
usePutUpdateUser,
usePutActivateUser,
useDeleteUser,
} from '@imphnen-frontend-service/service';
import {
TCreateUserRequest,
TUpdateUserRequest,
} from '@imphnen-frontend-service/service';
const PAGE_SIZE = 10;
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
} catch {
return iso;
}
};
const ROLE_STYLE: Record<string, string> = {
admin: 'bg-danger-100 text-danger-600',
'admin pembayaran': 'bg-warning-100 text-warning-800',
mentor: 'bg-success-100 text-success-700',
mentee: 'bg-primary-100 text-primary-700',
user: 'bg-primary-100 text-primary-700',
};
const emptyForm: TCreateUserRequest = {
email: '',
password: '',
fullname: '',
is_active: true,
role_id: '',
avatar: null,
};
const Components: FC = (): ReactElement => {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<TCreateUserRequest>(emptyForm);
const [saving, setSaving] = useState(false);
const { data, isLoading, refetch } = useGetUsersList({
page,
per_page: PAGE_SIZE,
search: query || undefined,
});
const { data: roles } = useGetRolesList();
const createUser = usePostCreateUser();
const updateUser = usePutUpdateUser();
const activateUser = usePutActivateUser();
const deleteUser = useDeleteUser();
const users = data?.data ?? [];
const total = data?.meta?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const roleName = (r: string) =>
roles?.find((x) => x.id === r)?.name ?? r;
const openCreate = () => {
setEditingId(null);
setForm(emptyForm);
setModalOpen(true);
};
const openEdit = (u: (typeof users)[number]) => {
setEditingId(u.id);
setForm({
email: u.email,
password: '',
fullname: u.fullname,
is_active: u.is_active,
role_id: '',
avatar: u.avatar,
});
setModalOpen(true);
};
const handleSave = async () => {
if (!form.fullname || !form.email) {
toast.error('Nama dan email wajib diisi');
return;
}
if (!editingId && form.password.length < 8) {
toast.error('Password minimal 8 karakter');
return;
}
setSaving(true);
try {
if (editingId) {
const payload: TUpdateUserRequest = {
fullname: form.fullname,
email: form.email,
is_active: form.is_active,
avatar: form.avatar,
};
if (form.password) payload.password = form.password;
if (form.role_id) payload.role_id = form.role_id;
await updateUser.mutateAsync({ id: editingId, payload });
toast.success('Pengguna diperbarui');
} else {
await createUser.mutateAsync({
...form,
role_id: form.role_id || roles?.[0]?.id || '',
});
toast.success('Pengguna berhasil dibuat');
}
setModalOpen(false);
await refetch();
} catch (e) {
toast.error('Gagal menyimpan pengguna');
} finally {
setSaving(false);
}
};
const handleToggleActive = async (u: (typeof users)[number]) => {
try {
await activateUser.mutateAsync({
id: u.id,
payload: { is_active: !u.is_active },
});
toast.success(u.is_active ? 'Pengguna dinonaktifkan' : 'Pengguna diaktifkan');
await refetch();
} catch {
toast.error('Gagal mengubah status');
}
};
const handleDelete = async (u: (typeof users)[number]) => {
if (!window.confirm(`Hapus pengguna ${u.fullname}?`)) return;
try {
await deleteUser.mutateAsync(u.id);
toast.success('Pengguna dihapus');
await refetch();
} catch {
toast.error('Gagal menghapus pengguna');
}
};
const inputCls =
'w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500';
return (
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-2xl font-bold text-neutral-800">User Management</h2>
<p className="text-neutral-500 mt-1">Kelola pengguna, role, dan status akun.</p>
</div>
<Button size="sm" variant="primary" onClick={openCreate}>
<span className="flex items-center gap-1.5"><PlusOutlined /> Tambah Pengguna</span>
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<SearchOutlined className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setQuery(search);
setPage(1);
}
}}
placeholder="Cari nama atau email..."
className="w-full rounded-lg border border-neutral-200 pl-9 pr-3 py-2 text-sm outline-none focus:border-primary-500"
/>
</div>
<Button size="sm" variant="bordered" onClick={() => { setQuery(search); setPage(1); }}>
Cari
</Button>
</div>
<div className="bg-white rounded-xl border border-neutral-100 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wide text-neutral-400 border-b border-neutral-100">
<th className="px-5 py-3 font-medium">Pengguna</th>
<th className="px-5 py-3 font-medium">Role</th>
<th className="px-5 py-3 font-medium">Dibuat</th>
<th className="px-5 py-3 font-medium">Status</th>
<th className="px-5 py-3 font-medium text-right">Aksi</th>
</tr>
</thead>
<tbody>
<Show condition={isLoading}>
<tr>
<td colSpan={5} className="px-5 py-10 text-center text-neutral-400">Memuat...</td>
</tr>
</Show>
<Show condition={!isLoading && users.length === 0}>
<tr>
<td colSpan={5} className="px-5 py-10 text-center text-neutral-400">
Tidak ada pengguna ditemukan.
</td>
</tr>
</Show>
<For data={users}>
{(u) => (
<tr className="border-b border-neutral-50 hover:bg-neutral-50/50">
<td className="px-5 py-3">
<div className="flex items-center gap-3">
<div className="size-9 rounded-full bg-primary-100 text-primary-500 flex items-center justify-center shrink-0">
<UserOutlined />
</div>
<div>
<p className="font-medium text-neutral-800">{u.fullname}</p>
<p className="text-xs text-neutral-400">{u.email}</p>
</div>
</div>
</td>
<td className="px-5 py-3">
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium capitalize', ROLE_STYLE[u.role.toLowerCase()] ?? 'bg-neutral-100 text-neutral-600')}>
{u.role}
</span>
</td>
<td className="px-5 py-3 text-neutral-500">{formatDate(u.created_at)}</td>
<td className="px-5 py-3">
<span className={cn('px-2 py-0.5 rounded-full text-xs font-medium', u.is_active ? 'bg-success-100 text-success-700' : 'bg-neutral-100 text-neutral-500')}>
{u.is_active ? 'Aktif' : 'Nonaktif'}
</span>
</td>
<td className="px-5 py-3">
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => openEdit(u)}
className="size-8 rounded-lg text-neutral-500 hover:bg-primary-50 hover:text-primary-500 flex items-center justify-center"
title="Edit"
>
<EditOutlined />
</button>
<button
type="button"
onClick={() => handleToggleActive(u)}
className={cn('size-8 rounded-lg flex items-center justify-center', u.is_active ? 'text-neutral-500 hover:bg-warning-50 hover:text-warning-600' : 'text-success-600 hover:bg-success-50')}
title={u.is_active ? 'Nonaktifkan' : 'Aktifkan'}
>
{u.is_active ? <StopOutlined /> : <CheckCircleOutlined />}
</button>
<button
type="button"
onClick={() => handleDelete(u)}
className="size-8 rounded-lg text-neutral-500 hover:bg-danger-50 hover:text-danger-500 flex items-center justify-center"
title="Hapus"
>
<DeleteOutlined />
</button>
</div>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-100">
<p className="text-xs text-neutral-400">
{total} pengguna · halaman {page}/{totalPages}
</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="bordered" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Sebelumnya
</Button>
<Button size="sm" variant="bordered" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Berikutnya
</Button>
</div>
</div>
</div>
<Show condition={modalOpen}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-neutral-100 flex items-center justify-between">
<h3 className="font-semibold text-neutral-800">
{editingId ? 'Edit Pengguna' : 'Tambah Pengguna'}
</h3>
<button type="button" onClick={() => setModalOpen(false)} className="text-neutral-400 hover:text-neutral-600 text-xl leading-none">
×
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Nama Lengkap</label>
<input className={inputCls} value={form.fullname} onChange={(e) => setForm({ ...form, fullname: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Email</label>
<input type="email" className={inputCls} value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Password {editingId && <span className="text-neutral-400 font-normal">(kosongkan jika tidak diubah)</span>}
</label>
<input type="password" className={inputCls} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">Role</label>
<select
className={inputCls}
value={form.role_id}
onChange={(e) => setForm({ ...form, role_id: e.target.value })}
>
<option value=""> Pilih Role </option>
<For data={roles ?? []}>
{(r) => <option key={r.id} value={r.id}>{r.name}</option>}
</For>
</select>
</div>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
className="size-4 accent-primary-500"
/>
Akun aktif
</label>
</div>
<div className="px-6 py-4 border-t border-neutral-100 flex justify-end gap-2">
<Button size="sm" variant="bordered" onClick={() => setModalOpen(false)}>Batal</Button>
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</div>
</div>
</Show>
</div>
);
};
export default Components;
+69
View File
@@ -1,5 +1,7 @@
import { api } from '../';
import {
TAdminSessionListResponse,
TAdminSessionsParams,
TArticleDetail,
TArticleListItem,
TArticleListParams,
@@ -16,8 +18,12 @@ import {
TMentorRegisterRequest,
TMentorStats,
TPayment,
TRoadmapItem,
TRoadmapRequest,
TSessionFeedbackRequest,
TSessionListResponse,
TUpdateSessionStatusRequest,
TUpdateSessionStatusResponse,
} from '../../types/dimentorin';
import { TResponseMessage } from '../../types/common';
@@ -263,3 +269,66 @@ export const postChat = async (
});
return data.data;
};
// ---- Admin Backoffice: sessions + status + roadmap ----
export const getAdminSessions = async (
params?: TAdminSessionsParams
): Promise<TAdminSessionListResponse> => {
const { data } = await api({
method: 'GET',
url: '/dimentorin/admin/sessions',
params,
});
return data;
};
export const putUpdateSessionStatus = async (
id: string,
payload: TUpdateSessionStatusRequest
): Promise<TUpdateSessionStatusResponse> => {
const { data } = await api({
method: 'PUT',
url: `/dimentorin/sessions/update/${id}/status`,
data: payload,
});
return data.data;
};
export const getRoadmapList = async (): Promise<TRoadmapItem[]> => {
const { data } = await api({
method: 'GET',
url: '/landing/cms/roadmap',
});
return data.data;
};
export const postCreateRoadmap = async (
payload: TRoadmapRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'POST',
url: '/landing/cms/roadmap/create',
data: payload,
});
return data;
};
export const patchUpdateRoadmap = async (
id: string,
payload: TRoadmapRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'PATCH',
url: `/landing/cms/roadmap/update/${id}`,
data: payload,
});
return data;
};
export const deleteRoadmap = async (id: string): Promise<void> => {
await api({
method: 'DELETE',
url: `/landing/cms/roadmap/delete/${id}`,
});
};
+100 -1
View File
@@ -1 +1,100 @@
export {};
import { api } from '../';
import {
TActivateUserRequest,
TCreateUserRequest,
TUpdateUserRequest,
TUserDetail,
TUserListParams,
TUserListResponse,
} from '../../types/users';
import { TRolesListItem } from '../../types/roles';
import { TResponseMessage } from '../../types/common';
export const getUserMe = async (): Promise<TUserDetail> => {
const { data } = await api({
method: 'GET',
url: '/iam/users/me',
});
return data.data;
};
export const putUpdateUserMe = async (
payload: TUpdateUserRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'PUT',
url: '/iam/users/update/me',
data: payload,
});
return data;
};
export const getUsersList = async (
params?: TUserListParams
): Promise<TUserListResponse> => {
const { data } = await api({
method: 'GET',
url: '/iam/users',
params,
});
return data;
};
export const getUserById = async (id: string): Promise<TUserDetail> => {
const { data } = await api({
method: 'GET',
url: `/iam/users/detail/${id}`,
});
return data.data;
};
export const postCreateUser = async (
payload: TCreateUserRequest
): Promise<TUserDetail> => {
const { data } = await api({
method: 'POST',
url: '/iam/users/create',
data: payload,
});
return data.data;
};
export const putUpdateUser = async (
id: string,
payload: TUpdateUserRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'PUT',
url: `/iam/users/update/${id}`,
data: payload,
});
return data;
};
export const putActivateUser = async (
id: string,
payload: TActivateUserRequest
): Promise<TResponseMessage> => {
const { data } = await api({
method: 'PUT',
url: `/iam/users/activate/${id}`,
data: payload,
});
return data;
};
export const deleteUser = async (id: string): Promise<TResponseMessage> => {
const { data } = await api({
method: 'DELETE',
url: `/iam/users/delete/${id}`,
});
return data;
};
export const getRolesList = async (): Promise<TRolesListItem[]> => {
const { data } = await api({
method: 'GET',
url: '/iam/roles',
});
return data.data;
};
@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
import {
deleteRoadmap,
getAdminSessions,
getArticleById,
getArticleBySlug,
getArticleCategories,
@@ -17,7 +19,10 @@ import {
getMaterialCategories,
getMaterialsList,
getMySessions,
getRoadmapList,
patchUpdateRoadmap,
postChat,
postCreateRoadmap,
getPaymentById,
getSessionPayments,
postBookSession,
@@ -26,8 +31,11 @@ import {
postRefreshPayment,
postRegisterMentor,
postSessionFeedback,
putUpdateSessionStatus,
} from '../../api/dimentorin';
import {
TAdminSessionListResponse,
TAdminSessionsParams,
TArticleDetail,
TArticleListItem,
TArticleListParams,
@@ -44,8 +52,12 @@ import {
TMentorRegisterRequest,
TMentorStats,
TPayment,
TRoadmapItem,
TRoadmapRequest,
TSessionFeedbackRequest,
TSessionListResponse,
TUpdateSessionStatusRequest,
TUpdateSessionStatusResponse,
} from '../../types/dimentorin';
import { TResponseError, TResponseMessage } from '../../types/common';
@@ -307,3 +319,66 @@ export const usePostChat = () => {
};
export type { TResponseMessage };
// ---- Admin Backoffice ----
export const useGetAdminSessions = (
params?: TAdminSessionsParams
): UseQueryResult<TAdminSessionListResponse, TResponseError> => {
return useQuery({
queryKey: ['admin-sessions', params],
queryFn: async () => await getAdminSessions(params),
});
};
export const usePutUpdateSessionStatus = () => {
const qc = useQueryClient();
return useMutation<
TUpdateSessionStatusResponse,
TResponseError,
{ id: string; payload: TUpdateSessionStatusRequest }
>({
mutationFn: async ({ id, payload }) =>
await putUpdateSessionStatus(id, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-sessions'] });
},
});
};
export const useGetRoadmapList = (): UseQueryResult<TRoadmapItem[], TResponseError> => {
return useQuery({
queryKey: ['admin-roadmap'],
queryFn: async () => await getRoadmapList(),
});
};
export const usePostCreateRoadmap = () => {
const qc = useQueryClient();
return useMutation<TResponseMessage, TResponseError, TRoadmapRequest>({
mutationFn: async (payload) => await postCreateRoadmap(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-roadmap'] });
},
});
};
export const usePatchUpdateRoadmap = () => {
const qc = useQueryClient();
return useMutation<TResponseMessage, TResponseError, { id: string; payload: TRoadmapRequest }>({
mutationFn: async ({ id, payload }) => await patchUpdateRoadmap(id, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-roadmap'] });
},
});
};
export const useDeleteRoadmap = () => {
const qc = useQueryClient();
return useMutation<void, TResponseError, string>({
mutationFn: async (id) => await deleteRoadmap(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-roadmap'] });
},
});
};
+108 -1
View File
@@ -1 +1,108 @@
export {};
import { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
import {
deleteUser,
getRolesList,
getUserById,
getUserMe,
getUsersList,
postCreateUser,
putActivateUser,
putUpdateUser,
putUpdateUserMe,
} from '../../api/users';
import {
TActivateUserRequest,
TCreateUserRequest,
TUpdateUserRequest,
TUserDetail,
TUserListParams,
TUserListResponse,
} from '../../types/users';
import { TRolesListItem } from '../../types/roles';
import { TResponseError, TResponseMessage } from '../../types/common';
const USERS_KEY = ['admin-users'];
const ROLES_KEY = ['admin-roles'];
export const useGetUsersList = (
params?: TUserListParams
): UseQueryResult<TUserListResponse, TResponseError> => {
return useQuery({
queryKey: [...USERS_KEY, params],
queryFn: async () => await getUsersList(params),
});
};
export const useGetUserById = (
id: string
): UseQueryResult<TUserDetail, TResponseError> => {
return useQuery({
queryKey: [...USERS_KEY, 'detail', id],
queryFn: async () => await getUserById(id),
enabled: !!id,
});
};
export const usePostCreateUser = () => {
const qc = useQueryClient();
return useMutation<TUserDetail, TResponseError, TCreateUserRequest>({
mutationFn: async (payload) => await postCreateUser(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: USERS_KEY });
},
});
};
export const usePutUpdateUser = () => {
const qc = useQueryClient();
return useMutation<TResponseMessage, TResponseError, { id: string; payload: TUpdateUserRequest }>({
mutationFn: async ({ id, payload }) => await putUpdateUser(id, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: USERS_KEY });
},
});
};
export const usePutActivateUser = () => {
const qc = useQueryClient();
return useMutation<TResponseMessage, TResponseError, { id: string; payload: TActivateUserRequest }>({
mutationFn: async ({ id, payload }) => await putActivateUser(id, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: USERS_KEY });
},
});
};
export const useDeleteUser = () => {
const qc = useQueryClient();
return useMutation<TResponseMessage, TResponseError, string>({
mutationFn: async (id) => await deleteUser(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: USERS_KEY });
},
});
};
export const useGetUserMe = (): UseQueryResult<TUserDetail, TResponseError> => {
return useQuery({
queryKey: [USERS_KEY, 'me'],
queryFn: async () => await getUserMe(),
});
};
export const usePutUpdateUserMe = () => {
const qc = useQueryClient();
return useMutation<TResponseMessage, TResponseError, TUpdateUserRequest>({
mutationFn: async (payload) => await putUpdateUserMe(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: USERS_KEY });
},
});
};
export const useGetRolesList = (): UseQueryResult<TRolesListItem[], TResponseError> => {
return useQuery({
queryKey: ROLES_KEY,
queryFn: async () => await getRolesList(),
});
};
@@ -229,3 +229,72 @@ export type TChatResponse = {
answer: string;
sources: TChatSource[];
};
// ---- Admin Backoffice ----
export type TAdminSession = {
id: string;
mentor_id: string;
mentor_fullname: string | null;
mentee_id: string;
mentee_fullname: string | null;
mentee_email: string | null;
topic: string;
scheduled_at: string;
duration_minutes: number;
meeting_link: string | null;
session_type: string;
status: string;
rating: number | null;
feedback: string | null;
feedback_submitted_at: string | null;
created_at: string;
};
export type TAdminSessionListResponse = {
data: TAdminSession[];
meta: {
page: number;
per_page: number;
total: number;
total_pages?: number;
has_next?: boolean;
has_prev?: boolean;
};
};
export type TAdminSessionsParams = {
page?: number;
per_page?: number;
status?: string;
};
export type TUpdateSessionStatusRequest = {
status: string;
meeting_link?: string | null;
};
export type TUpdateSessionStatusResponse = {
id: string;
status: string;
meeting_link: string | null;
updated_at: string;
};
// ---- CMS Roadmap ----
export type TRoadmapItem = {
id: string;
title: string;
description: string;
status: string; // upcoming | in_progress | shipped
votes: number;
is_deleted: boolean;
created_at: string;
updated_at?: string;
};
export type TRoadmapRequest = {
title: string;
description: string;
status: string;
};
+81 -7
View File
@@ -1,13 +1,87 @@
import { TRoleDetailItem } from '../roles';
export type TUserRole = {
id: string;
name: string;
permissions: Array<{ id: string; name: string }>;
created_at: string;
updated_at: string;
};
export type TUserItem = {
id: string;
avatar: string;
birthdate: string;
email: string;
role: string; // role NAME in list view
fullname: string;
gender: string;
email: string;
avatar: string | null;
is_active: boolean;
phone_number: string;
role: TRoleDetailItem;
created_at: string;
updated_at: string;
};
export type TUserDetail = {
id: string;
role: TUserRole;
fullname: string;
legal_name: string | null;
email: string;
avatar: string | null;
is_active: boolean;
profile_extension: unknown | null;
created_at: string;
updated_at: string;
};
export type TUserListParams = {
page?: number;
per_page?: number;
search?: string;
sort_by?: string;
order?: string;
filter?: string;
filter_by?: string;
};
export type TUserListResponse = {
data: TUserItem[];
meta: {
page: number;
per_page: number;
total: number;
total_pages?: number;
has_next?: boolean;
has_prev?: boolean;
};
};
export type TCreateUserRequest = {
email: string;
password: string;
fullname: string;
is_active?: boolean;
role_id: string;
avatar?: string | null;
};
export type TUpdateUserRequest = {
email?: string;
password?: string;
fullname?: string;
legal_name?: string;
is_active?: boolean;
avatar?: string | null;
role_id?: string;
};
export type TActivateUserRequest = {
is_active: boolean;
};
export type TUsersMeProfile = {
id: string;
fullname: string;
email: string;
avatar: string | null;
is_active: boolean;
role: TUserRole;
created_at: string;
updated_at: string;
};