Admin
This commit is contained in:
parent
f80ff9317e
commit
20b0251259
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +32,35 @@ adminApi.interceptors.response.use(
|
|||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
toast.success("Exporting dashboard data...")
|
||||
}
|
||||
|
|
@ -215,6 +223,50 @@ export default function DashboardPage() {
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,29 @@ 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],
|
||||
|
|
@ -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) => {
|
||||
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>
|
||||
|
|
@ -88,6 +146,68 @@ export default function SettingsPage() {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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,
|
||||
|
|
@ -28,7 +29,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 { toast } from "sonner"
|
||||
import { format } from "date-fns"
|
||||
|
|
@ -44,6 +45,8 @@ export default function UsersPage() {
|
|||
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],
|
||||
|
|
@ -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 () => {
|
||||
try {
|
||||
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) => {
|
||||
switch (role) {
|
||||
case 'ADMIN':
|
||||
|
|
@ -129,11 +164,17 @@ export default function UsersPage() {
|
|||
<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>
|
||||
|
|
@ -320,6 +361,45 @@ export default function UsersPage() {
|
|||
</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