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:
parent
578cca2687
commit
7a71fc6ca7
|
|
@ -1289,51 +1289,10 @@ function ItemEditor({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* X축 컬럼 */}
|
{/* X축/Y축 자동 안내 */}
|
||||||
<div>
|
<p className="text-[10px] text-muted-foreground">
|
||||||
<Label className="text-xs">X축 컬럼</Label>
|
X축: 그룹핑(X축)에서 선택한 컬럼 자동 적용 / Y축: 집계 결과(value) 자동 적용
|
||||||
<Input
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { DashboardItem } from "../../types";
|
import type { DashboardItem } from "../../types";
|
||||||
|
|
@ -124,7 +125,7 @@ export function ChartItemComponent({
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
) : (
|
) : (
|
||||||
/* pie */
|
/* pie - 카테고리명 + 값 라벨 표시 */
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={rows as Record<string, string | number>[]}
|
data={rows as Record<string, string | number>[]}
|
||||||
|
|
@ -132,8 +133,14 @@ export function ChartItemComponent({
|
||||||
nameKey={xKey}
|
nameKey={xKey}
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
outerRadius="80%"
|
outerRadius={containerWidth > 400 ? "70%" : "80%"}
|
||||||
label={containerWidth > 250}
|
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) => (
|
{rows.map((_, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
|
|
@ -142,7 +149,15 @@ export function ChartItemComponent({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => [value, name]}
|
||||||
|
/>
|
||||||
|
{containerWidth > 300 && (
|
||||||
|
<Legend
|
||||||
|
wrapperStyle={{ fontSize: 11 }}
|
||||||
|
iconSize={10}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</PieChart>
|
</PieChart>
|
||||||
)}
|
)}
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
* - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용
|
* - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
|
@ -238,19 +239,46 @@ export async function fetchAggregatedData(
|
||||||
// 집계 또는 조인이 있으면 SQL 직접 실행
|
// 집계 또는 조인이 있으면 SQL 직접 실행
|
||||||
if (config.aggregation || (config.joins && config.joins.length > 0)) {
|
if (config.aggregation || (config.joins && config.joins.length > 0)) {
|
||||||
const sql = buildAggregationSQL(config);
|
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: [] };
|
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 컬럼 추출
|
// 첫 번째 행의 value 컬럼 추출
|
||||||
const firstRow = result.rows[0];
|
const firstRow = processedRows[0];
|
||||||
const numericValue = parseFloat(firstRow.value ?? firstRow[result.columns[0]] ?? 0);
|
const numericValue = parseFloat(String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
value: Number.isFinite(numericValue) ? numericValue : 0,
|
value: Number.isFinite(numericValue) ? numericValue : 0,
|
||||||
rows: result.rows,
|
rows: processedRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue