fix(pop-dashboard): 차트 X/Y축 자동 적용 및 데이터 처리 안정화

설정 패널 간소화:
- 차트 X축/Y축 수동 입력 필드 제거 (자동 적용 안내 문구로 대체)
- groupBy 선택 시 X축 자동, 집계 결과를 Y축(value)으로 자동 매핑

차트 렌더링 개선 (ChartItem):
- PieChart에 카테고리명+값+비율 라벨 표시
- Legend 컴포넌트 추가 (containerWidth 300px 이상 시)
- Tooltip formatter로 이름/값 쌍 표시

데이터 fetcher 안정화 (dataFetcher):
- apiClient(axios) 우선 호출, dashboardApi(fetch) 폴백 패턴 적용
- PostgreSQL bigint/numeric 문자열 -> 숫자 자동 변환 처리
- Recharts가 숫자 타입을 요구하는 문제 해결

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim 2026-02-10 16:55:34 +09:00
parent 578cca2687
commit 7a71fc6ca7
3 changed files with 56 additions and 54 deletions

View File

@ -1289,51 +1289,10 @@ function ItemEditor({
</Select>
</div>
{/* X축 컬럼 */}
<div>
<Label className="text-xs">X축 </Label>
<Input
value={item.chartConfig?.xAxisColumn ?? ""}
onChange={(e) =>
onUpdate({
...item,
chartConfig: {
...item.chartConfig,
chartType: item.chartConfig?.chartType ?? "bar",
xAxisColumn: e.target.value || undefined,
},
})
}
placeholder="groupBy 컬럼명 (비우면 자동)"
className="h-8 text-xs"
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">
. groupBy
</p>
</div>
{/* Y축 컬럼 */}
<div>
<Label className="text-xs">Y축 </Label>
<Input
value={item.chartConfig?.yAxisColumn ?? ""}
onChange={(e) =>
onUpdate({
...item,
chartConfig: {
...item.chartConfig,
chartType: item.chartConfig?.chartType ?? "bar",
yAxisColumn: e.target.value || undefined,
},
})
}
placeholder="집계 결과 컬럼명 (비우면 자동)"
className="h-8 text-xs"
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">
.
</p>
</div>
{/* X축/Y축 자동 안내 */}
<p className="text-[10px] text-muted-foreground">
X축: 그룹핑(X축) / Y축: 집계 (value)
</p>
</div>
)}

View File

@ -19,6 +19,7 @@ import {
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import type { DashboardItem } from "../../types";
@ -124,7 +125,7 @@ export function ChartItemComponent({
/>
</LineChart>
) : (
/* pie */
/* pie - 카테고리명 + 값 라벨 표시 */
<PieChart>
<Pie
data={rows as Record<string, string | number>[]}
@ -132,8 +133,14 @@ export function ChartItemComponent({
nameKey={xKey}
cx="50%"
cy="50%"
outerRadius="80%"
label={containerWidth > 250}
outerRadius={containerWidth > 400 ? "70%" : "80%"}
label={
containerWidth > 250
? ({ name, value, percent }: { name: string; value: number; percent: number }) =>
`${name} ${value} (${(percent * 100).toFixed(0)}%)`
: false
}
labelLine={containerWidth > 250}
>
{rows.map((_, index) => (
<Cell
@ -142,7 +149,15 @@ export function ChartItemComponent({
/>
))}
</Pie>
<Tooltip />
<Tooltip
formatter={(value: number, name: string) => [value, name]}
/>
{containerWidth > 300 && (
<Legend
wrapperStyle={{ fontSize: 11 }}
iconSize={10}
/>
)}
</PieChart>
)}
</ResponsiveContainer>

View File

@ -10,6 +10,7 @@
* - fetch 금지: 반드시 dashboardApi/dataApi
*/
import { apiClient } from "@/lib/api/client";
import { dashboardApi } from "@/lib/api/dashboard";
import { dataApi } from "@/lib/api/data";
import { tableManagementApi } from "@/lib/api/tableManagement";
@ -238,19 +239,46 @@ export async function fetchAggregatedData(
// 집계 또는 조인이 있으면 SQL 직접 실행
if (config.aggregation || (config.joins && config.joins.length > 0)) {
const sql = buildAggregationSQL(config);
const result = await dashboardApi.executeQuery(sql);
if (result.rows.length === 0) {
// API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백
let queryResult: { columns: string[]; rows: any[] };
try {
// 1차: apiClient (axios 기반, 인증/세션 안정적)
const response = await apiClient.post("/dashboards/execute-query", { query: sql });
if (response.data?.success && response.data?.data) {
queryResult = response.data.data;
} else {
throw new Error(response.data?.message || "쿼리 실행 실패");
}
} catch {
// 2차: dashboardApi (fetch 기반, 폴백)
queryResult = await dashboardApi.executeQuery(sql);
}
if (queryResult.rows.length === 0) {
return { value: 0, rows: [] };
}
// PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨
// Recharts PieChart 등은 숫자 타입이 필수이므로 변환 처리
const processedRows = queryResult.rows.map((row: Record<string, unknown>) => {
const converted: Record<string, unknown> = { ...row };
for (const key of Object.keys(converted)) {
const val = converted[key];
if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
converted[key] = Number(val);
}
}
return converted;
});
// 첫 번째 행의 value 컬럼 추출
const firstRow = result.rows[0];
const numericValue = parseFloat(firstRow.value ?? firstRow[result.columns[0]] ?? 0);
const firstRow = processedRows[0];
const numericValue = parseFloat(String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0));
return {
value: Number.isFinite(numericValue) ? numericValue : 0,
rows: result.rows,
rows: processedRows,
};
}