2026-03-19 17:18:14 +09:00
|
|
|
|
"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";
|
2026-04-23 14:32:52 +09:00
|
|
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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;
|
2026-04-16 12:08:28 +09:00
|
|
|
|
/** 복합 그룹인 경우 파트 이름들 (예: ["거래처", "사이즈"]). 1차 그룹 선택 드롭다운에 사용 */
|
|
|
|
|
|
parts?: string[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 자유 조합 그룹핑에서 사용할 단위 필드
|
|
|
|
|
|
* 사용자가 이 필드들을 자유롭게 선택/조합해서 그룹 조건을 만들 수 있음
|
|
|
|
|
|
*/
|
|
|
|
|
|
export interface ReportGroupableField {
|
|
|
|
|
|
id: string; // getGroupKey에 넘겨질 id (예: "customer", "size", "thickness", "monthly")
|
|
|
|
|
|
name: string; // 화면 표시명 (예: "거래처")
|
2026-03-19 17:18:14 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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[];
|
2026-04-16 12:08:28 +09:00
|
|
|
|
/** 자유 조합 그룹핑에 쓸 단위 필드 목록 (선택 사항) */
|
|
|
|
|
|
groupableFields?: ReportGroupableField[];
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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 {
|
2026-04-16 12:08:28 +09:00
|
|
|
|
id: number;
|
2026-03-19 17:18:14 +09:00
|
|
|
|
name: string;
|
2026-04-16 12:08:28 +09:00
|
|
|
|
description: string | null;
|
2026-03-19 17:18:14 +09:00
|
|
|
|
config: {
|
|
|
|
|
|
groupBy: string;
|
|
|
|
|
|
startDate: string;
|
|
|
|
|
|
endDate: string;
|
|
|
|
|
|
conditions: ConditionGroup[];
|
|
|
|
|
|
};
|
2026-04-16 12:08:28 +09:00
|
|
|
|
created_by?: string | null;
|
|
|
|
|
|
created_at?: string;
|
|
|
|
|
|
updated_at?: string;
|
2026-03-19 17:18:14 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
// 유틸 함수
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
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)}`;
|
|
|
|
|
|
}
|
2026-04-16 12:08:28 +09:00
|
|
|
|
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}`;
|
|
|
|
|
|
}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
default:
|
|
|
|
|
|
return row[groupBy] || "미지정";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function aggregateValues(
|
|
|
|
|
|
rows: Record<string, any>[],
|
|
|
|
|
|
metricId: string,
|
2026-04-28 20:20:10 +09:00
|
|
|
|
method: string,
|
|
|
|
|
|
metric?: ReportMetric,
|
2026-03-19 17:18:14 +09:00
|
|
|
|
): number {
|
|
|
|
|
|
if (!rows.length) return 0;
|
|
|
|
|
|
const vals = rows.map((r) => Number(r[metricId]) || 0);
|
2026-04-28 20:20:10 +09:00
|
|
|
|
// 비율 메트릭(% 등 isRate)은 행 단위 합계가 의미 없음 → 산술평균으로 강제
|
|
|
|
|
|
// (예: 100% 행이 N건이면 sum=N×100% 으로 비정상 누적되는 문제 방지)
|
|
|
|
|
|
const effectiveMethod = metric?.isRate && method === "sum" ? "avg" : method;
|
|
|
|
|
|
switch (effectiveMethod) {
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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 ?? "");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-23 14:32:52 +09:00
|
|
|
|
// ============================================
|
|
|
|
|
|
// 헤더 필터 - 라벨 컬럼 (고유값 체크박스)
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 17:18:14 +09:00
|
|
|
|
// ============================================
|
|
|
|
|
|
// 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);
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 자유 조합 그룹핑: 기본 groupBy 뒤에 이어 붙일 추가 그룹 필드들 (순서대로)
|
|
|
|
|
|
const [extraGroupBys, setExtraGroupBys] = useState<string[]>([]);
|
2026-03-19 17:18:14 +09:00
|
|
|
|
const [startDate, setStartDate] = useState("");
|
|
|
|
|
|
const [endDate, setEndDate] = useState("");
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const [activePreset, setActivePreset] = useState("last1m");
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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);
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 집계 테이블 검색/정렬
|
|
|
|
|
|
const [tableSearchQuery, setTableSearchQuery] = useState("");
|
|
|
|
|
|
const [tableSortColumn, setTableSortColumn] = useState<string | null>(null);
|
|
|
|
|
|
const [tableSortDirection, setTableSortDirection] = useState<"asc" | "desc">("desc");
|
2026-04-23 14:32:52 +09:00
|
|
|
|
// 집계 테이블 헤더 필터: 라벨(그룹) 컬럼은 값 체크박스, 메트릭 컬럼은 숫자 범위
|
|
|
|
|
|
const [labelColumnFilter, setLabelColumnFilter] = useState<Set<string> | null>(null);
|
|
|
|
|
|
const [metricColumnFilters, setMetricColumnFilters] = useState<Record<number, { min?: number; max?: number }>>({});
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 집계 테이블 그룹핑 (1차 그룹으로 묶기)
|
|
|
|
|
|
const [tableGrouped, setTableGrouped] = useState(false);
|
|
|
|
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const [primaryGroupIndex, setPrimaryGroupIndex] = useState(0); // 현재 그룹화의 어떤 파트를 1차 기준으로 쓸지
|
2026-03-19 17:18:14 +09:00
|
|
|
|
|
|
|
|
|
|
const [presets, setPresets] = useState<Preset[]>([]);
|
|
|
|
|
|
const [presetModalOpen, setPresetModalOpen] = useState(false);
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const [presetMode, setPresetMode] = useState<"new" | "update">("new");
|
2026-03-19 17:18:14 +09:00
|
|
|
|
const [presetName, setPresetName] = useState("");
|
|
|
|
|
|
const [presetDesc, setPresetDesc] = useState("");
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
2026-03-19 17:18:14 +09:00
|
|
|
|
|
|
|
|
|
|
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(() => {
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const range = getDatePresetRange("last1m");
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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] };
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 메트릭 순서 변경 (왼쪽/오른쪽)
|
|
|
|
|
|
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 };
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 프리셋 저장/불러오기 (DB 기반 - company_code + report_key)
|
2026-03-19 17:18:14 +09:00
|
|
|
|
// ============================================
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const loadPresets = async () => {
|
2026-03-19 17:18:14 +09:00
|
|
|
|
try {
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const res = await apiClient.get(
|
|
|
|
|
|
`/report-presets?reportKey=${encodeURIComponent(config.key)}`
|
|
|
|
|
|
);
|
|
|
|
|
|
if (res.data?.success) {
|
|
|
|
|
|
setPresets(res.data.data || []);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("프리셋 목록 조회 실패", err);
|
|
|
|
|
|
}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 저장 모달 열기 - 신규
|
|
|
|
|
|
const openNewPresetModal = () => {
|
|
|
|
|
|
setPresetMode("new");
|
2026-03-19 17:18:14 +09:00
|
|
|
|
setPresetName("");
|
|
|
|
|
|
setPresetDesc("");
|
2026-04-16 12:08:28 +09:00
|
|
|
|
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);
|
2026-03-19 17:18:14 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 모달 저장 - 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);
|
2026-03-19 17:18:14 +09:00
|
|
|
|
if (!p?.config) return;
|
|
|
|
|
|
setGroupBy(p.config.groupBy);
|
|
|
|
|
|
setStartDate(p.config.startDate);
|
|
|
|
|
|
setEndDate(p.config.endDate);
|
|
|
|
|
|
setConditions(p.config.conditions);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-16 12:08:28 +09:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
// 데이터 분석 (클라이언트 사이드)
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
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>[]>;
|
2026-04-16 12:08:28 +09:00
|
|
|
|
values: Record<string, number>; // 미리 계산된 집계값 (O(1) 조회)
|
|
|
|
|
|
totalValue: number; // 전체 합계(또는 선택된 집계)
|
2026-03-19 17:18:14 +09:00
|
|
|
|
}[] = [];
|
|
|
|
|
|
|
|
|
|
|
|
const allLabelsSet = new Set<string>();
|
|
|
|
|
|
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 각 condition의 filter는 cache (같은 filter면 재사용)
|
|
|
|
|
|
const condFilterCache = new Map<string, any[]>();
|
|
|
|
|
|
|
2026-03-19 17:18:14 +09:00
|
|
|
|
conditions.forEach((cond, ci) => {
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// filter 결과 캐싱 (같은 필터면 재사용)
|
|
|
|
|
|
const filterKey = JSON.stringify(cond.filters);
|
|
|
|
|
|
let condData = condFilterCache.get(filterKey);
|
|
|
|
|
|
if (!condData) {
|
|
|
|
|
|
condData = applyConditionFilters(rawData, cond.filters, filterFields);
|
|
|
|
|
|
condFilterCache.set(filterKey, condData);
|
|
|
|
|
|
}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 그룹핑 (1회) — 기본 groupBy + extraGroupBys 조합
|
|
|
|
|
|
const allGroupBys = [groupBy, ...extraGroupBys];
|
2026-03-19 17:18:14 +09:00
|
|
|
|
const groups: Record<string, Record<string, any>[]> = {};
|
2026-04-16 12:08:28 +09:00
|
|
|
|
for (let i = 0; i < condData.length; i++) {
|
|
|
|
|
|
const d = condData[i];
|
|
|
|
|
|
const keyParts = allGroupBys.map((g) => getGroupKey(d, g));
|
|
|
|
|
|
const key = keyParts.join(" / ");
|
2026-03-19 17:18:14 +09:00
|
|
|
|
if (!groups[key]) groups[key] = [];
|
|
|
|
|
|
groups[key].push(d);
|
2026-04-16 12:08:28 +09:00
|
|
|
|
}
|
|
|
|
|
|
// Set 에 라벨 추가
|
|
|
|
|
|
for (const k in groups) allLabelsSet.add(k);
|
2026-03-19 17:18:14 +09:00
|
|
|
|
|
|
|
|
|
|
cond.metrics.forEach((metricId) => {
|
|
|
|
|
|
const m = config.metrics.find((x) => x.id === metricId);
|
|
|
|
|
|
if (!m) return;
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 각 라벨별 집계값을 한 번에 계산해서 저장 (렌더링 시 lookup 만)
|
|
|
|
|
|
const values: Record<string, number> = {};
|
|
|
|
|
|
for (const lb in groups) {
|
2026-04-28 20:20:10 +09:00
|
|
|
|
values[lb] = aggregateValues(groups[lb], metricId, cond.aggMethod, m);
|
2026-04-16 12:08:28 +09:00
|
|
|
|
}
|
|
|
|
|
|
// 전체 합계
|
2026-04-28 20:20:10 +09:00
|
|
|
|
const totalValue = aggregateValues(condData, metricId, cond.aggMethod, m);
|
2026-03-19 17:18:14 +09:00
|
|
|
|
seriesList.push({
|
|
|
|
|
|
condId: cond.id,
|
|
|
|
|
|
condName: cond.name,
|
|
|
|
|
|
condIdx: ci,
|
|
|
|
|
|
metricId,
|
|
|
|
|
|
metricName: m.name,
|
|
|
|
|
|
metricUnit: m.unit,
|
|
|
|
|
|
aggMethod: cond.aggMethod,
|
|
|
|
|
|
chartType: cond.chartType,
|
|
|
|
|
|
groups,
|
2026-04-16 12:08:28 +09:00
|
|
|
|
values,
|
|
|
|
|
|
totalValue,
|
2026-03-19 17:18:14 +09:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 다단계 정렬: 미리 계산된 values 사용 (O(1) 조회)
|
2026-03-19 17:18:14 +09:00
|
|
|
|
labels.sort((a, b) => {
|
2026-04-16 12:08:28 +09:00
|
|
|
|
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" });
|
2026-03-19 17:18:14 +09:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 차트 렌더링 성능 보호: 그룹이 너무 많으면 상위 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) => {
|
2026-03-19 17:18:14 +09:00
|
|
|
|
const point: Record<string, any> = { name: label };
|
|
|
|
|
|
seriesList.forEach((s) => {
|
|
|
|
|
|
const key = `${s.condName}_${s.metricName}`;
|
2026-04-16 12:08:28 +09:00
|
|
|
|
point[key] = s.values[label] || 0;
|
2026-03-19 17:18:14 +09:00
|
|
|
|
});
|
|
|
|
|
|
return point;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return { series: seriesList, labels, chartData };
|
2026-04-16 12:08:28 +09:00
|
|
|
|
}, [rawData, conditions, groupBy, extraGroupBys, filterFields, config.metrics]);
|
|
|
|
|
|
|
2026-04-23 14:32:52 +09:00
|
|
|
|
// 집계 테이블 표시용 라벨 (검색 + 헤더 정렬 + 헤더 필터 적용) — 미리 계산된 values 사용
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const displayLabels = useMemo(() => {
|
|
|
|
|
|
let list = analysisResult.labels;
|
|
|
|
|
|
if (tableSearchQuery) {
|
|
|
|
|
|
const q = tableSearchQuery.toLowerCase();
|
|
|
|
|
|
list = list.filter((l) => l.toLowerCase().includes(q));
|
|
|
|
|
|
}
|
2026-04-23 14:32:52 +09:00
|
|
|
|
// 라벨 컬럼 필터 (체크박스 선택값만 통과)
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-04-16 12:08:28 +09:00
|
|
|
|
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;
|
2026-04-23 14:32:52 +09:00
|
|
|
|
}, [
|
|
|
|
|
|
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]);
|
2026-04-16 12:08:28 +09:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
2026-03-19 17:18:14 +09:00
|
|
|
|
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
// 렌더링
|
|
|
|
|
|
// ============================================
|
|
|
|
|
|
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
|
2026-04-16 12:08:28 +09:00
|
|
|
|
value={selectedPresetId}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
onChange={(e) => loadSelectedPreset(e.target.value)}
|
|
|
|
|
|
className="h-8 min-w-[180px] rounded-md border px-2 text-xs"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">조건을 선택하세요</option>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
{presets.map((p) => (
|
|
|
|
|
|
<option key={p.id} value={String(p.id)}>{p.name}</option>
|
2026-03-19 17:18:14 +09:00
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
<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 ? "현재 조건을 선택된 프리셋에 덮어쓰기" : "먼저 프리셋을 선택하세요"}
|
|
|
|
|
|
>
|
|
|
|
|
|
현재 조건 수정
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</Button>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="h-8 text-xs"
|
|
|
|
|
|
onClick={deletePreset}
|
|
|
|
|
|
disabled={!selectedPresetId}
|
|
|
|
|
|
>
|
2026-03-19 17:18:14 +09:00
|
|
|
|
삭제
|
|
|
|
|
|
</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>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
<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>
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</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">
|
2026-04-16 12:08:28 +09:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{/* 전체 메트릭 목록 */}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
<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">
|
2026-04-16 12:08:28 +09:00
|
|
|
|
{extraGroupBys.length > 0 && currentGroupParts.length > 0
|
|
|
|
|
|
? currentGroupParts.map((p) => `${p}별`).join(" × ")
|
|
|
|
|
|
: config.groupByOptions.find((o) => o.id === groupBy)?.name}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</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);
|
2026-04-28 20:20:10 +09:00
|
|
|
|
const val = aggregateValues(condData, metricId, cond.aggMethod, m);
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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">
|
2026-04-16 12:08:28 +09:00
|
|
|
|
<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>
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="p-3">
|
|
|
|
|
|
{viewMode === "table" ? (
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr className="border-b">
|
2026-04-23 14:32:52 +09:00
|
|
|
|
<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>
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</th>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
{analysisResult.series.map((s, si) => {
|
|
|
|
|
|
const colKey = String(si);
|
2026-04-23 14:32:52 +09:00
|
|
|
|
const hasFilter = !!metricColumnFilters[si];
|
2026-04-16 12:08:28 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<th
|
|
|
|
|
|
key={si}
|
2026-04-23 14:32:52 +09:00
|
|
|
|
className="p-2 text-right text-xs font-medium select-none"
|
2026-04-16 12:08:28 +09:00
|
|
|
|
style={{ borderBottomColor: COLORS[si % COLORS.length], borderBottomWidth: 2 }}
|
|
|
|
|
|
>
|
2026-04-23 14:32:52 +09:00
|
|
|
|
<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>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
</span>
|
2026-04-23 14:32:52 +09:00
|
|
|
|
{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>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
</th>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
{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] || []);
|
2026-04-28 20:20:10 +09:00
|
|
|
|
const m = config.metrics.find((x) => x.id === s.metricId);
|
2026-04-16 12:08:28 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<td key={si} className="p-2 text-right tabular-nums">
|
2026-04-28 20:20:10 +09:00
|
|
|
|
{formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod, m))}
|
2026-04-16 12:08:28 +09:00
|
|
|
|
</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) => (
|
2026-03-19 17:18:14 +09:00
|
|
|
|
<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">
|
2026-04-16 12:08:28 +09:00
|
|
|
|
{formatNumber(s.values[label] || 0)}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</td>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<tr className="border-t-2 bg-muted/30 font-bold">
|
2026-04-16 12:08:28 +09:00
|
|
|
|
<td className="p-2">
|
|
|
|
|
|
{tableSearchQuery ? `필터 (${displayLabels.length}건)` : "전체"}
|
|
|
|
|
|
</td>
|
2026-03-19 17:18:14 +09:00
|
|
|
|
{analysisResult.series.map((s, si) => {
|
2026-04-16 12:08:28 +09:00
|
|
|
|
// 검색 시에만 동적 계산, 아니면 미리 계산된 totalValue 사용
|
|
|
|
|
|
let total: number;
|
|
|
|
|
|
if (tableSearchQuery) {
|
|
|
|
|
|
const allRows = displayLabels.flatMap((lb) => s.groups[lb] || []);
|
2026-04-28 20:20:10 +09:00
|
|
|
|
const m = config.metrics.find((x) => x.id === s.metricId);
|
|
|
|
|
|
total = aggregateValues(allRows, s.metricId, s.aggMethod, m);
|
2026-04-16 12:08:28 +09:00
|
|
|
|
} else {
|
|
|
|
|
|
total = s.totalValue;
|
|
|
|
|
|
}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
return (
|
|
|
|
|
|
<td key={si} className="p-2 text-right tabular-nums">
|
2026-04-16 12:08:28 +09:00
|
|
|
|
{formatNumber(total)}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</td>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
2026-04-16 12:08:28 +09:00
|
|
|
|
{displayLabels.length === 0 && (
|
|
|
|
|
|
<div className="col-span-full p-6 text-center text-xs text-muted-foreground">
|
|
|
|
|
|
{tableSearchQuery ? "검색 결과가 없습니다" : "데이터가 없습니다"}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{displayLabels.map((label) => {
|
2026-03-19 17:18:14 +09:00
|
|
|
|
const firstS = analysisResult.series[0];
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const val = firstS ? (firstS.values[label] || 0) : 0;
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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) => {
|
2026-04-16 12:08:28 +09:00
|
|
|
|
const v = s.values[label] || 0;
|
2026-03-19 17:18:14 +09:00
|
|
|
|
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>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
<DialogTitle className="text-base sm:text-lg">
|
|
|
|
|
|
{presetMode === "update" ? "조건 수정" : "신규 조건 저장"}
|
|
|
|
|
|
</DialogTitle>
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</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"
|
|
|
|
|
|
>
|
2026-04-16 12:08:28 +09:00
|
|
|
|
{presetMode === "update" ? "수정 저장" : "저장"}
|
2026-03-19 17:18:14 +09:00
|
|
|
|
</Button>
|
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
|
</DialogContent>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
|
|
<ScrollToTop />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|