Skip to content

Commit 996f3d7

Browse files
authored
feat: self-service data upload for PDP, AR, course files (#86) (#94)
feat: self-service data upload for PDP, AR, course files (#86) Closes #86
1 parent 6aeb82d commit 996f3d7

21 files changed

Lines changed: 5383 additions & 4 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,6 @@ operations/convert_institution_id_to_string.py
183183
operations/verify_institution_id.py
184184
.vercel
185185
.env.deploy
186+
187+
# Test upload fixtures (generated — do not commit)
188+
data/test_uploads/
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function AdminLayout({ children }: { children: React.ReactNode }) {
2+
return <>{children}</>
3+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"use client"
2+
3+
import { useState, useEffect, useCallback } from "react"
4+
import { useRouter } from "next/navigation"
5+
import { Button } from "@/components/ui/button"
6+
import { Loader2 } from "lucide-react"
7+
8+
interface UploadEntry {
9+
id: number
10+
userEmail: string
11+
filename: string
12+
fileType: string
13+
rowsInserted: number
14+
rowsSkipped: number
15+
errorCount: number
16+
status: "success" | "partial" | "failed"
17+
uploadedAt: string
18+
}
19+
20+
const FILE_TYPE_COLORS: Record<string, string> = {
21+
pdp_cohort_ar: "bg-green-50 text-green-700",
22+
pdp_cohort_submission: "bg-green-50 text-green-700",
23+
course_ar: "bg-blue-50 text-blue-700",
24+
course_submission: "bg-blue-50 text-blue-700",
25+
ml_predictions: "bg-purple-50 text-purple-700",
26+
}
27+
28+
const FILE_TYPE_LABELS: Record<string, string> = {
29+
pdp_cohort_ar: "PDP Cohort AR",
30+
pdp_cohort_submission: "PDP Cohort Submission",
31+
course_ar: "Course AR",
32+
course_submission: "Course Submission",
33+
ml_predictions: "ML Predictions",
34+
}
35+
36+
const STATUS_STYLES: Record<string, string> = {
37+
success: "bg-green-100 text-green-700",
38+
partial: "bg-amber-100 text-amber-700",
39+
failed: "bg-red-100 text-red-700",
40+
}
41+
42+
export default function UploadHistoryPage() {
43+
const router = useRouter()
44+
const [entries, setEntries] = useState<UploadEntry[]>([])
45+
const [total, setTotal] = useState(0)
46+
const [page, setPage] = useState(1)
47+
const [loading, setLoading] = useState(true)
48+
const [statusCounts, setStatusCounts] = useState<Record<string, number>>({})
49+
const pageSize = 20
50+
51+
const fetchHistory = useCallback(async (p: number) => {
52+
setLoading(true)
53+
try {
54+
const res = await fetch(
55+
`/api/admin/upload/history?page=${p}&pageSize=${pageSize}`
56+
)
57+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
58+
const data = await res.json()
59+
setEntries(data.data ?? [])
60+
setTotal(data.total ?? 0)
61+
setStatusCounts(data.statusCounts ?? {})
62+
} catch {
63+
setEntries([])
64+
setTotal(0)
65+
} finally {
66+
setLoading(false)
67+
}
68+
}, [])
69+
70+
useEffect(() => {
71+
fetchHistory(page)
72+
}, [page, fetchHistory])
73+
74+
const pageCount = Math.ceil(total / pageSize)
75+
76+
return (
77+
<div className="container mx-auto px-4 py-6 max-w-5xl">
78+
<div className="flex items-center justify-between mb-6">
79+
<div>
80+
<h1 className="text-xl font-bold">Upload History</h1>
81+
<p className="text-sm text-muted-foreground">
82+
All data file uploads by admin and IR users
83+
</p>
84+
</div>
85+
<Button
86+
className="bg-purple-600 hover:bg-purple-700"
87+
onClick={() => router.push("/admin/upload")}
88+
>
89+
+ New Upload
90+
</Button>
91+
</div>
92+
93+
<div className="grid grid-cols-4 gap-3 mb-6">
94+
<div className="bg-muted/30 border rounded-lg px-4 py-3">
95+
<div className="text-[11px] text-muted-foreground uppercase tracking-wide">Total Uploads</div>
96+
<div className="text-2xl font-bold mt-1">{total}</div>
97+
</div>
98+
<div className="bg-green-50 border border-green-200 rounded-lg px-4 py-3">
99+
<div className="text-[11px] text-green-700 uppercase tracking-wide">Successful</div>
100+
<div className="text-2xl font-bold text-green-700 mt-1">{statusCounts.success ?? 0}</div>
101+
</div>
102+
<div className="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3">
103+
<div className="text-[11px] text-amber-700 uppercase tracking-wide">Partial</div>
104+
<div className="text-2xl font-bold text-amber-700 mt-1">{statusCounts.partial ?? 0}</div>
105+
</div>
106+
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3">
107+
<div className="text-[11px] text-red-700 uppercase tracking-wide">Failed</div>
108+
<div className="text-2xl font-bold text-red-700 mt-1">{statusCounts.failed ?? 0}</div>
109+
</div>
110+
</div>
111+
112+
{loading ? (
113+
<div className="flex items-center justify-center py-12 text-muted-foreground">
114+
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading…
115+
</div>
116+
) : entries.length === 0 ? (
117+
<div className="text-center py-12 text-muted-foreground text-sm">
118+
No uploads yet. Click &quot;+ New Upload&quot; to get started.
119+
</div>
120+
) : (
121+
<div className="border rounded-lg overflow-hidden">
122+
<table className="w-full text-sm">
123+
<thead>
124+
<tr className="bg-muted">
125+
<th className="px-4 py-2.5 text-left font-semibold">File</th>
126+
<th className="px-4 py-2.5 text-left font-semibold">Type</th>
127+
<th className="px-4 py-2.5 text-right font-semibold">Inserted</th>
128+
<th className="px-4 py-2.5 text-right font-semibold">Skipped</th>
129+
<th className="px-4 py-2.5 text-right font-semibold">Errors</th>
130+
<th className="px-4 py-2.5 text-left font-semibold">Status</th>
131+
<th className="px-4 py-2.5 text-left font-semibold">Uploaded By</th>
132+
<th className="px-4 py-2.5 text-left font-semibold">Date</th>
133+
</tr>
134+
</thead>
135+
<tbody>
136+
{entries.map((e, i) => (
137+
<tr key={e.id} className={i % 2 === 1 ? "bg-muted/20" : ""}>
138+
<td className="px-4 py-2.5 font-mono text-xs">{e.filename}</td>
139+
<td className="px-4 py-2.5">
140+
<span className={`text-[11px] px-2 py-0.5 rounded ${FILE_TYPE_COLORS[e.fileType] ?? "bg-muted text-muted-foreground"}`}>
141+
{FILE_TYPE_LABELS[e.fileType] ?? e.fileType}
142+
</span>
143+
</td>
144+
<td className="px-4 py-2.5 text-right font-medium">{e.rowsInserted.toLocaleString()}</td>
145+
<td className="px-4 py-2.5 text-right text-amber-700">{e.rowsSkipped}</td>
146+
<td className="px-4 py-2.5 text-right text-red-700">{e.errorCount}</td>
147+
<td className="px-4 py-2.5">
148+
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${STATUS_STYLES[e.status] ?? ""}`}>
149+
{e.status}
150+
</span>
151+
</td>
152+
<td className="px-4 py-2.5 text-xs text-muted-foreground">{e.userEmail}</td>
153+
<td className="px-4 py-2.5 text-xs text-muted-foreground">{new Date(e.uploadedAt).toLocaleDateString()}</td>
154+
</tr>
155+
))}
156+
</tbody>
157+
</table>
158+
</div>
159+
)}
160+
161+
{pageCount > 1 && (
162+
<div className="flex items-center justify-between mt-4 text-xs text-muted-foreground">
163+
<span>Showing {(page - 1) * pageSize + 1}{Math.min(page * pageSize, total)} of {total} uploads</span>
164+
<div className="flex gap-1">
165+
<button className="border px-2 py-1 rounded disabled:opacity-40" disabled={page <= 1} onClick={() => setPage(page - 1)}>← Prev</button>
166+
{Array.from({ length: pageCount }, (_, i) => i + 1).slice(0, 5).map((p) => (
167+
<button
168+
key={p}
169+
className={`px-2 py-1 rounded ${p === page ? "bg-purple-600 text-white" : "border hover:bg-muted"}`}
170+
onClick={() => setPage(p)}
171+
>
172+
{p}
173+
</button>
174+
))}
175+
<button className="border px-2 py-1 rounded disabled:opacity-40" disabled={page >= pageCount} onClick={() => setPage(page + 1)}>Next →</button>
176+
</div>
177+
</div>
178+
)}
179+
</div>
180+
)
181+
}

0 commit comments

Comments
 (0)