Files
vexplor/frontend/components/admin/report/ReportEngine.tsx
kjs 5dfae5e6b6 Refactor analytics report data retrieval and enhance equipment report logic
- Updated the `getQualityReportData` function to improve item name retrieval logic by using `NULLIF` for better handling of empty values.
- Refactored the `getEquipmentReportData` function to include company and date filters for equipment statistics, ensuring accurate data aggregation.
- Enhanced the SQL queries for both quality and equipment reports to utilize lateral joins and improve performance.
- Improved loading states in frontend components for inspection records and inspection results across multiple companies.

This refactor enhances data accuracy and user experience in the analytics module.
2026-04-28 20:20:10 +09:00

2361 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
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 (
<div className="rounded-lg border border-border bg-popover px-3 py-2 shadow-lg">
<p className="mb-1.5 text-xs font-semibold text-popover-foreground">{label}</p>
{payload.map((entry: any, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: entry.color }} />
<span className="text-muted-foreground">{entry.name}:</span>
<span className="font-medium tabular-nums text-popover-foreground">
{typeof entry.value === "number" ? entry.value.toLocaleString() : entry.value}
</span>
</div>
))}
</div>
);
}
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<string, { id: string; name: string }[]> = {
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;
/** 복합 그룹인 경우 파트 이름들 (예: ["거래처", "사이즈"]). 1차 그룹 선택 드롭다운에 사용 */
parts?: string[];
}
/**
* 자유 조합 그룹핑에서 사용할 단위 필드
* 사용자가 이 필드들을 자유롭게 선택/조합해서 그룹 조건을 만들 수 있음
*/
export interface ReportGroupableField {
id: string; // getGroupKey에 넘겨질 id (예: "customer", "size", "thickness", "monthly")
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[];
/** 자유 조합 그룹핑에 쓸 단위 필드 목록 (선택 사항) */
groupableFields?: ReportGroupableField[];
defaultGroupBy: string;
defaultMetrics: string[];
thresholds: ReportThreshold[];
filterFieldDefs: FilterFieldDef[];
drilldownColumns: ReportColumnDef[];
rawDataColumns: ReportColumnDef[];
enrichRow?: (row: Record<string, any>) => Record<string, any>;
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 {
id: number;
name: string;
description: string | null;
config: {
groupBy: string;
startDate: string;
endDate: string;
conditions: ConditionGroup[];
};
created_by?: string | null;
created_at?: string;
updated_at?: 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<string, any>, 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)}`;
}
case "thickness": {
const t = row.thickness;
if (!t || t === "" || t === "0") return "미지정";
return `${t}T`;
}
case "sizeRange": {
const area = parseFloat(row.areaSingle) || 0;
if (area <= 0) return "미지정";
if (area < 1) return "소형 (1㎡ 미만)";
if (area < 3) return "중형 (1-3㎡)";
if (area < 6) return "대형 (3-6㎡)";
return "특대형 (6㎡ 이상)";
}
case "size": {
const w = row.width, h = row.height;
if (!w || !h) return "미지정";
return `${w}×${h}`;
}
case "itemSize": {
const item = row.item || "미지정";
const w = row.width, h = row.height;
const sizeStr = w && h ? `${w}×${h}` : "미지정";
return `${item} / ${sizeStr}`;
}
case "customerSize": {
const customer = row.customer || "미지정";
const w = row.width, h = row.height;
const sizeStr = w && h ? `${w}×${h}` : "미지정";
return `${customer} / ${sizeStr}`;
}
case "customerItem": {
const customer = row.customer || "미지정";
const item = row.item || "미지정";
return `${customer} / ${item}`;
}
case "customerItemSize": {
const customer = row.customer || "미지정";
const item = row.item || "미지정";
const w = row.width, h = row.height;
const sizeStr = w && h ? `${w}×${h}` : "미지정";
return `${customer} / ${item} / ${sizeStr}`;
}
default:
return row[groupBy] || "미지정";
}
}
function aggregateValues(
rows: Record<string, any>[],
metricId: string,
method: string,
metric?: ReportMetric,
): number {
if (!rows.length) return 0;
const vals = rows.map((r) => Number(r[metricId]) || 0);
// 비율 메트릭(% 등 isRate)은 행 단위 합계가 의미 없음 → 산술평균으로 강제
// (예: 100% 행이 N건이면 sum=N×100% 으로 비정상 누적되는 문제 방지)
const effectiveMethod = metric?.isRate && method === "sum" ? "avg" : method;
switch (effectiveMethod) {
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<string, any>,
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<string, any>[],
filters: ConditionFilter[],
filterFields: FilterField[]
): Record<string, any>[] {
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<string, any>, 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 <Badge variant="outline">{val || "-"}</Badge>;
default:
return String(val ?? "");
}
}
// ============================================
// 헤더 필터 - 라벨 컬럼 (고유값 체크박스)
// ============================================
function LabelFilterContent({
values,
selected,
onChange,
onClear,
}: {
values: string[];
selected: Set<string> | null;
onChange: (next: Set<string> | null) => void;
onClear: () => void;
}) {
const [search, setSearch] = useState("");
const filtered = useMemo(
() => values.filter((v) => v.toLowerCase().includes(search.toLowerCase())),
[values, search]
);
const effective = selected ?? new Set(values);
const toggle = (v: string) => {
const next = new Set(effective);
if (next.has(v)) next.delete(v); else next.add(v);
onChange(next);
};
const selectAll = () => onChange(new Set(values));
const deselectAll = () => onChange(new Set());
return (
<div className="space-y-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="값 검색..."
className="h-7 text-xs"
/>
<div className="flex gap-1 text-[11px]">
<button type="button" onClick={selectAll} className="flex-1 rounded border px-1 py-1 hover:bg-muted"></button>
<button type="button" onClick={deselectAll} className="flex-1 rounded border px-1 py-1 hover:bg-muted"></button>
<button type="button" onClick={onClear} className="flex-1 rounded border px-1 py-1 hover:bg-muted"></button>
</div>
<div className="max-h-64 space-y-0.5 overflow-y-auto rounded border">
{filtered.map((v) => {
const checked = effective.has(v);
return (
<label
key={v}
className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-muted"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggle(v)}
className="h-3.5 w-3.5"
/>
<span className="truncate text-xs">{v}</span>
</label>
);
})}
{filtered.length === 0 && (
<p className="py-2 text-center text-xs text-muted-foreground"> </p>
)}
</div>
<p className="text-[11px] text-muted-foreground">
{selected === null ? "전체 표시" : `${effective.size} / ${values.length} 선택`}
</p>
</div>
);
}
// ============================================
// 헤더 필터 - 메트릭(숫자) 컬럼 (min/max 범위)
// ============================================
function MetricFilterContent({
range,
onApply,
onClear,
}: {
range?: { min?: number; max?: number };
onApply: (r: { min?: number; max?: number }) => void;
onClear: () => void;
}) {
const [minInput, setMinInput] = useState(range?.min !== undefined ? String(range.min) : "");
const [maxInput, setMaxInput] = useState(range?.max !== undefined ? String(range.max) : "");
const apply = () => {
const minRaw = minInput.trim();
const maxRaw = maxInput.trim();
const minNum = minRaw === "" ? undefined : Number(minRaw);
const maxNum = maxRaw === "" ? undefined : Number(maxRaw);
onApply({
min: minNum !== undefined && !isNaN(minNum) ? minNum : undefined,
max: maxNum !== undefined && !isNaN(maxNum) ? maxNum : undefined,
});
};
return (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[11px]"> ()</Label>
<Input
type="number"
value={minInput}
onChange={(e) => setMinInput(e.target.value)}
className="h-7 text-xs"
placeholder="예: 100"
/>
</div>
<div className="space-y-1">
<Label className="text-[11px]"> ()</Label>
<Input
type="number"
value={maxInput}
onChange={(e) => setMaxInput(e.target.value)}
className="h-7 text-xs"
placeholder="예: 10000"
/>
</div>
<div className="flex gap-1 text-xs">
<button
type="button"
onClick={apply}
className="flex-1 rounded bg-primary py-1 text-primary-foreground hover:opacity-90"
>
</button>
<button
type="button"
onClick={() => { setMinInput(""); setMaxInput(""); onClear(); }}
className="flex-1 rounded border py-1 hover:bg-muted"
>
</button>
</div>
</div>
);
}
// ============================================
// ReportEngine 컴포넌트
// ============================================
interface ReportEngineProps {
config: ReportConfig;
}
export default function ReportEngine({ config }: ReportEngineProps) {
const [rawData, setRawData] = useState<Record<string, any>[]>([]);
const [filterOptions, setFilterOptions] = useState<Record<string, { value: string; label: string }[]>>({});
const [isLoading, setIsLoading] = useState(false);
const [groupBy, setGroupBy] = useState(config.defaultGroupBy);
// 자유 조합 그룹핑: 기본 groupBy 뒤에 이어 붙일 추가 그룹 필드들 (순서대로)
const [extraGroupBys, setExtraGroupBys] = useState<string[]>([]);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [activePreset, setActivePreset] = useState("last1m");
const [filterOpen, setFilterOpen] = useState(true);
const [conditions, setConditions] = useState<ConditionGroup[]>([]);
const condIdRef = useRef(0);
const filterIdRef = useRef(0);
const [viewMode, setViewMode] = useState<"table" | "card">("table");
const [drilldownLabel, setDrilldownLabel] = useState<string | null>(null);
const [rawDataOpen, setRawDataOpen] = useState(false);
// 집계 테이블 검색/정렬
const [tableSearchQuery, setTableSearchQuery] = useState("");
const [tableSortColumn, setTableSortColumn] = useState<string | null>(null);
const [tableSortDirection, setTableSortDirection] = useState<"asc" | "desc">("desc");
// 집계 테이블 헤더 필터: 라벨(그룹) 컬럼은 값 체크박스, 메트릭 컬럼은 숫자 범위
const [labelColumnFilter, setLabelColumnFilter] = useState<Set<string> | null>(null);
const [metricColumnFilters, setMetricColumnFilters] = useState<Record<number, { min?: number; max?: number }>>({});
// 집계 테이블 그룹핑 (1차 그룹으로 묶기)
const [tableGrouped, setTableGrouped] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [primaryGroupIndex, setPrimaryGroupIndex] = useState(0); // 현재 그룹화의 어떤 파트를 1차 기준으로 쓸지
const [presets, setPresets] = useState<Preset[]>([]);
const [presetModalOpen, setPresetModalOpen] = useState(false);
const [presetMode, setPresetMode] = useState<"new" | "update">("new");
const [presetName, setPresetName] = useState("");
const [presetDesc, setPresetDesc] = useState("");
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
const [thresholdValues, setThresholdValues] = useState<Record<string, number>>(() => {
const defaults: Record<string, number> = {};
config.thresholds.forEach((t) => { defaults[t.id] = t.defaultValue; });
return defaults;
});
const [refreshInterval, setRefreshInterval] = useState(0);
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
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("last1m");
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<string, any> = { ...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<ConditionGroup>) => {
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 moveMetric = (condId: number, metricId: string, dir: -1 | 1) => {
setConditions((prev) =>
prev.map((c) => {
if (c.id !== condId) return c;
const idx = c.metrics.indexOf(metricId);
if (idx === -1) return c;
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= c.metrics.length) return c;
const next = [...c.metrics];
[next[idx], next[newIdx]] = [next[newIdx], next[idx]];
return { ...c, metrics: next };
})
);
};
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<ConditionFilter>
) => {
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);
};
// ============================================
// 프리셋 저장/불러오기 (DB 기반 - company_code + report_key)
// ============================================
const loadPresets = async () => {
try {
const res = await apiClient.get(
`/report-presets?reportKey=${encodeURIComponent(config.key)}`
);
if (res.data?.success) {
setPresets(res.data.data || []);
}
} catch (err) {
console.error("프리셋 목록 조회 실패", err);
}
};
// 저장 모달 열기 - 신규
const openNewPresetModal = () => {
setPresetMode("new");
setPresetName("");
setPresetDesc("");
setPresetModalOpen(true);
};
// 저장 모달 열기 - 선택된 프리셋 수정
const openUpdatePresetModal = () => {
if (!selectedPresetId) return;
const p = presets.find((x) => String(x.id) === selectedPresetId);
if (!p) return;
setPresetMode("update");
setPresetName(p.name);
setPresetDesc(p.description || "");
setPresetModalOpen(true);
};
// 모달 저장 - mode 에 따라 POST(신규) / PUT(수정) 분기
const savePreset = async () => {
if (!presetName.trim()) return;
const body = {
name: presetName.trim(),
description: presetDesc.trim() || null,
config: { groupBy, startDate, endDate, conditions },
};
try {
if (presetMode === "update" && selectedPresetId) {
const res = await apiClient.put(`/report-presets/${selectedPresetId}`, body);
if (res.data?.success) {
setPresets((prev) =>
prev.map((x) => (String(x.id) === selectedPresetId ? res.data.data : x))
);
setPresetModalOpen(false);
setPresetName("");
setPresetDesc("");
}
} else {
const res = await apiClient.post(`/report-presets`, {
reportKey: config.key,
...body,
});
if (res.data?.success) {
setPresets((prev) => [res.data.data, ...prev]);
setSelectedPresetId(String(res.data.data.id));
setPresetModalOpen(false);
setPresetName("");
setPresetDesc("");
}
}
} catch (err) {
console.error("프리셋 저장 실패", err);
}
};
const loadSelectedPreset = (id: string) => {
setSelectedPresetId(id);
if (id === "") return;
const p = presets.find((x) => String(x.id) === id);
if (!p?.config) return;
setGroupBy(p.config.groupBy);
setStartDate(p.config.startDate);
setEndDate(p.config.endDate);
setConditions(p.config.conditions);
};
const deletePreset = async () => {
if (selectedPresetId === "") return;
try {
const res = await apiClient.delete(`/report-presets/${selectedPresetId}`);
if (res.data?.success) {
setPresets((prev) => prev.filter((p) => String(p.id) !== selectedPresetId));
setSelectedPresetId("");
}
} catch (err) {
console.error("프리셋 삭제 실패", err);
}
};
// ============================================
// 데이터 분석 (클라이언트 사이드)
// ============================================
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<string, Record<string, any>[]>;
values: Record<string, number>; // 미리 계산된 집계값 (O(1) 조회)
totalValue: number; // 전체 합계(또는 선택된 집계)
}[] = [];
const allLabelsSet = new Set<string>();
// 각 condition의 filter는 cache (같은 filter면 재사용)
const condFilterCache = new Map<string, any[]>();
conditions.forEach((cond, ci) => {
// filter 결과 캐싱 (같은 필터면 재사용)
const filterKey = JSON.stringify(cond.filters);
let condData = condFilterCache.get(filterKey);
if (!condData) {
condData = applyConditionFilters(rawData, cond.filters, filterFields);
condFilterCache.set(filterKey, condData);
}
// 그룹핑 (1회) — 기본 groupBy + extraGroupBys 조합
const allGroupBys = [groupBy, ...extraGroupBys];
const groups: Record<string, Record<string, any>[]> = {};
for (let i = 0; i < condData.length; i++) {
const d = condData[i];
const keyParts = allGroupBys.map((g) => getGroupKey(d, g));
const key = keyParts.join(" / ");
if (!groups[key]) groups[key] = [];
groups[key].push(d);
}
// Set 에 라벨 추가
for (const k in groups) allLabelsSet.add(k);
cond.metrics.forEach((metricId) => {
const m = config.metrics.find((x) => x.id === metricId);
if (!m) return;
// 각 라벨별 집계값을 한 번에 계산해서 저장 (렌더링 시 lookup 만)
const values: Record<string, number> = {};
for (const lb in groups) {
values[lb] = aggregateValues(groups[lb], metricId, cond.aggMethod, m);
}
// 전체 합계
const totalValue = aggregateValues(condData, metricId, cond.aggMethod, m);
seriesList.push({
condId: cond.id,
condName: cond.name,
condIdx: ci,
metricId,
metricName: m.name,
metricUnit: m.unit,
aggMethod: cond.aggMethod,
chartType: cond.chartType,
groups,
values,
totalValue,
});
});
});
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) {
// 다단계 정렬: 미리 계산된 values 사용 (O(1) 조회)
labels.sort((a, b) => {
for (const s of seriesList) {
const va = s.values[a] || 0;
const vb = s.values[b] || 0;
// 0은 하단으로: 한쪽만 0이면 0 아닌 쪽 우선
if (va === 0 && vb !== 0) return 1;
if (va !== 0 && vb === 0) return -1;
if (vb !== va) return vb - va; // 큰 값 먼저
}
// 모든 메트릭 동일 시 라벨 자연 정렬
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
});
}
// 차트 렌더링 성능 보호: 그룹이 너무 많으면 상위 N개만 차트에 표시
// (테이블/드릴다운은 전체 labels 유지 — 집계 계산은 그대로)
const CHART_MAX_LABELS = 30;
const chartLabels = !isTimeBased && labels.length > CHART_MAX_LABELS
? labels.slice(0, CHART_MAX_LABELS)
: labels;
const chartData = chartLabels.map((label) => {
const point: Record<string, any> = { name: label };
seriesList.forEach((s) => {
const key = `${s.condName}_${s.metricName}`;
point[key] = s.values[label] || 0;
});
return point;
});
return { series: seriesList, labels, chartData };
}, [rawData, conditions, groupBy, extraGroupBys, filterFields, config.metrics]);
// 집계 테이블 표시용 라벨 (검색 + 헤더 정렬 + 헤더 필터 적용) — 미리 계산된 values 사용
const displayLabels = useMemo(() => {
let list = analysisResult.labels;
if (tableSearchQuery) {
const q = tableSearchQuery.toLowerCase();
list = list.filter((l) => l.toLowerCase().includes(q));
}
// 라벨 컬럼 필터 (체크박스 선택값만 통과)
if (labelColumnFilter) {
const sel = labelColumnFilter;
list = list.filter((l) => {
if (tableGrouped) {
const parts = l.split(" / ");
if (parts.length >= 2) {
const primary = parts[primaryGroupIndex] ?? parts[0] ?? l;
return sel.has(primary);
}
}
return sel.has(l);
});
}
// 메트릭 컬럼 필터 (AND 조건, min/max 범위)
const activeMetricKeys = Object.keys(metricColumnFilters);
if (activeMetricKeys.length > 0) {
list = list.filter((l) => {
for (const key of activeMetricKeys) {
const range = metricColumnFilters[Number(key)];
if (!range) continue;
if (range.min === undefined && range.max === undefined) continue;
const s = analysisResult.series[Number(key)];
if (!s) continue;
const v = s.values[l] || 0;
if (range.min !== undefined && v < range.min) return false;
if (range.max !== undefined && v > range.max) return false;
}
return true;
});
}
if (tableSortColumn !== null) {
list = [...list].sort((a, b) => {
if (tableSortColumn === "__label__") {
const cmp = a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
return tableSortDirection === "asc" ? cmp : -cmp;
}
const si = Number(tableSortColumn);
const s = analysisResult.series[si];
if (!s) return 0;
const va = s.values[a] || 0;
const vb = s.values[b] || 0;
return tableSortDirection === "asc" ? va - vb : vb - va;
});
}
return list;
}, [
analysisResult,
tableSearchQuery,
tableSortColumn,
tableSortDirection,
labelColumnFilter,
metricColumnFilters,
tableGrouped,
primaryGroupIndex,
]);
// 라벨 필터 고유값 (그룹핑 상태에 따라 primary part 또는 전체 라벨 기준)
const labelFilterUniqueValues = useMemo(() => {
const set = new Set<string>();
for (const l of analysisResult.labels) {
if (tableGrouped) {
const parts = l.split(" / ");
if (parts.length >= 2) {
set.add(parts[primaryGroupIndex] ?? parts[0] ?? l);
continue;
}
}
set.add(l);
}
return Array.from(set).sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })
);
}, [analysisResult.labels, tableGrouped, primaryGroupIndex]);
// 그룹 기준 변경 시 기존 라벨 필터는 리셋 (값 기준이 달라지므로)
useEffect(() => {
setLabelColumnFilter(null);
}, [tableGrouped, primaryGroupIndex, groupBy, extraGroupBys]);
const toggleTableSort = (col: string) => {
if (tableSortColumn === col) {
setTableSortDirection((d) => (d === "asc" ? "desc" : "asc"));
} else {
setTableSortColumn(col);
setTableSortDirection("desc");
}
};
// 현재 그룹화 옵션의 parts 메타 (기본 groupBy + extraGroupBys 조합)
const currentGroupParts = useMemo(() => {
const getPartName = (id: string): string => {
const gf = config.groupableFields?.find((f) => f.id === id);
if (gf) return gf.name;
const go = config.groupByOptions.find((o) => o.id === id);
if (go) return go.name.replace(/별$/, "");
return id;
};
if (extraGroupBys.length === 0) {
// 기본 그룹의 parts 메타 (예전 복합 옵션) 호환
const opt = config.groupByOptions.find((o) => o.id === groupBy);
if (opt?.parts) return opt.parts;
return [];
}
return [groupBy, ...extraGroupBys].map(getPartName);
}, [config.groupByOptions, config.groupableFields, groupBy, extraGroupBys]);
// 그룹핑 가능 여부 (복합 그룹일 때만): parts가 2개 이상
const canGroupTable = currentGroupParts.length >= 2;
// groupBy 변경 시 primaryGroupIndex 초기화
useEffect(() => {
if (primaryGroupIndex >= currentGroupParts.length) {
setPrimaryGroupIndex(0);
}
}, [currentGroupParts.length, primaryGroupIndex]);
// 1차 그룹으로 묶은 결과 (tableGrouped가 true이고 canGroupTable일 때만)
const groupedLabels = useMemo(() => {
if (!tableGrouped || !canGroupTable) return null;
const groups: Record<string, string[]> = {};
const order: string[] = [];
for (const label of displayLabels) {
const parts = label.split(" / ");
const primary = parts[primaryGroupIndex] ?? parts[0] ?? label;
if (!groups[primary]) {
groups[primary] = [];
order.push(primary);
}
groups[primary].push(label);
}
return { groups, order };
}, [displayLabels, tableGrouped, canGroupTable, primaryGroupIndex]);
const toggleGroupCollapse = (primary: string) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(primary)) next.delete(primary);
else next.add(primary);
return next;
});
};
// ============================================
// 렌더링
// ============================================
return (
<div className="flex h-full flex-col overflow-auto bg-background">
<div className="space-y-4 p-4 sm:p-6">
{/* 헤더 */}
<div className="flex flex-col gap-3 border-b pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
{config.title}
</h1>
<p className="text-sm text-muted-foreground">
{config.description} | {rawData.length}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className={cn(
"h-8 rounded-md border px-2 text-xs",
refreshInterval > 0 && "border-primary bg-primary/5"
)}
>
<option value={0}> OFF</option>
<option value={30}>30</option>
<option value={60}>1</option>
<option value={300}>5</option>
</select>
<Button variant="outline" size="sm" onClick={() => window.print()}>
</Button>
</div>
</div>
{/* 프리셋 바 */}
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-3">
<span className="text-xs font-medium text-muted-foreground"> </span>
<select
value={selectedPresetId}
onChange={(e) => loadSelectedPreset(e.target.value)}
className="h-8 min-w-[180px] rounded-md border px-2 text-xs"
>
<option value=""> </option>
{presets.map((p) => (
<option key={p.id} value={String(p.id)}>{p.name}</option>
))}
</select>
<Button size="sm" variant="default" className="h-8 text-xs" onClick={openNewPresetModal}>
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 text-xs"
onClick={openUpdatePresetModal}
disabled={!selectedPresetId}
title={selectedPresetId ? "현재 조건을 선택된 프리셋에 덮어쓰기" : "먼저 프리셋을 선택하세요"}
>
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={deletePreset}
disabled={!selectedPresetId}
>
</Button>
</div>
{/* 분석 조건 설정 */}
<div className="rounded-md border">
<button
className="flex w-full items-center justify-between p-3 text-left hover:bg-muted/50"
onClick={() => setFilterOpen(!filterOpen)}
>
<span className="text-sm font-semibold"> </span>
<span className={cn("text-xs transition-transform", !filterOpen && "-rotate-90")}>
</span>
</button>
{filterOpen && (
<div className="space-y-4 border-t p-4">
{/* 기준축 + 기간 */}
<div className="flex flex-wrap gap-4">
<div className="space-y-1">
<Label className="text-xs"> (X축)</Label>
<div className="flex flex-wrap items-center gap-1">
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="h-9 w-[160px] rounded-md border px-2 text-sm"
>
{config.groupByOptions.map((o) => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
{/* 자유 조합 그룹핑: 추가 그룹 필드 칩 */}
{config.groupableFields && config.groupableFields.length > 0 && (
<>
{extraGroupBys.map((id, idx) => {
const f = config.groupableFields!.find((gf) => gf.id === id);
return (
<span
key={`${id}-${idx}`}
className="inline-flex items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs text-primary"
>
<span className="text-muted-foreground">+</span>
{f?.name || id}
<button
onClick={() =>
setExtraGroupBys((prev) => prev.filter((_, i) => i !== idx))
}
className="ml-0.5 text-primary/60 hover:text-destructive"
title="제거"
>
×
</button>
</span>
);
})}
<select
value=""
onChange={(e) => {
if (e.target.value) {
setExtraGroupBys((prev) => [...prev, e.target.value]);
}
}}
className="h-9 rounded-md border px-2 text-xs text-muted-foreground"
title="그룹 조합 추가"
>
<option value="">+ </option>
{config.groupableFields
.filter((f) => f.id !== groupBy && !extraGroupBys.includes(f.id))
.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
{extraGroupBys.length > 0 && (
<button
onClick={() => setExtraGroupBys([])}
className="h-9 px-2 rounded-md border text-xs text-muted-foreground hover:text-destructive"
title="조합 모두 제거"
>
</button>
)}
</>
)}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="h-9 w-[150px] text-sm"
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="h-9 w-[150px] text-sm"
/>
</div>
</div>
</div>
{/* 날짜 프리셋 */}
<div className="flex flex-wrap gap-1.5">
{DATE_PRESETS.map((p) => (
<button
key={p.id}
onClick={() => handleDatePreset(p.id)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
activePreset === p.id
? "border-primary bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
>
{p.name}
</button>
))}
</div>
{/* 다중 분석 조건 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"> </span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={addCondition}>
+
</Button>
</div>
{conditions.map((cond, ci) => {
const color = COLORS[ci % COLORS.length];
return (
<div
key={cond.id}
className="rounded-md border"
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
>
<div
className="flex cursor-pointer items-center justify-between p-3 hover:bg-muted/30"
onClick={() => updateCondition(cond.id, { collapsed: !cond.collapsed })}
>
<div className="flex items-center gap-2">
<div className="h-3 w-3 rounded" style={{ background: color }} />
<input
value={cond.name}
onClick={(e) => 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"
/>
<span className="text-xs text-muted-foreground">
{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}`}
</span>
</div>
<div className="flex items-center gap-1">
<button
className="rounded p-1 text-xs hover:bg-muted"
onClick={(e) => { e.stopPropagation(); duplicateCondition(cond.id); }}
>
</button>
<button
className="rounded p-1 text-xs text-destructive hover:bg-destructive/10"
onClick={(e) => { e.stopPropagation(); removeCondition(cond.id); }}
>
X
</button>
<span className={cn("ml-1 text-xs transition-transform", cond.collapsed && "-rotate-90")}>
</span>
</div>
</div>
{!cond.collapsed && (
<div className="space-y-3 border-t p-3">
<div className="space-y-2">
<span className="text-xs text-muted-foreground"> ( = / )</span>
{/* 선택된 메트릭: 순서대로 + 이동 버튼 */}
{cond.metrics.length > 0 && (
<div className="flex flex-wrap gap-1.5 rounded-md border border-primary/20 bg-primary/5 p-2">
{cond.metrics.map((mid, idx) => {
const m = config.metrics.find((x) => x.id === mid);
if (!m) return null;
const isFirst = idx === 0;
const isLast = idx === cond.metrics.length - 1;
return (
<div
key={mid}
className="inline-flex items-center gap-1 rounded-full border border-primary bg-background px-2 py-1 text-xs font-medium"
>
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] text-primary-foreground">
{idx + 1}
</span>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: m.color }}
/>
<span>{m.name}</span>
<button
type="button"
onClick={() => moveMetric(cond.id, mid, -1)}
disabled={isFirst}
className={cn(
"text-muted-foreground hover:text-primary ml-0.5",
isFirst && "opacity-30 cursor-not-allowed"
)}
title="왼쪽으로"
>
</button>
<button
type="button"
onClick={() => moveMetric(cond.id, mid, 1)}
disabled={isLast}
className={cn(
"text-muted-foreground hover:text-primary",
isLast && "opacity-30 cursor-not-allowed"
)}
title="오른쪽으로"
>
</button>
<button
type="button"
onClick={() => toggleMetric(cond.id, mid)}
disabled={cond.metrics.length <= 1}
className={cn(
"text-muted-foreground hover:text-destructive ml-0.5",
cond.metrics.length <= 1 && "opacity-30 cursor-not-allowed"
)}
title="제거"
>
×
</button>
</div>
);
})}
</div>
)}
{/* 전체 메트릭 목록 */}
<div className="flex flex-wrap gap-1.5">
{config.metrics.map((m) => {
const active = cond.metrics.includes(m.id);
return (
<button
key={m.id}
onClick={() => toggleMetric(cond.id, m.id)}
className={cn(
"flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors",
active
? "border-primary bg-primary/10 font-medium"
: "hover:bg-muted"
)}
>
{active && <span className="text-primary"></span>}
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: m.color }}
/>
{m.name}
</button>
);
})}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="space-y-1">
<span className="text-xs text-muted-foreground"></span>
<select
value={cond.aggMethod}
onChange={(e) =>
updateCondition(cond.id, { aggMethod: e.target.value })
}
className="h-8 rounded-md border px-2 text-xs"
>
{AGG_METHODS.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"></span>
<div className="flex gap-1">
{CHART_TYPES.map((ct) => (
<button
key={ct.id}
onClick={() => updateCondition(cond.id, { chartType: ct.id })}
className={cn(
"rounded border px-2.5 py-1 text-xs",
cond.chartType === ct.id
? "border-primary bg-primary text-primary-foreground"
: "hover:bg-muted"
)}
>
{ct.name}
</button>
))}
</div>
</div>
</div>
{/* 필터 */}
<div className="space-y-2">
<span className="text-xs text-muted-foreground"></span>
{cond.filters.length === 0 && (
<p className="text-xs text-muted-foreground/70"> ( )</p>
)}
{cond.filters.map((f, fi) => {
const field = filterFields.find((x) => x.id === f.field);
const ops = FILTER_OPERATORS[field?.type || "select"];
return (
<div key={f.id} className="flex flex-wrap items-center gap-1.5">
{fi === 0 ? (
<span className="w-[50px] text-center text-[11px] text-muted-foreground">WHERE</span>
) : (
<select
value={f.logic}
onChange={(e) =>
updateFilter(cond.id, f.id, {
logic: e.target.value as "AND" | "OR",
})
}
className="h-7 w-[50px] rounded border text-[11px]"
>
<option value="AND">AND</option>
<option value="OR">OR</option>
</select>
)}
<select
value={f.field}
onChange={(e) =>
updateFilter(cond.id, f.id, {
field: e.target.value,
operator: "eq",
value: "",
values: [],
})
}
className="h-7 rounded border px-1.5 text-[11px]"
>
{filterFields.map((ff) => (
<option key={ff.id} value={ff.id}>{ff.name}</option>
))}
</select>
<select
value={f.operator}
onChange={(e) =>
updateFilter(cond.id, f.id, { operator: e.target.value })
}
className="h-7 rounded border px-1.5 text-[11px]"
>
{ops.map((o) => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
{field?.type === "select" ? (
<select
value={f.value}
onChange={(e) =>
updateFilter(cond.id, f.id, { value: e.target.value })
}
className="h-7 min-w-[120px] rounded border px-1.5 text-[11px]"
>
<option value="">...</option>
{field.options.map((o) => (
<option key={o.value} value={o.label}>{o.label}</option>
))}
</select>
) : (
<input
type="number"
value={f.value}
onChange={(e) =>
updateFilter(cond.id, f.id, { value: e.target.value })
}
className="h-7 w-[100px] rounded border px-2 text-[11px]"
/>
)}
<button
onClick={() => removeFilter(cond.id, f.id)}
className="rounded p-1 text-xs text-destructive hover:bg-destructive/10"
>
X
</button>
</div>
);
})}
<button
onClick={() => addFilter(cond.id)}
className="text-xs text-primary hover:underline"
>
+
</button>
</div>
</div>
)}
</div>
);
})}
</div>
{/* 임계값 */}
{config.thresholds.length > 0 && (
<div className="flex flex-wrap items-center gap-4 rounded-md border bg-muted/20 p-3">
<span className="text-xs font-medium"> </span>
{config.thresholds.map((t, ti) => (
<div key={t.id} className="flex items-center gap-2">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ background: ti === 0 ? "#ef4444" : "#10b981" }}
/>
<span className="text-xs">{t.label}</span>
<Input
type="number"
value={thresholdValues[t.id] ?? t.defaultValue}
onChange={(e) =>
setThresholdValues((prev) => ({
...prev,
[t.id]: Number(e.target.value),
}))
}
className="h-7 w-[60px] text-xs"
/>
<span className="text-xs">{t.unit}</span>
</div>
))}
</div>
)}
{/* 액션 */}
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
condIdRef.current = 1;
setConditions([
{
id: 1,
name: "조건 1",
metrics: [...config.defaultMetrics],
aggMethod: "sum",
chartType: "bar",
collapsed: false,
filters: [],
},
]);
setGroupBy(config.defaultGroupBy);
handleDatePreset("last6m");
}}
>
</Button>
<Button size="sm" onClick={fetchData} disabled={isLoading}>
{isLoading ? "분석 중..." : "분석 실행"}
</Button>
</div>
</div>
)}
</div>
{/* 적용된 조건 태그 */}
{analysisResult.series.length > 0 && (
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary">
{extraGroupBys.length > 0 && currentGroupParts.length > 0
? currentGroupParts.map((p) => `${p}`).join(" × ")
: config.groupByOptions.find((o) => o.id === groupBy)?.name}
</Badge>
{(startDate || endDate) && (
<Badge variant="secondary">
{startDate || "~"} ~ {endDate || "~"}
</Badge>
)}
{conditions.map((cond, ci) => (
<Badge
key={cond.id}
variant="outline"
style={{ borderColor: COLORS[ci % COLORS.length] + "80" }}
>
<span
className="mr-1 inline-block h-2 w-2 rounded"
style={{ background: COLORS[ci % COLORS.length] }}
/>
{cond.name}: {cond.metrics.map((id) => config.metrics.find((m) => m.id === id)?.name).join("+")}
{" "}({aggLabel(cond.aggMethod)})
</Badge>
))}
</div>
)}
{/* KPI 카드 */}
{analysisResult.series.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{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, m);
const color = COLORS[ci % COLORS.length];
return (
<div
key={`${cond.id}-${metricId}`}
className="rounded-lg border p-4"
style={{ borderTopColor: color, borderTopWidth: 3 }}
>
<p className="truncate text-xs text-muted-foreground">
{cond.name} · {m.name} ({aggLabel(cond.aggMethod)})
</p>
<p className="mt-1 text-xl font-bold tabular-nums">
{formatNumber(val)}
<span className="ml-1 text-xs font-normal text-muted-foreground">
{cond.aggMethod === "count" ? "건" : m.unit}
</span>
</p>
</div>
);
})
)}
</div>
)}
{/* 차트 */}
{analysisResult.chartData.length > 0 && (
<div className="rounded-md border p-4">
<h3 className="mb-4 text-sm font-semibold"> </h3>
<div style={{ width: "100%", height: 400 }}>
<ResponsiveContainer>
{(() => {
const firstChartType = conditions[0]?.chartType || "bar";
const dataKeys = analysisResult.series.map(
(s) => `${s.condName}_${s.metricName}`
);
if (firstChartType === "line") {
return (
<LineChart data={analysisResult.chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="fill-muted-foreground" tickFormatter={(v) => v.toLocaleString()} />
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{dataKeys.map((key, i) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={COLORS[i % COLORS.length]}
strokeWidth={2}
dot={{ r: 4 }}
/>
))}
</LineChart>
);
}
if (firstChartType === "area") {
return (
<AreaChart data={analysisResult.chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="fill-muted-foreground" tickFormatter={(v) => v.toLocaleString()} />
<Tooltip content={<ChartTooltip />} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{dataKeys.map((key, i) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={COLORS[i % COLORS.length]}
fill={COLORS[i % COLORS.length]}
fillOpacity={0.3}
/>
))}
</AreaChart>
);
}
return (
<BarChart data={analysisResult.chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} className="fill-muted-foreground" />
<YAxis tick={{ fontSize: 12 }} className="fill-muted-foreground" tickFormatter={(v) => v.toLocaleString()} />
<Tooltip content={<ChartTooltip />} cursor={{ fill: "hsl(var(--muted))", opacity: 0.3 }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{dataKeys.map((key, i) => (
<Bar
key={key}
dataKey={key}
fill={COLORS[i % COLORS.length]}
radius={[4, 4, 0, 0]}
/>
))}
</BarChart>
);
})()}
</ResponsiveContainer>
</div>
</div>
)}
{/* 집계 데이터 */}
{analysisResult.series.length > 0 && (
<div className="rounded-md border">
<div className="flex items-center justify-between gap-2 border-b p-3">
<h3 className="text-sm font-semibold shrink-0"> </h3>
<div className="flex items-center gap-2 flex-1 justify-end">
<input
type="text"
value={tableSearchQuery}
onChange={(e) => setTableSearchQuery(e.target.value)}
placeholder="검색 (거래처 / 품목 / 사이즈 등)"
className="h-8 w-full max-w-[280px] rounded-md border px-2 text-xs"
/>
{tableSearchQuery && (
<button
onClick={() => setTableSearchQuery("")}
className="text-muted-foreground hover:text-foreground text-xs"
>
</button>
)}
{canGroupTable && (
<button
onClick={() => setTableGrouped((v) => !v)}
className={cn(
"h-8 px-3 rounded-md border text-xs shrink-0",
tableGrouped && "border-primary bg-primary/10 text-primary"
)}
title="1차 그룹 기준으로 묶어서 접기/펼치기"
>
{tableGrouped ? "ON" : "OFF"}
</button>
)}
{tableGrouped && canGroupTable && (
<>
<select
value={primaryGroupIndex}
onChange={(e) => {
setPrimaryGroupIndex(Number(e.target.value));
setCollapsedGroups(new Set());
}}
className="h-8 rounded-md border px-2 text-xs shrink-0 bg-background"
title="1차 그룹 기준"
>
{currentGroupParts.map((part, idx) => (
<option key={idx} value={idx}>{part}</option>
))}
</select>
<button
onClick={() => setCollapsedGroups(new Set(groupedLabels?.order || []))}
className="h-8 px-2 rounded-md border text-xs shrink-0"
title="모두 접기"
>
</button>
<button
onClick={() => setCollapsedGroups(new Set())}
className="h-8 px-2 rounded-md border text-xs shrink-0"
title="모두 펼치기"
>
</button>
</>
)}
<div className="flex rounded-md border shrink-0">
<button
onClick={() => setViewMode("table")}
className={cn(
"px-3 py-1 text-xs",
viewMode === "table" && "bg-primary text-primary-foreground"
)}
>
</button>
<button
onClick={() => setViewMode("card")}
className={cn(
"px-3 py-1 text-xs",
viewMode === "card" && "bg-primary text-primary-foreground"
)}
>
</button>
</div>
</div>
</div>
<div className="p-3">
{viewMode === "table" ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="p-2 text-left text-xs font-medium text-muted-foreground select-none">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="inline-flex items-center gap-1 hover:text-foreground"
onClick={() => toggleTableSort("__label__")}
>
{tableGrouped && canGroupTable && currentGroupParts[primaryGroupIndex]
? `${currentGroupParts[primaryGroupIndex]}`
: (currentGroupParts.length > 0
? currentGroupParts.map((p) => `${p}`).join(" × ")
: config.groupByOptions.find((o) => o.id === groupBy)?.name)}
{tableSortColumn === "__label__" && (
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
)}
</button>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-5 w-5 items-center justify-center rounded hover:bg-muted",
labelColumnFilter && "bg-primary/10 text-primary"
)}
title="값 필터"
>
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
<LabelFilterContent
values={labelFilterUniqueValues}
selected={labelColumnFilter}
onChange={setLabelColumnFilter}
onClear={() => setLabelColumnFilter(null)}
/>
</PopoverContent>
</Popover>
</div>
</th>
{analysisResult.series.map((s, si) => {
const colKey = String(si);
const hasFilter = !!metricColumnFilters[si];
return (
<th
key={si}
className="p-2 text-right text-xs font-medium select-none"
style={{ borderBottomColor: COLORS[si % COLORS.length], borderBottomWidth: 2 }}
>
<div className="flex items-center justify-end gap-2">
<button
type="button"
className="inline-flex items-center gap-1 justify-end text-right hover:text-foreground"
onClick={() => toggleTableSort(colKey)}
>
<span>
{s.condName}
<br />
<span className="font-normal text-muted-foreground">
{s.metricName}({aggLabel(s.aggMethod)})
</span>
</span>
{tableSortColumn === colKey && (
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
)}
</button>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex h-5 w-5 items-center justify-center rounded hover:bg-muted",
hasFilter && "bg-primary/10 text-primary"
)}
title="범위 필터"
>
</button>
</PopoverTrigger>
<PopoverContent className="w-60 p-2" align="end">
<MetricFilterContent
range={metricColumnFilters[si]}
onApply={(r) =>
setMetricColumnFilters((prev) => {
if (r.min === undefined && r.max === undefined) {
const next = { ...prev };
delete next[si];
return next;
}
return { ...prev, [si]: r };
})
}
onClear={() =>
setMetricColumnFilters((prev) => {
const next = { ...prev };
delete next[si];
return next;
})
}
/>
</PopoverContent>
</Popover>
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{displayLabels.length === 0 ? (
<tr>
<td colSpan={analysisResult.series.length + 1} className="p-6 text-center text-xs text-muted-foreground">
{tableSearchQuery ? "검색 결과가 없습니다" : "데이터가 없습니다"}
</td>
</tr>
) : tableGrouped && groupedLabels ? (
groupedLabels.order.map((primary) => {
const subLabels = groupedLabels.groups[primary];
const isCollapsed = collapsedGroups.has(primary);
return (
<React.Fragment key={`grp-${primary}`}>
<tr
className="bg-muted/40 border-b border-primary/20 cursor-pointer font-semibold hover:bg-muted"
onClick={() => toggleGroupCollapse(primary)}
>
<td className="p-2">
<span className="inline-flex items-center gap-1">
<span className="text-primary">{isCollapsed ? "▶" : "▼"}</span>
{primary}
<span className="text-muted-foreground text-xs font-normal">({subLabels.length})</span>
</span>
</td>
{analysisResult.series.map((s, si) => {
const allRows = subLabels.flatMap((lb) => s.groups[lb] || []);
const m = config.metrics.find((x) => x.id === s.metricId);
return (
<td key={si} className="p-2 text-right tabular-nums">
{formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod, m))}
</td>
);
})}
</tr>
{!isCollapsed && subLabels.map((label) => {
const parts = label.split(" / ");
const subPart = parts.filter((_, i) => i !== primaryGroupIndex).join(" / ") || label;
return (
<tr
key={label}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
onClick={() => setDrilldownLabel(label)}
>
<td className="p-2 pl-6 text-muted-foreground">{subPart}</td>
{analysisResult.series.map((s, si) => (
<td key={si} className="p-2 text-right tabular-nums">
{formatNumber(s.values[label] || 0)}
</td>
))}
</tr>
);
})}
</React.Fragment>
);
})
) : displayLabels.map((label) => (
<tr
key={label}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
onClick={() => setDrilldownLabel(label)}
>
<td className="p-2 font-medium">{label}</td>
{analysisResult.series.map((s, si) => (
<td key={si} className="p-2 text-right tabular-nums">
{formatNumber(s.values[label] || 0)}
</td>
))}
</tr>
))}
<tr className="border-t-2 bg-muted/30 font-bold">
<td className="p-2">
{tableSearchQuery ? `필터 (${displayLabels.length}건)` : "전체"}
</td>
{analysisResult.series.map((s, si) => {
// 검색 시에만 동적 계산, 아니면 미리 계산된 totalValue 사용
let total: number;
if (tableSearchQuery) {
const allRows = displayLabels.flatMap((lb) => s.groups[lb] || []);
const m = config.metrics.find((x) => x.id === s.metricId);
total = aggregateValues(allRows, s.metricId, s.aggMethod, m);
} else {
total = s.totalValue;
}
return (
<td key={si} className="p-2 text-right tabular-nums">
{formatNumber(total)}
</td>
);
})}
</tr>
</tbody>
</table>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{displayLabels.length === 0 && (
<div className="col-span-full p-6 text-center text-xs text-muted-foreground">
{tableSearchQuery ? "검색 결과가 없습니다" : "데이터가 없습니다"}
</div>
)}
{displayLabels.map((label) => {
const firstS = analysisResult.series[0];
const val = firstS ? (firstS.values[label] || 0) : 0;
return (
<div
key={label}
className="cursor-pointer rounded-lg border p-4 transition-shadow hover:shadow-md"
onClick={() => setDrilldownLabel(label)}
>
<p className="text-sm font-medium">{label}</p>
<p className="mt-1 text-xl font-bold tabular-nums">
{formatNumber(val)}
<span className="ml-1 text-xs font-normal text-muted-foreground">
{firstS?.metricUnit}
</span>
</p>
{analysisResult.series.length > 1 && (
<p className="mt-1 truncate text-xs text-muted-foreground">
{analysisResult.series.slice(1).map((s) => {
const v = s.values[label] || 0;
return `${s.condName}-${s.metricName}: ${formatNumber(v)}`;
}).join(" | ")}
</p>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
{/* 드릴다운 */}
{drilldownLabel && (
<div className="rounded-md border border-primary/30 bg-primary/5">
<div className="flex items-center justify-between border-b p-3">
<h3 className="text-sm font-semibold">{drilldownLabel} </h3>
<button
onClick={() => setDrilldownLabel(null)}
className="rounded p-1 text-sm hover:bg-muted"
>
X
</button>
</div>
<div className="overflow-x-auto p-3">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
{config.drilldownColumns.map((col) => (
<th
key={col.id}
className={cn("p-2 text-xs", col.align === "right" ? "text-right" : "text-left")}
>
{col.name}
</th>
))}
</tr>
</thead>
<tbody>
{rawData
.filter((d) => getGroupKey(d, groupBy) === drilldownLabel)
.map((r, i) => (
<tr key={i} className="border-b">
{config.drilldownColumns.map((col) => (
<td
key={col.id}
className={cn(
"p-2 text-xs",
col.align === "right" ? "text-right tabular-nums" : ""
)}
>
{renderCellValue(r, col)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 원본 데이터 */}
<div className="rounded-md border">
<button
className="flex w-full items-center justify-between p-3 text-left hover:bg-muted/50"
onClick={() => setRawDataOpen(!rawDataOpen)}
>
<span className="text-sm font-semibold"> ()</span>
<span className={cn("text-xs transition-transform", !rawDataOpen && "-rotate-90")}>
</span>
</button>
{rawDataOpen && (
<div className="overflow-x-auto border-t p-3">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
{config.rawDataColumns.map((col) => (
<th
key={col.id}
className={cn("p-2 text-xs", col.align === "right" ? "text-right" : "text-left")}
>
{col.name}
</th>
))}
</tr>
</thead>
<tbody>
{rawData.slice(0, 100).map((r, i) => (
<tr key={i} className="border-b">
{config.rawDataColumns.map((col) => (
<td
key={col.id}
className={cn(
"p-2 text-xs",
col.align === "right" ? "text-right tabular-nums" : ""
)}
>
{renderCellValue(r, col)}
</td>
))}
</tr>
))}
</tbody>
</table>
{rawData.length > 100 && (
<p className="mt-2 text-center text-xs text-muted-foreground">
100 ( {rawData.length})
</p>
)}
</div>
)}
</div>
{/* 데이터 없음 */}
{!isLoading && rawData.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<svg className="h-8 w-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="text-lg font-semibold">{config.emptyMessage}</h3>
<p className="mt-1 text-sm text-muted-foreground">
</p>
</div>
)}
</div>
{/* 프리셋 저장 모달 */}
<Dialog open={presetModalOpen} onOpenChange={setPresetModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{presetMode === "update" ? "조건 수정" : "신규 조건 저장"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
placeholder="예: 월별 추이 분석"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Input
value={presetDesc}
onChange={(e) => setPresetDesc(e.target.value)}
placeholder="조건 설명 (선택사항)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPresetModalOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={savePreset}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{presetMode === "update" ? "수정 저장" : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ScrollToTop />
</div>
);
}