From 5715e67ba9ec9afc6978e9b62bd4ad8b4b9da258 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 19 Mar 2026 17:18:14 +0900 Subject: [PATCH] 123 --- backend-node/src/app.ts | 4 + .../controllers/analyticsReportController.ts | 488 ++++++ .../src/controllers/salesReportController.ts | 161 ++ .../src/routes/analyticsReportRoutes.ts | 23 + backend-node/src/routes/salesReportRoutes.ts | 12 + .../(main)/admin/report/equipment/page.tsx | 72 + .../(main)/admin/report/inventory/page.tsx | 67 + .../app/(main)/admin/report/mold/page.tsx | 73 + .../(main)/admin/report/production/page.tsx | 80 + .../app/(main)/admin/report/purchase/page.tsx | 76 + .../app/(main)/admin/report/quality/page.tsx | 77 + .../app/(main)/admin/report/sales/page.tsx | 67 + .../components/admin/report/ReportEngine.tsx | 1544 +++++++++++++++++ 13 files changed, 2744 insertions(+) create mode 100644 backend-node/src/controllers/analyticsReportController.ts create mode 100644 backend-node/src/controllers/salesReportController.ts create mode 100644 backend-node/src/routes/analyticsReportRoutes.ts create mode 100644 backend-node/src/routes/salesReportRoutes.ts create mode 100644 frontend/app/(main)/admin/report/equipment/page.tsx create mode 100644 frontend/app/(main)/admin/report/inventory/page.tsx create mode 100644 frontend/app/(main)/admin/report/mold/page.tsx create mode 100644 frontend/app/(main)/admin/report/production/page.tsx create mode 100644 frontend/app/(main)/admin/report/purchase/page.tsx create mode 100644 frontend/app/(main)/admin/report/quality/page.tsx create mode 100644 frontend/app/(main)/admin/report/sales/page.tsx create mode 100644 frontend/components/admin/report/ReportEngine.tsx diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9ca42a1e..2d7192df 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -145,6 +145,8 @@ import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리 +import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 +import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 @@ -340,6 +342,8 @@ app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 +app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 +app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) diff --git a/backend-node/src/controllers/analyticsReportController.ts b/backend-node/src/controllers/analyticsReportController.ts new file mode 100644 index 00000000..d71eeb9a --- /dev/null +++ b/backend-node/src/controllers/analyticsReportController.ts @@ -0,0 +1,488 @@ +import { Response } from "express"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +function buildCompanyFilter(companyCode: string, alias: string, paramIdx: number) { + if (companyCode === "*") return { condition: "", params: [] as any[], nextIdx: paramIdx }; + return { + condition: `${alias}.company_code = $${paramIdx}`, + params: [companyCode], + nextIdx: paramIdx + 1, + }; +} + +function buildDateFilter(startDate: string | undefined, endDate: string | undefined, dateExpr: string, paramIdx: number) { + const conditions: string[] = []; + const params: any[] = []; + let idx = paramIdx; + + if (startDate) { + conditions.push(`${dateExpr} >= $${idx}`); + params.push(startDate); + idx++; + } + if (endDate) { + conditions.push(`${dateExpr} <= $${idx}`); + params.push(endDate); + idx++; + } + + return { conditions, params, nextIdx: idx }; +} + +function buildWhereClause(conditions: string[]): string { + return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; +} + +function extractFilterSet(rows: any[], field: string, labelField?: string): { value: string; label: string }[] { + const set = new Map(); + rows.forEach((r: any) => { + const val = r[field]; + if (val && val !== "미지정") set.set(val, r[labelField || field] || val); + }); + return [...set.entries()].map(([value, label]) => ({ value, label })); +} + +// ============================================ +// 생산 리포트 +// ============================================ +export async function getProductionReportData(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; } + + const { startDate, endDate } = req.query; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + const cf = buildCompanyFilter(companyCode, "wi", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx); + conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx; + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + SELECT + COALESCE(wi.start_date, wi.created_date::date::text) as date, + COALESCE(wi.routing, '미지정') as process, + COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment, + COALESCE(ii.item_name, wi.item_id, '미지정') as item, + COALESCE(wi.worker, '미지정') as worker, + CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty", + COALESCE(pr.production_qty, 0) as "prodQty", + COALESCE(pr.defect_qty, 0) as "defectQty", + 0 as "runTime", + 0 as "downTime", + wi.status, + wi.company_code + FROM work_instruction wi + LEFT JOIN ( + SELECT wo_id, company_code, + SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty, + SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty + FROM production_record GROUP BY wo_id, company_code + ) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code + LEFT JOIN ( + SELECT DISTINCT ON (equipment_code, company_code) + equipment_code, equipment_name, equipment_type, company_code + FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC + ) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, company_code + FROM item_info ORDER BY item_number, company_code, created_date DESC + ) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code + ${whereClause} + ORDER BY date DESC NULLS LAST + `; + + const dataRows = await query(dataQuery, params); + + logger.info("생산 리포트 데이터 조회", { companyCode, rowCount: dataRows.length }); + + res.status(200).json({ + success: true, + data: { + rows: dataRows, + filterOptions: { + processes: extractFilterSet(dataRows, "process"), + equipment: extractFilterSet(dataRows, "equipment"), + items: extractFilterSet(dataRows, "item"), + workers: extractFilterSet(dataRows, "worker"), + }, + totalCount: dataRows.length, + }, + }); + } catch (error: any) { + logger.error("생산 리포트 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: "생산 리포트 데이터 조회에 실패했습니다", error: error.message }); + } +} + +// ============================================ +// 재고 리포트 +// ============================================ +export async function getInventoryReportData(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; } + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + const cf = buildCompanyFilter(companyCode, "ist", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + SELECT + COALESCE(ist.updated_date, ist.created_date)::date::text as date, + ist.item_code, + COALESCE(ii.item_name, ist.item_code, '미지정') as item, + COALESCE(wi.warehouse_name, ist.warehouse_code, '미지정') as warehouse, + '일반' as category, + CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) as "currentQty", + CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) as "safetyQty", + COALESCE(ih_in.in_qty, 0) as "inQty", + COALESCE(ih_out.out_qty, 0) as "outQty", + 0 as "stockValue", + GREATEST(CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) + - CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric), 0) as "shortageQty", + CASE WHEN CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) > 0 + AND COALESCE(ih_out.out_qty, 0) > 0 + THEN ROUND(COALESCE(ih_out.out_qty, 0)::numeric + / CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '1') AS numeric), 2) + ELSE 0 END as "turnover", + ist.company_code + FROM inventory_stock ist + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, company_code + FROM item_info ORDER BY item_number, company_code, created_date DESC + ) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code + LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code + AND ist.company_code = wi.company_code + LEFT JOIN ( + SELECT item_code, company_code, + SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as in_qty + FROM inventory_history WHERE transaction_type = 'IN' + GROUP BY item_code, company_code + ) ih_in ON ist.item_code = ih_in.item_code AND ist.company_code = ih_in.company_code + LEFT JOIN ( + SELECT item_code, company_code, + SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as out_qty + FROM inventory_history WHERE transaction_type = 'OUT' + GROUP BY item_code, company_code + ) ih_out ON ist.item_code = ih_out.item_code AND ist.company_code = ih_out.company_code + ${whereClause} + ORDER BY date DESC NULLS LAST + `; + + const dataRows = await query(dataQuery, params); + + logger.info("재고 리포트 데이터 조회", { companyCode, rowCount: dataRows.length }); + + res.status(200).json({ + success: true, + data: { + rows: dataRows, + filterOptions: { + items: extractFilterSet(dataRows, "item"), + warehouses: extractFilterSet(dataRows, "warehouse"), + categories: [ + { value: "원자재", label: "원자재" }, { value: "부자재", label: "부자재" }, + { value: "반제품", label: "반제품" }, { value: "완제품", label: "완제품" }, + { value: "일반", label: "일반" }, + ], + }, + totalCount: dataRows.length, + }, + }); + } catch (error: any) { + logger.error("재고 리포트 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: "재고 리포트 데이터 조회에 실패했습니다", error: error.message }); + } +} + +// ============================================ +// 구매 리포트 +// ============================================ +export async function getPurchaseReportData(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; } + + const { startDate, endDate } = req.query; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + const cf = buildCompanyFilter(companyCode, "po", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx); + conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx; + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + SELECT + COALESCE(po.order_date, po.created_date::date::text) as date, + po.purchase_no, + COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier, + COALESCE(po.item_name, po.item_code, '미지정') as item, + po.item_code, + COALESCE(po.manager, '미지정') as manager, + po.status, + CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty", + CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty", + CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice", + CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt", + CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) + * CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt", + 1 as "orderCnt", + po.company_code + FROM purchase_order_mng po + ${whereClause} + ORDER BY date DESC NULLS LAST + `; + + const dataRows = await query(dataQuery, params); + + logger.info("구매 리포트 데이터 조회", { companyCode, rowCount: dataRows.length }); + + res.status(200).json({ + success: true, + data: { + rows: dataRows, + filterOptions: { + suppliers: extractFilterSet(dataRows, "supplier"), + items: extractFilterSet(dataRows, "item"), + managers: extractFilterSet(dataRows, "manager"), + statuses: extractFilterSet(dataRows, "status"), + }, + totalCount: dataRows.length, + }, + }); + } catch (error: any) { + logger.error("구매 리포트 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: "구매 리포트 데이터 조회에 실패했습니다", error: error.message }); + } +} + +// ============================================ +// 품질 리포트 +// ============================================ +export async function getQualityReportData(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; } + + const { startDate, endDate } = req.query; + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + const cf = buildCompanyFilter(companyCode, "pr", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const df = buildDateFilter(startDate, endDate, "COALESCE(pr.production_date, pr.created_date::date::text)", idx); + conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx; + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + SELECT + COALESCE(pr.production_date, pr.created_date::date::text) as date, + COALESCE(ii.item_name, wi.item_id, '미지정') as item, + '일반검사' as "defectType", + COALESCE(wi.routing, '미지정') as process, + COALESCE(pr.worker_name, '미지정') as inspector, + CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) as "inspQty", + CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) + - CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "passQty", + CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "defectQty", + 0 as "reworkQty", + 0 as "scrapQty", + 0 as "claimCnt", + pr.company_code + FROM production_record pr + LEFT JOIN work_instruction wi ON pr.wo_id = wi.id AND pr.company_code = wi.company_code + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, company_code + FROM item_info ORDER BY item_number, company_code, created_date DESC + ) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code + ${whereClause} + ORDER BY date DESC NULLS LAST + `; + + const dataRows = await query(dataQuery, params); + + logger.info("품질 리포트 데이터 조회", { companyCode, rowCount: dataRows.length }); + + res.status(200).json({ + success: true, + data: { + rows: dataRows, + filterOptions: { + items: extractFilterSet(dataRows, "item"), + defectTypes: [ + { value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" }, + { value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" }, + { value: "일반검사", label: "일반검사" }, + ], + processes: extractFilterSet(dataRows, "process"), + inspectors: extractFilterSet(dataRows, "inspector"), + }, + totalCount: dataRows.length, + }, + }); + } catch (error: any) { + logger.error("품질 리포트 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: "품질 리포트 데이터 조회에 실패했습니다", error: error.message }); + } +} + +// ============================================ +// 설비 리포트 +// ============================================ +export async function getEquipmentReportData(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; } + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + const cf = buildCompanyFilter(companyCode, "ei", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + SELECT + COALESCE(ei.updated_date, ei.created_date)::date::text as date, + ei.equipment_code, + COALESCE(ei.equipment_name, ei.equipment_code) as equipment, + COALESCE(ei.equipment_type, '미지정') as "equipType", + COALESCE(ei.location, '미지정') as line, + COALESCE(ui.user_name, ei.manager_id, '미지정') as manager, + ei.status, + CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "runTime", + 0 as "downTime", + 100 as "opRate", + 0 as "faultCnt", + 0 as "mtbf", + 0 as "mttr", + 0 as "maintCost", + CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "prodQty", + ei.company_code + FROM equipment_info ei + LEFT JOIN ( + SELECT DISTINCT ON (user_id) user_id, user_name FROM user_info + ) ui ON ei.manager_id = ui.user_id + ${whereClause} + ORDER BY equipment ASC + `; + + const dataRows = await query(dataQuery, params); + + logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.length }); + + res.status(200).json({ + success: true, + data: { + rows: dataRows, + filterOptions: { + equipment: extractFilterSet(dataRows, "equipment"), + equipTypes: extractFilterSet(dataRows, "equipType"), + lines: extractFilterSet(dataRows, "line"), + managers: extractFilterSet(dataRows, "manager"), + }, + totalCount: dataRows.length, + }, + }); + } catch (error: any) { + logger.error("설비 리포트 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message }); + } +} + +// ============================================ +// 금형 리포트 +// ============================================ +export async function getMoldReportData(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; } + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + const cf = buildCompanyFilter(companyCode, "mm", idx); + if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } + + const whereClause = buildWhereClause(conditions); + + const dataQuery = ` + SELECT + COALESCE(mm.updated_date, mm.created_date)::date::text as date, + mm.mold_code, + COALESCE(mm.mold_name, mm.mold_code) as mold, + COALESCE(mm.mold_type, mm.category, '미지정') as "moldType", + COALESCE(ii.item_name, '미지정') as item, + COALESCE(mm.manufacturer, '미지정') as maker, + mm.operation_status as status, + CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) as "shotCnt", + CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) as "guaranteeShot", + CASE WHEN CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) > 0 + THEN ROUND( + CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) * 100.0 + / CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '1') AS numeric), 1) + ELSE 0 END as "lifeRate", + 0 as "repairCnt", + 0 as "repairCost", + 0 as "prodQty", + 0 as "defectRate", + CAST(COALESCE(NULLIF(mm.cavity_count::text, ''), '0') AS numeric) as "cavityUse", + mm.company_code + FROM mold_mng mm + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, company_code + FROM item_info ORDER BY item_number, company_code, created_date DESC + ) ii ON mm.mold_code = ii.item_number AND mm.company_code = ii.company_code + ${whereClause} + ORDER BY mold ASC + `; + + const dataRows = await query(dataQuery, params); + + logger.info("금형 리포트 데이터 조회", { companyCode, rowCount: dataRows.length }); + + res.status(200).json({ + success: true, + data: { + rows: dataRows, + filterOptions: { + molds: extractFilterSet(dataRows, "mold"), + moldTypes: extractFilterSet(dataRows, "moldType"), + items: extractFilterSet(dataRows, "item"), + makers: extractFilterSet(dataRows, "maker"), + }, + totalCount: dataRows.length, + }, + }); + } catch (error: any) { + logger.error("금형 리포트 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: "금형 리포트 데이터 조회에 실패했습니다", error: error.message }); + } +} diff --git a/backend-node/src/controllers/salesReportController.ts b/backend-node/src/controllers/salesReportController.ts new file mode 100644 index 00000000..9d4e4fff --- /dev/null +++ b/backend-node/src/controllers/salesReportController.ts @@ -0,0 +1,161 @@ +import { Response } from "express"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 영업 리포트 컨트롤러 + * - 수주 데이터를 기반으로 집계/분석용 원본 데이터를 반환 + * - 프론트엔드에서 그룹핑/집계/필터링 처리 + */ +export async function getSalesReportData( + req: any, + res: Response +): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); + return; + } + + const { startDate, endDate } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIdx = 1; + + // 멀티테넌시: 최고관리자는 전체, 일반 회사는 자기 데이터만 + if (companyCode !== "*") { + conditions.push(`som.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } + + // 날짜 필터 (due_date 또는 order_date 기준) + if (startDate) { + conditions.push( + `COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) >= $${paramIdx}` + ); + params.push(startDate); + paramIdx++; + } + if (endDate) { + conditions.push( + `COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) <= $${paramIdx}` + ); + params.push(endDate); + paramIdx++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const dataQuery = ` + SELECT + som.order_no, + COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) as date, + som.order_date, + som.partner_id, + COALESCE(cm.customer_name, som.partner_id, '미지정') as customer, + sod.part_code, + COALESCE(ii.item_name, sod.part_name, sod.part_code, '미지정') as item, + CAST(COALESCE(NULLIF(sod.qty, ''), '0') AS numeric) as "orderQty", + CAST(COALESCE(NULLIF(sod.ship_qty, ''), '0') AS numeric) as "shipQty", + CAST(COALESCE(NULLIF(sod.unit_price, ''), '0') AS numeric) as "unitPrice", + CAST(COALESCE(NULLIF(sod.amount, ''), '0') AS numeric) as "orderAmt", + 1 as "orderCount", + som.status, + som.company_code + FROM sales_order_mng som + JOIN sales_order_detail sod + ON som.order_no = sod.order_no + AND som.company_code = sod.company_code + LEFT JOIN customer_mng cm + ON som.partner_id = cm.customer_code + AND som.company_code = cm.company_code + LEFT JOIN ( + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, company_code + FROM item_info + ORDER BY item_number, company_code, created_date DESC + ) ii + ON sod.part_code = ii.item_number + AND sod.company_code = ii.company_code + ${whereClause} + ORDER BY date DESC NULLS LAST + `; + + // query()는 rows 배열을 직접 반환 + const dataRows = await query(dataQuery, params); + + // 필터 옵션 조회 (거래처, 품목, 상태) + const filterParams: any[] = []; + let filterWhere = ""; + + if (companyCode !== "*") { + filterWhere = `WHERE company_code = $1`; + filterParams.push(companyCode); + } + + const statusWhere = filterWhere + ? `${filterWhere} AND status IS NOT NULL` + : `WHERE status IS NOT NULL`; + + const [customersRows, statusRows] = await Promise.all([ + query( + `SELECT DISTINCT customer_code as value, customer_name as label + FROM customer_mng ${filterWhere} + ORDER BY customer_name`, + filterParams + ), + query( + `SELECT DISTINCT status as value, status as label + FROM sales_order_mng ${statusWhere} + ORDER BY status`, + filterParams + ), + ]); + + // 품목은 데이터에서 추출 (실제 수주에 사용된 품목만) + const itemSet = new Map(); + dataRows.forEach((row: any) => { + if (row.part_code && !itemSet.has(row.part_code)) { + itemSet.set(row.part_code, row.item); + } + }); + const items = Array.from(itemSet.entries()).map(([value, label]) => ({ + value, + label, + })); + + logger.info("영업 리포트 데이터 조회", { + companyCode, + rowCount: dataRows.length, + startDate, + endDate, + }); + + res.status(200).json({ + success: true, + data: { + rows: dataRows, + filterOptions: { + customers: customersRows, + items, + statuses: statusRows, + }, + totalCount: dataRows.length, + }, + }); + } catch (error: any) { + logger.error("영업 리포트 데이터 조회 실패", { + error: error.message, + stack: error.stack, + }); + res.status(500).json({ + success: false, + message: "영업 리포트 데이터 조회에 실패했습니다", + error: error.message, + }); + } +} diff --git a/backend-node/src/routes/analyticsReportRoutes.ts b/backend-node/src/routes/analyticsReportRoutes.ts new file mode 100644 index 00000000..518de596 --- /dev/null +++ b/backend-node/src/routes/analyticsReportRoutes.ts @@ -0,0 +1,23 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getProductionReportData, + getInventoryReportData, + getPurchaseReportData, + getQualityReportData, + getEquipmentReportData, + getMoldReportData, +} from "../controllers/analyticsReportController"; + +const router = Router(); + +router.use(authenticateToken); + +router.get("/production/data", getProductionReportData); +router.get("/inventory/data", getInventoryReportData); +router.get("/purchase/data", getPurchaseReportData); +router.get("/quality/data", getQualityReportData); +router.get("/equipment/data", getEquipmentReportData); +router.get("/mold/data", getMoldReportData); + +export default router; diff --git a/backend-node/src/routes/salesReportRoutes.ts b/backend-node/src/routes/salesReportRoutes.ts new file mode 100644 index 00000000..d5c7e565 --- /dev/null +++ b/backend-node/src/routes/salesReportRoutes.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { getSalesReportData } from "../controllers/salesReportController"; + +const router = Router(); + +router.use(authenticateToken); + +// 영업 리포트 원본 데이터 조회 +router.get("/data", getSalesReportData); + +export default router; diff --git a/frontend/app/(main)/admin/report/equipment/page.tsx b/frontend/app/(main)/admin/report/equipment/page.tsx new file mode 100644 index 00000000..7fe7ba4b --- /dev/null +++ b/frontend/app/(main)/admin/report/equipment/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine"; + +const config: ReportConfig = { + key: "equipment_report_v2", + title: "설비 리포트", + description: "설비 가동/보전 다중 조건 비교 분석", + apiEndpoint: "/report/equipment/data", + metrics: [ + { id: "runTime", name: "가동시간", unit: "H", color: "#3b82f6" }, + { id: "downTime", name: "비가동시간", unit: "H", color: "#ef4444" }, + { id: "opRate", name: "가동률", unit: "%", color: "#10b981", isRate: true }, + { id: "faultCnt", name: "고장횟수", unit: "회", color: "#f59e0b" }, + { id: "mtbf", name: "MTBF", unit: "H", color: "#8b5cf6" }, + { id: "mttr", name: "MTTR", unit: "H", color: "#ec4899" }, + { id: "maintCost", name: "보전비용", unit: "만원", color: "#06b6d4" }, + { id: "prodQty", name: "생산수량", unit: "EA", color: "#84cc16" }, + ], + groupByOptions: [ + { id: "equipment", name: "설비별" }, + { id: "equipType", name: "설비유형별" }, + { id: "line", name: "라인별" }, + { id: "manager", name: "담당자별" }, + { id: "monthly", name: "월별" }, + { id: "quarterly", name: "분기별" }, + { id: "weekly", name: "주별" }, + { id: "daily", name: "일별" }, + ], + defaultGroupBy: "equipment", + defaultMetrics: ["runTime"], + thresholds: [ + { id: "fault", label: "고장횟수 ≥", defaultValue: 3, unit: "회" }, + { id: "opRate", label: "가동률 ≤", defaultValue: 80, unit: "%" }, + ], + filterFieldDefs: [ + { id: "equipment", name: "설비", type: "select", optionKey: "equipment" }, + { id: "equipType", name: "설비유형", type: "select", optionKey: "equipTypes" }, + { id: "line", name: "라인", type: "select", optionKey: "lines" }, + { id: "manager", name: "담당자", type: "select", optionKey: "managers" }, + { id: "opRate", name: "가동률", type: "number" }, + { id: "faultCnt", name: "고장횟수", type: "number" }, + ], + drilldownColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "equipment", name: "설비" }, + { id: "equipType", name: "설비유형" }, + { id: "line", name: "라인" }, + { id: "status", name: "상태", format: "badge" }, + { id: "runTime", name: "가동시간", align: "right", format: "number" }, + { id: "downTime", name: "비가동시간", align: "right", format: "number" }, + { id: "opRate", name: "가동률(%)", align: "right", format: "number" }, + { id: "faultCnt", name: "고장횟수", align: "right", format: "number" }, + ], + rawDataColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "equipment_code", name: "설비코드" }, + { id: "equipment", name: "설비명" }, + { id: "equipType", name: "설비유형" }, + { id: "line", name: "라인" }, + { id: "manager", name: "담당자" }, + { id: "status", name: "상태", format: "badge" }, + { id: "runTime", name: "가동시간", align: "right", format: "number" }, + { id: "downTime", name: "비가동시간", align: "right", format: "number" }, + { id: "faultCnt", name: "고장횟수", align: "right", format: "number" }, + ], + emptyMessage: "설비 데이터가 없습니다", +}; + +export default function EquipmentReportPage() { + return ; +} diff --git a/frontend/app/(main)/admin/report/inventory/page.tsx b/frontend/app/(main)/admin/report/inventory/page.tsx new file mode 100644 index 00000000..61fc0d0f --- /dev/null +++ b/frontend/app/(main)/admin/report/inventory/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine"; + +const config: ReportConfig = { + key: "inventory_report_v2", + title: "재고 리포트", + description: "재고 현황 다중 조건 비교 분석", + apiEndpoint: "/report/inventory/data", + metrics: [ + { id: "currentQty", name: "현재고", unit: "EA", color: "#3b82f6" }, + { id: "safetyQty", name: "안전재고", unit: "EA", color: "#10b981" }, + { id: "inQty", name: "입고수량", unit: "EA", color: "#f59e0b" }, + { id: "outQty", name: "출고수량", unit: "EA", color: "#ef4444" }, + { id: "turnover", name: "회전율", unit: "회", color: "#8b5cf6", isRate: true }, + { id: "stockValue", name: "재고금액", unit: "만원", color: "#ec4899" }, + { id: "shortageQty", name: "부족수량", unit: "EA", color: "#06b6d4" }, + ], + groupByOptions: [ + { id: "item", name: "품목별" }, + { id: "warehouse", name: "창고별" }, + { id: "category", name: "카테고리별" }, + { id: "monthly", name: "월별" }, + { id: "quarterly", name: "분기별" }, + { id: "weekly", name: "주별" }, + { id: "daily", name: "일별" }, + ], + defaultGroupBy: "item", + defaultMetrics: ["currentQty"], + thresholds: [ + { id: "safety", label: "안전재고 이하 경고", defaultValue: 0, unit: "EA" }, + { id: "turnover", label: "회전율 ≤", defaultValue: 2, unit: "회" }, + ], + filterFieldDefs: [ + { id: "item", name: "품목", type: "select", optionKey: "items" }, + { id: "warehouse", name: "창고", type: "select", optionKey: "warehouses" }, + { id: "category", name: "카테고리", type: "select", optionKey: "categories" }, + { id: "currentQty", name: "현재고", type: "number" }, + { id: "turnover", name: "회전율", type: "number" }, + ], + drilldownColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "item", name: "품목" }, + { id: "warehouse", name: "창고" }, + { id: "currentQty", name: "현재고", align: "right", format: "number" }, + { id: "safetyQty", name: "안전재고", align: "right", format: "number" }, + { id: "inQty", name: "입고", align: "right", format: "number" }, + { id: "outQty", name: "출고", align: "right", format: "number" }, + { id: "shortageQty", name: "부족", align: "right", format: "number" }, + ], + rawDataColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "item_code", name: "품목코드" }, + { id: "item", name: "품목명" }, + { id: "warehouse", name: "창고" }, + { id: "category", name: "카테고리" }, + { id: "currentQty", name: "현재고", align: "right", format: "number" }, + { id: "safetyQty", name: "안전재고", align: "right", format: "number" }, + { id: "inQty", name: "입고", align: "right", format: "number" }, + { id: "outQty", name: "출고", align: "right", format: "number" }, + ], + emptyMessage: "재고 데이터가 없습니다", +}; + +export default function InventoryReportPage() { + return ; +} diff --git a/frontend/app/(main)/admin/report/mold/page.tsx b/frontend/app/(main)/admin/report/mold/page.tsx new file mode 100644 index 00000000..4b42a413 --- /dev/null +++ b/frontend/app/(main)/admin/report/mold/page.tsx @@ -0,0 +1,73 @@ +"use client"; + +import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine"; + +const config: ReportConfig = { + key: "mold_report_v2", + title: "금형 리포트", + description: "금형 수명/관리 다중 조건 비교 분석", + apiEndpoint: "/report/mold/data", + metrics: [ + { id: "shotCnt", name: "타수", unit: "회", color: "#3b82f6" }, + { id: "guaranteeShot", name: "보증타수", unit: "회", color: "#10b981" }, + { id: "lifeRate", name: "수명률", unit: "%", color: "#f59e0b", isRate: true }, + { id: "repairCnt", name: "수리횟수", unit: "회", color: "#ef4444" }, + { id: "repairCost", name: "수리비용", unit: "만원", color: "#8b5cf6" }, + { id: "prodQty", name: "생산수량", unit: "EA", color: "#ec4899" }, + { id: "defectRate", name: "불량률", unit: "%", color: "#06b6d4", isRate: true }, + { id: "cavityUse", name: "캐비티사용률", unit: "%", color: "#84cc16", isRate: true }, + ], + groupByOptions: [ + { id: "mold", name: "금형별" }, + { id: "moldType", name: "금형유형별" }, + { id: "item", name: "적용품목별" }, + { id: "maker", name: "제조사별" }, + { id: "monthly", name: "월별" }, + { id: "quarterly", name: "분기별" }, + { id: "weekly", name: "주별" }, + { id: "daily", name: "일별" }, + ], + defaultGroupBy: "mold", + defaultMetrics: ["shotCnt"], + thresholds: [ + { id: "life", label: "보증타수 도달률 ≥", defaultValue: 90, unit: "%" }, + { id: "cost", label: "수리비용 ≥", defaultValue: 100, unit: "만원" }, + ], + filterFieldDefs: [ + { id: "mold", name: "금형", type: "select", optionKey: "molds" }, + { id: "moldType", name: "금형유형", type: "select", optionKey: "moldTypes" }, + { id: "item", name: "적용품목", type: "select", optionKey: "items" }, + { id: "maker", name: "제조사", type: "select", optionKey: "makers" }, + { id: "shotCnt", name: "타수", type: "number" }, + { id: "lifeRate", name: "수명률", type: "number" }, + ], + drilldownColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "mold", name: "금형" }, + { id: "moldType", name: "금형유형" }, + { id: "item", name: "적용품목" }, + { id: "status", name: "상태", format: "badge" }, + { id: "shotCnt", name: "타수", align: "right", format: "number" }, + { id: "guaranteeShot", name: "보증타수", align: "right", format: "number" }, + { id: "lifeRate", name: "수명률(%)", align: "right", format: "number" }, + { id: "repairCnt", name: "수리횟수", align: "right", format: "number" }, + ], + rawDataColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "mold_code", name: "금형코드" }, + { id: "mold", name: "금형명" }, + { id: "moldType", name: "금형유형" }, + { id: "maker", name: "제조사" }, + { id: "status", name: "상태", format: "badge" }, + { id: "shotCnt", name: "타수", align: "right", format: "number" }, + { id: "guaranteeShot", name: "보증타수", align: "right", format: "number" }, + { id: "lifeRate", name: "수명률(%)", align: "right", format: "number" }, + { id: "repairCnt", name: "수리횟수", align: "right", format: "number" }, + { id: "repairCost", name: "수리비용", align: "right", format: "number" }, + ], + emptyMessage: "금형 데이터가 없습니다", +}; + +export default function MoldReportPage() { + return ; +} diff --git a/frontend/app/(main)/admin/report/production/page.tsx b/frontend/app/(main)/admin/report/production/page.tsx new file mode 100644 index 00000000..e8416c10 --- /dev/null +++ b/frontend/app/(main)/admin/report/production/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine"; + +const config: ReportConfig = { + key: "production_report_v2", + title: "생산 리포트", + description: "생산 실적 다중 조건 비교 분석", + apiEndpoint: "/report/production/data", + metrics: [ + { id: "prodQty", name: "생산량", unit: "EA", color: "#3b82f6" }, + { id: "planQty", name: "계획수량", unit: "EA", color: "#10b981" }, + { id: "defectQty", name: "불량수량", unit: "EA", color: "#ef4444" }, + { id: "defectRate", name: "불량률", unit: "%", color: "#f59e0b", isRate: true }, + { id: "opRate", name: "가동률", unit: "%", color: "#8b5cf6", isRate: true }, + { id: "achRate", name: "달성률", unit: "%", color: "#ec4899", isRate: true }, + { id: "runTime", name: "가동시간", unit: "H", color: "#06b6d4" }, + { id: "downTime", name: "비가동시간", unit: "H", color: "#84cc16" }, + ], + groupByOptions: [ + { id: "process", name: "공정별" }, + { id: "equipment", name: "설비별" }, + { id: "item", name: "품목별" }, + { id: "worker", name: "작업자별" }, + { id: "monthly", name: "월별" }, + { id: "quarterly", name: "분기별" }, + { id: "weekly", name: "주별" }, + { id: "daily", name: "일별" }, + ], + defaultGroupBy: "process", + defaultMetrics: ["prodQty"], + thresholds: [ + { id: "defect", label: "불량률 ≥", defaultValue: 3, unit: "%" }, + { id: "opRate", label: "가동률 ≤", defaultValue: 85, unit: "%" }, + ], + filterFieldDefs: [ + { id: "process", name: "공정", type: "select", optionKey: "processes" }, + { id: "equipment", name: "설비", type: "select", optionKey: "equipment" }, + { id: "item", name: "품목", type: "select", optionKey: "items" }, + { id: "worker", name: "작업자", type: "select", optionKey: "workers" }, + { id: "prodQty", name: "생산량", type: "number" }, + { id: "defectRate", name: "불량률", type: "number" }, + ], + drilldownColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "process", name: "공정" }, + { id: "equipment", name: "설비" }, + { id: "item", name: "품목" }, + { id: "worker", name: "작업자" }, + { id: "prodQty", name: "생산량", align: "right", format: "number" }, + { id: "planQty", name: "계획수량", align: "right", format: "number" }, + { id: "defectQty", name: "불량수량", align: "right", format: "number" }, + { id: "defectRate", name: "불량률(%)", align: "right", format: "number" }, + ], + rawDataColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "process", name: "공정" }, + { id: "equipment", name: "설비" }, + { id: "item", name: "품목" }, + { id: "worker", name: "작업자" }, + { id: "prodQty", name: "생산량", align: "right", format: "number" }, + { id: "planQty", name: "계획수량", align: "right", format: "number" }, + { id: "defectQty", name: "불량수량", align: "right", format: "number" }, + { id: "runTime", name: "가동시간", align: "right", format: "number" }, + { id: "downTime", name: "비가동시간", align: "right", format: "number" }, + ], + enrichRow: (d) => ({ + ...d, + defectRate: d.prodQty > 0 ? parseFloat((d.defectQty / d.prodQty * 100).toFixed(1)) : 0, + opRate: (d.runTime + d.downTime) > 0 + ? parseFloat(((d.runTime / (d.runTime + d.downTime)) * 100).toFixed(1)) + : 0, + achRate: d.planQty > 0 ? parseFloat((d.prodQty / d.planQty * 100).toFixed(1)) : 0, + }), + emptyMessage: "생산 데이터가 없습니다", +}; + +export default function ProductionReportPage() { + return ; +} diff --git a/frontend/app/(main)/admin/report/purchase/page.tsx b/frontend/app/(main)/admin/report/purchase/page.tsx new file mode 100644 index 00000000..0ba7f843 --- /dev/null +++ b/frontend/app/(main)/admin/report/purchase/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine"; + +const config: ReportConfig = { + key: "purchase_report_v2", + title: "구매 리포트", + description: "구매/발주 다중 조건 비교 분석", + apiEndpoint: "/report/purchase/data", + metrics: [ + { id: "orderAmt", name: "발주금액", unit: "원", color: "#3b82f6" }, + { id: "receiveAmt", name: "입고금액", unit: "원", color: "#10b981" }, + { id: "orderQty", name: "발주수량", unit: "EA", color: "#f59e0b" }, + { id: "receiveQty", name: "입고수량", unit: "EA", color: "#8b5cf6" }, + { id: "receiveRate", name: "입고율", unit: "%", color: "#ef4444", isRate: true }, + { id: "unitPrice", name: "단가", unit: "원", color: "#ec4899" }, + { id: "orderCnt", name: "발주건수", unit: "건", color: "#f97316" }, + ], + groupByOptions: [ + { id: "supplier", name: "공급업체별" }, + { id: "item", name: "품목별" }, + { id: "manager", name: "구매담당별" }, + { id: "status", name: "상태별" }, + { id: "monthly", name: "월별" }, + { id: "quarterly", name: "분기별" }, + { id: "weekly", name: "주별" }, + { id: "daily", name: "일별" }, + ], + defaultGroupBy: "supplier", + defaultMetrics: ["orderAmt"], + thresholds: [ + { id: "delay", label: "납기지연 ≥", defaultValue: 3, unit: "일" }, + { id: "price", label: "단가변동률 ≥", defaultValue: 10, unit: "%" }, + ], + filterFieldDefs: [ + { id: "supplier", name: "공급업체", type: "select", optionKey: "suppliers" }, + { id: "item", name: "품목", type: "select", optionKey: "items" }, + { id: "manager", name: "구매담당", type: "select", optionKey: "managers" }, + { id: "status", name: "상태", type: "select", optionKey: "statuses" }, + { id: "orderAmt", name: "발주금액", type: "number" }, + { id: "orderQty", name: "발주수량", type: "number" }, + ], + drilldownColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "purchase_no", name: "발주번호" }, + { id: "supplier", name: "공급업체" }, + { id: "item", name: "품목" }, + { id: "status", name: "상태", format: "badge" }, + { id: "orderQty", name: "발주수량", align: "right", format: "number" }, + { id: "receiveQty", name: "입고수량", align: "right", format: "number" }, + { id: "unitPrice", name: "단가", align: "right", format: "number" }, + { id: "orderAmt", name: "발주금액", align: "right", format: "number" }, + ], + rawDataColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "purchase_no", name: "발주번호" }, + { id: "supplier", name: "공급업체" }, + { id: "item_code", name: "품목코드" }, + { id: "item", name: "품목명" }, + { id: "status", name: "상태", format: "badge" }, + { id: "orderQty", name: "발주수량", align: "right", format: "number" }, + { id: "receiveQty", name: "입고수량", align: "right", format: "number" }, + { id: "unitPrice", name: "단가", align: "right", format: "number" }, + { id: "orderAmt", name: "발주금액", align: "right", format: "number" }, + { id: "manager", name: "담당자" }, + ], + enrichRow: (d) => ({ + ...d, + receiveRate: d.orderQty > 0 ? parseFloat((d.receiveQty / d.orderQty * 100).toFixed(1)) : 0, + }), + emptyMessage: "구매 데이터가 없습니다", +}; + +export default function PurchaseReportPage() { + return ; +} diff --git a/frontend/app/(main)/admin/report/quality/page.tsx b/frontend/app/(main)/admin/report/quality/page.tsx new file mode 100644 index 00000000..0d1171f2 --- /dev/null +++ b/frontend/app/(main)/admin/report/quality/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine"; + +const config: ReportConfig = { + key: "quality_report_v2", + title: "품질 리포트", + description: "품질/검사 다중 조건 비교 분석", + apiEndpoint: "/report/quality/data", + metrics: [ + { id: "defectQty", name: "불량수량", unit: "EA", color: "#ef4444" }, + { id: "defectRate", name: "불량률", unit: "%", color: "#f59e0b", isRate: true }, + { id: "inspQty", name: "검사수량", unit: "EA", color: "#3b82f6" }, + { id: "passQty", name: "합격수량", unit: "EA", color: "#10b981" }, + { id: "passRate", name: "합격률", unit: "%", color: "#8b5cf6", isRate: true }, + { id: "reworkQty", name: "재작업수량", unit: "EA", color: "#ec4899" }, + { id: "scrapQty", name: "폐기수량", unit: "EA", color: "#06b6d4" }, + { id: "claimCnt", name: "클레임건수", unit: "건", color: "#84cc16" }, + ], + groupByOptions: [ + { id: "item", name: "품목별" }, + { id: "defectType", name: "불량유형별" }, + { id: "process", name: "공정별" }, + { id: "inspector", name: "검사자별" }, + { id: "monthly", name: "월별" }, + { id: "quarterly", name: "분기별" }, + { id: "weekly", name: "주별" }, + { id: "daily", name: "일별" }, + ], + defaultGroupBy: "item", + defaultMetrics: ["defectQty"], + thresholds: [ + { id: "defectRate", label: "불량률 ≥", defaultValue: 5, unit: "%" }, + { id: "defectQty", label: "불량수량 ≥", defaultValue: 20, unit: "EA" }, + ], + filterFieldDefs: [ + { id: "item", name: "품목", type: "select", optionKey: "items" }, + { id: "defectType", name: "불량유형", type: "select", optionKey: "defectTypes" }, + { id: "process", name: "공정", type: "select", optionKey: "processes" }, + { id: "inspector", name: "검사자", type: "select", optionKey: "inspectors" }, + { id: "defectQty", name: "불량수량", type: "number" }, + { id: "defectRate", name: "불량률", type: "number" }, + ], + drilldownColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "item", name: "품목" }, + { id: "defectType", name: "불량유형" }, + { id: "process", name: "공정" }, + { id: "inspector", name: "검사자" }, + { id: "inspQty", name: "검사수량", align: "right", format: "number" }, + { id: "defectQty", name: "불량수량", align: "right", format: "number" }, + { id: "defectRate", name: "불량률(%)", align: "right", format: "number" }, + { id: "passRate", name: "합격률(%)", align: "right", format: "number" }, + ], + rawDataColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "item", name: "품목" }, + { id: "defectType", name: "불량유형" }, + { id: "process", name: "공정" }, + { id: "inspector", name: "검사자" }, + { id: "inspQty", name: "검사수량", align: "right", format: "number" }, + { id: "passQty", name: "합격수량", align: "right", format: "number" }, + { id: "defectQty", name: "불량수량", align: "right", format: "number" }, + { id: "reworkQty", name: "재작업", align: "right", format: "number" }, + { id: "scrapQty", name: "폐기", align: "right", format: "number" }, + ], + enrichRow: (d) => ({ + ...d, + defectRate: d.inspQty > 0 ? parseFloat((d.defectQty / d.inspQty * 100).toFixed(1)) : 0, + passRate: d.inspQty > 0 ? parseFloat((d.passQty / d.inspQty * 100).toFixed(1)) : 0, + }), + emptyMessage: "품질 데이터가 없습니다", +}; + +export default function QualityReportPage() { + return ; +} diff --git a/frontend/app/(main)/admin/report/sales/page.tsx b/frontend/app/(main)/admin/report/sales/page.tsx new file mode 100644 index 00000000..1136028e --- /dev/null +++ b/frontend/app/(main)/admin/report/sales/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine"; + +const config: ReportConfig = { + key: "sales_report_v2", + title: "영업 리포트", + description: "다중 조건 비교 분석", + apiEndpoint: "/sales-report/data", + metrics: [ + { id: "orderAmt", name: "수주금액", unit: "원", color: "#3b82f6" }, + { id: "orderQty", name: "수주수량", unit: "EA", color: "#10b981" }, + { id: "shipQty", name: "출하수량", unit: "EA", color: "#ef4444" }, + { id: "unitPrice", name: "단가", unit: "원", color: "#8b5cf6" }, + { id: "orderCount", name: "수주건수", unit: "건", color: "#f59e0b" }, + ], + groupByOptions: [ + { id: "customer", name: "거래처별" }, + { id: "item", name: "품목별" }, + { id: "status", name: "상태별" }, + { id: "monthly", name: "월별" }, + { id: "quarterly", name: "분기별" }, + { id: "weekly", name: "주별" }, + { id: "daily", name: "일별" }, + ], + defaultGroupBy: "customer", + defaultMetrics: ["orderAmt"], + thresholds: [ + { id: "low", label: "목표 미달 ≤", defaultValue: 80, unit: "%" }, + { id: "high", label: "목표 초과 ≥", defaultValue: 120, unit: "%" }, + ], + filterFieldDefs: [ + { id: "customer", name: "거래처", type: "select", optionKey: "customers" }, + { id: "item", name: "품목", type: "select", optionKey: "items" }, + { id: "status", name: "상태", type: "select", optionKey: "statuses" }, + { id: "orderAmt", name: "수주금액", type: "number" }, + { id: "orderQty", name: "수주수량", type: "number" }, + ], + drilldownColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "order_no", name: "수주번호" }, + { id: "customer", name: "거래처" }, + { id: "item", name: "품목" }, + { id: "status", name: "상태", format: "badge" }, + { id: "orderQty", name: "수주수량", align: "right", format: "number" }, + { id: "unitPrice", name: "단가", align: "right", format: "number" }, + { id: "orderAmt", name: "수주금액", align: "right", format: "number" }, + { id: "shipQty", name: "출하수량", align: "right", format: "number" }, + ], + rawDataColumns: [ + { id: "date", name: "날짜", format: "date" }, + { id: "order_no", name: "수주번호" }, + { id: "customer", name: "거래처" }, + { id: "part_code", name: "품목코드" }, + { id: "item", name: "품목명" }, + { id: "status", name: "상태", format: "badge" }, + { id: "orderQty", name: "수주수량", align: "right", format: "number" }, + { id: "unitPrice", name: "단가", align: "right", format: "number" }, + { id: "orderAmt", name: "수주금액", align: "right", format: "number" }, + { id: "shipQty", name: "출하수량", align: "right", format: "number" }, + ], + emptyMessage: "수주 데이터가 없습니다", +}; + +export default function SalesReportPage() { + return ; +} diff --git a/frontend/components/admin/report/ReportEngine.tsx b/frontend/components/admin/report/ReportEngine.tsx new file mode 100644 index 00000000..92f14ba7 --- /dev/null +++ b/frontend/components/admin/report/ReportEngine.tsx @@ -0,0 +1,1544 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { + BarChart, Bar, LineChart, Line, AreaChart, Area, + XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, +} from "recharts"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; + +// ============================================ +// 공통 상수 +// ============================================ +const COLORS = [ + "#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", + "#ec4899", "#06b6d4", "#84cc16", "#f97316", "#14b8a6", +]; + +function ChartTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+ {payload.map((entry: any, i: number) => ( +
+ + {entry.name}: + + {typeof entry.value === "number" ? entry.value.toLocaleString() : entry.value} + +
+ ))} +
+ ); +} + +const AGG_METHODS = [ + { id: "sum", name: "합계 (SUM)" }, + { id: "avg", name: "평균 (AVG)" }, + { id: "max", name: "최대 (MAX)" }, + { id: "min", name: "최소 (MIN)" }, + { id: "count", name: "건수 (COUNT)" }, +]; + +const CHART_TYPES = [ + { id: "bar", name: "막대" }, + { id: "line", name: "선" }, + { id: "area", name: "영역" }, +]; + +const DATE_PRESETS = [ + { id: "today", name: "오늘" }, + { id: "week", name: "이번주" }, + { id: "month", name: "이번달" }, + { id: "quarter", name: "이번분기" }, + { id: "year", name: "올해" }, + { id: "prevMonth", name: "전월" }, + { id: "last3m", name: "최근3개월" }, + { id: "last6m", name: "최근6개월" }, +]; + +const FILTER_OPERATORS: Record = { + select: [ + { id: "eq", name: "=" }, + { id: "neq", name: "≠" }, + { id: "in", name: "포함(IN)" }, + ], + number: [ + { id: "eq", name: "=" }, + { id: "neq", name: "≠" }, + { id: "gt", name: ">" }, + { id: "gte", name: "≥" }, + { id: "lt", name: "<" }, + { id: "lte", name: "≤" }, + ], +}; + +// ============================================ +// 타입 정의 (외부 export) +// ============================================ +export interface ReportMetric { + id: string; + name: string; + unit: string; + color: string; + isRate?: boolean; +} + +export interface ReportGroupByOption { + id: string; + name: string; +} + +export interface ReportThreshold { + id: string; + label: string; + defaultValue: number; + unit: string; +} + +export interface ReportColumnDef { + id: string; + name: string; + align?: "left" | "right"; + format?: "number" | "text" | "badge" | "date"; +} + +export interface FilterFieldDef { + id: string; + name: string; + type: "select" | "number"; + optionKey?: string; +} + +export interface ReportConfig { + key: string; + title: string; + description: string; + apiEndpoint: string; + metrics: ReportMetric[]; + groupByOptions: ReportGroupByOption[]; + defaultGroupBy: string; + defaultMetrics: string[]; + thresholds: ReportThreshold[]; + filterFieldDefs: FilterFieldDef[]; + drilldownColumns: ReportColumnDef[]; + rawDataColumns: ReportColumnDef[]; + enrichRow?: (row: Record) => Record; + emptyMessage: string; +} + +interface ConditionFilter { + id: number; + logic: "AND" | "OR" | ""; + field: string; + operator: string; + value: string; + values: string[]; +} + +interface ConditionGroup { + id: number; + name: string; + metrics: string[]; + aggMethod: string; + chartType: string; + collapsed: boolean; + filters: ConditionFilter[]; +} + +interface FilterField { + id: string; + name: string; + type: "select" | "number"; + options: { value: string; label: string }[]; +} + +interface Preset { + name: string; + desc: string; + config: { + groupBy: string; + startDate: string; + endDate: string; + conditions: ConditionGroup[]; + }; + savedAt: string; +} + +// ============================================ +// 유틸 함수 +// ============================================ +function getDatePresetRange(preset: string): { start: string; end: string } { + const today = new Date(); + let s = new Date(today); + let e = new Date(today); + + switch (preset) { + case "today": + break; + case "week": + s.setDate(today.getDate() - today.getDay()); + e.setDate(s.getDate() + 6); + break; + case "month": + s.setDate(1); + e = new Date(today.getFullYear(), today.getMonth() + 1, 0); + break; + case "quarter": { + const q = Math.floor(today.getMonth() / 3); + s = new Date(today.getFullYear(), q * 3, 1); + e = new Date(today.getFullYear(), q * 3 + 3, 0); + break; + } + case "year": + s = new Date(today.getFullYear(), 0, 1); + e = new Date(today.getFullYear(), 11, 31); + break; + case "prevMonth": + s = new Date(today.getFullYear(), today.getMonth() - 1, 1); + e = new Date(today.getFullYear(), today.getMonth(), 0); + break; + case "last3m": + s = new Date(today.getFullYear(), today.getMonth() - 3, today.getDate()); + break; + case "last6m": + s = new Date(today.getFullYear(), today.getMonth() - 6, today.getDate()); + break; + } + + return { + start: s.toISOString().split("T")[0], + end: e.toISOString().split("T")[0], + }; +} + +function getGroupKey(row: Record, groupBy: string): string { + const dateStr = row.date || ""; + switch (groupBy) { + case "daily": + return dateStr.substring(0, 10) || "미지정"; + case "weekly": { + if (!dateStr) return "미지정"; + const dt = new Date(dateStr); + const weekNum = Math.ceil( + ((dt.getTime() - new Date(dt.getFullYear(), 0, 1).getTime()) / 86400000 + + new Date(dt.getFullYear(), 0, 1).getDay() + 1) / 7 + ); + return `${dt.getFullYear()}-W${String(weekNum).padStart(2, "0")}`; + } + case "monthly": + return dateStr.substring(0, 7) || "미지정"; + case "quarterly": { + if (!dateStr) return "미지정"; + const m = parseInt(dateStr.substring(5, 7)); + return `${dateStr.substring(0, 4)}-Q${Math.ceil(m / 3)}`; + } + default: + return row[groupBy] || "미지정"; + } +} + +function aggregateValues( + rows: Record[], + metricId: string, + method: string +): number { + if (!rows.length) return 0; + const vals = rows.map((r) => Number(r[metricId]) || 0); + switch (method) { + case "sum": return vals.reduce((a, b) => a + b, 0); + case "avg": return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10; + case "max": return Math.max(...vals); + case "min": return Math.min(...vals); + case "count": return rows.length; + default: return vals.reduce((a, b) => a + b, 0); + } +} + +function evalFilter( + rec: Record, + f: ConditionFilter, + filterFields: FilterField[] +): boolean { + const field = filterFields.find((x) => x.id === f.field); + if (!field) return true; + + const rv = rec[f.field]; + + if (field.type === "select") { + switch (f.operator) { + case "eq": return !f.value || rv === f.value; + case "neq": return !f.value || rv !== f.value; + case "in": return f.values.length === 0 || f.values.includes(rv); + } + } else { + const nv = parseFloat(rv) || 0; + const cv = parseFloat(f.value) || 0; + switch (f.operator) { + case "eq": return !f.value || nv === cv; + case "neq": return !f.value || nv !== cv; + case "gt": return !f.value || nv > cv; + case "gte": return !f.value || nv >= cv; + case "lt": return !f.value || nv < cv; + case "lte": return !f.value || nv <= cv; + } + } + return true; +} + +function applyConditionFilters( + data: Record[], + filters: ConditionFilter[], + filterFields: FilterField[] +): Record[] { + if (!filters.length) return data; + return data.filter((d) => { + let res = evalFilter(d, filters[0], filterFields); + for (let i = 1; i < filters.length; i++) { + const v = evalFilter(d, filters[i], filterFields); + res = filters[i].logic === "OR" ? res || v : res && v; + } + return res; + }); +} + +function formatNumber(n: number): string { + return n.toLocaleString("ko-KR"); +} + +function renderCellValue(row: Record, col: ReportColumnDef): React.ReactNode { + const val = row[col.id]; + switch (col.format) { + case "number": + return formatNumber(Number(val) || 0); + case "date": + return String(val || "").substring(0, 10); + case "badge": + return {val || "-"}; + default: + return String(val ?? ""); + } +} + +// ============================================ +// ReportEngine 컴포넌트 +// ============================================ +interface ReportEngineProps { + config: ReportConfig; +} + +export default function ReportEngine({ config }: ReportEngineProps) { + const [rawData, setRawData] = useState[]>([]); + const [filterOptions, setFilterOptions] = useState>({}); + const [isLoading, setIsLoading] = useState(false); + + const [groupBy, setGroupBy] = useState(config.defaultGroupBy); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [activePreset, setActivePreset] = useState("last6m"); + const [filterOpen, setFilterOpen] = useState(true); + + const [conditions, setConditions] = useState([]); + const condIdRef = useRef(0); + const filterIdRef = useRef(0); + + const [viewMode, setViewMode] = useState<"table" | "card">("table"); + const [drilldownLabel, setDrilldownLabel] = useState(null); + const [rawDataOpen, setRawDataOpen] = useState(false); + + const [presets, setPresets] = useState([]); + const [presetModalOpen, setPresetModalOpen] = useState(false); + const [presetName, setPresetName] = useState(""); + const [presetDesc, setPresetDesc] = useState(""); + const [selectedPresetIdx, setSelectedPresetIdx] = useState(""); + + const [thresholdValues, setThresholdValues] = useState>(() => { + const defaults: Record = {}; + config.thresholds.forEach((t) => { defaults[t.id] = t.defaultValue; }); + return defaults; + }); + + const [refreshInterval, setRefreshInterval] = useState(0); + const refreshTimerRef = useRef | null>(null); + + const PRESET_KEY = `${config.key}_presets`; + + const filterFields: FilterField[] = useMemo( + () => + config.filterFieldDefs.map((def) => ({ + id: def.id, + name: def.name, + type: def.type, + options: def.type === "select" && def.optionKey + ? filterOptions[def.optionKey] || [] + : [], + })), + [config.filterFieldDefs, filterOptions] + ); + + const aggLabel = (method: string) => + ({ sum: "합계", avg: "평균", max: "최대", min: "최소", count: "건수" }[method] || "합계"); + + // ============================================ + // 초기화 + // ============================================ + useEffect(() => { + const range = getDatePresetRange("last6m"); + setStartDate(range.start); + setEndDate(range.end); + + condIdRef.current = 1; + setConditions([ + { + id: 1, + name: "조건 1", + metrics: [...config.defaultMetrics], + aggMethod: "sum", + chartType: "bar", + collapsed: false, + filters: [], + }, + ]); + + loadPresets(); + }, []); + + useEffect(() => { + if (startDate && endDate) { + fetchData(); + } + }, [startDate, endDate]); + + useEffect(() => { + if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); + if (refreshInterval > 0) { + refreshTimerRef.current = setInterval(fetchData, refreshInterval * 1000); + } + return () => { + if (refreshTimerRef.current) clearInterval(refreshTimerRef.current); + }; + }, [refreshInterval]); + + // ============================================ + // API 호출 + // ============================================ + const fetchData = useCallback(async () => { + setIsLoading(true); + try { + const params = new URLSearchParams(); + if (startDate) params.set("startDate", startDate); + if (endDate) params.set("endDate", endDate); + + const response = await apiClient.get( + `${config.apiEndpoint}?${params.toString()}` + ); + + if (response.data?.success) { + const { rows, filterOptions: opts } = response.data.data; + let processedRows = rows.map((r: any) => { + const numericRow: Record = { ...r }; + config.metrics.forEach((m) => { + if (numericRow[m.id] !== undefined) { + numericRow[m.id] = Number(numericRow[m.id]) || 0; + } + }); + return numericRow; + }); + + if (config.enrichRow) { + processedRows = processedRows.map(config.enrichRow); + } + + setRawData(processedRows); + setFilterOptions(opts || {}); + } + } catch (err) { + console.error("데이터 조회 실패:", err); + } finally { + setIsLoading(false); + } + }, [startDate, endDate, config.apiEndpoint, config.enrichRow, config.metrics]); + + // ============================================ + // 조건 관리 + // ============================================ + const addCondition = () => { + condIdRef.current++; + setConditions((prev) => [ + ...prev, + { + id: condIdRef.current, + name: `조건 ${condIdRef.current}`, + metrics: [...config.defaultMetrics], + aggMethod: "sum", + chartType: "bar", + collapsed: false, + filters: [], + }, + ]); + }; + + const removeCondition = (id: number) => { + if (conditions.length <= 1) return; + setConditions((prev) => prev.filter((c) => c.id !== id)); + }; + + const duplicateCondition = (id: number) => { + const src = conditions.find((c) => c.id === id); + if (!src) return; + condIdRef.current++; + setConditions((prev) => [ + ...prev, + { ...JSON.parse(JSON.stringify(src)), id: condIdRef.current, name: src.name + " (복사)" }, + ]); + }; + + const updateCondition = (id: number, updates: Partial) => { + setConditions((prev) => + prev.map((c) => (c.id === id ? { ...c, ...updates } : c)) + ); + }; + + const toggleMetric = (condId: number, metricId: string) => { + setConditions((prev) => + prev.map((c) => { + if (c.id !== condId) return c; + const idx = c.metrics.indexOf(metricId); + if (idx > -1) { + if (c.metrics.length <= 1) return c; + return { ...c, metrics: c.metrics.filter((m) => m !== metricId) }; + } + return { ...c, metrics: [...c.metrics, metricId] }; + }) + ); + }; + + const addFilter = (condId: number) => { + filterIdRef.current++; + const firstField = config.filterFieldDefs[0]?.id || ""; + setConditions((prev) => + prev.map((c) => { + if (c.id !== condId) return c; + return { + ...c, + filters: [ + ...c.filters, + { + id: filterIdRef.current, + logic: c.filters.length === 0 ? "" : "AND", + field: firstField, + operator: "eq", + value: "", + values: [], + }, + ], + }; + }) + ); + }; + + const removeFilter = (condId: number, filterId: number) => { + setConditions((prev) => + prev.map((c) => { + if (c.id !== condId) return c; + const newFilters = c.filters.filter((f) => f.id !== filterId); + if (newFilters.length > 0) newFilters[0].logic = ""; + return { ...c, filters: newFilters }; + }) + ); + }; + + const updateFilter = ( + condId: number, + filterId: number, + updates: Partial + ) => { + setConditions((prev) => + prev.map((c) => { + if (c.id !== condId) return c; + return { + ...c, + filters: c.filters.map((f) => + f.id === filterId ? { ...f, ...updates } : f + ), + }; + }) + ); + }; + + // ============================================ + // 날짜 프리셋 + // ============================================ + const handleDatePreset = (preset: string) => { + setActivePreset(preset); + const range = getDatePresetRange(preset); + setStartDate(range.start); + setEndDate(range.end); + }; + + // ============================================ + // 프리셋 저장/불러오기 + // ============================================ + const loadPresets = () => { + try { + const stored = localStorage.getItem(PRESET_KEY); + if (stored) setPresets(JSON.parse(stored)); + } catch {} + }; + + const savePreset = () => { + if (!presetName.trim()) return; + const newPresets = [ + ...presets, + { + name: presetName.trim(), + desc: presetDesc.trim(), + config: { groupBy, startDate, endDate, conditions }, + savedAt: new Date().toISOString(), + }, + ]; + setPresets(newPresets); + localStorage.setItem(PRESET_KEY, JSON.stringify(newPresets)); + setPresetModalOpen(false); + setPresetName(""); + setPresetDesc(""); + }; + + const loadSelectedPreset = (idx: string) => { + setSelectedPresetIdx(idx); + if (idx === "") return; + const p = presets[parseInt(idx)]; + if (!p?.config) return; + setGroupBy(p.config.groupBy); + setStartDate(p.config.startDate); + setEndDate(p.config.endDate); + setConditions(p.config.conditions); + }; + + const deletePreset = () => { + if (selectedPresetIdx === "") return; + const newPresets = presets.filter((_, i) => i !== parseInt(selectedPresetIdx)); + setPresets(newPresets); + localStorage.setItem(PRESET_KEY, JSON.stringify(newPresets)); + setSelectedPresetIdx(""); + }; + + // ============================================ + // 데이터 분석 (클라이언트 사이드) + // ============================================ + const analysisResult = useMemo(() => { + if (!rawData.length) return { series: [], labels: [], chartData: [] }; + + const seriesList: { + condId: number; + condName: string; + condIdx: number; + metricId: string; + metricName: string; + metricUnit: string; + aggMethod: string; + chartType: string; + groups: Record[]>; + }[] = []; + + const allLabelsSet = new Set(); + + conditions.forEach((cond, ci) => { + const condData = applyConditionFilters(rawData, cond.filters, filterFields); + + const groups: Record[]> = {}; + condData.forEach((d) => { + const key = getGroupKey(d, groupBy); + if (!groups[key]) groups[key] = []; + groups[key].push(d); + }); + Object.keys(groups).forEach((k) => allLabelsSet.add(k)); + + cond.metrics.forEach((metricId) => { + const m = config.metrics.find((x) => x.id === metricId); + if (!m) return; + seriesList.push({ + condId: cond.id, + condName: cond.name, + condIdx: ci, + metricId, + metricName: m.name, + metricUnit: m.unit, + aggMethod: cond.aggMethod, + chartType: cond.chartType, + groups, + }); + }); + }); + + const isTimeBased = ["monthly", "quarterly", "weekly", "daily"].includes(groupBy); + let labels = [...allLabelsSet]; + + if (isTimeBased) { + labels.sort((a, b) => a.localeCompare(b)); + } else if (seriesList.length > 0) { + const first = seriesList[0]; + labels.sort((a, b) => { + const va = aggregateValues(first.groups[a] || [], first.metricId, first.aggMethod); + const vb = aggregateValues(first.groups[b] || [], first.metricId, first.aggMethod); + return vb - va; + }); + } + + const chartData = labels.map((label) => { + const point: Record = { name: label }; + seriesList.forEach((s) => { + const key = `${s.condName}_${s.metricName}`; + point[key] = aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod); + }); + return point; + }); + + return { series: seriesList, labels, chartData }; + }, [rawData, conditions, groupBy, filterFields, config.metrics]); + + // ============================================ + // 렌더링 + // ============================================ + return ( +
+
+ {/* 헤더 */} +
+
+

