Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
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"]
|
||||
|
||||
@@ -33,9 +33,12 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
Pencil, Copy, Settings2,
|
||||
Pencil, Copy, Settings2, Check, ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -44,19 +47,57 @@ import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 검색 가능한 카테고리 콤보박스
|
||||
function CategoryCombobox({ options, value, onChange, placeholder }: {
|
||||
options: { code: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((o) => o.code === value);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open} className="h-9 w-full justify-between font-normal">
|
||||
<span className="truncate">{selected?.label || <span className="text-muted-foreground">{placeholder}</span>}</span>
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="검색..." className="h-8" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없어요</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{options.map((opt) => (
|
||||
<CommandItem key={opt.code} value={opt.label} onSelect={() => { onChange(opt.code); setOpen(false); }}>
|
||||
<Check className={cn("mr-2 h-3.5 w-3.5", value === opt.code ? "opacity-100" : "opacity-0")} />
|
||||
{opt.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const TABLE_NAME = "item_info";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "image", label: "이미지", type: "image" },
|
||||
{ key: "division", label: "관리품목" },
|
||||
{ key: "type", label: "품목구분" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "selling_price", label: "판매가격", align: "right" as const },
|
||||
{ key: "standard_price", label: "기준단가", align: "right" as const },
|
||||
{ key: "selling_price", label: "판매가격", align: "right" as const, formatNumber: true },
|
||||
{ key: "standard_price", label: "기준단가", align: "right" as const, formatNumber: true },
|
||||
{ key: "weight", label: "중량", align: "right" as const },
|
||||
{ key: "inventory_unit", label: "재고단위" },
|
||||
{ key: "user_type01", label: "대분류" },
|
||||
@@ -83,6 +124,7 @@ const FORM_FIELDS = [
|
||||
{ key: "user_type01", label: "대분류", type: "category" },
|
||||
{ key: "user_type02", label: "중분류", type: "category" },
|
||||
{ key: "lead_time", label: "생산 리드타임(일)", type: "text", placeholder: "숫자 입력 (예: 7)" },
|
||||
{ key: "image", label: "품목 이미지", type: "image" },
|
||||
{ key: "meno", label: "메모", type: "textarea" },
|
||||
];
|
||||
|
||||
@@ -163,6 +205,14 @@ export default function ItemInfoPage() {
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
// 쉼표 구분 다중값 지원
|
||||
if (code.includes(",")) {
|
||||
return code.split(",").map((c) => {
|
||||
const trimmed = c.trim();
|
||||
if (!trimmed || trimmed === "s") return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === trimmed)?.label || trimmed;
|
||||
}).filter(Boolean).join(", ");
|
||||
}
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
const data = raw.map((r: any) => {
|
||||
@@ -360,7 +410,14 @@ export default function ItemInfoPage() {
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
align: col.align as "left" | "center" | "right" | undefined,
|
||||
align: (col as any).type === "image" ? "center" : col.align as "left" | "center" | "right" | undefined,
|
||||
formatNumber: (col as any).formatNumber,
|
||||
width: (col as any).type === "image" ? "w-[50px]" : undefined,
|
||||
render: (col as any).type === "image" ? (val: any) => (
|
||||
val ? (
|
||||
<img src={String(val).startsWith("http") || String(val).startsWith("/") ? val : `/api/files/preview/${val}`} alt="" className="h-8 w-8 rounded object-cover border border-border mx-auto" onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : <div className="h-8 w-8 rounded bg-muted mx-auto" />
|
||||
) : undefined,
|
||||
}))}
|
||||
data={ts.groupData(items)}
|
||||
loading={loading}
|
||||
@@ -387,28 +444,28 @@ export default function ItemInfoPage() {
|
||||
{FORM_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}
|
||||
className={cn("space-y-1.5", (field.type === "textarea" || field.type === "image") && "col-span-2")}
|
||||
>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === "category" ? (
|
||||
<Select
|
||||
{field.type === "image" ? (
|
||||
<ImageUpload
|
||||
value={formData[field.key] || ""}
|
||||
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder={`${field.label} 선택`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions[field.key] || []).map((opt) => (
|
||||
<SelectItem key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
tableName={TABLE_NAME}
|
||||
recordId={formData.id || ""}
|
||||
columnName={field.key}
|
||||
height="h-32"
|
||||
/>
|
||||
) : field.type === "category" ? (
|
||||
<CategoryCombobox
|
||||
options={categoryOptions[field.key] || []}
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
placeholder={`${field.label} 선택`}
|
||||
/>
|
||||
) : field.type === "textarea" ? (
|
||||
<Textarea
|
||||
value={formData[field.key] || ""}
|
||||
@@ -416,6 +473,16 @@ export default function ItemInfoPage() {
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : ["selling_price", "standard_price"].includes(field.key) ? (
|
||||
<Input
|
||||
value={formData[field.key] ? Number(String(formData[field.key]).replace(/,/g, "")).toLocaleString() : ""}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^\d.-]/g, "");
|
||||
setFormData((prev) => ({ ...prev, [field.key]: raw }));
|
||||
}}
|
||||
placeholder={field.placeholder || field.label}
|
||||
className="h-9 text-right"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
|
||||
@@ -209,6 +209,17 @@ function flattenTree(nodes: TreeNode[], level = 0): (TreeNode & { _level: number
|
||||
}
|
||||
|
||||
// ─── 타입 뱃지 색상 ────────────────────────────
|
||||
// BOM 이력 변경 유형 색상
|
||||
const CHANGE_TYPE_STYLE: Record<string, string> = {
|
||||
"등록": "bg-primary/10 text-primary ring-primary/20",
|
||||
"수정": "bg-amber-50 text-amber-600 ring-amber-200",
|
||||
"추가": "bg-emerald-50 text-emerald-600 ring-emerald-200",
|
||||
"삭제": "bg-destructive/10 text-destructive ring-red-200",
|
||||
"버전변경": "bg-blue-50 text-blue-600 ring-blue-200",
|
||||
"excel_upload": "bg-blue-50 text-blue-600 ring-blue-200",
|
||||
};
|
||||
const CHANGE_TYPE_OPTIONS = ["등록", "수정", "추가", "삭제", "버전변경", "기타"];
|
||||
|
||||
function getItemTypeBadge(type?: string) {
|
||||
switch (type) {
|
||||
case "원자재":
|
||||
@@ -762,6 +773,37 @@ export default function BomManagementPage() {
|
||||
|
||||
// 트리 품목 선택 완료 (트리에 추가)
|
||||
const handleTreeItemSelect = (item: any) => {
|
||||
// 1. 자기 자신 추가 방지 (BOM 마스터 품목과 동일한 품목)
|
||||
if (bomHeader && (item.id === bomHeader.item_id || item.item_number === bomHeader.item_code)) {
|
||||
toast.error("자기 자신을 BOM에 추가할 수 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 같은 경로에 동일 품목 중복 방지 (조상 노드에 같은 품목이 있으면 순환)
|
||||
const getAncestorItemIds = (tree: TreeNode[], targetId: string | null): Set<string> => {
|
||||
const ids = new Set<string>();
|
||||
if (!targetId) return ids;
|
||||
const collectPath = (nodes: TreeNode[], path: TreeNode[]): boolean => {
|
||||
for (const n of nodes) {
|
||||
const nk = n.id || n._tempId || "";
|
||||
if (nk === targetId) {
|
||||
path.forEach(p => { if (p.child_item_id) ids.add(p.child_item_id); });
|
||||
if (n.child_item_id) ids.add(n.child_item_id);
|
||||
return true;
|
||||
}
|
||||
if (n.children.length > 0 && collectPath(n.children, [...path, n])) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
collectPath(tree, []);
|
||||
return ids;
|
||||
};
|
||||
const ancestorIds = getAncestorItemIds(editingTree, addTargetParentId);
|
||||
if (ancestorIds.has(item.id)) {
|
||||
toast.error("상위 경로에 이미 동일 품목이 있어 순환 참조가 발생합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -892,6 +934,8 @@ export default function BomManagementPage() {
|
||||
await apiClient.put(`/table-management/tables/bom_detail/edit`, {
|
||||
originalData: { id: node.id },
|
||||
updatedData: {
|
||||
bom_id: selectedBomId,
|
||||
version_id: versionId,
|
||||
quantity: node.quantity,
|
||||
unit: node.unit,
|
||||
process_type: node.process_type,
|
||||
@@ -904,6 +948,36 @@ export default function BomManagementPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// 이력 자동 기록 (변경 내용 상세)
|
||||
const historyParts: string[] = [];
|
||||
// 추가된 품목
|
||||
for (const n of toInsert) {
|
||||
historyParts.push(`${n.item_number || "품목"} 추가 (소요량: ${n.quantity || 1})`);
|
||||
}
|
||||
// 수정된 품목 (변경 전후 비교)
|
||||
for (const n of toUpdate) {
|
||||
const orig = bomDetails.find((d: any) => d.id === n.id);
|
||||
if (orig) {
|
||||
const changes: string[] = [];
|
||||
if (String(orig.quantity || "") !== String(n.quantity || "")) changes.push(`소요량 ${orig.quantity || "-"} → ${n.quantity || "-"}`);
|
||||
if (String(orig.process_type || "") !== String(n.process_type || "")) changes.push(`공정 변경`);
|
||||
if (String(orig.loss_rate || "") !== String(n.loss_rate || "")) changes.push(`손실율 ${orig.loss_rate || "0"} → ${n.loss_rate || "0"}`);
|
||||
if (String(orig.remark || "") !== String(n.remark || "")) changes.push(`비고 변경`);
|
||||
if (changes.length > 0) historyParts.push(`${n.item_number || "품목"}: ${changes.join(", ")}`);
|
||||
}
|
||||
}
|
||||
// 삭제된 품목
|
||||
for (const id of toDelete) {
|
||||
const orig = bomDetails.find((d: any) => d.id === id);
|
||||
historyParts.push(`${orig?.item_number || "품목"} 삭제`);
|
||||
}
|
||||
if (historyParts.length > 0) {
|
||||
apiClient.post(`/bom/${selectedBomId}/history`, {
|
||||
change_type: historyParts.some(p => p.includes("추가")) ? "추가" : historyParts.some(p => p.includes("삭제")) ? "삭제" : "수정",
|
||||
change_description: historyParts.join("; "),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
toast.success("BOM 트리가 저장되었어요");
|
||||
setTreeHasChanges(false);
|
||||
fetchBomDetail(selectedBomId);
|
||||
@@ -1165,6 +1239,7 @@ export default function BomManagementPage() {
|
||||
await apiClient.post(`/bom/${selectedBomId}/versions`, {
|
||||
versionName: newVersionName.trim() || undefined,
|
||||
});
|
||||
apiClient.post(`/bom/${selectedBomId}/history`, { change_type: "버전변경", change_description: `새 버전 생성: ${newVersionName.trim() || "자동"}` }).catch(() => {});
|
||||
toast.success("새 버전이 생성되었어요");
|
||||
setShowNewVersionDialog(false);
|
||||
setNewVersionName("");
|
||||
@@ -1195,6 +1270,7 @@ export default function BomManagementPage() {
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.post(`/bom/${selectedBomId}/versions/${versionId}/activate`);
|
||||
apiClient.post(`/bom/${selectedBomId}/history`, { change_type: "버전변경", change_description: `버전 사용 확정` }).catch(() => {});
|
||||
toast.success("버전이 사용 확정되었어요");
|
||||
fetchVersions(selectedBomId);
|
||||
fetchBomDetail(selectedBomId);
|
||||
@@ -1491,48 +1567,6 @@ export default function BomManagementPage() {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 트리뷰 토글 (트리뷰 탭에서만) */}
|
||||
{rightTab === "tree" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 bg-card border rounded-md p-0.5">
|
||||
<button
|
||||
className={cn("px-2.5 py-1 text-[11px] font-medium rounded transition-all",
|
||||
treeDirection === "forward" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTreeDirection("forward")}
|
||||
>
|
||||
정전개
|
||||
</button>
|
||||
<button
|
||||
className={cn("px-2.5 py-1 text-[11px] font-medium rounded transition-all",
|
||||
treeDirection === "reverse" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTreeDirection("reverse")}
|
||||
>
|
||||
역전개
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<div className="flex items-center gap-0.5 bg-card border rounded-md p-0.5">
|
||||
<button
|
||||
className={cn("px-2.5 py-1 text-[11px] font-medium rounded transition-all",
|
||||
treeBasis === "material" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTreeBasis("material")}
|
||||
>
|
||||
자재기준
|
||||
</button>
|
||||
<button
|
||||
className={cn("px-2.5 py-1 text-[11px] font-medium rounded transition-all",
|
||||
treeBasis === "process" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTreeBasis("process")}
|
||||
>
|
||||
공정기준
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 트리뷰 탭 */}
|
||||
@@ -1865,10 +1899,6 @@ export default function BomManagementPage() {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{historyList.length}건 이력
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setShowHistoryDialog(true)}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
이력 추가
|
||||
</Button>
|
||||
</div>
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
@@ -1881,24 +1911,34 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{historyList.map((entry) => (
|
||||
<div key={entry.id} className="p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{entry.change_type}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{entry.changed_date ? new Date(entry.changed_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</span>
|
||||
{historyList.map((entry) => {
|
||||
const typeStyle = CHANGE_TYPE_STYLE[entry.change_type] || "bg-muted text-muted-foreground ring-border";
|
||||
return (
|
||||
<div key={entry.id} className="p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset", typeStyle)}>
|
||||
{entry.change_type}
|
||||
</span>
|
||||
{(entry.revision || entry.version) && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{entry.revision && `차수 ${entry.revision}`}{entry.revision && entry.version && " / "}{entry.version && `v${entry.version}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{entry.changed_date ? new Date(entry.changed_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1">{entry.change_description || "-"}</p>
|
||||
{entry.changed_by && (
|
||||
<span className="text-[10px] text-muted-foreground/60 mt-1 block">
|
||||
{entry.changed_by}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{entry.change_description || "-"}</p>
|
||||
{entry.changed_by && (
|
||||
<span className="text-[10px] text-muted-foreground/60 mt-1 block">
|
||||
작성자: {entry.changed_by}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2263,10 +2303,15 @@ export default function BomManagementPage() {
|
||||
<Button onClick={() => {
|
||||
if (treeEditTarget) {
|
||||
const nodeKey = treeEditTarget.id || treeEditTarget._tempId || "";
|
||||
handleTreeFieldChange(nodeKey, "quantity", treeEditForm.quantity || "1");
|
||||
handleTreeFieldChange(nodeKey, "process_type", treeEditForm.process_type || "");
|
||||
handleTreeFieldChange(nodeKey, "loss_rate", treeEditForm.loss_rate || "0");
|
||||
handleTreeFieldChange(nodeKey, "remark", treeEditForm.remark || "");
|
||||
setEditingTree(prev => updateTreeNode(prev, nodeKey, node => ({
|
||||
...node,
|
||||
quantity: treeEditForm.quantity || "1",
|
||||
process_type: treeEditForm.process_type || "",
|
||||
loss_rate: treeEditForm.loss_rate || "0",
|
||||
remark: treeEditForm.remark || "",
|
||||
_isModified: !node._isNew ? true : node._isModified,
|
||||
})));
|
||||
setTreeHasChanges(true);
|
||||
}
|
||||
setTreeEditModalOpen(false);
|
||||
}}>저장</Button>
|
||||
@@ -2300,7 +2345,27 @@ export default function BomManagementPage() {
|
||||
<p className="text-xs">검색어를 입력해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
itemSearchResults.map((item) => {
|
||||
itemSearchResults.filter((item) => {
|
||||
// 자기 자신 제외
|
||||
if (bomHeader && (item.id === bomHeader.item_id || item.item_number === bomHeader.item_code)) return false;
|
||||
// 조상 경로 순환 참조 제외
|
||||
if (addTargetParentId) {
|
||||
const collectAncestorIds = (nodes: TreeNode[], targetId: string, path: string[]): string[] | null => {
|
||||
for (const n of nodes) {
|
||||
const nk = n.id || n._tempId || "";
|
||||
if (nk === targetId) return [...path, n.child_item_id || ""];
|
||||
if (n.children.length > 0) {
|
||||
const r = collectAncestorIds(n.children, targetId, [...path, n.child_item_id || ""]);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const ancestors = collectAncestorIds(editingTree, addTargetParentId, []);
|
||||
if (ancestors && ancestors.includes(item.id)) return false;
|
||||
}
|
||||
return true;
|
||||
}).map((item) => {
|
||||
const badge = getItemTypeBadge(item.division);
|
||||
return (
|
||||
<div
|
||||
@@ -2365,12 +2430,9 @@ export default function BomManagementPage() {
|
||||
<Select value={newHistory.change_type} onValueChange={(v) => setNewHistory((prev) => ({ ...prev, change_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="변경 유형 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="design_change">설계 변경</SelectItem>
|
||||
<SelectItem value="version_update">버전 업데이트</SelectItem>
|
||||
<SelectItem value="material_change">자재 변경</SelectItem>
|
||||
<SelectItem value="qty_change">수량 변경</SelectItem>
|
||||
<SelectItem value="process_change">공정 변경</SelectItem>
|
||||
<SelectItem value="etc">기타</SelectItem>
|
||||
{CHANGE_TYPE_OPTIONS.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Filter, Check, Search, X, Loader2, Inbox, GripVertical,
|
||||
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown,
|
||||
ChevronLeft, ChevronRight, ChevronDown, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
@@ -290,6 +290,16 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
|
||||
|
||||
// 그룹 접기/펼치기
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const toggleGroup = (groupValue: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupValue)) next.delete(groupValue); else next.add(groupValue);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 인라인 편집
|
||||
const [editingCell, setEditingCell] = useState<{ rowId: string; colKey: string } | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
@@ -428,10 +438,27 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const pageOffset = (safePage - 1) * pageSize;
|
||||
const paginatedData = showPagination
|
||||
const paginatedDataRaw = showPagination
|
||||
? processedData.slice(pageOffset, pageOffset + pageSize)
|
||||
: processedData;
|
||||
|
||||
// 접힌 그룹의 데이터 행 숨김
|
||||
const paginatedData = useMemo(() => {
|
||||
if (collapsedGroups.size === 0) return paginatedDataRaw;
|
||||
let currentGroup: string | null = null;
|
||||
return paginatedDataRaw.filter((row) => {
|
||||
if ((row as any)._isGroupHeader) {
|
||||
currentGroup = (row as any)._groupValue;
|
||||
return true; // 헤더는 항상 표시
|
||||
}
|
||||
if ((row as any)._isGroupSummary) {
|
||||
return !collapsedGroups.has((row as any)._groupValue);
|
||||
}
|
||||
// 일반 행: 현재 그룹이 접혀있으면 숨김
|
||||
return !currentGroup || !collapsedGroups.has(currentGroup);
|
||||
});
|
||||
}, [paginatedDataRaw, collapsedGroups]);
|
||||
|
||||
const applyPageSize = () => {
|
||||
const n = parseInt(pageSizeInput, 10);
|
||||
if (!isNaN(n) && n >= 1) {
|
||||
@@ -641,8 +668,29 @@ export function EDataTable<T extends Record<string, any> = any>({
|
||||
</TableRow>
|
||||
) : (
|
||||
paginatedData.map((row, rowIdx) => {
|
||||
// 그룹 헤더 행 처리
|
||||
if ((row as any)._isGroupHeader) {
|
||||
const gv = (row as any)._groupValue || "";
|
||||
const gc = (row as any)._groupCount || 0;
|
||||
const isCollapsed = collapsedGroups.has(gv);
|
||||
const totalCols = columns.length + (showCheckbox ? 1 : 0) + (showRowNumber ? 1 : 0);
|
||||
return (
|
||||
<TableRow key={`group-${gv}-${rowIdx}`} className="bg-primary/5 hover:bg-primary/10 cursor-pointer border-t-2 border-primary/20" onClick={() => toggleGroup(gv)}>
|
||||
<TableCell colSpan={totalCols} className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isCollapsed ? <ChevronRight className="h-4 w-4 text-primary" /> : <ChevronDown className="h-4 w-4 text-primary" />}
|
||||
<span className="text-sm font-semibold text-primary">{gv}</span>
|
||||
<Badge variant="secondary" className="text-[10px] bg-primary/10 text-primary">{gc}건</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
// 그룹 소계 행 처리
|
||||
if ((row as any)._isGroupSummary) {
|
||||
const gv = (row as any)._groupValue || "";
|
||||
if (collapsedGroups.has(gv)) return null;
|
||||
return (
|
||||
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||
{showCheckbox && <TableCell />}
|
||||
|
||||
@@ -161,35 +161,39 @@ export function useTableSettings<T extends { key: string }>(
|
||||
* 소계 행은 _isGroupSummary: true, _groupKey, _groupValue 속성을 가집니다.
|
||||
*/
|
||||
const groupData = useCallback(
|
||||
<R extends Record<string, any>>(rows: R[]): (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] => {
|
||||
<R extends Record<string, any>>(rows: R[]): (R & { _isGroupSummary?: boolean; _isGroupHeader?: boolean; _groupKey?: string; _groupValue?: string; _groupCount?: number })[] => {
|
||||
if (groupColumns.length === 0) return rows;
|
||||
|
||||
const groupCol = groupColumns[0]; // 첫 번째 그룹 컬럼 기준
|
||||
// 다중 그룹 컬럼 결합 키
|
||||
const makeKey = (row: R) => groupColumns.map((col) => String(row[col] ?? "(빈 값)")).join(" / ");
|
||||
const groups = new Map<string, R[]>();
|
||||
|
||||
for (const row of rows) {
|
||||
const key = String(row[groupCol] ?? "(빈 값)");
|
||||
const key = makeKey(row);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(row);
|
||||
}
|
||||
|
||||
const result: (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] = [];
|
||||
const result: (R & { _isGroupSummary?: boolean; _isGroupHeader?: boolean; _groupKey?: string; _groupValue?: string; _groupCount?: number })[] = [];
|
||||
|
||||
for (const [groupValue, groupRows] of groups) {
|
||||
// 그룹 헤더 행
|
||||
const headerRow: any = { _isGroupHeader: true, _groupKey: groupColumns.join(","), _groupValue: groupValue, _groupCount: groupRows.length };
|
||||
result.push(headerRow);
|
||||
|
||||
// 그룹 내 데이터 행
|
||||
result.push(...groupRows);
|
||||
|
||||
// 소계 행 (groupSumEnabled일 때만)
|
||||
if (groupSumEnabled) {
|
||||
const summaryRow: any = { _isGroupSummary: true, _groupKey: groupCol, _groupValue: groupValue };
|
||||
// 숫자 컬럼 합산
|
||||
const summaryRow: any = { _isGroupSummary: true, _groupKey: groupColumns.join(","), _groupValue: groupValue };
|
||||
for (const col of defaultColumns) {
|
||||
const values = groupRows.map((r) => Number(r[col.key])).filter((v) => !isNaN(v));
|
||||
if (values.length > 0 && values.some((v) => v !== 0)) {
|
||||
summaryRow[col.key] = values.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
}
|
||||
summaryRow[groupCol] = `${groupValue} 소계 (${groupRows.length}건)`;
|
||||
summaryRow[groupColumns[0]] = `${groupValue} 소계 (${groupRows.length}건)`;
|
||||
result.push(summaryRow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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