feat(dimentorin): backoffice admin panel (dashboard, user mgmt, session mgmt, content/roadmap, feedback, settings)
This commit is contained in:
@@ -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;
|
||||
@@ -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}`,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user