ui revamp

This commit is contained in:
elnatansamuel25 2026-04-08 09:28:01 +03:00
parent 3290250db3
commit 5adda68494
33 changed files with 4878 additions and 2630 deletions

View File

@ -1,30 +1,35 @@
import { Navigate, Route, Routes } from "react-router-dom"
import { AppShell } from "@/layouts/app-shell"
import { ProtectedRoute } from "@/components/ProtectedRoute"
import LoginPage from "@/pages/login"
import DashboardPage from "@/pages/admin/dashboard"
import UsersPage from "@/pages/admin/users"
import UserDetailsPage from "@/pages/admin/users/[id]"
import UserActivityPage from "@/pages/admin/users/[id]/activity"
import ActivityLogPage from "@/pages/activity-log"
import SettingsPage from "@/pages/admin/settings"
import MaintenancePage from "@/pages/admin/maintenance"
import AnnouncementsPage from "@/pages/admin/announcements"
import AuditPage from "@/pages/admin/audit"
import SecurityPage from "@/pages/admin/security"
import FailedLoginsPage from "@/pages/admin/security/failed-logins"
import SuspiciousActivityPage from "@/pages/admin/security/suspicious"
import ApiKeysPage from "@/pages/admin/security/api-keys"
import RateLimitsPage from "@/pages/admin/security/rate-limits"
import SessionsPage from "@/pages/admin/security/sessions"
import AnalyticsPage from "@/pages/admin/analytics"
import AnalyticsOverviewPage from "@/pages/admin/analytics/overview"
import AnalyticsUsersPage from "@/pages/admin/analytics/users"
import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue"
import AnalyticsStoragePage from "@/pages/admin/analytics/storage"
import AnalyticsApiPage from "@/pages/admin/analytics/api"
import HealthPage from "@/pages/admin/health"
import NotificationsPage from "@/pages/notifications"
import { Navigate, Route, Routes } from "react-router-dom";
import { AppShell } from "@/layouts/app-shell";
import { ProtectedRoute } from "@/components/ProtectedRoute";
import LoginPage from "@/pages/login";
import DashboardPage from "@/pages/admin/dashboard";
import UsersPage from "@/pages/admin/users";
import UserDetailsPage from "@/pages/admin/users/[id]";
import UserActivityPage from "@/pages/admin/users/[id]/activity";
import ActivityLogPage from "@/pages/activity-log";
import SettingsPage from "@/pages/admin/settings";
import MaintenancePage from "@/pages/admin/maintenance";
import AnnouncementsPage from "@/pages/admin/announcements";
import AuditPage from "@/pages/admin/audit";
import SecurityPage from "@/pages/admin/security";
import FailedLoginsPage from "@/pages/admin/security/failed-logins";
import SuspiciousActivityPage from "@/pages/admin/security/suspicious";
import ApiKeysPage from "@/pages/admin/security/api-keys";
import RateLimitsPage from "@/pages/admin/security/rate-limits";
import SessionsPage from "@/pages/admin/security/sessions";
import AnalyticsPage from "@/pages/admin/analytics";
import AnalyticsOverviewPage from "@/pages/admin/analytics/overview";
import AnalyticsUsersPage from "@/pages/admin/analytics/users";
import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue";
import AnalyticsStoragePage from "@/pages/admin/analytics/storage";
import AnalyticsApiPage from "@/pages/admin/analytics/api";
import HealthPage from "@/pages/admin/health";
import NotificationsPage from "@/pages/notifications";
import PaymentsListPage from "@/pages/admin/payments/payments-list";
import PaymentRequestsPage from "@/pages/admin/payments/payment-requests";
import InvoicesPage from "@/pages/admin/invoices/invoices-list";
import ProformaPage from "@/pages/admin/invoices/proforma-list";
import ProformaRequestsPage from "@/pages/admin/invoices/proforma-requests";
function App() {
return (
@ -51,23 +56,49 @@ function App() {
<Route path="admin/announcements" element={<AnnouncementsPage />} />
<Route path="admin/audit" element={<AuditPage />} />
<Route path="admin/security" element={<SecurityPage />} />
<Route path="admin/security/failed-logins" element={<FailedLoginsPage />} />
<Route path="admin/security/suspicious" element={<SuspiciousActivityPage />} />
<Route
path="admin/security/failed-logins"
element={<FailedLoginsPage />}
/>
<Route
path="admin/security/suspicious"
element={<SuspiciousActivityPage />}
/>
<Route path="admin/security/api-keys" element={<ApiKeysPage />} />
<Route path="admin/security/rate-limits" element={<RateLimitsPage />} />
<Route path="admin/security/sessions" element={<SessionsPage />} />
<Route path="admin/analytics" element={<AnalyticsPage />} />
<Route path="admin/analytics/overview" element={<AnalyticsOverviewPage />} />
<Route
path="admin/analytics/overview"
element={<AnalyticsOverviewPage />}
/>
<Route path="admin/analytics/users" element={<AnalyticsUsersPage />} />
<Route path="admin/analytics/revenue" element={<AnalyticsRevenuePage />} />
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
<Route
path="admin/analytics/revenue"
element={<AnalyticsRevenuePage />}
/>
<Route
path="admin/analytics/storage"
element={<AnalyticsStoragePage />}
/>
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
<Route path="admin/payments" element={<PaymentsListPage />} />
<Route
path="admin/payment-requests"
element={<PaymentRequestsPage />}
/>
<Route path="admin/invoices" element={<InvoicesPage />} />
<Route path="admin/proforma" element={<ProformaPage />} />
<Route
path="admin/proforma-requests"
element={<ProformaRequestsPage />}
/>
<Route path="admin/health" element={<HealthPage />} />
<Route path="notifications" element={<NotificationsPage />} />
</Route>
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
</Routes>
)
);
}
export default App
export default App;

View File

@ -1,17 +1,17 @@
import { Navigate, useLocation } from "react-router-dom"
import { Navigate, useLocation } from "react-router-dom";
interface ProtectedRouteProps {
children: React.ReactNode
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const location = useLocation()
const token = localStorage.getItem('access_token')
// const location = useLocation()
// const token = localStorage.getItem('access_token')
if (!token) {
// Redirect to login page with return URL
return <Navigate to="/login" state={{ from: location }} replace />
}
// if (!token) {
// // Redirect to login page with return URL
// return <Navigate to="/login" state={{ from: location }} replace />
// }
return <>{children}</>
return <>{children}</>;
}

View File

@ -1,5 +1,5 @@
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom"
import { useState } from "react"
import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { useState } from "react";
import {
LayoutDashboard,
Users,
@ -14,10 +14,15 @@ import {
Search,
Bell,
LogOut,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
CreditCard,
FileClock,
Receipt,
FileSearch,
ClipboardList,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
@ -25,20 +30,33 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { authService } from "@/services"
import { toast } from "sonner"
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { authService } from "@/services";
import { toast } from "sonner";
interface User {
email: string
firstName?: string
lastName?: string
role: string
email: string;
firstName?: string;
lastName?: string;
role: string;
}
const adminNavigationItems = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
{ icon: Receipt, label: "Invoices", path: "/admin/invoices" },
{ icon: FileSearch, label: "Proforma", path: "/admin/proforma" },
{
icon: ClipboardList,
label: "Proforma Requests",
path: "/admin/proforma-requests",
},
{ icon: CreditCard, label: "Payments", path: "/admin/payments" },
{
icon: FileClock,
label: "Payment Requests",
path: "/admin/payment-requests",
},
{ icon: Users, label: "Users", path: "/admin/users" },
{ icon: FileText, label: "Logs", path: "/admin/logs" },
{ icon: Settings, label: "Settings", path: "/admin/settings" },
@ -48,81 +66,75 @@ const adminNavigationItems = [
{ icon: Shield, label: "Security", path: "/admin/security" },
{ icon: BarChart3, label: "Analytics", path: "/admin/analytics" },
{ icon: Heart, label: "System Health", path: "/admin/health" },
]
];
export function AppShell() {
const location = useLocation()
const navigate = useNavigate()
const [searchQuery, setSearchQuery] = useState("")
const location = useLocation();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
// Initialize user from localStorage
const [user] = useState<User | null>(() => {
const userStr = localStorage.getItem('user')
const userStr = localStorage.getItem("user");
if (userStr) {
try {
return JSON.parse(userStr)
return JSON.parse(userStr);
} catch (error) {
console.error('Failed to parse user data:', error)
return null
console.error("Failed to parse user data:", error);
return null;
}
}
return null
})
return null;
});
const isActive = (path: string) => {
return location.pathname.startsWith(path)
}
return location.pathname.startsWith(path);
};
const getPageTitle = () => {
const currentPath = location.pathname
const item = adminNavigationItems.find((item) =>
currentPath.startsWith(item.path)
)
return item?.label || "Admin Panel"
}
// Removed unused getPageTitle as header title is no longer displayed
const handleLogout = async () => {
await authService.logout()
navigate('/login', { replace: true })
}
await authService.logout();
navigate("/login", { replace: true });
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (searchQuery.trim()) {
const currentPath = location.pathname
navigate(`${currentPath}?search=${encodeURIComponent(searchQuery)}`)
toast.success(`Searching for: ${searchQuery}`)
const currentPath = location.pathname;
navigate(`${currentPath}?search=${encodeURIComponent(searchQuery)}`);
toast.success(`Searching for: ${searchQuery}`);
}
}
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value)
}
setSearchQuery(e.target.value);
};
const handleNotificationClick = () => {
navigate('/notifications')
}
navigate("/notifications");
};
const handleProfileClick = () => {
navigate('/admin/settings')
}
navigate("/admin/settings");
};
const getUserInitials = () => {
if (user?.firstName && user?.lastName) {
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
}
if (user?.email) {
return user.email.substring(0, 2).toUpperCase()
return user.email.substring(0, 2).toUpperCase();
}
return 'AD'
}
return "AD";
};
const getUserDisplayName = () => {
if (user?.firstName && user?.lastName) {
return `${user.firstName} ${user.lastName}`
return `${user.firstName} ${user.lastName}`;
}
return user?.email || 'Admin User'
}
return user?.email || "Admin User";
};
return (
<div className="flex h-screen bg-background">
@ -133,13 +145,15 @@ export function AppShell() {
<div className="w-10 h-10 bg-primary rounded flex items-center justify-center">
<span className="text-primary-foreground font-bold text-lg">A</span>
</div>
<span className="text-foreground font-semibold text-lg">Admin Panel</span>
<span className="text-foreground font-semibold text-lg">
Admin Panel
</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
{adminNavigationItems.map((item) => {
const Icon = item.icon
const Icon = item.icon;
return (
<Link
key={item.path}
@ -148,13 +162,13 @@ export function AppShell() {
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
isActive(item.path)
? "bg-primary text-primary-foreground"
: "text-foreground/70 hover:bg-accent hover:text-foreground"
: "text-foreground/70 hover:bg-accent hover:text-foreground",
)}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
)
);
})}
</nav>
@ -165,8 +179,12 @@ export function AppShell() {
<AvatarFallback>{getUserInitials()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{getUserDisplayName()}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email || 'admin@example.com'}</p>
<p className="text-sm font-medium truncate">
{getUserDisplayName()}
</p>
<p className="text-xs text-muted-foreground truncate">
{user?.email || "admin@example.com"}
</p>
</div>
</div>
<Button
@ -185,7 +203,7 @@ export function AppShell() {
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header */}
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
<div className="flex-1" />
<div className="flex items-center gap-4">
<form onSubmit={handleSearch} className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
@ -196,7 +214,12 @@ export function AppShell() {
onChange={handleSearchChange}
/>
</form>
<Button variant="ghost" size="icon" className="relative" onClick={handleNotificationClick}>
<Button
variant="ghost"
size="icon"
className="relative"
onClick={handleNotificationClick}
>
<Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full pointer-events-none" />
</Button>
@ -211,8 +234,12 @@ export function AppShell() {
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{getUserDisplayName()}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
<p className="text-sm font-medium">
{getUserDisplayName()}
</p>
<p className="text-xs text-muted-foreground">
{user?.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
@ -220,7 +247,7 @@ export function AppShell() {
<Settings className="w-4 h-4 mr-2" />
Profile Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate('/notifications')}>
<DropdownMenuItem onClick={() => navigate("/notifications")}>
<Bell className="w-4 h-4 mr-2" />
Notifications
</DropdownMenuItem>
@ -240,5 +267,5 @@ export function AppShell() {
</main>
</div>
</div>
)
);
}

View File

@ -1,195 +1,194 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Search, Download, Eye, ChevronLeft, ChevronRight } from "lucide-react"
import { auditService, type AuditLog } from "@/services"
import { format } from "date-fns"
Search,
ChevronLeft,
ChevronRight,
Filter,
Terminal,
} from "lucide-react";
import { auditService, type AuditLog } from "@/services";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
export default function ActivityLogPage() {
const [page, setPage] = useState(1)
const [limit] = useState(20)
const [search, setSearch] = useState("")
const [actionFilter, setActionFilter] = useState("")
const [resourceTypeFilter, setResourceTypeFilter] = useState("")
const [page, setPage] = useState(1);
const [limit] = useState(15);
const [search, setSearch] = useState("");
const { data: auditData, isLoading } = useQuery({
queryKey: ['activity-log', page, limit, search, actionFilter, resourceTypeFilter],
queryKey: ["activity-log", page, limit, search],
queryFn: async () => {
const params: Record<string, string | number> = { page, limit }
if (search) params.search = search
if (actionFilter) params.action = actionFilter
if (resourceTypeFilter) params.resourceType = resourceTypeFilter
return await auditService.getAuditLogs(params)
const params: Record<string, string | number> = { page, limit };
if (search) params.search = search;
return await auditService.getAuditLogs(params);
},
})
});
const handleExport = async () => {
try {
const blob = await auditService.exportAuditLogs({ format: 'csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `activity-log-${format(new Date(), 'yyyy-MM-dd')}.csv`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Export failed:', error)
}
}
const getActionBadgeColor = (action: string) => {
const colors: Record<string, string> = {
Create: "bg-blue-500",
Update: "bg-green-500",
Delete: "bg-red-500",
Login: "bg-purple-500",
Logout: "bg-gray-500",
}
return colors[action] || "bg-gray-500"
}
const getActionColor = (action: string) => {
const act = action.toUpperCase();
if (act.includes("CREATE"))
return "text-blue-600 bg-blue-50 border-blue-100";
if (act.includes("UPDATE"))
return "text-emerald-600 bg-emerald-50 border-emerald-100";
if (act.includes("DELETE"))
return "text-rose-600 bg-rose-50 border-rose-100";
if (act.includes("LOGIN"))
return "text-purple-600 bg-purple-50 border-purple-100";
return "text-slate-600 bg-slate-50 border-slate-100";
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Activity Log</h2>
<Button onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export Log
</Button>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Activity Log
</h1>
<p className="text-gray-500 mt-1">
Audit trail of all administrative actions.
</p>
</div>
<div className="flex items-center gap-2">
{/* View only access: Export button removed */}
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>All Activities</CardTitle>
<div className="flex items-center gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search activity..."
className="pl-10 w-64"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="px-3 py-2 border rounded-md text-sm"
value={actionFilter}
onChange={(e) => setActionFilter(e.target.value)}
>
<option value="">All Actions</option>
<option value="Create">Create</option>
<option value="Update">Update</option>
<option value="Delete">Delete</option>
<option value="Login">Login</option>
<option value="Logout">Logout</option>
</select>
<select
className="px-3 py-2 border rounded-md text-sm"
value={resourceTypeFilter}
onChange={(e) => setResourceTypeFilter(e.target.value)}
>
<option value="">All Resources</option>
<option value="Client">Client</option>
<option value="Subscription">Subscription</option>
<option value="User">User</option>
<option value="System">System</option>
</select>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
System Audit
</CardTitle>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search activity or user..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading activity logs...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Log ID</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Resource ID</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Timestamp</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditData?.data?.map((log: AuditLog) => (
<TableRow key={log.id}>
<TableCell className="font-medium">{log.id}</TableCell>
<TableCell>{log.userId || 'N/A'}</TableCell>
<TableCell>
<Badge className={getActionBadgeColor(log.action)}>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Action
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
User ID
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Resource
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
IP Address
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Timestamp
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Synchronizing audit records...
</td>
</tr>
) : auditData?.data && auditData.data.length > 0 ? (
auditData.data.map((log: AuditLog) => (
<tr
key={log.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
getActionColor(log.action),
)}
>
{log.action}
</Badge>
</TableCell>
<TableCell>{log.resourceType}</TableCell>
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
<TableCell>
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm:ss')}
</TableCell>
<TableCell>
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{auditData?.data?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No activity logs found
</div>
)}
{auditData && auditData.totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Page {auditData.page} of {auditData.totalPages} ({auditData.total} total)
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
</span>
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
{log.userId || "SYSTEM"}
</td>
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase flex items-center gap-1.5 mt-4">
<Terminal className="w-3 h-3" /> {log.resourceType}:{" "}
{log.resourceId.substring(0, 8)}...
</td>
<td className="px-6 py-4 text-xs font-mono text-gray-500">
{log.ipAddress || "--"}
</td>
<td className="px-6 py-4 text-right text-xs text-gray-500">
{format(new Date(log.timestamp), "MMM dd, HH:mm:ss")}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic"
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.min(auditData.totalPages, p + 1))}
disabled={page === auditData.totalPages}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
No activity logs recorded.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{auditData && auditData.totalPages > 1 && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Page {auditData.page} of {auditData.totalPages} ({auditData.total}{" "}
records)
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() =>
setPage((p) => Math.min(auditData.totalPages, p + 1))
}
disabled={page === auditData.totalPages}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
)
);
}

View File

