From ef4c533ebacc2cdc97e518f30e9a9350004fcdfb Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 28 Apr 2025 02:13:30 +0000 Subject: ~20250428 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/view-candidate_logs-dialog.tsx | 246 +++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 lib/vendor-candidates/table/view-candidate_logs-dialog.tsx (limited to 'lib/vendor-candidates/table/view-candidate_logs-dialog.tsx') diff --git a/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx new file mode 100644 index 00000000..6d119bf3 --- /dev/null +++ b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx @@ -0,0 +1,246 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { ScrollArea } from "@/components/ui/scroll-area" +import { formatDateTime } from "@/lib/utils" +import { CandidateLogWithUser, getCandidateLogs } from "../service" +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" + +interface ViewCandidateLogsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + candidateId: number | null +} + +export function ViewCandidateLogsDialog({ + open, + onOpenChange, + candidateId, +}: ViewCandidateLogsDialogProps) { + const [logs, setLogs] = React.useState([]) + const [filteredLogs, setFilteredLogs] = React.useState([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState(null) + const [searchQuery, setSearchQuery] = React.useState("") + const [actionFilter, setActionFilter] = React.useState("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 && candidateId) { + setLoading(true) + setError(null) + getCandidateLogs(candidateId) + .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, candidateId, 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", `candidate-logs-${candidateId}-${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 ( +
+ Status: + {oldStatus} + + {newStatus} +
+ ) + } + + return ( + + + + Audit Logs + + + {/* Filters and search */} + {/* Filters and search */} +
+
+
+ +
+ setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ + + + +
+ +
+ {loading && ( +
+

Loading logs...

+
+ )} + + {error && !loading && ( +
+ {error} +
+ )} + + {!loading && !error && filteredLogs.length === 0 && ( +

+ {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."} +

+ )} + + {!loading && !error && filteredLogs.length > 0 && ( + <> +
+ Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'} + {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`} +
+ + {filteredLogs.map((log) => ( +
+
+ {log.action} +
+ {formatDateTime(log.createdAt)} +
+
+ + {log.oldStatus && log.newStatus && ( +
+ {renderStatusChange(log.oldStatus, log.newStatus)} +
+ )} + + {log.comment && ( +
+ Comment: {log.comment} +
+ )} + + {(log.userName || log.userEmail) && ( +
+ + {log.userName || "Unknown"} + {log.userEmail && ({log.userEmail})} +
+ )} +
+ ))} +
+ + )} +
+
+
+ ) +} \ No newline at end of file -- cgit v1.2.3