Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
DDD1542
2026-04-07 14:21:30 +09:00
13 changed files with 1179 additions and 110 deletions
+2
View File
@@ -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 : "알 수 없는 오류",
},
});
}
};
+29
View File
@@ -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 이력 테이블 마이그레이션 시작...");
+8
View File
@@ -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;
+86 -6
View File
@@ -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,
});
}
}
+4 -1
View File
@@ -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>
);
}
+50 -2
View File
@@ -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 />}
+11 -7
View File
@@ -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);
}
}
+69
View File
@@ -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;
}