@ -1,109 +1,154 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { analyticsService } from "@/services"
import type { ApiUsageData } from "@/types/analytics.types"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { analyticsService } from "@/services";
import type { ApiUsageData } from "@/types/analytics.types";
import { Activity, Zap, AlertCircle, Clock, Terminal } from "lucide-react";
export default function AnalyticsApiPage() {
const { data: apiUsage, isLoading } = useQuery({
queryKey: ['admin', 'analytics', 'api-usage'],
queryKey: ["admin", "analytics", "api-usage"],
queryFn: () => analyticsService.getApiUsage(7),
})
});
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
queryKey: ['admin', 'analytics', 'error-rate'],
queryKey: ["admin", "analytics", "error-rate"],
queryFn: () => analyticsService.getErrorRate(7),
})
});
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">API Usage Analytics</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Total API Calls</CardTitle>
</CardHeader>
<CardContent>
{errorRateLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<div className="text-2xl font-bold">{errorRate?.total || 0}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Errors</CardTitle>
</CardHeader>
<CardContent>
{errorRateLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<div className="text-2xl font-bold">{errorRate?.errors || 0}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Error Rate</CardTitle>
</CardHeader>
<CardContent>
{errorRateLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<div className="text-2xl font-bold">
{errorRate?.errorRate ? `${errorRate.errorRate.toFixed(2)}%` : '0%'}
</div>
)}
</CardContent>
</Card>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
API Performance
</h1>
<p className="text-gray-500 mt-1">
Operational metrics for system integration and service health.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Endpoint Usage (Last 7 Days)</CardTitle>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{
label: "Total Ingress",
value: errorRate?.total || 0,
icon: Zap,
color: "text-blue-600 bg-blue-50 border-blue-100",
},
{
label: "Transit Errors",
value: errorRate?.errors || 0,
icon: AlertCircle,
color: "text-rose-600 bg-rose-50 border-rose-100",
},
{
label: "Failure Rate",
value: errorRate?.errorRate
? `${errorRate.errorRate.toFixed(2)}%`
: "0.00%",
icon: Activity,
color: "text-amber-600 bg-amber-50 border-amber-100",
},
].map((metric) => (
<Card
key={metric.label}
className="border shadow-none rounded-none bg-white"
>
<CardHeader className="pb-2 space-y-0">
<CardTitle className="text-[10px] font-bold uppercase tracking-widest text-gray-400 flex items-center justify-between">
{metric.label}
<metric.icon className="w-3 h-3" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-gray-900 tracking-tighter">
{errorRateLoading ? "..." : metric.value}
</span>
</div>
</CardContent>
</Card>
))}
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Endpoint Performance Ledger
</CardTitle>
<Terminal className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading API usage...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead>Calls</TableHead>
<TableHead>Avg Duration (ms)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiUsage?.map((endpoint: ApiUsageData, index: number) => (
<TableRow key={index}>
<TableCell className="font-mono text-sm">{endpoint.date}</TableCell>
<TableCell>{endpoint.requests}</TableCell>
<TableCell>{endpoint.avgResponseTime?.toFixed(2) || 'N/A'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{apiUsage?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No API usage data available
</div>
)}
</>
)}
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Temporal Reference
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
Transaction Volume
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Avg Latency (ms)
</th>
</tr>
</thead>
<tbody className="divide-y text-gray-600">
{isLoading ? (
<tr>
<td
colSpan={3}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Synchronizing performance data...
</td>
</tr>
) : apiUsage && apiUsage.length > 0 ? (
apiUsage.map((endpoint: ApiUsageData, index: number) => (
<tr
key={index}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Clock className="w-3 h-3 text-gray-300" />
<span className="text-xs font-mono font-medium text-gray-900">
{endpoint.date}
</span>
</div>
</td>
<td className="px-6 py-4 text-center">
<span className="text-sm font-bold text-gray-900 tracking-tighter">
{endpoint.requests.toLocaleString()}
</span>
</td>
<td className="px-6 py-4 text-right">
<span className="text-xs font-medium tabular-nums text-gray-500">
{endpoint.avgResponseTime?.toFixed(2) || "0.00"}{" "}
<span className="text-[10px] text-gray-400 uppercase ml-1">
MS
</span>
</span>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={3}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
>
No transaction history available.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
);
}

View File

@ -1,86 +1,89 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { BarChart3, Users, DollarSign, HardDrive, Activity } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
BarChart3,
Users,
DollarSign,
HardDrive,
Activity,
ChevronRight,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils";
export default function AnalyticsPage() {
const navigate = useNavigate()
const navigate = useNavigate();
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Analytics</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/overview')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
Overview
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Platform analytics overview
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/users')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
Users Analytics
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
User growth and statistics
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/revenue')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5" />
Revenue Analytics
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Revenue trends and breakdown
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/storage')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive className="w-5 h-5" />
Storage Analytics
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Storage usage and breakdown
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/analytics/api')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="w-5 h-5" />
API Usage
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
API endpoint usage statistics
</p>
</CardContent>
</Card>
{[
{
label: "Performance Overview",
description: "Platform analytics overview",
icon: BarChart3,
path: "/admin/analytics/overview",
color: "text-blue-600",
},
{
label: "User Dynamics",
description: "User growth and statistics",
icon: Users,
path: "/admin/analytics/users",
color: "text-purple-600",
},
{
label: "Revenue Streams",
description: "Revenue trends and breakdown",
icon: DollarSign,
path: "/admin/analytics/revenue",
color: "text-emerald-600",
},
{
label: "Resource Allocation",
description: "Storage usage and breakdown",
icon: HardDrive,
path: "/admin/analytics/storage",
color: "text-amber-600",
},
{
label: "API Operations",
description: "API endpoint usage statistics",
icon: Activity,
path: "/admin/analytics/api",
color: "text-rose-600",
},
].map((item) => (
<Card
key={item.label}
className="group cursor-pointer border-slate-200/60 shadow-sm hover:shadow-md transition-all rounded-none bg-white overflow-hidden"
onClick={() => navigate(item.path)}
>
<CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
{item.label}
</span>
<item.icon
className={cn("w-4 h-4 transition-colors", item.color)}
/>
</div>
</CardHeader>
<CardContent className="pt-4 flex items-end justify-between">
<div>
<p className="text-sm font-semibold text-slate-900 tracking-tight mb-1 group-hover:text-primary transition-colors">
{item.label}
</p>
<p className="text-xs text-muted-foreground leading-relaxed max-w-[200px]">
{item.description}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:translate-x-1 transition-transform" />
</CardContent>
</Card>
))}
</div>
</div>
)
);
}

View File

@ -1,44 +1,211 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
import { analyticsService } from "@/services"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { analyticsService } from "@/services";
import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types";
import {
HardDrive,
FileText,
Database,
Users,
ChevronRight,
} from "lucide-react";
export default function AnalyticsRevenuePage() {
const { data: revenue, isLoading } = useQuery({
queryKey: ['admin', 'analytics', 'revenue'],
queryFn: () => analyticsService.getRevenue('90days'),
})
const COLORS = ["#111827", "#4B5563", "#9CA3AF", "#D1D5DB", "#E2E8F0"];
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Revenue Analytics</h2>
<Card>
<CardHeader>
<CardTitle>Revenue Trends (Last 90 Days)</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-[400px] flex items-center justify-center">Loading...</div>
) : revenue && revenue.length > 0 ? (
<ResponsiveContainer width="100%" height={400}>
<BarChart data={revenue}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="revenue" fill="#8884d8" name="Revenue" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[400px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</CardContent>
</Card>
</div>
)
interface ChartDataItem {
name: string;
value: number;
}
export default function AnalyticsStoragePage() {
const { data: storage, isLoading } = useQuery<StorageAnalytics>({
queryKey: ["admin", "analytics", "storage"],
queryFn: () => analyticsService.getStorageAnalytics(),
});
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
const chartData: ChartDataItem[] =
storage?.byCategory?.map((cat) => ({
name: cat.category,
value: cat.size,
})) || [];
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Storage Intelligence
</h1>
<p className="text-gray-500 mt-1">
Infrastructure resource allocation and data distribution registry.
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="border shadow-none rounded-none bg-white">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Resource Consumption
</CardTitle>
<HardDrive className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent className="p-8 space-y-8">
{isLoading ? (
<div className="h-[300px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Quantifying resources...
</div>
) : (
<>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-1">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Aggregate Payload
</p>
<p className="text-3xl font-bold text-gray-900 tracking-tighter">
{storage?.total ? formatBytes(storage.total.size) : "0 B"}
</p>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Object Registry
</p>
<p className="text-3xl font-bold text-gray-900 tracking-tighter">
{storage?.total?.files?.toLocaleString() || 0}{" "}
<span className="text-xs text-gray-400 uppercase ml-1">
Items
</span>
</p>
</div>
</div>
<div className="pt-8 border-t">
<div className="flex items-center justify-between mb-4">
<span className="text-[10px] font-bold text-gray-900 uppercase tracking-widest">
Efficiency Status
</span>
<span className="px-2 py-0.5 text-[9px] font-bold uppercase bg-emerald-50 text-emerald-600 border border-emerald-100">
Optimal
</span>
</div>
<div className="w-full bg-gray-100 h-2">
<div className="bg-gray-900 h-full w-[42%]" />
</div>
<p className="text-[10px] text-gray-400 font-medium mt-2">
42% Cluster Capacity Utilized
</p>
</div>
</>
)}
</CardContent>
</Card>
<Card className="border shadow-none rounded-none bg-white">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Distribution by Taxonomy
</CardTitle>
<Database className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent className="p-8">
{isLoading ? (
<div className="h-[300px] flex items-center justify-center">
...
</div>
) : chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={4}
stroke="none"
dataKey="value"
>
{chartData.map((_entry: ChartDataItem, index: number) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "#fff",
border: "1px solid #E2E8F0",
borderRadius: "0px",
boxShadow: "none",
fontSize: "10px",
fontWeight: "700",
textTransform: "uppercase",
}}
formatter={(value: number) => formatBytes(value)}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
No category distribution.
</div>
)}
</CardContent>
</Card>
</div>
{storage?.topUsers && storage.topUsers.length > 0 && (
<Card className="border shadow-none rounded-none bg-white">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-gray-400" />
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
High-Consumption Operators
</CardTitle>
</div>
<FileText className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent className="p-0">
<div className="divide-y divide-gray-100">
{storage.topUsers.map((user: StorageByUser, index: number) => (
<div
key={index}
className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-8 h-8 flex items-center justify-center bg-gray-50 border text-[10px] font-bold text-gray-400">
0{index + 1}
</div>
<div>
<p className="text-sm font-bold text-gray-900 tracking-tighter">
{user.userName || user.email}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">
{user.documentCount.toLocaleString()} Object References
</p>
</div>
</div>
<div className="flex items-center gap-8">
<span className="text-sm font-bold text-gray-900 tabular-nums">
{formatBytes(user.storageUsed)}
</span>
<ChevronRight className="w-4 h-4 text-gray-300 opacity-0 group-hover:opacity-100 transition-all" />
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -1,71 +1,125 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts"
import { analyticsService } from "@/services"
import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { analyticsService } from "@/services";
import type { StorageByUser, StorageAnalytics } from "@/types/analytics.types";
import {
HardDrive,
FileText,
Database,
Users,
ChevronRight,
} from "lucide-react";
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
const COLORS = ["#111827", "#4B5563", "#9CA3AF", "#D1D5DB", "#E2E8F0"];
interface ChartDataItem {
name: string
value: number
name: string;
value: number;
}
export default function AnalyticsStoragePage() {
const { data: storage, isLoading } = useQuery<StorageAnalytics>({
queryKey: ['admin', 'analytics', 'storage'],
queryKey: ["admin", "analytics", "storage"],
queryFn: () => analyticsService.getStorageAnalytics(),
})
});
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
const chartData: ChartDataItem[] = storage?.byCategory?.map((cat) => ({
name: cat.category,
value: cat.size,
})) || []
const chartData: ChartDataItem[] =
storage?.byCategory?.map((cat) => ({
name: cat.category,
value: cat.size,
})) || [];
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Storage Analytics</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Storage Intelligence
</h1>
<p className="text-gray-500 mt-1">
Infrastructure resource allocation and data distribution registry.
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Storage Overview</CardTitle>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="border shadow-none rounded-none bg-white">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Resource Consumption
</CardTitle>
<HardDrive className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent>
<CardContent className="p-8 space-y-8">
{isLoading ? (
<div className="h-[300px] flex items-center justify-center">Loading...</div>
<div className="h-[300px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Quantifying resources...
</div>
) : (
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground">Total Storage</p>
<p className="text-2xl font-bold">
{storage?.total ? formatBytes(storage.total.size) : '0 Bytes'}
<>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-1">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Aggregate Payload
</p>
<p className="text-3xl font-bold text-gray-900 tracking-tighter">
{storage?.total ? formatBytes(storage.total.size) : "0 B"}
</p>
</div>
<div className="space-y-1">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Object Registry
</p>
<p className="text-3xl font-bold text-gray-900 tracking-tighter">
{storage?.total?.files?.toLocaleString() || 0}{" "}
<span className="text-xs text-gray-400 uppercase ml-1">
Items
</span>
</p>
</div>
</div>
<div className="pt-8 border-t">
<div className="flex items-center justify-between mb-4">
<span className="text-[10px] font-bold text-gray-900 uppercase tracking-widest">
Efficiency Status
</span>
<span className="px-2 py-0.5 text-[9px] font-bold uppercase bg-emerald-50 text-emerald-600 border border-emerald-100">
Optimal
</span>
</div>
<div className="w-full bg-gray-100 h-2">
<div className="bg-gray-900 h-full w-[42%]" />
</div>
<p className="text-[10px] text-gray-400 font-medium mt-2">
42% Cluster Capacity Utilized
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Files</p>
<p className="text-2xl font-bold">{storage?.total?.files || 0}</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Storage by Category</CardTitle>
<Card className="border shadow-none rounded-none bg-white">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Distribution by Taxonomy
</CardTitle>
<Database className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent>
<CardContent className="p-8">
{isLoading ? (
<div className="h-[300px] flex items-center justify-center">Loading...</div>
<div className="h-[300px] flex items-center justify-center">
...
</div>
) : chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
@ -73,23 +127,36 @@ export default function AnalyticsStoragePage() {
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
innerRadius={60}
outerRadius={100}
paddingAngle={4}
stroke="none"
dataKey="value"
>
{chartData.map((_entry: ChartDataItem, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip />
<Legend />
<Tooltip
contentStyle={{
backgroundColor: "#fff",
border: "1px solid #E2E8F0",
borderRadius: "0px",
boxShadow: "none",
fontSize: "10px",
fontWeight: "700",
textTransform: "uppercase",
}}
formatter={(value: number) => formatBytes(value)}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[300px] flex items-center justify-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
No category distribution.
</div>
)}
</CardContent>
@ -97,19 +164,42 @@ export default function AnalyticsStoragePage() {
</div>
{storage?.topUsers && storage.topUsers.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Top 10 Users by Storage Usage</CardTitle>
<Card className="border shadow-none rounded-none bg-white">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-gray-400" />
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
High-Consumption Operators
</CardTitle>
</div>
<FileText className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<CardContent className="p-0">
<div className="divide-y divide-gray-100">
{storage.topUsers.map((user: StorageByUser, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded">
<div>
<p className="font-medium">{user.userName || user.email}</p>
<p className="text-sm text-muted-foreground">{user.documentCount} files</p>
<div
key={index}
className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-8 h-8 flex items-center justify-center bg-gray-50 border text-[10px] font-bold text-gray-400">
0{index + 1}
</div>
<div>
<p className="text-sm font-bold text-gray-900 tracking-tighter">
{user.userName || user.email}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">
{user.documentCount.toLocaleString()} Object References
</p>
</div>
</div>
<div className="flex items-center gap-8">
<span className="text-sm font-bold text-gray-900 tabular-nums">
{formatBytes(user.storageUsed)}
</span>
<ChevronRight className="w-4 h-4 text-gray-300 opacity-0 group-hover:opacity-100 transition-all" />
</div>
<p className="font-medium">{formatBytes(user.storageUsed)}</p>
</div>
))}
</div>
@ -117,6 +207,5 @@ export default function AnalyticsStoragePage() {
</Card>
)}
</div>
)
);
}

View File

@ -1,46 +1,156 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
import { analyticsService } from "@/services"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { analyticsService } from "@/services";
import { Users, TrendingUp, UserCheck, ShieldCheck } from "lucide-react";
export default function AnalyticsUsersPage() {
const { data: userGrowth, isLoading } = useQuery({
queryKey: ['admin', 'analytics', 'users', 'growth'],
queryKey: ["admin", "analytics", "users", "growth"],
queryFn: () => analyticsService.getUserGrowth(90),
})
});
// Calculate some dummy metrics based on the last data point if available
const lastMetrics = userGrowth?.[userGrowth.length - 1];
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">User Analytics</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
User Intelligence
</h1>
<p className="text-gray-500 mt-1">
Growth trajectories and demographic distribution patterns.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>User Growth (Last 90 Days)</CardTitle>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{
label: "Total Population",
value: lastMetrics?.total || 0,
icon: Users,
color: "text-blue-600 bg-blue-50 border-blue-100",
},
{
label: "Privileged Access",
value: lastMetrics?.admins || 0,
icon: ShieldCheck,
color: "text-emerald-600 bg-emerald-50 border-emerald-100",
},
{
label: "Standard Users",
value: lastMetrics?.regular || 0,
icon: UserCheck,
color: "text-purple-600 bg-purple-50 border-purple-100",
},
].map((metric) => (
<Card
key={metric.label}
className="border shadow-none rounded-none bg-white"
>
<CardHeader className="pb-2 space-y-0">
<CardTitle className="text-[10px] font-bold uppercase tracking-widest text-gray-400 flex items-center justify-between">
{metric.label}
<metric.icon className="w-3 h-3" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<span className="text-2xl font-bold text-gray-900 tracking-tighter">
{isLoading
? "..."
: (metric.value as number).toLocaleString()}
</span>
</div>
</CardContent>
</Card>
))}
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Growth Trajectory (Last 90 Days)
</CardTitle>
<TrendingUp className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent>
<CardContent className="p-8">
{isLoading ? (
<div className="h-[400px] flex items-center justify-center">Loading...</div>
<div className="h-[400px] flex items-center justify-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Visualizing temporal growth...
</div>
) : userGrowth && userGrowth.length > 0 ? (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={userGrowth}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="total" stroke="#8884d8" name="Total Users" />
<Line type="monotone" dataKey="admins" stroke="#82ca9d" name="Admins" />
<Line type="monotone" dataKey="regular" stroke="#ffc658" name="Regular Users" />
<LineChart
data={userGrowth}
margin={{ top: 5, right: 30, left: 10, bottom: 5 }}
>
<CartesianGrid
strokeDasharray="1 4"
vertical={false}
stroke="#E2E8F0"
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fontWeight: 700, fill: "#94A3B8" }}
dy={10}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fontWeight: 700, fill: "#94A3B8" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#fff",
border: "1px solid #E2E8F0",
borderRadius: "0px",
boxShadow: "none",
fontSize: "10px",
fontWeight: "700",
textTransform: "uppercase",
}}
/>
<Line
type="monotone"
dataKey="total"
stroke="#111827"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "#111827", strokeWidth: 0 }}
name="Aggregate Population"
/>
<Line
type="monotone"
dataKey="admins"
stroke="#10B981"
strokeWidth={1.5}
strokeDasharray="4 4"
dot={false}
name="Privileged"
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[400px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[400px] flex items-center justify-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
Incomplete trajectory data.
</div>
)}
</CardContent>
</Card>
</div>
)
);
}

