feat: Implement smart factory log functionality

- Added a new controller for managing smart factory logs, including retrieval and statistics endpoints.
- Integrated smart factory log migration to set up the necessary database structure.
- Enhanced the authentication controller to include user name in log submissions.
- Developed a frontend page for displaying and filtering smart factory logs, accessible only to super admins.
- Implemented API calls for fetching logs and statistics, improving data visibility and management.

These changes aim to provide comprehensive logging capabilities for smart factory activities, enhancing monitoring and analysis for administrators.
This commit is contained in:
kjs
2026-04-07 10:35:16 +09:00
parent c48dd95045
commit 822f9ac35a
9 changed files with 895 additions and 7 deletions
@@ -0,0 +1,478 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Search,
RefreshCw,
ChevronLeft,
ChevronRight,
Factory,
CheckCircle2,
XCircle,
MinusCircle,
Building2,
Activity,
Users,
} from "lucide-react";
import {
getSmartFactoryLogs,
getSmartFactoryLogStats,
SmartFactoryLogEntry,
SmartFactoryLogFilters,
SmartFactoryLogStats,
} from "@/lib/api/smartFactoryLog";
import { getCompanyList } from "@/lib/api/company";
import { useAuth } from "@/hooks/useAuth";
import { Company } from "@/types/company";
const STATUS_CONFIG = {
SUCCESS: { label: "성공", variant: "success" as const, icon: CheckCircle2 },
FAIL: { label: "실패", variant: "destructive" as const, icon: XCircle },
SKIPPED: { label: "건너뜀", variant: "secondary" as const, icon: MinusCircle },
};
export default function SmartFactoryLogPage() {
const { user } = useAuth();
const [logs, setLogs] = useState<SmartFactoryLogEntry[]>([]);
const [stats, setStats] = useState<SmartFactoryLogStats | null>(null);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [filters, setFilters] = useState<SmartFactoryLogFilters>({
companyCode: "",
sendStatus: "",
search: "",
dateFrom: "",
dateTo: "",
page: 1,
limit: 50,
});
// 회사 목록 로드
useEffect(() => {
getCompanyList()
.then(setCompanies)
.catch(() => {});
}, []);
// 로그 데이터 로드
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const [logRes, statsRes] = await Promise.all([
getSmartFactoryLogs(filters),
getSmartFactoryLogStats(
filters.companyCode || undefined,
30
),
]);
if (logRes.success) {
setLogs(logRes.data);
setTotal(logRes.total);
}
if (statsRes.success) {
setStats(statsRes.data);
}
} catch (error) {
console.error("스마트공장 로그 조회 실패:", error);
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const totalPages = Math.ceil(total / (filters.limit || 50));
const handleFilterChange = (key: keyof SmartFactoryLogFilters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value, page: 1 }));
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
const d = new Date(dateStr);
return d.toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
// 최고관리자가 아니면 접근 불가 안내
if (user && user.userType !== "SUPER_ADMIN" && user.companyCode !== "*") {
return (
<div className="flex items-center justify-center h-[60vh]">
<p className="text-muted-foreground"> .</p>
</div>
);
}
return (
<div className="p-6 space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Factory className="h-6 w-6" />
</h1>
<p className="text-muted-foreground text-sm mt-1">
log.smart-factory.kr로
</p>
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
{/* 통계 카드 */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-sm text-muted-foreground"> 30 </p>
<p className="text-2xl font-bold">{stats.total.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<CheckCircle2 className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold text-green-600">
{(stats.statusCounts.find((s) => s.status === "SUCCESS")?.count || 0).toLocaleString()}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 rounded-lg">
<XCircle className="h-5 w-5 text-red-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold text-red-600">
{(stats.statusCounts.find((s) => s.status === "FAIL")?.count || 0).toLocaleString()}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Building2 className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm text-muted-foreground"> </p>
<p className="text-2xl font-bold text-blue-600">
{stats.companyCounts.length}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* 회사별 현황 */}
{stats && stats.companyCounts.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />
( 30)
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{stats.companyCounts.map((c) => (
<button
key={c.companyCode}
className={`p-3 rounded-lg border text-left hover:bg-accent transition-colors ${
filters.companyCode === c.companyCode
? "border-primary bg-primary/5"
: ""
}`}
onClick={() =>
handleFilterChange(
"companyCode",
filters.companyCode === c.companyCode ? "" : c.companyCode
)
}
>
<p className="text-xs text-muted-foreground truncate">
{c.companyName}
</p>
<p className="text-lg font-semibold">{c.count.toLocaleString()}</p>
</button>
))}
</div>
</CardContent>
</Card>
)}
{/* 필터 */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[200px]">
<label className="text-xs text-muted-foreground mb-1 block"></label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자, IP, 에러메시지..."
className="pl-9"
value={filters.search || ""}
onChange={(e) => handleFilterChange("search", e.target.value)}
/>
</div>
</div>
<div className="w-[180px]">
<label className="text-xs text-muted-foreground mb-1 block"></label>
<Select
value={filters.companyCode || "all"}
onValueChange={(v) => handleFilterChange("companyCode", v === "all" ? "" : v)}
>
<SelectTrigger>
<SelectValue placeholder="전체 회사" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
{companies.map((c) => (
<SelectItem key={c.company_code} value={c.company_code}>
{c.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[140px]">
<label className="text-xs text-muted-foreground mb-1 block"></label>
<Select
value={filters.sendStatus || "all"}
onValueChange={(v) => handleFilterChange("sendStatus", v === "all" ? "" : v)}
>
<SelectTrigger>
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="SUCCESS"></SelectItem>
<SelectItem value="FAIL"></SelectItem>
<SelectItem value="SKIPPED"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="w-[160px]">
<label className="text-xs text-muted-foreground mb-1 block"></label>
<Input
type="date"
value={filters.dateFrom || ""}
onChange={(e) => handleFilterChange("dateFrom", e.target.value)}
/>
</div>
<div className="w-[160px]">
<label className="text-xs text-muted-foreground mb-1 block"></label>
<Input
type="date"
value={filters.dateTo || ""}
onChange={(e) => handleFilterChange("dateTo", e.target.value)}
/>
</div>
</div>
</CardContent>
</Card>
{/* 테이블 */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">
({total.toLocaleString()})
</CardTitle>
<div className="flex items-center gap-2">
<Select
value={String(filters.limit || 50)}
onValueChange={(v) =>
setFilters((prev) => ({ ...prev, limit: parseInt(v, 10), page: 1 }))
}
>
<SelectTrigger className="w-[100px] h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[130px]"> IP</TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead> </TableHead>
<TableHead className="w-[170px]"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground">
...
</TableCell>
</TableRow>
) : logs.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
logs.map((log, idx) => {
const statusConf = STATUS_CONFIG[log.send_status as keyof typeof STATUS_CONFIG] || STATUS_CONFIG.FAIL;
const StatusIcon = statusConf.icon;
return (
<TableRow key={log.id}>
<TableCell className="text-muted-foreground text-xs">
{total - ((filters.page || 1) - 1) * (filters.limit || 50) - idx}
</TableCell>
<TableCell>
<span className="text-xs">{log.company_name || log.company_code}</span>
</TableCell>
<TableCell>
<div>
<span className="text-sm">{log.user_name || "-"}</span>
<span className="text-xs text-muted-foreground block">
{log.user_id}
</span>
</div>
</TableCell>
<TableCell className="text-xs">{log.use_type}</TableCell>
<TableCell className="text-xs font-mono">{log.connect_ip}</TableCell>
<TableCell>
<Badge variant={statusConf.variant} className="gap-1 text-xs">
<StatusIcon className="h-3 w-3" />
{statusConf.label}
</Badge>
</TableCell>
<TableCell className="text-xs text-center">
{log.response_status || "-"}
</TableCell>
<TableCell
className="text-xs text-muted-foreground max-w-[300px] truncate"
title={log.error_message || ""}
>
{log.error_message || "-"}
</TableCell>
<TableCell className="text-xs">{formatDate(log.created_at)}</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
{((filters.page || 1) - 1) * (filters.limit || 50) + 1}~
{Math.min((filters.page || 1) * (filters.limit || 50), total)} / {total.toLocaleString()}
</p>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={(filters.page || 1) <= 1}
onClick={() =>
setFilters((prev) => ({
...prev,
page: (prev.page || 1) - 1,
}))
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm px-2">
{filters.page || 1} / {totalPages}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={(filters.page || 1) >= totalPages}
onClick={() =>
setFilters((prev) => ({
...prev,
page: (prev.page || 1) + 1,
}))
}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}