summaryrefslogtreecommitdiff
path: root/lib/vendors/table/view-vendors_logs-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendors/table/view-vendors_logs-dialog.tsx')
-rw-r--r--lib/vendors/table/view-vendors_logs-dialog.tsx244
1 files changed, 244 insertions, 0 deletions
diff --git a/lib/vendors/table/view-vendors_logs-dialog.tsx b/lib/vendors/table/view-vendors_logs-dialog.tsx
new file mode 100644
index 00000000..7402ae55
--- /dev/null
+++ b/lib/vendors/table/view-vendors_logs-dialog.tsx
@@ -0,0 +1,244 @@
+"use client"
+
+import * as React from "react"
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { useToast } from "@/hooks/use-toast"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Badge } from "@/components/ui/badge"
+import { Download, Search, User } from "lucide-react"
+import { VendorsLogWithUser, getVendorLogs } from "../service"
+
+interface VendorLogsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ vendorId: number | null
+}
+
+export function ViewVendorLogsDialog({
+ open,
+ onOpenChange,
+ vendorId,
+}: VendorLogsDialogProps) {
+ const [logs, setLogs] = React.useState<VendorsLogWithUser[]>([])
+ const [filteredLogs, setFilteredLogs] = React.useState<VendorsLogWithUser[]>([])
+ const [loading, setLoading] = React.useState(false)
+ const [error, setError] = React.useState<string | null>(null)
+ const [searchQuery, setSearchQuery] = React.useState("")
+ const [actionFilter, setActionFilter] = React.useState<string>("all")
+ const { toast } = useToast()
+
+ // Get unique action types for filter dropdown
+ const actionTypes = React.useMemo(() => {
+ if (!logs.length) return []
+ return Array.from(new Set(logs.map(log => log.action)))
+ }, [logs])
+
+ // Fetch logs when dialog opens
+ React.useEffect(() => {
+ if (open && vendorId) {
+ setLoading(true)
+ setError(null)
+ getVendorLogs(vendorId)
+ .then((res) => {
+ setLogs(res)
+ setFilteredLogs(res)
+ })
+ .catch((err) => {
+ console.error(err)
+ setError("Failed to load logs. Please try again.")
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: "Failed to load candidate logs",
+ })
+ })
+ .finally(() => setLoading(false))
+ } else {
+ // Reset state when dialog closes
+ setSearchQuery("")
+ setActionFilter("all")
+ }
+ }, [open, vendorId, toast])
+
+ // Filter logs based on search query and action filter
+ React.useEffect(() => {
+ if (!logs.length) return
+
+ let result = [...logs]
+
+ // Apply action filter
+ if (actionFilter !== "all") {
+ result = result.filter(log => log.action === actionFilter)
+ }
+
+ // Apply search filter (case insensitive)
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase()
+ result = result.filter(log =>
+ log.action.toLowerCase().includes(query) ||
+ (log.comment && log.comment.toLowerCase().includes(query)) ||
+ (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) ||
+ (log.newStatus && log.newStatus.toLowerCase().includes(query)) ||
+ (log.userName && log.userName.toLowerCase().includes(query)) ||
+ (log.userEmail && log.userEmail.toLowerCase().includes(query))
+ )
+ }
+
+ setFilteredLogs(result)
+ }, [logs, searchQuery, actionFilter])
+
+ // Export logs as CSV
+ const exportLogs = () => {
+ if (!filteredLogs.length) return
+
+ const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"]
+ const csvContent = [
+ headers.join(","),
+ ...filteredLogs.map(log => [
+ `"${log.action}"`,
+ `"${log.oldStatus || ''}"`,
+ `"${log.newStatus || ''}"`,
+ `"${log.comment?.replace(/"/g, '""') || ''}"`,
+ `"${log.userName || ''}"`,
+ `"${log.userEmail || ''}"`,
+ `"${formatDateTime(log.createdAt)}"`
+ ].join(","))
+ ].join("\n")
+
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.setAttribute("href", url)
+ link.setAttribute("download", `vendor-logs-${vendorId}-${new Date().toISOString().split('T')[0]}.csv`)
+ link.style.visibility = "hidden"
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+
+ // Render status change with appropriate badge
+ const renderStatusChange = (oldStatus: string, newStatus: string) => {
+ return (
+ <div className="text-sm flex flex-wrap gap-2 items-center">
+ <strong>Status:</strong>
+ <Badge variant="outline" className="text-xs">{oldStatus}</Badge>
+ <span>→</span>
+ <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge>
+ </div>
+ )
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[700px]">
+ <DialogHeader>
+ <DialogTitle>Audit Logs</DialogTitle>
+ </DialogHeader>
+
+ {/* Filters and search */}
+ <div className="flex items-center gap-2 mb-4">
+ <div className="relative flex-1">
+ <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
+ <Search className="h-4 w-4 text-muted-foreground" />
+ </div>
+ <Input
+ placeholder="Search logs..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+
+ <Select
+ value={actionFilter}
+ onValueChange={setActionFilter}
+ >
+ <SelectTrigger className="w-[180px]">
+ <SelectValue placeholder="Filter by action" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">All actions</SelectItem>
+ {actionTypes.map(action => (
+ <SelectItem key={action} value={action}>{action}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <Button
+ size="icon"
+ variant="outline"
+ onClick={exportLogs}
+ disabled={filteredLogs.length === 0}
+ title="Export to CSV"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+
+ <div className="space-y-2">
+ {loading && (
+ <div className="flex justify-center py-8">
+ <p className="text-muted-foreground">Loading logs...</p>
+ </div>
+ )}
+
+ {error && !loading && (
+ <div className="bg-destructive/10 text-destructive p-3 rounded-md">
+ {error}
+ </div>
+ )}
+
+ {!loading && !error && filteredLogs.length === 0 && (
+ <p className="text-muted-foreground text-center py-8">
+ {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."}
+ </p>
+ )}
+
+ {!loading && !error && filteredLogs.length > 0 && (
+ <>
+ <div className="text-xs text-muted-foreground mb-2">
+ Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'}
+ {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`}
+ </div>
+ <div className="max-h-96 space-y-4 pr-4 overflow-y-auto">
+ {filteredLogs.map((log) => (
+ <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors">
+ <div className="flex justify-between items-start mb-2">
+ <Badge className="text-xs">{log.action}</Badge>
+ <div className="text-xs text-muted-foreground">
+ {formatDateTime(log.createdAt)}
+ </div>
+ </div>
+
+ {log.oldStatus && log.newStatus && (
+ <div className="my-2">
+ {renderStatusChange(log.oldStatus, log.newStatus)}
+ </div>
+ )}
+
+ {log.comment && (
+ <div className="my-2 text-sm bg-muted/50 p-2 rounded-md">
+ <strong>Comment:</strong> {log.comment}
+ </div>
+ )}
+
+ {(log.userName || log.userEmail) && (
+ <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground">
+ <User className="h-3 w-3 mr-1" />
+ {log.userName || "Unknown"}
+ {log.userEmail && <span className="ml-1">({log.userEmail})</span>}
+ </div>
+ )}
+ </div>
+ ))}
+ </div>
+ </>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file