rogasper commited on
Commit
94ac16a
·
1 Parent(s): 52afa91

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
- <table className="w-full text-sm">
158
- <thead className="bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
159
- <tr>
160
- <th className="text-left px-4 py-3 font-medium">Type</th>
161
- <th className="text-left px-4 py-3 font-medium">Amount</th>
162
- <th className="text-left px-4 py-3 font-medium">Description</th>
163
- <th className="text-left px-4 py-3 font-medium">Date</th>
164
- </tr>
165
- </thead>
166
- <tbody>
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
- txn.type === "signup_bonus" ? "bg-[var(--sunbeam-300)] text-[var(--sunbeam-800)]" :
172
- txn.type === "admin_adjust" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" :
173
  "bg-[var(--oat-border)] text-[var(--warm-charcoal)]"
174
- }`}>{txn.type}</span>
175
- </td>
176
- <td className={`px-4 py-3 font-medium ${txn.amount >= 0 ? "text-[var(--matcha-700)]" : "text-[var(--clay-red)]"}`}>
177
- {txn.amount >= 0 ? "+" : ""}{txn.amount.toLocaleString()}
178
- </td>
179
- <td className="px-4 py-3 text-[var(--warm-charcoal)]">{txn.description ?? "-"}</td>
180
- <td className="px-4 py-3 text-[var(--warm-charcoal)]">{new Date(txn.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "short", year: "numeric" })}</td>
181
- </tr>
182
- ))}
183
- </tbody>
184
- </table>
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
64
- <table className="w-full text-sm">
65
- <thead className="bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
66
- <tr>
67
- <th className="text-left px-4 py-3 font-medium">User</th>
68
- <th className="text-left px-4 py-3 font-medium">Exam</th>
69
- <th className="text-left px-4 py-3 font-medium">Mode</th>
70
- <th className="text-left px-4 py-3 font-medium">Progress</th>
71
- <th className="text-left px-4 py-3 font-medium">Tokens</th>
72
- <th className="text-left px-4 py-3 font-medium">Status</th>
73
- <th className="text-left px-4 py-3 font-medium">Created</th>
74
- <th className="text-right px-4 py-3 font-medium">Actions</th>
75
- </tr>
76
- </thead>
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
- <div className="bg-[var(--pure-white)] rounded-[var(--radius-xl)] border border-[var(--oat-border)] overflow-hidden">
43
- <table className="w-full text-sm">
44
- <thead className="bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
45
- <tr>
46
- <th className="text-left px-4 py-3 font-medium">Question</th>
47
- <th className="text-left px-4 py-3 font-medium">Exam</th>
48
- <th className="text-left px-4 py-3 font-medium">Format</th>
49
- <th className="text-left px-4 py-3 font-medium">Visibility</th>
50
- <th className="text-left px-4 py-3 font-medium">Source</th>
51
- <th className="text-left px-4 py-3 font-medium">Created</th>
52
- <th className="text-right px-4 py-3 font-medium">Actions</th>
53
- </tr>
54
- </thead>
55
- <tbody>
56
- {questions.isLoading ? (
57
- <tr><td colSpan={7} className="text-center py-16 text-[var(--warm-charcoal)]">Loading...</td></tr>
58
- ) : (!questions.data?.questions || questions.data.questions.length === 0) ? (
59
- <tr><td colSpan={7} className="text-center py-16 text-[var(--warm-charcoal)]">No questions yet.</td></tr>
60
- ) : (
61
- questions.data.questions.map((q) => (
62
- <tr key={q.id} className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
63
- <td className="px-4 py-3 max-w-xs">
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 { Pagination } from "@/components/admin/Pagination";
 
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
- return (
105
- <tr className="border-t border-[var(--oat-border)] hover:bg-[var(--oat-light)]/50 transition-colors">
106
- <td className="px-4 py-3 font-medium text-[var(--clay-black)]">{user.name}</td>
107
- <td className="px-4 py-3 text-[var(--warm-charcoal)]">
108
- {user.email}
109
- {!user.emailVerified && (
110
- <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>
111
- )}
112
- </td>
113
- <td className="px-4 py-3">
114
- <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${user.role === "admin" ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]" : "bg-[var(--oat-border)] text-[var(--warm-charcoal)]"}`}>
115
- {user.role}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  </span>
117
- </td>
118
- <td className="px-4 py-3">
 
 
 
 
 
119
  <span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
120
- user.suspended ? "bg-[var(--clay-red)]/10 text-[var(--clay-red)]" : "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
121
  }`}>
122
- {user.suspended ? "Suspended" : "Active"}
123
  </span>
124
- </td>
125
- <td className="px-4 py-3">
126
- <div className="flex items-center justify-end gap-2">
127
- <Button variant="outline" onClick={() => suspendMutation.mutate({ userId: user.id, suspended: !user.suspended })} disabled={suspendMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">
128
- {user.suspended ? "Unsuspend" : "Suspend"}
129
- </Button>
130
- <Button variant="outline" onClick={() => roleMutation.mutate({ userId: user.id, role: user.role === "admin" ? "user" : "admin" })} disabled={roleMutation.isPending} className="h-9 rounded-[var(--radius-lg)] text-xs">
131
- {user.role === "admin" ? "Demote" : "Promote"}
132
- </Button>
 
133
  </div>
134
- </td>
135
- </tr>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }