This commit is contained in:
Kirubel-Kibru-Yaltopia 2026-01-09 19:32:22 +03:00
parent f80ff9317e
commit 20b0251259
5 changed files with 386 additions and 6 deletions

View 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>
)
}

View File

@ -32,6 +32,35 @@ adminApi.interceptors.response.use(
// Redirect to login // Redirect to login
localStorage.removeItem('access_token'); localStorage.removeItem('access_token');
window.location.href = '/login'; 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); return Promise.reject(error);
} }

View File

@ -39,6 +39,14 @@ export default function DashboardPage() {
}, },
}) })
const { data: errorRate, isLoading: errorRateLoading } = useQuery({
queryKey: ['admin', 'analytics', 'error-rate'],
queryFn: async () => {
const response = await adminApiHelpers.getErrorRate(7)
return response.data
},
})
const handleExport = () => { const handleExport = () => {
toast.success("Exporting dashboard data...") toast.success("Exporting dashboard data...")
} }
@ -215,6 +223,50 @@ export default function DashboardPage() {
</Card> </Card>
</div> </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 */} {/* System Health */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -5,12 +5,29 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 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 { adminApiHelpers } from "@/lib/api-client"
import { toast } from "sonner" import { toast } from "sonner"
export default function SettingsPage() { export default function SettingsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [selectedCategory, setSelectedCategory] = useState<string>("GENERAL") 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({ const { data: settings, isLoading } = useQuery({
queryKey: ['admin', 'settings', selectedCategory], queryKey: ['admin', 'settings', selectedCategory],
@ -33,13 +50,54 @@ export default function SettingsPage() {
}, },
}) })
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) => { const handleSave = (key: string, value: string) => {
updateSettingMutation.mutate({ key, value }) updateSettingMutation.mutate({ key, value })
} }
const handleCreate = () => {
if (!newSetting.key || !newSetting.value) {
toast.error("Key and value are required")
return
}
createSettingMutation.mutate({
key: newSetting.key,
value: newSetting.value,
category: selectedCategory,
description: newSetting.description || undefined,
isPublic: newSetting.isPublic,
})
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-3xl font-bold">System Settings</h2> <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}> <Tabs value={selectedCategory} onValueChange={setSelectedCategory}>
<TabsList> <TabsList>
@ -88,6 +146,68 @@ export default function SettingsPage() {
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </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> </div>
) )
} }

View File

@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import { import {
Table, Table,
TableBody, TableBody,
@ -28,7 +29,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Search, Download, Eye, MoreVertical, UserPlus, Edit, Trash2, Key } from "lucide-react" import { Search, Download, Eye, MoreVertical, UserPlus, Edit, Trash2, Key, Upload } from "lucide-react"
import { adminApiHelpers } from "@/lib/api-client" import { adminApiHelpers } from "@/lib/api-client"
import { toast } from "sonner" import { toast } from "sonner"
import { format } from "date-fns" import { format } from "date-fns"
@ -44,6 +45,8 @@ export default function UsersPage() {
const [selectedUser, setSelectedUser] = useState<any>(null) const [selectedUser, setSelectedUser] = useState<any>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false) const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const { data: usersData, isLoading } = useQuery({ const { data: usersData, isLoading } = useQuery({
queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter], queryKey: ['admin', 'users', page, limit, search, roleFilter, statusFilter],
@ -85,6 +88,25 @@ export default function UsersPage() {
}, },
}) })
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 () => { const handleExport = async () => {
try { try {
const response = await adminApiHelpers.exportUsers('csv') const response = await adminApiHelpers.exportUsers('csv')
@ -112,6 +134,19 @@ export default function UsersPage() {
} }
} }
const handleImport = () => {
if (importFile) {
importUsersMutation.mutate(importFile)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setImportFile(file)
}
}
const getRoleBadgeVariant = (role: string) => { const getRoleBadgeVariant = (role: string) => {
switch (role) { switch (role) {
case 'ADMIN': case 'ADMIN':
@ -129,10 +164,16 @@ export default function UsersPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-3xl font-bold">Users Management</h2> <h2 className="text-3xl font-bold">Users Management</h2>
<Button> <div className="flex gap-2">
<UserPlus className="w-4 h-4 mr-2" /> <Button variant="outline" onClick={() => setImportDialogOpen(true)}>
Add User <Upload className="w-4 h-4 mr-2" />
</Button> Import Users
</Button>
<Button>
<UserPlus className="w-4 h-4 mr-2" />
Add User
</Button>
</div>
</div> </div>
<Card> <Card>
@ -320,6 +361,45 @@ export default function UsersPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
) )
} }