feat: implement HistoryPage component with lazy loading and session validation. Introduce new route for history, enhancing user experience by allowing users to view their training attempts. Update routeTree to support lazy loading of the HistoryComponent, improving performance. Refactor existing history route to streamline code and enhance maintainability.
Browse files- apps/web/src/components/routes/HistoryPage.tsx +303 -0
- apps/web/src/hooks/use-generation-jobs.ts +60 -54
- apps/web/src/hooks/use-polling-transport.ts +39 -14
- apps/web/src/routeTree.gen.ts +1 -1
- apps/web/src/routes/__root.tsx +2 -2
- apps/web/src/routes/history.lazy.tsx +6 -0
- apps/web/src/routes/history.tsx +1 -287
apps/web/src/components/routes/HistoryPage.tsx
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useRef, useState, useEffect } from "react";
|
| 2 |
+
import { useQuery } from "@tanstack/react-query";
|
| 3 |
+
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
+
import { z } from "zod";
|
| 5 |
+
import { authClient } from "@/lib/auth-client";
|
| 6 |
+
import { trpc } from "@/utils/trpc";
|
| 7 |
+
import { Button } from "@labas/ui/components/button";
|
| 8 |
+
import { Card, CardContent } from "@labas/ui/components/card";
|
| 9 |
+
import { Input } from "@labas/ui/components/input";
|
| 10 |
+
import {
|
| 11 |
+
Select,
|
| 12 |
+
SelectContent,
|
| 13 |
+
SelectGroup,
|
| 14 |
+
SelectItem,
|
| 15 |
+
SelectTrigger,
|
| 16 |
+
SelectValue,
|
| 17 |
+
} from "@labas/ui/components/select";
|
| 18 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 19 |
+
import { EXAM_TYPES } from "@/lib/exam-constants";
|
| 20 |
+
|
| 21 |
+
export const Route = createFileRoute("/history")({
|
| 22 |
+
validateSearch: z.object({
|
| 23 |
+
page: z.coerce.number().optional(),
|
| 24 |
+
examTypeId: z.string().optional(),
|
| 25 |
+
search: z.string().optional(),
|
| 26 |
+
}).parse,
|
| 27 |
+
beforeLoad: async () => {
|
| 28 |
+
const session = await authClient.getSession();
|
| 29 |
+
if (!session.data) {
|
| 30 |
+
redirect({ to: "/login", throw: true });
|
| 31 |
+
}
|
| 32 |
+
return { session };
|
| 33 |
+
},
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
function formatDate(dateStr: string | Date | null) {
|
| 37 |
+
if (!dateStr) return "-";
|
| 38 |
+
const d = new Date(dateStr);
|
| 39 |
+
return d.toLocaleDateString("id-ID", {
|
| 40 |
+
day: "numeric",
|
| 41 |
+
month: "short",
|
| 42 |
+
year: "numeric",
|
| 43 |
+
hour: "2-digit",
|
| 44 |
+
minute: "2-digit",
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function getStatusBadge(status: string) {
|
| 49 |
+
switch (status) {
|
| 50 |
+
case "completed":
|
| 51 |
+
return (
|
| 52 |
+
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 53 |
+
<MaterialIcon name="check_circle" className="text-xs" />
|
| 54 |
+
Selesai
|
| 55 |
+
</span>
|
| 56 |
+
);
|
| 57 |
+
case "abandoned":
|
| 58 |
+
return (
|
| 59 |
+
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold">
|
| 60 |
+
<MaterialIcon name="cancel" className="text-xs" />
|
| 61 |
+
Ditinggalkan
|
| 62 |
+
</span>
|
| 63 |
+
);
|
| 64 |
+
default:
|
| 65 |
+
return (
|
| 66 |
+
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
|
| 67 |
+
<MaterialIcon name="hourglass_top" className="text-xs" />
|
| 68 |
+
Berlangsung
|
| 69 |
+
</span>
|
| 70 |
+
);
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
export function HistoryComponent() {
|
| 75 |
+
const search = Route.useSearch();
|
| 76 |
+
const navigate = Route.useNavigate();
|
| 77 |
+
const page = search.page ?? 1;
|
| 78 |
+
const examTypeId = search.examTypeId ?? "";
|
| 79 |
+
const searchQuery = search.search ?? "";
|
| 80 |
+
const limit = 12;
|
| 81 |
+
|
| 82 |
+
const [localSearch, setLocalSearch] = useState(searchQuery);
|
| 83 |
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 84 |
+
|
| 85 |
+
useEffect(() => {
|
| 86 |
+
setLocalSearch(searchQuery);
|
| 87 |
+
}, [searchQuery]);
|
| 88 |
+
|
| 89 |
+
useEffect(() => {
|
| 90 |
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 91 |
+
debounceRef.current = setTimeout(() => {
|
| 92 |
+
if (localSearch !== searchQuery) {
|
| 93 |
+
navigate({ search: (prev) => ({ ...prev, search: localSearch || undefined, page: 1 }) });
|
| 94 |
+
}
|
| 95 |
+
}, 300);
|
| 96 |
+
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
| 97 |
+
}, [localSearch]);
|
| 98 |
+
|
| 99 |
+
const setExamTypeFilter = useCallback((value: string) => {
|
| 100 |
+
navigate({ search: (prev) => ({ ...prev, examTypeId: value || undefined, page: 1 }) });
|
| 101 |
+
}, [navigate]);
|
| 102 |
+
|
| 103 |
+
const query = useQuery(
|
| 104 |
+
trpc.attempt.myAttempts.queryOptions({
|
| 105 |
+
limit,
|
| 106 |
+
offset: (page - 1) * limit,
|
| 107 |
+
examTypeId: examTypeId || undefined,
|
| 108 |
+
search: searchQuery || undefined,
|
| 109 |
+
}),
|
| 110 |
+
);
|
| 111 |
+
|
| 112 |
+
const attempts = query.data?.attempts ?? [];
|
| 113 |
+
const total = query.data?.total ?? 0;
|
| 114 |
+
const totalPages = Math.ceil(total / limit);
|
| 115 |
+
|
| 116 |
+
return (
|
| 117 |
+
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 118 |
+
{/* Header */}
|
| 119 |
+
<div className="mb-8">
|
| 120 |
+
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 121 |
+
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
|
| 122 |
+
Beranda
|
| 123 |
+
</Link>
|
| 124 |
+
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 125 |
+
<span className="text-[var(--clay-black)] font-medium">Riwayat Latihan</span>
|
| 126 |
+
</div>
|
| 127 |
+
|
| 128 |
+
<div className="flex items-center justify-between">
|
| 129 |
+
<div>
|
| 130 |
+
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 131 |
+
Riwayat Latihan
|
| 132 |
+
</h1>
|
| 133 |
+
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 134 |
+
Semua attempt latihan Anda.
|
| 135 |
+
</p>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{/* Filters */}
|
| 140 |
+
<div className="flex flex-col md:flex-row gap-3 mt-6">
|
| 141 |
+
<div className="relative flex-1 max-w-md">
|
| 142 |
+
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 143 |
+
<Input
|
| 144 |
+
placeholder="Cari paket..."
|
| 145 |
+
value={localSearch}
|
| 146 |
+
onChange={(e) => setLocalSearch(e.target.value)}
|
| 147 |
+
aria-label="Cari paket"
|
| 148 |
+
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 149 |
+
/>
|
| 150 |
+
</div>
|
| 151 |
+
<Select value={examTypeId} onValueChange={(v) => setExamTypeFilter(v ?? "")}>
|
| 152 |
+
<SelectTrigger className="w-36">
|
| 153 |
+
<SelectValue placeholder="Semua Ujian" />
|
| 154 |
+
</SelectTrigger>
|
| 155 |
+
<SelectContent>
|
| 156 |
+
<SelectGroup>
|
| 157 |
+
<SelectItem value="">Semua Ujian</SelectItem>
|
| 158 |
+
{EXAM_TYPES.map((t) => (
|
| 159 |
+
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
|
| 160 |
+
))}
|
| 161 |
+
</SelectGroup>
|
| 162 |
+
</SelectContent>
|
| 163 |
+
</Select>
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
|
| 167 |
+
{/* Results */}
|
| 168 |
+
{query.isLoading ? (
|
| 169 |
+
<div className="space-y-4">
|
| 170 |
+
{Array.from({ length: 5 }).map((_, i) => (
|
| 171 |
+
<Card key={i} className="h-24 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 172 |
+
))}
|
| 173 |
+
</div>
|
| 174 |
+
) : attempts.length === 0 ? (
|
| 175 |
+
<div className="text-center py-20">
|
| 176 |
+
<MaterialIcon name="history" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
|
| 177 |
+
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Belum ada riwayat latihan</p>
|
| 178 |
+
<p className="text-sm text-[var(--warm-silver)] mt-1">
|
| 179 |
+
Mulai latihan dari paket soal untuk melihat riwayat di sini.
|
| 180 |
+
</p>
|
| 181 |
+
<Link to="/packages" className="inline-block mt-6">
|
| 182 |
+
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
|
| 183 |
+
<MaterialIcon name="folder" className="mr-2" />
|
| 184 |
+
Lihat Paket Soal
|
| 185 |
+
</Button>
|
| 186 |
+
</Link>
|
| 187 |
+
</div>
|
| 188 |
+
) : (
|
| 189 |
+
<>
|
| 190 |
+
<div className="space-y-4">
|
| 191 |
+
{attempts.map((attempt) => {
|
| 192 |
+
const pct =
|
| 193 |
+
attempt.maxScore && attempt.maxScore > 0 && attempt.totalScore != null
|
| 194 |
+
? Math.round((attempt.totalScore / attempt.maxScore) * 100)
|
| 195 |
+
: null;
|
| 196 |
+
|
| 197 |
+
return (
|
| 198 |
+
<Card
|
| 199 |
+
key={attempt.id}
|
| 200 |
+
className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
|
| 201 |
+
>
|
| 202 |
+
<CardContent className="p-5">
|
| 203 |
+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 204 |
+
<div className="flex-1 min-w-0">
|
| 205 |
+
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
| 206 |
+
{getStatusBadge(attempt.status)}
|
| 207 |
+
{attempt.examTypeName && (
|
| 208 |
+
<span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 209 |
+
{attempt.examTypeName}
|
| 210 |
+
</span>
|
| 211 |
+
)}
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<h2 className="font-headline text-lg font-bold text-[var(--clay-black)] truncate">
|
| 215 |
+
{attempt.packageTitle ?? "Paket tidak diketahui"}
|
| 216 |
+
</h2>
|
| 217 |
+
|
| 218 |
+
<div className="flex items-center gap-4 mt-1 text-xs text-[var(--warm-charcoal)]">
|
| 219 |
+
<span className="flex items-center gap-1">
|
| 220 |
+
<MaterialIcon name="event" className="text-xs" />
|
| 221 |
+
{formatDate(attempt.startedAt)}
|
| 222 |
+
</span>
|
| 223 |
+
{attempt.finishedAt && (
|
| 224 |
+
<span className="flex items-center gap-1">
|
| 225 |
+
<MaterialIcon name="check" className="text-xs" />
|
| 226 |
+
{formatDate(attempt.finishedAt)}
|
| 227 |
+
</span>
|
| 228 |
+
)}
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<div className="flex items-center gap-6 shrink-0">
|
| 233 |
+
{pct != null && (
|
| 234 |
+
<div className="text-center">
|
| 235 |
+
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 236 |
+
{pct}%
|
| 237 |
+
</div>
|
| 238 |
+
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 239 |
+
{attempt.totalScore}/{attempt.maxScore}
|
| 240 |
+
</div>
|
| 241 |
+
</div>
|
| 242 |
+
)}
|
| 243 |
+
|
| 244 |
+
<div className="flex gap-2">
|
| 245 |
+
{attempt.status === "completed" && (
|
| 246 |
+
<Link to="/attempt/$id" params={{ id: attempt.id }}>
|
| 247 |
+
<Button
|
| 248 |
+
variant="outline"
|
| 249 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 250 |
+
>
|
| 251 |
+
<MaterialIcon name="visibility" className="mr-1" />
|
| 252 |
+
Hasil
|
| 253 |
+
</Button>
|
| 254 |
+
</Link>
|
| 255 |
+
)}
|
| 256 |
+
{attempt.packageId && (
|
| 257 |
+
<Link to="/package/$id" params={{ id: attempt.packageId }}>
|
| 258 |
+
<Button
|
| 259 |
+
variant="outline"
|
| 260 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 261 |
+
>
|
| 262 |
+
<MaterialIcon name="folder" className="mr-1" />
|
| 263 |
+
Paket
|
| 264 |
+
</Button>
|
| 265 |
+
</Link>
|
| 266 |
+
)}
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
</div>
|
| 270 |
+
</CardContent>
|
| 271 |
+
</Card>
|
| 272 |
+
);
|
| 273 |
+
})}
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
{totalPages > 1 && (
|
| 277 |
+
<div className="flex items-center justify-center gap-2 mt-10">
|
| 278 |
+
<Button
|
| 279 |
+
variant="outline"
|
| 280 |
+
onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.max(1, page - 1) }) })}
|
| 281 |
+
disabled={page <= 1}
|
| 282 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 283 |
+
>
|
| 284 |
+
<MaterialIcon name="chevron_left" />
|
| 285 |
+
</Button>
|
| 286 |
+
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 287 |
+
Halaman {page} dari {totalPages}
|
| 288 |
+
</span>
|
| 289 |
+
<Button
|
| 290 |
+
variant="outline"
|
| 291 |
+
onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.min(totalPages, page + 1) }) })}
|
| 292 |
+
disabled={page >= totalPages}
|
| 293 |
+
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 294 |
+
>
|
| 295 |
+
<MaterialIcon name="chevron_right" />
|
| 296 |
+
</Button>
|
| 297 |
+
</div>
|
| 298 |
+
)}
|
| 299 |
+
</>
|
| 300 |
+
)}
|
| 301 |
+
</div>
|
| 302 |
+
);
|
| 303 |
+
}
|
apps/web/src/hooks/use-generation-jobs.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useEffect, useCallback } from "react";
|
| 2 |
import { useQuery } from "@tanstack/react-query";
|
| 3 |
import { trpc, trpcClient } from "@/utils/trpc";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
|
@@ -36,66 +36,73 @@ export function useGenerationJobs() {
|
|
| 36 |
});
|
| 37 |
|
| 38 |
/* Transport layer — polling now, swappable for WebSocket later */
|
| 39 |
-
const
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
transport.subscribe(jobIds);
|
| 43 |
-
return () => transport.unsubscribe();
|
| 44 |
-
}, [jobIds, transport]);
|
| 45 |
|
| 46 |
useEffect(() => {
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
)
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
if (!completedResults.some((r) => r.jobId === jobId)) {
|
| 81 |
-
setResult(baseResult);
|
| 82 |
-
}
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
if (status === "completed" && data.resultJson) {
|
| 86 |
setResult(baseResult);
|
| 87 |
}
|
|
|
|
|
|
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
}
|
|
|
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
/* Merge discovered jobs from myJobs fallback */
|
| 101 |
useEffect(() => {
|
|
@@ -127,7 +134,6 @@ export function useGenerationJobs() {
|
|
| 127 |
return () => timers.forEach(clearTimeout);
|
| 128 |
}, [processedStates, jobIds, removeJob]);
|
| 129 |
|
| 130 |
-
const activeJobs = transport.activeJobs;
|
| 131 |
const activeCount = activeJobs.length;
|
| 132 |
const canAddMore = activeCount < MAX_PARALLEL;
|
| 133 |
const isGenerating = activeCount > 0;
|
|
|
|
| 1 |
+
import { useEffect, useCallback, useRef } from "react";
|
| 2 |
import { useQuery } from "@tanstack/react-query";
|
| 3 |
import { trpc, trpcClient } from "@/utils/trpc";
|
| 4 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
| 36 |
});
|
| 37 |
|
| 38 |
/* Transport layer — polling now, swappable for WebSocket later */
|
| 39 |
+
const { subscribe, unsubscribe, onUpdate, activeJobs } = usePollingTransport({
|
| 40 |
+
isAuthenticated,
|
| 41 |
+
});
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
useEffect(() => {
|
| 44 |
+
subscribe(jobIds);
|
| 45 |
+
return () => unsubscribe();
|
| 46 |
+
}, [jobIds, subscribe, unsubscribe]);
|
| 47 |
+
|
| 48 |
+
const onUpdateHandlerRef = useRef<
|
| 49 |
+
(event: { jobId: string; status: string; data: Record<string, unknown> }) => void
|
| 50 |
+
>(() => {});
|
| 51 |
+
onUpdateHandlerRef.current = (event) => {
|
| 52 |
+
const { jobId, status, data } = event;
|
| 53 |
+
const prevStatus = processedStates[jobId];
|
| 54 |
+
if (prevStatus === status) return;
|
| 55 |
+
trackStatus(jobId, status);
|
| 56 |
+
|
| 57 |
+
const extractPkgId = (): string | null => {
|
| 58 |
+
const rj = data.resultJson as Record<string, unknown> | null | undefined;
|
| 59 |
+
if (!rj || typeof rj !== "object") return null;
|
| 60 |
+
const id = rj.generatedPackageId;
|
| 61 |
+
return typeof id === "string" ? id : null;
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const baseResult = {
|
| 65 |
+
jobId,
|
| 66 |
+
result: data.resultJson as GenerationResult,
|
| 67 |
+
generatedPackageId: extractPkgId(),
|
| 68 |
+
mode: (data.mode as string) ?? "quick",
|
| 69 |
+
timestamp: Date.now(),
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
const alreadyCompleted = completedResults.some(
|
| 73 |
+
(r) => r.jobId === jobId && r.timestamp > 0,
|
| 74 |
+
);
|
| 75 |
+
|
| 76 |
+
if (alreadyCompleted) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
if (status === "completed" && data.resultJson) {
|
| 78 |
setResult(baseResult);
|
| 79 |
}
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
|
| 83 |
+
if (status === "partial_ready" && data.resultJson) {
|
| 84 |
+
if (!completedResults.some((r) => r.jobId === jobId)) {
|
| 85 |
+
setResult(baseResult);
|
| 86 |
}
|
| 87 |
+
}
|
| 88 |
|
| 89 |
+
if (status === "completed" && data.resultJson) {
|
| 90 |
+
setResult(baseResult);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
if (status === "failed") {
|
| 94 |
+
setError((data.errorMessage as string) ?? "Generation failed");
|
| 95 |
+
clearResult(jobId);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
if (status === "cancelled") {
|
| 99 |
+
clearResult(jobId);
|
| 100 |
+
}
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
useEffect(() => {
|
| 104 |
+
onUpdate((event) => onUpdateHandlerRef.current(event));
|
| 105 |
+
}, [onUpdate]);
|
| 106 |
|
| 107 |
/* Merge discovered jobs from myJobs fallback */
|
| 108 |
useEffect(() => {
|
|
|
|
| 134 |
return () => timers.forEach(clearTimeout);
|
| 135 |
}, [processedStates, jobIds, removeJob]);
|
| 136 |
|
|
|
|
| 137 |
const activeCount = activeJobs.length;
|
| 138 |
const canAddMore = activeCount < MAX_PARALLEL;
|
| 139 |
const isGenerating = activeCount > 0;
|
apps/web/src/hooks/use-polling-transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, useEffect, useCallback, useRef } from "react";
|
| 2 |
import { useQueries } from "@tanstack/react-query";
|
| 3 |
import { trpc } from "@/utils/trpc";
|
| 4 |
import { type ActiveJob, type JobTransport, type JobTransportEvent, isTerminal } from "./use-job-shared";
|
|
@@ -11,6 +11,11 @@ function jobStatusRefetchInterval(query: unknown): number | false {
|
|
| 11 |
return 1000;
|
| 12 |
}
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
/** Polling-based implementation of JobTransport.
|
| 15 |
* Uses tRPC useQueries to poll each tracked job individually. */
|
| 16 |
export function usePollingTransport({
|
|
@@ -20,9 +25,14 @@ export function usePollingTransport({
|
|
| 20 |
}): JobTransport {
|
| 21 |
const [jobIds, setJobIds] = useState<string[]>([]);
|
| 22 |
const [activeJobs, setActiveJobs] = useState<ActiveJob[]>([]);
|
|
|
|
|
|
|
| 23 |
const onUpdateRef = useRef<(event: JobTransportEvent) => void>(undefined);
|
| 24 |
const onErrorRef = useRef<(error: Error) => void>(undefined);
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
const jobQueries = useQueries({
|
| 27 |
queries: jobIds.map((jobId) => ({
|
| 28 |
...trpc.ai.getJobStatus.queryOptions({ jobId }),
|
|
@@ -35,24 +45,36 @@ export function usePollingTransport({
|
|
| 35 |
const nextActive = jobQueries
|
| 36 |
.filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
|
| 37 |
.map((q) => q.data as unknown as ActiveJob);
|
| 38 |
-
setActiveJobs(nextActive);
|
| 39 |
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
for (let i = 0; i < jobQueries.length; i++) {
|
| 42 |
const query = jobQueries[i];
|
| 43 |
const jobId = jobIds[i];
|
| 44 |
if (!jobId || !query.data) continue;
|
| 45 |
const data = query.data as Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
| 46 |
onUpdateRef.current?.({
|
| 47 |
jobId,
|
| 48 |
-
status
|
| 49 |
data,
|
| 50 |
});
|
| 51 |
}
|
| 52 |
}, [jobQueries, jobIds]);
|
| 53 |
|
| 54 |
const subscribe = useCallback((ids: string[]) => {
|
| 55 |
-
setJobIds(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}, []);
|
| 57 |
|
| 58 |
const unsubscribe = useCallback(() => {
|
|
@@ -68,13 +90,16 @@ export function usePollingTransport({
|
|
| 68 |
onErrorRef.current = callback;
|
| 69 |
}, []);
|
| 70 |
|
| 71 |
-
return
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
|
|
|
| 1 |
+
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
| 2 |
import { useQueries } from "@tanstack/react-query";
|
| 3 |
import { trpc } from "@/utils/trpc";
|
| 4 |
import { type ActiveJob, type JobTransport, type JobTransportEvent, isTerminal } from "./use-job-shared";
|
|
|
|
| 11 |
return 1000;
|
| 12 |
}
|
| 13 |
|
| 14 |
+
/** Track previous active job IDs and per-job statuses to avoid redundant state updates. */
|
| 15 |
+
function serializeJobIds(jobs: ActiveJob[]): string {
|
| 16 |
+
return jobs.map((j) => j.id).sort().join(",");
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
/** Polling-based implementation of JobTransport.
|
| 20 |
* Uses tRPC useQueries to poll each tracked job individually. */
|
| 21 |
export function usePollingTransport({
|
|
|
|
| 25 |
}): JobTransport {
|
| 26 |
const [jobIds, setJobIds] = useState<string[]>([]);
|
| 27 |
const [activeJobs, setActiveJobs] = useState<ActiveJob[]>([]);
|
| 28 |
+
const activeJobsRef = useRef(activeJobs);
|
| 29 |
+
activeJobsRef.current = activeJobs;
|
| 30 |
const onUpdateRef = useRef<(event: JobTransportEvent) => void>(undefined);
|
| 31 |
const onErrorRef = useRef<(error: Error) => void>(undefined);
|
| 32 |
|
| 33 |
+
const prevActiveIdsRef = useRef("");
|
| 34 |
+
const prevStatusesRef = useRef<Record<string, string>>({});
|
| 35 |
+
|
| 36 |
const jobQueries = useQueries({
|
| 37 |
queries: jobIds.map((jobId) => ({
|
| 38 |
...trpc.ai.getJobStatus.queryOptions({ jobId }),
|
|
|
|
| 45 |
const nextActive = jobQueries
|
| 46 |
.filter((q) => q.data && !isTerminal((q.data as { status: string }).status))
|
| 47 |
.map((q) => q.data as unknown as ActiveJob);
|
|
|
|
| 48 |
|
| 49 |
+
const nextIds = serializeJobIds(nextActive);
|
| 50 |
+
if (nextIds !== prevActiveIdsRef.current) {
|
| 51 |
+
prevActiveIdsRef.current = nextIds;
|
| 52 |
+
setActiveJobs(nextActive);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
for (let i = 0; i < jobQueries.length; i++) {
|
| 56 |
const query = jobQueries[i];
|
| 57 |
const jobId = jobIds[i];
|
| 58 |
if (!jobId || !query.data) continue;
|
| 59 |
const data = query.data as Record<string, unknown>;
|
| 60 |
+
const status = data.status as string;
|
| 61 |
+
if (prevStatusesRef.current[jobId] === status) continue;
|
| 62 |
+
prevStatusesRef.current[jobId] = status;
|
| 63 |
onUpdateRef.current?.({
|
| 64 |
jobId,
|
| 65 |
+
status,
|
| 66 |
data,
|
| 67 |
});
|
| 68 |
}
|
| 69 |
}, [jobQueries, jobIds]);
|
| 70 |
|
| 71 |
const subscribe = useCallback((ids: string[]) => {
|
| 72 |
+
setJobIds((prev) => {
|
| 73 |
+
if (prev.length === ids.length && prev.every((id, i) => id === ids[i])) {
|
| 74 |
+
return prev;
|
| 75 |
+
}
|
| 76 |
+
return ids;
|
| 77 |
+
});
|
| 78 |
}, []);
|
| 79 |
|
| 80 |
const unsubscribe = useCallback(() => {
|
|
|
|
| 90 |
onErrorRef.current = callback;
|
| 91 |
}, []);
|
| 92 |
|
| 93 |
+
return useMemo(
|
| 94 |
+
() => ({
|
| 95 |
+
subscribe,
|
| 96 |
+
unsubscribe,
|
| 97 |
+
onUpdate,
|
| 98 |
+
onError,
|
| 99 |
+
get activeJobs() {
|
| 100 |
+
return activeJobsRef.current;
|
| 101 |
+
},
|
| 102 |
+
}),
|
| 103 |
+
[subscribe, unsubscribe, onUpdate, onError],
|
| 104 |
+
);
|
| 105 |
}
|
apps/web/src/routeTree.gen.ts
CHANGED
|
@@ -87,7 +87,7 @@ const HistoryRoute = HistoryRouteImport.update({
|
|
| 87 |
id: '/history',
|
| 88 |
path: '/history',
|
| 89 |
getParentRoute: () => rootRouteImport,
|
| 90 |
-
} as any)
|
| 91 |
const GenerateRoute = GenerateRouteImport.update({
|
| 92 |
id: '/generate',
|
| 93 |
path: '/generate',
|
|
|
|
| 87 |
id: '/history',
|
| 88 |
path: '/history',
|
| 89 |
getParentRoute: () => rootRouteImport,
|
| 90 |
+
} as any).lazy(() => import('./routes/history.lazy').then((d) => d.Route))
|
| 91 |
const GenerateRoute = GenerateRouteImport.update({
|
| 92 |
id: '/generate',
|
| 93 |
path: '/generate',
|
apps/web/src/routes/__root.tsx
CHANGED
|
@@ -94,12 +94,12 @@ function RootComponent() {
|
|
| 94 |
<GlobalGenerationProgress />
|
| 95 |
<Toaster richColors />
|
| 96 |
</ThemeProvider>
|
| 97 |
-
{import.meta.env.DEV && (
|
| 98 |
<>
|
| 99 |
<TanStackRouterDevtools position="bottom-left" />
|
| 100 |
<ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
|
| 101 |
</>
|
| 102 |
-
)}
|
| 103 |
</>
|
| 104 |
);
|
| 105 |
}
|
|
|
|
| 94 |
<GlobalGenerationProgress />
|
| 95 |
<Toaster richColors />
|
| 96 |
</ThemeProvider>
|
| 97 |
+
{/* {import.meta.env.DEV && (
|
| 98 |
<>
|
| 99 |
<TanStackRouterDevtools position="bottom-left" />
|
| 100 |
<ReactQueryDevtools position="bottom" buttonPosition="bottom-right" />
|
| 101 |
</>
|
| 102 |
+
)} */}
|
| 103 |
</>
|
| 104 |
);
|
| 105 |
}
|
apps/web/src/routes/history.lazy.tsx
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createLazyFileRoute } from "@tanstack/react-router";
|
| 2 |
+
import { HistoryComponent } from "@/components/routes/HistoryPage";
|
| 3 |
+
|
| 4 |
+
export const Route = createLazyFileRoute("/history")({
|
| 5 |
+
component: HistoryComponent,
|
| 6 |
+
});
|
apps/web/src/routes/history.tsx
CHANGED
|
@@ -1,25 +1,8 @@
|
|
| 1 |
-
import {
|
| 2 |
-
import { useQuery } from "@tanstack/react-query";
|
| 3 |
-
import { createFileRoute, redirect, Link } from "@tanstack/react-router";
|
| 4 |
import { z } from "zod";
|
| 5 |
import { authClient } from "@/lib/auth-client";
|
| 6 |
-
import { trpc } from "@/utils/trpc";
|
| 7 |
-
import { Button } from "@labas/ui/components/button";
|
| 8 |
-
import { Card, CardContent } from "@labas/ui/components/card";
|
| 9 |
-
import { Input } from "@labas/ui/components/input";
|
| 10 |
-
import {
|
| 11 |
-
Select,
|
| 12 |
-
SelectContent,
|
| 13 |
-
SelectGroup,
|
| 14 |
-
SelectItem,
|
| 15 |
-
SelectTrigger,
|
| 16 |
-
SelectValue,
|
| 17 |
-
} from "@labas/ui/components/select";
|
| 18 |
-
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 19 |
-
import { EXAM_TYPES } from "@/lib/exam-constants";
|
| 20 |
|
| 21 |
export const Route = createFileRoute("/history")({
|
| 22 |
-
component: HistoryComponent,
|
| 23 |
validateSearch: z.object({
|
| 24 |
page: z.coerce.number().optional(),
|
| 25 |
examTypeId: z.string().optional(),
|
|
@@ -33,272 +16,3 @@ export const Route = createFileRoute("/history")({
|
|
| 33 |
return { session };
|
| 34 |
},
|
| 35 |
});
|
| 36 |
-
|
| 37 |
-
function formatDate(dateStr: string | Date | null) {
|
| 38 |
-
if (!dateStr) return "-";
|
| 39 |
-
const d = new Date(dateStr);
|
| 40 |
-
return d.toLocaleDateString("id-ID", {
|
| 41 |
-
day: "numeric",
|
| 42 |
-
month: "short",
|
| 43 |
-
year: "numeric",
|
| 44 |
-
hour: "2-digit",
|
| 45 |
-
minute: "2-digit",
|
| 46 |
-
});
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
function getStatusBadge(status: string) {
|
| 50 |
-
switch (status) {
|
| 51 |
-
case "completed":
|
| 52 |
-
return (
|
| 53 |
-
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 54 |
-
<MaterialIcon name="check_circle" className="text-xs" />
|
| 55 |
-
Selesai
|
| 56 |
-
</span>
|
| 57 |
-
);
|
| 58 |
-
case "abandoned":
|
| 59 |
-
return (
|
| 60 |
-
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)] text-xs font-semibold">
|
| 61 |
-
<MaterialIcon name="cancel" className="text-xs" />
|
| 62 |
-
Ditinggalkan
|
| 63 |
-
</span>
|
| 64 |
-
);
|
| 65 |
-
default:
|
| 66 |
-
return (
|
| 67 |
-
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-[var(--slushie-500)]/20 text-[var(--slushie-800)] text-xs font-semibold">
|
| 68 |
-
<MaterialIcon name="hourglass_top" className="text-xs" />
|
| 69 |
-
Berlangsung
|
| 70 |
-
</span>
|
| 71 |
-
);
|
| 72 |
-
}
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
export function HistoryComponent() {
|
| 76 |
-
const search = Route.useSearch();
|
| 77 |
-
const navigate = Route.useNavigate();
|
| 78 |
-
const page = search.page ?? 1;
|
| 79 |
-
const examTypeId = search.examTypeId ?? "";
|
| 80 |
-
const searchQuery = search.search ?? "";
|
| 81 |
-
const limit = 12;
|
| 82 |
-
|
| 83 |
-
const [localSearch, setLocalSearch] = useState(searchQuery);
|
| 84 |
-
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 85 |
-
|
| 86 |
-
useEffect(() => {
|
| 87 |
-
setLocalSearch(searchQuery);
|
| 88 |
-
}, [searchQuery]);
|
| 89 |
-
|
| 90 |
-
useEffect(() => {
|
| 91 |
-
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 92 |
-
debounceRef.current = setTimeout(() => {
|
| 93 |
-
if (localSearch !== searchQuery) {
|
| 94 |
-
navigate({ search: (prev) => ({ ...prev, search: localSearch || undefined, page: 1 }) });
|
| 95 |
-
}
|
| 96 |
-
}, 300);
|
| 97 |
-
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
| 98 |
-
}, [localSearch]);
|
| 99 |
-
|
| 100 |
-
const setExamTypeFilter = useCallback((value: string) => {
|
| 101 |
-
navigate({ search: (prev) => ({ ...prev, examTypeId: value || undefined, page: 1 }) });
|
| 102 |
-
}, [navigate]);
|
| 103 |
-
|
| 104 |
-
const query = useQuery(
|
| 105 |
-
trpc.attempt.myAttempts.queryOptions({
|
| 106 |
-
limit,
|
| 107 |
-
offset: (page - 1) * limit,
|
| 108 |
-
examTypeId: examTypeId || undefined,
|
| 109 |
-
search: searchQuery || undefined,
|
| 110 |
-
}),
|
| 111 |
-
);
|
| 112 |
-
|
| 113 |
-
const attempts = query.data?.attempts ?? [];
|
| 114 |
-
const total = query.data?.total ?? 0;
|
| 115 |
-
const totalPages = Math.ceil(total / limit);
|
| 116 |
-
|
| 117 |
-
return (
|
| 118 |
-
<div className="min-h-screen pt-8 pb-32 px-6 md:px-12 lg:px-16 max-w-5xl mx-auto bg-[var(--warm-cream)]">
|
| 119 |
-
{/* Header */}
|
| 120 |
-
<div className="mb-8">
|
| 121 |
-
<div className="flex items-center gap-2 text-sm text-[var(--warm-charcoal)] mb-4">
|
| 122 |
-
<Link to="/" className="hover:text-[var(--clay-black)] transition-colors">
|
| 123 |
-
Beranda
|
| 124 |
-
</Link>
|
| 125 |
-
<MaterialIcon name="chevron_right" className="text-xs" />
|
| 126 |
-
<span className="text-[var(--clay-black)] font-medium">Riwayat Latihan</span>
|
| 127 |
-
</div>
|
| 128 |
-
|
| 129 |
-
<div className="flex items-center justify-between">
|
| 130 |
-
<div>
|
| 131 |
-
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
| 132 |
-
Riwayat Latihan
|
| 133 |
-
</h1>
|
| 134 |
-
<p className="text-lg text-[var(--warm-charcoal)] mt-2">
|
| 135 |
-
Semua attempt latihan Anda.
|
| 136 |
-
</p>
|
| 137 |
-
</div>
|
| 138 |
-
</div>
|
| 139 |
-
|
| 140 |
-
{/* Filters */}
|
| 141 |
-
<div className="flex flex-col md:flex-row gap-3 mt-6">
|
| 142 |
-
<div className="relative flex-1 max-w-md">
|
| 143 |
-
<MaterialIcon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--warm-charcoal)]" />
|
| 144 |
-
<Input
|
| 145 |
-
placeholder="Cari paket..."
|
| 146 |
-
value={localSearch}
|
| 147 |
-
onChange={(e) => setLocalSearch(e.target.value)}
|
| 148 |
-
aria-label="Cari paket"
|
| 149 |
-
className="pl-10 rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] h-11"
|
| 150 |
-
/>
|
| 151 |
-
</div>
|
| 152 |
-
<Select value={examTypeId} onValueChange={(v) => setExamTypeFilter(v ?? "")}>
|
| 153 |
-
<SelectTrigger className="w-36">
|
| 154 |
-
<SelectValue placeholder="Semua Ujian" />
|
| 155 |
-
</SelectTrigger>
|
| 156 |
-
<SelectContent>
|
| 157 |
-
<SelectGroup>
|
| 158 |
-
<SelectItem value="">Semua Ujian</SelectItem>
|
| 159 |
-
{EXAM_TYPES.map((t) => (
|
| 160 |
-
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
|
| 161 |
-
))}
|
| 162 |
-
</SelectGroup>
|
| 163 |
-
</SelectContent>
|
| 164 |
-
</Select>
|
| 165 |
-
</div>
|
| 166 |
-
</div>
|
| 167 |
-
|
| 168 |
-
{/* Results */}
|
| 169 |
-
{query.isLoading ? (
|
| 170 |
-
<div className="space-y-4">
|
| 171 |
-
{Array.from({ length: 5 }).map((_, i) => (
|
| 172 |
-
<Card key={i} className="h-24 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-xl)]" />
|
| 173 |
-
))}
|
| 174 |
-
</div>
|
| 175 |
-
) : attempts.length === 0 ? (
|
| 176 |
-
<div className="text-center py-20">
|
| 177 |
-
<MaterialIcon name="history" className="text-6xl text-[var(--warm-silver)] mx-auto mb-4" />
|
| 178 |
-
<p className="text-lg text-[var(--warm-charcoal)] font-semibold">Belum ada riwayat latihan</p>
|
| 179 |
-
<p className="text-sm text-[var(--warm-silver)] mt-1">
|
| 180 |
-
Mulai latihan dari paket soal untuk melihat riwayat di sini.
|
| 181 |
-
</p>
|
| 182 |
-
<Link to="/packages" className="inline-block mt-6">
|
| 183 |
-
<Button className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]">
|
| 184 |
-
<MaterialIcon name="folder" className="mr-2" />
|
| 185 |
-
Lihat Paket Soal
|
| 186 |
-
</Button>
|
| 187 |
-
</Link>
|
| 188 |
-
</div>
|
| 189 |
-
) : (
|
| 190 |
-
<>
|
| 191 |
-
<div className="space-y-4">
|
| 192 |
-
{attempts.map((attempt) => {
|
| 193 |
-
const pct =
|
| 194 |
-
attempt.maxScore && attempt.maxScore > 0 && attempt.totalScore != null
|
| 195 |
-
? Math.round((attempt.totalScore / attempt.maxScore) * 100)
|
| 196 |
-
: null;
|
| 197 |
-
|
| 198 |
-
return (
|
| 199 |
-
<Card
|
| 200 |
-
key={attempt.id}
|
| 201 |
-
className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] hover:border-[var(--matcha-400)] transition-colors"
|
| 202 |
-
>
|
| 203 |
-
<CardContent className="p-5">
|
| 204 |
-
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
| 205 |
-
<div className="flex-1 min-w-0">
|
| 206 |
-
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
| 207 |
-
{getStatusBadge(attempt.status)}
|
| 208 |
-
{attempt.examTypeName && (
|
| 209 |
-
<span className="px-2.5 py-1 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-xs font-semibold">
|
| 210 |
-
{attempt.examTypeName}
|
| 211 |
-
</span>
|
| 212 |
-
)}
|
| 213 |
-
</div>
|
| 214 |
-
|
| 215 |
-
<h2 className="font-headline text-lg font-bold text-[var(--clay-black)] truncate">
|
| 216 |
-
{attempt.packageTitle ?? "Paket tidak diketahui"}
|
| 217 |
-
</h2>
|
| 218 |
-
|
| 219 |
-
<div className="flex items-center gap-4 mt-1 text-xs text-[var(--warm-charcoal)]">
|
| 220 |
-
<span className="flex items-center gap-1">
|
| 221 |
-
<MaterialIcon name="event" className="text-xs" />
|
| 222 |
-
{formatDate(attempt.startedAt)}
|
| 223 |
-
</span>
|
| 224 |
-
{attempt.finishedAt && (
|
| 225 |
-
<span className="flex items-center gap-1">
|
| 226 |
-
<MaterialIcon name="check" className="text-xs" />
|
| 227 |
-
{formatDate(attempt.finishedAt)}
|
| 228 |
-
</span>
|
| 229 |
-
)}
|
| 230 |
-
</div>
|
| 231 |
-
</div>
|
| 232 |
-
|
| 233 |
-
<div className="flex items-center gap-6 shrink-0">
|
| 234 |
-
{pct != null && (
|
| 235 |
-
<div className="text-center">
|
| 236 |
-
<div className="text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 237 |
-
{pct}%
|
| 238 |
-
</div>
|
| 239 |
-
<div className="text-xs text-[var(--warm-charcoal)]">
|
| 240 |
-
{attempt.totalScore}/{attempt.maxScore}
|
| 241 |
-
</div>
|
| 242 |
-
</div>
|
| 243 |
-
)}
|
| 244 |
-
|
| 245 |
-
<div className="flex gap-2">
|
| 246 |
-
{attempt.status === "completed" && (
|
| 247 |
-
<Link to="/attempt/$id" params={{ id: attempt.id }}>
|
| 248 |
-
<Button
|
| 249 |
-
variant="outline"
|
| 250 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 251 |
-
>
|
| 252 |
-
<MaterialIcon name="visibility" className="mr-1" />
|
| 253 |
-
Hasil
|
| 254 |
-
</Button>
|
| 255 |
-
</Link>
|
| 256 |
-
)}
|
| 257 |
-
{attempt.packageId && (
|
| 258 |
-
<Link to="/package/$id" params={{ id: attempt.packageId }}>
|
| 259 |
-
<Button
|
| 260 |
-
variant="outline"
|
| 261 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover text-sm"
|
| 262 |
-
>
|
| 263 |
-
<MaterialIcon name="folder" className="mr-1" />
|
| 264 |
-
Paket
|
| 265 |
-
</Button>
|
| 266 |
-
</Link>
|
| 267 |
-
)}
|
| 268 |
-
</div>
|
| 269 |
-
</div>
|
| 270 |
-
</div>
|
| 271 |
-
</CardContent>
|
| 272 |
-
</Card>
|
| 273 |
-
);
|
| 274 |
-
})}
|
| 275 |
-
</div>
|
| 276 |
-
|
| 277 |
-
{totalPages > 1 && (
|
| 278 |
-
<div className="flex items-center justify-center gap-2 mt-10">
|
| 279 |
-
<Button
|
| 280 |
-
variant="outline"
|
| 281 |
-
onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.max(1, page - 1) }) })}
|
| 282 |
-
disabled={page <= 1}
|
| 283 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 284 |
-
>
|
| 285 |
-
<MaterialIcon name="chevron_left" />
|
| 286 |
-
</Button>
|
| 287 |
-
<span className="text-sm text-[var(--warm-charcoal)] px-4">
|
| 288 |
-
Halaman {page} dari {totalPages}
|
| 289 |
-
</span>
|
| 290 |
-
<Button
|
| 291 |
-
variant="outline"
|
| 292 |
-
onClick={() => navigate({ search: (prev) => ({ ...prev, page: Math.min(totalPages, page + 1) }) })}
|
| 293 |
-
disabled={page >= totalPages}
|
| 294 |
-
className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] clay-hover"
|
| 295 |
-
>
|
| 296 |
-
<MaterialIcon name="chevron_right" />
|
| 297 |
-
</Button>
|
| 298 |
-
</div>
|
| 299 |
-
)}
|
| 300 |
-
</>
|
| 301 |
-
)}
|
| 302 |
-
</div>
|
| 303 |
-
);
|
| 304 |
-
}
|
|
|
|
| 1 |
+
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
|
|
|
|
|
| 2 |
import { z } from "zod";
|
| 3 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export const Route = createFileRoute("/history")({
|
|
|
|
| 6 |
validateSearch: z.object({
|
| 7 |
page: z.coerce.number().optional(),
|
| 8 |
examTypeId: z.string().optional(),
|
|
|
|
| 16 |
return { session };
|
| 17 |
},
|
| 18 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|