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:
DDD1542
2026-04-16 12:08:28 +09:00
parent 2e8350c0f6
commit 623cbc0b61
22 changed files with 1411 additions and 391 deletions

View File

@@ -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>