From 8817eff093a9eb2779f4199a5e88b78609af815e Mon Sep 17 00:00:00 2001 From: pixels26 Date: Sun, 31 May 2026 11:26:54 +0000 Subject: [PATCH] added dispute resolution page UI --- app/dashboard/disputes/[id]/page.test.tsx | 45 + app/dashboard/disputes/[id]/page.tsx | 508 +++++ app/dashboard/disputes/page.tsx | 17 +- package-lock.json | 22 +- package.json | 8 + pnpm-lock.yaml | 2090 ++++++++++++++++++++- vitest.config.ts | 15 + vitest.setup.ts | 1 + 8 files changed, 2646 insertions(+), 60 deletions(-) create mode 100644 app/dashboard/disputes/[id]/page.test.tsx create mode 100644 app/dashboard/disputes/[id]/page.tsx create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/app/dashboard/disputes/[id]/page.test.tsx b/app/dashboard/disputes/[id]/page.test.tsx new file mode 100644 index 0000000..115953f --- /dev/null +++ b/app/dashboard/disputes/[id]/page.test.tsx @@ -0,0 +1,45 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DisputeResolutionPage from './page' + +const mockDispute = { + id: 42, + job_title: 'Website Rebuild', + reason: 'Quality issue with the final delivery', + status: 'open', + created_at: '2024-02-01T12:00:00.000Z', + updated_at: '2024-02-01T12:00:00.000Z', + raised_by_username: 'alice', + raised_by_wallet: 'GABCDE1234', +} + +describe('DisputeResolutionPage', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => mockDispute, + }) as Response, + )) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders dispute details and evidence UI', async () => { + render() + + expect(screen.getByText(/review dispute/i)).toBeInTheDocument() + expect(screen.getByText(/dispute resolution/i)).toBeInTheDocument() + + await waitFor(() => { + expect(screen.getByText(/Website Rebuild/i)).toBeInTheDocument() + expect(screen.getByText(/Quality issue with the final delivery/i)).toBeInTheDocument() + expect(screen.getByText(/alice/i)).toBeInTheDocument() + }) + + expect(screen.getByRole('button', { name: /choose files/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /submit evidence/i })).toBeInTheDocument() + }) +}) diff --git a/app/dashboard/disputes/[id]/page.tsx b/app/dashboard/disputes/[id]/page.tsx new file mode 100644 index 0000000..4370f4f --- /dev/null +++ b/app/dashboard/disputes/[id]/page.tsx @@ -0,0 +1,508 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { + ArrowLeft, + AlertCircle, + CheckCircle2, + Clock3, + FilePlus, + ShieldCheck, + Upload, + Users, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +interface DisputeDetails { + id: number; + job_title: string; + reason: string; + status: "open" | "under_review" | "resolved" | string; + created_at: string; + updated_at?: string; + resolution?: string | null; + resolved_at?: string | null; + raised_by_username?: string | null; + raised_by_wallet?: string | null; +} + +interface PreviewFile { + id: string; + name: string; + url: string; + type: string; + size: number; +} + +const statusStyles: Record = { + open: { + label: "Open", + color: "bg-amber-500/10", + textColor: "text-amber-500", + }, + under_review: { + label: "Under Review", + color: "bg-secondary/10", + textColor: "text-secondary", + }, + resolved: { + label: "Resolved", + color: "bg-accent/10", + textColor: "text-accent", + }, +}; + +const allowedFileTypes = [ + "image/", + "application/pdf", + "text/plain", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", +]; + +function formatDate(value?: string) { + if (!value) return "—"; + return new Date(value).toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function createPreviewId(file: File) { + return `${file.name}-${file.size}-${file.lastModified}`; +} + +function isAcceptedFile(file: File) { + return ( + allowedFileTypes.some((allowed) => file.type.startsWith(allowed)) || + file.name.toLowerCase().endsWith(".docx") + ); +} + +export default function DisputeResolutionPage({ params }: { params: { id: string } }) { + return ; +} + +function DisputeResolutionView({ disputeId }: { disputeId: string }) { + const [dispute, setDispute] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); + const [previews, setPreviews] = useState([]); + const [notes, setNotes] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + + useEffect(() => { + let mounted = true; + // eslint-disable-next-line react-hooks/set-state-in-effect + setLoading(true); + setError(null); + + fetch(`/api/disputes/${disputeId}`, { + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + }) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.error || "Unable to load dispute details."); + } + return res.json(); + }) + .then((data) => { + if (!mounted) return; + setDispute(data); + }) + .catch((err) => { + if (!mounted) return; + setError(err.message || "Unable to load dispute details."); + }) + .finally(() => { + if (mounted) setLoading(false); + }); + + return () => { + mounted = false; + }; + }, [disputeId]); + + useEffect(() => { + return () => { + previews.forEach((preview) => URL.revokeObjectURL(preview.url)); + }; + }, [previews]); + + const handleFileUpload = (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + if (files.length === 0) return; + + const nextFiles: File[] = []; + const invalidFiles: string[] = []; + const sizeExceeded: string[] = []; + + for (const file of files) { + if (nextFiles.length + selectedFiles.length >= 5) break; + if (!isAcceptedFile(file)) { + invalidFiles.push(file.name); + continue; + } + if (file.size > 10 * 1024 * 1024) { + sizeExceeded.push(file.name); + continue; + } + nextFiles.push(file); + } + + if (invalidFiles.length > 0) { + toast.error(`Unsupported file type: ${invalidFiles.join(", ")}`); + } + if (sizeExceeded.length > 0) { + toast.error(`File size too large: ${sizeExceeded.join(", ")}`); + } + + if (nextFiles.length === 0) { + event.target.value = ""; + return; + } + + const newPreviews = nextFiles.map((file) => ({ + id: createPreviewId(file), + name: file.name, + type: file.type, + size: file.size, + url: URL.createObjectURL(file), + })); + + setSelectedFiles((current) => [...current, ...nextFiles].slice(0, 5)); + setPreviews((current) => [...current, ...newPreviews].slice(0, 5)); + event.target.value = ""; + }; + + const handleRemoveFile = (removeId: string) => { + setSelectedFiles((current) => + current.filter((file) => createPreviewId(file) !== removeId), + ); + setPreviews((current) => current.filter((preview) => preview.id !== removeId)); + }; + + const submitEvidence = async (event: React.FormEvent) => { + event.preventDefault(); + + if (selectedFiles.length === 0 && notes.trim().length === 0) { + toast.error("Add evidence or notes before submitting."); + return; + } + + setIsSubmitting(true); + await new Promise((resolve) => setTimeout(resolve, 800)); + setIsSubmitting(false); + setSelectedFiles([]); + setPreviews([]); + setNotes(""); + toast.success("Dispute evidence submitted successfully."); + }; + + const status = statusStyles[dispute?.status ?? "open"] ?? statusStyles.open; + + return ( +
+
+
+

