diff options
Diffstat (limited to 'lib/vendors/table/view-vendors_logs-dialog.tsx')
| -rw-r--r-- | lib/vendors/table/view-vendors_logs-dialog.tsx | 244 |
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 |
