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 { api } from '../';
|
||||||
import {
|
import {
|
||||||
|
TAdminSessionListResponse,
|
||||||
|
TAdminSessionsParams,
|
||||||
TArticleDetail,
|
TArticleDetail,
|
||||||
TArticleListItem,
|
TArticleListItem,
|
||||||
TArticleListParams,
|
TArticleListParams,
|
||||||
@@ -16,8 +18,12 @@ import {
|
|||||||
TMentorRegisterRequest,
|
TMentorRegisterRequest,
|
||||||
TMentorStats,
|
TMentorStats,
|
||||||
TPayment,
|
TPayment,
|
||||||
|
TRoadmapItem,
|
||||||
|
TRoadmapRequest,
|
||||||
TSessionFeedbackRequest,
|
TSessionFeedbackRequest,
|
||||||
TSessionListResponse,
|
TSessionListResponse,
|
||||||
|
TUpdateSessionStatusRequest,
|
||||||
|
TUpdateSessionStatusResponse,
|
||||||
} from '../../types/dimentorin';
|
} from '../../types/dimentorin';
|
||||||
import { TResponseMessage } from '../../types/common';
|
import { TResponseMessage } from '../../types/common';
|
||||||
|
|
||||||
@@ -263,3 +269,66 @@ export const postChat = async (
|
|||||||
});
|
});
|
||||||
return data.data;
|
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 { useMutation, useQuery, useQueryClient, UseQueryResult } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
deleteRoadmap,
|
||||||
|
getAdminSessions,
|
||||||
getArticleById,
|
getArticleById,
|
||||||
getArticleBySlug,
|
getArticleBySlug,
|
||||||
getArticleCategories,
|
getArticleCategories,
|
||||||
@@ -12,12 +14,15 @@ import {
|
|||||||
getMentorStats,
|
getMentorStats,
|
||||||
getMentors,
|
getMentors,
|
||||||
getMyPayments,
|
getMyPayments,
|
||||||
getMaterialById,
|
getMaterialById,
|
||||||
getMaterialBySlug,
|
getMaterialBySlug,
|
||||||
getMaterialCategories,
|
getMaterialCategories,
|
||||||
getMaterialsList,
|
getMaterialsList,
|
||||||
getMySessions,
|
getMySessions,
|
||||||
|
getRoadmapList,
|
||||||
|
patchUpdateRoadmap,
|
||||||
postChat,
|
postChat,
|
||||||
|
postCreateRoadmap,
|
||||||
getPaymentById,
|
getPaymentById,
|
||||||
getSessionPayments,
|
getSessionPayments,
|
||||||
postBookSession,
|
postBookSession,
|
||||||
@@ -26,12 +31,15 @@ import {
|
|||||||
postRefreshPayment,
|
postRefreshPayment,
|
||||||
postRegisterMentor,
|
postRegisterMentor,
|
||||||
postSessionFeedback,
|
postSessionFeedback,
|
||||||
|
putUpdateSessionStatus,
|
||||||
} from '../../api/dimentorin';
|
} from '../../api/dimentorin';
|
||||||
import {
|
import {
|
||||||
|
TAdminSessionListResponse,
|
||||||
|
TAdminSessionsParams,
|
||||||
TArticleDetail,
|
TArticleDetail,
|
||||||
TArticleListItem,
|
TArticleListItem,
|
||||||
TArticleListParams,
|
TArticleListParams,
|
||||||
TBookSessionRequest,
|
TBookSessionRequest,
|
||||||
TChatRequest,
|
TChatRequest,
|
||||||
TChatResponse,
|
TChatResponse,
|
||||||
TMaterialDetail,
|
TMaterialDetail,
|
||||||
@@ -44,8 +52,12 @@ import {
|
|||||||
TMentorRegisterRequest,
|
TMentorRegisterRequest,
|
||||||
TMentorStats,
|
TMentorStats,
|
||||||
TPayment,
|
TPayment,
|
||||||
|
TRoadmapItem,
|
||||||
|
TRoadmapRequest,
|
||||||
TSessionFeedbackRequest,
|
TSessionFeedbackRequest,
|
||||||
TSessionListResponse,
|
TSessionListResponse,
|
||||||
|
TUpdateSessionStatusRequest,
|
||||||
|
TUpdateSessionStatusResponse,
|
||||||
} from '../../types/dimentorin';
|
} from '../../types/dimentorin';
|
||||||
import { TResponseError, TResponseMessage } from '../../types/common';
|
import { TResponseError, TResponseMessage } from '../../types/common';
|
||||||
|
|
||||||
@@ -307,3 +319,66 @@ export const usePostChat = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type { TResponseMessage };
|
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;
|
answer: string;
|
||||||
sources: TChatSource[];
|
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 = {
|
export type TUserItem = {
|
||||||
id: string;
|
id: string;
|
||||||
avatar: string;
|
role: string; // role NAME in list view
|
||||||
birthdate: string;
|
|
||||||
email: string;
|
|
||||||
fullname: string;
|
fullname: string;
|
||||||
gender: string;
|
email: string;
|
||||||
|
avatar: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
phone_number: string;
|
created_at: string;
|
||||||
role: TRoleDetailItem;
|
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