diff --git a/frontend/module/components/document-search/DocumentSearchBar.tsx b/frontend/module/components/document-search/DocumentSearchBar.tsx new file mode 100644 index 0000000..3323efa --- /dev/null +++ b/frontend/module/components/document-search/DocumentSearchBar.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +interface DocumentSearchResult { + id: string; + title: string; + status: string; + uploadedAt: string; +} + +interface DocumentSearchBarProps { + onResults: (results: DocumentSearchResult[]) => void; +} + +export default function DocumentSearchBar({ onResults }: DocumentSearchBarProps) { + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [empty, setEmpty] = useState(false); + const debounceRef = useRef | null>(null); + const abortRef = useRef(null); + + const search = useCallback( + async (q: string) => { + if (!q.trim()) { + onResults([]); + setEmpty(false); + return; + } + + abortRef.current?.abort(); + abortRef.current = new AbortController(); + + setLoading(true); + setEmpty(false); + + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/module/documents/search?q=${encodeURIComponent(q)}`, + { + headers: { + Authorization: `Bearer ${ + typeof localStorage !== "undefined" + ? (localStorage.getItem("access_token") ?? "") + : "" + }`, + }, + signal: abortRef.current.signal, + } + ); + if (!res.ok) throw new Error("Search failed"); + const data: DocumentSearchResult[] = await res.json(); + onResults(data); + setEmpty(data.length === 0); + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") return; + } finally { + setLoading(false); + } + }, + [onResults] + ); + + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + search(query); + }, 350); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query, search]); + + function handleClear() { + setQuery(""); + setEmpty(false); + onResults([]); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + if (debounceRef.current) clearTimeout(debounceRef.current); + search(query); + } + if (e.key === "Escape") handleClear(); + } + + return ( +
+
+ + + +
+ + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search documents…" + aria-label="Search documents" + className="w-full rounded-lg border border-gray-300 py-2 pl-9 pr-10 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ {loading && ( +
+ )} + {!loading && query && ( + + )} +
+ + {empty && !loading && ( +

No results found.

+ )} +
+ ); +} diff --git a/frontend/module/components/pagination/PaginationControls.tsx b/frontend/module/components/pagination/PaginationControls.tsx new file mode 100644 index 0000000..5089b97 --- /dev/null +++ b/frontend/module/components/pagination/PaginationControls.tsx @@ -0,0 +1,89 @@ +"use client"; + +interface PaginationControlsProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +function buildPageRange(current: number, total: number): (number | "...")[] { + if (total <= 7) { + return Array.from({ length: total }, (_, i) => i + 1); + } + + const pages: (number | "...")[] = []; + + if (current <= 4) { + for (let i = 1; i <= 5; i++) pages.push(i); + pages.push("..."); + pages.push(total); + } else if (current >= total - 3) { + pages.push(1); + pages.push("..."); + for (let i = total - 4; i <= total; i++) pages.push(i); + } else { + pages.push(1); + pages.push("..."); + pages.push(current - 1); + pages.push(current); + pages.push(current + 1); + pages.push("..."); + pages.push(total); + } + + return pages; +} + +export default function PaginationControls({ + currentPage, + totalPages, + onPageChange, +}: PaginationControlsProps) { + if (totalPages <= 1) return null; + + const pages = buildPageRange(currentPage, totalPages); + + return ( + + ); +} diff --git a/frontend/module/disputes/list/DisputeListPage.tsx b/frontend/module/disputes/list/DisputeListPage.tsx new file mode 100644 index 0000000..6b63adb --- /dev/null +++ b/frontend/module/disputes/list/DisputeListPage.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; +import PaginationControls from "../../components/pagination/PaginationControls"; + +interface Dispute { + id: string; + documentTitle: string; + disputeType: string; + status: string; + submittedAt: string; +} + +const STATUS_COLORS: Record = { + OPEN: "bg-blue-100 text-blue-700", + UNDER_REVIEW: "bg-amber-100 text-amber-700", + RESOLVED: "bg-green-100 text-green-700", + REJECTED: "bg-red-100 text-red-700", +}; + +const STATUS_OPTIONS = ["", "OPEN", "UNDER_REVIEW", "RESOLVED", "REJECTED"]; +const PAGE_SIZE = 10; + +function StatusBadge({ status }: { status: string }) { + return ( + + {status.replace("_", " ")} + + ); +} + +function SkeletonRow() { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + +
+ + ))} + + ); +} + +export default function DisputeListPage() { + const [disputes, setDisputes] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDisputes = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams({ + page: String(page), + limit: String(PAGE_SIZE), + ...(statusFilter ? { status: statusFilter } : {}), + }); + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/module/disputes?${params}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("access_token")}`, + }, + } + ); + if (!res.ok) throw new Error("Failed to load disputes."); + const data = await res.json(); + setDisputes(data.data ?? data); + setTotal(data.total ?? (data.data ?? data).length); + } catch (err) { + setError(err instanceof Error ? err.message : "Unexpected error."); + } finally { + setLoading(false); + } + }, [page, statusFilter]); + + useEffect(() => { + fetchDisputes(); + }, [fetchDisputes]); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+
+

