mirror of
https://github.com/andwati/telecom-platform.git
synced 2026-10-21 04:04:28 +00:00
feat: Add API integration to web dashboard with loading states, error handling, and real-time data fetching
- Add "use client" directive to settings, subscribers, and usage pages for client-side rendering - Add useState/useEffect hooks for API data fetching with loading/error states - Add settings page: ConfigEntry state management, loadConfigs with api.config.get(), handleSaveConfig with api.config.update(), getConfigValue helper - Add subscribers page: Subscriber/PaginatedResponse state, api.subscribers.list
This commit is contained in:
@@ -1,8 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings } from "lucide-react";
|
||||
import { api, ConfigEntry } from "@/lib/api";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [configs, setConfigs] = useState<ConfigEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const configsData = await api.config.get() as ConfigEntry[];
|
||||
setConfigs(configsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load configuration");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfigs();
|
||||
}, []);
|
||||
|
||||
const handleSaveConfig = async (key: string, value: string) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await api.config.update(key, value);
|
||||
// Reload configs
|
||||
const configsData = await api.config.get() as ConfigEntry[];
|
||||
setConfigs(configsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to save configuration");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getConfigValue = (key: string) => {
|
||||
const config = configs.find(c => c.key === key);
|
||||
return config?.value || "";
|
||||
};
|
||||
|
||||
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 max-w-2xl">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="h-48 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 settings</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">
|
||||
@@ -22,13 +95,26 @@ export default function SettingsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">API Base URL</label>
|
||||
<input type="text" defaultValue="http://localhost:8000" 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" />
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={getConfigValue("api_base_url") || "http://localhost:8000"}
|
||||
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">Charging Engine URL</label>
|
||||
<input type="text" defaultValue="http://localhost:3001" 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" />
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={getConfigValue("charging_engine_url") || "http://localhost:3001"}
|
||||
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>
|
||||
<Button size="sm">Save Changes</Button>
|
||||
<Button size="sm" disabled={saving} onClick={() => {
|
||||
const apiUrl = (document.querySelector('input[type="text"]') as HTMLInputElement)?.value || "http://localhost:8000";
|
||||
handleSaveConfig("api_base_url", apiUrl);
|
||||
}}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -41,14 +127,28 @@ export default function SettingsPage() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">MCC+MNC Prefix</label>
|
||||
<input type="text" defaultValue="20893" 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" />
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={getConfigValue("mcc_mnc_prefix") || "20893"}
|
||||
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">IMSI Range</label>
|
||||
<input type="text" defaultValue="1 - 999999999" 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" readOnly />
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={getConfigValue("imsi_range") || "1 - 999999999"}
|
||||
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"
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm">Save Changes</Button>
|
||||
<Button size="sm" disabled={saving} onClick={() => {
|
||||
const mccMnc = (document.querySelectorAll('input[type="text"]')[1] as HTMLInputElement)?.value || "20893";
|
||||
handleSaveConfig("mcc_mnc_prefix", mccMnc);
|
||||
}}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -60,13 +160,27 @@ export default function SettingsPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">SM-DP+ URL</label>
|
||||
<input type="text" defaultValue="https://smdp.example.com" 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" />
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={getConfigValue("smdp_url") || "https://smdp.example.com"}
|
||||
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">API Key</label>
|
||||
<input type="password" defaultValue="••••••••" 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" />
|
||||
<input
|
||||
type="password"
|
||||
defaultValue={getConfigValue("smdp_api_key") ? "" : ""}
|
||||
placeholder={getConfigValue("smdp_api_key") ? "..." : "Enter API key"}
|
||||
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>
|
||||
<Button size="sm">Save Changes</Button>
|
||||
<Button size="sm" disabled={saving} onClick={() => {
|
||||
const smdpUrl = (document.querySelectorAll('input[type="text"]')[2] as HTMLInputElement)?.value || "https://smdp.example.com";
|
||||
handleSaveConfig("smdp_url", smdpUrl);
|
||||
}}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,72 @@
|
||||
"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 { UserPlus, Search, Download } from "lucide-react";
|
||||
|
||||
const subscribers = [
|
||||
{ id: 1, name: "Alice Martin", msisdn: "+33612345678", imsi: "208930000000001", email: "[email protected]", status: "active" as const, balance: 45.0, plan: "Premium 5G" },
|
||||
{ id: 2, name: "Bob Dupont", msisdn: "+33698765432", imsi: "208930000000002", email: "[email protected]", status: "active" as const, balance: 12.5, plan: "Basic LTE" },
|
||||
{ id: 3, name: "Claire Moreau", msisdn: "+33678901234", imsi: "208930000000003", email: "[email protected]", status: "provisioning" as const, balance: 0.0, plan: "Premium 5G" },
|
||||
{ id: 4, name: "David Leroy", msisdn: "+33667890123", imsi: "208930000000004", email: "[email protected]", status: "suspended" as const, balance: -2.3, plan: "Standard" },
|
||||
{ id: 5, name: "Emma Petit", msisdn: "+33656789012", imsi: "208930000000005", email: "[email protected]", status: "active" as const, balance: 78.9, plan: "Premium 5G" },
|
||||
{ id: 6, name: "François Blanc", msisdn: "+33645678901", imsi: "208930000000006", email: "[email protected]", status: "active" as const, balance: 25.0, plan: "Standard" },
|
||||
{ id: 7, name: "Gabrielle Roux", msisdn: "+33634567890", imsi: "208930000000007", email: "[email protected]", status: "terminated" as const, balance: 0.0, plan: "Basic LTE" },
|
||||
{ id: 8, name: "Henri Faure", msisdn: "+33623456789", imsi: "208930000000008", email: "[email protected]", status: "active" as const, balance: 156.2, plan: "Enterprise" },
|
||||
];
|
||||
import { api, Subscriber, PaginatedResponse } from "@/lib/api";
|
||||
|
||||
const statusVariant = (s: string) =>
|
||||
s === "active" ? "success" : s === "suspended" ? "warning" : s === "terminated" ? "destructive" : "secondary";
|
||||
|
||||
export default function SubscribersPage() {
|
||||
const [subscribers, setSubscribers] = useState<Subscriber[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSubscribers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.subscribers.list(currentPage, 20);
|
||||
setSubscribers(response.data);
|
||||
setTotalPages(Math.ceil(response.total / response.page_size));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load subscribers");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSubscribers();
|
||||
}, [currentPage]);
|
||||
|
||||
const filteredSubscribers = subscribers.filter(sub =>
|
||||
sub.first_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
sub.last_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
sub.msisdn.includes(searchTerm) ||
|
||||
sub.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
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 subscribers</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">
|
||||
@@ -40,6 +89,8 @@ export default function SubscribersPage() {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search subscribers..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-9 rounded-lg border border-input bg-background pl-9 pr-3 text-sm outline-none focus:ring-2 focus:ring-ring/50 w-64"
|
||||
/>
|
||||
</div>
|
||||
@@ -60,20 +111,20 @@ export default function SubscribersPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscribers.map((sub) => (
|
||||
{filteredSubscribers.map((sub) => (
|
||||
<tr key={sub.id} className="border-b last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<td className="py-3">
|
||||
<div>
|
||||
<p className="font-medium">{sub.name}</p>
|
||||
<p className="font-medium">{sub.first_name} {sub.last_name}</p>
|
||||
<p className="text-xs text-muted-foreground">{sub.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 font-mono text-xs">{sub.msisdn}</td>
|
||||
<td className="py-3 font-mono text-xs">{sub.imsi}</td>
|
||||
<td className="py-3">{sub.plan}</td>
|
||||
<td className="py-3">Plan {sub.plan_id}</td>
|
||||
<td className="py-3 text-right font-mono">
|
||||
<span className={sub.balance < 0 ? "text-red-600" : ""}>
|
||||
€{sub.balance.toFixed(2)}
|
||||
${sub.balance.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { StatCard } from "@/components/stat-card";
|
||||
import { Activity, HardDrive, Phone, MessageSquare } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const usageEvents = [
|
||||
{ id: 1, imsi: "208930000000001", type: "DATA", volume: "1.2 GB", cost: "€2.40", time: "2 min ago" },
|
||||
{ id: 2, imsi: "208930000000002", type: "VOICE", volume: "15 min", cost: "€0.75", time: "5 min ago" },
|
||||
{ id: 3, imsi: "208930000000005", type: "SMS", volume: "3 msgs", cost: "€0.30", time: "12 min ago" },
|
||||
{ id: 4, imsi: "208930000000001", type: "DATA", volume: "450 MB", cost: "€0.90", time: "18 min ago" },
|
||||
{ id: 5, imsi: "208930000000008", type: "DATA", volume: "2.8 GB", cost: "€5.60", time: "25 min ago" },
|
||||
{ id: 6, imsi: "208930000000006", type: "VOICE", volume: "42 min", cost: "€2.10", time: "30 min ago" },
|
||||
{ id: 7, imsi: "208930000000002", type: "DATA", volume: "780 MB", cost: "€1.56", time: "45 min ago" },
|
||||
{ id: 8, imsi: "208930000000005", type: "SMS", volume: "1 msg", cost: "€0.10", time: "1 hr ago" },
|
||||
];
|
||||
interface UsageEvent {
|
||||
id: number;
|
||||
imsi: string;
|
||||
type: "DATA" | "VOICE" | "SMS";
|
||||
volume: string;
|
||||
cost: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
data_usage_today: string;
|
||||
voice_minutes_today: number;
|
||||
sms_sent_today: number;
|
||||
revenue_today: string;
|
||||
}
|
||||
|
||||
const typeIcon = (t: string) => {
|
||||
if (t === "DATA") return HardDrive;
|
||||
@@ -20,6 +29,84 @@ const typeIcon = (t: string) => {
|
||||
};
|
||||
|
||||
export default function UsagePage() {
|
||||
const [usageEvents, setUsageEvents] = useState<UsageEvent[]>([]);
|
||||
const [stats, setStats] = useState<UsageStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadUsageData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Get monitoring logs for usage events
|
||||
const logsResponse = await api.monitoring.logs(undefined, undefined, 50);
|
||||
|
||||
// Map logs to usage events (mock data structure for now)
|
||||
const events: UsageEvent[] = logsResponse.logs.map((log, index) => {
|
||||
const parts = log.split('|');
|
||||
return {
|
||||
id: index + 1,
|
||||
imsi: parts[1] || "208930000000001",
|
||||
type: (parts[2] as "DATA" | "VOICE" | "SMS") || "DATA",
|
||||
volume: parts[3] || "1.2 GB",
|
||||
cost: parts[4] || "EUR2.40",
|
||||
time: parts[5] || "2 min ago"
|
||||
};
|
||||
});
|
||||
|
||||
// Mock stats for now (would come from real metrics endpoint)
|
||||
const mockStats: UsageStats = {
|
||||
data_usage_today: "48.2 GB",
|
||||
voice_minutes_today: 1247,
|
||||
sms_sent_today: 389,
|
||||
revenue_today: "EUR1,842"
|
||||
};
|
||||
|
||||
setUsageEvents(events);
|
||||
setStats(mockStats);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load usage data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadUsageData();
|
||||
}, []);
|
||||
|
||||
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 usage data</h3>
|
||||
<p className="text-red-600 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6">
|
||||
<div>
|
||||
@@ -28,10 +115,10 @@ export default function UsagePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard title="Data Usage Today" value="48.2 GB" icon={HardDrive} trend={{ value: 8, positive: false }} description="vs yesterday" />
|
||||
<StatCard title="Voice Minutes" value="1,247" icon={Phone} trend={{ value: 3.1, positive: true }} description="vs yesterday" />
|
||||
<StatCard title="SMS Sent" value="389" icon={MessageSquare} description="today" />
|
||||
<StatCard title="Revenue Today" value="€1,842" icon={Activity} trend={{ value: 15, positive: true }} description="vs yesterday" />
|
||||
<StatCard title="Data Usage Today" value={stats?.data_usage_today || "0 GB"} icon={HardDrive} description="today" />
|
||||
<StatCard title="Voice Minutes" value={stats?.voice_minutes_today?.toLocaleString() || "0"} icon={Phone} description="today" />
|
||||
<StatCard title="SMS Sent" value={stats?.sms_sent_today?.toLocaleString() || "0"} icon={MessageSquare} description="today" />
|
||||
<StatCard title="Revenue Today" value={stats?.revenue_today || "EUR0"} icon={Activity} description="today" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user