arbret/frontend/app/admin/trades/page.tsx
counterweight e8d0ee2eca
refactor: import React types directly instead of namespace
Replace 'import React from "react"' with direct imports of
CSSProperties and ChangeEvent. This eliminates unused imports
and follows modern React patterns where the namespace import
is not required for JSX (React 17+).
2025-12-23 12:23:32 +01:00

463 lines
16 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, CSSProperties } from "react";
import { Permission } from "../../auth-context";
import { api } from "../../api";
import { Header } from "../../components/Header";
import { SatsDisplay } from "../../components/SatsDisplay";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
import { formatDateTime } from "../../utils/date";
import { formatEur, getTradeStatusDisplay } from "../../utils/exchange";
import {
layoutStyles,
typographyStyles,
bannerStyles,
badgeStyles,
buttonStyles,
tradeCardStyles,
} from "../../styles/shared";
type AdminExchangeResponse = components["schemas"]["AdminExchangeResponse"];
type Tab = "upcoming" | "past";
export default function AdminTradesPage() {
const { user, isLoading, isAuthorized } = useRequireAuth({
requiredPermission: Permission.VIEW_ALL_EXCHANGES,
fallbackRedirect: "/",
});
const [activeTab, setActiveTab] = useState<Tab>("upcoming");
const [upcomingTrades, setUpcomingTrades] = useState<AdminExchangeResponse[]>([]);
const [pastTrades, setPastTrades] = useState<AdminExchangeResponse[]>([]);
const [isLoadingTrades, setIsLoadingTrades] = useState(true);
const [error, setError] = useState<string | null>(null);
// Action state
const [actioningId, setActioningId] = useState<number | null>(null);
const [confirmAction, setConfirmAction] = useState<{
id: number;
type: "complete" | "no_show" | "cancel";
} | null>(null);
// Past trades filters
const [statusFilter, setStatusFilter] = useState<string>("all");
const [userSearch, setUserSearch] = useState("");
const fetchUpcomingTrades = useCallback(async () => {
try {
const data = await api.get<AdminExchangeResponse[]>("/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<AdminExchangeResponse[]>(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<AdminExchangeResponse>(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 (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!isAuthorized) {
return null;
}
const trades = activeTab === "upcoming" ? upcomingTrades : pastTrades;
return (
<main style={layoutStyles.main}>
<Header currentPage="admin-trades" />
<div style={styles.content}>
<h1 style={typographyStyles.pageTitle}>Trades</h1>
<p style={typographyStyles.pageSubtitle}>Manage Bitcoin exchange trades</p>
{error && <div style={bannerStyles.errorBanner}>{error}</div>}
{/* Tabs */}
<div style={styles.tabRow}>
<button
onClick={() => setActiveTab("upcoming")}
style={{
...styles.tabButton,
...(activeTab === "upcoming" ? styles.tabButtonActive : {}),
}}
>
Upcoming ({upcomingTrades.length})
</button>
<button
onClick={() => setActiveTab("past")}
style={{
...styles.tabButton,
...(activeTab === "past" ? styles.tabButtonActive : {}),
}}
>
History ({pastTrades.length})
</button>
</div>
{/* Filters for Past tab */}
{activeTab === "past" && (
<div style={styles.filterRow}>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
style={styles.filterSelect}
>
<option value="all">All Statuses</option>
<option value="completed">Completed</option>
<option value="no_show">No Show</option>
<option value="cancelled_by_user">User Cancelled</option>
<option value="cancelled_by_admin">Admin Cancelled</option>
</select>
<input
type="text"
placeholder="Search by email..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
style={styles.searchInput}
/>
</div>
)}
{isLoadingTrades ? (
<div style={tradeCardStyles.emptyState}>Loading trades...</div>
) : trades.length === 0 ? (
<div style={tradeCardStyles.emptyState}>
{activeTab === "upcoming" ? "No upcoming trades." : "No trades found."}
</div>
) : (
<div style={tradeCardStyles.tradeList}>
{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 (
<div key={trade.id} style={tradeCardStyles.tradeCard}>
<div style={tradeCardStyles.tradeHeader}>
<div style={tradeCardStyles.tradeInfo}>
<div style={tradeCardStyles.tradeTime}>
{formatDateTime(trade.slot_start)}
</div>
{/* User Info */}
<div style={styles.userInfo}>
<span style={styles.userEmail}>{trade.user_email}</span>
{trade.user_contact.telegram && (
<span style={styles.contactBadge}>{trade.user_contact.telegram}</span>
)}
{trade.user_contact.signal && (
<span style={styles.contactBadge}>
Signal: {trade.user_contact.signal}
</span>
)}
</div>
{/* Trade Details */}
<div style={tradeCardStyles.tradeDetails}>
<span
style={{
...tradeCardStyles.directionBadge,
background: isBuy
? "rgba(74, 222, 128, 0.15)"
: "rgba(248, 113, 113, 0.15)",
color: isBuy ? "#4ade80" : "#f87171",
}}
>
{isBuy ? "BUY" : "SELL"}
</span>
<span style={tradeCardStyles.amount}>{formatEur(trade.eur_amount)}</span>
<span style={tradeCardStyles.arrow}></span>
<span style={tradeCardStyles.satsAmount}>
<SatsDisplay sats={trade.sats_amount} />
</span>
</div>
<div style={tradeCardStyles.rateRow}>
<span style={tradeCardStyles.rateLabel}>Rate:</span>
<span style={tradeCardStyles.rateValue}>
{trade.agreed_price_eur.toLocaleString("de-DE", {
maximumFractionDigits: 0,
})}
/BTC
</span>
<span style={tradeCardStyles.rateLabel}>Market:</span>
<span style={tradeCardStyles.rateValue}>
{trade.market_price_eur.toLocaleString("de-DE", {
maximumFractionDigits: 0,
})}
</span>
</div>
<span
style={{
...badgeStyles.badge,
background: status.bgColor,
color: status.textColor,
marginTop: "0.5rem",
}}
>
{status.text}
</span>
</div>
{/* Actions */}
<div style={styles.buttonGroup}>
{confirmAction?.id === trade.id ? (
<>
<button
onClick={() => handleAction(trade.id, confirmAction.type)}
disabled={actioningId === trade.id}
style={
confirmAction.type === "cancel"
? styles.dangerButton
: styles.successButton
}
>
{actioningId === trade.id ? "..." : "Confirm"}
</button>
<button
onClick={() => setConfirmAction(null)}
style={buttonStyles.secondaryButton}
>
No
</button>
</>
) : (
<>
{canComplete && (
<>
<button
onClick={() =>
setConfirmAction({
id: trade.id,
type: "complete",
})
}
style={styles.successButton}
>
Complete
</button>
<button
onClick={() =>
setConfirmAction({
id: trade.id,
type: "no_show",
})
}
style={styles.warningButton}
>
No Show
</button>
</>
)}
{trade.status === "booked" && (
<button
onClick={() =>
setConfirmAction({
id: trade.id,
type: "cancel",
})
}
style={buttonStyles.secondaryButton}
>
Cancel
</button>
)}
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</main>
);
}
// Page-specific styles (trade card styles are shared via tradeCardStyles)
const styles: Record<string, CSSProperties> = {
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",
},
// Admin-specific: user contact info
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)",
},
// Admin-specific: vertical button group
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",
},
};