"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 && (
{/* 기준축 + 기간 */}
{/* 날짜 프리셋 */}
{DATE_PRESETS.map((p) => (
))}
{/* 다중 분석 조건 */}
분석 조건
{conditions.map((cond, ci) => {
const color = COLORS[ci % COLORS.length];
return (
updateCondition(cond.id, { collapsed: !cond.collapsed })}
>
▼
{!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" ? (
|
{config.groupByOptions.find((o) => o.id === groupBy)?.name}
|
{analysisResult.series.map((s, si) => (
{s.condName}
{s.metricName}({aggLabel(s.aggMethod)})
|
))}
{analysisResult.labels.map((label) => (
setDrilldownLabel(label)}
>
| {label} |
{analysisResult.series.map((s, si) => (
{formatNumber(
aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod)
)}
|
))}
))}
| 전체 |
{analysisResult.series.map((s, si) => {
const allRows = analysisResult.labels.flatMap(
(lb) => s.groups[lb] || []
);
return (
{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) => (
|
{col.name}
|
))}
{rawData
.filter((d) => getGroupKey(d, groupBy) === drilldownLabel)
.map((r, i) => (
{config.drilldownColumns.map((col) => (
|
{renderCellValue(r, col)}
|
))}
))}
)}
{/* 원본 데이터 */}
{rawDataOpen && (
{config.rawDataColumns.map((col) => (
|
{col.name}
|
))}
{rawData.slice(0, 100).map((r, i) => (
{config.rawDataColumns.map((col) => (
|
{renderCellValue(r, col)}
|
))}
))}
{rawData.length > 100 && (
상위 100건만 표시 (전체 {rawData.length}건)
)}
)}
{/* 데이터 없음 */}
{!isLoading && rawData.length === 0 && (
{config.emptyMessage}
기간을 변경하거나 데이터를 확인해주세요
)}
{/* 프리셋 저장 모달 */}
);
}