My Disputes

+
+ + + Submit a Dispute + +
+
+ + {error && ( +
+

{error}

+ +
+ )} + +
+ + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ) + ) : disputes.length === 0 ? ( + + + + ) : ( + disputes.map((d) => ( + + + + + + + + )) + )} + +
Document TitleDispute TypeStatusSubmitted DateActions
+ You have no disputes.{" "} + + Submit a Dispute + +
+ {d.documentTitle} + {d.disputeType} + + + {new Date(d.submittedAt).toLocaleDateString()} + + + View Details + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/module/disputes/submit/DisputeSubmissionForm.tsx b/frontend/module/disputes/submit/DisputeSubmissionForm.tsx new file mode 100644 index 0000000..a7bec94 --- /dev/null +++ b/frontend/module/disputes/submit/DisputeSubmissionForm.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +interface Document { + id: string; + title: string; +} + +interface DisputeSubmissionFormProps { + documentId?: string; +} + +const DISPUTE_TYPES = [ + "OWNERSHIP", + "AUTHENTICITY", + "CONTENT", + "DUPLICATE", + "OTHER", +]; + +const MAX_FILE_SIZE = 10 * 1024 * 1024; +const ALLOWED_TYPES = ["application/pdf", "image/png", "image/jpeg"]; + +export default function DisputeSubmissionForm({ + documentId, +}: DisputeSubmissionFormProps) { + const router = useRouter(); + const [documents, setDocuments] = useState([]); + const [selectedDocId, setSelectedDocId] = useState(documentId ?? ""); + const [disputeType, setDisputeType] = useState(""); + const [description, setDescription] = useState(""); + const [file, setFile] = useState(null); + const [fileError, setFileError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + const fileRef = useRef(null); + + const token = () => localStorage.getItem("access_token") ?? ""; + + useEffect(() => { + fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/module/documents`, { + headers: { Authorization: `Bearer ${token()}` }, + }) + .then((r) => r.json()) + .then((data) => setDocuments(data.data ?? data)) + .catch(() => {}); + }, []); + + function handleFileChange(e: React.ChangeEvent) { + const f = e.target.files?.[0] ?? null; + setFileError(null); + if (!f) { + setFile(null); + return; + } + if (!ALLOWED_TYPES.includes(f.type)) { + setFileError("Only PDF, PNG, and JPEG files are allowed."); + if (fileRef.current) fileRef.current.value = ""; + return; + } + if (f.size > MAX_FILE_SIZE) { + setFileError("File must be smaller than 10 MB."); + if (fileRef.current) fileRef.current.value = ""; + return; + } + setFile(f); + } + + function validate() { + const errs: Record = {}; + if (!selectedDocId) errs.document = "Please select a document."; + if (!disputeType) errs.disputeType = "Please select a dispute type."; + if (!description.trim()) { + errs.description = "Description is required."; + } else if (description.trim().length < 20) { + errs.description = "Description must be at least 20 characters."; + } + return errs; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const errs = validate(); + if (Object.keys(errs).length > 0) { + setErrors(errs); + return; + } + setErrors({}); + setSubmitting(true); + + try { + let evidenceKey: string | null = null; + + if (file) { + const fd = new FormData(); + fd.append("file", file); + const uploadRes = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/module/disputes/evidence`, + { + method: "POST", + headers: { Authorization: `Bearer ${token()}` }, + body: fd, + } + ); + if (!uploadRes.ok) throw new Error("Evidence upload failed."); + const uploadData = await uploadRes.json(); + evidenceKey = uploadData.key ?? null; + } + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/module/disputes`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token()}`, + }, + body: JSON.stringify({ + documentId: selectedDocId, + disputeType, + description: description.trim(), + ...(evidenceKey ? { evidenceKey } : {}), + }), + } + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.message ?? "Submission failed."); + } + + router.push("/disputes?submitted=1"); + } catch (err) { + setErrors({ + form: err instanceof Error ? err.message : "Unexpected error.", + }); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Submit a Dispute

+ +
+ {errors.form && ( +

+ {errors.form} +

+ )} + +
+ + + {errors.document && ( +

{errors.document}

+ )} +
+ +
+ + + {errors.disputeType && ( +

{errors.disputeType}

+ )} +
+ +
+
+ + + {description.length} chars (min 20) + +
+