diff --git a/frontend/src/components/common/StatusBadge/StatusBadge.test.tsx b/frontend/src/components/common/StatusBadge/StatusBadge.test.tsx new file mode 100644 index 0000000..a2fa1c1 --- /dev/null +++ b/frontend/src/components/common/StatusBadge/StatusBadge.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import StatusBadge from './StatusBadge'; + +describe('StatusBadge component', () => { + it('renders display label and applies badge classes for IN_TRANSIT', () => { + render(); + expect(screen.getByText('In Transit')).toBeInTheDocument(); + // class should include the blue token + expect(screen.getByText('In Transit').className).toContain('#3b82f6'); + }); +}); diff --git a/frontend/src/components/common/StatusBadge/StatusBadge.tsx b/frontend/src/components/common/StatusBadge/StatusBadge.tsx index 5a45875..41d4ccd 100644 --- a/frontend/src/components/common/StatusBadge/StatusBadge.tsx +++ b/frontend/src/components/common/StatusBadge/StatusBadge.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { getStatusVariant } from './statusBadgeVariants'; +import { getStatusDisplayLabel, getStatusBadgeClass } from '../../../utils/shipmentStatus'; export interface StatusBadgeProps { status: string; @@ -7,13 +7,14 @@ export interface StatusBadgeProps { } const StatusBadge: React.FC = ({ status, className = '' }) => { - const { bg, text } = getStatusVariant(status); + const label = getStatusDisplayLabel(status); + const classes = getStatusBadgeClass(status); return ( - {status} + {label} ); }; diff --git a/frontend/src/components/shipment/DeliveryConfirmation/DeliveryConfirmation.tsx b/frontend/src/components/shipment/DeliveryConfirmation/DeliveryConfirmation.tsx index 96f9549..e5189bb 100644 --- a/frontend/src/components/shipment/DeliveryConfirmation/DeliveryConfirmation.tsx +++ b/frontend/src/components/shipment/DeliveryConfirmation/DeliveryConfirmation.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { CheckCircle2 } from 'lucide-react'; +import { getStatusDisplayLabel } from '../../../utils/shipmentStatus'; export interface DeliveryConfirmationProps { shipmentId: string; @@ -73,7 +74,7 @@ const DeliveryConfirmation: React.FC = ({ const displayRating = hovered || rating; - if (status !== 'delivered') return null; + if (getStatusDisplayLabel(status).toLowerCase() !== 'delivered') return null; return (
= ({ - status, - className = '' +import { getStatusDisplayLabel, getStatusBadgeClass } from '../../../utils/shipmentStatus'; + +export const StatusBadge: React.FC = ({ + status, + className = '' }) => { - const getStatusClassName = (status: ShipmentStatus): string => { - switch (status) { - case 'In Transit': - case 'Picked Up': - case 'At Checkpoint': - case 'Out for Delivery': - return 'status-in-transit'; - case 'Delivered': - return 'status-delivered'; - case 'Pending Approval': - return 'status-pending'; - case 'Cancelled': - return 'status-cancelled'; - default: - return 'status-in-transit'; - } - }; + // Use shared mapping for display and classes + const label = getStatusDisplayLabel(status); + const classes = getStatusBadgeClass(status); return ( - - {status} + {label} ); }; diff --git a/frontend/src/components/ui/StatusBadge/index.ts b/frontend/src/components/ui/StatusBadge/index.ts index 66736cf..e6ef4a8 100644 --- a/frontend/src/components/ui/StatusBadge/index.ts +++ b/frontend/src/components/ui/StatusBadge/index.ts @@ -1,2 +1,2 @@ -export { StatusBadge, type StatusBadgeProps, type ShipmentStatus } from './StatusBadge'; +export { StatusBadge, type StatusBadgeProps } from './StatusBadge'; export { default } from './StatusBadge'; \ No newline at end of file diff --git a/frontend/src/pages/Settlements/Settlements.tsx b/frontend/src/pages/Settlements/Settlements.tsx index 11dd34c..3cf3fb9 100644 --- a/frontend/src/pages/Settlements/Settlements.tsx +++ b/frontend/src/pages/Settlements/Settlements.tsx @@ -1,7 +1,363 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from "react"; +import { + ArrowUpDown, + ExternalLink, + ChevronLeft, + ChevronRight, + Loader2, +} from "lucide-react"; +import { Link } from "react-router-dom"; +import { + settlementsApi, + Settlement, + SettlementStatus, + SettlementDetail, +} from "@services/api/endpoints/settlements"; +import { PaymentDetailModal } from "./components"; -const Settlements: React.FC = () => { - return
Settlements Page (Coming Soon)
; + +// Local lightweight table formatting (kept inline to avoid coupling) +const truncateHash = (hash?: string) => { + if (!hash) return "-"; + return `${hash.slice(0, 6)}...${hash.slice(-4)}`; +}; + +const getStellarExplorerUrl = (hash?: string) => { + if (!hash) return undefined; + return `https://stellar.expert/explorer/public/tx/${hash}`; }; -export default Settlements; +const statusClasses: Record = { + PENDING: + "bg-[rgba(245,158,11,0.15)] text-[#fbbf24] border border-[rgba(245,158,11,0.3)]", + ESCROWED: + "bg-[rgba(98,255,255,0.15)] text-[#62ffff] border border-[rgba(98,255,255,0.3)]", + RELEASED: + "bg-[rgba(16,185,129,0.15)] text-[#34d399] border border-[rgba(16,185,129,0.3)]", + DISPUTED: + "bg-[rgba(239,68,68,0.15)] text-[#f87171] border border-[rgba(239,68,68,0.3)]", + FAILED: + "bg-[rgba(239,68,68,0.15)] text-[#f87171] border border-[rgba(239,68,68,0.3)]", +}; + +const toStatusLabel = (s: SettlementStatus) => s; + +const LoadingState: React.FC = () => ( +
+
+ + Loading settlements... +
+
+); + +const EmptyState: React.FC = () => ( +
+
+
🧾
+

No Settlements Found

+

No settlement records match your criteria.

+
+
+); + +export default function Settlements() { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [filterStatus, setFilterStatus] = useState("ALL"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + + const [currentPage, setCurrentPage] = useState(1); + const [limit] = useState(10); + + const [settlements, setSettlements] = useState([]); + const [total, setTotal] = useState(0); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [selectedDetail, setSelectedDetail] = useState(null); + const [isModalLoading, setIsModalLoading] = useState(false); + + const totalPages = Math.max(1, Math.ceil(total / limit)); + + const load = async () => { + setIsLoading(true); + setError(null); + try { + const res = await settlementsApi.getSettlements({ + page: currentPage, + limit, + status: filterStatus === "ALL" ? undefined : filterStatus, + sortBy: "createdAt", + sortOrder, + }); + setSettlements(res.data); + setTotal(res.total); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load settlements"); + setSettlements([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPage, filterStatus, sortOrder]); + + const summary = useMemo(() => { + // Aggregate from current page (fallback). If backend summary is desired, extend endpoint. + const totalSettledAmount = settlements + .filter((s) => s.status === "RELEASED") + .reduce((sum, s) => sum + (s.amount ?? 0), 0); + + const pendingCount = settlements.filter((s) => s.status === "PENDING").length; + const disputedCount = settlements.filter((s) => s.status === "DISPUTED").length; + + return { + totalSettledAmount, + pendingCount, + disputedCount, + }; + }, [settlements]); + + const onOpen = async (s: Settlement) => { + setSelected(s); + setSelectedDetail(null); + setIsModalOpen(true); + setIsModalLoading(true); + try { + const detail = await settlementsApi.getSettlementById(s._id); + setSelectedDetail(detail); + } catch { + // Keep modal open with list info + } finally { + setIsModalLoading(false); + } + }; + + const tableContainerClass = + "bg-[rgba(19,186,186,0.05)] border border-[rgba(98,255,255,0.2)] rounded-2xl overflow-hidden mb-5 shadow-[inset_0_0_20px_0px_rgba(0,128,128,0.3)]"; + const thClass = + "text-left px-6 py-4 text-[11px] font-semibold text-[#62ffff] uppercase border-b border-[rgba(98,255,255,0.2)]"; + const tdClass = "px-6 py-4 text-sm border-b border-[rgba(98,255,255,0.2)]"; + + if (isLoading) return ; + if (error) { + return ( +
+
+
Error
+
{error}
+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Settlements

+

+ Track escrow releases and settlement outcomes +

+
+ +
+
+ + +
+ setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"))} + > + Date + + {sortOrder === "desc" ? "Newest" : "Oldest"} + + +
+
+ + {/* Summary cards (simple; designed to be replaced with PaymentSummaryCards integration) */} +
+
+
Total settled
+
+ {summary.totalSettledAmount.toLocaleString(undefined, { maximumFractionDigits: 2 })} +
+
+
+
Pending
+
{summary.pendingCount}
+
+
+
Disputed
+
{summary.disputedCount}
+
+
+
Total records
+
{total}
+
+
+ + {settlements.length === 0 ? ( + + ) : ( + <> +
+ + + + + + + + + + + + {settlements.map((s) => { + const url = getStellarExplorerUrl(s.stellarTxHash); + return ( + void onOpen(s)} + > + + + + + + + ); + })} + +
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"))} + > + Date + Shipment IDAmountStatusStellar Tx
+ {new Date(s.createdAt).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + e.stopPropagation()} + > + {s.shipmentId} + + +
+ {s.amount.toLocaleString()} + {s.token} +
+
+ + {toStatusLabel(s.status)} + + + {url ? ( + e.stopPropagation()} + className="text-text-secondary no-underline flex items-center gap-1.5 transition-colors hover:text-[#62ffff]" + > + {truncateHash(s.stellarTxHash)} + + + ) : ( + - + )} +
+
+ +
+
+ Page {currentPage} of {totalPages} +
+
+ + {[...Array(totalPages)].map((_, i) => ( + + ))} + +
+
+ + setIsModalOpen(false)} + settlement={selected} + detail={selectedDetail} + isLoading={isModalLoading} + /> + + )} +
+ ); +} + diff --git a/frontend/src/pages/Settlements/components/PaymentDetailModal.tsx b/frontend/src/pages/Settlements/components/PaymentDetailModal.tsx new file mode 100644 index 0000000..99688e3 --- /dev/null +++ b/frontend/src/pages/Settlements/components/PaymentDetailModal.tsx @@ -0,0 +1,149 @@ +import { useEffect } from "react"; +import { X } from "lucide-react"; +import { Settlement, SettlementDetail } from "@services/api/endpoints/settlements"; + +interface PaymentDetailModalProps { + isOpen: boolean; + onClose: () => void; + settlement: Settlement | null; + detail: SettlementDetail | null; + isLoading?: boolean; +} + +const truncate = (s?: string) => { + if (!s) return "-"; + if (s.length <= 16) return s; + return `${s.slice(0, 12)}...${s.slice(-8)}`; +}; + +const getStellarExplorerUrl = (hash?: string) => { + if (!hash) return undefined; + return `https://stellar.expert/explorer/public/tx/${hash}`; +}; + +export default function PaymentDetailModal({ + isOpen, + onClose, + settlement, + detail, + isLoading, +}: PaymentDetailModalProps) { + useEffect(() => { + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [onClose]); + + if (!isOpen) return null; + + const effective = detail?.settlement ?? settlement; + if (!effective) return null; + + const url = getStellarExplorerUrl(effective.stellarTxHash); + + const conditionDescription = effective.escrowRelease?.conditionDescription; + const releasedAt = effective.escrowRelease?.releasedAt; + const disputedAt = effective.escrowRelease?.disputedAt; + const disputeReason = effective.escrowRelease?.disputeReason; + + return ( +
+
e.stopPropagation()} + > +
+

Escrow Details

+ +
+ + {isLoading ? ( +
Loading escrow information...
+ ) : ( + <> +
+ {([ + ["Shipment", effective.shipmentId], + [ + "Amount", + `${effective.amount.toLocaleString()} ${effective.token}`, + ], + ["Status", effective.status], + ["Stellar Tx", truncate(effective.stellarTxHash)], + ] as [string, string][]).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ +
+
Release conditions
+
+ {conditionDescription ?? "-"} +
+ +
+
+ Released at + {releasedAt ?? "-"} +
+
+ Disputed at + {disputedAt ?? "-"} +
+
+ Dispute reason + {disputeReason ?? "-"} +
+
+ +
+
+ Payer + {effective.payerAddress ?? "-"} +
+
+ Payee + {effective.payeeAddress ?? "-"} +
+
+
+ +
+ + {url ? ( + + Verify on Blockchain + + ) : null} +
+ + )} +
+
+ ); +} + diff --git a/frontend/src/pages/Settlements/components/index.ts b/frontend/src/pages/Settlements/components/index.ts new file mode 100644 index 0000000..55e46a8 --- /dev/null +++ b/frontend/src/pages/Settlements/components/index.ts @@ -0,0 +1,2 @@ +export { default as PaymentDetailModal } from "./PaymentDetailModal"; + diff --git a/frontend/src/pages/Shipment/sections/ShipmentHeader/ShipmentHeader.tsx b/frontend/src/pages/Shipment/sections/ShipmentHeader/ShipmentHeader.tsx index c8e6bbf..b68c64b 100644 --- a/frontend/src/pages/Shipment/sections/ShipmentHeader/ShipmentHeader.tsx +++ b/frontend/src/pages/Shipment/sections/ShipmentHeader/ShipmentHeader.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Download, Share2, MapPin } from 'lucide-react'; -import { StatusBadge, ShipmentStatus } from '../../../../components/ui/StatusBadge/StatusBadge'; +import StatusBadge from '../../../../components/ui/StatusBadge/StatusBadge'; +import type { ShipmentStatus } from '../../../../services/api/endpoints/shipments'; import './ShipmentHeader.css'; export interface ShipmentHeaderProps { @@ -60,18 +61,18 @@ export const ShipmentHeader: React.FC = ({

{sender.name}

{sender.address}

- +
- +

To

{receiver.name}

{receiver.address}

- +
Created: @@ -87,7 +88,7 @@ export const ShipmentHeader: React.FC = ({ {/* Right Section - Action Buttons */}
- - -
@@ -200,8 +185,9 @@ const CompanyDashboard: React.FC = () => { {s.destination} - - {s.status} + {/** Use canonical label and badge classes */} + + {getStatusDisplayLabel(getStatusKey(s.status))} @@ -213,7 +199,7 @@ const CompanyDashboard: React.FC = () => {
{recentShipments.map((s) => { const key = getStatusKey(s.status); - const isQr = s.status === "IN-TRANSIT" && s.id === "#NVN-2109"; + const isQr = key === 'IN_TRANSIT' && s.id === '#NVN-2109'; return (
@@ -222,12 +208,12 @@ const CompanyDashboard: React.FC = () => {
{s.id}
-
- {s.statusLabel} - {s.destination} +
+ {getStatusDisplayLabel(key)} - {s.destination}
-
); @@ -255,7 +241,7 @@ const CompanyDashboard: React.FC = () => { {[ { seed: "Felix", bg: "b6e3f4" }, { seed: "Aneka", bg: "c0aede" }, - { seed: "Jack", bg: "ffdfbf" }, + { seed: "Jack", bg: "ffdfbf" }, ].map((a, i) => (
Driver diff --git a/frontend/src/pages/dashboard/Company/RecentShipments/RecentShipments.tsx b/frontend/src/pages/dashboard/Company/RecentShipments/RecentShipments.tsx index ee81c67..373b794 100644 --- a/frontend/src/pages/dashboard/Company/RecentShipments/RecentShipments.tsx +++ b/frontend/src/pages/dashboard/Company/RecentShipments/RecentShipments.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { ChevronLeft, ChevronRight, ChevronsUpDown } from 'lucide-react'; import { MOCK_SHIPMENTS, type Shipment } from './mockShipments'; +import { getStatusDisplayLabel, getStatusBadgeClass } from '../../../../utils/shipmentStatus'; type SortKey = 'createdAt' | 'status'; type SortDirection = 'asc' | 'desc'; @@ -13,21 +14,16 @@ interface RecentShipmentsProps { const PAGE_SIZE = 5; const statusRank: Record = { - 'Pending Approval': 1, - 'In Transit': 2, - Delivered: 3, - Cancelled: 4, + CREATED: 1, + IN_TRANSIT: 2, + DELIVERED: 3, + CANCELLED: 4, }; const formatCreatedDate = (createdAt: string) => new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(createdAt)); -const statusClasses: Record = { - 'Pending Approval': 'bg-[rgba(245,158,11,0.1)] text-[#fbbf24]', - 'In Transit': 'bg-[rgba(59,130,246,0.1)] text-[#60a5fa]', - Delivered: 'bg-[rgba(16,185,129,0.1)] text-[#34d399]', - Cancelled: 'bg-[rgba(239,68,68,0.1)] text-[#f87171]', -}; +// Now resolved via shared mapping const RecentShipments: React.FC = ({ shipments = MOCK_SHIPMENTS, @@ -132,8 +128,8 @@ const RecentShipments: React.FC = ({ {shipment.origin} {shipment.destination} - - {shipment.status} + + {getStatusDisplayLabel(shipment.status)} {formatCreatedDate(shipment.createdAt)} @@ -165,11 +161,10 @@ const RecentShipments: React.FC = ({