+ Dispute resolution +

+

Review dispute

+

+ Use this page to view dispute details, attach supporting evidence, and track the resolution outcome. +

+
+ + + +
+ + {loading ? ( +
+ Loading dispute details… +
+ ) : error ? ( +
+

Unable to load dispute

+

{error}

+
+ ) : ( +
+
+ + +
+
+ {dispute?.job_title ?? "Dispute details"} + + {dispute?.reason ? "Reason and context for this dispute." : "No dispute reason available."} + +
+ + {status.label} + +
+
+ + +
+
+

Raised by

+

+ {dispute?.raised_by_username ?? "Unknown"} +

+

{dispute?.raised_by_wallet ?? "Wallet unavailable"}

+
+
+

Created

+

+ {formatDate(dispute?.created_at)} +

+

Last updated {formatDate(dispute?.updated_at ?? dispute?.created_at)}

+
+
+ +
+

Dispute reason

+
+ {dispute?.reason ?? "No additional information provided."} +
+
+ +
+

Current resolution outcome

+
+

Status

+

+ {dispute?.status === "resolved" + ? "Resolved" + : dispute?.status === "under_review" + ? "In review" + : "Open"} +

+

+ {dispute?.status === "resolved" + ? dispute?.resolution ?? "Final outcome has been recorded." + : "This dispute is being evaluated by the platform team and DAO voting integration will be added soon."} +

+
+
+
+
+ +
+ + +
+ Evidence upload + + Share files and notes to support the dispute resolution review. + +
+
+ + +
+
+ + + +

+ Maximum 5 files, 10MB each. Supported: images, PDF, DOCX, TXT. +

+
+
+

Selected files

+

{selectedFiles.length}

+
+
+ + {previews.length > 0 && ( +
+ {previews.map((file) => ( +
+ {file.type.startsWith("image/") ? ( + {file.name} + ) : ( +
+ +
+ )} +
+
+

{file.name}

+

{(file.size / 1024 / 1024).toFixed(1)} MB

+
+ +
+
+ ))} +
+ )} + +
+ +