리스트 위젯 업데이트 #145

Merged
hjlee merged 2 commits from lhj into main 2025-10-24 12:21:15 +09:00
3 changed files with 363 additions and 131 deletions

View File

@ -324,6 +324,8 @@ export interface YardManagementConfig {
// 사용자 커스텀 카드 설정
export interface CustomMetricConfig {
groupByMode?: boolean; // 그룹별 카드 생성 모드 (기본: false)
groupByDataSource?: ChartDataSource; // 그룹별 카드 전용 데이터 소스 (선택사항)
metrics: Array<{
id: string; // 고유 ID
field: string; // 집계할 컬럼명

View File

@ -37,8 +37,13 @@ export default function CustomMetricConfigSidebar({
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [customTitle, setCustomTitle] = useState<string>(element.customTitle || element.title || "");
const [showHeader, setShowHeader] = useState<boolean>(element.showHeader !== false);
const [groupByMode, setGroupByMode] = useState<boolean>(element.customMetricConfig?.groupByMode || false);
const [groupByDataSource, setGroupByDataSource] = useState<ChartDataSource | undefined>(
element.customMetricConfig?.groupByDataSource,
);
const [groupByQueryColumns, setGroupByQueryColumns] = useState<string[]>([]);
// 쿼리 실행 결과 처리
// 쿼리 실행 결과 처리 (일반 지표용)
const handleQueryTest = (result: any) => {
// QueryEditor에서 오는 경우: { success: true, data: { columns: [...], rows: [...] } }
if (result.success && result.data?.columns) {
@ -54,6 +59,17 @@ export default function CustomMetricConfigSidebar({
}
};
// 쿼리 실행 결과 처리 (그룹별 카드용)
const handleGroupByQueryTest = (result: any) => {
if (result.success && result.data?.columns) {
setGroupByQueryColumns(result.data.columns);
} else if (result.columns && Array.isArray(result.columns)) {
setGroupByQueryColumns(result.columns);
} else {
setGroupByQueryColumns([]);
}
};
// 메트릭 추가
const addMetric = () => {
const newMetric = {
@ -135,12 +151,20 @@ export default function CustomMetricConfigSidebar({
setQueryColumns([]);
};
// 그룹별 데이터 소스 업데이트
const handleGroupByDataSourceUpdate = (updates: Partial<ChartDataSource>) => {
const newDataSource = { ...groupByDataSource, ...updates } as ChartDataSource;
setGroupByDataSource(newDataSource);
};
// 저장
const handleSave = () => {
onApply({
customTitle: customTitle,
showHeader: showHeader,
customMetricConfig: {
groupByMode,
groupByDataSource: groupByMode ? groupByDataSource : undefined,
metrics,
},
});
@ -250,17 +274,21 @@ export default function CustomMetricConfigSidebar({
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
)}
{/* 지표 설정 섹션 - 쿼리 실행 후에만 표시 */}
{queryColumns.length > 0 && (
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="text-[10px] font-semibold tracking-wide text-gray-500 uppercase"></div>
{/* 일반 지표 설정 (항상 표시) */}
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
{queryColumns.length > 0 && (
<Button size="sm" variant="outline" className="h-7 gap-1 text-xs" onClick={addMetric}>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
</div>
{queryColumns.length === 0 ? (
<p className="text-xs text-gray-500"> </p>
) : (
<div className="space-y-2">
{metrics.length === 0 ? (
<p className="text-xs text-gray-500"> </p>
@ -410,6 +438,65 @@ export default function CustomMetricConfigSidebar({
))
)}
</div>
)}
</div>
{/* 그룹별 카드 생성 모드 (항상 표시) */}
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<label className="text-xs font-medium text-gray-900"> </label>
<p className="mt-0.5 text-[9px] text-gray-500">
</p>
</div>
<button
onClick={() => {
setGroupByMode(!groupByMode);
if (!groupByMode && !groupByDataSource) {
// 그룹별 모드 활성화 시 기본 데이터 소스 초기화
setGroupByDataSource({ type: "database", connectionType: "current", refreshInterval: 0 });
}
}}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
groupByMode ? "bg-primary" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
groupByMode ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
</div>
{groupByMode && (
<div className="rounded-md bg-blue-50 p-2 text-[9px] text-blue-700">
<p className="font-medium">💡 </p>
<ul className="mt-1 space-y-0.5 pl-3 text-[8px]">
<li> 컬럼: 카드 </li>
<li> 컬럼: 카드 </li>
<li> : SELECT status, COUNT(*) FROM drivers GROUP BY status</li>
<li> <strong> </strong> ( )</li>
</ul>
</div>
)}
</div>
</div>
{/* 그룹별 카드 전용 쿼리 (활성화 시에만 표시) */}
{groupByMode && groupByDataSource && (
<div className="rounded-lg bg-white p-3 shadow-sm">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">
</div>
<DatabaseConfig dataSource={groupByDataSource} onChange={handleGroupByDataSourceUpdate} />
<QueryEditor
dataSource={groupByDataSource}
onDataSourceChange={handleGroupByDataSourceUpdate}
onQueryTest={handleGroupByQueryTest}
/>
</div>
)}
</div>

View File

@ -45,8 +45,10 @@ const colorMap = {
export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) {
const [metrics, setMetrics] = useState<any[]>([]);
const [groupedCards, setGroupedCards] = useState<Array<{ label: string; value: number }>>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isGroupByMode = element?.customMetricConfig?.groupByMode || false;
useEffect(() => {
loadData();
@ -61,136 +63,236 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
setLoading(true);
setError(null);
// 데이터 소스 타입 확인
const dataSourceType = element?.dataSource?.type;
// 설정이 없으면 초기 상태로 반환
if (!element?.customMetricConfig?.metrics) {
setMetrics([]);
setLoading(false);
return;
// 그룹별 카드 데이터 로드
if (isGroupByMode && element?.customMetricConfig?.groupByDataSource) {
await loadGroupByData();
}
// Database 타입
if (dataSourceType === "database") {
if (!element?.dataSource?.query) {
setMetrics([]);
setLoading(false);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
connectionId: element.dataSource.connectionId,
}),
});
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
const rows = result.data.rows;
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
const value = calculateMetric(rows, metric.field, metric.aggregation);
return {
...metric,
calculatedValue: value,
};
});
setMetrics(calculatedMetrics);
} else {
throw new Error(result.message || "데이터 로드 실패");
}
}
// API 타입
else if (dataSourceType === "api") {
if (!element?.dataSource?.endpoint) {
setMetrics([]);
setLoading(false);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
method: element.dataSource.method || "GET",
url: element.dataSource.endpoint,
headers: element.dataSource.headers || {},
body: element.dataSource.body,
authType: element.dataSource.authType,
authConfig: element.dataSource.authConfig,
}),
});
if (!response.ok) throw new Error("API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
// API 응답 데이터 구조 확인 및 처리
let rows: any[] = [];
// result.data가 배열인 경우
if (Array.isArray(result.data)) {
rows = result.data;
}
// result.data.results가 배열인 경우 (일반적인 API 응답 구조)
else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
}
// result.data.items가 배열인 경우
else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
}
// result.data.data가 배열인 경우
else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
}
// 그 외의 경우 단일 객체를 배열로 래핑
else {
rows = [result.data];
}
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
const value = calculateMetric(rows, metric.field, metric.aggregation);
return {
...metric,
calculatedValue: value,
};
});
setMetrics(calculatedMetrics);
} else {
throw new Error("API 응답 형식 오류");
}
} else {
setMetrics([]);
setLoading(false);
// 일반 지표 데이터 로드
if (element?.customMetricConfig?.metrics && element?.customMetricConfig.metrics.length > 0) {
await loadMetricsData();
}
} catch (err) {
console.error("메트릭 로드 실패:", err);
console.error("데이터 로드 실패:", err);
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
} finally {
setLoading(false);
}
};
// 그룹별 카드 데이터 로드
const loadGroupByData = async () => {
const groupByDS = element?.customMetricConfig?.groupByDataSource;
if (!groupByDS) return;
const dataSourceType = groupByDS.type;
// Database 타입
if (dataSourceType === "database") {
if (!groupByDS.query) return;
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: groupByDS.query,
connectionType: groupByDS.connectionType || "current",
connectionId: groupByDS.connectionId,
}),
});
if (!response.ok) throw new Error("그룹별 카드 데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
const rows = result.data.rows;
if (rows.length > 0) {
const columns = result.data.columns || Object.keys(rows[0]);
const labelColumn = columns[0];
const valueColumn = columns[1];
const cards = rows.map((row) => ({
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
setGroupedCards(cards);
}
}
}
// API 타입
else if (dataSourceType === "api") {
if (!groupByDS.endpoint) return;
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
method: groupByDS.method || "GET",
url: groupByDS.endpoint,
headers: groupByDS.headers || {},
body: groupByDS.body,
authType: groupByDS.authType,
authConfig: groupByDS.authConfig,
}),
});
if (!response.ok) throw new Error("그룹별 카드 API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
let rows: any[] = [];
if (Array.isArray(result.data)) {
rows = result.data;
} else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
} else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
} else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
} else {
rows = [result.data];
}
if (rows.length > 0) {
const columns = Object.keys(rows[0]);
const labelColumn = columns[0];
const valueColumn = columns[1];
const cards = rows.map((row) => ({
label: String(row[labelColumn] || ""),
value: parseFloat(row[valueColumn]) || 0,
}));
setGroupedCards(cards);
}
}
}
};
// 일반 지표 데이터 로드
const loadMetricsData = async () => {
const dataSourceType = element?.dataSource?.type;
// Database 타입
if (dataSourceType === "database") {
if (!element?.dataSource?.query) {
setMetrics([]);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/execute-query"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
query: element.dataSource.query,
connectionType: element.dataSource.connectionType || "current",
connectionId: element.dataSource.connectionId,
}),
});
if (!response.ok) throw new Error("데이터 로딩 실패");
const result = await response.json();
if (result.success && result.data?.rows) {
const rows = result.data.rows;
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
const value = calculateMetric(rows, metric.field, metric.aggregation);
return {
...metric,
calculatedValue: value,
};
});
setMetrics(calculatedMetrics);
} else {
throw new Error(result.message || "데이터 로드 실패");
}
}
// API 타입
else if (dataSourceType === "api") {
if (!element?.dataSource?.endpoint) {
setMetrics([]);
return;
}
const token = localStorage.getItem("authToken");
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
method: element.dataSource.method || "GET",
url: element.dataSource.endpoint,
headers: element.dataSource.headers || {},
body: element.dataSource.body,
authType: element.dataSource.authType,
authConfig: element.dataSource.authConfig,
}),
});
if (!response.ok) throw new Error("API 호출 실패");
const result = await response.json();
if (result.success && result.data) {
// API 응답 데이터 구조 확인 및 처리
let rows: any[] = [];
// result.data가 배열인 경우
if (Array.isArray(result.data)) {
rows = result.data;
}
// result.data.results가 배열인 경우 (일반적인 API 응답 구조)
else if (result.data.results && Array.isArray(result.data.results)) {
rows = result.data.results;
}
// result.data.items가 배열인 경우
else if (result.data.items && Array.isArray(result.data.items)) {
rows = result.data.items;
}
// result.data.data가 배열인 경우
else if (result.data.data && Array.isArray(result.data.data)) {
rows = result.data.data;
}
// 그 외의 경우 단일 객체를 배열로 래핑
else {
rows = [result.data];
}
const calculatedMetrics = element.customMetricConfig.metrics.map((metric) => {
const value = calculateMetric(rows, metric.field, metric.aggregation);
return {
...metric,
calculatedValue: value,
};
});
setMetrics(calculatedMetrics);
} else {
throw new Error("API 응답 형식 오류");
}
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center bg-white">
@ -218,12 +320,26 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
);
}
// 데이터 소스가 없거나 설정이 없는 경우
const hasDataSource =
// 데이터 소스 체크
const hasMetricsDataSource =
(element?.dataSource?.type === "database" && element?.dataSource?.query) ||
(element?.dataSource?.type === "api" && element?.dataSource?.endpoint);
if (!hasDataSource || !element?.customMetricConfig?.metrics || metrics.length === 0) {
const hasGroupByDataSource =
isGroupByMode &&
element?.customMetricConfig?.groupByDataSource &&
((element.customMetricConfig.groupByDataSource.type === "database" &&
element.customMetricConfig.groupByDataSource.query) ||
(element.customMetricConfig.groupByDataSource.type === "api" &&
element.customMetricConfig.groupByDataSource.endpoint));
const hasMetricsConfig = element?.customMetricConfig?.metrics && element.customMetricConfig.metrics.length > 0;
// 둘 다 없으면 빈 화면 표시
const shouldShowEmpty =
(!hasGroupByDataSource && !hasMetricsConfig) || (!hasGroupByDataSource && !hasMetricsDataSource);
if (shouldShowEmpty) {
return (
<div className="flex h-full items-center justify-center bg-white p-4">
<div className="max-w-xs space-y-2 text-center">
@ -235,11 +351,21 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
<li> </li>
<li> COUNT, SUM, AVG, MIN, MAX </li>
<li> </li>
<li> <strong> </strong> </li>
</ul>
</div>
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
<p className="font-medium"> </p>
<p>SQL </p>
<p className="mb-1">
{isGroupByMode
? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)"
: "SQL 쿼리를 입력하고 지표를 추가하세요"}
</p>
{isGroupByMode && (
<p className="text-[9px]">
💡 컬럼: 카드 , 컬럼: 카드
</p>
)}
</div>
</div>
</div>
@ -251,6 +377,23 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
{/* 스크롤 가능한 콘텐츠 영역 */}
<div className="flex-1 overflow-y-auto">
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
{/* 그룹별 카드 (활성화 시) */}
{isGroupByMode &&
groupedCards.map((card, index) => {
// 색상 순환 (6가지 색상)
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
const colorKey = colorKeys[index % colorKeys.length];
const colors = colorMap[colorKey];
return (
<div key={`group-${index}`} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}>
<div className="text-sm text-gray-600">{card.label}</div>
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
</div>
);
})}
{/* 일반 지표 카드 (항상 표시) */}
{metrics.map((metric) => {
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);