View File

@ -1,16 +1,7 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@ -18,329 +9,436 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Plus, Edit, Trash2 } from "lucide-react"
import { announcementService, type Announcement, type CreateAnnouncementData } from "@/services"
import { toast } from "sonner"
import { format } from "date-fns"
import type { ApiError } from "@/types/error.types"
} from "@/components/ui/dialog";
import { Megaphone, Plus, Edit, Trash2, Filter } from "lucide-react";
import {
announcementService,
type Announcement,
type CreateAnnouncementData,
} from "@/services";
import { toast } from "sonner";
import { format } from "date-fns";
import type { ApiError } from "@/types/error.types";
import { cn } from "@/lib/utils";
export default function AnnouncementsPage() {
const queryClient = useQueryClient()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [formDialogOpen, setFormDialogOpen] = useState(false)
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null)
const queryClient = useQueryClient();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [formDialogOpen, setFormDialogOpen] = useState(false);
const [selectedAnnouncement, setSelectedAnnouncement] =
useState<Announcement | null>(null);
const [formData, setFormData] = useState<CreateAnnouncementData>({
title: '',
message: '',
type: 'info' as 'info' | 'warning' | 'success' | 'error',
title: "",
message: "",
type: "info" as "info" | "warning" | "success" | "error",
priority: 0,
targetAudience: 'all',
startsAt: '',
endsAt: '',
})
targetAudience: "all",
startsAt: "",
endsAt: "",
});
const { data: announcements, isLoading } = useQuery({
queryKey: ['admin', 'announcements'],
queryKey: ["admin", "announcements"],
queryFn: () => announcementService.getAnnouncements(false),
})
});
const createMutation = useMutation({
mutationFn: (data: CreateAnnouncementData) => announcementService.createAnnouncement(data),
mutationFn: (data: CreateAnnouncementData) =>
announcementService.createAnnouncement(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
toast.success("Announcement created successfully")
setFormDialogOpen(false)
resetForm()
queryClient.invalidateQueries({ queryKey: ["admin", "announcements"] });
toast.success("Announcement created successfully");
setFormDialogOpen(false);
resetForm();
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to create announcement")
const apiError = error as ApiError;
toast.error(
apiError.response?.data?.message || "Failed to create announcement",
);
},
})
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: CreateAnnouncementData }) =>
announcementService.updateAnnouncement(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
toast.success("Announcement updated successfully")
setFormDialogOpen(false)
resetForm()
queryClient.invalidateQueries({ queryKey: ["admin", "announcements"] });
toast.success("Announcement updated successfully");
setFormDialogOpen(false);
resetForm();
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to update announcement")
const apiError = error as ApiError;
toast.error(
apiError.response?.data?.message || "Failed to update announcement",
);
},
})
});
const deleteMutation = useMutation({
mutationFn: (id: string) => announcementService.deleteAnnouncement(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
toast.success("Announcement deleted successfully")
setDeleteDialogOpen(false)
queryClient.invalidateQueries({ queryKey: ["admin", "announcements"] });
toast.success("Announcement deleted successfully");
setDeleteDialogOpen(false);
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to delete announcement")
const apiError = error as ApiError;
toast.error(
apiError.response?.data?.message || "Failed to delete announcement",
);
},
})
});
const resetForm = () => {
setFormData({
title: '',
message: '',
type: 'info',
title: "",
message: "",
type: "info",
priority: 0,
targetAudience: 'all',
startsAt: '',
endsAt: '',
})
setSelectedAnnouncement(null)
}
targetAudience: "all",
startsAt: "",
endsAt: "",
});
setSelectedAnnouncement(null);
};
const handleOpenCreateDialog = () => {
resetForm()
setFormDialogOpen(true)
}
resetForm();
setFormDialogOpen(true);
};
const handleOpenEditDialog = (announcement: Announcement) => {
setSelectedAnnouncement(announcement)
setSelectedAnnouncement(announcement);
setFormData({
title: announcement.title || '',
message: announcement.message || '',
type: announcement.type || 'info',
title: announcement.title || "",
message: announcement.message || "",
type: announcement.type || "info",
priority: announcement.priority || 0,
targetAudience: announcement.targetAudience || 'all',
startsAt: announcement.startsAt ? announcement.startsAt.split('T')[0] : '',
endsAt: announcement.endsAt ? announcement.endsAt.split('T')[0] : '',
})
setFormDialogOpen(true)
}
targetAudience: announcement.targetAudience || "all",
startsAt: announcement.startsAt
? announcement.startsAt.split("T")[0]
: "",
endsAt: announcement.endsAt ? announcement.endsAt.split("T")[0] : "",
});
setFormDialogOpen(true);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!formData.title || !formData.message) {
toast.error("Title and message are required")
return
toast.error("Title and message are required");
return;
}
const submitData = {
...formData,
startsAt: formData.startsAt || undefined,
endsAt: formData.endsAt || undefined,
}
};
if (selectedAnnouncement) {
updateMutation.mutate({ id: selectedAnnouncement.id, data: submitData })
updateMutation.mutate({ id: selectedAnnouncement.id, data: submitData });
} else {
createMutation.mutate(submitData)
createMutation.mutate(submitData);
}
}
};
const handleDelete = () => {
if (selectedAnnouncement) {
deleteMutation.mutate(selectedAnnouncement.id)
deleteMutation.mutate(selectedAnnouncement.id);
}
}
};
const getBadgeStyle = (type: string) => {
switch (type) {
case "warning":
return "bg-amber-50 text-amber-600 border-amber-100";
case "error":
return "bg-rose-50 text-rose-600 border-rose-100";
case "success":
return "bg-emerald-50 text-emerald-600 border-emerald-100";
default:
return "bg-blue-50 text-blue-600 border-blue-100";
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Announcements</h2>
<Button onClick={handleOpenCreateDialog}>
<Plus className="w-4 h-4 mr-2" />
Create Announcement
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Announcements
</h1>
<p className="text-gray-500 mt-1">
Broadcast important information to users.
</p>
</div>
<Button
onClick={handleOpenCreateDialog}
className="rounded-none font-bold uppercase tracking-widest text-[10px]"
>
<Plus className="w-4 h-4 mr-2" /> Create Announcement
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>All Announcements</CardTitle>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Bulletin Archive
</CardTitle>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading announcements...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Type</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Status</TableHead>
<TableHead>Start Date</TableHead>
<TableHead>End Date</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{announcements?.map((announcement: Announcement) => (
<TableRow key={announcement.id}>
<TableCell className="font-medium">{announcement.title}</TableCell>
<TableCell>
<Badge>{announcement.type || 'info'}</Badge>
</TableCell>
<TableCell>{announcement.priority || 0}</TableCell>
<TableCell>
<Badge variant={announcement.isActive ? 'default' : 'secondary'}>
{announcement.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
{announcement.startsAt ? format(new Date(announcement.startsAt), 'MMM dd, yyyy') : 'N/A'}
</TableCell>
<TableCell>
{announcement.endsAt ? format(new Date(announcement.endsAt), 'MMM dd, yyyy') : 'N/A'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Title
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Status
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Type
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Scheduled
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Synchronizing broadcast data...
</td>
</tr>
) : announcements && announcements.length > 0 ? (
announcements.map((announcement: Announcement) => (
<tr
key={announcement.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4 max-w-sm">
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900 truncate">
{announcement.title}
</span>
<span className="text-[10px] text-gray-400 truncate">
{announcement.message}
</span>
</div>
</td>
<td className="px-6 py-4">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
announcement.isActive
? "bg-emerald-50 text-emerald-600 border-emerald-100"
: "bg-slate-50 text-slate-600 border-slate-100",
)}
>
{announcement.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-6 py-4">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border capitalize",
getBadgeStyle(announcement.type || "info"),
)}
>
{announcement.type || "info"}
</span>
</td>
<td className="px-6 py-4">
<div className="flex flex-col text-[10px] text-gray-400">
<span>
{announcement.startsAt
? format(
new Date(announcement.startsAt),
"MMM dd",
)
: "--"}
</span>
<span>
{" "}
{announcement.endsAt
? format(new Date(announcement.endsAt), "MMM dd")
: "--"}
</span>
</div>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => handleOpenEditDialog(announcement)}
>
<Edit className="w-4 h-4" />
<Edit className="w-3.5 h-3.5 text-gray-400" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => {
setSelectedAnnouncement(announcement)
setDeleteDialogOpen(true)
setSelectedAnnouncement(announcement);
setDeleteDialogOpen(true);
}}
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-3.5 h-3.5 text-gray-400" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{announcements?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No announcements found
</div>
)}
</>
)}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
>
No active broadcasts.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Create/Edit Dialog */}
{/* Form Dialog */}
<Dialog open={formDialogOpen} onOpenChange={setFormDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogContent className="max-w-xl rounded-none border border-gray-200">
<DialogHeader>
<DialogTitle>
{selectedAnnouncement ? 'Edit Announcement' : 'Create Announcement'}
<DialogTitle className="text-sm font-bold uppercase tracking-widest text-gray-900 border-b pb-2">
{selectedAnnouncement ? "Edit Broadcast" : "New Broadcast"}
</DialogTitle>
<DialogDescription>
{selectedAnnouncement
? 'Update the announcement details below.'
: 'Fill in the details to create a new announcement.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Title *</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-md"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Message *</label>
<textarea
className="w-full px-3 py-2 border rounded-md min-h-[100px]"
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Type</label>
<select
className="w-full px-3 py-2 border rounded-md"
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'info' | 'warning' | 'success' | 'error' })}
>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="success">Success</option>
<option value="error">Error</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Priority</label>
<form onSubmit={handleSubmit} className="space-y-6 pt-4">
<div className="space-y-4">
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
Announcement Title
</label>
<input
type="number"
className="w-full px-3 py-2 border rounded-md"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 0 })}
type="text"
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm font-medium focus:ring-1 focus:ring-gray-900 focus:outline-none"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Target Audience</label>
<input
type="text"
className="w-full px-3 py-2 border rounded-md"
value={formData.targetAudience}
onChange={(e) => setFormData({ ...formData, targetAudience: e.target.value })}
placeholder="all, admins, users, etc."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Start Date</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-md"
value={formData.startsAt}
onChange={(e) => setFormData({ ...formData, startsAt: e.target.value })}
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
BroadCast Message
</label>
<textarea
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm font-medium min-h-[120px] focus:ring-1 focus:ring-gray-900 focus:outline-none"
value={formData.message}
onChange={(e) =>
setFormData({ ...formData, message: e.target.value })
}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">End Date</label>
<input
type="date"
className="w-full px-3 py-2 border rounded-md"
value={formData.endsAt}
onChange={(e) => setFormData({ ...formData, endsAt: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
Type
</label>
<select
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm appearance-none bg-white focus:ring-1 focus:ring-gray-900 focus:outline-none"
value={formData.type}
onChange={(e) =>
setFormData({ ...formData, type: e.target.value as any })
}
>
<option value="info">Information</option>
<option value="warning">Warning</option>
<option value="success">Success</option>
<option value="error">Critical</option>
</select>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
Target Audience
</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm focus:ring-1 focus:ring-gray-900 focus:outline-none"
value={formData.targetAudience}
onChange={(e) =>
setFormData({
...formData,
targetAudience: e.target.value,
})
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
Launch Date
</label>
<input
type="date"
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm focus:outline-none"
value={formData.startsAt}
onChange={(e) =>
setFormData({ ...formData, startsAt: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-gray-400">
Expiry Date
</label>
<input
type="date"
className="w-full px-3 py-2 border border-gray-200 rounded-none text-sm focus:outline-none"
value={formData.endsAt}
onChange={(e) =>
setFormData({ ...formData, endsAt: e.target.value })
}
/>
</div>
</div>
</div>
<DialogFooter>
<DialogFooter className="border-t pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
setFormDialogOpen(false)
resetForm()
}}
variant="ghost"
onClick={() => setFormDialogOpen(false)}
className="rounded-none text-xs uppercase tracking-widest"
>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending || updateMutation.isPending}
className="rounded-none text-xs uppercase tracking-widest px-8"
>
{selectedAnnouncement ? 'Update' : 'Create'}
Confirm
</Button>
</DialogFooter>
</form>
@ -349,23 +447,34 @@ export default function AnnouncementsPage() {
{/* Delete Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogContent className="rounded-none border-rose-100">
<DialogHeader>
<DialogTitle>Delete Announcement</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{selectedAnnouncement?.title}"? This action cannot be undone.
<DialogTitle className="text-rose-600 text-sm font-bold uppercase tracking-widest">
Delete BroadCast
</DialogTitle>
<DialogDescription className="text-xs">
Confirm removal of "{selectedAnnouncement?.title}". This operation
cannot be reversed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
<DialogFooter className="mt-4">
<Button
variant="outline"
onClick={() => setDeleteDialogOpen(false)}
className="rounded-none text-xs"
>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
<Button
variant="destructive"
onClick={handleDelete}
className="rounded-none text-xs"
>
Permanent Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
);
}

View File

@ -1,105 +1,192 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Search, Eye } from "lucide-react"
import { auditService, type AuditLog } from "@/services"
import { format } from "date-fns"
Search,
Eye,
ChevronLeft,
ChevronRight,
Filter,
Terminal,
} from "lucide-react";
import { auditService, type AuditLog } from "@/services";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
export default function AuditPage() {
const [page] = useState(1)
const [limit] = useState(50)
const [search, setSearch] = useState("")
const [page, setPage] = useState(1);
const [limit] = useState(15);
const [search, setSearch] = useState("");
const { data: auditData, isLoading } = useQuery({
queryKey: ['admin', 'audit', 'logs', page, limit, search],
queryKey: ["admin", "audit", "logs", page, limit, search],
queryFn: async () => {
const params: Record<string, string | number> = { page, limit }
if (search) params.search = search
return await auditService.getAuditLogs(params)
const params: Record<string, string | number> = { page, limit };
if (search) params.search = search;
return await auditService.getAuditLogs(params);
},
})
});
const getActionColor = (action: string) => {
const act = action.toUpperCase();
if (act.includes("CREATE"))
return "text-blue-600 bg-blue-50 border-blue-100";
if (act.includes("UPDATE"))
return "text-emerald-600 bg-emerald-50 border-emerald-100";
if (act.includes("DELETE"))
return "text-rose-600 bg-rose-50 border-rose-100";
if (act.includes("LOGIN"))
return "text-purple-600 bg-purple-50 border-purple-100";
return "text-slate-600 bg-slate-50 border-slate-100";
};
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Audit Logs</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Audit Logs
</h1>
<p className="text-gray-500 mt-1">
Comprehensive system transaction registry.
</p>
</div>
<div className="flex items-center gap-2">
{/* View only access: No administrative actions */}
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>All Audit Logs</CardTitle>
<div className="flex items-center gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search audit logs..."
className="pl-10 w-64"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Security Ledger
</CardTitle>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search resources..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading audit logs...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Action</TableHead>
<TableHead>Resource Type</TableHead>
<TableHead>Resource ID</TableHead>
<TableHead>User</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Date</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{auditData?.data?.map((log: AuditLog) => (
<TableRow key={log.id}>
<TableCell>
<Badge>{log.action}</Badge>
</TableCell>
<TableCell>{log.resourceType}</TableCell>
<TableCell className="font-mono text-sm">{log.resourceId}</TableCell>
<TableCell>{log.userId || 'N/A'}</TableCell>
<TableCell>{log.ipAddress || 'N/A'}</TableCell>
<TableCell>
{format(new Date(log.timestamp), 'MMM dd, yyyy HH:mm')}
</TableCell>
<TableCell>
<Button variant="ghost" size="icon">
<Eye className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{auditData?.data?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No audit logs found
</div>
)}
</>
)}
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Act
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
User ID
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Resource
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
IP
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Date
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Retrieving audit trail...
</td>
</tr>
) : auditData?.data && auditData.data.length > 0 ? (
auditData.data.map((log: AuditLog) => (
<tr
key={log.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
getActionColor(log.action),
)}
>
{log.action}
</span>
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
{log.userId || "--"}
</td>
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase flex items-center gap-1.5 mt-4">
<Terminal className="w-3 h-3" /> {log.resourceType}:{" "}
{log.resourceId.substring(0, 10)}
</td>
<td className="px-6 py-4 text-xs font-mono text-gray-500">
{log.ipAddress || "--"}
</td>
<td className="px-6 py-4 text-right text-xs text-gray-500 font-medium">
{format(new Date(log.timestamp), "MMM dd, HH:mm")}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
>
Security ledger is clear.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{auditData && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Standard View: {auditData.total || 0} Entries
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => p + 1)}
disabled={!auditData?.data || auditData.data.length < limit}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
)
);
}

View File

@ -1,324 +1,289 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Download, Users, FileText, DollarSign, HardDrive, AlertCircle } from "lucide-react"
import { analyticsService, systemService } from "@/services"
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
import { toast } from "sonner"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { dashboardService, invoiceService } from "@/services";
import { Badge } from "@/components/ui/badge";
import { format } from "date-fns";
const COLORS = ["#10b981", "#f59e0b", "#ef4444", "#3b82f6", "#8b5cf6"];
export default function DashboardPage() {
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ['admin', 'analytics', 'overview'],
queryFn: () => analyticsService.getOverview(),
})
const { data: metrics, isLoading: metricsLoading } = useQuery({
queryKey: ["admin", "dashboard", "metrics"],
queryFn: () => dashboardService.getMetrics(),
});
const { data: userGrowth, isLoading: growthLoading } = useQuery({
queryKey: ['admin', 'analytics', 'users', 'growth'],
queryFn: () => analyticsService.getUserGrowth(30),
})
const { data: scannedInvoices, isLoading: scannedLoading } = useQuery({
queryKey: ["admin", "dashboard", "scanned"],
queryFn: () => dashboardService.getScannedInvoices(),
});
const { data: revenue, isLoading: revenueLoading } = useQuery({
queryKey: ['admin', 'analytics', 'revenue'],
queryFn: () => analyticsService.getRevenue('30days'),
})
const { data: statusBreakdown, isLoading: statusLoading } = useQuery({
queryKey: ["admin", "dashboard", "status-breakdown"],
queryFn: () => dashboardService.getInvoiceStatusBreakdown(),
});
const { data: health, isLoading: healthLoading } = useQuery({
queryKey: ['admin', 'system', 'health'],
queryFn: () => systemService.getHealth(),
})
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
queryKey: ['admin', 'analytics', 'error-rate'],
queryFn: () => analyticsService.getErrorRate(7),
})
const handleExport = () => {
try {
// Create CSV content from current dashboard data
const csvContent = [
['Metric', 'Value'],
['Total Users', overview?.users?.total || 0],
['Active Users', overview?.users?.active || 0],
['Inactive Users', overview?.users?.inactive || 0],
['Total Invoices', overview?.invoices?.total || 0],
['Total Revenue', overview?.revenue?.total || 0],
['Storage Used', overview?.storage?.totalSize || 0],
['Total Documents', overview?.storage?.documents || 0],
['Error Rate', errorRate?.errorRate || 0],
['Total Errors', errorRate?.errors || 0],
['Export Date', new Date().toISOString()],
]
.map(row => row.join(','))
.join('\n')
// Create and download the file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `admin-dashboard-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success("Dashboard data exported successfully!")
} catch (error) {
toast.error("Failed to export data. Please try again.")
console.error('Export error:', error)
}
}
const { data: proformaRequests, isLoading: requestsLoading } = useQuery({
queryKey: ["admin", "dashboard", "proforma-requests"],
queryFn: () => invoiceService.getProformaRequests({ limit: 5 }),
});
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount)
}
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(amount);
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
if (metricsLoading || scannedLoading || statusLoading || requestsLoading) {
return (
<div className="p-8 text-center text-gray-500 font-medium">
Loading dashboard...
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Dashboard Overview</h2>
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
<Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export Data
</Button>
</div>
</div>
<div className="p-8 space-y-12 max-w-7xl mx-auto bg-white min-h-screen">
<header>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Dashboard Overview
</h1>
<p className="text-gray-500 mt-1">
Operational status and pending verification items.
</p>
</header>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<>
<div className="text-2xl font-bold">{overview?.users?.total || 0}</div>
<p className="text-xs text-muted-foreground">
{overview?.users?.active || 0} active, {overview?.users?.inactive || 0} inactive
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Invoices</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<>
<div className="text-2xl font-bold">{overview?.invoices?.total || 0}</div>
<p className="text-xs text-muted-foreground">
All time invoices
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<>
<div className="text-2xl font-bold">
{overview?.revenue ? formatCurrency(overview.revenue.total) : '$0.00'}
</div>
<p className="text-xs text-muted-foreground">
Total revenue
</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Storage Usage</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{overviewLoading ? (
<div className="text-2xl font-bold">...</div>
) : (
<>
<div className="text-2xl font-bold">
{overview?.storage ? formatBytes(overview.storage.totalSize) : '0 Bytes'}
</div>
<p className="text-xs text-muted-foreground">
{overview?.storage?.documents || 0} documents
</p>
</>
)}
</CardContent>
</Card>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* User Growth Chart */}
<Card>
<CardHeader>
<CardTitle>User Growth (Last 30 Days)</CardTitle>
</CardHeader>
<CardContent>
{growthLoading ? (
<div className="h-[300px] flex items-center justify-center">Loading...</div>
) : userGrowth && userGrowth.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={userGrowth}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="total" stroke="#8884d8" name="Total Users" />
<Line type="monotone" dataKey="admins" stroke="#82ca9d" name="Admins" />
<Line type="monotone" dataKey="regular" stroke="#ffc658" name="Regular Users" />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</CardContent>
</Card>
{/* Revenue Chart */}
<Card>
<CardHeader>
<CardTitle>Revenue Analytics (Last 30 Days)</CardTitle>
</CardHeader>
<CardContent>
{revenueLoading ? (
<div className="h-[300px] flex items-center justify-center">Loading...</div>
) : revenue && revenue.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={revenue}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="revenue" fill="#8884d8" name="Revenue" />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
</div>
)}
</CardContent>
</Card>
</div>
{/* Error Rate Chart */}
{errorRate && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Error Rate (Last 7 Days)
{/* Top Metrics Cards */}
<section className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="border-none bg-gray-50 shadow-none rounded-none">
<CardHeader className="pb-1">
<CardTitle className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Gross Revenue
</CardTitle>
</CardHeader>
<CardContent>
{errorRateLoading ? (
<div className="h-[200px] flex items-center justify-center">Loading...</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Total Errors</p>
<p className="text-2xl font-bold">{errorRate.errors || 0}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Total Requests</p>
<p className="text-2xl font-bold">{errorRate.total || 0}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Error Rate</p>
<p className="text-2xl font-bold">
{errorRate.errorRate ? `${errorRate.errorRate.toFixed(2)}%` : '0%'}
</p>
</div>
</div>
<div className="w-full bg-muted rounded-full h-4">
<div
className="bg-destructive h-4 rounded-full transition-all"
style={{
width: `${Math.min(errorRate.errorRate || 0, 100)}%`,
}}
/>
</div>
</div>
)}
<div className="text-3xl font-black text-gray-900">
{formatCurrency(metrics?.totalRevenue || 0)}
</div>
</CardContent>
</Card>
)}
{/* System Health */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
System Health
</CardTitle>
</CardHeader>
<CardContent>
{healthLoading ? (
<div>Loading system health...</div>
<Card className="border-none bg-gray-50 shadow-none rounded-none">
<CardHeader className="pb-1">
<CardTitle className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Total Payments
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-black text-gray-900">
{(metrics?.totalPayments || 0).toLocaleString()}
</div>
</CardContent>
</Card>
<Card className="border-none bg-gray-50 shadow-none rounded-none">
<CardHeader className="pb-1">
<CardTitle className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Total Invoices
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-black text-gray-900">
{(metrics?.totalInvoices || 0).toLocaleString()}
</div>
</CardContent>
</Card>
</section>
{/* Invoice Status Breakdown (Full Width) */}
<section className="space-y-4">
<h2 className="text-xl font-bold text-gray-900">
Invoice Status Breakdown
</h2>
<div className="border bg-white divide-y overflow-hidden rounded-none">
{statusBreakdown && statusBreakdown.length > 0 ? (
statusBreakdown.map((item, idx) => (
<div
key={idx}
className="p-4 flex items-center justify-between hover:bg-gray-50"
>
<div className="flex items-center gap-3">
<div
className="w-2.5 h-2.5"
style={{ backgroundColor: COLORS[idx % COLORS.length] }}
/>
<span className="text-sm font-bold uppercase tracking-wide text-gray-700">
{item.status}
</span>
</div>
<span className="text-lg font-black text-gray-900">
{item.count.toLocaleString()}
</span>
</div>
))
) : (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<p className="text-lg font-semibold capitalize">{health?.status || 'Unknown'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Database</p>
<p className="text-lg font-semibold capitalize">{health?.database || 'Unknown'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Recent Errors</p>
<p className="text-lg font-semibold">{health?.recentErrors || 0}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Active Users</p>
<p className="text-lg font-semibold">{health?.activeUsers || 0}</p>
</div>
<div className="p-12 text-center text-gray-400 italic">
No status data available.
</div>
)}
</CardContent>
</Card>
</div>
)
}
{/* Detailed Stats Row */}
<div className="p-6 grid grid-cols-3 gap-8 bg-gray-50/30">
<div className="text-left">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Paid Invoices
</p>
<p className="text-2xl font-black text-green-600">
{metrics?.paidInvoices || 0}
</p>
</div>
<div className="text-left border-l pl-8">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Pending Invoices
</p>
<p className="text-2xl font-black text-orange-500">
{metrics?.pendingInvoices || 0}
</p>
</div>
<div className="text-left border-l pl-8">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Overdue Invoices
</p>
<p className="text-2xl font-black text-red-500">
{metrics?.overdueInvoices || 0}
</p>
</div>
</div>
</div>
</section>
{/* Recent Proforma Requests (Full Width) */}
<section className="space-y-4">
<h2 className="text-xl font-bold text-gray-900">
Recent Proforma Requests
</h2>
<div className="border overflow-hidden rounded-none">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Title
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Category
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Status
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Deadline
</th>
</tr>
</thead>
<tbody className="bg-white divide-y">
{proformaRequests?.data && proformaRequests.data.length > 0 ? (
proformaRequests.data.map((request) => (
<tr
key={request.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-5">
<div className="text-sm font-bold text-gray-900 uppercase tracking-tight">
{request.title}
</div>
</td>
<td className="px-6 py-5 text-sm font-medium text-gray-600">
{request.category}
</td>
<td className="px-6 py-5">
<Badge
variant="outline"
className="rounded-none border-gray-200 font-bold text-[10px] uppercase tracking-widest"
>
{request.status}
</Badge>
</td>
<td className="px-6 py-5 text-sm text-gray-500 font-medium">
{format(
new Date(request.submissionDeadline),
"MMM dd, yyyy",
)}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={4}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
>
No proforma requests recorded.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{/* Pending Verification (Full Width) */}
<section className="space-y-4">
<h2 className="text-xl font-bold text-gray-900">
Pending Verification
</h2>
<div className="border overflow-hidden rounded-none">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Invoice Number
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Customer
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Amount
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-500 uppercase tracking-widest">
Issue Date
</th>
</tr>
</thead>
<tbody className="bg-white divide-y">
{scannedInvoices && scannedInvoices.length > 0 ? (
scannedInvoices.map((invoice) => (
<tr
key={invoice.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-5 text-sm font-bold text-gray-900">
{invoice.invoiceNumber}
</td>
<td className="px-6 py-5 text-sm text-gray-600">
{invoice.customerName}
</td>
<td className="px-6 py-5 text-sm font-bold text-gray-900">
{formatCurrency(invoice.amount)}
</td>
<td className="px-6 py-5 text-sm text-gray-500">
{new Date(invoice.issueDate).toLocaleDateString()}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={4}
className="px-6 py-20 text-center text-gray-400 italic"
>
No invoices are currently pending verification.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
</div>
);
}

View File

@ -1,177 +1,207 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { AlertCircle, CheckCircle, XCircle, Users } from "lucide-react"
import { systemService } from "@/services"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
AlertCircle,
CheckCircle,
XCircle,
Users,
Zap,
Database,
Activity,
Cpu,
} from "lucide-react";
import { systemService } from "@/services";
export default function HealthPage() {
const { data: health, isLoading: healthLoading } = useQuery({
queryKey: ['admin', 'system', 'health'],
queryKey: ["admin", "system", "health"],
queryFn: () => systemService.getHealth(),
refetchInterval: 30000, // Refetch every 30 seconds
})
refetchInterval: 30000,
});
const { data: systemInfo, isLoading: infoLoading } = useQuery({
queryKey: ['admin', 'system', 'info'],
queryKey: ["admin", "system", "info"],
queryFn: () => systemService.getSystemInfo(),
})
});
const getStatusIcon = (status?: string) => {
switch (status?.toLowerCase()) {
case 'healthy':
case 'connected':
return <CheckCircle className="w-5 h-5 text-green-500" />
case 'degraded':
return <AlertCircle className="w-5 h-5 text-yellow-500" />
case 'disconnected':
case 'down':
return <XCircle className="w-5 h-5 text-red-500" />
case "healthy":
case "connected":
return <CheckCircle className="w-5 h-5 text-green-500" />;
case "degraded":
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
case "disconnected":
case "down":
return <XCircle className="w-5 h-5 text-red-500" />;
default:
return <AlertCircle className="w-5 h-5 text-gray-500" />
return <AlertCircle className="w-5 h-5 text-gray-500" />;
}
}
};
const formatUptime = (seconds: number) => {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return `${days}d ${hours}h ${minutes}m`
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${days}d ${hours}h ${minutes}m`;
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
};
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">System Health</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getStatusIcon(health?.status)}
System Status
</CardTitle>
</CardHeader>
<CardContent>
{healthLoading ? (
<div>Loading...</div>
) : (
<Badge variant={health?.status === 'healthy' ? 'default' : 'destructive'}>
{health?.status || 'Unknown'}
</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getStatusIcon(health?.database)}
Database
</CardTitle>
</CardHeader>
<CardContent>
{healthLoading ? (
<div>Loading...</div>
) : (
<Badge variant={health?.database === 'connected' ? 'default' : 'destructive'}>
{health?.database || 'Unknown'}
</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
Recent Errors
</CardTitle>
</CardHeader>
<CardContent>
{healthLoading ? (
<div>Loading...</div>
) : (
<div className="text-2xl font-bold">{health?.recentErrors || 0}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
Active Users
</CardTitle>
</CardHeader>
<CardContent>
{healthLoading ? (
<div>Loading...</div>
) : (
<div className="text-2xl font-bold">{health?.activeUsers || 0}</div>
)}
</CardContent>
</Card>
{[
{
label: "Core Services",
value: health?.status,
icon: Zap,
color: "text-blue-600 bg-blue-50/50 border-blue-100/50",
},
{
label: "Database",
value: health?.database,
icon: Database,
color: "text-emerald-600 bg-emerald-50/50 border-emerald-100/50",
},
{
label: "Critical Errors",
value: health?.recentErrors || 0,
icon: Activity,
color: "text-rose-600 bg-rose-50/50 border-rose-100/50",
},
{
label: "Active Sessions",
value: health?.activeUsers || 0,
icon: Users,
color: "text-purple-600 bg-purple-50/50 border-purple-100/50",
},
].map((metric) => (
<Card
key={metric.label}
className="border-slate-200/60 shadow-sm hover:shadow-md transition-shadow rounded-none bg-white overflow-hidden group"
>
<CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
<CardTitle className="text-[10px] font-bold uppercase tracking-widest text-slate-400 flex items-center justify-between">
{metric.label}
<metric.icon className="w-3.5 h-3.5 text-slate-300 group-hover:text-slate-400 transition-colors" />
</CardTitle>
</CardHeader>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<span className="text-2xl font-black text-slate-900 tracking-tighter">
{healthLoading
? "..."
: metric.label === "Core Services" ||
metric.label === "Database"
? (metric.value as string)?.toUpperCase()
: metric.value}
</span>
{typeof metric.value === "string" &&
getStatusIcon(metric.value)}
</div>
</CardContent>
</Card>
))}
</div>
{systemInfo && (
<Card>
<CardHeader>
<CardTitle>System Information</CardTitle>
<Card className="border-slate-200/60 shadow-sm rounded-none overflow-hidden">
<CardHeader className="border-b border-slate-100 bg-slate-50/30 flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle className="text-xs font-bold uppercase tracking-widest text-slate-900">
Environment Specifications
</CardTitle>
<p className="text-[10px] text-slate-400 font-medium uppercase tracking-tighter mt-0.5">
Hardware & Infrastructure Metadata
</p>
</div>
<Cpu className="w-4 h-4 text-slate-300" />
</CardHeader>
<CardContent>
<CardContent className="p-0">
{infoLoading ? (
<div className="text-center py-8">Loading system info...</div>
<div className="text-center py-16 text-slate-400 font-bold uppercase tracking-widest text-[10px]">
Interrogating backend environment...
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<p className="text-sm text-muted-foreground">Node.js Version</p>
<p className="font-medium">{systemInfo.nodeVersion}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Platform</p>
<p className="font-medium">{systemInfo.platform || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Architecture</p>
<p className="font-medium">{systemInfo.architecture || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Uptime</p>
<p className="font-medium">{formatUptime(systemInfo.uptime || 0)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Environment</p>
<p className="font-medium">{systemInfo.env || systemInfo.environment}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Memory Usage</p>
<p className="font-medium">
{formatBytes(systemInfo.memory?.used || 0)} / {formatBytes(systemInfo.memory?.total || 0)}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">CPU Cores</p>
<p className="font-medium">{systemInfo.cpu?.cores || 'N/A'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Load Average</p>
<p className="font-medium">
{systemInfo.cpu?.loadAverage?.map((load: number) => load.toFixed(2)).join(', ') || 'N/A'}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 divide-x divide-y md:divide-y-0 border-b border-slate-100">
{[
{ label: "Node.js Version", value: systemInfo.nodeVersion },
{
label: "Platform Layer",
value: systemInfo.platform || "N/A",
},
{
label: "Architecture",
value: systemInfo.architecture || "N/A",
},
{
label: "Temporal Uptime",
value: formatUptime(systemInfo.uptime || 0),
},
].map((item) => (
<div
key={item.label}
className="p-6 transition-colors hover:bg-slate-50/50"
>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5">
{item.label}
</p>
<p className="text-lg font-black text-slate-900 tracking-tighter">
{item.value}
</p>
</div>
))}
</div>
)}
{!infoLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 divide-x">
{[
{
label: "Environment Scope",
value: systemInfo.env || systemInfo.environment,
},
{
label: "Aggregate Memory",
value: `${formatBytes(systemInfo.memory?.used || 0)} / ${formatBytes(systemInfo.memory?.total || 0)}`,
},
{
label: "Physical Cores",
value: systemInfo.cpu?.cores || "N/A",
},
{
label: "Traffic Index (1m/5m/15m)",
value:
systemInfo.cpu?.loadAverage
?.map((load: number) => load.toFixed(2))
.join(" • ") || "N/A",
},
].map((item) => (
<div
key={item.label}
className="p-6 transition-colors hover:bg-slate-50/50"
>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1.5">
{item.label}
</p>
<p className="text-lg font-black text-slate-900 tracking-tighter">
{item.value}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
)
);
}

View File

@ -0,0 +1,221 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
ChevronLeft,
ChevronRight,
Filter,
Download,
} from "lucide-react";
import { invoiceService } from "@/services";
import { cn } from "@/lib/utils";
export default function InvoicesPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const { data: invoicesData, isLoading } = useQuery({
queryKey: ["admin", "invoices", page, search],
queryFn: () =>
invoiceService.getInvoices({
page,
limit: 10,
search: search || undefined,
}),
});
const formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(val);
};
const getStatusColor = (status: string) => {
switch (status) {
case "PAID":
return "text-emerald-600 bg-emerald-50 border-emerald-100";
case "PENDING":
return "text-amber-600 bg-amber-50 border-amber-100";
case "OVERDUE":
return "text-rose-600 bg-rose-50 border-rose-100";
case "CANCELLED":
return "text-gray-600 bg-gray-50 border-gray-100";
default:
return "text-slate-600 bg-slate-50 border-slate-100";
}
};
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Invoices
</h1>
<p className="text-gray-500 mt-1">
Manage sales and purchase invoices.
</p>
</div>
<div className="flex items-center gap-2">
{/* View only access: Create and Export buttons removed */}
</div>
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Invoice Ledger
</CardTitle>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search customer or #..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Invoice #
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Customer
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Type
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Amount
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Status
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Date
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={6}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Synchronizing ledger data...
</td>
</tr>
) : invoicesData?.data && invoicesData.data.length > 0 ? (
invoicesData.data.map((invoice) => (
<tr
key={invoice.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
{invoice.invoiceNumber}
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900">
{invoice.customerName}
</span>
<span className="text-[10px] text-gray-400">
{invoice.customerEmail}
</span>
</div>
</td>
<td className="px-6 py-4">
<span
className={cn(
"px-1.5 py-0.5 text-[9px] font-black uppercase tracking-tighter border",
invoice.type === "SALES"
? "text-blue-600 border-blue-100 bg-blue-50/30"
: "text-purple-600 border-purple-100 bg-purple-50/30",
)}
>
{invoice.type}
</span>
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900">
{formatCurrency(invoice.amount)}
</td>
<td className="px-6 py-4">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
getStatusColor(invoice.status),
)}
>
{invoice.status}
</span>
</td>
<td className="px-6 py-4 text-right text-sm text-gray-500">
{new Date(invoice.issueDate).toLocaleDateString()}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={6}
className="px-6 py-20 text-center text-gray-400 italic"
>
No invoices found in ledger.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{invoicesData?.meta && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Page {invoicesData.meta.page} of {invoicesData.meta.totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
disabled={!invoicesData.meta.hasPreviousPage}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
disabled={!invoicesData.meta.hasNextPage}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,182 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
ChevronLeft,
ChevronRight,
Filter,
FileText,
} from "lucide-react";
import { invoiceService } from "@/services";
export default function ProformaPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const { data: proformaData, isLoading } = useQuery({
queryKey: ["admin", "proforma", page, search],
queryFn: () =>
invoiceService.getProformas({
page,
limit: 10,
search: search || undefined,
}),
});
const formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(val);
};
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Proforma Invoices
</h1>
<p className="text-gray-500 mt-1">
Manage draft and preliminary invoices.
</p>
</div>
{/* View only access: Create button removed */}
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Proforma Registry
</CardTitle>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search Customer..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Proforma #
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Customer
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Amount
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Issue Date
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Due Date
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Retrieving proforma records...
</td>
</tr>
) : proformaData?.data && proformaData.data.length > 0 ? (
proformaData.data.map((item) => (
<tr
key={item.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter flex items-center gap-2">
<FileText className="w-3.5 h-3.5 text-gray-400" />
{item.proformaNumber}
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900">
{item.customerName}
</span>
<span className="text-[10px] text-gray-400">
{item.customerEmail}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900">
{formatCurrency(item.amount)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(item.issueDate).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right text-sm text-gray-500">
{new Date(item.dueDate).toLocaleDateString()}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic"
>
No proforma records found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{proformaData?.meta && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Page {proformaData.meta.page} of {proformaData.meta.totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
disabled={!proformaData.meta.hasPreviousPage}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
disabled={!proformaData.meta.hasNextPage}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,295 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Search,
ChevronLeft,
ChevronRight,
ClipboardList,
Calendar,
Layers,
} from "lucide-react";
import { invoiceService, type ProformaRequest } from "@/services";
import { format } from "date-fns";
export default function ProformaRequestsPage() {
const [page, setPage] = useState(1);
const [limit] = useState(10);
const [status, setStatus] = useState<string>("all");
const [category, setCategory] = useState<string>("all");
const [search, setSearch] = useState("");
const { data, isLoading } = useQuery({
queryKey: [
"admin",
"proforma-requests",
page,
limit,
status,
category,
search,
],
queryFn: () =>
invoiceService.getProformaRequests({
page,
limit,
status: status === "all" ? undefined : status,
category: category === "all" ? undefined : category,
search: search || undefined,
}),
});
const getStatusBadge = (status: ProformaRequest["status"]) => {
switch (status) {
case "OPEN":
return (
<Badge variant="default" className="bg-blue-500 hover:bg-blue-600">
Open
</Badge>
);
case "DRAFT":
return <Badge variant="secondary">Draft</Badge>;
case "UNDER_REVIEW":
return (
<Badge
variant="outline"
className="text-amber-600 border-amber-200 bg-amber-50"
>
Review
</Badge>
);
case "REVISION_REQUESTED":
return (
<Badge
variant="destructive"
className="bg-orange-500 hover:bg-orange-600"
>
Revision
</Badge>
);
case "CLOSED":
return (
<Badge
variant="outline"
className="text-emerald-600 border-emerald-200 bg-emerald-50"
>
Closed
</Badge>
);
case "CANCELLED":
return <Badge variant="destructive">Cancelled</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getCategoryIcon = (category: ProformaRequest["category"]) => {
switch (category) {
case "EQUIPMENT":
return <Layers className="w-3.5 h-3.5 mr-1" />;
case "SERVICE":
return <ClipboardList className="w-3.5 h-3.5 mr-1" />;
case "MIXED":
return <Layers className="w-3.5 h-3.5 mr-1" />;
default:
return null;
}
};
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Proforma Requests
</h1>
<p className="text-muted-foreground mt-1">
Manage and review customer requests for proforma invoices.
</p>
</div>
</div>
<Card className="border-slate-200/60 shadow-sm">
<CardHeader className="pb-3 border-b border-slate-100 bg-slate-50/30">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="flex flex-1 items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search requests..."
className="pl-9 h-10 border-slate-200/80 focus-visible:ring-slate-900"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-[140px] h-10 border-slate-200/80">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="OPEN">Open</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="UNDER_REVIEW">Under Review</SelectItem>
<SelectItem value="REVISION_REQUESTED">Revision</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-[140px] h-10 border-slate-200/80">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="EQUIPMENT">Equipment</SelectItem>
<SelectItem value="SERVICE">Service</SelectItem>
<SelectItem value="MIXED">Mixed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader className="bg-slate-50/50">
<TableRow className="hover:bg-transparent border-slate-100">
<TableHead className="w-[300px] text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4">
Request Details
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Category
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Status
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Deadline
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Items
</TableHead>
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Timestamp
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i} className="animate-pulse">
<TableCell colSpan={6} className="h-16 bg-slate-50/30" />
</TableRow>
))
) : data?.data && data.data.length > 0 ? (
data.data.map((request: ProformaRequest) => (
<TableRow
key={request.id}
className="group hover:bg-slate-50 transition-colors border-slate-100"
>
<TableCell className="px-6 py-4">
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-slate-900 line-clamp-1">
{request.title}
</span>
<span className="text-xs text-muted-foreground line-clamp-1">
{request.description}
</span>
</div>
</TableCell>
<TableCell className="px-6">
<div className="flex items-center text-xs font-medium text-slate-600">
{getCategoryIcon(request.category)}
{request.category}
</div>
</TableCell>
<TableCell className="px-6">
{getStatusBadge(request.status)}
</TableCell>
<TableCell className="px-6">
<div className="flex items-center gap-1.5 text-xs text-slate-600 font-medium">
<Calendar className="w-3.5 h-3.5 text-slate-400" />
{format(
new Date(request.submissionDeadline),
"MMM dd, yyyy",
)}
</div>
</TableCell>
<TableCell className="px-6">
<Badge
variant="outline"
className="bg-slate-50 border-slate-200 text-slate-600 text-[10px]"
>
{request.items.length} Items
</Badge>
</TableCell>
<TableCell className="text-right px-6 text-xs text-slate-500 font-medium">
{format(new Date(request.createdAt), "HH:mm, MMM dd")}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={6}
className="h-32 text-center text-sm text-muted-foreground bg-slate-50/10 italic"
>
No proforma requests found matching your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
{data?.meta && (
<div className="px-6 py-4 flex items-center justify-between border-t border-slate-100 bg-slate-50/10">
<span className="text-xs font-bold uppercase tracking-widest text-slate-400">
Total Entries: {data.meta.total}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md border-slate-200"
disabled={!data.meta.hasPreviousPage}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4 text-slate-600" />
</Button>
<div className="text-xs font-bold tabular-nums text-slate-900 border px-2 py-1 bg-white rounded-md border-slate-200">
{data.meta.page} / {data.meta.totalPages}
</div>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-md border-slate-200"
disabled={!data.meta.hasNextPage}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="w-4 h-4 text-slate-600" />
</Button>
</div>
</div>
)}
</Card>
</div>
);
}

View File

@ -1,112 +1,146 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Badge } from "@/components/ui/badge"
import { systemService } from "@/services"
import { toast } from "sonner"
import { useState } from "react"
import type { ApiError } from "@/types/error.types"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { systemService } from "@/services";
import { toast } from "sonner";
import { useState } from "react";
import type { ApiError } from "@/types/error.types";
import { cn } from "@/lib/utils";
export default function MaintenancePage() {
const queryClient = useQueryClient()
const [message, setMessage] = useState("")
const queryClient = useQueryClient();
const [message, setMessage] = useState("");
const { data: status, isLoading } = useQuery({
queryKey: ['admin', 'maintenance'],
queryKey: ["admin", "maintenance"],
queryFn: () => systemService.getMaintenanceStatus(),
})
});
const enableMutation = useMutation({
mutationFn: (msg?: string) => systemService.enableMaintenance(msg),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
toast.success("Maintenance mode enabled")
setMessage("")
queryClient.invalidateQueries({ queryKey: ["admin", "maintenance"] });
toast.success("Maintenance mode enabled");
setMessage("");
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to enable maintenance mode")
const apiError = error as ApiError;
toast.error(
apiError.response?.data?.message || "Failed to enable maintenance mode",
);
},
})
});
const disableMutation = useMutation({
mutationFn: () => systemService.disableMaintenance(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
toast.success("Maintenance mode disabled")
queryClient.invalidateQueries({ queryKey: ["admin", "maintenance"] });
toast.success("Maintenance mode disabled");
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to disable maintenance mode")
const apiError = error as ApiError;
toast.error(
apiError.response?.data?.message ||
"Failed to disable maintenance mode",
);
},
})
});
const handleToggle = (enabled: boolean) => {
if (enabled) {
enableMutation.mutate(message || undefined)
enableMutation.mutate(message || undefined);
} else {
disableMutation.mutate()
disableMutation.mutate();
}
}
};
if (isLoading) {
return <div className="text-center py-8">Loading maintenance status...</div>
}
const isEnabled = status?.status === 'ACTIVE'
const isEnabled = status?.status === "ACTIVE";
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Maintenance Mode</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Maintenance Mode
</h1>
<p className="text-gray-500 mt-1">
Control public access to the platform.
</p>
</div>
<div
className={cn(
"px-4 py-1.5 text-[10px] font-bold uppercase tracking-widest border rounded-none transition-colors",
isEnabled
? "bg-rose-50 text-rose-600 border-rose-100"
: "bg-emerald-50 text-emerald-600 border-emerald-100",
)}
>
System: {isEnabled ? "OFFLINE (MAINTENANCE)" : "ONLINE"}
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Maintenance Status</CardTitle>
<Badge variant={isEnabled ? 'destructive' : 'default'}>
{isEnabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<Card className="border shadow-none rounded-none max-w-2xl">
<CardHeader className="border-b pb-4 bg-gray-50/30">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Status Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Maintenance Mode</Label>
<p className="text-sm text-muted-foreground">
Enable maintenance mode to temporarily disable access to the platform
<CardContent className="p-8 space-y-8">
<div className="flex items-center justify-between gap-8">
<div className="space-y-1">
<Label className="text-sm font-bold text-gray-900 uppercase tracking-tighter">
Toggle Maintenance
</Label>
<p className="text-xs text-gray-400 leading-relaxed max-w-xs">
Activating this will redirect all non-admin traffic to the
maintenance landing page.
</p>
</div>
<Switch
checked={isEnabled}
onCheckedChange={handleToggle}
className="data-[state=checked]:bg-gray-900"
/>
</div>
{!isEnabled && (
<div className="space-y-2">
<Label htmlFor="message">Maintenance Message (Optional)</Label>
<div className="space-y-3 animate-in fade-in slide-in-from-top-2 duration-300">
<Label
htmlFor="message"
className="text-[10px] font-bold uppercase tracking-widest text-gray-400"
>
Broadcast Message (Optional)
</Label>
<Input
id="message"
placeholder="Enter maintenance message..."
placeholder="We'll be back shortly..."
value={message}
onChange={(e) => setMessage(e.target.value)}
className="h-11 rounded-none border-gray-200 text-sm font-medium focus-visible:ring-gray-900 shadow-none"
/>
<p className="text-sm text-muted-foreground">
This message will be displayed to users when maintenance mode is enabled
</p>
</div>
)}
{isEnabled && status?.message && (
<div>
<Label>Current Message</Label>
<p className="text-sm mt-2">{status.message}</p>
<div className="p-4 bg-slate-50 border border-slate-100 rounded-none animate-in fade-in duration-300">
<Label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Current Broadcast
</Label>
<p className="text-sm font-medium text-slate-900 mt-1">
{status.message}
</p>
</div>
)}
</CardContent>
</Card>
{isLoading && (
<div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Verifying system status...
</div>
)}
</div>
)
);
}

View File

@ -0,0 +1,196 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, ChevronLeft, ChevronRight, Filter } from "lucide-react";
import { paymentService } from "@/services";
import { cn } from "@/lib/utils";
export default function PaymentRequestsPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const { data: requestsData, isLoading: requestsLoading } = useQuery({
queryKey: ["admin", "payment-requests", page, search],
queryFn: () =>
paymentService.getPaymentRequests({
page,
limit: 10,
search: search || undefined,
}),
});
const formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(val);
};
const getStatusColor = (status: string) => {
switch (status) {
case "PAID":
return "text-emerald-600 bg-emerald-50";
case "SENT":
return "text-blue-600 bg-blue-50";
case "OPENED":
return "text-amber-600 bg-amber-50";
case "EXPIRED":
case "CANCELLED":
return "text-red-600 bg-red-50";
default:
return "text-gray-600 bg-gray-50";
}
};
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Payment Requests
</h1>
<p className="text-gray-500 mt-1">
Manage outbound customer requests.
</p>
</div>
{/* View only access: New Request button removed */}
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Request Queue
</CardTitle>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search Customer..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Request #
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Customer
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Amount
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Due Date
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status
</th>
</tr>
</thead>
<tbody className="divide-y">
{requestsLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium"
>
Loading requests...
</td>
</tr>
) : requestsData?.data && requestsData.data.length > 0 ? (
requestsData.data.map((request) => (
<tr key={request.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
{request.paymentRequestNumber}
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900">
{request.customerName}
</span>
<span className="text-[10px] text-gray-400">
{request.customerEmail}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900">
{formatCurrency(request.amount)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(request.dueDate).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
getStatusColor(request.status),
)}
>
{request.status}
</span>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic"
>
No records found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{requestsData?.meta && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Page {requestsData.meta.page} of {requestsData.meta.totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
disabled={!requestsData.meta.hasPreviousPage}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
disabled={!requestsData.meta.hasNextPage}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,162 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
ChevronLeft,
ChevronRight,
Download,
Flag,
} from "lucide-react";
import { paymentService } from "@/services";
export default function PaymentsListPage() {
const [page, setPage] = useState(1);
const { data: paymentsData, isLoading: paymentsLoading } = useQuery({
queryKey: ["admin", "payments", page],
queryFn: () => paymentService.getPayments({ page, limit: 10 }),
});
const formatCurrency = (amount: number | any) => {
const val = typeof amount === "number" ? amount : 0;
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(val);
};
return (
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Payments
</h1>
<p className="text-gray-500 mt-1">History of settled transactions.</p>
</div>
{/* View only access: Export button removed */}
</div>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Transaction History
</CardTitle>
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search Transaction ID..."
/>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Transaction ID
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Sender
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Method
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Amount
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Date
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Status
</th>
</tr>
</thead>
<tbody className="divide-y">
{paymentsLoading ? (
<tr>
<td
colSpan={6}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium"
>
Loading payments...
</td>
</tr>
) : paymentsData?.data && paymentsData.data.length > 0 ? (
paymentsData.data.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-bold text-gray-900 uppercase tracking-tighter">
{payment.transactionId}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{payment.senderName || "Unknown"}
</td>
<td className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase">
{payment.paymentMethod}
</td>
<td className="px-6 py-4 text-sm font-bold text-gray-900">
{formatCurrency(payment.amount)}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{new Date(payment.paymentDate).toLocaleDateString()}
</td>
<td className="px-6 py-4 text-right">
{payment.isFlagged && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-none text-[9px] font-bold uppercase bg-red-50 text-red-600 border border-red-100">
<Flag className="w-2.5 h-2.5" /> Flagged
</span>
)}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={6}
className="px-6 py-20 text-center text-gray-400 italic"
>
No records found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{paymentsData?.meta && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Page {paymentsData.meta.page} of {paymentsData.meta.totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
disabled={!paymentsData.meta.hasPreviousPage}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
disabled={!paymentsData.meta.hasNextPage}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
);
}

View File

@ -1,102 +1,158 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Ban } from "lucide-react"
import { securityService, type ApiKey } from "@/services"
import { toast } from "sonner"
import { format } from "date-fns"
import type { ApiError } from "@/types/error.types"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Ban, Key, Calendar, User, Zap } from "lucide-react";
import { securityService, type ApiKey } from "@/services";
import { toast } from "sonner";
import { format } from "date-fns";
import type { ApiError } from "@/types/error.types";
import { cn } from "@/lib/utils";
export default function ApiKeysPage() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
const { data: apiKeys, isLoading } = useQuery({
queryKey: ['admin', 'security', 'api-keys'],
queryKey: ["admin", "security", "api-keys"],
queryFn: () => securityService.getAllApiKeys(),
})
});
const revokeMutation = useMutation({
mutationFn: (id: string) => securityService.revokeApiKey(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
toast.success("API key revoked successfully")
queryClient.invalidateQueries({
queryKey: ["admin", "security", "api-keys"],
});
toast.success("API access credential revoked");
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to revoke API key")
const apiError = error as ApiError;
toast.error(
apiError.response?.data?.message || "Failed to revoke access",
);
},
})
});
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">API Keys</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
API Gateway
</h1>
<p className="text-gray-500 mt-1">
Management of system access credentials and authentication tokens.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>All API Keys</CardTitle>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between bg-gray-50/30">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Credential Registry
</CardTitle>
<Zap className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading API keys...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>User</TableHead>
<TableHead>Last Used</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys?.map((key: ApiKey) => (
<TableRow key={key.id}>
<TableCell className="font-medium">{key.name}</TableCell>
<TableCell>{key.userId || 'N/A'}</TableCell>
<TableCell>
{key.lastUsed ? format(new Date(key.lastUsed), 'MMM dd, yyyy') : 'Never'}
</TableCell>
<TableCell>
<Badge variant={key.isActive ? 'default' : 'destructive'}>
{key.isActive ? 'Active' : 'Revoked'}
</Badge>
</TableCell>
<TableCell>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Key Identifier
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Operator
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Last Activity
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Access Status
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y text-gray-600">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Retrieving secure credentials...
</td>
</tr>
) : apiKeys && apiKeys.length > 0 ? (
apiKeys.map((key: ApiKey) => (
<tr
key={key.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Key className="w-3 h-3 text-gray-300" />
<span className="text-sm font-bold text-gray-900 tracking-tighter">
{key.name}
</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-xs font-medium">
<User className="w-3 h-3 text-gray-300" />
{key.userId || "N/A"}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-xs font-medium">
<Calendar className="w-3 h-3 text-gray-300" />
{key.lastUsed
? format(new Date(key.lastUsed), "MMM dd, yyyy")
: "Inactive"}
</div>
</td>
<td className="px-6 py-4">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest border rounded-none",
key.isActive
? "bg-emerald-50 text-emerald-600 border-emerald-100"
: "bg-rose-50 text-rose-600 border-rose-100",
)}
>
{key.isActive ? "Authorized" : "Deactivated"}
</span>
</td>
<td className="px-6 py-4 text-right">
{key.isActive && (
<Button
variant="ghost"
size="icon"
size="sm"
className="h-8 rounded-none border border-transparent hover:border-rose-100 hover:bg-rose-50 hover:text-rose-600 transition-all font-bold uppercase tracking-widest text-[9px]"
onClick={() => revokeMutation.mutate(key.id)}
>
<Ban className="w-4 h-4" />
<Ban className="w-3 h-3 mr-2" /> Revoke Access
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{apiKeys?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No API keys found
</div>
)}
</>
)}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
>
No API access credentials defined.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
);
}

View File

@ -1,105 +1,177 @@
import { useState } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Search, Ban } from "lucide-react"
import { securityService, type FailedLogin } from "@/services"
import { format } from "date-fns"
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search, Ban, ChevronLeft, ChevronRight, Filter } from "lucide-react";
import { securityService, type FailedLogin } from "@/services";
import { format } from "date-fns";
export default function FailedLoginsPage() {
const [page] = useState(1)
const [limit] = useState(50)
const [search, setSearch] = useState("")
const [page, setPage] = useState(1);
const [limit] = useState(15);
const [search, setSearch] = useState("");
const { data: failedLogins, isLoading } = useQuery({
queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
queryKey: ["admin", "security", "failed-logins", page, limit, search],
queryFn: async () => {
const params: Record<string, string | number> = { page, limit }
if (search) params.email = search
return await securityService.getFailedLogins(params)
const params: Record<string, string | number> = { page, limit };
if (search) params.email = search;
return await securityService.getFailedLogins(params);
},
})
});
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Failed Login Attempts</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Access Violations
</h1>
<p className="text-gray-500 mt-1">
Audit trail for authentication failures and potential threats.
</p>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Failed Logins</CardTitle>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Violation Ledger
</CardTitle>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
placeholder="Search by email..."
className="pl-10 w-64"
className="pl-10 h-9 rounded-none border-gray-200 text-xs focus-visible:ring-gray-900"
placeholder="Search by identifier..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading failed logins...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>User Agent</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Attempted At</TableHead>
<TableHead>Blocked</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{failedLogins?.data?.map((login: FailedLogin) => (
<TableRow key={login.id}>
<TableCell className="font-medium">{login.email}</TableCell>
<TableCell className="font-mono text-sm">{login.ipAddress}</TableCell>
<TableCell className="max-w-xs truncate">{login.ipAddress}</TableCell>
<TableCell>{login.reason || 'N/A'}</TableCell>
<TableCell>
{format(new Date(login.timestamp), 'MMM dd, yyyy HH:mm')}
</TableCell>
<TableCell>
<Badge variant="secondary">
N/A
</Badge>
</TableCell>
<TableCell>
<Button variant="ghost" size="icon">
<Ban className="w-4 h-4" />
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Identity
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Network Source
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Failure Reason
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Timestamp
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y text-gray-600">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Synchronizing security data...
</td>
</tr>
) : failedLogins?.data && failedLogins.data.length > 0 ? (
failedLogins.data.map((login: FailedLogin) => (
<tr
key={login.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4 text-sm font-bold text-gray-900 tracking-tighter">
{login.email}
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-xs font-mono font-medium">
{login.ipAddress}
</span>
<span className="text-[10px] text-gray-400 uppercase tracking-tighter truncate max-w-[120px]">
Gateway Transit
</span>
</div>
</td>
<td className="px-6 py-4">
<span className="px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest bg-rose-50 text-rose-600 border border-rose-100">
{login.reason || "Auth Failure"}
</span>
</td>
<td className="px-6 py-4 text-xs font-medium">
{format(new Date(login.timestamp), "MMM dd, HH:mm:ss")}
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none opacity-0 group-hover:opacity-100 transition-opacity"
>
<Ban className="w-3.5 h-3.5 text-gray-400 hover:text-rose-600" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{failedLogins?.data?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No failed login attempts found
</div>
)}
</>
)}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
>
No access violations recorded.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{failedLogins && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Total Violations: {failedLogins.total || 0}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => p + 1)}
disabled={
!failedLogins?.data || failedLogins.data.length < limit
}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</Card>
</div>
)
);
}

View File

@ -1,86 +1,89 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Shield, AlertTriangle, Key, Gauge, Users } from "lucide-react"
import { useNavigate } from "react-router-dom"
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Shield,
AlertTriangle,
Key,
Gauge,
Users,
ChevronRight,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { cn } from "@/lib/utils";
export default function SecurityPage() {
const navigate = useNavigate()
const navigate = useNavigate();
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Security</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/failed-logins')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Failed Logins
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
View and manage failed login attempts
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/suspicious')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Suspicious Activity
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Monitor suspicious IPs and emails
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/api-keys')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
API Keys
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Manage API keys and tokens
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/rate-limits')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Gauge className="w-5 h-5" />
Rate Limits
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
View rate limit violations
</p>
</CardContent>
</Card>
<Card className="cursor-pointer hover:bg-accent transition-colors" onClick={() => navigate('/admin/security/sessions')}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5" />
Active Sessions
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Manage active user sessions
</p>
</CardContent>
</Card>
{[
{
label: "Failed Logins",
description: "View and manage failed login attempts",
icon: AlertTriangle,
path: "/admin/security/failed-logins",
color: "text-rose-600",
},
{
label: "Suspicious Activity",
description: "Monitor suspicious IPs and emails",
icon: Shield,
path: "/admin/security/suspicious",
color: "text-amber-600",
},
{
label: "API Keys",
description: "Manage API keys and tokens",
icon: Key,
path: "/admin/security/api-keys",
color: "text-blue-600",
},
{
label: "Rate Limits",
description: "View rate limit violations",
icon: Gauge,
path: "/admin/security/rate-limits",
color: "text-purple-600",
},
{
label: "Active Sessions",
description: "Manage active user sessions",
icon: Users,
path: "/admin/security/sessions",
color: "text-emerald-600",
},
].map((item) => (
<Card
key={item.label}
className="group cursor-pointer border-slate-200/60 shadow-sm hover:shadow-md transition-all rounded-none bg-white overflow-hidden"
onClick={() => navigate(item.path)}
>
<CardHeader className="pb-2 space-y-0 border-b border-slate-50 bg-slate-50/30">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
{item.label}
</span>
<item.icon
className={cn("w-4 h-4 transition-colors", item.color)}
/>
</div>
</CardHeader>
<CardContent className="pt-4 flex items-end justify-between">
<div>
<p className="text-sm font-semibold text-slate-900 tracking-tight mb-1 group-hover:text-primary transition-colors">
{item.label}
</p>
<p className="text-xs text-muted-foreground leading-relaxed max-w-[200px]">
{item.description}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-300 group-hover:translate-x-1 transition-transform" />
</CardContent>
</Card>
))}
</div>
</div>
)
);
}

View File

@ -1,65 +1,136 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { securityService } from "@/services"
import type { RateLimitViolation } from "@/types/security.types"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Gauge, Clock, Activity, AlertTriangle } from "lucide-react";
import { securityService } from "@/services";
import type { RateLimitViolation } from "@/types/security.types";
export default function RateLimitsPage() {
const { data: violations, isLoading } = useQuery({
queryKey: ['admin', 'security', 'rate-limits'],
queryKey: ["admin", "security", "rate-limits"],
queryFn: () => securityService.getRateLimitViolations(7),
})
});
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Rate Limit Violations</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Traffic Control
</h1>
<p className="text-gray-500 mt-1">
Audit of rate limit violations and anomalous request frequencies.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Recent Violations (Last 7 Days)</CardTitle>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between bg-gray-50/30">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Violation Registry
</CardTitle>
<Gauge className="w-4 h-4 text-gray-300" />
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading violations...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Requests</TableHead>
<TableHead>Period</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{violations?.map((violation: RateLimitViolation) => (
<TableRow key={violation.id}>
<TableCell>{violation.userId || 'N/A'}</TableCell>
<TableCell className="font-mono text-sm">{violation.ipAddress}</TableCell>
<TableCell>{violation.requests}</TableCell>
<TableCell>{violation.period}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{violations?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No rate limit violations found
</div>
)}
</>
)}
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Protocol Identifier
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Network Origin
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
Velocity
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-center">
Reference Period
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Severity
</th>
</tr>
</thead>
<tbody className="divide-y text-gray-600">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Analyzing traffic patterns...
</td>
</tr>
) : violations && violations.length > 0 ? (
violations.map((violation: RateLimitViolation) => (
<tr
key={violation.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Clock className="w-3 h-3 text-gray-300" />
<span className="text-sm font-bold text-gray-900 tracking-tighter">
{violation.userId || "PUBLIC_TRANSIT"}
</span>
</div>
</td>
<td className="px-6 py-4">
<span className="text-xs font-mono font-medium">
{violation.ipAddress}
</span>
</td>
<td className="px-6 py-4 text-center">
<div className="flex items-center justify-center gap-1.5 text-xs font-bold text-gray-900">
<Activity className="w-3 h-3 text-rose-500" />
{violation.requests}{" "}
<span className="text-[10px] text-gray-400 font-medium">
REQ
</span>
</div>
</td>
<td className="px-6 py-4 text-center">
<span className="text-xs font-medium bg-gray-50 border px-2 py-0.5">
{violation.period}
</span>
</td>
<td className="px-6 py-4 text-right">
<span className="px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-amber-100 bg-amber-50 text-amber-600 rounded-none">
Warning
</span>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
>
Traffic volume within nominal limits.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
}
<div className="p-6 bg-slate-50 border border-slate-100 rounded-none flex items-start gap-4">
<AlertTriangle className="w-5 h-5 text-slate-400 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-900">
Adaptive Throttling Active
</p>
<p className="text-xs text-slate-500 leading-relaxed font-medium">
System is currently monitoring high-velocity traffic. Automatic
blocking protocols will engage if violation frequency exceeds 5% of
total ingress.
</p>
</div>
</div>
</div>
);
}

View File

@ -1,75 +1,132 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { LogOut } from "lucide-react"
import { securityService, type ActiveSession } from "@/services"
import { format } from "date-fns"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { LogOut, Monitor, MapPin, Clock } from "lucide-react";
import { securityService, type ActiveSession } from "@/services";
import { format } from "date-fns";
export default function SessionsPage() {
const { data: sessions, isLoading } = useQuery({
queryKey: ['admin', 'security', 'sessions'],
queryKey: ["admin", "security", "sessions"],
queryFn: () => securityService.getActiveSessions(),
})
});
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Active Sessions</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Active Sessions
</h1>
<p className="text-gray-500 mt-1">
Live oversight of authenticated system access.
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>All Active Sessions</CardTitle>
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Access Registry
</CardTitle>
<div className="px-3 py-1 bg-emerald-50 border border-emerald-100 text-[10px] font-bold text-emerald-600 uppercase tracking-widest">
{sessions?.length || 0} Authenticated Sessions
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading sessions...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>User Agent</TableHead>
<TableHead>Last Activity</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions?.map((session: ActiveSession) => (
<TableRow key={session.id}>
<TableCell className="font-medium">{session.userId || 'N/A'}</TableCell>
<TableCell className="font-mono text-sm">{session.ipAddress}</TableCell>
<TableCell className="max-w-xs truncate">{session.userAgent}</TableCell>
<TableCell>
{format(new Date(session.lastActivity), 'MMM dd, yyyy HH:mm')}
</TableCell>
<TableCell>
<Button variant="ghost" size="icon">
<LogOut className="w-4 h-4" />
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Operator Identity
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Endpoint
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Environment
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Activity Status
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Control
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Interrogating active sessions...
</td>
</tr>
) : sessions && sessions.length > 0 ? (
sessions.map((session: ActiveSession) => (
<tr
key={session.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900 tracking-tighter">
{session.userId || "N/A"}
</span>
<span className="text-[10px] text-gray-400 uppercase font-medium">
Internal Reference
</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-xs font-mono font-medium text-gray-600">
<MapPin className="w-3 h-3 text-gray-300" />
{session.ipAddress}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-xs font-medium text-gray-500">
<Monitor className="w-3 h-3 text-gray-300" />
<span className="truncate max-w-[150px]">
{session.userAgent}
</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-xs font-medium text-gray-900">
<Clock className="w-3 h-3 text-emerald-500" />
{format(new Date(session.lastActivity), "HH:mm:ss")}
</div>
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="sm"
className="h-8 rounded-none border border-transparent hover:border-rose-100 hover:bg-rose-50 hover:text-rose-600 transition-all font-bold uppercase tracking-widest text-[9px]"
>
<LogOut className="w-3 h-3 mr-2" /> Revoke
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{sessions?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No active sessions found
</div>
)}
</>
)}
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]"
>
No active authenticated sessions.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)
);
}

View File

@ -1,88 +1,148 @@
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Shield, Ban } from "lucide-react"
import { securityService } from "@/services"
import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types"
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Shield, Ban, Mail, Globe, AlertTriangle } from "lucide-react";
import { securityService } from "@/services";
import type { SuspiciousIP, SuspiciousEmail } from "@/types/security.types";
export default function SuspiciousActivityPage() {
const { data: suspicious, isLoading } = useQuery({
queryKey: ['admin', 'security', 'suspicious'],
queryKey: ["admin", "security", "suspicious"],
queryFn: () => securityService.getSuspiciousActivity(),
})
});
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold">Suspicious Activity</h2>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Anomalous Activity
</h1>
<p className="text-gray-500 mt-1">
High-risk identifiers flagged for potential system abuse.
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Suspicious IP Addresses
</CardTitle>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card className="border shadow-none rounded-none overflow-hidden">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-gray-400" />
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Suspicious Network Ingress
</CardTitle>
</div>
<span className="px-2 py-0.5 text-[9px] font-bold uppercase bg-rose-50 text-rose-600 border border-rose-100">
Shield Active
</span>
</CardHeader>
<CardContent>
<CardContent className="p-0">
{isLoading ? (
<div className="text-center py-8">Loading...</div>
<div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Interrogating global threats...
</div>
) : (suspicious?.suspiciousIPs?.length ?? 0) > 0 ? (
<div className="space-y-2">
{suspicious?.suspiciousIPs?.map((ip: SuspiciousIP, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded">
<div>
<p className="font-mono font-medium">{ip.ipAddress}</p>
<p className="text-sm text-muted-foreground">{ip.attempts} attempts</p>
<div className="divide-y">
{suspicious?.suspiciousIPs?.map(
(ip: SuspiciousIP, index: number) => (
<div
key={index}
className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
>
<div>
<p className="text-sm font-bold font-mono text-gray-900 tracking-tighter">
{ip.ipAddress}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">
{ip.attempts} Flagged Interactions
</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 rounded-none border border-transparent hover:border-gray-900 hover:bg-gray-900 hover:text-white transition-all font-bold uppercase tracking-widest text-[9px]"
>
<Ban className="w-3 h-3 mr-2" /> Block IP
</Button>
</div>
<Button variant="outline" size="sm">
<Ban className="w-4 h-4 mr-2" />
Block
</Button>
</div>
))}
),
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No suspicious IPs found
<div className="p-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
No high-risk network sources.
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Suspicious Emails
</CardTitle>
<Card className="border shadow-none rounded-none overflow-hidden">
<CardHeader className="border-b pb-4 bg-gray-50/30 flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400" />
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
Flagged Credentials
</CardTitle>
</div>
<span className="px-2 py-0.5 text-[9px] font-bold uppercase bg-emerald-50 text-emerald-600 border border-emerald-100">
Monitoring
</span>
</CardHeader>
<CardContent>
<CardContent className="p-0">
{isLoading ? (
<div className="text-center py-8">Loading...</div>
<div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Screening identity registry...
</div>
) : (suspicious?.suspiciousEmails?.length ?? 0) > 0 ? (
<div className="space-y-2">
{suspicious?.suspiciousEmails?.map((email: SuspiciousEmail, index: number) => (
<div key={index} className="flex items-center justify-between p-2 border rounded">
<div>
<p className="font-medium">{email.email}</p>
<p className="text-sm text-muted-foreground">{email.attempts} attempts</p>
<div className="divide-y">
{suspicious?.suspiciousEmails?.map(
(email: SuspiciousEmail, index: number) => (
<div
key={index}
className="flex items-center justify-between p-6 hover:bg-gray-50 transition-colors group"
>
<div>
<p className="text-sm font-bold text-gray-900 tracking-tighter">
{email.email}
</p>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">
{email.attempts} Security Triggers
</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 rounded-none border border-transparent hover:border-gray-900 hover:bg-gray-900 hover:text-white transition-all font-bold uppercase tracking-widest text-[9px]"
>
<Ban className="w-3 h-3 mr-2" /> Block Domain
</Button>
</div>
<Button variant="outline" size="sm">
<Ban className="w-4 h-4 mr-2" />
Block
</Button>
</div>
))}
),
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
No suspicious emails found
<div className="p-20 text-center text-gray-400 italic font-medium uppercase tracking-widest text-[10px]">
No suspicious identity triggers.
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}
<div className="p-6 bg-amber-50 border border-amber-100 rounded-none flex items-start gap-4">
<AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-[10px] font-bold uppercase tracking-widest text-amber-900">
Protocol Awareness
</p>
<p className="text-xs text-amber-700 leading-relaxed font-medium">
Flagged items above are generated based on heuristic analysis of
failed signatures, geofence violations, and credential stuffing
patterns. Actions taken here apply globally to the ingress proxy.
</p>
</div>
</div>
</div>
);
}

View File

@ -1,211 +1,143 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Switch } from "@/components/ui/switch"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Plus } from "lucide-react"
import { settingsService, type Setting } from "@/services"
import { toast } from "sonner"
import type { ApiError } from "@/types/error.types"
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { settingsService, type Setting } from "@/services";
import { toast } from "sonner";
import type { ApiError } from "@/types/error.types";
import { cn } from "@/lib/utils";
export default function SettingsPage() {
const queryClient = useQueryClient()
const [selectedCategory, setSelectedCategory] = useState<string>("GENERAL")
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [newSetting, setNewSetting] = useState({
key: "",
value: "",
description: "",
isPublic: false,
})
const queryClient = useQueryClient();
const [selectedCategory, setSelectedCategory] = useState<string>("GENERAL");
const { data: settings, isLoading } = useQuery({
queryKey: ['admin', 'settings', selectedCategory],
queryKey: ["admin", "settings", selectedCategory],
queryFn: () => settingsService.getSettings(selectedCategory),
})
});
const updateSettingMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) =>
mutationFn: ({ key, value }: { key: string; value: string }) =>
settingsService.updateSetting(key, { value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
toast.success("Setting updated successfully")
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
toast.success("Setting updated successfully");
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to update setting")
const apiError = error as ApiError;
toast.error(
apiError.response?.data?.message || "Failed to update setting",
);
},
})
const createSettingMutation = useMutation({
mutationFn: (data: {
key: string
value: string
category: string
description?: string
isPublic?: boolean
}) => settingsService.createSetting(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
toast.success("Setting created successfully")
setCreateDialogOpen(false)
setNewSetting({ key: "", value: "", description: "", isPublic: false })
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to create setting")
},
})
});
const handleSave = (key: string, value: string) => {
updateSettingMutation.mutate({ key, value })
}
updateSettingMutation.mutate({ key, value });
};
const handleCreate = () => {
if (!newSetting.key || !newSetting.value) {
toast.error("Key and value are required")
return
}
createSettingMutation.mutate({
key: newSetting.key,
value: newSetting.value,
category: selectedCategory,
description: newSetting.description || undefined,
isPublic: newSetting.isPublic,
})
}
const categories = [
"GENERAL",
"EMAIL",
"STORAGE",
"SECURITY",
"API",
"FEATURES",
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">System Settings</h2>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Setting
</Button>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
System Settings
</h1>
<p className="text-gray-500 mt-1">
Configure global application parameters.
</p>
</div>
<div className="flex items-center gap-2">
{/* View only access: Create Setting button removed */}
</div>
</div>
<Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
<TabsList>
<TabsTrigger value="GENERAL">General</TabsTrigger>
<TabsTrigger value="EMAIL">Email</TabsTrigger>
<TabsTrigger value="STORAGE">Storage</TabsTrigger>
<TabsTrigger value="SECURITY">Security</TabsTrigger>
<TabsTrigger value="API">API</TabsTrigger>
<TabsTrigger value="FEATURES">Features</TabsTrigger>
<Tabs
value={selectedCategory}
onValueChange={setSelectedCategory}
className="space-y-6"
>
<TabsList className="bg-gray-100/50 p-1 rounded-none border border-gray-200">
{categories.map((cat) => (
<TabsTrigger
key={cat}
value={cat}
className="rounded-none data-[state=active]:bg-white data-[state=active]:shadow-sm px-6 text-[10px] font-bold uppercase tracking-widest transition-all"
>
{cat}
</TabsTrigger>
))}
</TabsList>
<TabsContent value={selectedCategory} className="space-y-4">
<Card>
<CardHeader>
<CardTitle>{selectedCategory} Settings</CardTitle>
<TabsContent value={selectedCategory} className="outline-none">
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 bg-gray-50/30">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
{selectedCategory} Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="p-0 divide-y">
{isLoading ? (
<div className="text-center py-8">Loading settings...</div>
<div className="p-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]">
Fetching system variables...
</div>
) : settings && settings.length > 0 ? (
settings.map((setting: Setting) => (
<div key={setting.key} className="space-y-2">
<Label htmlFor={setting.key}>{setting.key}</Label>
<div className="flex gap-2">
<div
key={setting.key}
className="p-6 flex flex-col md:flex-row md:items-center justify-between gap-6 hover:bg-gray-50/50 transition-colors"
>
<div className="max-w-md">
<Label
className="text-sm font-bold text-gray-900 uppercase tracking-tighter"
htmlFor={setting.key}
>
{setting.key.replace(/\./g, " / ")}
</Label>
{setting.description && (
<p className="text-xs text-gray-400 mt-1 leading-relaxed">
{setting.description}
</p>
)}
</div>
<div className="flex-1 md:max-w-sm">
<Input
id={setting.key}
defaultValue={setting.value}
className={cn(
"h-10 rounded-none border-gray-200 text-sm font-medium focus-visible:ring-gray-900",
updateSettingMutation.isPending &&
"opacity-50 pointer-events-none",
)}
onBlur={(e) => {
if (e.target.value !== setting.value) {
handleSave(setting.key, e.target.value)
handleSave(setting.key, e.target.value);
}
}}
/>
</div>
{setting.description && (
<p className="text-sm text-muted-foreground">{setting.description}</p>
)}
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">
No settings found for this category
<div className="p-20 text-center text-gray-400 italic">
No variables defined for this category.
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Create Setting Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Setting</DialogTitle>
<DialogDescription>
Create a new system setting in the {selectedCategory} category
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="setting-key">Key *</Label>
<Input
id="setting-key"
placeholder="setting.key"
value={newSetting.key}
onChange={(e) => setNewSetting({ ...newSetting, key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="setting-value">Value *</Label>
<Input
id="setting-value"
placeholder="Setting value"
value={newSetting.value}
onChange={(e) => setNewSetting({ ...newSetting, value: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="setting-description">Description</Label>
<Input
id="setting-description"
placeholder="Setting description"
value={newSetting.description}
onChange={(e) => setNewSetting({ ...newSetting, description: e.target.value })}
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="setting-public"
checked={newSetting.isPublic}
onCheckedChange={(checked) => setNewSetting({ ...newSetting, isPublic: checked })}
/>
<Label htmlFor="setting-public" className="text-sm">
Public (accessible via API)
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setCreateDialogOpen(false)
setNewSetting({ key: "", value: "", description: "", isPublic: false })
}}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createSettingMutation.isPending}>
{createSettingMutation.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
);
}

View File

@ -1,409 +1,213 @@
import { useState } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "react-router-dom"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Search, Download, Eye, UserPlus, Trash2, Key, Upload } from "lucide-react"
import { userService } from "@/services"
import { toast } from "sonner"
import { format } from "date-fns"
import type { ApiError } from "@/types/error.types"
interface User {
id: string
email: string
firstName: string
lastName: string
role: string
isActive: boolean
createdAt: string
}
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Search, Eye, ChevronLeft, ChevronRight, Filter } from "lucide-react";
import { userService } from "@/services";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
export default function UsersPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [limit] = useState(20)
const [search, setSearch] = useState("")
const [roleFilter, setRoleFilter] = useState<string>("all")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [selectedUser, setSelectedUser] = useState<User | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [limit] = useState(15);
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>("all");
const { data: usersData, isLoading } = useQuery({
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
queryKey: ["admin", "users", page, limit, search, roleFilter],
queryFn: async () => {
const params: Record<string, string | number | boolean> = { page, limit }
if (search) params.search = search
if (roleFilter !== 'all') params.role = roleFilter
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
return await userService.getUsers(params)
const params: Record<string, string | number | boolean> = { page, limit };
if (search) params.search = search;
if (roleFilter !== "all") params.role = roleFilter;
return await userService.getUsers(params);
},
})
});
const deleteUserMutation = useMutation({
mutationFn: ({ id, hard }: { id: string; hard: boolean }) =>
userService.deleteUser(id, hard),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success("User deleted successfully")
setDeleteDialogOpen(false)
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to delete user")
},
})
const resetPasswordMutation = useMutation({
mutationFn: (id: string) => userService.resetPassword(id),
onSuccess: (data) => {
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
setResetPasswordDialogOpen(false)
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to reset password")
},
})
const importUsersMutation = useMutation({
mutationFn: (file: File) => userService.importUsers(file),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.success(`Imported ${data.imported} users. ${data.failed} failed.`)
setImportDialogOpen(false)
setImportFile(null)
},
onError: (error) => {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to import users")
},
})
const handleExport = async () => {
try {
const blob = await userService.exportUsers('csv')
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `users-${new Date().toISOString()}.csv`
a.click()
toast.success("Users exported successfully")
} catch (error) {
const apiError = error as ApiError
toast.error(apiError.response?.data?.message || "Failed to export users")
}
}
const handleDelete = () => {
if (selectedUser) {
deleteUserMutation.mutate({ id: selectedUser.id, hard: false })
}
}
const handleResetPassword = () => {
if (selectedUser) {
resetPasswordMutation.mutate(selectedUser.id)
}
}
const handleImport = () => {
if (importFile) {
importUsersMutation.mutate(importFile)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setImportFile(file)
}
}
const getRoleBadgeVariant = (role: string) => {
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'ADMIN':
return 'destructive'
case 'USER':
return 'default'
case 'VIEWER':
return 'secondary'
case "ADMIN":
return "text-rose-600 bg-rose-50 border-rose-100";
case "USER":
return "text-blue-600 bg-blue-50 border-blue-100";
case "VIEWER":
return "text-emerald-600 bg-emerald-50 border-emerald-100";
default:
return 'outline'
return "text-gray-600 bg-gray-50 border-gray-100";
}
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Users Management</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setImportDialogOpen(true)}>
<Upload className="w-4 h-4 mr-2" />
Import Users
</Button>
<Button>
<UserPlus className="w-4 h-4 mr-2" />
Add User
</Button>
<div className="space-y-8 max-w-7xl mx-auto bg-white p-4 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
Users
</h1>
<p className="text-gray-500 mt-1">
Manage system access and permissions.
</p>
</div>
<div className="flex items-center gap-2">
{/* View only access: Add User and Import buttons removed */}
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>All Users</CardTitle>
<div className="flex items-center gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search users..."
className="pl-10 w-64"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Roles" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="USER">User</SelectItem>
<SelectItem value="VIEWER">Viewer</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-40">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export
<Card className="border shadow-none rounded-none">
<CardHeader className="border-b pb-4 flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-xs font-bold uppercase tracking-widest text-gray-400">
User Directory
</CardTitle>
<div className="flex items-center gap-2">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
className="pl-10 h-9 rounded-none border-gray-200 text-xs"
placeholder="Search email or name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button
variant="outline"
size="sm"
className="h-9 rounded-none border-gray-200"
>
<Filter className="w-4 h-4 mr-2" /> Filter
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
User
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Role
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Status
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Created
</th>
<th className="px-6 py-4 text-[10px] font-bold text-gray-400 uppercase tracking-widest text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{isLoading ? (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 animate-pulse font-medium uppercase tracking-widest text-[10px]"
>
Retrieving user data...
</td>
</tr>
) : usersData?.data && usersData.data.length > 0 ? (
usersData.data.map((user: any) => (
<tr
key={user.id}
className="hover:bg-gray-50 transition-colors group"
>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-sm font-bold text-gray-900">
{user.firstName} {user.lastName}
</span>
<span className="text-[10px] text-gray-400">
{user.email}
</span>
</div>
</td>
<td className="px-6 py-4">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
getRoleBadgeColor(user.role),
)}
>
{user.role}
</span>
</td>
<td className="px-6 py-4">
<span
className={cn(
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest rounded-none border",
user.isActive
? "text-emerald-600 bg-emerald-50 border-emerald-100"
: "text-slate-600 bg-slate-50 border-slate-100",
)}
>
{user.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{format(new Date(user.createdAt), "MMM dd, yyyy")}
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => navigate(`/admin/users/${user.id}`)}
>
<Eye className="w-4 h-4 text-gray-400" />
</Button>
</td>
</tr>
))
) : (
<tr>
<td
colSpan={5}
className="px-6 py-20 text-center text-gray-400 italic"
>
No users found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
{usersData && (
<div className="p-4 border-t flex items-center justify-between bg-gray-50/30">
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">
Showing {(page - 1) * limit + 1} to{" "}
{Math.min(page * limit, usersData.total)} of {usersData.total}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-none"
onClick={() => setPage((p) => p + 1)}
disabled={page * limit >= usersData.total}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading users...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created At</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersData?.data?.map((user: User) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell>{user.firstName} {user.lastName}</TableCell>
<TableCell>
<Badge variant={getRoleBadgeVariant(user.role)}>
{user.role}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.isActive ? 'default' : 'secondary'}>
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</TableCell>
<TableCell>
{format(new Date(user.createdAt), 'MMM dd, yyyy')}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/admin/users/${user.id}`)}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelectedUser(user)
setResetPasswordDialogOpen(true)
}}
>
<Key className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelectedUser(user)
setDeleteDialogOpen(true)
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{usersData?.data?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
No users found
</div>
)}
{usersData && usersData.total > limit && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Showing {(page - 1) * limit + 1} to {Math.min(page * limit, usersData.total)} of {usersData.total} users
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(p => p + 1)}
disabled={page * limit >= usersData.total}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
)}
</Card>
{/* Delete Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
<DialogDescription>
Are you sure you want to delete {selectedUser?.email}? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reset Password Dialog */}
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset Password</DialogTitle>
<DialogDescription>
Reset password for {selectedUser?.email}? A temporary password will be generated.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setResetPasswordDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleResetPassword}>
Reset Password
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Import Users Dialog */}
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Import Users</DialogTitle>
<DialogDescription>
Upload a CSV file with user data. The file should contain columns: email, firstName, lastName, role (optional).
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="import-file">CSV File</Label>
<Input
id="import-file"
type="file"
accept=".csv"
onChange={handleFileChange}
/>
{importFile && (
<p className="text-sm text-muted-foreground">
Selected: {importFile.name}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => {
setImportDialogOpen(false)
setImportFile(null)
}}>
Cancel
</Button>
<Button onClick={handleImport} disabled={!importFile || importUsersMutation.isPending}>
{importUsersMutation.isPending ? "Importing..." : "Import"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
);
}