+ {config.title} +

+

+ {config.description} | 원본 {rawData.length}건 +

+
+
+ + +
+
+ + {/* 프리셋 바 */} +
+ 저장된 조건 + + + +
+ + {/* 분석 조건 설정 */} +
+ + + {filterOpen && ( +
+ {/* 기준축 + 기간 */} +
+
+ + +
+
+ +
+ setStartDate(e.target.value)} + className="h-9 w-[150px] text-sm" + /> + ~ + setEndDate(e.target.value)} + className="h-9 w-[150px] text-sm" + /> +
+
+
+ + {/* 날짜 프리셋 */} +
+ {DATE_PRESETS.map((p) => ( + + ))} +
+ + {/* 다중 분석 조건 */} +
+
+ 분석 조건 + +
+ + {conditions.map((cond, ci) => { + const color = COLORS[ci % COLORS.length]; + return ( +
+
updateCondition(cond.id, { collapsed: !cond.collapsed })} + > +
+
+ e.stopPropagation()} + onChange={(e) => updateCondition(cond.id, { name: e.target.value })} + className="w-[100px] border-b border-transparent bg-transparent text-sm font-medium focus:border-primary focus:outline-none" + /> + + {cond.metrics.map((id) => config.metrics.find((m) => m.id === id)?.name).join("+")} + {" "}{aggLabel(cond.aggMethod)} + {" "}{CHART_TYPES.find((t) => t.id === cond.chartType)?.name} + {cond.filters.length > 0 && ` | 필터 ${cond.filters.length}개`} + +
+
+ + + + ▼ + +
+
+ + {!cond.collapsed && ( +
+
+ 데이터 +
+ {config.metrics.map((m) => { + const active = cond.metrics.includes(m.id); + return ( + + ); + })} +
+
+ +
+
+ 집계 + +
+
+ 차트 +
+ {CHART_TYPES.map((ct) => ( + + ))} +
+
+
+ + {/* 필터 */} +
+ 필터 + {cond.filters.length === 0 && ( +

전체 데이터 (필터 없음)

+ )} + {cond.filters.map((f, fi) => { + const field = filterFields.find((x) => x.id === f.field); + const ops = FILTER_OPERATORS[field?.type || "select"]; + return ( +
+ {fi === 0 ? ( + WHERE + ) : ( + + )} + + + {field?.type === "select" ? ( + + ) : ( + + updateFilter(cond.id, f.id, { value: e.target.value }) + } + className="h-7 w-[100px] rounded border px-2 text-[11px]" + /> + )} + +
+ ); + })} + +
+
+ )} +
+ ); + })} +
+ + {/* 임계값 */} + {config.thresholds.length > 0 && ( +
+ 임계값 설정 + {config.thresholds.map((t, ti) => ( +
+ + {t.label} + + setThresholdValues((prev) => ({ + ...prev, + [t.id]: Number(e.target.value), + })) + } + className="h-7 w-[60px] text-xs" + /> + {t.unit} +
+ ))} +
+ )} + + {/* 액션 */} +
+ + +
+
+ )} +
+ + {/* 적용된 조건 태그 */} + {analysisResult.series.length > 0 && ( +
+ + {config.groupByOptions.find((o) => o.id === groupBy)?.name} + + {(startDate || endDate) && ( + + {startDate || "~"} ~ {endDate || "~"} + + )} + {conditions.map((cond, ci) => ( + + + {cond.name}: {cond.metrics.map((id) => config.metrics.find((m) => m.id === id)?.name).join("+")} + {" "}({aggLabel(cond.aggMethod)}) + + ))} +
+ )} + + {/* KPI 카드 */} + {analysisResult.series.length > 0 && ( +
+ {conditions.flatMap((cond, ci) => + cond.metrics.map((metricId) => { + const m = config.metrics.find((x) => x.id === metricId); + if (!m) return null; + const condData = applyConditionFilters(rawData, cond.filters, filterFields); + const val = aggregateValues(condData, metricId, cond.aggMethod); + const color = COLORS[ci % COLORS.length]; + return ( +
+

+ {cond.name} · {m.name} ({aggLabel(cond.aggMethod)}) +

+

+ {formatNumber(val)} + + {cond.aggMethod === "count" ? "건" : m.unit} + +

+
+ ); + }) + )} +
+ )} + + {/* 차트 */} + {analysisResult.chartData.length > 0 && ( +
+

분석 차트

+
+ + {(() => { + const firstChartType = conditions[0]?.chartType || "bar"; + const dataKeys = analysisResult.series.map( + (s) => `${s.condName}_${s.metricName}` + ); + + if (firstChartType === "line") { + return ( + + + + v.toLocaleString()} /> + } /> + + {dataKeys.map((key, i) => ( + + ))} + + ); + } + + if (firstChartType === "area") { + return ( + + + + v.toLocaleString()} /> + } /> + + {dataKeys.map((key, i) => ( + + ))} + + ); + } + + return ( + + + + v.toLocaleString()} /> + } cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }} /> + + {dataKeys.map((key, i) => ( + + ))} + + ); + })()} + +
+
+ )} + + {/* 집계 데이터 */} + {analysisResult.series.length > 0 && ( +
+
+

집계 데이터

+
+ + +
+
+ +
+ {viewMode === "table" ? ( +
+ + + + + {analysisResult.series.map((s, si) => ( + + ))} + + + + {analysisResult.labels.map((label) => ( + setDrilldownLabel(label)} + > + + {analysisResult.series.map((s, si) => ( + + ))} + + ))} + + + {analysisResult.series.map((s, si) => { + const allRows = analysisResult.labels.flatMap( + (lb) => s.groups[lb] || [] + ); + return ( + + ); + })} + + +
+ {config.groupByOptions.find((o) => o.id === groupBy)?.name} + + {s.condName} +
+ + {s.metricName}({aggLabel(s.aggMethod)}) + +
{label} + {formatNumber( + aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod) + )} +
전체 + {formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))} +
+
+ ) : ( +
+ {analysisResult.labels.map((label) => { + const firstS = analysisResult.series[0]; + const val = firstS + ? aggregateValues(firstS.groups[label] || [], firstS.metricId, firstS.aggMethod) + : 0; + return ( +
setDrilldownLabel(label)} + > +

{label}

+

+ {formatNumber(val)} + + {firstS?.metricUnit} + +

+ {analysisResult.series.length > 1 && ( +

+ {analysisResult.series.slice(1).map((s) => { + const v = aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod); + return `${s.condName}-${s.metricName}: ${formatNumber(v)}`; + }).join(" | ")} +

+ )} +
+ ); + })} +
+ )} +
+
+ )} + + {/* 드릴다운 */} + {drilldownLabel && ( +
+
+

{drilldownLabel} 상세

+ +
+
+ + + + {config.drilldownColumns.map((col) => ( + + ))} + + + + {rawData + .filter((d) => getGroupKey(d, groupBy) === drilldownLabel) + .map((r, i) => ( + + {config.drilldownColumns.map((col) => ( + + ))} + + ))} + +
+ {col.name} +
+ {renderCellValue(r, col)} +
+
+
+ )} + + {/* 원본 데이터 */} +
+ + {rawDataOpen && ( +
+ + + + {config.rawDataColumns.map((col) => ( + + ))} + + + + {rawData.slice(0, 100).map((r, i) => ( + + {config.rawDataColumns.map((col) => ( + + ))} + + ))} + +
+ {col.name} +
+ {renderCellValue(r, col)} +
+ {rawData.length > 100 && ( +

+ 상위 100건만 표시 (전체 {rawData.length}건) +

+ )} +
+ )} +
+ + {/* 데이터 없음 */} + {!isLoading && rawData.length === 0 && ( +
+
+ + + +
+

{config.emptyMessage}

+

+ 기간을 변경하거나 데이터를 확인해주세요 +

+
+ )} +
+ + {/* 프리셋 저장 모달 */} + + + + 조건 저장 + +
+
+ + setPresetName(e.target.value)} + placeholder="예: 월별 추이 분석" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setPresetDesc(e.target.value)} + placeholder="조건 설명 (선택사항)" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + + + +
+
+ + +
+ ); +}