aethex-forge/client/components/admin/AdminNexusManager.tsx
Builder.io b546c84bfa Add API_BASE and fix relative URLs in AdminNexusManager.tsx
cgen-50567210c65e4967b7980fb031949edc
2025-11-13 02:56:53 +00:00

636 lines
22 KiB
TypeScript

import React, { useState, useEffect } from "react";
const API_BASE = import.meta.env.VITE_API_BASE || "";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Briefcase,
AlertTriangle,
TrendingUp,
XCircle,
CheckCircle,
Search,
Eye,
Flag,
} from "lucide-react";
import { aethexToast } from "@/lib/aethex-toast";
interface Opportunity {
id: string;
title: string;
category: string;
budget_min?: number;
budget_max?: number;
status: "open" | "in_progress" | "filled" | "closed" | "cancelled";
application_count: number;
posted_by_email?: string;
created_at: string;
is_featured: boolean;
}
interface Dispute {
id: string;
contract_id: string;
reported_by_email?: string;
reason: string;
status: "open" | "reviewing" | "resolved" | "escalated";
created_at: string;
resolution_notes?: string;
}
interface Commission {
period_start: string;
period_end: string;
total_volume: number;
total_commission: number;
creator_payouts: number;
aethex_revenue: number;
status: "pending" | "settled" | "disputed";
}
export default function AdminNexusManager() {
const [opportunities, setOpportunities] = useState<Opportunity[]>([]);
const [disputes, setDisputes] = useState<Dispute[]>([]);
const [commissions, setCommissions] = useState<Commission[]>([]);
const [loadingOpp, setLoadingOpp] = useState(true);
const [loadingDisputes, setLoadingDisputes] = useState(true);
const [loadingCommissions, setLoadingCommissions] = useState(true);
const [searchOpp, setSearchOpp] = useState("");
const [disputeFilter, setDisputeFilter] = useState<
"all" | "open" | "resolved"
>("all");
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
const [disputeDialogOpen, setDisputeDialogOpen] = useState(false);
const [disputeResolution, setDisputeResolution] = useState("");
const [disputeAction, setDisputeAction] = useState<"resolve" | "escalate">(
"resolve",
);
useEffect(() => {
fetchOpportunities();
fetchDisputes();
fetchCommissions();
}, []);
const fetchOpportunities = async () => {
try {
setLoadingOpp(true);
const response = await fetch(`${API_BASE}/api/admin/nexus/opportunities`);
if (!response.ok) throw new Error("Failed to fetch opportunities");
const data = await response.json();
setOpportunities(data || []);
} catch (error) {
aethexToast.error("Failed to load opportunities");
console.error(error);
} finally {
setLoadingOpp(false);
}
};
const fetchDisputes = async () => {
try {
setLoadingDisputes(true);
const response = await fetch(`${API_BASE}/api/admin/nexus/disputes`);
if (!response.ok) throw new Error("Failed to fetch disputes");
const data = await response.json();
setDisputes(data || []);
} catch (error) {
aethexToast.error("Failed to load disputes");
console.error(error);
} finally {
setLoadingDisputes(false);
}
};
const fetchCommissions = async () => {
try {
setLoadingCommissions(true);
const response = await fetch(`${API_BASE}/api/admin/nexus/commissions`);
if (!response.ok) throw new Error("Failed to fetch commissions");
const data = await response.json();
setCommissions(data || []);
} catch (error) {
aethexToast.error("Failed to load commissions");
console.error(error);
} finally {
setLoadingCommissions(false);
}
};
const handleModerateOpportunity = async (
opportunityId: string,
status: "open" | "filled" | "closed" | "cancelled",
) => {
try {
const response = await fetch(
`/api/admin/nexus/opportunities/${opportunityId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
},
);
if (!response.ok) throw new Error("Failed to update opportunity");
aethexToast.success(`Opportunity marked as ${status}`);
fetchOpportunities();
} catch (error) {
aethexToast.error("Failed to update opportunity");
console.error(error);
}
};
const handleFeatureOpportunity = async (
opportunityId: string,
featured: boolean,
) => {
try {
const response = await fetch(
`/api/admin/nexus/opportunities/${opportunityId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_featured: featured }),
},
);
if (!response.ok) throw new Error("Failed to update opportunity");
aethexToast.success(
`Opportunity ${featured ? "featured" : "unfeatured"}`,
);
fetchOpportunities();
} catch (error) {
aethexToast.error("Failed to update opportunity");
console.error(error);
}
};
const handleDisputeResolution = async () => {
if (!selectedDispute) return;
try {
const response = await fetch(
`/api/admin/nexus/disputes/${selectedDispute.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: disputeAction === "resolve" ? "resolved" : "escalated",
resolution_notes: disputeResolution,
}),
},
);
if (!response.ok) throw new Error("Failed to update dispute");
aethexToast.success(
`Dispute ${disputeAction === "resolve" ? "resolved" : "escalated"}`,
);
setDisputeDialogOpen(false);
setSelectedDispute(null);
setDisputeResolution("");
fetchDisputes();
} catch (error) {
aethexToast.error("Failed to update dispute");
console.error(error);
}
};
const filteredOpportunities = opportunities.filter((o) =>
o.title.toLowerCase().includes(searchOpp.toLowerCase()),
);
const filteredDisputes = disputes.filter((d) => {
if (disputeFilter === "all") return true;
if (disputeFilter === "open") return d.status === "open";
if (disputeFilter === "resolved") return d.status === "resolved";
return true;
});
const openOpportunities = opportunities.filter(
(o) => o.status === "open",
).length;
const openDisputes = disputes.filter((d) => d.status === "open").length;
const totalCommissionsRevenue = commissions.reduce(
(sum, c) => sum + c.aethex_revenue,
0,
);
return (
<div className="space-y-6">
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Opportunities
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{opportunities.length}</div>
<p className="text-xs text-muted-foreground mt-1">
{openOpportunities} open
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Open Disputes
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-500">
{openDisputes}
</div>
<p className="text-xs text-muted-foreground mt-1">
Requires attention
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Commission Revenue
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${totalCommissionsRevenue.toFixed(0)}
</div>
<p className="text-xs text-muted-foreground mt-1">
20% of marketplace
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Ledger Periods
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{commissions.length}</div>
<p className="text-xs text-muted-foreground mt-1">
Commission records
</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="opportunities" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger
value="opportunities"
className="flex items-center gap-2"
>
<Briefcase className="h-4 w-4" />
Opportunities
</TabsTrigger>
<TabsTrigger value="disputes" className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Disputes
</TabsTrigger>
<TabsTrigger value="commissions" className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Commissions
</TabsTrigger>
</TabsList>
{/* OPPORTUNITIES TAB */}
<TabsContent value="opportunities" className="space-y-4">
<div className="flex gap-2">
<div className="flex-1 relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search opportunities..."
value={searchOpp}
onChange={(e) => setSearchOpp(e.target.value)}
className="pl-10"
/>
</div>
</div>
{loadingOpp ? (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground">
Loading opportunities...
</p>
</CardContent>
</Card>
) : filteredOpportunities.length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground">No opportunities found</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredOpportunities.map((opp) => (
<Card key={opp.id}>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium text-base">{opp.title}</h4>
{opp.is_featured && (
<Badge className="bg-yellow-600">Featured</Badge>
)}
<Badge
variant="outline"
className={
opp.status === "open"
? "bg-green-50"
: opp.status === "filled"
? "bg-blue-50"
: "bg-gray-50"
}
>
{opp.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-3">
Category: {opp.category} {opp.application_count}{" "}
applications
</p>
{opp.budget_min && opp.budget_max && (
<p className="text-sm font-medium">
${opp.budget_min} - ${opp.budget_max}
</p>
)}
</div>
<div className="flex gap-2 flex-wrap justify-end">
{opp.status === "open" && (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
handleFeatureOpportunity(
opp.id,
!opp.is_featured,
)
}
>
{opp.is_featured ? "Unfeature" : "Feature"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
handleModerateOpportunity(opp.id, "filled")
}
>
Mark Filled
</Button>
</>
)}
{opp.status !== "cancelled" && (
<Button
size="sm"
variant="destructive"
onClick={() =>
handleModerateOpportunity(opp.id, "cancelled")
}
>
<XCircle className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* DISPUTES TAB */}
<TabsContent value="disputes" className="space-y-4">
<div className="flex gap-2">
<Select
value={disputeFilter}
onValueChange={(v: any) => setDisputeFilter(v)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Disputes</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
</SelectContent>
</Select>
</div>
{loadingDisputes ? (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground">Loading disputes...</p>
</CardContent>
</Card>
) : filteredDisputes.length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground">No disputes found</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{filteredDisputes.map((dispute) => (
<Card
key={dispute.id}
className={dispute.status === "open" ? "border-red-300" : ""}
>
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Flag className="h-4 w-4 text-red-500" />
<h4 className="font-medium">{dispute.reason}</h4>
<Badge
variant="outline"
className={
dispute.status === "open"
? "bg-red-50"
: dispute.status === "escalated"
? "bg-orange-50"
: "bg-green-50"
}
>
{dispute.status}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-3">
Reported by: {dispute.reported_by_email}
</p>
{dispute.resolution_notes && (
<p className="text-sm text-muted-foreground">
Resolution: {dispute.resolution_notes}
</p>
)}
</div>
{dispute.status === "open" && (
<Button
size="sm"
onClick={() => {
setSelectedDispute(dispute);
setDisputeAction("resolve");
setDisputeDialogOpen(true);
}}
className="bg-blue-600 hover:bg-blue-700"
>
<CheckCircle className="h-4 w-4 mr-1" />
Resolve
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
{/* COMMISSIONS TAB */}
<TabsContent value="commissions" className="space-y-4">
{loadingCommissions ? (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground">Loading commissions...</p>
</CardContent>
</Card>
) : commissions.length === 0 ? (
<Card>
<CardContent className="pt-6">
<p className="text-muted-foreground">No commission records</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{commissions.map((commission, idx) => (
<Card key={idx}>
<CardContent className="pt-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-muted-foreground">Period</p>
<p className="font-medium text-sm">
{new Date(
commission.period_start,
).toLocaleDateString()}{" "}
-{" "}
{new Date(commission.period_end).toLocaleDateString()}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
Total Volume
</p>
<p className="font-medium text-sm">
${commission.total_volume.toFixed(2)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">
AeThex Revenue (20%)
</p>
<p className="font-medium text-sm text-green-600">
${commission.aethex_revenue.toFixed(2)}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground">Status</p>
<Badge
variant="outline"
className={
commission.status === "settled"
? "bg-green-50"
: commission.status === "disputed"
? "bg-red-50"
: "bg-yellow-50"
}
>
{commission.status}
</Badge>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
</Tabs>
{/* Dispute Resolution Dialog */}
<AlertDialog open={disputeDialogOpen} onOpenChange={setDisputeDialogOpen}>
<AlertDialogContent className="max-w-lg">
<AlertDialogTitle>Resolve Dispute</AlertDialogTitle>
<div className="space-y-4 my-4">
<div>
<label className="text-sm font-medium">Resolution Notes</label>
<Textarea
placeholder="Document how this dispute was resolved..."
value={disputeResolution}
onChange={(e) => setDisputeResolution(e.target.value)}
className="mt-2"
/>
</div>
<div>
<label className="text-sm font-medium">Action</label>
<Select
value={disputeAction}
onValueChange={(v: any) => setDisputeAction(v)}
>
<SelectTrigger className="mt-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="resolve">Resolve & Close</SelectItem>
<SelectItem value="escalate">
Escalate to Senior Team
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-3 justify-end">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDisputeResolution}>
{disputeAction === "resolve" ? "Resolve" : "Escalate"}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialog>
</div>
);
}