feat: add report preset management API
- Implemented CRUD operations for report presets in reportPresetController. - Added routes for listing, creating, updating, and deleting report presets. - Ensured authentication is required for all preset operations. - Enhanced MaterialData interface to include optional width, height, and thickness properties.
This commit is contained in:
@@ -97,6 +97,17 @@ export interface ReportMetric {
|
||||
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 {
|
||||
@@ -127,6 +138,8 @@ export interface ReportConfig {
|
||||
apiEndpoint: string;
|
||||
metrics: ReportMetric[];
|
||||
groupByOptions: ReportGroupByOption[];
|
||||
/** 자유 조합 그룹핑에 쓸 단위 필드 목록 (선택 사항) */
|
||||
groupableFields?: ReportGroupableField[];
|
||||
defaultGroupBy: string;
|
||||
defaultMetrics: string[];
|
||||
thresholds: ReportThreshold[];
|
||||
@@ -164,15 +177,18 @@ interface FilterField {
|
||||
}
|
||||
|
||||
interface Preset {
|
||||
id: number;
|
||||
name: string;
|
||||
desc: string;
|
||||
description: string | null;
|
||||
config: {
|
||||
groupBy: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
conditions: ConditionGroup[];
|
||||
};
|
||||
savedAt: string;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -243,6 +259,48 @@ function getGroupKey(row: Record<string, any>, groupBy: string): string {
|
||||
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] || "미지정";
|
||||
}
|
||||
@@ -343,9 +401,11 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
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("last6m");
|
||||
const [activePreset, setActivePreset] = useState("last1m");
|
||||
const [filterOpen, setFilterOpen] = useState(true);
|
||||
|
||||
const [conditions, setConditions] = useState<ConditionGroup[]>([]);
|
||||
@@ -355,12 +415,21 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
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");
|
||||
// 집계 테이블 그룹핑 (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 [selectedPresetIdx, setSelectedPresetIdx] = useState("");
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
||||
|
||||
const [thresholdValues, setThresholdValues] = useState<Record<string, number>>(() => {
|
||||
const defaults: Record<string, number> = {};
|
||||
@@ -371,8 +440,6 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
const [refreshInterval, setRefreshInterval] = useState(0);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const PRESET_KEY = `${config.key}_presets`;
|
||||
|
||||
const filterFields: FilterField[] = useMemo(
|
||||
() =>
|
||||
config.filterFieldDefs.map((def) => ({
|
||||
@@ -393,7 +460,7 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
// 초기화
|
||||
// ============================================
|
||||
useEffect(() => {
|
||||
const range = getDatePresetRange("last6m");
|
||||
const range = getDatePresetRange("last1m");
|
||||
setStartDate(range.start);
|
||||
setEndDate(range.end);
|
||||
|
||||
@@ -523,6 +590,22 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
);
|
||||
};
|
||||
|
||||
// 메트릭 순서 변경 (왼쪽/오른쪽)
|
||||
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 || "";
|
||||
@@ -587,37 +670,81 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 프리셋 저장/불러오기
|
||||
// 프리셋 저장/불러오기 (DB 기반 - company_code + report_key)
|
||||
// ============================================
|
||||
const loadPresets = () => {
|
||||
const loadPresets = async () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(PRESET_KEY);
|
||||
if (stored) setPresets(JSON.parse(stored));
|
||||
} catch {}
|
||||
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 savePreset = () => {
|
||||
if (!presetName.trim()) return;
|
||||
const newPresets = [
|
||||
...presets,
|
||||
{
|
||||
name: presetName.trim(),
|
||||
desc: presetDesc.trim(),
|
||||
config: { groupBy, startDate, endDate, conditions },
|
||||
savedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
setPresets(newPresets);
|
||||
localStorage.setItem(PRESET_KEY, JSON.stringify(newPresets));
|
||||
setPresetModalOpen(false);
|
||||
// 저장 모달 열기 - 신규
|
||||
const openNewPresetModal = () => {
|
||||
setPresetMode("new");
|
||||
setPresetName("");
|
||||
setPresetDesc("");
|
||||
setPresetModalOpen(true);
|
||||
};
|
||||
|
||||
const loadSelectedPreset = (idx: string) => {
|
||||
setSelectedPresetIdx(idx);
|
||||
if (idx === "") return;
|
||||
const p = presets[parseInt(idx)];
|
||||
// 저장 모달 열기 - 선택된 프리셋 수정
|
||||
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);
|
||||
@@ -625,12 +752,17 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
setConditions(p.config.conditions);
|
||||
};
|
||||
|
||||
const deletePreset = () => {
|
||||
if (selectedPresetIdx === "") return;
|
||||
const newPresets = presets.filter((_, i) => i !== parseInt(selectedPresetIdx));
|
||||
setPresets(newPresets);
|
||||
localStorage.setItem(PRESET_KEY, JSON.stringify(newPresets));
|
||||
setSelectedPresetIdx("");
|
||||
const 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);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
@@ -649,24 +781,47 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
aggMethod: string;
|
||||
chartType: string;
|
||||
groups: Record<string, Record<string, any>[]>;
|
||||
values: Record<string, number>; // 미리 계산된 집계값 (O(1) 조회)
|
||||
totalValue: number; // 전체 합계(또는 선택된 집계)
|
||||
}[] = [];
|
||||
|
||||
const allLabelsSet = new Set<string>();
|
||||
|
||||
conditions.forEach((cond, ci) => {
|
||||
const condData = applyConditionFilters(rawData, cond.filters, filterFields);
|
||||
// 각 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>[]> = {};
|
||||
condData.forEach((d) => {
|
||||
const key = getGroupKey(d, groupBy);
|
||||
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);
|
||||
});
|
||||
Object.keys(groups).forEach((k) => allLabelsSet.add(k));
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// 전체 합계
|
||||
const totalValue = aggregateValues(condData, metricId, cond.aggMethod);
|
||||
seriesList.push({
|
||||
condId: cond.id,
|
||||
condName: cond.name,
|
||||
@@ -677,6 +832,8 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
aggMethod: cond.aggMethod,
|
||||
chartType: cond.chartType,
|
||||
groups,
|
||||
values,
|
||||
totalValue,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -687,25 +844,126 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
if (isTimeBased) {
|
||||
labels.sort((a, b) => a.localeCompare(b));
|
||||
} else if (seriesList.length > 0) {
|
||||
const first = seriesList[0];
|
||||
// 다단계 정렬: 미리 계산된 values 사용 (O(1) 조회)
|
||||
labels.sort((a, b) => {
|
||||
const va = aggregateValues(first.groups[a] || [], first.metricId, first.aggMethod);
|
||||
const vb = aggregateValues(first.groups[b] || [], first.metricId, first.aggMethod);
|
||||
return vb - va;
|
||||
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" });
|
||||
});
|
||||
}
|
||||
|
||||
const chartData = labels.map((label) => {
|
||||
// 차트 렌더링 성능 보호: 그룹이 너무 많으면 상위 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] = aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod);
|
||||
point[key] = s.values[label] || 0;
|
||||
});
|
||||
return point;
|
||||
});
|
||||
|
||||
return { series: seriesList, labels, chartData };
|
||||
}, [rawData, conditions, groupBy, filterFields, config.metrics]);
|
||||
}, [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 (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]);
|
||||
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 렌더링
|
||||
@@ -747,19 +1005,35 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
<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={selectedPresetIdx}
|
||||
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, i) => (
|
||||
<option key={i} value={i}>{p.name}</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={() => setPresetModalOpen(true)}>
|
||||
조건 저장
|
||||
<Button size="sm" variant="default" className="h-8 text-xs" onClick={openNewPresetModal}>
|
||||
신규 저장
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={deletePreset}>
|
||||
<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>
|
||||
@@ -782,15 +1056,71 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">분석 기준 (X축)</Label>
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value)}
|
||||
className="h-9 w-[140px] rounded-md border px-2 text-sm"
|
||||
>
|
||||
{config.groupByOptions.map((o) => (
|
||||
<option key={o.id} value={o.id}>{o.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<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>
|
||||
@@ -887,8 +1217,71 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
|
||||
{!cond.collapsed && (
|
||||
<div className="space-y-3 border-t p-3">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">데이터</span>
|
||||
<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);
|
||||
@@ -1115,7 +1508,9 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
{analysisResult.series.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">
|
||||
{config.groupByOptions.find((o) => o.id === groupBy)?.name}
|
||||
{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">
|
||||
@@ -1253,27 +1648,87 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
{/* 집계 데이터 */}
|
||||
{analysisResult.series.length > 0 && (
|
||||
<div className="rounded-md border">
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h3 className="text-sm font-semibold">집계 데이터</h3>
|
||||
<div className="flex rounded-md border">
|
||||
<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 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>
|
||||
|
||||
@@ -1283,26 +1738,102 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="p-2 text-left text-xs font-medium text-muted-foreground">
|
||||
{config.groupByOptions.find((o) => o.id === groupBy)?.name}
|
||||
<th
|
||||
className="p-2 text-left text-xs font-medium text-muted-foreground cursor-pointer hover:bg-muted/50 select-none"
|
||||
onClick={() => toggleTableSort("__label__")}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
{analysisResult.series.map((s, si) => (
|
||||
<th
|
||||
key={si}
|
||||
className="p-2 text-right text-xs font-medium"
|
||||
style={{ borderBottomColor: COLORS[si % COLORS.length], borderBottomWidth: 2 }}
|
||||
>
|
||||
{s.condName}
|
||||
<br />
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{s.metricName}({aggLabel(s.aggMethod)})
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
{analysisResult.series.map((s, si) => {
|
||||
const colKey = String(si);
|
||||
return (
|
||||
<th
|
||||
key={si}
|
||||
className="p-2 text-right text-xs font-medium cursor-pointer hover:bg-muted/50 select-none"
|
||||
style={{ borderBottomColor: COLORS[si % COLORS.length], borderBottomWidth: 2 }}
|
||||
onClick={() => toggleTableSort(colKey)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 justify-end w-full">
|
||||
<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>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analysisResult.labels.map((label) => (
|
||||
{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] || []);
|
||||
return (
|
||||
<td key={si} className="p-2 text-right tabular-nums">
|
||||
{formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))}
|
||||
</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"
|
||||
@@ -1311,22 +1842,27 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
<td className="p-2 font-medium">{label}</td>
|
||||
{analysisResult.series.map((s, si) => (
|
||||
<td key={si} className="p-2 text-right tabular-nums">
|
||||
{formatNumber(
|
||||
aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod)
|
||||
)}
|
||||
{formatNumber(s.values[label] || 0)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
<tr className="border-t-2 bg-muted/30 font-bold">
|
||||
<td className="p-2">전체</td>
|
||||
<td className="p-2">
|
||||
{tableSearchQuery ? `필터 (${displayLabels.length}건)` : "전체"}
|
||||
</td>
|
||||
{analysisResult.series.map((s, si) => {
|
||||
const allRows = analysisResult.labels.flatMap(
|
||||
(lb) => s.groups[lb] || []
|
||||
);
|
||||
// 검색 시에만 동적 계산, 아니면 미리 계산된 totalValue 사용
|
||||
let total: number;
|
||||
if (tableSearchQuery) {
|
||||
const allRows = displayLabels.flatMap((lb) => s.groups[lb] || []);
|
||||
total = aggregateValues(allRows, s.metricId, s.aggMethod);
|
||||
} else {
|
||||
total = s.totalValue;
|
||||
}
|
||||
return (
|
||||
<td key={si} className="p-2 text-right tabular-nums">
|
||||
{formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))}
|
||||
{formatNumber(total)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
@@ -1336,11 +1872,14 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{analysisResult.labels.map((label) => {
|
||||
{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
|
||||
? aggregateValues(firstS.groups[label] || [], firstS.metricId, firstS.aggMethod)
|
||||
: 0;
|
||||
const val = firstS ? (firstS.values[label] || 0) : 0;
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
@@ -1357,7 +1896,7 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
{analysisResult.series.length > 1 && (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{analysisResult.series.slice(1).map((s) => {
|
||||
const v = aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod);
|
||||
const v = s.values[label] || 0;
|
||||
return `${s.condName}-${s.metricName}: ${formatNumber(v)}`;
|
||||
}).join(" | ")}
|
||||
</p>
|
||||
@@ -1496,7 +2035,9 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
<Dialog open={presetModalOpen} onOpenChange={setPresetModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">조건 저장</DialogTitle>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{presetMode === "update" ? "조건 수정" : "신규 조건 저장"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
@@ -1532,7 +2073,7 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
onClick={savePreset}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
저장
|
||||
{presetMode === "update" ? "수정 저장" : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user