Skip to content

Commit eab917f

Browse files
committed
feat(upload): 3-step upload wizard page with auto-detection
1 parent f032b03 commit eab917f

1 file changed

Lines changed: 356 additions & 0 deletions

File tree

  • codebenders-dashboard/app/admin/upload
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
"use client"
2+
3+
import { useState, useCallback, useEffect } from "react"
4+
import { useRouter } from "next/navigation"
5+
import { DropZone } from "@/components/upload/drop-zone"
6+
import { ColumnMapper } from "@/components/upload/column-mapper"
7+
import { DataPreview } from "@/components/upload/data-preview"
8+
import { UploadSummary } from "@/components/upload/upload-summary"
9+
import { Button } from "@/components/ui/button"
10+
import { AlertCircle, CheckCircle, Loader2 } from "lucide-react"
11+
import type { ColumnMapping } from "@/lib/upload-schemas"
12+
13+
type Step = "upload" | "preview" | "complete"
14+
15+
interface PreviewData {
16+
detectedSchema: string | null
17+
detectedSchemaLabel: string | null
18+
confidence: number
19+
scores: Array<{ schemaId: string; label: string; score: number }>
20+
columns: ColumnMapping[]
21+
sampleRows: Record<string, string>[]
22+
totalRows: number
23+
warnings: string[]
24+
errors: string[]
25+
}
26+
27+
interface CommitResult {
28+
inserted: number
29+
skipped: number
30+
errors: Array<{ row: number; message: string }>
31+
uploadId: number
32+
}
33+
34+
interface HistoryEntry {
35+
id: number
36+
filename: string
37+
fileType: string
38+
rowsInserted: number
39+
status: string
40+
uploadedAt: string
41+
}
42+
43+
export default function UploadPage() {
44+
const router = useRouter()
45+
const [step, setStep] = useState<Step>("upload")
46+
const [file, setFile] = useState<File | null>(null)
47+
const [preview, setPreview] = useState<PreviewData | null>(null)
48+
const [columns, setColumns] = useState<ColumnMapping[]>([])
49+
const [selectedSchema, setSelectedSchema] = useState<string | null>(null)
50+
const [selectedSchemaLabel, setSelectedSchemaLabel] = useState<string | null>(null)
51+
const [commitResult, setCommitResult] = useState<CommitResult | null>(null)
52+
const [loading, setLoading] = useState(false)
53+
const [error, setError] = useState<string | null>(null)
54+
const [recentUploads, setRecentUploads] = useState<HistoryEntry[]>([])
55+
56+
useEffect(() => {
57+
fetch("/api/admin/upload/history?pageSize=5")
58+
.then((r) => r.json())
59+
.then((d) => setRecentUploads(d.data ?? []))
60+
.catch(() => {})
61+
}, [])
62+
63+
const handleFile = useCallback(async (f: File) => {
64+
setFile(f)
65+
setError(null)
66+
setLoading(true)
67+
68+
try {
69+
const formData = new FormData()
70+
formData.append("file", f)
71+
72+
const res = await fetch("/api/admin/upload/preview", {
73+
method: "POST",
74+
body: formData,
75+
})
76+
77+
if (!res.ok) {
78+
const data = await res.json()
79+
setError(data.error ?? "Preview failed")
80+
setLoading(false)
81+
return
82+
}
83+
84+
const data: PreviewData = await res.json()
85+
setPreview(data)
86+
setColumns(data.columns)
87+
setSelectedSchema(data.detectedSchema)
88+
setSelectedSchemaLabel(data.detectedSchemaLabel)
89+
setStep("preview")
90+
} catch (err) {
91+
setError((err as Error).message)
92+
} finally {
93+
setLoading(false)
94+
}
95+
}, [])
96+
97+
const handleSchemaOverride = useCallback(
98+
(schemaId: string, label: string) => {
99+
setSelectedSchema(schemaId)
100+
setSelectedSchemaLabel(label)
101+
},
102+
[]
103+
)
104+
105+
const handleCommit = useCallback(async () => {
106+
if (!file || !selectedSchema) return
107+
setLoading(true)
108+
setError(null)
109+
110+
try {
111+
const formData = new FormData()
112+
formData.append("file", file)
113+
formData.append("schemaId", selectedSchema)
114+
formData.append("columnMapping", JSON.stringify(columns))
115+
116+
const res = await fetch("/api/admin/upload/commit", {
117+
method: "POST",
118+
body: formData,
119+
})
120+
121+
if (!res.ok) {
122+
const data = await res.json()
123+
setError(data.error ?? "Upload failed")
124+
setLoading(false)
125+
return
126+
}
127+
128+
const data: CommitResult = await res.json()
129+
setCommitResult(data)
130+
setStep("complete")
131+
} catch (err) {
132+
setError((err as Error).message)
133+
} finally {
134+
setLoading(false)
135+
}
136+
}, [file, selectedSchema, columns])
137+
138+
const resetWizard = useCallback(() => {
139+
setStep("upload")
140+
setFile(null)
141+
setPreview(null)
142+
setColumns([])
143+
setSelectedSchema(null)
144+
setSelectedSchemaLabel(null)
145+
setCommitResult(null)
146+
setError(null)
147+
}, [])
148+
149+
const headers = preview?.sampleRows?.[0] ? Object.keys(preview.sampleRows[0]) : []
150+
const hasRequiredErrors = (preview?.errors?.length ?? 0) > 0
151+
const stepLabels = ["Upload", "Preview & Map", "Complete"]
152+
const stepIndex = step === "upload" ? 0 : step === "preview" ? 1 : 2
153+
154+
return (
155+
<div className="container mx-auto px-4 py-6 max-w-4xl">
156+
<div className="mb-6">
157+
<h1 className="text-xl font-bold">Upload Data</h1>
158+
<p className="text-sm text-muted-foreground">
159+
Drop a PDP, course, or prediction file — we&apos;ll detect the format
160+
automatically
161+
</p>
162+
</div>
163+
164+
{/* Step indicator */}
165+
<div className="flex items-center gap-2 mb-6 text-xs">
166+
{stepLabels.map((label, i) => (
167+
<span key={label} className="flex items-center gap-2">
168+
{i > 0 && <span className="text-muted-foreground"></span>}
169+
<span
170+
className={
171+
i < stepIndex
172+
? "text-green-600 line-through"
173+
: i === stepIndex
174+
? "font-bold text-purple-700 bg-purple-50 px-2 py-0.5 rounded-full"
175+
: "text-muted-foreground"
176+
}
177+
>
178+
{i < stepIndex ? `${label} ✓` : `${i + 1}. ${label}`}
179+
</span>
180+
</span>
181+
))}
182+
</div>
183+
184+
{/* Error banner */}
185+
{error && (
186+
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 flex items-center gap-2 text-sm text-red-800">
187+
<AlertCircle className="h-4 w-4 shrink-0" />
188+
{error}
189+
</div>
190+
)}
191+
192+
{/* Step 1: Upload */}
193+
{step === "upload" && (
194+
<div className="space-y-6">
195+
<DropZone onFile={handleFile} disabled={loading} />
196+
{loading && (
197+
<div className="flex items-center gap-2 text-sm text-muted-foreground justify-center">
198+
<Loader2 className="h-4 w-4 animate-spin" /> Parsing file…
199+
</div>
200+
)}
201+
202+
{recentUploads.length > 0 && (
203+
<div>
204+
<h3 className="text-sm font-semibold mb-2">Recent Uploads</h3>
205+
<div className="space-y-1.5">
206+
{recentUploads.map((u) => (
207+
<div
208+
key={u.id}
209+
className="flex items-center justify-between px-3 py-2 bg-muted/30 rounded-md text-xs"
210+
>
211+
<div className="flex items-center gap-2">
212+
<code>{u.filename}</code>
213+
<span className="bg-green-50 text-green-700 px-1.5 py-0.5 rounded text-[10px]">
214+
{u.fileType}
215+
</span>
216+
</div>
217+
<div className="flex items-center gap-3 text-muted-foreground">
218+
<span>{u.rowsInserted} rows</span>
219+
<span>{new Date(u.uploadedAt).toLocaleDateString()}</span>
220+
<span className="text-green-600"></span>
221+
</div>
222+
</div>
223+
))}
224+
</div>
225+
</div>
226+
)}
227+
</div>
228+
)}
229+
230+
{/* Step 2: Preview & Map */}
231+
{step === "preview" && preview && (
232+
<div className="space-y-5">
233+
{preview.confidence >= 0.6 ? (
234+
<div className="bg-green-50 border border-green-200 rounded-lg p-3 flex items-center justify-between">
235+
<div className="flex items-center gap-2 text-sm">
236+
<CheckCircle className="h-4 w-4 text-green-600" />
237+
<span className="font-semibold">{selectedSchemaLabel}</span>
238+
<span className="text-muted-foreground">
239+
{file?.name}{preview.totalRows} rows,{" "}
240+
{columns.filter((c) => c.status === "matched").length}/
241+
{columns.length} columns matched
242+
</span>
243+
</div>
244+
<button
245+
className="text-xs text-muted-foreground border px-2 py-0.5 rounded hover:bg-muted"
246+
onClick={() => {}}
247+
>
248+
Wrong? Change type
249+
</button>
250+
</div>
251+
) : (
252+
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
253+
<div className="flex items-center gap-2 text-sm mb-3">
254+
<AlertCircle className="h-4 w-4 text-amber-600" />
255+
<span className="font-semibold">
256+
Couldn&apos;t confidently detect the file type
257+
</span>
258+
</div>
259+
<p className="text-xs text-muted-foreground mb-3">
260+
{file?.name} has {columns.length} columns — it partially matches
261+
multiple schemas. Please select the correct type:
262+
</p>
263+
<div className="flex gap-2 flex-wrap">
264+
{preview.scores
265+
.filter((s) => s.score > 0.1)
266+
.map((s) => (
267+
<button
268+
key={s.schemaId}
269+
className={`text-xs px-3 py-1.5 rounded border ${
270+
selectedSchema === s.schemaId
271+
? "border-purple-600 bg-purple-50 font-semibold"
272+
: "border-muted hover:bg-muted/50"
273+
}`}
274+
onClick={() => handleSchemaOverride(s.schemaId, s.label)}
275+
>
276+
{s.label}{" "}
277+
<span className="text-muted-foreground">
278+
({Math.round(s.score * 100)}%)
279+
</span>
280+
</button>
281+
))}
282+
</div>
283+
</div>
284+
)}
285+
286+
<div>
287+
<h3 className="text-sm font-semibold mb-2">Column Mapping</h3>
288+
<ColumnMapper
289+
columns={columns}
290+
schema={null}
291+
onMappingChange={setColumns}
292+
/>
293+
</div>
294+
295+
<DataPreview headers={headers} rows={preview.sampleRows} />
296+
297+
<div className="flex items-center justify-between">
298+
<div className="flex gap-4 text-xs">
299+
<span>
300+
📊 <strong>{preview.totalRows}</strong> rows
301+
</span>
302+
<span className="text-green-700">
303+
{columns.filter((c) => c.status === "matched").length} matched
304+
</span>
305+
{columns.filter((c) => c.status === "unmapped").length > 0 && (
306+
<span className="text-amber-700">
307+
{" "}
308+
{columns.filter((c) => c.status === "unmapped").length}{" "}
309+
unmapped
310+
</span>
311+
)}
312+
{hasRequiredErrors && (
313+
<span className="text-red-700">
314+
{preview.errors.length} errors
315+
</span>
316+
)}
317+
</div>
318+
<div className="flex gap-2">
319+
<Button variant="outline" size="sm" onClick={resetWizard}>
320+
← Back
321+
</Button>
322+
<Button
323+
size="sm"
324+
className="bg-purple-600 hover:bg-purple-700"
325+
onClick={handleCommit}
326+
disabled={loading || hasRequiredErrors || !selectedSchema}
327+
>
328+
{loading ? (
329+
<>
330+
<Loader2 className="h-3 w-3 animate-spin mr-1" />
331+
Uploading…
332+
</>
333+
) : (
334+
`Upload ${preview.totalRows} Rows →`
335+
)}
336+
</Button>
337+
</div>
338+
</div>
339+
</div>
340+
)}
341+
342+
{/* Step 3: Complete */}
343+
{step === "complete" && commitResult && (
344+
<UploadSummary
345+
filename={file?.name ?? ""}
346+
schemaLabel={selectedSchemaLabel ?? "Unknown"}
347+
inserted={commitResult.inserted}
348+
skipped={commitResult.skipped}
349+
errorCount={commitResult.errors.length}
350+
onUploadAnother={resetWizard}
351+
onViewHistory={() => router.push("/admin/upload/history")}
352+
/>
353+
)}
354+
</div>
355+
)
356+
}

0 commit comments

Comments
 (0)