diff --git a/app/admin/invoices/__tests__/page.test.tsx b/app/admin/invoices/__tests__/page.test.tsx new file mode 100644 index 0000000..515c86f --- /dev/null +++ b/app/admin/invoices/__tests__/page.test.tsx @@ -0,0 +1,203 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import AdminInvoicesPage from "../page"; +import * as api from "@/lib/api"; +import * as AuthContextModule from "@/context/AuthContext"; + +vi.mock("@/lib/api"); +vi.mock("@/context/AuthContext"); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), + usePathname: () => "/admin/invoices", + useSearchParams: () => new URLSearchParams(), +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; +} + +// Helper to create a fake JWT with a specific role +function makeToken(role: string) { + const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = btoa(JSON.stringify({ sub: "user-1", role })); + const signature = "signature"; + return `${header}.${payload}.${signature}`; +} + +const mockPendingInvoices = [ + { + invoiceId: "inv-101", + sellerName: "Acme Supplies", + faceValue: 5000, + submittedAt: "2026-08-20T10:00:00.000Z", + documentUrl: "/docs/inv-101.pdf", + status: "pending", + }, + { + invoiceId: "inv-102", + sellerName: "Stellar Logistics", + faceValue: 12000, + submittedAt: "2026-08-21T14:30:00.000Z", + documentUrl: "/docs/inv-102.pdf", + status: "pending", + }, +]; + +describe("Admin Invoices Page", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("denies access when user JWT has no admin role", () => { + const userToken = makeToken("user"); + vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({ + jwt: userToken, + address: "GABC123", + isConnecting: false, + loginWithWallet: vi.fn(), + logout: vi.fn(), + }); + + render(, { wrapper: createWrapper() }); + expect(screen.getByTestId("unauthorized-card")).toBeInTheDocument(); + expect(screen.getByText("Access Denied")).toBeInTheDocument(); + }); + + it("allows access and displays pending invoices with all required fields when user is admin", async () => { + const adminToken = makeToken("admin"); + vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({ + jwt: adminToken, + address: "GADMIN123", + isConnecting: false, + loginWithWallet: vi.fn(), + logout: vi.fn(), + }); + + vi.mocked(api.fetchAdminInvoices).mockResolvedValue({ + invoices: mockPendingInvoices, + has_more: false, + next_cursor: null, + }); + + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByTestId("admin-invoices-table")).toBeInTheDocument(); + }); + + expect(screen.getByText("Acme Supplies")).toBeInTheDocument(); + expect(screen.getByText("inv-101")).toBeInTheDocument(); + expect(screen.getByText("5,000 XLM")).toBeInTheDocument(); + + expect(screen.getByText("Stellar Logistics")).toBeInTheDocument(); + expect(screen.getByText("inv-102")).toBeInTheDocument(); + expect(screen.getByText("12,000 XLM")).toBeInTheDocument(); + }); + + it("'View Document' opens document modal", async () => { + const adminToken = makeToken("admin"); + vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({ + jwt: adminToken, + address: "GADMIN123", + isConnecting: false, + loginWithWallet: vi.fn(), + logout: vi.fn(), + }); + + vi.mocked(api.fetchAdminInvoices).mockResolvedValue({ + invoices: [mockPendingInvoices[0]], + has_more: false, + next_cursor: null, + }); + + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByTestId("view-doc-btn-inv-101")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("view-doc-btn-inv-101")); + + expect(screen.getByTestId("document-modal")).toBeInTheDocument(); + }); + + it("Approve action updates invoice status and removes row", async () => { + const adminToken = makeToken("admin"); + vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({ + jwt: adminToken, + address: "GADMIN123", + isConnecting: false, + loginWithWallet: vi.fn(), + logout: vi.fn(), + }); + + vi.mocked(api.fetchAdminInvoices).mockResolvedValue({ + invoices: [mockPendingInvoices[0]], + has_more: false, + next_cursor: null, + }); + vi.mocked(api.approveAdminInvoice).mockResolvedValue({ success: true }); + + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByTestId("approve-btn-inv-101")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("approve-btn-inv-101")); + + await waitFor(() => { + expect(api.approveAdminInvoice).toHaveBeenCalledWith("inv-101", adminToken); + expect(screen.queryByTestId("invoice-row-inv-101")).not.toBeInTheDocument(); + }); + }); + + it("Reject action requires a reason and updates status with reason", async () => { + const adminToken = makeToken("admin"); + vi.spyOn(AuthContextModule, "useAuth").mockReturnValue({ + jwt: adminToken, + address: "GADMIN123", + isConnecting: false, + loginWithWallet: vi.fn(), + logout: vi.fn(), + }); + + vi.mocked(api.fetchAdminInvoices).mockResolvedValue({ + invoices: [mockPendingInvoices[0]], + has_more: false, + next_cursor: null, + }); + vi.mocked(api.rejectAdminInvoice).mockResolvedValue({ success: true }); + + render(, { wrapper: createWrapper() }); + + await waitFor(() => { + expect(screen.getByTestId("reject-btn-inv-101")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("reject-btn-inv-101")); + + const confirmBtn = screen.getByTestId("confirm-reject-btn-inv-101"); + expect(confirmBtn).toBeDisabled(); + + const reasonInput = screen.getByTestId("reject-reason-input-inv-101"); + fireEvent.change(reasonInput, { target: { value: "Invalid documentation" } }); + + expect(confirmBtn).not.toBeDisabled(); + fireEvent.click(confirmBtn); + + await waitFor(() => { + expect(api.rejectAdminInvoice).toHaveBeenCalledWith("inv-101", "Invalid documentation", adminToken); + expect(screen.queryByTestId("invoice-row-inv-101")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/admin/invoices/page.tsx b/app/admin/invoices/page.tsx index 0c9786c..13c3b2e 100644 --- a/app/admin/invoices/page.tsx +++ b/app/admin/invoices/page.tsx @@ -1,11 +1,80 @@ "use client"; -import { AdminInvoiceReview } from "@/components/admin/AdminInvoiceReview"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/context/AuthContext"; +import { AdminInvoicesReview } from "@/components/admin/AdminInvoicesReview"; +import { Card, CardContent } from "@/components/ui/card"; +import { ShieldAlert } from "lucide-react"; + +interface JwtPayload { + role?: string; + exp?: number; + [key: string]: any; +} + +function parseJwt(token: string): JwtPayload | null { + try { + const base64Url = token.split(".")[1]; + if (!base64Url) return null; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + atob(base64) + .split("") + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join("") + ); + return JSON.parse(jsonPayload); + } catch { + return null; + } +} export default function AdminInvoicesPage() { + const { jwt, isConnecting } = useAuth(); + const router = useRouter(); + const [isAdmin, setIsAdmin] = useState(null); + + useEffect(() => { + if (isConnecting) return; + + if (!jwt) { + setIsAdmin(false); + return; + } + + const payload = parseJwt(jwt); + if (payload && payload.role === "admin") { + setIsAdmin(true); + } else { + setIsAdmin(false); + } + }, [jwt, isConnecting]); + + if (isConnecting || isAdmin === null) { + return null; + } + + if (!isAdmin) { + return ( +
+ + + +

