diff --git a/frontend/app/admin/price-history/page.tsx b/frontend/app/admin/price-history/page.tsx new file mode 100644 index 0000000..fb8a69e --- /dev/null +++ b/frontend/app/admin/price-history/page.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Permission } from "../../auth-context"; +import { api } from "../../api"; +import { sharedStyles } from "../../styles/shared"; +import { Header } from "../../components/Header"; +import { useRequireAuth } from "../../hooks/useRequireAuth"; +import { components } from "../../generated/api"; + +type PriceHistoryRecord = components["schemas"]["PriceHistoryResponse"]; + +export default function AdminPriceHistoryPage() { + const [records, setRecords] = useState([]); + const [error, setError] = useState(null); + const [isLoadingData, setIsLoadingData] = useState(true); + const [isFetching, setIsFetching] = useState(false); + const { user, isLoading, isAuthorized } = useRequireAuth({ + requiredPermission: Permission.VIEW_AUDIT, + fallbackRedirect: "/", + }); + + const fetchRecords = useCallback(async () => { + setError(null); + setIsLoadingData(true); + try { + const data = await api.get("/api/audit/price-history"); + setRecords(data); + } catch (err) { + setRecords([]); + setError(err instanceof Error ? err.message : "Failed to load price history"); + } finally { + setIsLoadingData(false); + } + }, []); + + const handleFetchNow = async () => { + setIsFetching(true); + setError(null); + try { + await api.post("/api/audit/price-history/fetch", {}); + await fetchRecords(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch price"); + } finally { + setIsFetching(false); + } + }; + + useEffect(() => { + if (user && isAuthorized) { + fetchRecords(); + } + }, [user, isAuthorized, fetchRecords]); + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString(); + }; + + const formatPrice = (price: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "EUR", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(price); + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!user || !isAuthorized) { + return null; + } + + return ( +
+
+ +
+
+
+

Bitcoin Price History

+
+ {records.length} records + + +
+
+ +
+ + + + + + + + + + + {error && ( + + + + )} + {!error && isLoadingData && ( + + + + )} + {!error && !isLoadingData && records.length === 0 && ( + + + + )} + {!error && + !isLoadingData && + records.map((record) => ( + + + + + + + ))} + +
SourcePairPriceTimestamp
+ {error} +
+ Loading... +
+ No price records yet. Click "Fetch Now" to get the current price. +
{record.source}{record.pair}{formatPrice(record.price)}{formatDate(record.timestamp)}
+
+
+
+
+ ); +} + +const pageStyles: Record = { + content: { + flex: 1, + padding: "2rem", + overflowY: "auto", + }, + tableCard: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "20px", + padding: "1.5rem", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + maxWidth: "900px", + margin: "0 auto", + }, + tableHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "1rem", + flexWrap: "wrap", + gap: "1rem", + }, + tableTitle: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "1.5rem", + fontWeight: 400, + color: "#fff", + margin: 0, + }, + headerActions: { + display: "flex", + alignItems: "center", + gap: "1rem", + }, + totalCount: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.4)", + }, + refreshBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + padding: "0.5rem 1rem", + background: "rgba(255, 255, 255, 0.08)", + border: "1px solid rgba(255, 255, 255, 0.15)", + borderRadius: "8px", + color: "rgba(255, 255, 255, 0.8)", + cursor: "pointer", + }, + fetchBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + padding: "0.5rem 1rem", + background: "linear-gradient(135deg, #a78bfa, #8b5cf6)", + border: "none", + borderRadius: "8px", + color: "#fff", + cursor: "pointer", + fontWeight: 500, + }, + tableWrapper: { + overflowX: "auto", + }, + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "'DM Sans', system-ui, sans-serif", + }, + th: { + textAlign: "left", + padding: "0.75rem 1rem", + fontSize: "0.75rem", + fontWeight: 600, + color: "rgba(255, 255, 255, 0.4)", + textTransform: "uppercase", + letterSpacing: "0.05em", + borderBottom: "1px solid rgba(255, 255, 255, 0.08)", + }, + tr: { + borderBottom: "1px solid rgba(255, 255, 255, 0.04)", + }, + td: { + padding: "0.875rem 1rem", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.7)", + }, + tdPrice: { + padding: "0.875rem 1rem", + fontSize: "1rem", + color: "#fbbf24", + fontWeight: 600, + fontFamily: "'DM Mono', monospace", + }, + tdDate: { + padding: "0.875rem 1rem", + fontSize: "0.75rem", + color: "rgba(255, 255, 255, 0.4)", + }, + emptyRow: { + padding: "2rem 1rem", + textAlign: "center", + color: "rgba(255, 255, 255, 0.3)", + fontSize: "0.875rem", + }, + errorRow: { + padding: "2rem 1rem", + textAlign: "center", + color: "#f87171", + fontSize: "0.875rem", + }, +}; + +const styles = { ...sharedStyles, ...pageStyles }; diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index 37e482b..afacc97 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -18,7 +18,8 @@ type PageId = | "admin-invites" | "admin-availability" | "admin-appointments" - | "admin-random-jobs"; + | "admin-random-jobs" + | "admin-price-history"; interface HeaderProps { currentPage: PageId; @@ -47,6 +48,7 @@ const ADMIN_NAV_ITEMS: NavItem[] = [ { id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true }, { id: "admin-appointments", label: "Appointments", href: "/admin/appointments", adminOnly: true }, { id: "admin-random-jobs", label: "Random Jobs", href: "/admin/random-jobs", adminOnly: true }, + { id: "admin-price-history", label: "Prices", href: "/admin/price-history", adminOnly: true }, ]; export function Header({ currentPage }: HeaderProps) { diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index c9b467d..12eb9e5 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -204,6 +204,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/audit/price-history": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Price History + * @description Get the 20 most recent price history records. + */ + get: operations["get_price_history_api_audit_price_history_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/audit/price-history/fetch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Fetch Price Now + * @description Manually trigger a price fetch from Bitfinex. + */ + post: operations["fetch_price_now_api_audit_price_history_fetch_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/profile": { parameters: { query?: never; @@ -791,6 +831,30 @@ export interface components { * @enum {string} */ Permission: "view_counter" | "increment_counter" | "use_sum" | "view_audit" | "manage_own_profile" | "manage_invites" | "view_own_invites" | "book_appointment" | "view_own_appointments" | "cancel_own_appointment" | "manage_availability" | "view_all_appointments" | "cancel_any_appointment"; + /** + * PriceHistoryResponse + * @description Response model for a price history record. + */ + PriceHistoryResponse: { + /** Id */ + id: number; + /** Source */ + source: string; + /** Pair */ + pair: string; + /** Price */ + price: number; + /** + * Timestamp + * Format: date-time + */ + timestamp: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + }; /** * ProfileResponse * @description Response model for profile data. @@ -1268,6 +1332,46 @@ export interface operations { }; }; }; + get_price_history_api_audit_price_history_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PriceHistoryResponse"][]; + }; + }; + }; + }; + fetch_price_now_api_audit_price_history_fetch_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PriceHistoryResponse"]; + }; + }; + }; + }; get_profile_api_profile_get: { parameters: { query?: never;