diff --git a/frontend/app/admin/trades/page.tsx b/frontend/app/admin/trades/page.tsx new file mode 100644 index 0000000..3d7bc45 --- /dev/null +++ b/frontend/app/admin/trades/page.tsx @@ -0,0 +1,603 @@ +"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 thousand separators + */ +function formatSats(sats: number): string { + const btc = sats / 100_000_000; + const btcStr = btc.toFixed(8); + const [whole, decimal] = btcStr.split("."); + const grouped = decimal.replace(/(.{2})(.{3})(.{3})/, "$1 $2 $3"); + return `${whole}.${grouped}`; +} + +/** + * 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_APPOINTMENTS, + 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)} + + {formatSats(trade.sats_amount)} sats +
+ +
+ 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", + }, +}; diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index 03893d0..bb0b2d9 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -37,9 +37,9 @@ const REGULAR_NAV_ITEMS: NavItem[] = [ ]; const ADMIN_NAV_ITEMS: NavItem[] = [ - { id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true }, + { id: "admin-appointments", label: "Trades", href: "/admin/trades", adminOnly: true }, { id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true }, - { id: "admin-appointments", label: "Appointments", href: "/admin/appointments", adminOnly: true }, + { id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true }, { id: "admin-price-history", label: "Prices", href: "/admin/price-history", adminOnly: true }, ]; diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index c1e5c9e..a151f4c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -22,9 +22,9 @@ export default function Home() { // Redirect based on role if (hasRole(ADMIN)) { - router.replace("/admin/appointments"); + router.replace("/admin/trades"); } else if (hasRole(REGULAR)) { - router.replace("/booking"); + router.replace("/exchange"); } else { // User with no roles - redirect to login router.replace("/login");