Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/components/ApprovalStatusWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { ClockIcon, CheckCircleIcon, XCircleIcon } from "./icons/StatusIcons";

interface ApprovalRole {
id: string;
name: string;
avatar?: string;
}

interface ApprovalStatusWidgetProps {
required: ApprovalRole[];
given: ApprovalRole[];
pending: ApprovalRole[];
}

export function ApprovalStatusWidget({
required,
given,
pending,
}: ApprovalStatusWidgetProps) {
const getInitial = (name: string): string => {
return name.charAt(0).toUpperCase();
};

const renderRole = (
role: ApprovalRole,
status: "pending" | "approved" | "rejected"
) => {
const statusConfig = {
pending: { icon: ClockIcon, label: "Pending" },
approved: { icon: CheckCircleIcon, label: "Approved" },
rejected: { icon: XCircleIcon, label: "Rejected" },
};

const { icon: IconComponent, label } = statusConfig[status];

return (
<div
key={role.id}
className="flex items-center gap-4 p-4 rounded-lg border border-slate-200 bg-white hover:bg-slate-50 transition-colors"
aria-label={`${role.name} - ${label}`}
>
<div className="flex-shrink-0">
{role.avatar ? (
<img
src={role.avatar}
alt={role.name}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-slate-400 to-slate-600 flex items-center justify-center text-white font-semibold text-sm">
{getInitial(role.name)}
</div>
)}
</div>

<div className="flex-1">
<p className="text-sm font-medium text-slate-900">{role.name}</p>
</div>

<div className="flex items-center gap-2">
<IconComponent />
<span className="text-xs font-medium text-slate-600">{label}</span>
</div>
</div>
);
};

return (
<div className="w-full space-y-6">
{required.length > 0 && (
<section>
<h3 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-600 mb-3">
Required Approvals
</h3>
<div className="space-y-2">
{required.map((role) => renderRole(role, "approved"))}
</div>
</section>
)}

{given.length > 0 && (
<section>
<h3 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-600 mb-3">
Given Approvals
</h3>
<div className="space-y-2">
{given.map((role) => renderRole(role, "approved"))}
</div>
</section>
)}

{pending.length > 0 && (
<section>
<h3 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-600 mb-3">
Pending Approvals
</h3>
<div className="space-y-2">
{pending.map((role) => renderRole(role, "pending"))}
</div>
</section>
)}

{required.length === 0 && given.length === 0 && pending.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-6 text-center">
<p className="text-sm text-slate-600">No approvals to display</p>
</div>
)}
</div>
);
}
6 changes: 6 additions & 0 deletions src/components/icons/StatusIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,9 @@ export const TrashIcon = () => (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);

export const XCircleIcon = () => (
<svg className="w-5 h-5 text-red-500 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2.26-2.26m0 0l2.26-2.26m-2.26 2.26l-2.26-2.26m2.26 2.26l2.26 2.26M12 2a10 10 0 110 20 10 10 0 010-20z" />
</svg>
);
219 changes: 219 additions & 0 deletions src/pages/MyDisputesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { useState, useMemo } from "react";

Check failure on line 1 in src/pages/MyDisputesPage.tsx

View workflow job for this annotation

GitHub Actions / validate

'useMemo' is defined but never used
import { useNavigate } from "react-router-dom";
import { useApiQuery } from "../hooks/useApiQuery";
import { EscrowStatusBadge } from "../components/ui/EscrowStatusBadge";

interface Dispute {
id: string;
petName: string;
opponentName: string;
status: string;
createdAt: string;
}

interface DisputesResponse {
disputes: Dispute[];
total: number;
page: number;
pageSize: number;
}

const ITEMS_PER_PAGE = 10;

export default function MyDisputesPage() {
const navigate = useNavigate();
const [currentPage, setCurrentPage] = useState(1);

const { data, isLoading, isError } = useApiQuery<DisputesResponse>(
["disputes", currentPage],
() =>
fetch(`/api/disputes?page=${currentPage}&pageSize=${ITEMS_PER_PAGE}`).then(
(res) => res.json()
)
);

const disputes = data?.disputes || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / ITEMS_PER_PAGE);

const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};

if (isLoading) {
return (
<div className="min-h-screen bg-white py-6 sm:py-10 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 mb-6 sm:mb-8">
My Disputes
</h1>
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="animate-pulse rounded-lg border border-slate-200 bg-white p-4 h-20"
/>
))}
</div>
</div>
</div>
);
}

if (isError || !data) {
return (
<div className="min-h-screen bg-white py-6 sm:py-10 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 mb-6 sm:mb-8">
My Disputes
</h1>
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<p className="text-sm font-medium text-red-900">
Failed to load disputes. Please try again.
</p>
</div>
</div>
</div>
);
}

return (
<div className="min-h-screen bg-white py-6 sm:py-10 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 mb-2">
My Disputes
</h1>
<p className="text-sm text-gray-600 mb-6">
{total} dispute{total !== 1 ? "s" : ""}
</p>

{disputes.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-slate-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<p className="mt-4 text-sm font-medium text-slate-600">
No disputes yet
</p>
<p className="text-xs text-slate-500">
Active disputes will appear here
</p>
</div>
) : (
<>
<div className="space-y-3 mb-6">
{disputes.map((dispute) => (
<button
key={dispute.id}
onClick={() => navigate(`/disputes/${dispute.id}`)}
className="w-full text-left rounded-lg border border-slate-200 bg-white p-4 hover:border-slate-300 hover:bg-slate-50 transition-colors"
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex-1">
<p className="text-xs font-semibold text-slate-500 uppercase tracking-[0.2em]">
Dispute #{dispute.id}
</p>
<h3 className="mt-1 text-base font-semibold text-slate-900">
{dispute.petName}
</h3>
<p className="mt-1 text-sm text-slate-600">
Opponent: {dispute.opponentName}
</p>
</div>

<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div>
<p className="text-xs text-slate-500">Filed on</p>
<p className="text-sm font-medium text-slate-900">
{formatDate(dispute.createdAt)}
</p>
</div>
<div className="min-w-fit">
<EscrowStatusBadge status={dispute.status} />
</div>
</div>
</div>
</button>
))}
</div>

{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-200 pt-6">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-900 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>

<div className="flex items-center gap-2">
{Array.from({ length: totalPages }).map((_, i) => {
const page = i + 1;
const isCurrentPage = page === currentPage;
if (
page === 1 ||
page === totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1)
) {
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isCurrentPage
? "bg-slate-900 text-white"
: "border border-slate-200 text-slate-900 hover:bg-slate-50"
}`}
>
{page}
</button>
);
} else if (
(page === 2 && currentPage > 3) ||
(page === totalPages - 1 && currentPage < totalPages - 2)
) {
return (
<span
key={page}
className="px-2 text-slate-600"
>
...
</span>
);
}
})}
</div>

<button
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
disabled={currentPage === totalPages}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-900 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</>
)}
</div>
</div>
);
}
Loading