"use client"; import React from "react"; import { useEffect, useState, useCallback } from "react"; import { Permission } from "../../auth-context"; import { api } from "../../api"; import { Header } from "../../components/Header"; import { useRequireAuth } from "../../hooks/useRequireAuth"; import { components } from "../../generated/api"; import { formatDateTime } from "../../utils/date"; import { layoutStyles, typographyStyles, bannerStyles, badgeStyles, buttonStyles, } from "../../styles/shared"; type AdminExchangeResponse = components["schemas"]["AdminExchangeResponse"]; /** * Format EUR amount from cents to display string */ function formatEur(cents: number): string { return `€${(cents / 100).toLocaleString("de-DE")}`; } /** * Format satoshi amount with styled components * Leading zeros are subtle, main digits are prominent */ function SatsDisplay({ sats }: { sats: number }) { const btc = sats / 100_000_000; const btcStr = btc.toFixed(8); const [whole, decimal] = btcStr.split("."); const part1 = decimal.slice(0, 2); const part2 = decimal.slice(2, 5); const part3 = decimal.slice(5, 8); const fullDecimal = part1 + part2 + part3; let firstNonZero = fullDecimal.length; for (let i = 0; i < fullDecimal.length; i++) { if (fullDecimal[i] !== "0") { firstNonZero = i; break; } } const subtleStyle: React.CSSProperties = { opacity: 0.45, fontWeight: 400, }; const renderPart = (part: string, startIdx: number) => { return part.split("").map((char, i) => { const globalIdx = startIdx + i; const isSubtle = globalIdx < firstNonZero; return ( {char} ); }); }; return ( {whole}. {renderPart(part1, 0)} {renderPart(part2, 2)} {renderPart(part3, 5)} sats ); } /** * Get status display properties */ function getTradeStatusDisplay(status: string): { text: string; bgColor: string; textColor: string; } { switch (status) { case "booked": return { text: "Pending", bgColor: "rgba(99, 102, 241, 0.2)", textColor: "rgba(129, 140, 248, 0.9)", }; case "completed": return { text: "Completed", bgColor: "rgba(34, 197, 94, 0.2)", textColor: "rgba(34, 197, 94, 0.9)", }; case "cancelled_by_user": return { text: "User Cancelled", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "rgba(239, 68, 68, 0.9)", }; case "cancelled_by_admin": return { text: "Admin Cancelled", bgColor: "rgba(239, 68, 68, 0.2)", textColor: "rgba(239, 68, 68, 0.9)", }; case "no_show": return { text: "No Show", bgColor: "rgba(251, 146, 60, 0.2)", textColor: "rgba(251, 146, 60, 0.9)", }; default: return { text: status, bgColor: "rgba(255, 255, 255, 0.1)", textColor: "rgba(255, 255, 255, 0.6)", }; } } type Tab = "upcoming" | "past"; export default function AdminTradesPage() { const { user, isLoading, isAuthorized } = useRequireAuth({ requiredPermission: Permission.VIEW_ALL_EXCHANGES, fallbackRedirect: "/", }); const [activeTab, setActiveTab] = useState("upcoming"); const [upcomingTrades, setUpcomingTrades] = useState([]); const [pastTrades, setPastTrades] = useState([]); const [isLoadingTrades, setIsLoadingTrades] = useState(true); const [error, setError] = useState(null); // Action state const [actioningId, setActioningId] = useState(null); const [confirmAction, setConfirmAction] = useState<{ id: number; type: "complete" | "no_show" | "cancel"; } | null>(null); // Past trades filters const [statusFilter, setStatusFilter] = useState("all"); const [userSearch, setUserSearch] = useState(""); const fetchUpcomingTrades = useCallback(async () => { try { const data = await api.get("/api/admin/trades/upcoming"); setUpcomingTrades(data); } catch (err) { console.error("Failed to fetch upcoming trades:", err); setError("Failed to load upcoming trades"); } }, []); const fetchPastTrades = useCallback(async () => { try { let url = "/api/admin/trades/past"; const params = new URLSearchParams(); if (statusFilter !== "all") { params.append("status", statusFilter); } if (userSearch.trim()) { params.append("user_search", userSearch.trim()); } if (params.toString()) { url += `?${params.toString()}`; } const data = await api.get(url); setPastTrades(data); } catch (err) { console.error("Failed to fetch past trades:", err); setError("Failed to load past trades"); } }, [statusFilter, userSearch]); useEffect(() => { if (user && isAuthorized) { setIsLoadingTrades(true); Promise.all([fetchUpcomingTrades(), fetchPastTrades()]).finally(() => { setIsLoadingTrades(false); }); } }, [user, isAuthorized, fetchUpcomingTrades, fetchPastTrades]); const handleAction = async (tradeId: number, action: "complete" | "no_show" | "cancel") => { setActioningId(tradeId); setError(null); try { const endpoint = action === "no_show" ? `/api/admin/trades/${tradeId}/no-show` : `/api/admin/trades/${tradeId}/${action}`; await api.post(endpoint, {}); await Promise.all([fetchUpcomingTrades(), fetchPastTrades()]); setConfirmAction(null); } catch (err) { setError(err instanceof Error ? err.message : `Failed to ${action} trade`); } finally { setActioningId(null); } }; if (isLoading) { return (
Loading...
); } if (!isAuthorized) { return null; } const trades = activeTab === "upcoming" ? upcomingTrades : pastTrades; return (

Trades

Manage Bitcoin exchange trades

{error &&
{error}
} {/* Tabs */}
{/* Filters for Past tab */} {activeTab === "past" && (
setUserSearch(e.target.value)} style={styles.searchInput} />
)} {isLoadingTrades ? (
Loading trades...
) : trades.length === 0 ? (
{activeTab === "upcoming" ? "No upcoming trades." : "No trades found."}
) : (
{trades.map((trade) => { const status = getTradeStatusDisplay(trade.status); const isBuy = trade.direction === "buy"; const isPast = new Date(trade.slot_start) <= new Date(); const canComplete = trade.status === "booked" && isPast && activeTab === "past"; return (
{formatDateTime(trade.slot_start)}
{/* User Info */}
{trade.user_email} {trade.user_contact.telegram && ( {trade.user_contact.telegram} )} {trade.user_contact.signal && ( Signal: {trade.user_contact.signal} )}
{/* Trade Details */}
{isBuy ? "BUY" : "SELL"} {formatEur(trade.eur_amount)}
Rate: € {trade.agreed_price_eur.toLocaleString("de-DE", { maximumFractionDigits: 0, })} /BTC Market: € {trade.market_price_eur.toLocaleString("de-DE", { maximumFractionDigits: 0, })}
{status.text}
{/* Actions */}
{confirmAction?.id === trade.id ? ( <> ) : ( <> {canComplete && ( <> )} {trade.status === "booked" && ( )} )}
); })}
)}
); } // Page-specific styles const styles: Record = { content: { flex: 1, padding: "2rem", maxWidth: "900px", margin: "0 auto", width: "100%", }, tabRow: { display: "flex", gap: "0.5rem", marginBottom: "1.5rem", }, tabButton: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.9rem", fontWeight: 500, padding: "0.75rem 1.5rem", background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "8px", color: "rgba(255, 255, 255, 0.6)", cursor: "pointer", transition: "all 0.2s", }, tabButtonActive: { background: "rgba(167, 139, 250, 0.15)", border: "1px solid #a78bfa", color: "#a78bfa", }, filterRow: { display: "flex", gap: "0.75rem", marginBottom: "1.5rem", flexWrap: "wrap", }, filterSelect: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.5rem 1rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "6px", color: "#fff", fontSize: "0.875rem", }, searchInput: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.5rem 1rem", background: "rgba(255, 255, 255, 0.05)", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "6px", color: "#fff", fontSize: "0.875rem", minWidth: "200px", }, tradeList: { display: "flex", flexDirection: "column", gap: "0.75rem", }, tradeCard: { background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "12px", padding: "1.25rem", transition: "all 0.2s", }, tradeHeader: { display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: "1rem", }, tradeInfo: { display: "flex", flexDirection: "column", gap: "0.25rem", }, tradeTime: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "1rem", fontWeight: 500, color: "#fff", marginBottom: "0.25rem", }, userInfo: { display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.5rem", flexWrap: "wrap", }, userEmail: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.875rem", color: "rgba(255, 255, 255, 0.7)", }, contactBadge: { fontFamily: "'DM Mono', monospace", fontSize: "0.7rem", padding: "0.15rem 0.5rem", background: "rgba(99, 102, 241, 0.15)", border: "1px solid rgba(99, 102, 241, 0.3)", borderRadius: "4px", color: "rgba(129, 140, 248, 0.9)", }, tradeDetails: { display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap", }, directionBadge: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.7rem", fontWeight: 600, padding: "0.2rem 0.5rem", borderRadius: "4px", textTransform: "uppercase", }, amount: { fontFamily: "'DM Mono', monospace", fontSize: "0.95rem", color: "#fff", fontWeight: 500, }, arrow: { color: "rgba(255, 255, 255, 0.3)", fontSize: "0.8rem", }, satsAmount: { fontFamily: "'DM Mono', monospace", fontSize: "0.9rem", color: "#f7931a", }, rateRow: { display: "flex", alignItems: "center", gap: "0.5rem", marginTop: "0.25rem", flexWrap: "wrap", }, rateLabel: { fontFamily: "'DM Sans', system-ui, sans-serif", fontSize: "0.75rem", color: "rgba(255, 255, 255, 0.4)", }, rateValue: { fontFamily: "'DM Mono', monospace", fontSize: "0.8rem", color: "rgba(255, 255, 255, 0.7)", }, buttonGroup: { display: "flex", flexDirection: "column", gap: "0.5rem", alignItems: "flex-end", }, successButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.4rem 0.85rem", fontSize: "0.75rem", fontWeight: 500, background: "rgba(34, 197, 94, 0.2)", border: "1px solid rgba(34, 197, 94, 0.3)", borderRadius: "6px", color: "#4ade80", cursor: "pointer", transition: "all 0.2s", }, warningButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.4rem 0.85rem", fontSize: "0.75rem", fontWeight: 500, background: "rgba(251, 146, 60, 0.2)", border: "1px solid rgba(251, 146, 60, 0.3)", borderRadius: "6px", color: "#fb923c", cursor: "pointer", transition: "all 0.2s", }, dangerButton: { fontFamily: "'DM Sans', system-ui, sans-serif", padding: "0.4rem 0.85rem", fontSize: "0.75rem", fontWeight: 500, background: "rgba(239, 68, 68, 0.2)", border: "1px solid rgba(239, 68, 68, 0.3)", borderRadius: "6px", color: "#f87171", cursor: "pointer", transition: "all 0.2s", }, emptyState: { fontFamily: "'DM Sans', system-ui, sans-serif", color: "rgba(255, 255, 255, 0.4)", textAlign: "center", padding: "3rem", }, };