Compare commits
2 Commits
d7c860df17
...
20b0251259
| Author | SHA1 | Date | |
|---|---|---|---|
| 20b0251259 | |||
| f80ff9317e |
1248
package-lock.json
generated
1248
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -11,17 +11,27 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.11.0",
|
"react-router-dom": "^7.11.0",
|
||||||
|
"recharts": "^3.6.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0"
|
"tailwind-merge": "^3.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
67
src/App.tsx
67
src/App.tsx
|
|
@ -1,26 +1,63 @@
|
||||||
import { Navigate, Route, Routes } from "react-router-dom"
|
import { Navigate, Route, Routes } from "react-router-dom"
|
||||||
import { AppShell } from "@/layouts/app-shell"
|
import { AppShell } from "@/layouts/app-shell"
|
||||||
import DashboardPage from "@/pages/dashboard"
|
import DashboardPage from "@/pages/admin/dashboard"
|
||||||
import SubscriptionsPage from "@/pages/subscriptions"
|
import UsersPage from "@/pages/admin/users"
|
||||||
import TransactionsPage from "@/pages/transactions"
|
import UserDetailsPage from "@/pages/admin/users/[id]"
|
||||||
import ClientsPage from "@/pages/clients"
|
import UserActivityPage from "@/pages/admin/users/[id]/activity"
|
||||||
import MembersPage from "@/pages/members"
|
import LogsPage from "@/pages/admin/logs"
|
||||||
import NotificationsPage from "@/pages/notifications"
|
import ErrorLogsPage from "@/pages/admin/logs/errors"
|
||||||
import ActivityLogPage from "@/pages/activity-log"
|
import AccessLogsPage from "@/pages/admin/logs/access"
|
||||||
|
import LogDetailsPage from "@/pages/admin/logs/[id]"
|
||||||
|
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"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
<Route path="subscriptions" element={<SubscriptionsPage />} />
|
<Route path="admin/dashboard" element={<DashboardPage />} />
|
||||||
<Route path="transactions" element={<TransactionsPage />} />
|
<Route path="admin/users" element={<UsersPage />} />
|
||||||
<Route path="clients" element={<ClientsPage />} />
|
<Route path="admin/users/:id" element={<UserDetailsPage />} />
|
||||||
<Route path="members" element={<MembersPage />} />
|
<Route path="admin/users/:id/activity" element={<UserActivityPage />} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="admin/logs" element={<LogsPage />} />
|
||||||
<Route path="activity-log" element={<ActivityLogPage />} />
|
<Route path="admin/logs/errors" element={<ErrorLogsPage />} />
|
||||||
|
<Route path="admin/logs/access" element={<AccessLogsPage />} />
|
||||||
|
<Route path="admin/logs/:id" element={<LogDetailsPage />} />
|
||||||
|
<Route path="admin/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="admin/maintenance" element={<MaintenancePage />} />
|
||||||
|
<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/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/users" element={<AnalyticsUsersPage />} />
|
||||||
|
<Route path="admin/analytics/revenue" element={<AnalyticsRevenuePage />} />
|
||||||
|
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
|
||||||
|
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
|
||||||
|
<Route path="admin/health" element={<HealthPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
99
src/components/ui/date-range-picker.tsx
Normal file
99
src/components/ui/date-range-picker.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Calendar, X } from "lucide-react"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
interface DateRangePickerProps {
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
onDateRangeChange: (startDate?: string, endDate?: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateRangePicker({ startDate, endDate, onDateRangeChange, className }: DateRangePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [localStartDate, setLocalStartDate] = useState(startDate || "")
|
||||||
|
const [localEndDate, setLocalEndDate] = useState(endDate || "")
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
onDateRangeChange(localStartDate || undefined, localEndDate || undefined)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setLocalStartDate("")
|
||||||
|
setLocalEndDate("")
|
||||||
|
onDateRangeChange(undefined, undefined)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayText = () => {
|
||||||
|
if (startDate && endDate) {
|
||||||
|
try {
|
||||||
|
const start = format(new Date(startDate), "MMM dd, yyyy")
|
||||||
|
const end = format(new Date(endDate), "MMM dd, yyyy")
|
||||||
|
return `${start} - ${end}`
|
||||||
|
} catch {
|
||||||
|
return "Invalid date range"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Select date range"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className={className}>
|
||||||
|
<Calendar className="w-4 h-4 mr-2" />
|
||||||
|
{displayText()}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Select Date Range</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a start and end date to filter logs
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="start-date">Start Date</Label>
|
||||||
|
<Input
|
||||||
|
id="start-date"
|
||||||
|
type="date"
|
||||||
|
value={localStartDate}
|
||||||
|
onChange={(e) => setLocalStartDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="end-date">End Date</Label>
|
||||||
|
<Input
|
||||||
|
id="end-date"
|
||||||
|
type="date"
|
||||||
|
value={localEndDate}
|
||||||
|
onChange={(e) => setLocalEndDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={handleClear}>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleApply}>Apply</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
121
src/components/ui/dialog.tsx
Normal file
121
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
|
|
||||||
25
src/components/ui/label.tsx
Normal file
25
src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
|
|
||||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
|
|
||||||
28
src/components/ui/switch.tsx
Normal file
28
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
|
|
||||||
54
src/components/ui/tabs.tsx
Normal file
54
src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
|
|
||||||
27
src/components/ui/toast.tsx
Normal file
27
src/components/ui/toast.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
FolderKanban,
|
|
||||||
Calendar,
|
|
||||||
Leaf,
|
|
||||||
Settings,
|
|
||||||
Bell,
|
|
||||||
HelpCircle,
|
|
||||||
DollarSign,
|
|
||||||
FileText,
|
|
||||||
Users,
|
Users,
|
||||||
Briefcase,
|
FileText,
|
||||||
Mail,
|
Settings,
|
||||||
Copy,
|
Wrench,
|
||||||
UserPlus,
|
|
||||||
Megaphone,
|
Megaphone,
|
||||||
|
Shield,
|
||||||
|
BarChart3,
|
||||||
|
Activity,
|
||||||
|
Heart,
|
||||||
Search,
|
Search,
|
||||||
|
Mail,
|
||||||
|
Bell,
|
||||||
|
LogOut,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -24,35 +22,34 @@ import { Separator } from "@/components/ui/separator"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const navigationItems = [
|
|
||||||
{ icon: LayoutDashboard, label: "Dashboard", path: "/" },
|
|
||||||
{ icon: FolderKanban, label: "Projects", path: "/projects" },
|
|
||||||
{ icon: Calendar, label: "Calendar", path: "/calendar" },
|
|
||||||
{ icon: Leaf, label: "Leave Management", path: "/leave" },
|
|
||||||
{ icon: Settings, label: "Settings", path: "/settings" },
|
|
||||||
{ icon: Bell, label: "Notification", path: "/notifications" },
|
|
||||||
{ icon: HelpCircle, label: "Help & Center", path: "/help" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const adminNavigationItems = [
|
const adminNavigationItems = [
|
||||||
{ icon: Users, label: "Subscriptions", path: "/subscriptions" },
|
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
|
||||||
{ icon: DollarSign, label: "Transactions", path: "/transactions" },
|
{ icon: Users, label: "Users", path: "/admin/users" },
|
||||||
{ icon: Briefcase, label: "Clients", path: "/clients" },
|
{ icon: FileText, label: "Logs", path: "/admin/logs" },
|
||||||
{ icon: Users, label: "Members", path: "/members" },
|
{ icon: Settings, label: "Settings", path: "/admin/settings" },
|
||||||
{ icon: Bell, label: "Notifications", path: "/notifications" },
|
{ icon: Wrench, label: "Maintenance", path: "/admin/maintenance" },
|
||||||
{ icon: FileText, label: "Activity Log", path: "/activity-log" },
|
{ icon: Megaphone, label: "Announcements", path: "/admin/announcements" },
|
||||||
|
{ icon: Activity, label: "Audit", path: "/admin/audit" },
|
||||||
|
{ 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() {
|
export function AppShell() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === "/") {
|
|
||||||
return location.pathname === "/"
|
|
||||||
}
|
|
||||||
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"
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-background">
|
<div className="flex h-screen bg-background">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
|
|
@ -60,38 +57,13 @@ export function AppShell() {
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-6 flex items-center gap-2">
|
<div className="p-6 flex items-center gap-2">
|
||||||
<div className="w-10 h-10 bg-primary rounded flex items-center justify-center">
|
<div className="w-10 h-10 bg-primary rounded flex items-center justify-center">
|
||||||
<span className="text-primary-foreground font-bold text-lg">H</span>
|
<span className="text-primary-foreground font-bold text-lg">A</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-foreground font-semibold text-lg">TurHR</span>
|
<span className="text-foreground font-semibold text-lg">Admin Panel</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-2 space-y-1">
|
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto">
|
||||||
{navigationItems.map((item) => {
|
|
||||||
const Icon = item.icon
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Separator className="my-4" />
|
|
||||||
|
|
||||||
<div className="text-xs font-semibold text-foreground/50 uppercase tracking-wider px-3 py-2">
|
|
||||||
Admin
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{adminNavigationItems.map((item) => {
|
{adminNavigationItems.map((item) => {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
return (
|
return (
|
||||||
|
|
@ -112,31 +84,29 @@ export function AppShell() {
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Announcements Card */}
|
{/* User Section */}
|
||||||
<div className="p-4">
|
<div className="p-4 border-t">
|
||||||
<Card>
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<CardContent className="p-4">
|
<Avatar>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<AvatarFallback>AD</AvatarFallback>
|
||||||
<Megaphone className="w-4 h-4 text-primary" />
|
</Avatar>
|
||||||
<span className="font-semibold text-sm">Announcements</span>
|
<div className="flex-1 min-w-0">
|
||||||
</div>
|
<p className="text-sm font-medium truncate">Admin User</p>
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground truncate">admin@example.com</p>
|
||||||
Stay updated with TurHR
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<Button size="sm" className="w-full">
|
<Button
|
||||||
Create Now
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
<div className="mt-3 text-xs text-muted-foreground">
|
className="w-full"
|
||||||
Read by:
|
onClick={() => {
|
||||||
</div>
|
localStorage.removeItem('access_token')
|
||||||
<div className="flex items-center gap-1 mt-1">
|
window.location.href = '/login'
|
||||||
<div className="w-6 h-6 rounded-full bg-primary/20" />
|
}}
|
||||||
<div className="w-6 h-6 rounded-full bg-primary/20" />
|
>
|
||||||
<div className="w-6 h-6 rounded-full bg-primary/20" />
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
<span className="text-xs text-muted-foreground ml-1">+10</span>
|
Logout
|
||||||
</div>
|
</Button>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -144,7 +114,7 @@ export function AppShell() {
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
||||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
|
@ -157,21 +127,12 @@ export function AppShell() {
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<Copy className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="relative">
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
<Users className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-xs px-1 rounded">
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
||||||
+10
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<Button>
|
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
Invite
|
|
||||||
</Button>
|
</Button>
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarFallback>U</AvatarFallback>
|
<AvatarFallback>AD</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
277
src/lib/api-client.ts
Normal file
277
src/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
import axios, { type AxiosInstance, type AxiosError } from 'axios';
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const adminApi: AxiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add token interceptor
|
||||||
|
adminApi.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add response interceptor for error handling
|
||||||
|
adminApi.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Redirect to login
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
// Show access denied
|
||||||
|
const message = error.response?.data?.message || 'You do not have permission to access this resource';
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
import('sonner').then(({ toast }) => {
|
||||||
|
toast.error(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (error.response?.status === 404) {
|
||||||
|
const message = error.response?.data?.message || 'Resource not found';
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
import('sonner').then(({ toast }) => {
|
||||||
|
toast.error(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (error.response?.status === 500) {
|
||||||
|
const message = error.response?.data?.message || 'Server error occurred. Please try again later.';
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
import('sonner').then(({ toast }) => {
|
||||||
|
toast.error(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!error.response) {
|
||||||
|
// Network error
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
import('sonner').then(({ toast }) => {
|
||||||
|
toast.error('Network error. Please check your connection.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// API helper functions
|
||||||
|
export const adminApiHelpers = {
|
||||||
|
// Users
|
||||||
|
getUsers: (params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
role?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
search?: string;
|
||||||
|
}) => adminApi.get('/admin/users', { params }),
|
||||||
|
|
||||||
|
getUser: (id: string) => adminApi.get(`/admin/users/${id}`),
|
||||||
|
|
||||||
|
getUserActivity: (id: string, days: number = 30) =>
|
||||||
|
adminApi.get(`/admin/users/${id}/activity`, { params: { days } }),
|
||||||
|
|
||||||
|
updateUser: (id: string, data: {
|
||||||
|
role?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
}) => adminApi.put(`/admin/users/${id}`, data),
|
||||||
|
|
||||||
|
deleteUser: (id: string, hard: boolean = false) =>
|
||||||
|
adminApi.delete(`/admin/users/${id}?hard=${hard}`),
|
||||||
|
|
||||||
|
resetPassword: (id: string) =>
|
||||||
|
adminApi.post(`/admin/users/${id}/reset-password`),
|
||||||
|
|
||||||
|
exportUsers: (format: string = 'csv') =>
|
||||||
|
adminApi.post('/admin/users/export', null, { params: { format } }),
|
||||||
|
|
||||||
|
importUsers: (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return adminApi.post('/admin/users/import', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
getLogs: (params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
level?: string;
|
||||||
|
type?: string;
|
||||||
|
userId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
search?: string;
|
||||||
|
minDuration?: number;
|
||||||
|
}) => adminApi.get('/admin/logs', { params }),
|
||||||
|
|
||||||
|
getErrorLogs: (params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
userId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}) => adminApi.get('/admin/logs/errors', { params }),
|
||||||
|
|
||||||
|
getAccessLogs: (params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
userId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}) => adminApi.get('/admin/logs/access', { params }),
|
||||||
|
|
||||||
|
getLogById: (id: string) => adminApi.get(`/admin/logs/${id}`),
|
||||||
|
|
||||||
|
getLogStats: (startDate?: string, endDate?: string) =>
|
||||||
|
adminApi.get('/admin/logs/stats/summary', { params: { startDate, endDate } }),
|
||||||
|
|
||||||
|
exportLogs: (params: {
|
||||||
|
format?: string;
|
||||||
|
level?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}) => adminApi.post('/admin/logs/export', null, { params }),
|
||||||
|
|
||||||
|
cleanupLogs: (days: number = 30) =>
|
||||||
|
adminApi.post('/admin/logs/cleanup', null, { params: { days } }),
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
getOverview: () => adminApi.get('/admin/analytics/overview'),
|
||||||
|
|
||||||
|
getUserGrowth: (days: number = 30) =>
|
||||||
|
adminApi.get('/admin/analytics/users/growth', { params: { days } }),
|
||||||
|
|
||||||
|
getRevenue: (period: string = '30days') =>
|
||||||
|
adminApi.get('/admin/analytics/revenue', { params: { period } }),
|
||||||
|
|
||||||
|
getStorageAnalytics: () => adminApi.get('/admin/analytics/storage'),
|
||||||
|
|
||||||
|
getApiUsage: (days: number = 7) =>
|
||||||
|
adminApi.get('/admin/analytics/api-usage', { params: { days } }),
|
||||||
|
|
||||||
|
getErrorRate: (days: number = 7) =>
|
||||||
|
adminApi.get('/admin/analytics/error-rate', { params: { days } }),
|
||||||
|
|
||||||
|
// System
|
||||||
|
getHealth: () => adminApi.get('/admin/system/health'),
|
||||||
|
|
||||||
|
getSystemInfo: () => adminApi.get('/admin/system/info'),
|
||||||
|
|
||||||
|
getSettings: (category?: string) =>
|
||||||
|
adminApi.get('/admin/system/settings', { params: { category } }),
|
||||||
|
|
||||||
|
getSetting: (key: string) => adminApi.get(`/admin/system/settings/${key}`),
|
||||||
|
|
||||||
|
createSetting: (data: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
category: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}) => adminApi.post('/admin/system/settings', data),
|
||||||
|
|
||||||
|
updateSetting: (key: string, data: {
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}) => adminApi.put(`/admin/system/settings/${key}`, data),
|
||||||
|
|
||||||
|
deleteSetting: (key: string) => adminApi.delete(`/admin/system/settings/${key}`),
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
getMaintenanceStatus: () => adminApi.get('/admin/maintenance'),
|
||||||
|
|
||||||
|
enableMaintenance: (message?: string) =>
|
||||||
|
adminApi.post('/admin/maintenance/enable', { message }),
|
||||||
|
|
||||||
|
disableMaintenance: () => adminApi.post('/admin/maintenance/disable'),
|
||||||
|
|
||||||
|
// Announcements
|
||||||
|
getAnnouncements: (activeOnly: boolean = false) =>
|
||||||
|
adminApi.get('/admin/announcements', { params: { activeOnly } }),
|
||||||
|
|
||||||
|
createAnnouncement: (data: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type?: string;
|
||||||
|
priority?: number;
|
||||||
|
targetAudience?: string;
|
||||||
|
startsAt?: string;
|
||||||
|
endsAt?: string;
|
||||||
|
}) => adminApi.post('/admin/announcements', data),
|
||||||
|
|
||||||
|
updateAnnouncement: (id: string, data: {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
type?: string;
|
||||||
|
priority?: number;
|
||||||
|
targetAudience?: string;
|
||||||
|
startsAt?: string;
|
||||||
|
endsAt?: string;
|
||||||
|
}) => adminApi.put(`/admin/announcements/${id}`, data),
|
||||||
|
|
||||||
|
toggleAnnouncement: (id: string) =>
|
||||||
|
adminApi.patch(`/admin/announcements/${id}/toggle`),
|
||||||
|
|
||||||
|
deleteAnnouncement: (id: string) =>
|
||||||
|
adminApi.delete(`/admin/announcements/${id}`),
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
getAuditLogs: (params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
userId?: string;
|
||||||
|
action?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}) => adminApi.get('/admin/audit/logs', { params }),
|
||||||
|
|
||||||
|
getUserAuditActivity: (userId: string, days: number = 30) =>
|
||||||
|
adminApi.get(`/admin/audit/users/${userId}`, { params: { days } }),
|
||||||
|
|
||||||
|
getResourceHistory: (type: string, id: string) =>
|
||||||
|
adminApi.get(`/admin/audit/resource/${type}/${id}`),
|
||||||
|
|
||||||
|
getAuditStats: (startDate?: string, endDate?: string) =>
|
||||||
|
adminApi.get('/admin/audit/stats', { params: { startDate, endDate } }),
|
||||||
|
|
||||||
|
// Security
|
||||||
|
getFailedLogins: (params?: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
email?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
}) => adminApi.get('/admin/security/failed-logins', { params }),
|
||||||
|
|
||||||
|
getSuspiciousActivity: () => adminApi.get('/admin/security/suspicious-activity'),
|
||||||
|
|
||||||
|
getAllApiKeys: () => adminApi.get('/admin/security/api-keys'),
|
||||||
|
|
||||||
|
revokeApiKey: (id: string) =>
|
||||||
|
adminApi.patch(`/admin/security/api-keys/${id}/revoke`),
|
||||||
|
|
||||||
|
getRateLimitViolations: (days: number = 7) =>
|
||||||
|
adminApi.get('/admin/security/rate-limits', { params: { days } }),
|
||||||
|
|
||||||
|
getActiveSessions: () => adminApi.get('/admin/security/sessions'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default adminApi;
|
||||||
|
|
||||||
|
|
@ -5,12 +5,14 @@ import App from './App.tsx'
|
||||||
import { BrowserRouter } from "react-router-dom"
|
import { BrowserRouter } from "react-router-dom"
|
||||||
import { QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClientProvider } from "@tanstack/react-query"
|
||||||
import { queryClient } from "@/app/query-client"
|
import { queryClient } from "@/app/query-client"
|
||||||
|
import { Toaster } from "@/components/ui/toast"
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|
|
||||||
114
src/pages/admin/analytics/api.tsx
Normal file
114
src/pages/admin/analytics/api.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
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 { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
|
||||||
|
export default function AnalyticsApiPage() {
|
||||||
|
const { data: apiUsage, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'api-usage'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getApiUsage(7)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'error-rate'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getErrorRate(7)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Endpoint Usage (Last 7 Days)</CardTitle>
|
||||||
|
</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: any, index: number) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell className="font-mono text-sm">{endpoint.endpoint}</TableCell>
|
||||||
|
<TableCell>{endpoint.calls}</TableCell>
|
||||||
|
<TableCell>{endpoint.avgDuration?.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>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
87
src/pages/admin/analytics/index.tsx
Normal file
87
src/pages/admin/analytics/index.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { BarChart3, Users, DollarSign, HardDrive, Activity } from "lucide-react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
export default function AnalyticsPage() {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
6
src/pages/admin/analytics/overview.tsx
Normal file
6
src/pages/admin/analytics/overview.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import DashboardPage from "../../dashboard"
|
||||||
|
|
||||||
|
export default function AnalyticsOverviewPage() {
|
||||||
|
return <DashboardPage />
|
||||||
|
}
|
||||||
|
|
||||||
47
src/pages/admin/analytics/revenue.tsx
Normal file
47
src/pages/admin/analytics/revenue.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
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 { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
|
||||||
|
export default function AnalyticsRevenuePage() {
|
||||||
|
const { data: revenue, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'revenue'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getRevenue('90days')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
119
src/pages/admin/analytics/storage.tsx
Normal file
119
src/pages/admin/analytics/storage.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
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 { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
|
||||||
|
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8']
|
||||||
|
|
||||||
|
export default function AnalyticsStoragePage() {
|
||||||
|
const { data: storage, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'storage'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getStorageAnalytics()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = storage?.byCategory?.map((cat: any) => ({
|
||||||
|
name: cat.category,
|
||||||
|
value: cat.size,
|
||||||
|
})) || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">Storage Analytics</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Storage Overview</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center">Loading...</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'}
|
||||||
|
</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>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-[300px] flex items-center justify-center">Loading...</div>
|
||||||
|
) : chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
labelLine={false}
|
||||||
|
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{chartData.map((entry: any, index: number) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
||||||
|
No data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{storage?.topUsers && storage.topUsers.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Top 10 Users by Storage Usage</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{storage.topUsers.map((user: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-2 border rounded">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.user}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{user.files} files</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium">{formatBytes(user.size)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
49
src/pages/admin/analytics/users.tsx
Normal file
49
src/pages/admin/analytics/users.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
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 { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
|
||||||
|
export default function AnalyticsUsersPage() {
|
||||||
|
const { data: userGrowth, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getUserGrowth(90)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">User Analytics</h2>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Growth (Last 90 Days)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-[400px] flex items-center justify-center">Loading...</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>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[400px] flex items-center justify-center text-muted-foreground">
|
||||||
|
No data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
166
src/pages/admin/announcements/index.tsx
Normal file
166
src/pages/admin/announcements/index.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Megaphone, Plus, Edit, Trash2 } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
export default function AnnouncementsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [selectedAnnouncement, setSelectedAnnouncement] = useState<any>(null)
|
||||||
|
|
||||||
|
const { data: announcements, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'announcements'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getAnnouncements(false)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await adminApiHelpers.deleteAnnouncement(id)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'announcements'] })
|
||||||
|
toast.success("Announcement deleted successfully")
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to delete announcement")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (selectedAnnouncement) {
|
||||||
|
deleteMutation.mutate(selectedAnnouncement.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-3xl font-bold">Announcements</h2>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Announcement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Announcements</CardTitle>
|
||||||
|
</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: any) => (
|
||||||
|
<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">
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAnnouncement(announcement)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{announcements?.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No announcements found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Delete Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Announcement</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete "{selectedAnnouncement?.title}"? 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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
106
src/pages/admin/audit/index.tsx
Normal file
106
src/pages/admin/audit/index.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
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 {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Search, Eye } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
export default function AuditPage() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [limit] = useState(50)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
const { data: auditData, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'audit', 'logs', page, limit, search],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params: any = { page, limit }
|
||||||
|
if (search) params.search = search
|
||||||
|
const response = await adminApiHelpers.getAuditLogs(params)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">Audit Logs</h2>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</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: any) => (
|
||||||
|
<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.createdAt), '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>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
306
src/pages/admin/dashboard/index.tsx
Normal file
306
src/pages/admin/dashboard/index.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
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, TrendingUp, AlertCircle } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: overview, isLoading: overviewLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'overview'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getOverview()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: userGrowth, isLoading: growthLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'users', 'growth'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getUserGrowth(30)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: revenue, isLoading: revenueLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'revenue'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getRevenue('30days')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: health, isLoading: healthLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'system', 'health'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getHealth()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'analytics', 'error-rate'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getErrorRate(7)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
toast.success("Exporting dashboard data...")
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).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]
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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)
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
183
src/pages/admin/health/index.tsx
Normal file
183
src/pages/admin/health/index.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
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, Database, Users, Activity } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
|
||||||
|
export default function HealthPage() {
|
||||||
|
const { data: health, isLoading: healthLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'system', 'health'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getHealth()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: 30000, // Refetch every 30 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: systemInfo, isLoading: infoLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'system', 'info'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getSystemInfo()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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" />
|
||||||
|
default:
|
||||||
|
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 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]
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{systemInfo && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>System Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{infoLoading ? (
|
||||||
|
<div className="text-center py-8">Loading system info...</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}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Architecture</p>
|
||||||
|
<p className="font-medium">{systemInfo.architecture}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Uptime</p>
|
||||||
|
<p className="font-medium">{formatUptime(systemInfo.uptime)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Environment</p>
|
||||||
|
<p className="font-medium">{systemInfo.env}</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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
116
src/pages/admin/maintenance/index.tsx
Normal file
116
src/pages/admin/maintenance/index.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
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 { Switch } from "@/components/ui/switch"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
export default function MaintenancePage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
|
||||||
|
const { data: status, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'maintenance'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getMaintenanceStatus()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const enableMutation = useMutation({
|
||||||
|
mutationFn: async (msg?: string) => {
|
||||||
|
await adminApiHelpers.enableMaintenance(msg)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
||||||
|
toast.success("Maintenance mode enabled")
|
||||||
|
setMessage("")
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to enable maintenance mode")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const disableMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await adminApiHelpers.disableMaintenance()
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'maintenance'] })
|
||||||
|
toast.success("Maintenance mode disabled")
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to disable maintenance mode")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleToggle = (enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
enableMutation.mutate(message || undefined)
|
||||||
|
} else {
|
||||||
|
disableMutation.mutate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-center py-8">Loading maintenance status...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">Maintenance Mode</h2>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>Maintenance Status</CardTitle>
|
||||||
|
<Badge variant={status?.enabled ? 'destructive' : 'default'}>
|
||||||
|
{status?.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={status?.enabled || false}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!status?.enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="message">Maintenance Message (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="message"
|
||||||
|
placeholder="Enter maintenance message..."
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This message will be displayed to users when maintenance mode is enabled
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.enabled && status?.message && (
|
||||||
|
<div>
|
||||||
|
<Label>Current Message</Label>
|
||||||
|
<p className="text-sm mt-2">{status.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
105
src/pages/admin/security/api-keys.tsx
Normal file
105
src/pages/admin/security/api-keys.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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 { Key, Ban } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
export default function ApiKeysPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: apiKeys, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'security', 'api-keys'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getAllApiKeys()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await adminApiHelpers.revokeApiKey(id)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'security', 'api-keys'] })
|
||||||
|
toast.success("API key revoked successfully")
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to revoke API key")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">API Keys</h2>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All API Keys</CardTitle>
|
||||||
|
</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: any) => (
|
||||||
|
<TableRow key={key.id}>
|
||||||
|
<TableCell className="font-medium">{key.name}</TableCell>
|
||||||
|
<TableCell>{key.userId || 'N/A'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{key.lastUsedAt ? format(new Date(key.lastUsedAt), 'MMM dd, yyyy') : 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={key.revoked ? 'destructive' : 'default'}>
|
||||||
|
{key.revoked ? 'Revoked' : 'Active'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{!key.revoked && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => revokeMutation.mutate(key.id)}
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{apiKeys?.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No API keys found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
105
src/pages/admin/security/failed-logins.tsx
Normal file
105
src/pages/admin/security/failed-logins.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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 {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Search, Ban } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
export default function FailedLoginsPage() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [limit] = useState(50)
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
const { data: failedLogins, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'security', 'failed-logins', page, limit, search],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params: any = { page, limit }
|
||||||
|
if (search) params.email = search
|
||||||
|
const response = await adminApiHelpers.getFailedLogins(params)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">Failed Login Attempts</h2>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by email..."
|
||||||
|
className="pl-10 w-64"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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: any) => (
|
||||||
|
<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.userAgent}</TableCell>
|
||||||
|
<TableCell>{login.reason || 'N/A'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{format(new Date(login.attemptedAt), 'MMM dd, yyyy HH:mm')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={login.blocked ? 'destructive' : 'secondary'}>
|
||||||
|
{login.blocked ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{failedLogins?.data?.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No failed login attempts found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
87
src/pages/admin/security/index.tsx
Normal file
87
src/pages/admin/security/index.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Shield, AlertTriangle, Key, Gauge, Users } from "lucide-react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
|
export default function SecurityPage() {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
67
src/pages/admin/security/rate-limits.tsx
Normal file
67
src/pages/admin/security/rate-limits.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
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 { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
|
||||||
|
export default function RateLimitsPage() {
|
||||||
|
const { data: violations, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'security', 'rate-limits'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getRateLimitViolations(7)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">Rate Limit Violations</h2>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Violations (Last 7 Days)</CardTitle>
|
||||||
|
</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: any) => (
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
78
src/pages/admin/security/sessions.tsx
Normal file
78
src/pages/admin/security/sessions.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
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 { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
export default function SessionsPage() {
|
||||||
|
const { data: sessions, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'security', 'sessions'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getActiveSessions()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">Active Sessions</h2>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Active Sessions</CardTitle>
|
||||||
|
</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: any) => (
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{sessions?.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No active sessions found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
91
src/pages/admin/security/suspicious.tsx
Normal file
91
src/pages/admin/security/suspicious.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Shield, Ban } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
|
||||||
|
export default function SuspiciousActivityPage() {
|
||||||
|
const { data: suspicious, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'security', 'suspicious'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getSuspiciousActivity()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-3xl font-bold">Suspicious Activity</h2>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">Loading...</div>
|
||||||
|
) : suspicious?.suspiciousIPs?.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suspicious.suspiciousIPs.map((ip: any, 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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
Suspicious Emails
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">Loading...</div>
|
||||||
|
) : suspicious?.suspiciousEmails?.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suspicious.suspiciousEmails.map((email: any, 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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
214
src/pages/admin/settings/index.tsx
Normal file
214
src/pages/admin/settings/index.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
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 { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
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 { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'settings', selectedCategory],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getSettings(selectedCategory)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateSettingMutation = useMutation({
|
||||||
|
mutationFn: async ({ key, value }: { key: string; value: string }) => {
|
||||||
|
await adminApiHelpers.updateSetting(key, { value })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||||
|
toast.success("Setting updated successfully")
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to update setting")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createSettingMutation = useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
category: string
|
||||||
|
description?: string
|
||||||
|
isPublic?: boolean
|
||||||
|
}) => {
|
||||||
|
await adminApiHelpers.createSetting(data)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
|
||||||
|
toast.success("Setting created successfully")
|
||||||
|
setCreateDialogOpen(false)
|
||||||
|
setNewSetting({ key: "", value: "", description: "", isPublic: false })
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to create setting")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = (key: string, value: string) => {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value={selectedCategory} className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{selectedCategory} Settings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8">Loading settings...</div>
|
||||||
|
) : settings && settings.length > 0 ? (
|
||||||
|
settings.map((setting: any) => (
|
||||||
|
<div key={setting.id} className="space-y-2">
|
||||||
|
<Label htmlFor={setting.key}>{setting.key}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id={setting.key}
|
||||||
|
defaultValue={setting.value}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.target.value !== setting.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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
66
src/pages/admin/users/[id]/activity.tsx
Normal file
66
src/pages/admin/users/[id]/activity.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useParams, useNavigate } from "react-router-dom"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
export default function UserActivityPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const { data: activity, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'users', id, 'activity'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getUserActivity(id!, 30)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-center py-8">Loading activity...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users/${id}`)}>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-3xl font-bold">User Activity</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Activity Timeline (Last 30 Days)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{activity && activity.length > 0 ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activity.map((item: any, index: number) => (
|
||||||
|
<div key={index} className="border-l-2 pl-4 pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.action || 'Activity'}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{item.description || item.message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{format(new Date(item.createdAt || item.timestamp), 'PPpp')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No activity found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
155
src/pages/admin/users/[id]/index.tsx
Normal file
155
src/pages/admin/users/[id]/index.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { useParams, useNavigate } from "react-router-dom"
|
||||||
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import { ArrowLeft, Edit, Key, Trash2 } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
export default function UserDetailsPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const { data: user, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'users', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await adminApiHelpers.getUser(id!)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateUserMutation = useMutation({
|
||||||
|
mutationFn: async (data: any) => {
|
||||||
|
await adminApiHelpers.updateUser(id!, data)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users', id] })
|
||||||
|
toast.success("User updated successfully")
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to update user")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="text-center py-8">Loading user details...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <div className="text-center py-8">User not found</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/users')}>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-3xl font-bold">User Details</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="info" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="info">Information</TabsTrigger>
|
||||||
|
<TabsTrigger value="statistics">Statistics</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity" onClick={() => navigate(`/admin/users/${id}/activity`)}>
|
||||||
|
Activity
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="info" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>User Information</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Key className="w-4 h-4 mr-2" />
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Email</p>
|
||||||
|
<p className="font-medium">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Name</p>
|
||||||
|
<p className="font-medium">{user.firstName} {user.lastName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Role</p>
|
||||||
|
<Badge>{user.role}</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Status</p>
|
||||||
|
<Badge variant={user.isActive ? 'default' : 'secondary'}>
|
||||||
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Created At</p>
|
||||||
|
<p className="font-medium">{format(new Date(user.createdAt), 'PPpp')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Updated At</p>
|
||||||
|
<p className="font-medium">{format(new Date(user.updatedAt), 'PPpp')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="statistics" className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">Invoices</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{user._count?.invoices || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">Reports</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{user._count?.reports || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">Documents</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{user._count?.documents || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-medium">Payments</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{user._count?.payments || 0}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
406
src/pages/admin/users/index.tsx
Normal file
406
src/pages/admin/users/index.tsx
Normal file
|
|
@ -0,0 +1,406 @@
|
||||||
|
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, MoreVertical, UserPlus, Edit, Trash2, Key, Upload } from "lucide-react"
|
||||||
|
import { adminApiHelpers } from "@/lib/api-client"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { format } from "date-fns"
|
||||||
|
|
||||||
|
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<any>(null)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
|
||||||
|
const [importDialogOpen, setImportDialogOpen] = useState(false)
|
||||||
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
|
|
||||||
|
const { data: usersData, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params: any = { page, limit }
|
||||||
|
if (search) params.search = search
|
||||||
|
if (roleFilter !== 'all') params.role = roleFilter
|
||||||
|
if (statusFilter !== 'all') params.isActive = statusFilter === 'active'
|
||||||
|
const response = await adminApiHelpers.getUsers(params)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteUserMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, hard }: { id: string; hard: boolean }) => {
|
||||||
|
await adminApiHelpers.deleteUser(id, hard)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||||
|
toast.success("User deleted successfully")
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to delete user")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPasswordMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const response = await adminApiHelpers.resetPassword(id)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(`Password reset. Temporary password: ${data.temporaryPassword}`)
|
||||||
|
setResetPasswordDialogOpen(false)
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to reset password")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const importUsersMutation = useMutation({
|
||||||
|
mutationFn: async (file: File) => {
|
||||||
|
const response = await adminApiHelpers.importUsers(file)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||||
|
toast.success(`Imported ${data.success} users. ${data.failed} failed.`)
|
||||||
|
setImportDialogOpen(false)
|
||||||
|
setImportFile(null)
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
console.error('Import errors:', data.errors)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error.response?.data?.message || "Failed to import users")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
try {
|
||||||
|
const response = await adminApiHelpers.exportUsers('csv')
|
||||||
|
const blob = new Blob([response.data], { type: 'text/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: any) {
|
||||||
|
toast.error(error.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) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return 'destructive'
|
||||||
|
case 'USER':
|
||||||
|
return 'default'
|
||||||
|
case 'VIEWER':
|
||||||
|
return 'secondary'
|
||||||
|
default:
|
||||||
|
return 'outline'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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
|
||||||
|
</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: any) => (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user