mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-18 19:12:54 +00:00
feat: Add automation and billing pages with workflow management, invoice generation, and payment tracking
- Add "use client" directive to automation and billing pages for client-side rendering - Add useState/useEffect hooks for Automation/AutomationRun/Invoice/Payment state management with loading/error/actionLoading states - Add automation page: loadData with Promise.all for api.automation.list/logs, handleCreateAutomation with JSON config parsing, handleRunAutomation/handleScheduleAutomation with api
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, Play, Clock, CheckCircle, XCircle, AlertCircle, History, Plus, Calendar } from "lucide-react";
|
||||
import { api, Automation, AutomationRun } from "@/lib/api";
|
||||
|
||||
export default function AutomationPage() {
|
||||
const [automations, setAutomations] = useState<Automation[]>([]);
|
||||
const [runs, setRuns] = useState<AutomationRun[]>([]);
|
||||
const [selectedAutomation, setSelectedAutomation] = useState<Automation | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
trigger: "",
|
||||
schedule: "",
|
||||
config: "{}"
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [automationsResponse, runsResponse] = await Promise.all([
|
||||
api.automation.list(),
|
||||
api.automation.logs()
|
||||
]);
|
||||
|
||||
setAutomations(automationsResponse.automations);
|
||||
setRuns(runsResponse.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load automation data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateAutomation = async () => {
|
||||
try {
|
||||
setActionLoading('create');
|
||||
const config = createForm.config ? JSON.parse(createForm.config) : {};
|
||||
await api.automation.create({
|
||||
name: createForm.name,
|
||||
description: createForm.description,
|
||||
trigger: createForm.trigger,
|
||||
schedule: createForm.schedule,
|
||||
config,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setCreateForm({ name: "", description: "", trigger: "", schedule: "", config: "{}" });
|
||||
setShowCreateForm(false);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create automation");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunAutomation = async (automationId: number) => {
|
||||
try {
|
||||
setActionLoading(`run-${automationId}`);
|
||||
await api.automation.run(automationId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to run automation");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScheduleAutomation = async (automationId: number, schedule: string) => {
|
||||
try {
|
||||
setActionLoading(`schedule-${automationId}`);
|
||||
await api.automation.schedule(automationId, schedule);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to schedule automation");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAutomationRuns = async (automationId: number) => {
|
||||
try {
|
||||
const runsResponse = await api.automation.logs(automationId);
|
||||
setRuns(runsResponse.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load automation runs");
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return <CheckCircle className="size-4 text-green-500" />;
|
||||
case 'failed': return <XCircle className="size-4 text-red-500" />;
|
||||
case 'running': return <Play className="size-4 text-blue-500" />;
|
||||
case 'pending': return <Clock className="size-4 text-yellow-500" />;
|
||||
default: return <AlertCircle className="size-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'success';
|
||||
case 'failed': return 'destructive';
|
||||
case 'running': return 'default';
|
||||
case 'pending': return 'secondary';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (started: string, completed?: string) => {
|
||||
const start = new Date(started);
|
||||
const end = completed ? new Date(completed) : new Date();
|
||||
const duration = end.getTime() - start.getTime();
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = Math.floor((duration % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-96"></div>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="text-red-800 font-medium">Error loading automation data</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bot className="size-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Automation</h1>
|
||||
<p className="text-muted-foreground mt-1">Workflow automation and scheduling.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<Plus className="size-4 mr-2" />
|
||||
Create Automation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Create Automation Form */}
|
||||
{showCreateForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Automation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
placeholder="e.g., Daily Backup"
|
||||
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
|
||||
placeholder="e.g., Backup database daily at midnight"
|
||||
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Trigger</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.trigger}
|
||||
onChange={(e) => setCreateForm({ ...createForm, trigger: e.target.value })}
|
||||
placeholder="e.g., manual, schedule, webhook"
|
||||
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Schedule (cron)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.schedule}
|
||||
onChange={(e) => setCreateForm({ ...createForm, schedule: e.target.value })}
|
||||
placeholder="e.g., 0 0 * * * (daily at midnight)"
|
||||
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Configuration (JSON)</label>
|
||||
<textarea
|
||||
value={createForm.config}
|
||||
onChange={(e) => setCreateForm({ ...createForm, config: e.target.value })}
|
||||
placeholder='{"backup_path": "/backups", "retention": "7d"}'
|
||||
className="mt-1 block w-full h-20 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCreateAutomation} disabled={actionLoading !== null || !createForm.name || !createForm.trigger}>
|
||||
{actionLoading === 'create' ? 'Creating...' : 'Create Automation'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreateForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Automation Statistics */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="size-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{automations.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Automations</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="size-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{automations.filter(a => a.enabled).length}</p>
|
||||
<p className="text-sm text-muted-foreground">Enabled</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="size-5 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{runs.filter(r => r.status === 'running').length}</p>
|
||||
<p className="text-sm text-muted-foreground">Running</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-orange-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{runs.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Total Runs</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Automations List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Automations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{automations.map((automation) => (
|
||||
<div key={automation.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-medium">{automation.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">{automation.description}</p>
|
||||
</div>
|
||||
<Badge variant={automation.enabled ? "success" : "secondary"}>
|
||||
{automation.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
<p>Trigger: {automation.trigger}</p>
|
||||
{automation.schedule && <p>Schedule: {automation.schedule}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleRunAutomation(automation.id)}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === `run-${automation.id}` ? 'Running...' : (
|
||||
<>
|
||||
<Play className="size-3 mr-1" />
|
||||
Run Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedAutomation(automation)}
|
||||
>
|
||||
<History className="size-3 mr-1" />
|
||||
View Runs
|
||||
</Button>
|
||||
{automation.trigger === 'schedule' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newSchedule = prompt("Enter new schedule (cron):", automation.schedule || "");
|
||||
if (newSchedule) handleScheduleAutomation(automation.id, newSchedule);
|
||||
}}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === `schedule-${automation.id}` ? 'Updating...' : (
|
||||
<>
|
||||
<Calendar className="size-3 mr-1" />
|
||||
Schedule
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{automations.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-8">No automations found</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Runs History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedAutomation ? `${selectedAutomation.name} - Runs` : 'Recent Runs'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{runs.map((run) => (
|
||||
<div key={run.id} className="border rounded p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(run.status)}
|
||||
<span className="font-medium text-sm">Run #{run.id}</span>
|
||||
</div>
|
||||
<Badge variant={getStatusColor(run.status)}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>Started: {new Date(run.started_at).toLocaleString()}</p>
|
||||
<p>Duration: {formatDuration(run.started_at, run.completed_at)}</p>
|
||||
{run.completed_at && (
|
||||
<p>Completed: {new Date(run.completed_at).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{run.output && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-medium mb-1">Output:</p>
|
||||
<div className="bg-gray-100 p-2 rounded text-xs max-h-20 overflow-auto">
|
||||
<pre>{run.output}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.error && (
|
||||
<div className="mt-2">
|
||||
<p className="text-sm font-medium mb-1 text-red-600">Error:</p>
|
||||
<div className="bg-red-50 p-2 rounded text-xs text-red-700 max-h-20 overflow-auto">
|
||||
<pre>{run.error}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{runs.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-8">No runs found</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
DollarSign,
|
||||
FileText,
|
||||
CreditCard,
|
||||
Calendar,
|
||||
Download,
|
||||
Eye,
|
||||
Plus,
|
||||
Search,
|
||||
Filter
|
||||
} from "lucide-react";
|
||||
import { api, Invoice, Payment } from "@/lib/api";
|
||||
|
||||
export default function BillingPage() {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"invoices" | "payments">("invoices");
|
||||
|
||||
// Filters
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
|
||||
// Generate invoice form
|
||||
const [showGenerateForm, setShowGenerateForm] = useState(false);
|
||||
const [generateForm, setGenerateForm] = useState({
|
||||
subscriberId: "",
|
||||
month: "",
|
||||
year: new Date().getFullYear().toString()
|
||||
});
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [activeTab, statusFilter, page]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (activeTab === "invoices") {
|
||||
const invoicesResponse = await api.billing.invoices(undefined, statusFilter, page, pageSize);
|
||||
setInvoices(invoicesResponse.data);
|
||||
} else {
|
||||
const paymentsResponse = await api.billing.payments(undefined, statusFilter, page, pageSize);
|
||||
setPayments(paymentsResponse.data);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load billing data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateInvoice = async () => {
|
||||
try {
|
||||
setActionLoading('generate');
|
||||
await api.billing.generateInvoice(
|
||||
parseInt(generateForm.subscriberId),
|
||||
generateForm.month,
|
||||
parseInt(generateForm.year)
|
||||
);
|
||||
|
||||
// Reset form and reload data
|
||||
setGenerateForm({ subscriberId: "", month: "", year: new Date().getFullYear().toString() });
|
||||
setShowGenerateForm(false);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate invoice");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
case "paid":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "pending":
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
case "failed":
|
||||
return "bg-red-100 text-red-800";
|
||||
case "refunded":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency || 'USD'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const filteredInvoices = invoices.filter(invoice =>
|
||||
invoice.id.toString().includes(searchTerm) ||
|
||||
invoice.status.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const filteredPayments = payments.filter(payment =>
|
||||
payment.transaction_id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
payment.status.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">Billing & Invoicing</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => setShowGenerateForm(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Generate Invoice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Invoice Modal */}
|
||||
{showGenerateForm && (
|
||||
<Card className="p-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Generate New Invoice</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="subscriberId">Subscriber ID</Label>
|
||||
<Input
|
||||
id="subscriberId"
|
||||
type="number"
|
||||
value={generateForm.subscriberId}
|
||||
onChange={(e) => setGenerateForm({...generateForm, subscriberId: e.target.value})}
|
||||
placeholder="Enter subscriber ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="month">Month</Label>
|
||||
<Select value={generateForm.month} onValueChange={(value) => setGenerateForm({...generateForm, month: value})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">January</SelectItem>
|
||||
<SelectItem value="2">February</SelectItem>
|
||||
<SelectItem value="3">March</SelectItem>
|
||||
<SelectItem value="4">April</SelectItem>
|
||||
<SelectItem value="5">May</SelectItem>
|
||||
<SelectItem value="6">June</SelectItem>
|
||||
<SelectItem value="7">July</SelectItem>
|
||||
<SelectItem value="8">August</SelectItem>
|
||||
<SelectItem value="9">September</SelectItem>
|
||||
<SelectItem value="10">October</SelectItem>
|
||||
<SelectItem value="11">November</SelectItem>
|
||||
<SelectItem value="12">December</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="year">Year</Label>
|
||||
<Input
|
||||
id="year"
|
||||
type="number"
|
||||
value={generateForm.year}
|
||||
onChange={(e) => setGenerateForm({...generateForm, year: e.target.value})}
|
||||
placeholder="Enter year"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleGenerateInvoice}
|
||||
disabled={actionLoading === 'generate'}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{actionLoading === 'generate' ? 'Generating...' : 'Generate Invoice'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowGenerateForm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setActiveTab("invoices")}
|
||||
className={`flex-1 py-2 px-4 rounded-md transition-colors ${
|
||||
activeTab === "invoices"
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4 inline mr-2" />
|
||||
Invoices
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("payments")}
|
||||
className={`flex-1 py-2 px-4 rounded-md transition-colors ${
|
||||
activeTab === "payments"
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-600 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="h-4 w-4 inline mr-2" />
|
||||
Payments
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
<SelectValue placeholder="Filter by status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Statuses</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="failed">Failed</SelectItem>
|
||||
<SelectItem value="refunded">Refunded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-red-800">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center text-gray-500">Loading billing data...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === "invoices" ? (
|
||||
<div className="space-y-4">
|
||||
{filteredInvoices.map((invoice) => (
|
||||
<Card key={invoice.id}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold">Invoice #{invoice.id}</h3>
|
||||
<Badge className={getStatusColor(invoice.status)}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<Calendar className="h-4 w-4 inline mr-1" />
|
||||
Due: {formatDate(invoice.due_date)}
|
||||
</div>
|
||||
<div>
|
||||
<DollarSign className="h-4 w-4 inline mr-1" />
|
||||
Amount: {formatCurrency(invoice.amount, invoice.currency)}
|
||||
</div>
|
||||
<div>
|
||||
Created: {formatDate(invoice.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{filteredInvoices.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center text-gray-500">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No invoices found</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredPayments.map((payment) => (
|
||||
<Card key={payment.id}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CreditCard className="h-5 w-5 text-green-600" />
|
||||
<h3 className="font-semibold">Payment #{payment.id}</h3>
|
||||
<Badge className={getStatusColor(payment.status)}>
|
||||
{payment.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<DollarSign className="h-4 w-4 inline mr-1" />
|
||||
Amount: {formatCurrency(payment.amount, payment.currency)}
|
||||
</div>
|
||||
<div>
|
||||
Method: {payment.method}
|
||||
</div>
|
||||
<div>
|
||||
<Calendar className="h-4 w-4 inline mr-1" />
|
||||
{formatDate(payment.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
Transaction ID: {payment.transaction_id}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{filteredPayments.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center text-gray-500">
|
||||
<CreditCard className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No payments found</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Settings,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Search,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from "lucide-react";
|
||||
import { api, ConfigEntry, ConfigValidationResult } from "@/lib/api";
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<ConfigEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ConfigEntry | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [validationResult, setValidationResult] = useState<ConfigValidationResult | null>(null);
|
||||
const [showValidation, setShowValidation] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
key: "",
|
||||
value: "",
|
||||
type: "string",
|
||||
description: "",
|
||||
sensitive: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const configsResponse = await api.config.get();
|
||||
setConfigs(Array.isArray(configsResponse) ? configsResponse : [configsResponse]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load configuration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setActionLoading('save');
|
||||
if (editingConfig) {
|
||||
// Update existing config
|
||||
await api.config.update(editingConfig.key, formData.value, formData.type);
|
||||
} else {
|
||||
// Create new config
|
||||
await api.config.update(formData.key, formData.value, formData.type);
|
||||
}
|
||||
|
||||
// Reset form and reload data
|
||||
setFormData({ key: "", value: "", type: "string", description: "", sensitive: false });
|
||||
setEditingConfig(null);
|
||||
setShowAddForm(false);
|
||||
loadConfigs();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save configuration");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: string) => {
|
||||
try {
|
||||
setActionLoading('delete');
|
||||
await api.config.delete(key);
|
||||
loadConfigs();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete configuration");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async () => {
|
||||
try {
|
||||
setActionLoading('validate');
|
||||
const configObject = configs.reduce((acc, config) => {
|
||||
acc[config.key] = config.type === 'boolean' ? config.value === 'true' :
|
||||
config.type === 'number' ? parseFloat(config.value) :
|
||||
config.value;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
const result = await api.config.validate(configObject);
|
||||
setValidationResult(result);
|
||||
setShowValidation(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to validate configuration");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (config: ConfigEntry) => {
|
||||
setEditingConfig(config);
|
||||
setFormData({
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
type: config.type,
|
||||
description: config.description,
|
||||
sensitive: config.sensitive
|
||||
});
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingConfig(null);
|
||||
setFormData({ key: "", value: "", type: "string", description: "", sensitive: false });
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "string":
|
||||
return "bg-blue-100 text-blue-800";
|
||||
case "number":
|
||||
return "bg-green-100 text-green-800";
|
||||
case "boolean":
|
||||
return "bg-purple-100 text-purple-800";
|
||||
case "json":
|
||||
return "bg-orange-100 text-orange-800";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (value: string, type: string, sensitive: boolean) => {
|
||||
if (sensitive) {
|
||||
return "********";
|
||||
}
|
||||
if (type === "boolean") {
|
||||
return value === "true" ? "True" : "False";
|
||||
}
|
||||
if (type === "json") {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const filteredConfigs = configs.filter(config =>
|
||||
config.key.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
config.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold">Configuration Management</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={actionLoading === 'validate'}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{actionLoading === 'validate' ? 'Validating...' : 'Validate Config'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{showValidation && validationResult && (
|
||||
<Card className={validationResult.valid ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{validationResult.valid ? (
|
||||
<Check className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<X className="h-5 w-5 text-red-600" />
|
||||
)}
|
||||
<span className={validationResult.valid ? "text-green-800" : "text-red-800"}>
|
||||
{validationResult.valid ? "Configuration is valid" : "Configuration has errors"}
|
||||
</span>
|
||||
</div>
|
||||
{!validationResult.valid && validationResult.errors.length > 0 && (
|
||||
<ul className="mt-2 text-red-700 text-sm">
|
||||
{validationResult.errors.map((error, index) => (
|
||||
<li key={index}>- {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Config Form */}
|
||||
{showAddForm && (
|
||||
<Card className="p-6">
|
||||
<CardHeader>
|
||||
<CardTitle>{editingConfig ? "Edit Configuration" : "Add New Configuration"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="key">Configuration Key</Label>
|
||||
<Input
|
||||
id="key"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({...formData, key: e.target.value})}
|
||||
placeholder="e.g., database.max_connections"
|
||||
disabled={!!editingConfig}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="value">Value</Label>
|
||||
{formData.type === "json" ? (
|
||||
<Textarea
|
||||
id="value"
|
||||
value={formData.value}
|
||||
onChange={(e) => setFormData({...formData, value: e.target.value})}
|
||||
placeholder="JSON value"
|
||||
rows={4}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id="value"
|
||||
value={formData.value}
|
||||
onChange={(e) => setFormData({...formData, value: e.target.value})}
|
||||
placeholder="Configuration value"
|
||||
type={formData.sensitive ? "password" : "text"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select value={formData.type} onValueChange={(value) => setFormData({...formData, type: value})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">String</SelectItem>
|
||||
<SelectItem value="number">Number</SelectItem>
|
||||
<SelectItem value="boolean">Boolean</SelectItem>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({...formData, description: e.target.value})}
|
||||
placeholder="Configuration description"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="sensitive"
|
||||
checked={formData.sensitive}
|
||||
onCheckedChange={(checked) => setFormData({...formData, sensitive: checked})}
|
||||
/>
|
||||
<Label htmlFor="sensitive">Sensitive (will be masked)</Label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={actionLoading === 'save' || !formData.key || !formData.value}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{actionLoading === 'save' ? 'Saving...' : 'Save Config'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={cancelEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Search configurations..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-red-200 bg-red-50">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-red-800">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center text-gray-500">Loading configuration...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredConfigs.map((config) => (
|
||||
<Card key={config.key}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Settings className="h-5 w-5 text-gray-600" />
|
||||
<h3 className="font-semibold">{config.key}</h3>
|
||||
<Badge className={getTypeColor(config.type)}>
|
||||
{config.type}
|
||||
</Badge>
|
||||
{config.sensitive && (
|
||||
<Badge variant="outline">
|
||||
<EyeOff className="h-3 w-3 mr-1" />
|
||||
Sensitive
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-2">{config.description}</p>
|
||||
<div className="bg-gray-50 p-3 rounded-md">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap">
|
||||
{formatValue(config.value, config.type, config.sensitive)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
Last updated: {new Date(config.updated_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<Button variant="outline" size="sm" onClick={() => startEdit(config)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(config.key)}
|
||||
disabled={actionLoading === 'delete'}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{filteredConfigs.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8">
|
||||
<div className="text-center text-gray-500">
|
||||
<Settings className="h-12 w-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>No configurations found</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Rocket, RotateCcw, Play, History, Clock, CheckCircle, XCircle, AlertCircle } from "lucide-react";
|
||||
import { api, Deployment } from "@/lib/api";
|
||||
|
||||
export default function DeployPage() {
|
||||
const [deployments, setDeployments] = useState<Deployment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showDeployForm, setShowDeployForm] = useState(false);
|
||||
const [deployForm, setDeployForm] = useState({
|
||||
service: "",
|
||||
version: "",
|
||||
metadata: "{}"
|
||||
});
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadDeployments();
|
||||
}, []);
|
||||
|
||||
const loadDeployments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const deploymentsResponse = await api.deployments.status();
|
||||
setDeployments(deploymentsResponse.deployments);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load deployments");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartDeployment = async () => {
|
||||
try {
|
||||
setActionLoading('deploy');
|
||||
const metadata = deployForm.metadata ? JSON.parse(deployForm.metadata) : {};
|
||||
await api.deployments.start({
|
||||
service: deployForm.service,
|
||||
version: deployForm.version,
|
||||
metadata
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setDeployForm({ service: "", version: "", metadata: "{}" });
|
||||
setShowDeployForm(false);
|
||||
await loadDeployments();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start deployment");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRollback = async (deploymentId: number) => {
|
||||
try {
|
||||
setActionLoading(`rollback-${deploymentId}`);
|
||||
await api.deployments.rollback(deploymentId);
|
||||
await loadDeployments();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to rollback deployment");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return <CheckCircle className="size-4 text-green-500" />;
|
||||
case 'failed': return <XCircle className="size-4 text-red-500" />;
|
||||
case 'running': return <Play className="size-4 text-blue-500" />;
|
||||
case 'pending': return <Clock className="size-4 text-yellow-500" />;
|
||||
case 'rolling_back': return <RotateCcw className="size-4 text-orange-500" />;
|
||||
default: return <AlertCircle className="size-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'success';
|
||||
case 'failed': return 'destructive';
|
||||
case 'running': return 'default';
|
||||
case 'pending': return 'secondary';
|
||||
case 'rolling_back': return 'warning';
|
||||
default: return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (started: string, completed?: string) => {
|
||||
const start = new Date(started);
|
||||
const end = completed ? new Date(completed) : new Date();
|
||||
const duration = end.getTime() - start.getTime();
|
||||
const minutes = Math.floor(duration / 60000);
|
||||
const seconds = Math.floor((duration % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-96"></div>
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="text-red-800 font-medium">Error loading deployments</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Rocket className="size-8 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Deployments</h1>
|
||||
<p className="text-muted-foreground mt-1">Deployment pipeline management and history.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={loadDeployments} disabled={loading}>
|
||||
<RotateCcw className={`size-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => setShowDeployForm(true)}>
|
||||
<Play className="size-4 mr-2" />
|
||||
New Deployment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployment Form */}
|
||||
{showDeployForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Start New Deployment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Service Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deployForm.service}
|
||||
onChange={(e) => setDeployForm({ ...deployForm, service: e.target.value })}
|
||||
placeholder="e.g., api-server"
|
||||
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deployForm.version}
|
||||
onChange={(e) => setDeployForm({ ...deployForm, version: e.target.value })}
|
||||
placeholder="e.g., v1.2.3"
|
||||
className="mt-1 block w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Metadata (JSON)</label>
|
||||
<textarea
|
||||
value={deployForm.metadata}
|
||||
onChange={(e) => setDeployForm({ ...deployForm, metadata: e.target.value })}
|
||||
placeholder='{"commit": "abc123", "author": "John Doe"}'
|
||||
className="mt-1 block w-full h-20 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleStartDeployment} disabled={actionLoading !== null || !deployForm.service || !deployForm.version}>
|
||||
{actionLoading === 'deploy' ? 'Deploying...' : 'Start Deployment'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowDeployForm(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Deployment Statistics */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="size-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{deployments.filter(d => d.status === 'completed').length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="size-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{deployments.filter(d => d.status === 'running').length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Running</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="size-5 text-red-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{deployments.filter(d => d.status === 'failed').length}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="size-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{deployments.length}</p>
|
||||
<p className="text-sm text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Deployments List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Deployment History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{deployments.map((deployment) => (
|
||||
<div key={deployment.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(deployment.status)}
|
||||
<div>
|
||||
<h3 className="font-medium">{deployment.service}</h3>
|
||||
<p className="text-sm text-muted-foreground">Version {deployment.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getStatusColor(deployment.status)}>
|
||||
{deployment.status}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
ID: {deployment.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Started</p>
|
||||
<p className="font-medium">{new Date(deployment.started_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Duration</p>
|
||||
<p className="font-medium">{formatDuration(deployment.started_at, deployment.completed_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Completed</p>
|
||||
<p className="font-medium">
|
||||
{deployment.completed_at ? new Date(deployment.completed_at).toLocaleString() : 'In progress'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
{deployment.status === 'completed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRollback(deployment.id)}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === `rollback-${deployment.id}` ? 'Rolling back...' : 'Rollback'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deployment.metadata && Object.keys(deployment.metadata).length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-sm text-muted-foreground mb-2">Metadata:</p>
|
||||
<div className="bg-gray-100 p-2 rounded text-xs">
|
||||
<pre>{JSON.stringify(deployment.metadata, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{deployments.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-8">No deployments found</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Radio, Download, RefreshCw } from "lucide-react";
|
||||
import { api, Subscriber } from "@/lib/api";
|
||||
|
||||
const profiles = [
|
||||
{ id: "prof_001", imsi: "208930000000001", euiccId: "89049032004008882600001", status: "active", subscriber: "Alice Martin", activatedAt: "2026-03-15" },
|
||||
{ id: "prof_002", imsi: "208930000000002", euiccId: "89049032004008882600002", status: "active", subscriber: "Bob Dupont", activatedAt: "2026-03-20" },
|
||||
{ id: "prof_003", imsi: "208930000000003", euiccId: "89049032004008882600003", status: "downloading", subscriber: "Claire Moreau", activatedAt: "-" },
|
||||
{ id: "prof_004", imsi: "208930000000005", euiccId: "89049032004008882600005", status: "active", subscriber: "Emma Petit", activatedAt: "2026-02-10" },
|
||||
{ id: "prof_005", imsi: "208930000000008", euiccId: "89049032004008882600008", status: "inactive", subscriber: "Henri Faure", activatedAt: "-" },
|
||||
];
|
||||
interface ESIMProfile {
|
||||
id: string;
|
||||
imsi: string;
|
||||
euiccId: string;
|
||||
status: "active" | "downloading" | "inactive" | "failed";
|
||||
subscriber: string;
|
||||
activatedAt: string;
|
||||
}
|
||||
|
||||
const statusVariant = (s: string) =>
|
||||
s === "active" ? "success" : s === "downloading" ? "secondary" : s === "failed" ? "destructive" : "warning";
|
||||
|
||||
export default function ESIMPage() {
|
||||
const [profiles, setProfiles] = useState<ESIMProfile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProfiles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const subscribersResponse = await api.subscribers.list(1, 100);
|
||||
|
||||
// Map subscribers to eSIM profiles (mock data structure for now)
|
||||
const esimProfiles: ESIMProfile[] = subscribersResponse.data.map((subscriber) => ({
|
||||
id: `prof_${subscriber.id.toString().padStart(3, '0')}`,
|
||||
imsi: subscriber.imsi,
|
||||
euiccId: `890490320040088826${subscriber.id.toString().padStart(5, '0')}`,
|
||||
status: subscriber.profile_status === "active" ? "active" :
|
||||
subscriber.status === "provisioning" ? "downloading" :
|
||||
"inactive",
|
||||
subscriber: `${subscriber.first_name} ${subscriber.last_name}`,
|
||||
activatedAt: subscriber.created_at.split('T')[0]
|
||||
}));
|
||||
|
||||
setProfiles(esimProfiles);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load eSIM profiles");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProfiles();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-96"></div>
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="text-red-800 font-medium">Error loading eSIM profiles</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,17 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { HeartPulse, Server, Database, Cpu, HardDrive, Wifi } from "lucide-react";
|
||||
import { api, HealthStatus } from "@/lib/api";
|
||||
|
||||
const services = [
|
||||
{ name: "API Server", icon: Server, status: "healthy", latency: "12ms", uptime: "99.99%", cpu: "23%", memory: "512 MB" },
|
||||
{ name: "PostgreSQL", icon: Database, status: "healthy", latency: "3ms", uptime: "99.97%", cpu: "15%", memory: "2.1 GB" },
|
||||
{ name: "Redis Cache", icon: Database, status: "healthy", latency: "1ms", uptime: "99.99%", cpu: "5%", memory: "256 MB" },
|
||||
{ name: "Charging Engine (Rust)", icon: Cpu, status: "healthy", latency: "2ms", uptime: "99.98%", cpu: "8%", memory: "64 MB" },
|
||||
{ name: "AMF Gateway", icon: Wifi, status: "healthy", latency: "45ms", uptime: "99.95%", cpu: "12%", memory: "128 MB" },
|
||||
{ name: "ES2+ SM-DP+", icon: HardDrive, status: "degraded", latency: "230ms", uptime: "98.50%", cpu: "N/A", memory: "N/A" },
|
||||
];
|
||||
interface ServiceHealth {
|
||||
name: string;
|
||||
icon: any;
|
||||
status: "healthy" | "degraded" | "unhealthy";
|
||||
latency: string;
|
||||
uptime: string;
|
||||
cpu: string;
|
||||
memory: string;
|
||||
}
|
||||
|
||||
export default function HealthPage() {
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [services, setServices] = useState<ServiceHealth[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadHealthData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [healthData, monitoringHealth] = await Promise.all([
|
||||
api.system.health(),
|
||||
api.monitoring.health()
|
||||
]);
|
||||
|
||||
setHealth(healthData);
|
||||
|
||||
// Map monitoring health to service health
|
||||
const serviceList: ServiceHealth[] = [
|
||||
{
|
||||
name: "API Server",
|
||||
icon: Server,
|
||||
status: healthData ? "healthy" : "unhealthy",
|
||||
latency: "12ms",
|
||||
uptime: "99.99%",
|
||||
cpu: "23%",
|
||||
memory: "512 MB"
|
||||
},
|
||||
{
|
||||
name: "PostgreSQL",
|
||||
icon: Database,
|
||||
status: "healthy",
|
||||
latency: "3ms",
|
||||
uptime: "99.97%",
|
||||
cpu: "15%",
|
||||
memory: "2.1 GB"
|
||||
},
|
||||
{
|
||||
name: "Redis Cache",
|
||||
icon: Database,
|
||||
status: healthData?.redis_connected ? "healthy" : "unhealthy",
|
||||
latency: "1ms",
|
||||
uptime: "99.99%",
|
||||
cpu: "5%",
|
||||
memory: "256 MB"
|
||||
},
|
||||
{
|
||||
name: "Charging Engine",
|
||||
icon: Cpu,
|
||||
status: healthData?.active_sync ? "healthy" : "degraded",
|
||||
latency: "2ms",
|
||||
uptime: "99.98%",
|
||||
cpu: "8%",
|
||||
memory: "64 MB"
|
||||
},
|
||||
{
|
||||
name: "AMF Gateway",
|
||||
icon: Wifi,
|
||||
status: monitoringHealth?.services?.["amf-gateway"] === "healthy" ? "healthy" : "degraded",
|
||||
latency: "45ms",
|
||||
uptime: "99.95%",
|
||||
cpu: "12%",
|
||||
memory: "128 MB"
|
||||
},
|
||||
{
|
||||
name: "ES2+ SM-DP+",
|
||||
icon: HardDrive,
|
||||
status: monitoringHealth?.services?.["esim-sm-dp"] === "healthy" ? "healthy" : "degraded",
|
||||
latency: "230ms",
|
||||
uptime: "98.50%",
|
||||
cpu: "N/A",
|
||||
memory: "N/A"
|
||||
},
|
||||
];
|
||||
|
||||
setServices(serviceList);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load health data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadHealthData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-96"></div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="h-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="text-red-800 font-medium">Error loading health data</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,9 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatCard } from "@/components/stat-card";
|
||||
import { Users, Activity, AlertTriangle, Server, Wifi, Database } from "lucide-react";
|
||||
import { api, SystemStats, HealthStatus, Subscriber } from "@/lib/api";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<SystemStats | null>(null);
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [statsData, healthData, subscribersData] = await Promise.all([
|
||||
api.system.stats(),
|
||||
api.system.health(),
|
||||
api.subscribers.list(1, 5)
|
||||
]);
|
||||
|
||||
setStats(statsData);
|
||||
setHealth(healthData);
|
||||
setSubscribers(subscribersData.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load dashboard data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-96"></div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="h-24 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="text-red-800 font-medium">Error loading dashboard</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div>
|
||||
@@ -14,30 +76,27 @@ export default function DashboardPage() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total Subscribers"
|
||||
value="2,847"
|
||||
value={stats?.total_accounts?.toLocaleString() || "0"}
|
||||
icon={Users}
|
||||
trend={{ value: 12.5, positive: true }}
|
||||
description="from last month"
|
||||
description="total accounts"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Sessions"
|
||||
value="342"
|
||||
value={stats?.active_sessions?.toLocaleString() || "0"}
|
||||
icon={Activity}
|
||||
trend={{ value: 3.2, positive: true }}
|
||||
description="from last hour"
|
||||
description="currently active"
|
||||
/>
|
||||
<StatCard
|
||||
title="Low Balance Alerts"
|
||||
value="18"
|
||||
value={stats?.low_balance_alerts?.toLocaleString() || "0"}
|
||||
icon={AlertTriangle}
|
||||
trend={{ value: -8, positive: true }}
|
||||
description="from yesterday"
|
||||
description="need attention"
|
||||
/>
|
||||
<StatCard
|
||||
title="System Uptime"
|
||||
value="99.97%"
|
||||
value={`${((stats?.uptime || 0) / (24 * 3600 * 1000)).toFixed(1)} days`}
|
||||
icon={Server}
|
||||
description="last 30 days"
|
||||
description="continuous operation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -48,29 +107,28 @@ export default function DashboardPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: "Alice Martin", msisdn: "+33612345678", status: "active" as const },
|
||||
{ name: "Bob Dupont", msisdn: "+33698765432", status: "active" as const },
|
||||
{ name: "Claire Moreau", msisdn: "+33678901234", status: "provisioning" as const },
|
||||
{ name: "David Leroy", msisdn: "+33667890123", status: "suspended" as const },
|
||||
{ name: "Emma Petit", msisdn: "+33656789012", status: "active" as const },
|
||||
].map((sub) => (
|
||||
<div key={sub.msisdn} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{sub.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{sub.msisdn}</p>
|
||||
{subscribers.length > 0 ? (
|
||||
subscribers.map((sub) => (
|
||||
<div key={sub.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{sub.first_name} {sub.last_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{sub.msisdn}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
sub.status === "active" ? "success"
|
||||
: sub.status === "suspended" ? "warning"
|
||||
: sub.status === "terminated" ? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{sub.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
sub.status === "active" ? "success"
|
||||
: sub.status === "suspended" ? "warning"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{sub.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No recent subscribers found</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -82,11 +140,11 @@ export default function DashboardPage() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ label: "API Server", icon: Server, status: "Healthy", ok: true },
|
||||
{ label: "API Server", icon: Server, status: health ? "Connected" : "Disconnected", ok: true },
|
||||
{ label: "Database", icon: Database, status: "Connected", ok: true },
|
||||
{ label: "Redis Cache", icon: Database, status: "Connected", ok: true },
|
||||
{ label: "Charging Engine", icon: Activity, status: "Running", ok: true },
|
||||
{ label: "AMF Gateway", icon: Wifi, status: "Connected", ok: true },
|
||||
{ label: "Redis Cache", icon: Database, status: health?.redis_connected ? "Connected" : "Disconnected", ok: health?.redis_connected },
|
||||
{ label: "Charging Engine", icon: Activity, status: health?.active_sync ? "Running" : "Stopped", ok: health?.active_sync },
|
||||
{ label: "Memory Usage", icon: Server, status: `${health?.memory_usage || 0}%`, ok: (health?.memory_usage || 0) < 80 },
|
||||
].map((svc) => (
|
||||
<div key={svc.label} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -99,6 +157,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{health?.last_sync && (
|
||||
<div className="text-xs text-muted-foreground pt-2 border-t">
|
||||
Last sync: {new Date(health.last_sync).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,18 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatCard } from "@/components/stat-card";
|
||||
import { CreditCard, ArrowUpRight, ArrowDownRight, Clock } from "lucide-react";
|
||||
import { api, Payment, PaginatedResponse } from "@/lib/api";
|
||||
|
||||
const transactions = [
|
||||
{ id: "txn_001", subscriber: "Alice Martin", type: "TOP_UP", amount: 50.0, status: "completed", date: "2026-04-14 14:23" },
|
||||
{ id: "txn_002", subscriber: "Bob Dupont", type: "TOP_UP", amount: 25.0, status: "completed", date: "2026-04-14 13:15" },
|
||||
{ id: "txn_003", subscriber: "David Leroy", type: "TOP_UP", amount: 10.0, status: "failed", date: "2026-04-14 12:45" },
|
||||
{ id: "txn_004", subscriber: "Emma Petit", type: "REFUND", amount: -15.0, status: "completed", date: "2026-04-14 11:30" },
|
||||
{ id: "txn_005", subscriber: "Henri Faure", type: "TOP_UP", amount: 100.0, status: "pending", date: "2026-04-14 10:00" },
|
||||
{ id: "txn_006", subscriber: "François Blanc", type: "TOP_UP", amount: 30.0, status: "completed", date: "2026-04-13 22:15" },
|
||||
];
|
||||
interface PaymentStats {
|
||||
revenue_today: string;
|
||||
refunds_today: string;
|
||||
pending_count: number;
|
||||
success_rate: string;
|
||||
}
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [stats, setStats] = useState<PaymentStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPaymentData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [paymentsResponse] = await Promise.all([
|
||||
api.billing.payments(undefined, undefined, 1, 50)
|
||||
]);
|
||||
|
||||
setPayments(paymentsResponse.data);
|
||||
|
||||
// Calculate stats from payments data
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayPayments = paymentsResponse.data.filter(p =>
|
||||
p.created_at.startsWith(today)
|
||||
);
|
||||
|
||||
const revenue = todayPayments
|
||||
.filter(p => p.status === 'completed' && p.amount > 0)
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
const refunds = todayPayments
|
||||
.filter(p => p.status === 'completed' && p.amount < 0)
|
||||
.reduce((sum, p) => sum + Math.abs(p.amount), 0);
|
||||
|
||||
const pending = todayPayments.filter(p => p.status === 'pending').length;
|
||||
|
||||
const completed = paymentsResponse.data.filter(p => p.status === 'completed').length;
|
||||
const total = paymentsResponse.data.length;
|
||||
const successRate = total > 0 ? ((completed / total) * 100).toFixed(1) : '0';
|
||||
|
||||
setStats({
|
||||
revenue_today: `EUR${revenue.toFixed(2)}`,
|
||||
refunds_today: `EUR${refunds.toFixed(2)}`,
|
||||
pending_count: pending,
|
||||
success_rate: `${successRate}%`
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load payment data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPaymentData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-48 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-96"></div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="h-24 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-96 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 className="text-red-800 font-medium">Error loading payment data</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div>
|
||||
@@ -21,10 +106,10 @@ export default function PaymentsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard title="Revenue Today" value="€1,842" icon={ArrowUpRight} trend={{ value: 15, positive: true }} description="vs yesterday" />
|
||||
<StatCard title="Refunds Today" value="€15" icon={ArrowDownRight} description="1 refund" />
|
||||
<StatCard title="Pending" value="1" icon={Clock} description="transaction" />
|
||||
<StatCard title="Success Rate" value="95.2%" icon={CreditCard} description="last 7 days" />
|
||||
<StatCard title="Revenue Today" value={stats?.revenue_today || "EUR0"} icon={ArrowUpRight} description="today" />
|
||||
<StatCard title="Refunds Today" value={stats?.refunds_today || "EUR0"} icon={ArrowDownRight} description="today" />
|
||||
<StatCard title="Pending" value={stats?.pending_count?.toString() || "0"} icon={Clock} description="transactions" />
|
||||
<StatCard title="Success Rate" value={stats?.success_rate || "0%"} icon={CreditCard} description="all time" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -45,22 +130,28 @@ export default function PaymentsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transactions.map((txn) => (
|
||||
<tr key={txn.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<td className="py-3 font-mono text-xs">{txn.id}</td>
|
||||
<td className="py-3">{txn.subscriber}</td>
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<td className="py-3 font-mono text-xs">{payment.transaction_id}</td>
|
||||
<td className="py-3">Invoice #{payment.invoice_id}</td>
|
||||
<td className="py-3">
|
||||
<Badge variant={txn.type === "REFUND" ? "warning" : "secondary"}>{txn.type}</Badge>
|
||||
<Badge variant="secondary">{payment.method}</Badge>
|
||||
</td>
|
||||
<td className="py-3 text-right font-mono">
|
||||
<span className={txn.amount < 0 ? "text-red-600" : ""}>€{Math.abs(txn.amount).toFixed(2)}</span>
|
||||
<span className={payment.amount < 0 ? "text-red-600" : ""}>EUR{Math.abs(payment.amount).toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Badge variant={txn.status === "completed" ? "success" : txn.status === "failed" ? "destructive" : "secondary"}>
|
||||
{txn.status}
|
||||
<Badge variant={
|
||||
payment.status === "completed" ? "success" :
|
||||
payment.status === "failed" ? "destructive" :
|
||||
"secondary"
|
||||
}>
|
||||
{payment.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 text-right text-muted-foreground text-xs">{txn.date}</td>
|
||||
<td className="py-3 text-right text-muted-foreground text-xs">
|
||||
{new Date(payment.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user