Access Denied

+

+ You must be logged in as an admin user to access this page. +

+
+
+
+ ); + } + return ( -
- -
+
+

Admin Invoice Management

+ +
); } diff --git a/app/marketplace/__tests__/marketplace-filter.test.tsx b/app/marketplace/__tests__/marketplace-filter.test.tsx new file mode 100644 index 0000000..cce9fad --- /dev/null +++ b/app/marketplace/__tests__/marketplace-filter.test.tsx @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import MarketplacePage from "../page"; +import { useInfiniteQuery } from "@tanstack/react-query"; + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useInfiniteQuery: vi.fn(), + }; +}); + +const mockReplace = vi.fn(); +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ replace: mockReplace }), + usePathname: () => "/marketplace", +})); + +const mockUseInfiniteQuery = vi.mocked(useInfiniteQuery); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; +} + +function makeInvoice(overrides: any = {}) { + return { + id: "inv-1", + title: "Test Invoice", + seller: "seller", + amount: 10000, + raised: 5000, + investor_count: 3, + status: "open", + due_date: "2026-12-01T00:00:00.000Z", + yield_percentage: 15, + has_more: false, + next_cursor: null, + ...overrides, + }; +} + +const mockInvoices = [ + makeInvoice({ id: "1", title: "Open Low Yield", status: "open", yield_percentage: 5, due_date: "2026-05-01T00:00:00Z" }), + makeInvoice({ id: "2", title: "Funded High Yield", status: "funded", yield_percentage: 20, due_date: "2026-08-01T00:00:00Z" }), + makeInvoice({ id: "3", title: "Settled Mid Yield", status: "settled", yield_percentage: 12, due_date: "2026-11-01T00:00:00Z" }), +]; + +function setupMock(invoiceList = mockInvoices) { + mockUseInfiniteQuery.mockReturnValue({ + data: { pages: [{ invoices: invoiceList, has_more: false, next_cursor: null }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isFetching: false, + } as any); +} + +describe("Marketplace Filter Panel & URL Sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + setupMock(); + vi.stubGlobal( + "IntersectionObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("Funding status filter correctly narrows results", () => { + render(, { wrapper: createWrapper() }); + + const openCheckbox = screen.getByTestId("filter-status-open"); + fireEvent.click(openCheckbox); + + expect(screen.getByText("Open Low Yield")).toBeInTheDocument(); + expect(screen.queryByText("Funded High Yield")).not.toBeInTheDocument(); + expect(screen.queryByText("Settled Mid Yield")).not.toBeInTheDocument(); + }); + + it("Minimum yield slider filters out invoices below the selected yield", () => { + render(, { wrapper: createWrapper() }); + + const slider = screen.getByTestId("min-yield-slider"); + fireEvent.change(slider, { target: { value: "10" } }); + + expect(screen.queryByText("Open Low Yield")).not.toBeInTheDocument(); + expect(screen.getByText("Funded High Yield")).toBeInTheDocument(); + expect(screen.getByText("Settled Mid Yield")).toBeInTheDocument(); + }); + + it("Due date range filters invoices correctly", () => { + render(, { wrapper: createWrapper() }); + + const fromInput = screen.getByTestId("from-date-input"); + const toInput = screen.getByTestId("to-date-input"); + + fireEvent.change(fromInput, { target: { value: "2026-06-01" } }); + fireEvent.change(toInput, { target: { value: "2026-09-01" } }); + + expect(screen.queryByText("Open Low Yield")).not.toBeInTheDocument(); + expect(screen.getByText("Funded High Yield")).toBeInTheDocument(); + expect(screen.queryByText("Settled Mid Yield")).not.toBeInTheDocument(); + }); + + it("Active filters reflected in the URL query string", () => { + render(, { wrapper: createWrapper() }); + + const slider = screen.getByTestId("min-yield-slider"); + fireEvent.change(slider, { target: { value: "15" } }); + + expect(mockReplace).toHaveBeenCalledWith("/marketplace?minYield=15", { scroll: false }); + }); + + it("Clearing all filters restores the full unfiltered list", () => { + render(, { wrapper: createWrapper() }); + + const slider = screen.getByTestId("min-yield-slider"); + fireEvent.change(slider, { target: { value: "25" } }); + + expect(screen.getByTestId("no-invoices-msg")).toBeInTheDocument(); + + const clearBtn = screen.getByTestId("clear-filters-btn"); + fireEvent.click(clearBtn); + + expect(screen.getByText("Open Low Yield")).toBeInTheDocument(); + expect(screen.getByText("Funded High Yield")).toBeInTheDocument(); + expect(screen.getByText("Settled Mid Yield")).toBeInTheDocument(); + }); +}); diff --git a/app/marketplace/__tests__/marketplace-sorting.test.tsx b/app/marketplace/__tests__/marketplace-sorting.test.tsx index 1220198..4fb51c2 100644 --- a/app/marketplace/__tests__/marketplace-sorting.test.tsx +++ b/app/marketplace/__tests__/marketplace-sorting.test.tsx @@ -16,6 +16,13 @@ vi.mock("@/lib/logger", () => ({ logError: vi.fn(), })); +const mockReplace = vi.fn(); +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ replace: mockReplace, push: vi.fn() }), + usePathname: () => "/marketplace", +})); + import { useInfiniteQuery } from "@tanstack/react-query"; const mockUseInfiniteQuery = vi.mocked(useInfiniteQuery); diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index cbda2df..c3cff62 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useInfiniteQuery } from "@tanstack/react-query"; import { fetchInvoices, type Invoice } from "@/lib/api"; import { usePageTitle } from "@/hooks/usePageTitle"; @@ -8,7 +9,13 @@ import { FundingProgressBar } from "@/components/invoices"; import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { MarketplaceFilterBar } from "@/components/marketplace"; +import { + MarketplaceFilterBar, + FilterPanel, + MarketplaceFilterState, + FundingStatus, + isExpired, +} from "@/components/marketplace"; import { Loader2, ArrowUp, ArrowDown } from "lucide-react"; type SortField = "amount" | "due_date" | null; @@ -54,13 +61,19 @@ function InvoiceRow({ invoice }: { invoice: Invoice }) { -
+
{invoice.amount.toLocaleString()} XLM Amount
+
+ + {invoice.yield_percentage !== undefined ? `${invoice.yield_percentage}%` : "N/A"} + + Yield +
{invoice.investor_count} @@ -96,11 +109,15 @@ function SkeletonRow() {
-
+
+
+ + +
@@ -117,6 +134,87 @@ function SkeletonRow() { export default function MarketplacePage() { usePageTitle("Browse Invoices"); + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + // Initialize filter state from URL search params + const initialStatuses = useMemo(() => { + const raw = searchParams.get("statuses"); + if (!raw) return []; + return raw.split(",").filter((s) => ["open", "funded", "settled", "expired"].includes(s)) as FundingStatus[]; + }, [searchParams]); + + const initialMinYield = useMemo(() => { + const raw = searchParams.get("minYield"); + return raw ? Number(raw) || 0 : 0; + }, [searchParams]); + + const initialFromDate = searchParams.get("fromDate") || ""; + const initialToDate = searchParams.get("toDate") || ""; + const initialStatus = (searchParams.get("status") as "open" | "funded" | "settled" | "all") || "all"; + const initialSearch = searchParams.get("search") || ""; + + const [panelFilters, setPanelFilters] = useState({ + statuses: initialStatuses, + minYield: initialMinYield, + fromDate: initialFromDate, + toDate: initialToDate, + }); + + const [status, setStatus] = useState<"open" | "funded" | "settled" | "all">(initialStatus); + const [search, setSearch] = useState(initialSearch); + const debounceRef = useRef | null>(null); + const [debouncedSearch, setDebouncedSearch] = useState(initialSearch); + const [sortField, setSortField] = useState(null); + const [sortDirection, setSortDirection] = useState("asc"); + + // Sync state to URL params + const updateUrlParams = useCallback( + (newFilters: MarketplaceFilterState, newStatus: string, newSearch: string) => { + const params = new URLSearchParams(); + if (newFilters.statuses.length > 0) { + params.set("statuses", newFilters.statuses.join(",")); + } + if (newFilters.minYield > 0) { + params.set("minYield", newFilters.minYield.toString()); + } + if (newFilters.fromDate) { + params.set("fromDate", newFilters.fromDate); + } + if (newFilters.toDate) { + params.set("toDate", newFilters.toDate); + } + if (newStatus !== "all") { + params.set("status", newStatus); + } + if (newSearch) { + params.set("search", newSearch); + } + + const queryString = params.toString(); + const targetUrl = queryString ? `${pathname}?${queryString}` : pathname; + router.replace(targetUrl, { scroll: false }); + }, + [pathname, router] + ); + + const handleFilterChange = (newFilters: MarketplaceFilterState) => { + setPanelFilters(newFilters); + updateUrlParams(newFilters, status, debouncedSearch); + }; + + const queryParamsObj = useMemo(() => { + const obj: Record = {}; + if (panelFilters.statuses.length > 0) obj.statuses = panelFilters.statuses.join(","); + if (panelFilters.minYield > 0) obj.minYield = panelFilters.minYield.toString(); + if (panelFilters.fromDate) obj.fromDate = panelFilters.fromDate; + if (panelFilters.toDate) obj.toDate = panelFilters.toDate; + if (status !== "all") obj.status = status; + if (debouncedSearch) obj.search = debouncedSearch; + return obj; + }, [panelFilters, status, debouncedSearch]); + const { data, fetchNextPage, @@ -125,8 +223,8 @@ export default function MarketplacePage() { isLoading, isFetching, } = useInfiniteQuery({ - queryKey: ["invoices"], - queryFn: ({ pageParam }) => fetchInvoices(pageParam as string | undefined), + queryKey: ["invoices", queryParamsObj], + queryFn: ({ pageParam }) => fetchInvoices(pageParam as string | undefined, queryParamsObj), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined, @@ -167,18 +265,25 @@ export default function MarketplacePage() { [data] ); - const [status, setStatus] = useState<"open" | "funded" | "settled" | "all">("all"); - const [search, setSearch] = useState(""); - const debounceRef = useRef | null>(null); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [sortField, setSortField] = useState(null); - const [sortDirection, setSortDirection] = useState("asc"); + const handleSearchChange = useCallback( + (value: string) => { + setSearch(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setDebouncedSearch(value); + updateUrlParams(panelFilters, status, value); + }, 300); + }, + [panelFilters, status, updateUrlParams] + ); - const handleSearchChange = useCallback((value: string) => { - setSearch(value); - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => setDebouncedSearch(value), 300); - }, []); + const handleStatusChange = useCallback( + (newStatus: "open" | "funded" | "settled" | "all") => { + setStatus(newStatus); + updateUrlParams(panelFilters, newStatus, debouncedSearch); + }, + [panelFilters, debouncedSearch, updateUrlParams] + ); const handleSort = useCallback( (field: SortField) => { @@ -197,21 +302,73 @@ export default function MarketplacePage() { [sortField, sortDirection] ); - const handleClear = useCallback(() => { + const handleClearAll = useCallback(() => { + const emptyFilters: MarketplaceFilterState = { + statuses: [], + minYield: 0, + fromDate: "", + toDate: "", + }; + setPanelFilters(emptyFilters); setStatus("all"); setSearch(""); setDebouncedSearch(""); setSortField(null); setSortDirection("asc"); - }, []); + router.replace(pathname, { scroll: false }); + }, [pathname, router]); const filtered = useMemo(() => { let result = allInvoices.filter((inv) => { - const matchesStatus = status === "all" || inv.status === status; + // Bar status filter + const matchesBarStatus = status === "all" || inv.status === status; + // Search filter const matchesSearch = debouncedSearch === "" || inv.title.toLowerCase().includes(debouncedSearch.toLowerCase()); - return matchesStatus && matchesSearch; + + // Panel funding status filter + let matchesPanelStatus = true; + if (panelFilters.statuses.length > 0) { + matchesPanelStatus = panelFilters.statuses.some((st) => { + if (st === "expired") { + return isExpired(inv.due_date); + } + return inv.status === st; + }); + } + + // Panel min yield filter + let matchesYield = true; + if (panelFilters.minYield > 0) { + const y = inv.yield_percentage ?? 0; + matchesYield = y >= panelFilters.minYield; + } + + // Panel due date range filter + let matchesFromDate = true; + if (panelFilters.fromDate) { + const invTime = new Date(inv.due_date).getTime(); + const fromTime = new Date(panelFilters.fromDate).getTime(); + matchesFromDate = invTime >= fromTime; + } + + let matchesToDate = true; + if (panelFilters.toDate) { + const invTime = new Date(inv.due_date).getTime(); + // End of the day for toDate + const toTime = new Date(`${panelFilters.toDate}T23:59:59.999Z`).getTime(); + matchesToDate = invTime <= toTime; + } + + return ( + matchesBarStatus && + matchesSearch && + matchesPanelStatus && + matchesYield && + matchesFromDate && + matchesToDate + ); }); if (sortField) { @@ -227,16 +384,21 @@ export default function MarketplacePage() { } return result; - }, [allInvoices, status, debouncedSearch, sortField, sortDirection]); + }, [allInvoices, status, debouncedSearch, panelFilters, sortField, sortDirection]); if (isLoading) { return (

Invoice Marketplace

-
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} +
+
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
); @@ -246,58 +408,69 @@ export default function MarketplacePage() {

Invoice Marketplace

- - - {isFetching && !isLoading && ( -
- - Refreshing… -
- )} - -
- - + {/* Collapsible Filter Panel on the left */} + -
-
- {filtered.length === 0 ? ( -

- No invoices match your filters. -

- ) : ( - filtered.map((invoice) => ( - - )) - )} - {isFetchingNextPage && - Array.from({ length: 3 }).map((_, i) => ( - - ))} - {hasNextPage &&
} - {!hasNextPage && filtered.length > 0 && ( -

- No more invoices -

- )} +
+ + + {isFetching && !isLoading && ( +
+ + Refreshing… +
+ )} + +
+ + +
+ +
+ {filtered.length === 0 ? ( +

+ No invoices match your filters. +

+ ) : ( + filtered.map((invoice) => ( + + )) + )} + {isFetchingNextPage && + Array.from({ length: 3 }).map((_, i) => ( + + ))} + {hasNextPage &&
} + {!hasNextPage && filtered.length > 0 && ( +

+ No more invoices +

+ )} +
+
); diff --git a/components/admin/AdminInvoicesReview.tsx b/components/admin/AdminInvoicesReview.tsx new file mode 100644 index 0000000..08b03d0 --- /dev/null +++ b/components/admin/AdminInvoicesReview.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/context/AuthContext"; +import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + fetchAdminInvoices, + approveAdminInvoice, + rejectAdminInvoice, + AdminInvoiceRow, +} from "@/lib/api"; +import { usePageTitle } from "@/hooks/usePageTitle"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DocumentPreview } from "@/components/invoices/DocumentPreview"; +import { toast } from "sonner"; +import { FileText, CheckCircle, XCircle } from "lucide-react"; + +export function AdminInvoicesReview() { + usePageTitle("Admin Invoice Review"); + const { jwt } = useAuth(); + const queryClient = useQueryClient(); + + const [selectedDocUrl, setSelectedDocUrl] = useState(null); + const [rejectingInvoiceId, setRejectingInvoiceId] = useState(null); + const [rejectionReason, setRejectionReason] = useState(""); + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteQuery({ + queryKey: ["admin-invoices-pending", jwt], + queryFn: ({ pageParam }) => + fetchAdminInvoices("pending", pageParam as string | undefined, jwt ?? undefined), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined, + enabled: !!jwt, + }); + + const approveMutation = useMutation({ + mutationFn: (invoiceId: string) => approveAdminInvoice(invoiceId, jwt ?? undefined), + onSuccess: (_, invoiceId) => { + toast.success("Invoice approved successfully"); + queryClient.setQueryData(["admin-invoices-pending", jwt], (oldData: any) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map((page: any) => ({ + ...page, + invoices: page.invoices.filter((inv: AdminInvoiceRow) => inv.invoiceId !== invoiceId), + })), + }; + }); + }, + onError: (err: any) => { + toast.error(err?.message || "Failed to approve invoice"); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: ({ invoiceId, reason }: { invoiceId: string; reason: string }) => + rejectAdminInvoice(invoiceId, reason, jwt ?? undefined), + onSuccess: (_, variables) => { + toast.success("Invoice rejected successfully"); + setRejectingInvoiceId(null); + setRejectionReason(""); + queryClient.setQueryData(["admin-invoices-pending", jwt], (oldData: any) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map((page: any) => ({ + ...page, + invoices: page.invoices.filter( + (inv: AdminInvoiceRow) => inv.invoiceId !== variables.invoiceId + ), + })), + }; + }); + }, + onError: (err: any) => { + toast.error(err?.message || "Failed to reject invoice"); + }, + }); + + const allInvoices = data?.pages.flatMap((p) => p.invoices) ?? []; + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + ))} +
+ ); + } + + if (isError) { + return ( +
+ Error loading pending invoices. Please try again. +
+ ); + } + + return ( +
+ + + Pending Invoice Submissions + + + {allInvoices.length === 0 ? ( +

+ No pending invoices to review. +

+ ) : ( +
+ + + + + + + + + + + + + {allInvoices.map((inv) => ( + + + + + + + + + ))} + +
Seller NameInvoice IDFace ValueSubmitted AtDocumentActions
{inv.sellerName}{inv.invoiceId}{inv.faceValue.toLocaleString()} XLM + {new Date(inv.submittedAt).toLocaleDateString()} + + + + {rejectingInvoiceId === inv.invoiceId ? ( +
+ setRejectionReason(e.target.value)} + className="h-8 w-48 text-xs" + data-testid={`reject-reason-input-${inv.invoiceId}`} + /> + + +
+ ) : ( +
+ + +
+ )} +
+
+ )} + + {hasNextPage && ( +
+ +
+ )} +
+
+ + {/* Document Modal */} + {selectedDocUrl && ( +
+
+
+

Document Preview

+ +
+ +
+
+ )} +
+ ); +} diff --git a/components/dashboard/InvestorPortfolio.tsx b/components/dashboard/InvestorPortfolio.tsx index 8f12527..57118aa 100644 --- a/components/dashboard/InvestorPortfolio.tsx +++ b/components/dashboard/InvestorPortfolio.tsx @@ -1,10 +1,12 @@ "use client"; +import { useState } from "react"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { PositionCard } from "@/components/dashboard/PositionCard"; +import { PayoutHistoryTable } from "@/components/dashboard/PayoutHistoryTable"; import { usePortfolio } from "@/hooks/usePortfolio"; import { calculateActiveTotal } from "@/lib/portfolio"; @@ -23,7 +25,8 @@ function PositionRowSkeleton() { } export function InvestorPortfolio() { - const { data, isLoading, isFetching } = usePortfolio(); + const [activeTab, setActiveTab] = useState<"positions" | "payouts">("positions"); + const { data, isLoading } = usePortfolio(); // Show full skeleton only on initial load if (isLoading || !data) { @@ -71,26 +74,60 @@ export function InvestorPortfolio() { -
-

Active Positions

- {positions.length === 0 ? ( -
-

- No active investments yet — browse the marketplace to get started -

- -
- ) : ( - positions.map((position) => ( - - )) - )} +
+ +
+ + {activeTab === "positions" ? ( +
+

Active Positions

+ {positions.length === 0 ? ( +
+

+ No active investments yet — browse the marketplace to get started +

+ +
+ ) : ( + positions.map((position) => ( + + )) + )} +
+ ) : ( +
+

Payout History

+ +
+ )}
); } diff --git a/components/dashboard/OnboardingChecklist.tsx b/components/dashboard/OnboardingChecklist.tsx new file mode 100644 index 0000000..c3b94d0 --- /dev/null +++ b/components/dashboard/OnboardingChecklist.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { CheckCircle2, Circle, X } from "lucide-react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export interface OnboardingUserData { + kycStatus: "pending" | "approved" | "rejected" | null; + displayName: string | null; + invoiceCount: number; +} + +export interface OnboardingChecklistProps { + data: OnboardingUserData; +} + +const STORAGE_KEY = "onboarding_checklist_dismissed"; + +export function OnboardingChecklist({ data }: OnboardingChecklistProps) { + const [isDismissed, setIsDismissed] = useState(false); + + useEffect(() => { + if (typeof window !== "undefined") { + const dismissed = localStorage.getItem(STORAGE_KEY); + if (dismissed === "true") { + setIsDismissed(true); + } + } + }, []); + + const step1Complete = data.kycStatus === "pending" || data.kycStatus === "approved"; + const step2Complete = data.displayName !== null && data.displayName.trim() !== ""; + const step3Complete = data.invoiceCount >= 1; + + const allComplete = step1Complete && step2Complete && step3Complete; + + if (isDismissed || allComplete) { + return null; + } + + const handleDismiss = () => { + setIsDismissed(true); + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, "true"); + } + }; + + return ( + + + Getting Started Checklist + + + +
+ {step1Complete ? ( + + ) : ( + + )} + + Complete Identity Verification (KYC) + +
+ +
+ {step2Complete ? ( + + ) : ( + + )} + + Set your Display Name + +
+ +
+ {step3Complete ? ( + + ) : ( + + )} + + Create your first invoice + +
+
+
+ ); +} diff --git a/components/dashboard/PayoutHistoryTable.tsx b/components/dashboard/PayoutHistoryTable.tsx new file mode 100644 index 0000000..4489fda --- /dev/null +++ b/components/dashboard/PayoutHistoryTable.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useInfiniteQuery } from "@tanstack/react-query"; +import { fetchInvestorPayouts, PayoutRecord } from "@/lib/api"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function PayoutHistoryTable() { + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isError, + } = useInfiniteQuery({ + queryKey: ["investor-payouts"], + queryFn: ({ pageParam }) => fetchInvestorPayouts(pageParam as string | undefined), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => + lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined, + }); + + const allPayouts = data?.pages.flatMap((p) => p.payouts) ?? []; + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + if (isError) { + return ( +

+ Failed to load payout history. +

+ ); + } + + if (allPayouts.length === 0) { + return ( +
+ No payouts yet +
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {allPayouts.map((row, idx) => { + const isShortfall = row.amountReceived < row.amountInvested; + return ( + + + + + + + + + ); + })} + +
Invoice IDSeller NameAmount InvestedAmount ReceivedYieldSettled At
{row.invoiceId}{row.sellerName}{row.amountInvested.toLocaleString()} XLM{row.amountReceived.toLocaleString()} XLM{row.yield}% + {new Date(row.settledAt).toLocaleDateString()} +
+
+ + {hasNextPage && ( +
+ +
+ )} +
+ ); +} diff --git a/components/dashboard/__tests__/OnboardingChecklist.test.tsx b/components/dashboard/__tests__/OnboardingChecklist.test.tsx new file mode 100644 index 0000000..f35d385 --- /dev/null +++ b/components/dashboard/__tests__/OnboardingChecklist.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { OnboardingChecklist, OnboardingUserData } from "../OnboardingChecklist"; + +describe("OnboardingChecklist", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + const baseData: OnboardingUserData = { + kycStatus: null, + displayName: null, + invoiceCount: 0, + }; + + it("Step 1 is incomplete when kycStatus is null and complete when kycStatus is pending or approved", () => { + const { rerender } = render(); + expect(screen.getByTestId("step-1-incomplete")).toBeInTheDocument(); + expect(screen.queryByTestId("step-1-complete")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("step-1-complete")).toBeInTheDocument(); + expect(screen.queryByTestId("step-1-incomplete")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("step-1-complete")).toBeInTheDocument(); + expect(screen.queryByTestId("step-1-incomplete")).not.toBeInTheDocument(); + }); + + it("Step 2 is incomplete when displayName is null and complete when displayName is set", () => { + const { rerender } = render(); + expect(screen.getByTestId("step-2-incomplete")).toBeInTheDocument(); + expect(screen.queryByTestId("step-2-complete")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("step-2-complete")).toBeInTheDocument(); + expect(screen.queryByTestId("step-2-incomplete")).not.toBeInTheDocument(); + }); + + it("Step 3 is incomplete when invoiceCount is 0 and complete when invoiceCount is at least 1", () => { + const { rerender } = render(); + expect(screen.getByTestId("step-3-incomplete")).toBeInTheDocument(); + expect(screen.queryByTestId("step-3-complete")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("step-3-complete")).toBeInTheDocument(); + expect(screen.queryByTestId("step-3-incomplete")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByTestId("step-3-complete")).toBeInTheDocument(); + expect(screen.queryByTestId("step-3-incomplete")).not.toBeInTheDocument(); + }); + + it("checklist is hidden when all three steps are complete", () => { + const completeData: OnboardingUserData = { + kycStatus: "approved", + displayName: "Alice", + invoiceCount: 1, + }; + render(); + expect(screen.queryByTestId("onboarding-checklist")).not.toBeInTheDocument(); + }); + + it("dismissal writes to localStorage and hides the checklist", () => { + render(); + expect(screen.getByTestId("onboarding-checklist")).toBeInTheDocument(); + + const dismissBtn = screen.getByTestId("dismiss-checklist-btn"); + fireEvent.click(dismissBtn); + + expect(screen.queryByTestId("onboarding-checklist")).not.toBeInTheDocument(); + expect(localStorage.getItem("onboarding_checklist_dismissed")).toBe("true"); + }); +}); diff --git a/components/dashboard/__tests__/PayoutHistoryTable.test.tsx b/components/dashboard/__tests__/PayoutHistoryTable.test.tsx new file mode 100644 index 0000000..8571865 --- /dev/null +++ b/components/dashboard/__tests__/PayoutHistoryTable.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React from "react"; +import { InvestorPortfolio } from "../InvestorPortfolio"; +import * as api from "@/lib/api"; +import * as usePortfolioModule from "@/hooks/usePortfolio"; + +vi.mock("@/lib/api"); +vi.mock("@/hooks/usePortfolio"); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); + }; +} + +const mockPayouts = [ + { + invoiceId: "inv-201", + sellerName: "Stellar Tech", + amountInvested: 1000, + amountReceived: 1100, + yield: 10, + settledAt: "2026-07-01T12:00:00.000Z", + }, + { + invoiceId: "inv-202", + sellerName: "Risky Business", + amountInvested: 2000, + amountReceived: 1500, // Shortfall + yield: 5, + settledAt: "2026-07-15T12:00:00.000Z", + }, +]; + +describe("Investor Dashboard Payout History", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(usePortfolioModule, "usePortfolio").mockReturnValue({ + data: { + positions: [ + { + invoice_id: "inv-1", + title: "Active Inv 1", + committed_amount: 5000, + target_amount: 10000, + status: "open", + due_date: "2026-12-31", + } as any, + ], + }, + isLoading: false, + } as any); + }); + + it("Payout history tab renders on the investor dashboard", () => { + render(, { wrapper: createWrapper() }); + const tab = screen.getByTestId("tab-payout-history"); + expect(tab).toBeInTheDocument(); + }); + + it("All required columns displayed correctly", async () => { + vi.mocked(api.fetchInvestorPayouts).mockResolvedValue({ + payouts: [mockPayouts[0]], + has_more: false, + next_cursor: null, + }); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByTestId("tab-payout-history")); + + await waitFor(() => { + expect(screen.getByTestId("payout-history-table")).toBeInTheDocument(); + }); + + expect(screen.getByText("Invoice ID")).toBeInTheDocument(); + expect(screen.getByText("Seller Name")).toBeInTheDocument(); + expect(screen.getByText("Amount Invested")).toBeInTheDocument(); + expect(screen.getByText("Amount Received")).toBeInTheDocument(); + expect(screen.getByText("Yield")).toBeInTheDocument(); + expect(screen.getByText("Settled At")).toBeInTheDocument(); + + expect(screen.getByText("inv-201")).toBeInTheDocument(); + expect(screen.getByText("Stellar Tech")).toBeInTheDocument(); + expect(screen.getByText("1,000 XLM")).toBeInTheDocument(); + expect(screen.getByText("1,100 XLM")).toBeInTheDocument(); + expect(screen.getByText("10%")).toBeInTheDocument(); + }); + + it("Shortfall rows highlighted in amber", async () => { + vi.mocked(api.fetchInvestorPayouts).mockResolvedValue({ + payouts: mockPayouts, + has_more: false, + next_cursor: null, + }); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByTestId("tab-payout-history")); + + await waitFor(() => { + expect(screen.getByTestId("payout-row-inv-202")).toBeInTheDocument(); + }); + + const normalRow = screen.getByTestId("payout-row-inv-201"); + const shortfallRow = screen.getByTestId("payout-row-inv-202"); + + expect(shortfallRow).toHaveClass("bg-amber-500/15"); + expect(normalRow).not.toHaveClass("bg-amber-500/15"); + }); + + it("Cursor pagination loads next page correctly", async () => { + vi.mocked(api.fetchInvestorPayouts) + .mockResolvedValueOnce({ + payouts: [mockPayouts[0]], + has_more: true, + next_cursor: "cursor-page-2", + }) + .mockResolvedValueOnce({ + payouts: [mockPayouts[1]], + has_more: false, + next_cursor: null, + }); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByTestId("tab-payout-history")); + + await waitFor(() => { + expect(screen.getByTestId("payouts-load-next")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("payouts-load-next")); + + await waitFor(() => { + expect(api.fetchInvestorPayouts).toHaveBeenCalledWith("cursor-page-2"); + expect(screen.getByTestId("payout-row-inv-202")).toBeInTheDocument(); + }); + }); + + it("Empty state shown when no payouts exist", async () => { + vi.mocked(api.fetchInvestorPayouts).mockResolvedValue({ + payouts: [], + has_more: false, + next_cursor: null, + }); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByTestId("tab-payout-history")); + + await waitFor(() => { + expect(screen.getByTestId("empty-payouts")).toBeInTheDocument(); + }); + + expect(screen.getByText("No payouts yet")).toBeInTheDocument(); + }); +}); diff --git a/components/dashboard/index.ts b/components/dashboard/index.ts index 4e89f96..008739c 100644 --- a/components/dashboard/index.ts +++ b/components/dashboard/index.ts @@ -4,6 +4,6 @@ export { NotificationPreferences } from "./NotificationPreferences"; export { KycSubmissionForm } from "./KycSubmissionForm"; export { SellerDashboard } from "./SellerDashboard"; export { InvestorPortfolio } from "./InvestorPortfolio"; -export { NotificationList } from "./NotificationList"; - +export { OnboardingChecklist } from "./OnboardingChecklist"; +export { PayoutHistoryTable } from "./PayoutHistoryTable"; diff --git a/components/invoices/__tests__/PublishInvoiceForm.test.tsx b/components/invoices/__tests__/PublishInvoiceForm.test.tsx index bafe70a..3256add 100644 --- a/components/invoices/__tests__/PublishInvoiceForm.test.tsx +++ b/components/invoices/__tests__/PublishInvoiceForm.test.tsx @@ -26,7 +26,7 @@ describe("PublishInvoiceForm field preservation", () => { const descriptionInput = screen.getByLabelText("Description"); await user.type(descriptionInput, "Test Description"); - const faceValueInput = screen.getByDisplayValue(/^$/); + const faceValueInput = screen.getByLabelText("Face Value (XLM)"); await user.type(faceValueInput, "1000"); const deadlineInput = screen.getByLabelText("Funding Deadline"); @@ -36,7 +36,7 @@ describe("PublishInvoiceForm field preservation", () => { await user.click(nextButton); await waitFor(() => { - expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument(); + expect(screen.getByLabelText("Upload Document")).toBeInTheDocument(); }); const backButton = screen.getByRole("button", { name: /Back/i }); @@ -58,7 +58,7 @@ describe("PublishInvoiceForm field preservation", () => { const descriptionInput = screen.getByLabelText("Description"); await user.type(descriptionInput, "Test Description"); - const faceValueInput = screen.getByDisplayValue(/^$/); + const faceValueInput = screen.getByLabelText("Face Value (XLM)"); await user.type(faceValueInput, "1000"); const deadlineInput = screen.getByLabelText("Funding Deadline"); @@ -68,7 +68,7 @@ describe("PublishInvoiceForm field preservation", () => { await user.click(nextButton); await waitFor(() => { - expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument(); + expect(screen.getByLabelText("Upload Document")).toBeInTheDocument(); }); const backButton = screen.getByRole("button", { name: /Back/i }); @@ -90,7 +90,7 @@ describe("PublishInvoiceForm field preservation", () => { const descriptionInput = screen.getByLabelText("Description"); await user.type(descriptionInput, "Test Description"); - const faceValueInput = screen.getByDisplayValue(/^$/); + const faceValueInput = screen.getByLabelText("Face Value (XLM)"); await user.type(faceValueInput, "1000"); const deadlineInput = screen.getByLabelText("Funding Deadline"); @@ -100,7 +100,7 @@ describe("PublishInvoiceForm field preservation", () => { await user.click(nextButton); await waitFor(() => { - expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument(); + expect(screen.getByLabelText("Upload Document")).toBeInTheDocument(); }); const backButton = screen.getByRole("button", { name: /Back/i }); @@ -122,7 +122,7 @@ describe("PublishInvoiceForm field preservation", () => { const descriptionInput = screen.getByLabelText("Description"); await user.type(descriptionInput, "Test Description"); - const faceValueInput = screen.getByDisplayValue(/^$/); + const faceValueInput = screen.getByLabelText("Face Value (XLM)"); await user.type(faceValueInput, "1000"); const deadlineInput = screen.getByLabelText("Funding Deadline"); @@ -132,7 +132,7 @@ describe("PublishInvoiceForm field preservation", () => { await user.click(nextButton); await waitFor(() => { - expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument(); + expect(screen.getByLabelText("Upload Document")).toBeInTheDocument(); }); const backButton = screen.getByRole("button", { name: /Back/i }); @@ -154,7 +154,7 @@ describe("PublishInvoiceForm field preservation", () => { const descriptionInput = screen.getByLabelText("Description"); await user.type(descriptionInput, "Test Description"); - const faceValueInput = screen.getByDisplayValue(/^$/); + const faceValueInput = screen.getByLabelText("Face Value (XLM)"); await user.type(faceValueInput, "1000"); const deadlineInput = screen.getByLabelText("Funding Deadline"); @@ -164,7 +164,7 @@ describe("PublishInvoiceForm field preservation", () => { await user.click(nextButton); await waitFor(() => { - expect(screen.getByLabelText("Invoice Document")).toBeInTheDocument(); + expect(screen.getByLabelText("Upload Document")).toBeInTheDocument(); }); const backButton = screen.getByRole("button", { name: /Back/i }); diff --git a/components/marketplace/FilterPanel.tsx b/components/marketplace/FilterPanel.tsx new file mode 100644 index 0000000..91a8d10 --- /dev/null +++ b/components/marketplace/FilterPanel.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState } from "react"; +import { Filter, X, ChevronDown, ChevronUp } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; + +export type FundingStatus = "open" | "funded" | "settled" | "expired"; + +export interface MarketplaceFilterState { + statuses: FundingStatus[]; + minYield: number; + fromDate: string; + toDate: string; +} + +interface FilterPanelProps { + filters: MarketplaceFilterState; + onFilterChange: (newFilters: MarketplaceFilterState) => void; + onClear: () => void; +} + +const STATUS_OPTIONS: { label: string; value: FundingStatus }[] = [ + { label: "Open", value: "open" }, + { label: "Funded", value: "funded" }, + { label: "Settled", value: "settled" }, + { label: "Expired", value: "expired" }, +]; + +export function FilterPanel({ filters, onFilterChange, onClear }: FilterPanelProps) { + const [isOpen, setIsOpen] = useState(true); + + const handleStatusToggle = (status: FundingStatus) => { + const exists = filters.statuses.includes(status); + const newStatuses = exists + ? filters.statuses.filter((s) => s !== status) + : [...filters.statuses, status]; + onFilterChange({ ...filters, statuses: newStatuses }); + }; + + const activeCount = + filters.statuses.length + + (filters.minYield > 0 ? 1 : 0) + + (filters.fromDate ? 1 : 0) + + (filters.toDate ? 1 : 0); + + return ( +
+
+ + + {activeCount > 0 && ( + + )} +
+ + {isOpen && ( +
+ {/* Funding Status Multi-select */} +
+ +
+ {STATUS_OPTIONS.map((opt) => { + const checked = filters.statuses.includes(opt.value); + return ( + + ); + })} +
+
+ + {/* Minimum Yield Slider */} +
+
+ + {filters.minYield}% +
+ + onFilterChange({ ...filters, minYield: Number(e.target.value) }) + } + className="w-full h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary" + data-testid="min-yield-slider" + /> +
+ 0% + 30% +
+
+ + {/* Due Date Range Picker */} +
+ +
+
+ + + onFilterChange({ ...filters, fromDate: e.target.value }) + } + data-testid="from-date-input" + /> +
+
+ + + onFilterChange({ ...filters, toDate: e.target.value }) + } + data-testid="to-date-input" + /> +
+
+
+
+ )} +
+ ); +} diff --git a/components/marketplace/index.ts b/components/marketplace/index.ts index 0bef154..d25e46a 100644 --- a/components/marketplace/index.ts +++ b/components/marketplace/index.ts @@ -1,5 +1,5 @@ export { MarketplaceFilterBar } from "./filter-bar"; export { InvoiceCard } from "./invoice-card"; export { CountdownTimer, isExpired } from "./countdown-timer"; -export { TopInvestorsLeaderboard } from "./TopInvestorsLeaderboard"; - +export { FilterPanel } from "./FilterPanel"; +export type { MarketplaceFilterState, FundingStatus } from "./FilterPanel"; diff --git a/lib/api/index.ts b/lib/api/index.ts index 4d06bfc..cf8d6b7 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -7,6 +7,7 @@ export interface Invoice { investor_count: number; status: "open" | "funded" | "settled" | "rejected" | "draft"; due_date: string; + yield_percentage?: number; rejection_reason?: string; has_more: boolean; next_cursor: string | null; @@ -28,9 +29,17 @@ import type { InvestmentPosition } from "@/lib/portfolio"; const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api"; -export async function fetchInvoices(cursor?: string): Promise { +export async function fetchInvoices( + cursor?: string, + paramsObj?: Record +): Promise { const params = new URLSearchParams(); if (cursor) params.set("cursor", cursor); + if (paramsObj) { + Object.entries(paramsObj).forEach(([k, v]) => { + if (v) params.set(k, v); + }); + } const res = await fetch(`${API_BASE}/invoices?${params}`); if (!res.ok) throw new Error("Failed to fetch invoices"); return res.json(); @@ -157,98 +166,91 @@ export async function updateNotificationPreference( return res.json(); } -export interface LeaderboardInvestor { - address: string; - total_committed: number; - invoice_count: number; +export interface AdminInvoiceRow { + invoiceId: string; + sellerName: string; + faceValue: number; + submittedAt: string; + documentUrl?: string; + status: string; } -export async function fetchLeaderboard(): Promise { - const res = await fetch(`${API_BASE}/leaderboard`); - if (!res.ok) throw new Error("Failed to fetch leaderboard"); - return res.json(); +export interface AdminInvoicesResponse { + invoices: AdminInvoiceRow[]; + has_more: boolean; + next_cursor: string | null; } -export interface UpdateInvoiceInput { - title?: string; - description?: string; - faceValue?: number; - fundingDeadline?: string; - documentCid?: string; -} +export async function fetchAdminInvoices( + status = "pending", + cursor?: string, + token?: string +): Promise { + const params = new URLSearchParams({ status }); + if (cursor) params.set("cursor", cursor); + const headers: Record = {}; + if (token) headers["Authorization"] = `Bearer ${token}`; -export async function updateInvoice( - id: string, - input: UpdateInvoiceInput -): Promise { - const res = await fetch(`${API_BASE}/invoices/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(input), - }); - if (!res.ok) throw new Error("Failed to update invoice"); + const res = await fetch(`${API_BASE}/admin/invoices?${params}`, { headers }); + if (!res.ok) throw new Error("Failed to fetch admin invoices"); return res.json(); } -export interface PendingInvoice { - id: string; - title: string; - seller: string; - face_value: number; - submission_date: string; - status: "pending_review"; -} - -export async function fetchPendingInvoices(): Promise { - const res = await fetch(`${API_BASE}/admin/invoices/pending`); - if (!res.ok) throw new Error("Failed to fetch pending invoices"); - return res.json(); -} +export async function approveAdminInvoice( + invoiceId: string, + token?: string +): Promise<{ success: boolean }> { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; -export async function approveInvoice(id: string): Promise<{ success: boolean }> { - const res = await fetch(`${API_BASE}/admin/invoices/${id}/approve`, { + const res = await fetch(`${API_BASE}/admin/invoices/${invoiceId}/approve`, { method: "POST", + headers, }); if (!res.ok) throw new Error("Failed to approve invoice"); return res.json(); } -export async function rejectInvoice(id: string, reason: string): Promise<{ success: boolean }> { - const res = await fetch(`${API_BASE}/admin/invoices/${id}/reject`, { +export async function rejectAdminInvoice( + invoiceId: string, + reason: string, + token?: string +): Promise<{ success: boolean }> { + const headers: Record = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + + const res = await fetch(`${API_BASE}/admin/invoices/${invoiceId}/reject`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ reason }), }); if (!res.ok) throw new Error("Failed to reject invoice"); return res.json(); } -export interface NotificationItem { - id: string; - title?: string; - message: string; - read: boolean; - createdAt?: string; - created_at?: string; +export interface PayoutRecord { + invoiceId: string; + sellerName: string; + amountInvested: number; + amountReceived: number; + yield: number; + settledAt: string; } -export async function fetchNotifications(): Promise { - const res = await fetch(`${API_BASE}/notifications`); - if (!res.ok) throw new Error("Failed to fetch notifications"); - return res.json(); +export interface PayoutsResponse { + payouts: PayoutRecord[]; + has_more: boolean; + next_cursor: string | null; } -export async function fetchUnreadCount(): Promise<{ count: number }> { - const res = await fetch(`${API_BASE}/notifications/unread-count`); - if (!res.ok) throw new Error("Failed to fetch unread count"); - return res.json(); -} +export async function fetchInvestorPayouts( + cursor?: string +): Promise { + const params = new URLSearchParams(); + if (cursor) params.set("cursor", cursor); -export async function markNotificationAsRead(id: string): Promise<{ success: boolean }> { - const res = await fetch(`${API_BASE}/notifications/${id}/read`, { - method: "PATCH", - }); - if (!res.ok) throw new Error("Failed to mark notification as read"); + const res = await fetch(`${API_BASE}/investor/payouts?${params}`); + if (!res.ok) throw new Error("Failed to fetch payout history"); return res.json(); } diff --git a/package-lock.json b/package-lock.json index ac7f64c..1af5b1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.6", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -2853,6 +2854,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/package.json b/package.json index d8fb7d2..525b732 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.6", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0",