feat: introduce DataTable component for improved data presentation in admin routes. Refactor existing tables in AdminCredits, AdminJobs, AdminModeration, AdminUsers, and others to utilize the new DataTable for enhanced functionality, including sorting, pagination, and customizable columns. This update streamlines the display of data across various admin sections, improving user experience and maintainability.
Browse files
apps/web/src/components/admin/DataTable.tsx
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from "react";
|
| 2 |
+
import { Pagination } from "@/components/admin/Pagination";
|
| 3 |
+
|
| 4 |
+
interface ColumnDef<T> {
|
| 5 |
+
id: string;
|
| 6 |
+
header: string;
|
| 7 |
+
accessorKey?: keyof T & string;
|
| 8 |
+
accessorFn?: (row: T) => ReactNode;
|
| 9 |
+
cell?: (props: { row: T; value: ReactNode }) => ReactNode;
|
| 10 |
+
className?: string;
|
| 11 |
+
sortable?: boolean;
|
| 12 |
+
size?: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
interface DataTableProps<T> {
|
| 16 |
+
data: T[];
|
| 17 |
+
columns: ColumnDef<T>[];
|
| 18 |
+
isLoading?: boolean;
|
| 19 |
+
emptyMessage?: string;
|
| 20 |
+
keyExtractor: (row: T) => string | number;
|
| 21 |
+
page?: number;
|
| 22 |
+
totalPages?: number;
|
| 23 |
+
onPageChange?: (p: number) => void;
|
| 24 |
+
sortColumn?: string;
|
| 25 |
+
sortDirection?: "asc" | "desc";
|
| 26 |
+
onSort?: (column: string) => void;
|
| 27 |
+
actions?: (row: T) => ReactNode;
|
| 28 |
+
classNames?: {
|
| 29 |
+
wrapper?: string;
|
| 30 |
+
table?: string;
|
| 31 |
+
thead?: string;
|
| 32 |
+
th?: string;
|
| 33 |
+
tbody?: string;
|
| 34 |
+
td?: string;
|
| 35 |
+
};
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function DataTableInner<T>({
|
| 39 |
+
data,
|
| 40 |
+
columns,
|
| 41 |
+
isLoading,
|
| 42 |
+
emptyMessage = "No data.",
|
| 43 |
+
keyExtractor,
|
| 44 |
+
page,
|
| 45 |
+
totalPages,
|
| 46 |
+
onPageChange,
|
| 47 |
+
sortColumn,
|
| 48 |
+
sortDirection,
|
| 49 |
+
onSort,
|
| 50 |
+
actions,
|
| 51 |
+
classNames = {},
|
| 52 |
+
}: DataTableProps<T>) {
|
| 53 |
+
const cn = {
|
| 54 |
+
wrapper: classNames.wrapper ?? "bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden",
|
| 55 |
+
table: classNames.table ?? "w-full text-sm",
|
| 56 |
+
thead: classNames.thead ?? "bg-[var(--oat-light)] text-[var(--warm-charcoal)]",
|
| 57 |
+
th: classNames.th ?? "text-left px-4 py-3 font-medium",
|
| 58 |
+
tbody: classNames.tbody ?? "",
|
| 59 |
+
td: classNames.td ?? "px-4 py-3",
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
const colCount = columns.length + (actions ? 1 : 0);
|
| 63 |
+
|
| 64 |
+
function renderCell(row: T, col: ColumnDef<T>): ReactNode {
|
| 65 |
+
let value: ReactNode;
|
| 66 |
+
if (col.accessorKey !== undefined) {
|
| 67 |
+
value = row[col.accessorKey] as ReactNode;
|
| 68 |
+
} else if (col.accessorFn) {
|
| 69 |
+
value = col.accessorFn(row);
|
| 70 |
+
} else {
|
| 71 |
+
value = null;
|
| 72 |
+
}
|
| 73 |
+
if (col.cell) {
|
| 74 |
+
return col.cell({ row, value });
|
| 75 |
+
}
|
| 76 |
+
return value;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<div className={cn.wrapper}>
|
| 81 |
+
<table className={cn.table}>
|
| 82 |
+
<thead className={cn.thead}>
|
| 83 |
+
<tr>
|
| 84 |
+
{columns.map((col) => (
|
| 85 |
+
<th
|
| 86 |
+
key={col.id}
|
| 87 |
+
className={`${cn.th} ${col.size ?? ""} ${col.className ?? ""} ${col.sortable ? "cursor-pointer select-none hover:text-[var(--clay-black)]" : ""}`}
|
| 88 |
+
onClick={col.sortable ? () => onSort?.(col.id) : undefined}
|
| 89 |
+
aria-sort={
|
| 90 |
+
sortColumn === col.id
|
| 91 |
+
? sortDirection === "asc" ? "ascending" : "descending"
|
| 92 |
+
: undefined
|
| 93 |
+
}
|
| 94 |
+
>
|
| 95 |
+
<span className="inline-flex items-center gap-1">
|
| 96 |
+
{col.header}
|
| 97 |
+
{col.sortable && sortColumn === col.id && (
|
| 98 |
+
<span className="text-xs">{sortDirection === "asc" ? "\u25B2" : "\u25BC"}</span>
|
| 99 |
+
)}
|
| 100 |
+
</span>
|
| 101 |
+
</th>
|
| 102 |
+
))}
|
| 103 |
+
{actions && <th className={`${cn.th} text-right`}>Actions</th>}
|
| 104 |
+
</tr>
|
| 105 |
+
</thead>
|
| 106 |
+
<tbody className={cn.tbody}>
|
| 107 |
+
{isLoading ? (
|
| 108 |
+
<tr>
|
| 109 |
+
<td colSpan={colCount} className={`${cn.td} text-center py-16 text-[var(--warm-charcoal)]`}>
|
| 110 |
+
Loading...
|
| 111 |
+
</td>
|
| 112 |
+
</tr>
|
| 113 |
+
) : data.length === 0 ? (
|
| 114 |
+
<tr>
|
| 115 |
+
<td colSpan={colCount} className={`${cn.td} text-center py-16 text-[var(--warm-charcoal)]`}>
|
| 116 |
+
{emptyMessage}
|
| 117 |
+
</td>
|
| 118 |
+
</tr>
|
| 119 |
+
) : (
|
| 120 |
+
data.map((row) => (
|
| 121 |
+
<tr
|
| 122 |
+
key={keyExtractor(row)}
|
| 123 |
+
className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors"
|
| 124 |
+
>
|
| 125 |
+
{columns.map((col) => (
|
| 126 |
+
<td key={col.id} className={`${cn.td} ${col.size ?? ""}`}>
|
| 127 |
+
{renderCell(row, col)}
|
| 128 |
+
</td>
|
| 129 |
+
))}
|
| 130 |
+
{actions && (
|
| 131 |
+
<td className={`${cn.td} text-right`}>
|
| 132 |
+
{actions(row)}
|
| 133 |
+
</td>
|
| 134 |
+
)}
|
| 135 |
+
</tr>
|
| 136 |
+
))
|
| 137 |
+
)}
|
| 138 |
+
</tbody>
|
| 139 |
+
</table>
|
| 140 |
+
{page !== undefined && totalPages !== undefined && onPageChange && (
|
| 141 |
+
<Pagination page={page} totalPages={totalPages} onChange={onPageChange} />
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export { DataTableInner as DataTable };
|
| 148 |
+
export type { ColumnDef, DataTableProps };
|
apps/web/src/routes/admin.credits.tsx
CHANGED
|
@@ -7,11 +7,22 @@ import { Button } from "@labas/ui/components/button";
|
|
| 7 |
import { toast } from "sonner";
|
| 8 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 9 |
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
|
|
|
|
|
| 10 |
|
| 11 |
export const Route = createFileRoute("/admin/credits")({
|
| 12 |
component: AdminCredits,
|
| 13 |
});
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
function AdminCredits() {
|
| 16 |
const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300);
|
| 17 |
const [selectedUserId, setSelectedUserId] = useState("");
|
|
@@ -154,34 +165,43 @@ function AdminCredits() {
|
|
| 154 |
{historyQuery.data && historyQuery.data.transactions.length > 0 && (
|
| 155 |
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
|
| 156 |
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)] px-6 pt-6 pb-2">Transaction History</h2>
|
| 157 |
-
<
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
{historyQuery.data.transactions.map((txn) => (
|
| 168 |
-
<tr key={txn.id} className="border-t border-[var(--oat-border)]">
|
| 169 |
-
<td className="px-4 py-3">
|
| 170 |
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 171 |
-
|
| 172 |
-
|
| 173 |
"bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 174 |
-
}`}>{
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
</
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
</div>
|
| 186 |
)}
|
| 187 |
</div>
|
|
|
|
| 7 |
import { toast } from "sonner";
|
| 8 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 9 |
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
| 10 |
+
import { DataTable } from "@/components/admin/DataTable";
|
| 11 |
+
import type { ColumnDef } from "@/components/admin/DataTable";
|
| 12 |
|
| 13 |
export const Route = createFileRoute("/admin/credits")({
|
| 14 |
component: AdminCredits,
|
| 15 |
});
|
| 16 |
|
| 17 |
+
type TxnRow = {
|
| 18 |
+
id: string;
|
| 19 |
+
userId: string;
|
| 20 |
+
amount: number;
|
| 21 |
+
type: string;
|
| 22 |
+
description: string | null;
|
| 23 |
+
createdAt: string | Date;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
function AdminCredits() {
|
| 27 |
const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300);
|
| 28 |
const [selectedUserId, setSelectedUserId] = useState("");
|
|
|
|
| 165 |
{historyQuery.data && historyQuery.data.transactions.length > 0 && (
|
| 166 |
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
|
| 167 |
<h2 className="text-lg font-headline font-bold text-[var(--clay-black)] px-6 pt-6 pb-2">Transaction History</h2>
|
| 168 |
+
<DataTable
|
| 169 |
+
data={historyQuery.data.transactions}
|
| 170 |
+
columns={[
|
| 171 |
+
{
|
| 172 |
+
id: "type",
|
| 173 |
+
header: "Type",
|
| 174 |
+
accessorKey: "type",
|
| 175 |
+
cell: ({ value }) => {
|
| 176 |
+
const t = value as string;
|
| 177 |
+
return (
|
|
|
|
|
|
|
|
|
|
| 178 |
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 179 |
+
t === "signup_bonus" ? "bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]" :
|
| 180 |
+
t === "admin_adjust" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" :
|
| 181 |
"bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 182 |
+
}`}>{t}</span>
|
| 183 |
+
);
|
| 184 |
+
},
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
id: "amount",
|
| 188 |
+
header: "Amount",
|
| 189 |
+
accessorFn: (t: TxnRow) => <span className={`font-medium ${t.amount >= 0 ? "text-[var(--matcha-700)]" : "text-[var(--clay-red)]"}`}>{t.amount >= 0 ? "+" : ""}{t.amount.toLocaleString()}</span>,
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
id: "description",
|
| 193 |
+
header: "Description",
|
| 194 |
+
accessorFn: (t: TxnRow) => <span className="text-[var(--warm-charcoal)]">{t.description ?? "-"}</span>,
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
id: "date",
|
| 198 |
+
header: "Date",
|
| 199 |
+
accessorFn: (t: TxnRow) => <span className="text-[var(--warm-charcoal)]">{new Date(t.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "short", year: "numeric" })}</span>,
|
| 200 |
+
},
|
| 201 |
+
]}
|
| 202 |
+
keyExtractor={(t) => t.id}
|
| 203 |
+
classNames={{ wrapper: "" }}
|
| 204 |
+
/>
|
| 205 |
</div>
|
| 206 |
)}
|
| 207 |
</div>
|
apps/web/src/routes/admin.jobs.tsx
CHANGED
|
@@ -5,6 +5,8 @@ import { trpc } from "@/utils/trpc";
|
|
| 5 |
import { Button } from "@labas/ui/components/button";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { getErrorMessage } from "@/lib/error-utils";
|
|
|
|
|
|
|
| 8 |
|
| 9 |
const STATUSES = ["all", "pending", "running", "completed", "failed", "cancelled"] as const;
|
| 10 |
|
|
@@ -12,6 +14,18 @@ export const Route = createFileRoute("/admin/jobs")({
|
|
| 12 |
component: AdminJobs,
|
| 13 |
});
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
function formatDate(dateStr: string | Date | null) {
|
| 16 |
if (!dateStr) return "-";
|
| 17 |
return new Date(dateStr).toLocaleDateString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
@@ -39,6 +53,54 @@ function AdminJobs() {
|
|
| 39 |
}),
|
| 40 |
);
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
return (
|
| 43 |
<div>
|
| 44 |
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Generation Jobs</h1>
|
|
@@ -60,63 +122,20 @@ function AdminJobs() {
|
|
| 60 |
))}
|
| 61 |
</div>
|
| 62 |
|
| 63 |
-
<
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
<tbody>
|
| 78 |
-
{jobs.isLoading ? (
|
| 79 |
-
<tr><td colSpan={8} className="text-center py-16 text-[var(--warm-charcoal)]">Loading...</td></tr>
|
| 80 |
-
) : (!jobs.data?.jobs || jobs.data.jobs.length === 0) ? (
|
| 81 |
-
<tr><td colSpan={8} className="text-center py-16 text-[var(--warm-charcoal)]">No jobs found.</td></tr>
|
| 82 |
-
) : (
|
| 83 |
-
jobs.data.jobs.map((job) => (
|
| 84 |
-
<tr key={job.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
|
| 85 |
-
<td className="px-4 py-3 text-[var(--warm-charcoal)] font-mono text-xs">{job.userId?.slice(0, 10)}...</td>
|
| 86 |
-
<td className="px-4 py-3">{job.examTypeId}</td>
|
| 87 |
-
<td className="px-4 py-3 text-xs">{job.mode}</td>
|
| 88 |
-
<td className="px-4 py-3">
|
| 89 |
-
<div className="flex items-center gap-2">
|
| 90 |
-
<div className="w-16 h-1.5 bg-[var(--oat-border)] rounded-full overflow-hidden">
|
| 91 |
-
<div className={`h-full rounded-full transition-all ${job.progress === 100 ? "bg-[var(--matcha-500)]" : "bg-[var(--sunbeam-500)]"}`} style={{ width: `${job.progress ?? 0}%` }} />
|
| 92 |
-
</div>
|
| 93 |
-
<span className="text-xs text-[var(--warm-charcoal)]">{job.progress ?? 0}%</span>
|
| 94 |
-
</div>
|
| 95 |
-
</td>
|
| 96 |
-
<td className="px-4 py-3">{job.tokensUsed?.toLocaleString() ?? "-"}</td>
|
| 97 |
-
<td className="px-4 py-3">
|
| 98 |
-
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 99 |
-
job.status === "completed" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" :
|
| 100 |
-
job.status === "failed" ? "bg-[var(--clay-red)]/10 text-[var(--clay-red)]" :
|
| 101 |
-
job.status === "cancelled" ? "bg-[var(--oat-border)] text-[var(--warm-charcoal)]" :
|
| 102 |
-
job.status === "running" ? "bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]" :
|
| 103 |
-
"bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 104 |
-
}`}>{job.status}</span>
|
| 105 |
-
</td>
|
| 106 |
-
<td className="px-4 py-3 text-[var(--warm-charcoal)] text-xs">{formatDate(job.createdAt)}</td>
|
| 107 |
-
<td className="px-4 py-3 text-right">
|
| 108 |
-
{(job.status === "pending" || job.status === "running") && (
|
| 109 |
-
<Button variant="outline" className="text-[var(--clay-red)] h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => cancelMutation.mutate({ jobId: job.id })} disabled={cancelMutation.isPending}>
|
| 110 |
-
Cancel
|
| 111 |
-
</Button>
|
| 112 |
-
)}
|
| 113 |
-
</td>
|
| 114 |
-
</tr>
|
| 115 |
-
))
|
| 116 |
-
)}
|
| 117 |
-
</tbody>
|
| 118 |
-
</table>
|
| 119 |
-
</div>
|
| 120 |
</div>
|
| 121 |
);
|
| 122 |
}
|
|
|
|
| 5 |
import { Button } from "@labas/ui/components/button";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 8 |
+
import { DataTable } from "@/components/admin/DataTable";
|
| 9 |
+
import type { ColumnDef } from "@/components/admin/DataTable";
|
| 10 |
|
| 11 |
const STATUSES = ["all", "pending", "running", "completed", "failed", "cancelled"] as const;
|
| 12 |
|
|
|
|
| 14 |
component: AdminJobs,
|
| 15 |
});
|
| 16 |
|
| 17 |
+
type JobRow = {
|
| 18 |
+
id: string;
|
| 19 |
+
userId: string | null;
|
| 20 |
+
examTypeId: string | null;
|
| 21 |
+
mode: string | null;
|
| 22 |
+
progress: number | null;
|
| 23 |
+
tokensUsed: number | null;
|
| 24 |
+
status: string;
|
| 25 |
+
createdAt: string | Date;
|
| 26 |
+
completedAt: string | Date | null;
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
function formatDate(dateStr: string | Date | null) {
|
| 30 |
if (!dateStr) return "-";
|
| 31 |
return new Date(dateStr).toLocaleDateString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
|
|
| 53 |
}),
|
| 54 |
);
|
| 55 |
|
| 56 |
+
const columns: ColumnDef<JobRow>[] = [
|
| 57 |
+
{
|
| 58 |
+
id: "user",
|
| 59 |
+
header: "User",
|
| 60 |
+
accessorFn: (j) => <span className="font-mono text-xs text-[var(--warm-charcoal)]">{j.userId?.slice(0, 10)}...</span>,
|
| 61 |
+
},
|
| 62 |
+
{ id: "exam", header: "Exam", accessorKey: "examTypeId" },
|
| 63 |
+
{
|
| 64 |
+
id: "mode", header: "Mode",
|
| 65 |
+
cell: ({ value }) => <span className="text-xs">{value as string}</span>,
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
id: "progress",
|
| 69 |
+
header: "Progress",
|
| 70 |
+
cell: ({ row }) => (
|
| 71 |
+
<div className="flex items-center gap-2">
|
| 72 |
+
<div className="w-16 h-1.5 bg-[var(--oat-border)] rounded-full overflow-hidden">
|
| 73 |
+
<div className={`h-full rounded-full transition-all ${(row.progress ?? 0) >= 100 ? "bg-[var(--matcha-500)]" : "bg-[var(--sunbeam-500)]"}`} style={{ width: `${row.progress ?? 0}%` }} />
|
| 74 |
+
</div>
|
| 75 |
+
<span className="text-xs text-[var(--warm-charcoal)]">{row.progress ?? 0}%</span>
|
| 76 |
+
</div>
|
| 77 |
+
),
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
id: "tokens",
|
| 81 |
+
header: "Tokens",
|
| 82 |
+
accessorFn: (j) => j.tokensUsed?.toLocaleString() ?? "-",
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
id: "status",
|
| 86 |
+
header: "Status",
|
| 87 |
+
cell: ({ row }) => (
|
| 88 |
+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 89 |
+
row.status === "completed" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" :
|
| 90 |
+
row.status === "failed" ? "bg-[var(--clay-red)]/10 text-[var(--clay-red)]" :
|
| 91 |
+
row.status === "cancelled" ? "bg-[var(--oat-border)] text-[var(--warm-charcoal)]" :
|
| 92 |
+
row.status === "running" ? "bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]" :
|
| 93 |
+
"bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 94 |
+
}`}>{row.status}</span>
|
| 95 |
+
),
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
id: "created",
|
| 99 |
+
header: "Created",
|
| 100 |
+
accessorFn: (j) => <span className="text-xs text-[var(--warm-charcoal)]">{formatDate(j.createdAt)}</span>,
|
| 101 |
+
},
|
| 102 |
+
];
|
| 103 |
+
|
| 104 |
return (
|
| 105 |
<div>
|
| 106 |
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Generation Jobs</h1>
|
|
|
|
| 122 |
))}
|
| 123 |
</div>
|
| 124 |
|
| 125 |
+
<DataTable
|
| 126 |
+
data={jobs.data?.jobs ?? []}
|
| 127 |
+
columns={columns}
|
| 128 |
+
isLoading={jobs.isLoading}
|
| 129 |
+
emptyMessage="No jobs found."
|
| 130 |
+
keyExtractor={(j) => j.id}
|
| 131 |
+
actions={(j) => (
|
| 132 |
+
(j.status === "pending" || j.status === "running") ? (
|
| 133 |
+
<Button variant="outline" className="text-[var(--clay-red)] h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => cancelMutation.mutate({ jobId: j.id })} disabled={cancelMutation.isPending}>
|
| 134 |
+
Cancel
|
| 135 |
+
</Button>
|
| 136 |
+
) : null
|
| 137 |
+
)}
|
| 138 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
</div>
|
| 140 |
);
|
| 141 |
}
|
apps/web/src/routes/admin.moderation.tsx
CHANGED
|
@@ -5,11 +5,24 @@ import { trpc } from "@/utils/trpc";
|
|
| 5 |
import { Button } from "@labas/ui/components/button";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { getErrorMessage } from "@/lib/error-utils";
|
|
|
|
|
|
|
| 8 |
|
| 9 |
export const Route = createFileRoute("/admin/moderation")({
|
| 10 |
component: AdminModeration,
|
| 11 |
});
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
function formatDate(dateStr: string | Date | null) {
|
| 14 |
if (!dateStr) return "-";
|
| 15 |
return new Date(dateStr).toLocaleDateString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
@@ -34,62 +47,77 @@ function AdminModeration() {
|
|
| 34 |
}),
|
| 35 |
);
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return (
|
| 38 |
<div>
|
| 39 |
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Content Moderation</h1>
|
| 40 |
<p className="text-[var(--warm-charcoal)] mb-6">Review latest questions and manage visibility.</p>
|
| 41 |
|
| 42 |
-
<
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
</
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
<p className="font-medium text-[var(--clay-black)] truncate">{q.questionText}</p>
|
| 65 |
-
<p className="text-xs text-[var(--warm-charcoal)] truncate mt-0.5">{q.passageText?.slice(0, 80)}{(q.passageText?.length ?? 0) > 80 ? "..." : ""}</p>
|
| 66 |
-
</td>
|
| 67 |
-
<td className="px-4 py-3">{q.examTypeId}</td>
|
| 68 |
-
<td className="px-4 py-3 text-xs">{q.format}</td>
|
| 69 |
-
<td className="px-4 py-3">
|
| 70 |
-
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 71 |
-
q.isPublic ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" : "bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 72 |
-
}`}>{q.isPublic ? "Public" : "Private"}</span>
|
| 73 |
-
</td>
|
| 74 |
-
<td className="px-4 py-3 text-xs">{q.source}</td>
|
| 75 |
-
<td className="px-4 py-3 text-[var(--warm-charcoal)] text-xs">{formatDate(q.createdAt)}</td>
|
| 76 |
-
<td className="px-4 py-3 text-right">
|
| 77 |
-
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => toggleMutation.mutate({ questionId: q.id })} disabled={toggleMutation.isPending}>
|
| 78 |
-
{q.isPublic ? "Make Private" : "Make Public"}
|
| 79 |
-
</Button>
|
| 80 |
-
</td>
|
| 81 |
-
</tr>
|
| 82 |
-
))
|
| 83 |
-
)}
|
| 84 |
-
</tbody>
|
| 85 |
-
</table>
|
| 86 |
-
</div>
|
| 87 |
-
|
| 88 |
-
<div className="flex items-center justify-center gap-4 mt-6">
|
| 89 |
-
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
|
| 90 |
-
<span className="text-sm text-[var(--warm-charcoal)]">Page {page}</span>
|
| 91 |
-
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => setPage((p) => p + 1)} disabled={(questions.data?.questions?.length ?? 0) < limit}>Next</Button>
|
| 92 |
-
</div>
|
| 93 |
</div>
|
| 94 |
);
|
| 95 |
}
|
|
|
|
| 5 |
import { Button } from "@labas/ui/components/button";
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 8 |
+
import { DataTable } from "@/components/admin/DataTable";
|
| 9 |
+
import type { ColumnDef } from "@/components/admin/DataTable";
|
| 10 |
|
| 11 |
export const Route = createFileRoute("/admin/moderation")({
|
| 12 |
component: AdminModeration,
|
| 13 |
});
|
| 14 |
|
| 15 |
+
type QuestionRow = {
|
| 16 |
+
id: string;
|
| 17 |
+
questionText: string;
|
| 18 |
+
passageText: string | null;
|
| 19 |
+
examTypeId: string;
|
| 20 |
+
format: string;
|
| 21 |
+
isPublic: boolean;
|
| 22 |
+
source: string;
|
| 23 |
+
createdAt: string | Date;
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
function formatDate(dateStr: string | Date | null) {
|
| 27 |
if (!dateStr) return "-";
|
| 28 |
return new Date(dateStr).toLocaleDateString("id-ID", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
|
|
|
|
| 47 |
}),
|
| 48 |
);
|
| 49 |
|
| 50 |
+
const columns: ColumnDef<QuestionRow>[] = [
|
| 51 |
+
{
|
| 52 |
+
id: "question",
|
| 53 |
+
header: "Question",
|
| 54 |
+
size: "w-[35%]",
|
| 55 |
+
cell: ({ row }) => (
|
| 56 |
+
<div className="max-w-xs">
|
| 57 |
+
<p className="font-medium text-[var(--clay-black)] truncate">{row.questionText}</p>
|
| 58 |
+
{row.passageText && (
|
| 59 |
+
<p className="text-xs text-[var(--warm-charcoal)] truncate mt-0.5">
|
| 60 |
+
{row.passageText.slice(0, 80)}{row.passageText.length > 80 ? "..." : ""}
|
| 61 |
+
</p>
|
| 62 |
+
)}
|
| 63 |
+
</div>
|
| 64 |
+
),
|
| 65 |
+
},
|
| 66 |
+
{ id: "exam", header: "Exam", accessorKey: "examTypeId" },
|
| 67 |
+
{
|
| 68 |
+
id: "format", header: "Format",
|
| 69 |
+
cell: ({ value }) => <span className="text-xs">{value as string}</span>,
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
id: "visibility",
|
| 73 |
+
header: "Visibility",
|
| 74 |
+
cell: ({ row }) => (
|
| 75 |
+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 76 |
+
row.isPublic ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" : "bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
|
| 77 |
+
}`}>{row.isPublic ? "Public" : "Private"}</span>
|
| 78 |
+
),
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
id: "source", header: "Source",
|
| 82 |
+
cell: ({ value }) => <span className="text-xs">{value as string}</span>,
|
| 83 |
+
},
|
| 84 |
+
{
|
| 85 |
+
id: "created",
|
| 86 |
+
header: "Created",
|
| 87 |
+
accessorFn: (q) => <span className="text-xs text-[var(--warm-charcoal)]">{formatDate(q.createdAt)}</span>,
|
| 88 |
+
},
|
| 89 |
+
];
|
| 90 |
+
|
| 91 |
+
const questionData = questions.data?.questions ?? [];
|
| 92 |
+
const hasMore = questionData.length >= limit;
|
| 93 |
+
|
| 94 |
return (
|
| 95 |
<div>
|
| 96 |
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)] mb-2">Content Moderation</h1>
|
| 97 |
<p className="text-[var(--warm-charcoal)] mb-6">Review latest questions and manage visibility.</p>
|
| 98 |
|
| 99 |
+
<DataTable
|
| 100 |
+
data={questionData}
|
| 101 |
+
columns={columns}
|
| 102 |
+
isLoading={questions.isLoading}
|
| 103 |
+
emptyMessage="No questions yet."
|
| 104 |
+
keyExtractor={(q) => q.id}
|
| 105 |
+
page={page}
|
| 106 |
+
totalPages={page + 1}
|
| 107 |
+
onPageChange={setPage}
|
| 108 |
+
actions={(q) => (
|
| 109 |
+
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => toggleMutation.mutate({ questionId: q.id })} disabled={toggleMutation.isPending}>
|
| 110 |
+
{q.isPublic ? "Make Private" : "Make Public"}
|
| 111 |
+
</Button>
|
| 112 |
+
)}
|
| 113 |
+
/>
|
| 114 |
+
{hasMore && (
|
| 115 |
+
<div className="flex items-center justify-center gap-4 mt-6">
|
| 116 |
+
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Previous</Button>
|
| 117 |
+
<span className="text-sm text-[var(--warm-charcoal)]">Page {page}</span>
|
| 118 |
+
<Button variant="outline" className="h-9 rounded-[var(--radius-lg)] text-xs" onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>Next</Button>
|
| 119 |
+
</div>
|
| 120 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
</div>
|
| 122 |
);
|
| 123 |
}
|
apps/web/src/routes/admin.users.tsx
CHANGED
|
@@ -7,7 +7,8 @@ import { Button } from "@labas/ui/components/button";
|
|
| 7 |
import { toast } from "sonner";
|
| 8 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 9 |
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
| 10 |
-
import {
|
|
|
|
| 11 |
|
| 12 |
const PAGE_SIZE = 20;
|
| 13 |
|
|
@@ -15,9 +16,19 @@ export const Route = createFileRoute("/admin/users")({
|
|
| 15 |
component: AdminUsers,
|
| 16 |
});
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
function AdminUsers() {
|
| 19 |
const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300);
|
| 20 |
const [page, setPage] = useState(1);
|
|
|
|
| 21 |
|
| 22 |
const usersQuery = useQuery(
|
| 23 |
trpc.admin.listUsers.queryOptions({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
|
|
@@ -32,55 +43,6 @@ function AdminUsers() {
|
|
| 32 |
setPage(1);
|
| 33 |
}
|
| 34 |
|
| 35 |
-
return (
|
| 36 |
-
<div>
|
| 37 |
-
<div className="flex items-center justify-between mb-6">
|
| 38 |
-
<div>
|
| 39 |
-
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)]">Users</h1>
|
| 40 |
-
<p className="text-[var(--warm-charcoal)] mt-1">{total.toLocaleString()} total users</p>
|
| 41 |
-
</div>
|
| 42 |
-
</div>
|
| 43 |
-
|
| 44 |
-
<div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
|
| 45 |
-
<div className="px-4 py-3 border-b border-[var(--oat-border)]">
|
| 46 |
-
<Input
|
| 47 |
-
placeholder="Search by name or email..."
|
| 48 |
-
value={search}
|
| 49 |
-
onChange={(e) => setSearch(e.target.value)}
|
| 50 |
-
aria-label="Cari user"
|
| 51 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 52 |
-
/>
|
| 53 |
-
</div>
|
| 54 |
-
<table className="w-full text-sm">
|
| 55 |
-
<thead className="bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 56 |
-
<tr>
|
| 57 |
-
<th className="text-left px-4 py-3 font-medium w-[25%]">Name</th>
|
| 58 |
-
<th className="text-left px-4 py-3 font-medium w-[30%]">Email</th>
|
| 59 |
-
<th className="text-left px-4 py-3 font-medium w-[10%]">Role</th>
|
| 60 |
-
<th className="text-left px-4 py-3 font-medium w-[10%]">Status</th>
|
| 61 |
-
<th className="text-right px-4 py-3 font-medium w-[25%]">Actions</th>
|
| 62 |
-
</tr>
|
| 63 |
-
</thead>
|
| 64 |
-
<tbody>
|
| 65 |
-
{usersQuery.isLoading ? (
|
| 66 |
-
<tr><td colSpan={5} className="text-center py-16 text-[var(--warm-charcoal)]">Loading...</td></tr>
|
| 67 |
-
) : users.length === 0 ? (
|
| 68 |
-
<tr><td colSpan={5} className="text-center py-16 text-[var(--warm-charcoal)]">No users found.</td></tr>
|
| 69 |
-
) : (
|
| 70 |
-
users.map((u) => <UserRow key={u.id} user={u} />)
|
| 71 |
-
)}
|
| 72 |
-
</tbody>
|
| 73 |
-
</table>
|
| 74 |
-
</div>
|
| 75 |
-
|
| 76 |
-
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
|
| 77 |
-
</div>
|
| 78 |
-
);
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
function UserRow({ user }: { user: { id: string; name: string; email: string; role: string; suspended: boolean; emailVerified: boolean } }) {
|
| 82 |
-
const queryClient = useQueryClient();
|
| 83 |
-
|
| 84 |
const suspendMutation = useMutation(
|
| 85 |
trpc.admin.suspendUser.mutationOptions({
|
| 86 |
onSuccess: (data) => {
|
|
@@ -101,37 +63,91 @@ function UserRow({ user }: { user: { id: string; name: string; email: string; ro
|
|
| 101 |
}),
|
| 102 |
);
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</span>
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 120 |
-
|
| 121 |
}`}>
|
| 122 |
-
{
|
| 123 |
</span>
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
</
|
|
|
|
| 133 |
</div>
|
| 134 |
-
</
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
);
|
| 137 |
}
|
|
|
|
| 7 |
import { toast } from "sonner";
|
| 8 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 9 |
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
| 10 |
+
import { DataTable } from "@/components/admin/DataTable";
|
| 11 |
+
import type { ColumnDef } from "@/components/admin/DataTable";
|
| 12 |
|
| 13 |
const PAGE_SIZE = 20;
|
| 14 |
|
|
|
|
| 16 |
component: AdminUsers,
|
| 17 |
});
|
| 18 |
|
| 19 |
+
type UserRow = {
|
| 20 |
+
id: string;
|
| 21 |
+
name: string;
|
| 22 |
+
email: string;
|
| 23 |
+
role: string;
|
| 24 |
+
suspended: boolean;
|
| 25 |
+
emailVerified: boolean;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
function AdminUsers() {
|
| 29 |
const [search, debouncedSearch, setSearch] = useDebouncedValue("", 300);
|
| 30 |
const [page, setPage] = useState(1);
|
| 31 |
+
const queryClient = useQueryClient();
|
| 32 |
|
| 33 |
const usersQuery = useQuery(
|
| 34 |
trpc.admin.listUsers.queryOptions({ search: debouncedSearch || undefined, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }),
|
|
|
|
| 43 |
setPage(1);
|
| 44 |
}
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
const suspendMutation = useMutation(
|
| 47 |
trpc.admin.suspendUser.mutationOptions({
|
| 48 |
onSuccess: (data) => {
|
|
|
|
| 63 |
}),
|
| 64 |
);
|
| 65 |
|
| 66 |
+
const columns: ColumnDef<UserRow>[] = [
|
| 67 |
+
{
|
| 68 |
+
id: "name",
|
| 69 |
+
header: "Name",
|
| 70 |
+
accessorKey: "name",
|
| 71 |
+
size: "w-[25%]",
|
| 72 |
+
cell: ({ value }) => <span className="font-medium text-[var(--clay-black)]">{value as string}</span>,
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
id: "email",
|
| 76 |
+
header: "Email",
|
| 77 |
+
accessorKey: "email",
|
| 78 |
+
size: "w-[30%]",
|
| 79 |
+
cell: ({ row }) => (
|
| 80 |
+
<span className="text-[var(--warm-charcoal)]">
|
| 81 |
+
{row.email}
|
| 82 |
+
{!row.emailVerified && (
|
| 83 |
+
<span className="ml-1.5 text-[10px] bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)] px-1.5 py-0.5 rounded-full font-medium">unverified</span>
|
| 84 |
+
)}
|
| 85 |
+
</span>
|
| 86 |
+
),
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
id: "role",
|
| 90 |
+
header: "Role",
|
| 91 |
+
size: "w-[10%]",
|
| 92 |
+
cell: ({ row }) => (
|
| 93 |
+
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${row.role === "admin" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" : "bg-[var(--oat-border)] text-[var(--warm-charcoal)]"}`}>
|
| 94 |
+
{row.role}
|
| 95 |
</span>
|
| 96 |
+
),
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
id: "status",
|
| 100 |
+
header: "Status",
|
| 101 |
+
size: "w-[10%]",
|
| 102 |
+
cell: ({ row }) => (
|
| 103 |
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
|
| 104 |
+
row.suspended ? "bg-[var(--clay-red)]/10 text-[var(--clay-red)]" : "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
|
| 105 |
}`}>
|
| 106 |
+
{row.suspended ? "Suspended" : "Active"}
|
| 107 |
</span>
|
| 108 |
+
),
|
| 109 |
+
},
|
| 110 |
+
];
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<div>
|
| 114 |
+
<div className="flex items-center justify-between mb-6">
|
| 115 |
+
<div>
|
| 116 |
+
<h1 className="text-3xl font-headline font-bold text-[var(--clay-black)]">Users</h1>
|
| 117 |
+
<p className="text-[var(--warm-charcoal)] mt-1">{total.toLocaleString()} total users</p>
|
| 118 |
</div>
|
| 119 |
+
</div>
|
| 120 |
+
|
| 121 |
+
<div className="mb-4">
|
| 122 |
+
<Input
|
| 123 |
+
placeholder="Search by name or email..."
|
| 124 |
+
value={search}
|
| 125 |
+
onChange={(e) => handleSearch(e.target.value)}
|
| 126 |
+
aria-label="Cari user"
|
| 127 |
+
className="max-w-md rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 128 |
+
/>
|
| 129 |
+
</div>
|
| 130 |
+
|
| 131 |
+
<DataTable
|
| 132 |
+
data={users}
|
| 133 |
+
columns={columns}
|
| 134 |
+
isLoading={usersQuery.isLoading}
|
| 135 |
+
emptyMessage="No users found."
|
| 136 |
+
keyExtractor={(u) => u.id}
|
| 137 |
+
page={page}
|
| 138 |
+
totalPages={totalPages}
|
| 139 |
+
onPageChange={setPage}
|
| 140 |
+
actions={(u) => (
|
| 141 |
+
<div className="flex items-center justify-end gap-2">
|
| 142 |
+
<Button variant="outline" onClick={() => suspendMutation.mutate({ userId: u.id, suspended: !u.suspended })} disabled={suspendMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">
|
| 143 |
+
{u.suspended ? "Unsuspend" : "Suspend"}
|
| 144 |
+
</Button>
|
| 145 |
+
<Button variant="outline" onClick={() => roleMutation.mutate({ userId: u.id, role: u.role === "admin" ? "user" : "admin" })} disabled={roleMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">
|
| 146 |
+
{u.role === "admin" ? "Demote" : "Promote"}
|
| 147 |
+
</Button>
|
| 148 |
+
</div>
|
| 149 |
+
)}
|
| 150 |
+
/>
|
| 151 |
+
</div>
|
| 152 |
);
|
| 153 |
}
|