"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" />
); }