Merge branch 'feature/screen-management' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
7bbe88d7ae
|
|
@ -324,6 +324,8 @@ export interface YardManagementConfig {
|
|||
|
||||
// 사용자 커스텀 카드 설정
|
||||
export interface CustomMetricConfig {
|
||||
groupByMode?: boolean; // 그룹별 카드 생성 모드 (기본: false)
|
||||
groupByDataSource?: ChartDataSource; // 그룹별 카드 전용 데이터 소스 (선택사항)
|
||||
metrics: Array<{
|
||||
id: string; // 고유 ID
|
||||
field: string; // 집계할 컬럼명
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import { getApiUrl } from "@/lib/utils/apiUrl";
|
||||
|
||||
interface CustomMetricWidgetProps {
|
||||
element?: DashboardElement;
|
||||
|
|
@ -44,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();
|
||||
|
|
@ -60,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("/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("/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">
|
||||
|
|
@ -217,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">
|
||||
|
|
@ -234,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>
|
||||
|
|
@ -250,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);
|
||||
|
|
|
|||
|
|
@ -63,13 +63,19 @@ const TokenManager = {
|
|||
|
||||
setToken: (token: string): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
// localStorage에 저장
|
||||
localStorage.setItem("authToken", token);
|
||||
// 쿠키에도 저장 (미들웨어에서 사용)
|
||||
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
}
|
||||
},
|
||||
|
||||
removeToken: (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
// localStorage에서 제거
|
||||
localStorage.removeItem("authToken");
|
||||
// 쿠키에서도 제거
|
||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -61,11 +61,15 @@ export const useLogin = () => {
|
|||
* API 호출 공통 함수
|
||||
*/
|
||||
const apiCall = useCallback(async (endpoint: string, options: RequestInit = {}): Promise<LoginResponse> => {
|
||||
// 로컬 스토리지에서 토큰 가져오기
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
...(token && { Authorization: `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
|
|
@ -90,16 +94,19 @@ export const useLogin = () => {
|
|||
// 토큰이 있으면 API 호출로 유효성 확인
|
||||
const result = await apiCall(AUTH_CONFIG.ENDPOINTS.STATUS);
|
||||
|
||||
if (result.success && result.data?.isLoggedIn) {
|
||||
// 백엔드가 isAuthenticated 필드를 반환함
|
||||
if (result.success && result.data?.isAuthenticated) {
|
||||
// 이미 로그인된 경우 메인으로 리다이렉트
|
||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||
} else {
|
||||
// 토큰이 유효하지 않으면 제거
|
||||
localStorage.removeItem("authToken");
|
||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
} catch (error) {
|
||||
// 에러가 발생하면 토큰 제거
|
||||
localStorage.removeItem("authToken");
|
||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||
console.debug("기존 인증 체크 중 오류 (정상):", error);
|
||||
}
|
||||
}, [apiCall, router]);
|
||||
|
|
@ -128,9 +135,12 @@ export const useLogin = () => {
|
|||
});
|
||||
|
||||
if (result.success && result.data?.token) {
|
||||
// JWT 토큰 저장
|
||||
// JWT 토큰 저장 (localStorage와 쿠키 모두에 저장)
|
||||
localStorage.setItem("authToken", result.data.token);
|
||||
|
||||
// 쿠키에도 저장 (미들웨어에서 사용)
|
||||
document.cookie = `authToken=${result.data.token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
|
||||
// 로그인 성공
|
||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||
} else {
|
||||
|
|
@ -148,13 +158,10 @@ export const useLogin = () => {
|
|||
[formData, validateForm, apiCall, router],
|
||||
);
|
||||
|
||||
// 컴포넌트 마운트 시 기존 인증 상태 확인 (한 번만 실행)
|
||||
// 컴포넌트 마운트 시 기존 인증 상태 확인
|
||||
useEffect(() => {
|
||||
// 로그인 페이지에서만 실행
|
||||
if (window.location.pathname === "/login") {
|
||||
checkExistingAuth();
|
||||
}
|
||||
}, []); // 의존성 배열을 비워서 한 번만 실행
|
||||
checkExistingAuth();
|
||||
}, [checkExistingAuth]);
|
||||
|
||||
return {
|
||||
// 상태
|
||||
|
|
|
|||
|
|
@ -3,7 +3,27 @@
|
|||
*/
|
||||
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import { API_BASE_URL } from "./client";
|
||||
|
||||
// API URL 동적 설정
|
||||
function getApiBaseUrl(): string {
|
||||
// 클라이언트 사이드에서만 실행
|
||||
if (typeof window !== "undefined") {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// 프로덕션: v1.vexplor.com → https://api.vexplor.com/api
|
||||
if (hostname === "v1.vexplor.com") {
|
||||
return "https://api.vexplor.com/api";
|
||||
}
|
||||
|
||||
// 로컬 개발: localhost → http://localhost:8080/api
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
}
|
||||
|
||||
// 서버 사이드 렌더링 시 기본값
|
||||
return "/api";
|
||||
}
|
||||
|
||||
// 토큰 가져오기 (실제 인증 시스템에 맞게 수정)
|
||||
function getAuthToken(): string | null {
|
||||
|
|
@ -17,6 +37,7 @@ async function apiRequest<T>(
|
|||
options: RequestInit = {},
|
||||
): Promise<{ success: boolean; data?: T; message?: string; pagination?: any }> {
|
||||
const token = getAuthToken();
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* API URL 유틸리티
|
||||
* 프로덕션/개발 환경에 따라 올바른 API URL을 반환
|
||||
*/
|
||||
|
||||
export function getApiUrl(endpoint: string): string {
|
||||
// 클라이언트 사이드에서만 실행
|
||||
if (typeof window !== "undefined") {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// 프로덕션: v1.vexplor.com → https://api.vexplor.com
|
||||
if (hostname === "v1.vexplor.com") {
|
||||
return `https://api.vexplor.com${endpoint}`;
|
||||
}
|
||||
|
||||
// 로컬 개발: localhost → http://localhost:8080
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
return `http://localhost:8080${endpoint}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 기본값: 상대 경로
|
||||
return endpoint;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Next.js 미들웨어
|
||||
* 페이지 렌더링 전에 실행되어 인증 상태를 확인하고 리다이렉트 처리
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// 인증 토큰 확인
|
||||
const token = request.cookies.get("authToken")?.value || request.headers.get("authorization")?.replace("Bearer ", "");
|
||||
|
||||
// /login 페이지 접근 시
|
||||
if (pathname === "/login") {
|
||||
// 토큰이 있으면 메인 페이지로 리다이렉트
|
||||
if (token) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/main";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
// 토큰이 없으면 로그인 페이지 표시
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 인증이 필요한 페이지들
|
||||
const protectedPaths = ["/main", "/admin", "/dashboard", "/settings"];
|
||||
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path));
|
||||
|
||||
if (isProtectedPath && !token) {
|
||||
// 인증되지 않은 사용자는 로그인 페이지로 리다이렉트
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 미들웨어가 실행될 경로 설정
|
||||
*/
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* 다음 경로를 제외한 모든 요청에 대해 실행:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public 폴더의 파일들
|
||||
*/
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.svg$).*)",
|
||||
],
|
||||
};
|
||||
Loading…
Reference in New Issue