View File

@ -1,9 +1,9 @@
import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
@ -11,180 +11,166 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Search, Download, Eye, CheckCheck, Bell, Loader2 } from "lucide-react"
import { notificationService } from "@/services/notification.service"
import { toast } from "sonner"
} from "@/components/ui/table";
import {
Search,
Eye,
CheckCheck,
Loader2,
Calendar,
Mail,
Tag,
} from "lucide-react";
import { notificationService } from "@/services/notification.service";
import { toast } from "sonner";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
export default function NotificationsPage() {
const [searchQuery, setSearchQuery] = useState("")
const [typeFilter, setTypeFilter] = useState("")
const [statusFilter, setStatusFilter] = useState("")
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const { data: notifications, isLoading, refetch } = useQuery({
queryKey: ['notifications'],
const {
data: notifications,
isLoading,
refetch,
} = useQuery({
queryKey: ["notifications"],
queryFn: () => notificationService.getNotifications(),
})
});
const { data: unreadCount } = useQuery({
queryKey: ['notifications', 'unread-count'],
queryKey: ["notifications", "unread-count"],
queryFn: () => notificationService.getUnreadCount(),
})
});
// Client-side filtering
const filteredNotifications = useMemo(() => {
if (!notifications) return []
if (!notifications) return [];
return notifications.filter((notification) => {
// Type filter
if (typeFilter && notification.type !== typeFilter) return false
if (typeFilter && notification.type !== typeFilter) return false;
// Status filter
if (statusFilter === 'read' && !notification.isRead) return false
if (statusFilter === 'unread' && notification.isRead) return false
if (statusFilter === "read" && !notification.isRead) return false;
if (statusFilter === "unread" && notification.isRead) return false;
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase()
const query = searchQuery.toLowerCase();
return (
notification.title.toLowerCase().includes(query) ||
notification.message.toLowerCase().includes(query) ||
notification.recipient.toLowerCase().includes(query)
)
}
return true
})
}, [notifications, typeFilter, statusFilter, searchQuery])
const handleExport = () => {
try {
if (!filteredNotifications || filteredNotifications.length === 0) {
toast.error("No notifications to export")
return
);
}
const csvData = [
['Notification ID', 'Title', 'Message', 'Type', 'Recipient', 'Status', 'Created Date', 'Read Date'],
...filteredNotifications.map(n => [
n.id,
n.title,
n.message,
n.type,
n.recipient,
n.isRead ? 'Read' : 'Unread',
new Date(n.createdAt).toLocaleString(),
n.readAt ? new Date(n.readAt).toLocaleString() : '-'
])
]
const csvContent = csvData.map(row =>
row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')
).join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `notifications-${new Date().toISOString().split('T')[0]}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
toast.success("Notifications exported successfully!")
} catch (error) {
toast.error("Failed to export notifications")
console.error('Export error:', error)
}
}
return true;
});
}, [notifications, typeFilter, statusFilter, searchQuery]);
const handleMarkAsRead = async (id: string) => {
try {
await notificationService.markAsRead(id)
toast.success("Notification marked as read")
refetch()
await notificationService.markAsRead(id);
toast.success("Notification marked as read");
refetch();
} catch (error) {
toast.error("Failed to mark notification as read")
console.error('Mark as read error:', error)
toast.error("Failed to mark notification as read");
console.error("Mark as read error:", error);
}
}
};
const handleMarkAllAsRead = async () => {
try {
await notificationService.markAllAsRead()
toast.success("All notifications marked as read")
refetch()
await notificationService.markAllAsRead();
toast.success("All notifications marked as read");
refetch();
} catch (error) {
toast.error("Failed to mark all as read")
console.error('Mark all as read error:', error)
toast.error("Failed to mark all as read");
console.error("Mark all as read error:", error);
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
const formatDateTime = (dateString?: string) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
};
const getStatusBadge = (isRead: boolean) => {
return isRead ? 'bg-gray-500' : 'bg-orange-500'
}
return isRead ? (
<Badge
variant="outline"
className="text-[10px] font-bold uppercase tracking-widest text-slate-400 border-slate-200"
>
Read
</Badge>
) : (
<Badge
variant="default"
className="text-[10px] font-bold uppercase tracking-widest bg-orange-500 hover:bg-orange-600"
>
Unread
</Badge>
);
};
const getTypeIcon = (type: string) => {
switch (type.toLowerCase()) {
case "system":
return <Tag className="w-3.5 h-3.5 mr-1.5" />;
case "alert":
return <CheckCheck className="w-3.5 h-3.5 mr-1.5" />;
case "invoice":
return <Mail className="w-3.5 h-3.5 mr-1.5" />;
default:
return <Tag className="w-3.5 h-3.5 mr-1.5" />;
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-bold">Notifications</h2>
{unreadCount !== undefined && unreadCount > 0 && (
<p className="text-sm text-muted-foreground mt-1">
You have {unreadCount} unread notification{unreadCount !== 1 ? 's' : ''}
<h1 className="text-3xl font-bold tracking-tight">Notifications</h1>
{unreadCount !== undefined && unreadCount > 0 ? (
<p className="text-muted-foreground mt-1">
You have{" "}
<span className="font-bold text-slate-900">{unreadCount}</span>{" "}
unread notification{unreadCount !== 1 ? "s" : ""}
</p>
) : (
<p className="text-muted-foreground mt-1">
All messages been processed.
</p>
)}
</div>
<div className="flex gap-2">
{unreadCount !== undefined && unreadCount > 0 && (
<Button variant="outline" onClick={handleMarkAllAsRead}>
<Button
variant="outline"
size="sm"
onClick={handleMarkAllAsRead}
className="border-slate-200 text-[10px] font-bold uppercase tracking-widest h-9"
>
<CheckCheck className="w-4 h-4 mr-2" />
Mark All as Read
</Button>
)}
<Button>
<Bell className="w-4 h-4 mr-2" />
Settings
</Button>
</div>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>All Notifications</CardTitle>
<div className="flex items-center gap-4">
<div className="relative">
<Card className="border-slate-200/60 shadow-sm rounded-none">
<CardHeader className="pb-3 border-b border-slate-100 bg-slate-50/30">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-1 items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search notification..."
className="pl-10 w-64"
placeholder="Filter notifications..."
className="pl-9 h-10 border-slate-200/80 focus-visible:ring-slate-900 rounded-none shadow-none"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<select
className="px-3 py-2 border rounded-md text-sm"
<select
className="h-10 px-3 bg-white border border-slate-200 text-xs font-bold uppercase tracking-widest rounded-none focus:outline-none focus:ring-1 focus:ring-slate-900"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
@ -195,8 +181,8 @@ export default function NotificationsPage() {
<option value="invoice">Invoice</option>
<option value="payment">Payment</option>
</select>
<select
className="px-3 py-2 border rounded-md text-sm"
<select
className="h-10 px-3 bg-white border border-slate-200 text-xs font-bold uppercase tracking-widest rounded-none focus:outline-none focus:ring-1 focus:ring-slate-900"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
@ -204,55 +190,94 @@ export default function NotificationsPage() {
<option value="read">Read</option>
<option value="unread">Unread</option>
</select>
<Button variant="outline" onClick={handleExport}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<div className="flex items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-slate-300" />
</div>
) : filteredNotifications && filteredNotifications.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Notification ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Message</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created Date</TableHead>
<TableHead>Read Date</TableHead>
<TableHead>Action</TableHead>
<TableHeader className="bg-slate-50/50">
<TableRow className="hover:bg-transparent border-slate-100">
<TableHead className="w-[120px] text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6 py-4">
ID Reference
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Message Intelligence
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Classification
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
State
</TableHead>
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500 px-6">
Timeline
</TableHead>
<TableHead className="text-right text-[10px] font-bold uppercase tracking-widest text-slate-500 px-10">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredNotifications.map((notification) => (
<TableRow key={notification.id} className={!notification.isRead ? 'bg-blue-50' : ''}>
<TableCell className="font-medium">{notification.id}</TableCell>
<TableCell className="font-medium">{notification.title}</TableCell>
<TableCell className="max-w-xs truncate">{notification.message}</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">{notification.type}</Badge>
<TableRow
key={notification.id}
className={cn(
"group hover:bg-slate-50 transition-colors border-slate-100",
!notification.isRead && "bg-slate-50/20",
)}
>
<TableCell className="px-6 py-4">
<code className="text-[10px] font-bold text-slate-400 font-mono tracking-tighter">
{notification.id.substring(0, 12)}
</code>
</TableCell>
<TableCell>
<Badge className={getStatusBadge(notification.isRead)}>
{notification.isRead ? 'Read' : 'Unread'}
</Badge>
<TableCell className="px-6 py-4">
<div className="flex flex-col gap-0.5">
<span
className={cn(
"text-xs font-black tracking-tight uppercase",
notification.isRead
? "text-slate-500"
: "text-slate-900",
)}
>
{notification.title}
</span>
<span className="text-xs text-slate-500 line-clamp-1">
{notification.message}
</span>
</div>
</TableCell>
<TableCell>{formatDate(notification.createdAt)}</TableCell>
<TableCell>{formatDateTime(notification.readAt)}</TableCell>
<TableCell>
<TableCell className="px-6">
<div className="flex items-center text-[10px] font-bold uppercase tracking-widest text-slate-600">
{getTypeIcon(notification.type)}
{notification.type}
</div>
</TableCell>
<TableCell className="px-6">
{getStatusBadge(notification.isRead)}
</TableCell>
<TableCell className="px-6">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-slate-500">
<Calendar className="w-3.5 h-3.5 text-slate-300" />
{format(
new Date(notification.createdAt),
"MMM dd, HH:mm",
)}
</div>
</TableCell>
<TableCell className="text-right px-6">
{!notification.isRead && (
<Button
variant="ghost"
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-orange-500 transition-colors"
onClick={() => handleMarkAsRead(notification.id)}
title="Mark as read"
>
<Eye className="w-4 h-4" />
</Button>
@ -263,15 +288,14 @@ export default function NotificationsPage() {
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
{searchQuery || typeFilter || statusFilter
? 'No notifications match your filters'
: 'No notifications found'
}
<div className="text-center py-24 text-slate-400 font-bold uppercase tracking-widest text-[10px]">
{searchQuery || typeFilter || statusFilter
? "No matching telemetry records found"
: "No notification stream detected"}
</div>
)}
</CardContent>
</Card>
</div>
)
);
}

View File

@ -1,87 +1,92 @@
import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios'
import axios, {
type AxiosInstance,
type AxiosError,
type InternalAxiosRequestConfig,
} from "axios";
const API_BASE_URL = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1'
const API_BASE_URL =
import.meta.env.VITE_BACKEND_API_URL || "http://localhost:3001";
interface RetryableAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean
_retry?: boolean;
}
// Create axios instance with default config
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
withCredentials: true, // Send cookies with requests
timeout: 30000, // 30 second timeout
paramsSerializer: {
serialize: (params) => {
// Custom serializer to preserve number types
const searchParams = new URLSearchParams()
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value))
searchParams.append(key, String(value));
}
})
return searchParams.toString()
}
}
})
});
return searchParams.toString();
},
},
});
// Request interceptor - Add auth token
apiClient.interceptors.request.use(
(config) => {
// Add token from localStorage as fallback (cookies are preferred)
const token = localStorage.getItem('access_token')
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
return config
return config;
},
(error) => {
return Promise.reject(error)
}
)
return Promise.reject(error);
},
);
// Response interceptor - Handle errors and token refresh
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as RetryableAxiosRequestConfig
const originalRequest = error.config as RetryableAxiosRequestConfig;
// Handle 401 Unauthorized - Try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
originalRequest._retry = true;
try {
// Try to refresh token
const refreshToken = localStorage.getItem('refresh_token')
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
const response = await axios.post(
`${API_BASE_URL}/auth/refresh`,
{ refreshToken },
{ withCredentials: true }
)
{ withCredentials: true },
);
const { accessToken } = response.data
localStorage.setItem('access_token', accessToken)
const { accessToken } = response.data;
localStorage.setItem("access_token", accessToken);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return apiClient(originalRequest)
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
}
} catch (refreshError) {
// Refresh failed - logout user
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = '/login'
return Promise.reject(refreshError)
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
window.location.href = "/login";
return Promise.reject(refreshError);
}
}
return Promise.reject(error)
}
)
return Promise.reject(error);
},
);
export default apiClient
export default apiClient;

View File

@ -1,21 +1,57 @@
import apiClient from './api/client'
import type { ActivityLog } from '@/types/activity.types'
import apiClient from "./api/client";
import type { ActivityLog } from "@/types/activity.types";
export interface UserDashboardStats {
totalInvoices: number
totalTransactions: number
totalRevenue: number
pendingInvoices: number
growthPercentage?: number
recentActivity?: ActivityLog[]
totalInvoices: number;
totalTransactions: number;
totalRevenue: number;
pendingInvoices: number;
growthPercentage?: number;
recentActivity?: ActivityLog[];
}
export interface DashboardMetrics {
totalInvoices: number;
totalRevenue: number;
totalPayments: number;
pendingInvoices: number;
overdueInvoices: number;
paidInvoices: number;
}
export interface ScannedInvoice {
id: string;
invoiceNumber: string;
customerName: string;
amount: number;
issueDate: string;
scannedData: Record<string, any>;
}
export interface SalesPurchaseComparison {
sales: {
status: string;
count: number;
total: number;
}[];
purchases: {
status: string;
count: number;
total: number;
}[];
}
export interface InvoiceStatusBreakdown {
status: string;
count: number;
}
export interface UserProfile {
id: string
email: string
firstName: string
lastName: string
role: string
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
}
class DashboardService {
@ -23,37 +59,76 @@ class DashboardService {
* Get current user profile
*/
async getUserProfile(): Promise<UserProfile> {
const response = await apiClient.get<UserProfile>('/user/profile')
return response.data
const response = await apiClient.get<UserProfile>("/user/profile");
return response.data;
}
/**
* Get user dashboard statistics
*/
async getUserStats(): Promise<UserDashboardStats> {
const response = await apiClient.get<UserDashboardStats>('/user/stats')
return response.data
const response = await apiClient.get<UserDashboardStats>("/user/stats");
return response.data;
}
/**
* Get user recent activity
*/
async getRecentActivity(limit: number = 10): Promise<ActivityLog[]> {
const response = await apiClient.get<ActivityLog[]>('/user/activity', {
const response = await apiClient.get<ActivityLog[]>("/user/activity", {
params: { limit },
})
return response.data
});
return response.data;
}
/**
* Export user dashboard data
*/
async exportData(): Promise<Blob> {
const response = await apiClient.get('/user/export', {
responseType: 'blob',
})
return response.data
const response = await apiClient.get("/user/export", {
responseType: "blob",
});
return response.data;
}
/**
* Get main dashboard metrics
*/
async getMetrics(): Promise<DashboardMetrics> {
const response =
await apiClient.get<DashboardMetrics>("/dashboard/metrics");
return response.data;
}
/**
* Get scanned invoices pending verification
*/
async getScannedInvoices(): Promise<ScannedInvoice[]> {
const response = await apiClient.get<ScannedInvoice[]>(
"/dashboard/scanned-invoices",
);
return response.data;
}
/**
* Get sales vs purchase comparison
*/
async getSalesPurchaseComparison(): Promise<SalesPurchaseComparison> {
const response = await apiClient.get<SalesPurchaseComparison>(
"/dashboard/sales-purchase",
);
return response.data;
}
/**
* Get invoice status breakdown
*/
async getInvoiceStatusBreakdown(): Promise<InvoiceStatusBreakdown[]> {
const response = await apiClient.get<InvoiceStatusBreakdown[]>(
"/dashboard/invoice-status",
);
return response.data;
}
}
export const dashboardService = new DashboardService()
export const dashboardService = new DashboardService();

View File

@ -1,23 +1,64 @@
// Export all services from a single entry point
export { authService } from './auth.service'
export { userService } from './user.service'
export { analyticsService } from './analytics.service'
export { securityService } from './security.service'
export { systemService } from './system.service'
export { announcementService } from './announcement.service'
export { auditService } from './audit.service'
export { settingsService } from './settings.service'
export { dashboardService } from './dashboard.service'
export { notificationService } from './notification.service'
export { authService } from "./auth.service";
export { userService } from "./user.service";
export { analyticsService } from "./analytics.service";
export { securityService } from "./security.service";
export { systemService } from "./system.service";
export { announcementService } from "./announcement.service";
export { auditService } from "./audit.service";
export { settingsService } from "./settings.service";
export { dashboardService } from "./dashboard.service";
export { notificationService } from "./notification.service";
export { paymentService } from "./payment.service";
export { invoiceService } from "./invoice.service";
// Export types
export type { LoginRequest, LoginResponse } from './auth.service'
export type { User, GetUsersParams, PaginatedResponse } from './user.service'
export type { OverviewStats, UserGrowthData, RevenueData } from './analytics.service'
export type { SuspiciousActivity, ActiveSession, FailedLogin, ApiKey } from './security.service'
export type { HealthStatus, SystemInfo, MaintenanceStatus } from './system.service'
export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } from './announcement.service'
export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service'
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
export type { UserDashboardStats, UserProfile } from './dashboard.service'
export type { Notification, NotificationSettings } from './notification.service'
export type { LoginRequest, LoginResponse } from "./auth.service";
export type { User, GetUsersParams, PaginatedResponse } from "./user.service";
export type {
OverviewStats,
UserGrowthData,
RevenueData,
} from "./analytics.service";
export type {
SuspiciousActivity,
ActiveSession,
FailedLogin,
ApiKey,
} from "./security.service";
export type {
HealthStatus,
SystemInfo,
MaintenanceStatus,
} from "./system.service";
export type {
Announcement,
CreateAnnouncementData,
UpdateAnnouncementData,
} from "./announcement.service";
export type { AuditLog, GetAuditLogsParams, AuditStats } from "./audit.service";
export type {
Setting,
CreateSettingData,
UpdateSettingData,
} from "./settings.service";
export type { UserDashboardStats, UserProfile } from "./dashboard.service";
export type {
Notification,
NotificationSettings,
} from "./notification.service";
export type {
Payment,
PaymentRequest,
PaymentFilters,
PaymentRequestFilters,
} from "./payment.service";
export type {
Invoice,
Proforma,
ProformaRequest,
ProformaRequestItem,
InvoiceFilters,
ProformaFilters,
ProformaRequestFilters,
} from "./invoice.service";

View File

@ -0,0 +1,178 @@
import apiClient from "./api/client";
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export interface InvoiceItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
total: number;
}
export interface Invoice {
id: string;
invoiceNumber: string;
customerName: string;
customerEmail: string;
customerPhone: string;
amount: number;
currency: string;
type: "SALES" | "PURCHASE";
status: "DRAFT" | "PENDING" | "PAID" | "OVERDUE" | "CANCELLED";
issueDate: string;
dueDate: string;
paidDate?: string;
description: string;
notes: string;
taxAmount: number;
discountAmount: number;
isScanned: boolean;
scannedData?: any;
userId: string;
items: InvoiceItem[];
createdAt: string;
updatedAt: string;
}
export interface Proforma {
id: string;
proformaNumber: string;
customerName: string;
customerEmail: string;
customerPhone: string;
amount: number;
currency: string;
issueDate: string;
dueDate: string;
description: string;
notes: string;
taxAmount: number;
discountAmount: number;
pdfPath: string;
userId: string;
items: InvoiceItem[];
createdAt: string;
updatedAt: string;
}
export interface ProformaRequestItem {
id: string;
itemName: string;
quantity: number;
unitOfMeasure: string;
createdAt: string;
}
export interface ProformaRequest {
id: string;
requesterId: string;
title: string;
description: string;
category: "EQUIPMENT" | "SERVICE" | "MIXED";
status:
| "DRAFT"
| "OPEN"
| "UNDER_REVIEW"
| "REVISION_REQUESTED"
| "CLOSED"
| "CANCELLED";
submissionDeadline: string;
allowRevisions: boolean;
paymentTerms: string;
taxIncluded: boolean;
items: ProformaRequestItem[];
createdAt: string;
updatedAt: string;
}
export interface InvoiceFilters {
page?: number;
limit?: number;
status?: string;
type?: string;
startDate?: string;
endDate?: string;
customerName?: string;
invoiceNumber?: string;
search?: string;
}
export interface ProformaFilters {
page?: number;
limit?: number;
startDate?: string;
endDate?: string;
customerName?: string;
proformaNumber?: string;
search?: string;
}
export interface ProformaRequestFilters {
page?: number;
limit?: number;
status?: string;
category?: string;
search?: string;
deadlineFrom?: string;
deadlineTo?: string;
}
class InvoiceService {
/**
* Get all invoices
*/
async getInvoices(
filters: InvoiceFilters = {},
): Promise<PaginatedResponse<Invoice>> {
const response = await apiClient.get<PaginatedResponse<Invoice>>(
"/invoices",
{
params: filters,
},
);
return response.data;
}
/**
* Get all proforma invoices
*/
async getProformas(
filters: ProformaFilters = {},
): Promise<PaginatedResponse<Proforma>> {
const response = await apiClient.get<PaginatedResponse<Proforma>>(
"/proforma",
{
params: filters,
},
);
return response.data;
}
/**
* Get all proforma requests (admin view)
*/
async getProformaRequests(
filters: ProformaRequestFilters = {},
): Promise<PaginatedResponse<ProformaRequest>> {
const response = await apiClient.get<PaginatedResponse<ProformaRequest>>(
"/admin/proforma-requests",
{
params: filters,
},
);
return response.data;
}
}
export const invoiceService = new InvoiceService();

View File

@ -0,0 +1,118 @@
import apiClient from "./api/client";
export interface Payment {
id: string;
transactionId: string;
amount: number;
currency: string;
paymentDate: string;
paymentMethod: string;
notes: string;
isFlagged: boolean;
flagReason: string;
flagNotes: string;
receiptPath: string;
senderName: string;
senderId: string;
receiverName: string;
receiverId: string;
userId: string;
invoiceId: string;
createdAt: string;
updatedAt: string;
}
export interface PaymentRequest {
id: string;
paymentRequestNumber: string;
customerName: string;
customerEmail: string;
customerPhone: string;
amount: number;
currency: string;
issueDate: string;
dueDate: string;
description: string;
notes: string;
taxAmount: number;
discountAmount: number;
openedCount: number;
copiedAccountCount: number;
status: "DRAFT" | "SENT" | "OPENED" | "PAID" | "EXPIRED" | "CANCELLED";
paymentId: string;
accounts: any[];
pdfPath: string;
userId: string;
items: {
id: string;
description: string;
quantity: number;
unitPrice: number;
total: number;
}[];
createdAt: string;
updatedAt: string;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export interface PaymentFilters {
page?: number;
limit?: number;
invoiceId?: string;
}
export interface PaymentRequestFilters {
page?: number;
limit?: number;
startDate?: string;
endDate?: string;
customerName?: string;
paymentRequestNumber?: string;
status?: string;
search?: string;
}
class PaymentService {
/**
* Get all payments
*/
async getPayments(
filters: PaymentFilters = {},
): Promise<PaginatedResponse<Payment>> {
const response = await apiClient.get<PaginatedResponse<Payment>>(
"/payments",
{
params: filters,
},
);
return response.data;
}
/**
* Get payment requests
*/
async getPaymentRequests(
filters: PaymentRequestFilters = {},
): Promise<PaginatedResponse<PaymentRequest>> {
const response = await apiClient.get<PaginatedResponse<PaymentRequest>>(
"/payment-requests",
{
params: filters,
},
);
return response.data;
}
}
export const paymentService = new PaymentService();