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