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:
@@ -449,6 +449,7 @@ async function initializeServices() {
|
||||
runApprovalSystemMigration,
|
||||
runUserMailAccountsMigration,
|
||||
runMessengerMigration,
|
||||
runSmartFactoryLogMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
@@ -457,6 +458,7 @@ async function initializeServices() {
|
||||
await runApprovalSystemMigration();
|
||||
await runUserMailAccountsMigration();
|
||||
await runMessengerMigration();
|
||||
await runSmartFactoryLogMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export class AuthController {
|
||||
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
|
||||
sendSmartFactoryLog({
|
||||
userId: userInfo.userId,
|
||||
userName: userInfo.userName,
|
||||
remoteAddr,
|
||||
useType: "접속",
|
||||
companyCode: userInfo.companyCode,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// 스마트공장 활용 로그 조회 컨트롤러
|
||||
// 최고관리자(*) 전용 — 회사별 필터링 가능
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../middleware/permissionMiddleware";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log
|
||||
* 스마트공장 로그 목록 조회
|
||||
*/
|
||||
export const getSmartFactoryLogs = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const {
|
||||
companyCode,
|
||||
userId,
|
||||
sendStatus,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
search,
|
||||
page = "1",
|
||||
limit = "50",
|
||||
} = req.query;
|
||||
|
||||
const whereConditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 필터
|
||||
if (companyCode && companyCode !== "all") {
|
||||
whereConditions.push(`sfl.company_code = $${paramIndex}`);
|
||||
queryParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 사용자 필터
|
||||
if (userId && (userId as string).trim()) {
|
||||
whereConditions.push(`sfl.user_id ILIKE $${paramIndex}`);
|
||||
queryParams.push(`%${(userId as string).trim()}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 전송 상태 필터
|
||||
if (sendStatus && sendStatus !== "all") {
|
||||
whereConditions.push(`sfl.send_status = $${paramIndex}`);
|
||||
queryParams.push(sendStatus);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 날짜 범위 필터
|
||||
if (dateFrom) {
|
||||
whereConditions.push(`sfl.created_at >= $${paramIndex}`);
|
||||
queryParams.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereConditions.push(`sfl.created_at < ($${paramIndex}::date + 1)`);
|
||||
queryParams.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 통합 검색
|
||||
if (search && (search as string).trim()) {
|
||||
whereConditions.push(
|
||||
`(sfl.user_id ILIKE $${paramIndex} OR sfl.user_name ILIKE $${paramIndex} OR sfl.connect_ip ILIKE $${paramIndex} OR sfl.error_message ILIKE $${paramIndex})`
|
||||
);
|
||||
queryParams.push(`%${(search as string).trim()}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
// 총 개수
|
||||
const countResult = await queryOne<{ total: string }>(
|
||||
`SELECT COUNT(*) as total FROM smart_factory_log sfl ${whereClause}`,
|
||||
queryParams
|
||||
);
|
||||
const total = parseInt(countResult?.total || "0", 10);
|
||||
|
||||
// 페이지네이션
|
||||
const pageNum = Math.max(1, parseInt(page as string, 10));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10)));
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
|
||||
// 데이터 조회 (회사명 JOIN)
|
||||
const logs = await query<any>(
|
||||
`SELECT sfl.*, cm.company_name
|
||||
FROM smart_factory_log sfl
|
||||
LEFT JOIN company_mng cm ON cm.company_code = sfl.company_code
|
||||
${whereClause}
|
||||
ORDER BY sfl.created_at DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
[...queryParams, limitNum, offset]
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: logs,
|
||||
total,
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("스마트공장 로그 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "스마트공장 로그 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/smart-factory-log/stats
|
||||
* 스마트공장 로그 통계 (회사별 요약)
|
||||
*/
|
||||
export const getSmartFactoryLogStats = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, days = "30" } = req.query;
|
||||
const daysNum = parseInt(days as string, 10) || 30;
|
||||
|
||||
const whereConditions: string[] = [
|
||||
`sfl.created_at >= NOW() - INTERVAL '${daysNum} days'`,
|
||||
];
|
||||
const queryParams: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode && companyCode !== "all") {
|
||||
whereConditions.push(`sfl.company_code = $${paramIndex}`);
|
||||
queryParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${whereConditions.join(" AND ")}`;
|
||||
|
||||
// 상태별 건수
|
||||
const statusCounts = await query<{ send_status: string; count: string }>(
|
||||
`SELECT send_status, COUNT(*) as count
|
||||
FROM smart_factory_log sfl
|
||||
${whereClause}
|
||||
GROUP BY send_status`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// 회사별 건수
|
||||
const companyCounts = await query<{
|
||||
company_code: string;
|
||||
company_name: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT sfl.company_code, COALESCE(cm.company_name, sfl.company_code) as company_name, COUNT(*) as count
|
||||
FROM smart_factory_log sfl
|
||||
LEFT JOIN company_mng cm ON cm.company_code = sfl.company_code
|
||||
${whereClause}
|
||||
GROUP BY sfl.company_code, cm.company_name
|
||||
ORDER BY count DESC`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// 일별 추이
|
||||
const dailyCounts = await query<{ date: string; count: string }>(
|
||||
`SELECT DATE(sfl.created_at) as date, COUNT(*) as count
|
||||
FROM smart_factory_log sfl
|
||||
${whereClause}
|
||||
GROUP BY DATE(sfl.created_at)
|
||||
ORDER BY date DESC
|
||||
LIMIT ${daysNum}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
// 전체 건수
|
||||
const totalResult = await queryOne<{ total: string }>(
|
||||
`SELECT COUNT(*) as total FROM smart_factory_log sfl ${whereClause}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
total: parseInt(totalResult?.total || "0", 10),
|
||||
statusCounts: statusCounts.map((r) => ({
|
||||
status: r.send_status,
|
||||
count: parseInt(r.count, 10),
|
||||
})),
|
||||
companyCounts: companyCounts.map((r) => ({
|
||||
companyCode: r.company_code,
|
||||
companyName: r.company_name,
|
||||
count: parseInt(r.count, 10),
|
||||
})),
|
||||
dailyCounts: dailyCounts.map((r) => ({
|
||||
date: r.date,
|
||||
count: parseInt(r.count, 10),
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("스마트공장 로그 통계 조회 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "통계 조회 중 오류가 발생했습니다.",
|
||||
error: {
|
||||
code: "SERVER_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -170,6 +170,35 @@ export async function runMessengerMigration() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스마트공장 활용 로그 테이블 마이그레이션
|
||||
*/
|
||||
export async function runSmartFactoryLogMigration() {
|
||||
try {
|
||||
console.log("🔄 스마트공장 로그 테이블 마이그레이션 시작...");
|
||||
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/200_create_smart_factory_log.sql"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
|
||||
console.log("✅ 스마트공장 로그 테이블 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 스마트공장 로그 테이블 마이그레이션 실패:", error);
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 테이블이 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDtgManagementLogMigration() {
|
||||
try {
|
||||
console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작...");
|
||||
|
||||
@@ -32,6 +32,10 @@ import {
|
||||
setUserLocale,
|
||||
getTableSchema, // 테이블 스키마 조회
|
||||
} from "../controllers/adminController";
|
||||
import {
|
||||
getSmartFactoryLogs,
|
||||
getSmartFactoryLogStats,
|
||||
} from "../controllers/smartFactoryLogController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
|
||||
|
||||
@@ -84,4 +88,8 @@ router.post("/user-locale", setUserLocale);
|
||||
// 테이블 스키마 API (엑셀 업로드 컬럼 매핑용)
|
||||
router.get("/tables/:tableName/schema", getTableSchema);
|
||||
|
||||
// 스마트공장 활용 로그 API (최고관리자 전용)
|
||||
router.get("/smart-factory-log", requireSuperAdmin, getSmartFactoryLogs);
|
||||
router.get("/smart-factory-log/stats", requireSuperAdmin, getSmartFactoryLogStats);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -3,20 +3,26 @@
|
||||
|
||||
import axios from "axios";
|
||||
import { logger } from "./logger";
|
||||
import { query } from "../database/db";
|
||||
|
||||
const SMART_FACTORY_LOG_URL =
|
||||
"https://log.smart-factory.kr/apisvc/sendLogDataJSON.do";
|
||||
|
||||
/**
|
||||
* 스마트공장 활용 로그 전송
|
||||
* 스마트공장 활용 로그 전송 + DB 저장
|
||||
* 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음
|
||||
*/
|
||||
export async function sendSmartFactoryLog(params: {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
remoteAddr: string;
|
||||
useType?: string;
|
||||
companyCode?: string;
|
||||
}): Promise<void> {
|
||||
const now = new Date();
|
||||
const logDt = formatDateTime(now);
|
||||
const useType = params.useType || "접속";
|
||||
|
||||
// 회사별 키 우선 조회, 없으면 공통 키 폴백
|
||||
const apiKey = (params.companyCode && process.env[`SMART_FACTORY_API_KEY_${params.companyCode}`])
|
||||
|| process.env.SMART_FACTORY_API_KEY;
|
||||
@@ -25,17 +31,26 @@ export async function sendSmartFactoryLog(params: {
|
||||
logger.warn(
|
||||
"SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다."
|
||||
);
|
||||
// SKIPPED 상태로 DB 기록
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
useType,
|
||||
connectIp: params.remoteAddr,
|
||||
sendStatus: "SKIPPED",
|
||||
responseStatus: null,
|
||||
errorMessage: "API 키 미설정",
|
||||
logDt: now,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const logDt = formatDateTime(now);
|
||||
|
||||
const logData = {
|
||||
crtfcKey: apiKey,
|
||||
logDt,
|
||||
useSe: params.useType || "접속",
|
||||
useSe: useType,
|
||||
sysUser: params.userId,
|
||||
conectIp: params.remoteAddr,
|
||||
dataUsgqty: "",
|
||||
@@ -52,11 +67,76 @@ export async function sendSmartFactoryLog(params: {
|
||||
userId: params.userId,
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
// SUCCESS 상태로 DB 기록
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
useType,
|
||||
connectIp: params.remoteAddr,
|
||||
sendStatus: "SUCCESS",
|
||||
responseStatus: response.status,
|
||||
errorMessage: null,
|
||||
logDt: now,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
// 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록
|
||||
logger.error("스마트공장 로그 전송 실패", {
|
||||
userId: params.userId,
|
||||
error: error instanceof Error ? error.message : error,
|
||||
error: errorMsg,
|
||||
});
|
||||
|
||||
// FAIL 상태로 DB 기록
|
||||
await saveLog({
|
||||
companyCode: params.companyCode || "",
|
||||
userId: params.userId,
|
||||
userName: params.userName,
|
||||
useType,
|
||||
connectIp: params.remoteAddr,
|
||||
sendStatus: "FAIL",
|
||||
responseStatus: null,
|
||||
errorMessage: errorMsg,
|
||||
logDt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** DB에 로그 저장 */
|
||||
async function saveLog(params: {
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
useType: string;
|
||||
connectIp: string;
|
||||
sendStatus: string;
|
||||
responseStatus: number | null;
|
||||
errorMessage: string | null;
|
||||
logDt: Date;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO smart_factory_log
|
||||
(company_code, user_id, user_name, use_type, connect_ip, send_status, response_status, error_message, log_dt)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
params.companyCode,
|
||||
params.userId,
|
||||
params.userName || null,
|
||||
params.useType,
|
||||
params.connectIp,
|
||||
params.sendStatus,
|
||||
params.responseStatus,
|
||||
params.errorMessage,
|
||||
params.logDt,
|
||||
]
|
||||
);
|
||||
} catch (dbError) {
|
||||
// DB 저장 실패해도 로그인 프로세스에 영향 없도록
|
||||
logger.error("스마트공장 로그 DB 저장 실패", {
|
||||
userId: params.userId,
|
||||
error: dbError instanceof Error ? dbError.message : dbError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,8 +23,11 @@ services:
|
||||
- KMA_API_KEY=${KMA_API_KEY}
|
||||
- ITS_API_KEY=${ITS_API_KEY}
|
||||
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_7=${SMART_FACTORY_API_KEY_COMPANY_7:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_8=${SMART_FACTORY_API_KEY_COMPANY_8:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_9=${SMART_FACTORY_API_KEY_COMPANY_9:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_16=${SMART_FACTORY_API_KEY_COMPANY_16:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface SmartFactoryLogEntry {
|
||||
id: number;
|
||||
company_code: string;
|
||||
company_name: string | null;
|
||||
user_id: string;
|
||||
user_name: string | null;
|
||||
use_type: string;
|
||||
connect_ip: string;
|
||||
send_status: "SUCCESS" | "FAIL" | "SKIPPED";
|
||||
response_status: number | null;
|
||||
error_message: string | null;
|
||||
log_dt: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SmartFactoryLogFilters {
|
||||
companyCode?: string;
|
||||
userId?: string;
|
||||
sendStatus?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SmartFactoryLogStats {
|
||||
total: number;
|
||||
statusCounts: Array<{ status: string; count: number }>;
|
||||
companyCounts: Array<{ companyCode: string; companyName: string; count: number }>;
|
||||
dailyCounts: Array<{ date: string; count: number }>;
|
||||
}
|
||||
|
||||
export async function getSmartFactoryLogs(
|
||||
filters: SmartFactoryLogFilters
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data: SmartFactoryLogEntry[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.companyCode) params.append("companyCode", filters.companyCode);
|
||||
if (filters.userId) params.append("userId", filters.userId);
|
||||
if (filters.sendStatus) params.append("sendStatus", filters.sendStatus);
|
||||
if (filters.dateFrom) params.append("dateFrom", filters.dateFrom);
|
||||
if (filters.dateTo) params.append("dateTo", filters.dateTo);
|
||||
if (filters.search) params.append("search", filters.search);
|
||||
if (filters.page) params.append("page", String(filters.page));
|
||||
if (filters.limit) params.append("limit", String(filters.limit));
|
||||
|
||||
const response = await apiClient.get(`/admin/smart-factory-log?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getSmartFactoryLogStats(
|
||||
companyCode?: string,
|
||||
days?: number
|
||||
): Promise<{ success: boolean; data: SmartFactoryLogStats }> {
|
||||
const params = new URLSearchParams();
|
||||
if (companyCode) params.append("companyCode", companyCode);
|
||||
if (days) params.append("days", String(days));
|
||||
|
||||
const response = await apiClient.get(`/admin/smart-factory-log/stats?${params.toString()}`);
|
||||
return response.data;
|
||||
}
|
||||
Reference in New Issue
Block a user