diff --git a/backend-node/src/controllers/DashboardController.ts b/backend-node/src/controllers/DashboardController.ts index cf8f3cc2..0f6f07cc 100644 --- a/backend-node/src/controllers/DashboardController.ts +++ b/backend-node/src/controllers/DashboardController.ts @@ -547,4 +547,93 @@ export class DashboardController { }); } } + + /** + * 테이블 스키마 조회 (날짜 컬럼 감지용) + * POST /api/dashboards/table-schema + */ + async getTableSchema( + req: AuthenticatedRequest, + res: Response + ): Promise { + try { + const { tableName } = req.body; + + if (!tableName || typeof tableName !== "string") { + res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + return; + } + + // 테이블명 검증 (SQL 인젝션 방지) + if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) { + res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + }); + return; + } + + // PostgreSQL information_schema에서 컬럼 정보 조회 + const query = ` + SELECT + column_name, + data_type, + udt_name + FROM information_schema.columns + WHERE table_name = $1 + ORDER BY ordinal_position + `; + + const result = await PostgreSQLService.query(query, [ + tableName.toLowerCase(), + ]); + + // 날짜/시간 타입 컬럼 필터링 + const dateColumns = result.rows + .filter((row: any) => { + const dataType = row.data_type?.toLowerCase(); + const udtName = row.udt_name?.toLowerCase(); + return ( + dataType === "timestamp" || + dataType === "timestamp without time zone" || + dataType === "timestamp with time zone" || + dataType === "date" || + dataType === "time" || + dataType === "time without time zone" || + dataType === "time with time zone" || + udtName === "timestamp" || + udtName === "timestamptz" || + udtName === "date" || + udtName === "time" || + udtName === "timetz" + ); + }) + .map((row: any) => row.column_name); + + res.status(200).json({ + success: true, + data: { + tableName, + columns: result.rows.map((row: any) => ({ + name: row.column_name, + type: row.data_type, + udtName: row.udt_name, + })), + dateColumns, + }, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "테이블 스키마 조회 중 오류가 발생했습니다.", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : "스키마 조회 오류", + }); + } + } } diff --git a/backend-node/src/routes/dashboardRoutes.ts b/backend-node/src/routes/dashboardRoutes.ts index 7ed7d634..87db696b 100644 --- a/backend-node/src/routes/dashboardRoutes.ts +++ b/backend-node/src/routes/dashboardRoutes.ts @@ -36,6 +36,12 @@ router.post( dashboardController.fetchExternalApi.bind(dashboardController) ); +// 테이블 스키마 조회 (날짜 컬럼 감지용) +router.post( + "/table-schema", + dashboardController.getTableSchema.bind(dashboardController) +); + // 인증이 필요한 라우트들 router.use(authenticateToken); diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 8257a238..b9675147 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -19,6 +19,9 @@ services: - CORS_CREDENTIALS=true - LOG_LEVEL=debug - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure + - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA + - ITS_API_KEY=${ITS_API_KEY:-} + - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} volumes: - ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영 - /app/node_modules diff --git a/frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx b/frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx new file mode 100644 index 00000000..92220b6c --- /dev/null +++ b/frontend/app/(main)/admin/dashboard/edit/[id]/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import { use } from "react"; +import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +/** + * 대시보드 편집 페이지 + * - 기존 대시보드 편집 + */ +export default function DashboardEditPage({ params }: PageProps) { + const { id } = use(params); + + return ( +
+ +
+ ); +} diff --git a/frontend/app/(main)/admin/dashboard/new/page.tsx b/frontend/app/(main)/admin/dashboard/new/page.tsx new file mode 100644 index 00000000..d2f2ce11 --- /dev/null +++ b/frontend/app/(main)/admin/dashboard/new/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import React from "react"; +import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner"; + +/** + * 새 대시보드 생성 페이지 + */ +export default function DashboardNewPage() { + return ( +
+ +
+ ); +} diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index cd65cd8a..dcf81963 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,18 +1,236 @@ -'use client'; +"use client"; -import React from 'react'; -import DashboardDesigner from '@/components/admin/dashboard/DashboardDesigner'; +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { Dashboard } from "@/lib/api/dashboard"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Plus, Search, MoreVertical, Edit, Trash2, Copy, Eye } from "lucide-react"; /** * 대시보드 관리 페이지 - * - 드래그 앤 드롭으로 대시보드 레이아웃 설계 - * - 차트 및 위젯 배치 관리 - * - 독립적인 컴포넌트로 구성되어 다른 시스템에 영향 없음 + * - 대시보드 목록 조회 + * - 대시보드 생성/수정/삭제/복사 */ -export default function DashboardPage() { +export default function DashboardListPage() { + const router = useRouter(); + const [dashboards, setDashboards] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [error, setError] = useState(null); + + // 대시보드 목록 로드 + const loadDashboards = async () => { + try { + setLoading(true); + setError(null); + const result = await dashboardApi.getMyDashboards({ search: searchTerm }); + setDashboards(result.dashboards); + } catch (err) { + console.error("Failed to load dashboards:", err); + setError("대시보드 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadDashboards(); + }, [searchTerm]); + + // 대시보드 삭제 + const handleDelete = async (id: string, title: string) => { + if (!confirm(`"${title}" 대시보드를 삭제하시겠습니까?`)) { + return; + } + + try { + await dashboardApi.deleteDashboard(id); + alert("대시보드가 삭제되었습니다."); + loadDashboards(); + } catch (err) { + console.error("Failed to delete dashboard:", err); + alert("대시보드 삭제에 실패했습니다."); + } + }; + + // 대시보드 복사 + const handleCopy = async (dashboard: Dashboard) => { + try { + const newDashboard = await dashboardApi.createDashboard({ + title: `${dashboard.title} (복사본)`, + description: dashboard.description, + elements: dashboard.elements || [], + isPublic: false, + tags: dashboard.tags, + category: dashboard.category, + }); + alert("대시보드가 복사되었습니다."); + loadDashboards(); + } catch (err) { + console.error("Failed to copy dashboard:", err); + alert("대시보드 복사에 실패했습니다."); + } + }; + + // 포맷팅 헬퍼 + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + }; + + if (loading) { + return ( +
+
+
로딩 중...
+
대시보드 목록을 불러오고 있습니다
+
+
+ ); + } + return ( -
- +
+
+ {/* 헤더 */} +
+

대시보드 관리

+

대시보드를 생성하고 관리할 수 있습니다

+
+ + {/* 액션 바 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-9" + /> +
+ +
+ + {/* 에러 메시지 */} + {error && ( + +

{error}

+
+ )} + + {/* 대시보드 목록 */} + {dashboards.length === 0 ? ( + +
+ +
+

대시보드가 없습니다

+

첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요

+ +
+ ) : ( + + + + + 제목 + 설명 + 요소 수 + 상태 + 생성일 + 수정일 + 작업 + + + + {dashboards.map((dashboard) => ( + + +
+ {dashboard.title} + {dashboard.isPublic && ( + + 공개 + + )} +
+
+ + {dashboard.description || "-"} + + + {dashboard.elementsCount || 0}개 + + + {dashboard.isPublic ? ( + 공개 + ) : ( + 비공개 + )} + + {formatDate(dashboard.createdAt)} + {formatDate(dashboard.updatedAt)} + + + + + + + router.push(`/dashboard/${dashboard.id}`)} className="gap-2"> + + 보기 + + router.push(`/admin/dashboard/edit/${dashboard.id}`)} + className="gap-2" + > + + 편집 + + handleCopy(dashboard)} className="gap-2"> + + 복사 + + handleDelete(dashboard.id, dashboard.title)} + className="gap-2 text-red-600 focus:text-red-600" + > + + 삭제 + + + + +
+ ))} +
+
+
+ )} +
); } diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 92a39cb5..5c75acb7 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -37,11 +37,42 @@ const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widget loading: () =>
로딩 중...
, }); -const DeliveryStatusWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusWidget"), { +// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합) +const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), { ssr: false, loading: () =>
로딩 중...
, }); +// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합) +const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); + +// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석 +/* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), { + ssr: false, + loading: () =>
로딩 중...
, +}); */ + +// 개별 위젯들 (주석 처리 - StatusSummaryWidget으로 통합됨) +// const DeliveryStatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusSummaryWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); +// const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), { +// ssr: false, +// loading: () =>
로딩 중...
, +// }); + const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), { ssr: false, loading: () =>
로딩 중...
, @@ -295,13 +326,17 @@ export function CanvasElement({ try { let result; + // 필터 적용 (날짜 필터 등) + const { applyQueryFilters } = await import("./utils/queryHelpers"); + const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig); + // 외부 DB vs 현재 DB 분기 if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { // 외부 DB const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const externalResult = await ExternalDbConnectionAPI.executeQuery( parseInt(element.dataSource.externalConnectionId), - element.dataSource.query, + filteredQuery, ); if (!externalResult.success) { @@ -317,7 +352,7 @@ export function CanvasElement({ } else { // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); - result = await dashboardApi.executeQuery(element.dataSource.query); + result = await dashboardApi.executeQuery(filteredQuery); setChartData({ columns: result.columns || [], @@ -336,6 +371,7 @@ export function CanvasElement({ element.dataSource?.query, element.dataSource?.connectionType, element.dataSource?.externalConnectionId, + element.chartConfig, element.type, ]); @@ -496,15 +532,86 @@ export function CanvasElement({
+ ) : element.type === "widget" && element.subtype === "map-summary" ? ( + // 커스텀 지도 카드 - 범용 위젯 +
+ +
) : element.type === "widget" && element.subtype === "vehicle-map" ? ( - // 차량 위치 지도 위젯 렌더링 + // 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
- ) : element.type === "widget" && element.subtype === "delivery-status" ? ( - // 배송/화물 현황 위젯 렌더링 + ) : element.type === "widget" && element.subtype === "status-summary" ? ( + // 커스텀 상태 카드 - 범용 위젯
- + +
+ ) : /* element.type === "widget" && element.subtype === "list-summary" ? ( + // 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석) +
+ +
+ ) : */ element.type === "widget" && element.subtype === "delivery-status" ? ( + // 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환) +
+ +
+ ) : element.type === "widget" && element.subtype === "delivery-status-summary" ? ( + // 배송 상태 요약 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "delivery-today-stats" ? ( + // 오늘 처리 현황 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "cargo-list" ? ( + // 화물 목록 - 범용 위젯 사용 +
+ +
+ ) : element.type === "widget" && element.subtype === "customer-issues" ? ( + // 고객 클레임/이슈 - 범용 위젯 사용 +
+
) : element.type === "widget" && element.subtype === "risk-alert" ? ( // 리스크/알림 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx index a7649c8a..2257848d 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useCallback } from "react"; +import React, { useState, useCallback, useEffect } from "react"; import { ChartConfig, QueryResult } from "./types"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -10,7 +10,10 @@ import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; import { Separator } from "@/components/ui/separator"; -import { TrendingUp, AlertCircle } from "lucide-react"; +import { AlertCircle } from "lucide-react"; +import { DateFilterPanel } from "./DateFilterPanel"; +import { extractTableNameFromQuery } from "./utils/queryHelpers"; +import { dashboardApi } from "@/lib/api/dashboard"; interface ChartConfigPanelProps { config?: ChartConfig; @@ -18,6 +21,7 @@ interface ChartConfigPanelProps { onConfigChange: (config: ChartConfig) => void; chartType?: string; dataSourceType?: "database" | "api"; // 데이터 소스 타입 + query?: string; // SQL 쿼리 (테이블명 추출용) } /** @@ -32,8 +36,10 @@ export function ChartConfigPanel({ onConfigChange, chartType, dataSourceType, + query, }: ChartConfigPanelProps) { const [currentConfig, setCurrentConfig] = useState(config || {}); + const [dateColumns, setDateColumns] = useState([]); // 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님 const isPieChart = chartType === "pie" || chartType === "donut"; @@ -70,6 +76,34 @@ export function ChartConfigPanel({ return type === "object" || type === "array"; }); + // 테이블 스키마에서 실제 날짜 컬럼 가져오기 + useEffect(() => { + if (!query || !queryResult || dataSourceType === "api") { + // API 소스는 스키마 조회 불가 + setDateColumns([]); + return; + } + + const tableName = extractTableNameFromQuery(query); + + if (!tableName) { + setDateColumns([]); + return; + } + dashboardApi + .getTableSchema(tableName) + .then((schema) => { + // 원본 테이블의 모든 날짜 컬럼을 표시 + // (SELECT에 없어도 WHERE 절에 사용 가능) + setDateColumns(schema.dateColumns); + }) + .catch((error) => { + console.error("❌ 테이블 스키마 조회 실패:", error); + // 실패 시 빈 배열 (날짜 필터 비활성화) + setDateColumns([]); + }); + }, [query, queryResult, dataSourceType]); + return (
{/* 데이터 필드 매핑 */} @@ -80,7 +114,7 @@ export function ChartConfigPanel({
-

📋 API 응답 데이터 미리보기

+

API 응답 데이터 미리보기

총 {queryResult.totalRows}개 데이터 중 첫 번째 행:
@@ -94,7 +128,7 @@ export function ChartConfigPanel({ -
⚠️ 차트에 사용할 수 없는 컬럼 감지
+
차트에 사용할 수 없는 컬럼 감지
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
@@ -106,7 +140,7 @@ export function ChartConfigPanel({
- 💡 해결 방법: JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요. + 해결 방법: JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
예: main 또는{" "} data.items @@ -138,7 +172,7 @@ export function ChartConfigPanel({ - + {simpleColumns.map((col) => { const preview = sampleData[col]; const previewText = @@ -158,7 +192,7 @@ export function ChartConfigPanel({ {simpleColumns.length === 0 && ( -

⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

+

사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

)}
@@ -176,17 +210,14 @@ export function ChartConfigPanel({ {/* 숫자 타입 우선 표시 */} {numericColumns.length > 0 && ( <> -
✅ 숫자 타입 (권장)
+
숫자 타입 (권장)
{numericColumns.map((col) => { const isSelected = Array.isArray(currentConfig.yAxis) ? currentConfig.yAxis.includes(col) : currentConfig.yAxis === col; return ( -
+
{ @@ -226,7 +257,7 @@ export function ChartConfigPanel({ {simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && ( <> {numericColumns.length > 0 &&
} -
📝 기타 타입
+
기타 타입
{simpleColumns .filter((col) => !numericColumns.includes(col)) .map((col) => { @@ -275,7 +306,7 @@ export function ChartConfigPanel({
{simpleColumns.length === 0 && ( -

⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

+

사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.

)}

팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰) @@ -301,7 +332,7 @@ export function ChartConfigPanel({ - + 없음 - SQL에서 집계됨 합계 (SUM) - 모든 값을 더함 평균 (AVG) - 평균값 계산 @@ -311,7 +342,7 @@ export function ChartConfigPanel({

- 💡 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계) + 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)

@@ -328,7 +359,7 @@ export function ChartConfigPanel({ - + 없음 {availableColumns.map((col) => ( @@ -387,44 +418,10 @@ export function ChartConfigPanel({ - {/* 설정 미리보기 */} - -
- - 설정 미리보기 -
-
-
- X축: - {currentConfig.xAxis || "미설정"} -
-
- Y축: - - {Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 0 - ? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(", ")})` - : currentConfig.yAxis || "미설정"} - -
-
- 집계: - {currentConfig.aggregation || "없음"} -
- {currentConfig.groupBy && ( -
- 그룹핑: - {currentConfig.groupBy} -
- )} -
- 데이터 행 수: - {queryResult.rows.length}개 -
- {Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && ( -
✨ 다중 시리즈 차트가 생성됩니다!
- )} -
-
+ {/* 날짜 필터 */} + {dateColumns.length > 0 && ( + + )} {/* 필수 필드 확인 */} {!currentConfig.xAxis && ( diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index abb328b0..65ba514c 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -10,6 +10,10 @@ import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; import { GRID_CONFIG } from "./gridUtils"; +interface DashboardDesignerProps { + dashboardId?: string; +} + /** * 대시보드 설계 도구 메인 컴포넌트 * - 드래그 앤 드롭으로 차트/위젯 배치 @@ -17,27 +21,24 @@ import { GRID_CONFIG } from "./gridUtils"; * - 요소 이동, 크기 조절, 삭제 기능 * - 레이아웃 저장/불러오기 기능 */ -export default function DashboardDesigner() { +export default function DashboardDesigner({ dashboardId: initialDashboardId }: DashboardDesignerProps = {}) { const router = useRouter(); const [elements, setElements] = useState([]); const [selectedElement, setSelectedElement] = useState(null); const [elementCounter, setElementCounter] = useState(0); const [configModalElement, setConfigModalElement] = useState(null); - const [dashboardId, setDashboardId] = useState(null); + const [dashboardId, setDashboardId] = useState(initialDashboardId || null); const [dashboardTitle, setDashboardTitle] = useState(""); const [isLoading, setIsLoading] = useState(false); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("#f9fafb"); const canvasRef = useRef(null); - // URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드 + // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { - const params = new URLSearchParams(window.location.search); - const loadId = params.get("load"); - - if (loadId) { - loadDashboard(loadId); + if (initialDashboardId) { + loadDashboard(initialDashboardId); } - }, []); + }, [initialDashboardId]); // 대시보드 데이터 로드 const loadDashboard = async (id: string) => { diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 60457000..cc34feb4 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -1,14 +1,26 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import { DragData, ElementType, ElementSubtype } from "./types"; +import { ChevronDown, ChevronRight } from "lucide-react"; /** * 대시보드 사이드바 컴포넌트 * - 드래그 가능한 차트/위젯 목록 - * - 카테고리별 구분 + * - 아코디언 방식으로 카테고리별 구분 */ export function DashboardSidebar() { + const [expandedSections, setExpandedSections] = useState({ + charts: true, + widgets: true, + operations: true, + }); + + // 섹션 토글 + const toggleSection = (section: keyof typeof expandedSections) => { + setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); + }; + // 드래그 시작 처리 const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { const dragData: DragData = { type, subtype }; @@ -17,27 +29,36 @@ export function DashboardSidebar() { }; return ( -
+
{/* 차트 섹션 */} -
-

📊 차트 종류

+
+ -
- + {expandedSections.charts && ( +
+ -
+
+ )}
{/* 위젯 섹션 */} -
-

🔧 위젯 종류

+
+ -
- + {expandedSections.widgets && ( +
+ - - - + /> */} -
+
+ )}
{/* 운영/작업 지원 섹션 */} -
-

📋 운영/작업 지원

+
+ -
- - - - - -
+ {expandedSections.operations && ( +
+ + + + + +
+ )}
); @@ -253,10 +264,9 @@ function DraggableItem({ icon, title, type, subtype, className = "", onDragStart return (
onDragStart(e, type, subtype)} > - {icon} {title}
); diff --git a/frontend/components/admin/dashboard/DateFilterPanel.tsx b/frontend/components/admin/dashboard/DateFilterPanel.tsx new file mode 100644 index 00000000..3fb94bb4 --- /dev/null +++ b/frontend/components/admin/dashboard/DateFilterPanel.tsx @@ -0,0 +1,198 @@ +"use client"; + +import React from "react"; +import { ChartConfig } from "./types"; +import { Card } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Calendar, ChevronDown, ChevronUp } from "lucide-react"; +import { getQuickDateRange } from "./utils/queryHelpers"; + +interface DateFilterPanelProps { + config: ChartConfig; + dateColumns: string[]; + onChange: (updates: Partial) => void; +} + +export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPanelProps) { + const [isExpanded, setIsExpanded] = React.useState(false); + + const dateFilter = config.dateFilter || { + enabled: false, + dateColumn: dateColumns[0] || "", + startDate: "", + endDate: "", + }; + + const handleQuickRange = (range: "today" | "week" | "month" | "year") => { + const { startDate, endDate } = getQuickDateRange(range); + onChange({ + dateFilter: { + ...dateFilter, + enabled: true, + startDate, + endDate, + quickRange: range, + }, + }); + }; + + // 날짜 컬럼이 없으면 표시하지 않음 + if (dateColumns.length === 0) { + return null; + } + + return ( + +
setIsExpanded(!isExpanded)}> +
+ + + {dateFilter.enabled && 활성} +
+ {isExpanded ? : } +
+ + {isExpanded && ( +
+ {/* 필터 활성화 */} +
+ + onChange({ + dateFilter: { + ...dateFilter, + enabled: checked as boolean, + }, + }) + } + /> + +
+ + {dateFilter.enabled && ( + <> + {/* 날짜 컬럼 선택 */} +
+ + +

감지된 날짜 컬럼: {dateColumns.join(", ")}

+
+ + {/* 빠른 선택 */} +
+ +
+ + + + +
+
+ + {/* 직접 입력 */} +
+
+ + + onChange({ + dateFilter: { + ...dateFilter, + startDate: e.target.value, + quickRange: undefined, // 직접 입력 시 빠른 선택 해제 + }, + }) + } + /> +
+
+ + + onChange({ + dateFilter: { + ...dateFilter, + endDate: e.target.value, + quickRange: undefined, // 직접 입력 시 빠른 선택 해제 + }, + }) + } + /> +
+
+ + {/* 필터 정보 */} + {dateFilter.startDate && dateFilter.endDate && ( +
+ 필터 적용: {dateFilter.dateColumn} 컬럼에서 {dateFilter.startDate}부터{" "} + {dateFilter.endDate}까지 데이터를 가져옵니다. +
+ )} + + )} +
+ )} +
+ ); +} diff --git a/frontend/components/admin/dashboard/ElementConfigModal.tsx b/frontend/components/admin/dashboard/ElementConfigModal.tsx index f069e477..05c254bc 100644 --- a/frontend/components/admin/dashboard/ElementConfigModal.tsx +++ b/frontend/components/admin/dashboard/ElementConfigModal.tsx @@ -4,12 +4,12 @@ import React, { useState, useCallback, useEffect } from "react"; import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types"; import { QueryEditor } from "./QueryEditor"; import { ChartConfigPanel } from "./ChartConfigPanel"; +import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel"; import { DataSourceSelector } from "./data-sources/DataSourceSelector"; import { DatabaseConfig } from "./data-sources/DatabaseConfig"; import { ApiConfig } from "./data-sources/ApiConfig"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Progress } from "@/components/ui/progress"; import { X, ChevronLeft, ChevronRight, Save } from "lucide-react"; interface ElementConfigModalProps { @@ -31,6 +31,23 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element const [chartConfig, setChartConfig] = useState(element.chartConfig || {}); const [queryResult, setQueryResult] = useState(null); const [currentStep, setCurrentStep] = useState<1 | 2>(1); + + // 차트 설정이 필요 없는 위젯 (쿼리/API만 필요) + const isSimpleWidget = + element.subtype === "vehicle-status" || + element.subtype === "vehicle-list" || + element.subtype === "status-summary" || // 커스텀 상태 카드 + // element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석) + element.subtype === "delivery-status" || + element.subtype === "delivery-status-summary" || + element.subtype === "delivery-today-stats" || + element.subtype === "cargo-list" || + element.subtype === "customer-issues" || + element.subtype === "driver-management"; + + // 지도 위젯 (위도/경도 매핑 필요) + const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary"; + // 주석 // 모달이 열릴 때 초기화 useEffect(() => { @@ -76,6 +93,10 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element // 쿼리 테스트 결과 처리 const handleQueryTest = useCallback((result: QueryResult) => { setQueryResult(result); + + // 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋) + console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화"); + setChartConfig({}); }, []); // 다음 단계로 이동 @@ -99,6 +120,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element dataSource, chartConfig, }; + + console.log(" 저장할 element:", updatedElement); + onSave(updatedElement); onClose(); }, [element, dataSource, chartConfig, onSave, onClose]); @@ -118,21 +142,36 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element const isPieChart = element.subtype === "pie" || element.subtype === "donut"; const isApiSource = dataSource.type === "api"; - const canSave = - currentStep === 2 && - queryResult && - queryResult.rows.length > 0 && - chartConfig.xAxis && - (isPieChart || isApiSource - ? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요 - chartConfig.yAxis || - (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) || - chartConfig.aggregation === "count" - : // 일반 차트 (DB): Y축 필수 - chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); + // Y축 검증 헬퍼 + const hasYAxis = + chartConfig.yAxis && + (typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0)); + + const canSave = isSimpleWidget + ? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능 + currentStep === 2 && queryResult && queryResult.rows.length > 0 + : isMapWidget + ? // 지도 위젯: 위도/경도 매핑 필요 + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.latitudeColumn && + chartConfig.longitudeColumn + : // 차트: 기존 로직 (2단계에서 차트 설정 필요) + currentStep === 2 && + queryResult && + queryResult.rows.length > 0 && + chartConfig.xAxis && + (isPieChart || isApiSource + ? // 파이/도넛 차트 또는 REST API + chartConfig.aggregation === "count" + ? true // count는 Y축 없어도 됨 + : hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수 + : // 일반 차트 (DB): Y축 필수 + hasYAxis); return ( -
+

{element.title} 설정

- {currentStep === 1 ? "데이터 소스를 선택하세요" : "쿼리를 실행하고 차트를 설정하세요"} + {isSimpleWidget + ? "데이터 소스를 설정하세요" + : currentStep === 1 + ? "데이터 소스를 선택하세요" + : "쿼리를 실행하고 차트를 설정하세요"}

- {/* 진행 상황 표시 */} -
-
-
- 단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"} + {/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */} + {!isSimpleWidget && ( +
+
+
+ 단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"} +
- {Math.round((currentStep / 2) * 100)}% 완료
- -
+ )} {/* 단계별 내용 */}
@@ -169,7 +212,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element )} {currentStep === 2 && ( -
+
{/* 왼쪽: 데이터 설정 */}
{dataSource.type === "database" ? ( @@ -186,40 +229,53 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element )}
- {/* 오른쪽: 차트 설정 */} -
- {queryResult && queryResult.rows.length > 0 ? ( - - ) : ( -
-
-
데이터를 가져온 후 차트 설정이 표시됩니다
+ {/* 오른쪽: 설정 패널 */} + {!isSimpleWidget && ( +
+ {isMapWidget ? ( + // 지도 위젯: 위도/경도 매핑 패널 + queryResult && queryResult.rows.length > 0 ? ( + + ) : ( +
+
+
데이터를 가져온 후 지도 설정이 표시됩니다
+
+
+ ) + ) : // 차트: 차트 설정 패널 + queryResult && queryResult.rows.length > 0 ? ( + + ) : ( +
+
+
데이터를 가져온 후 차트 설정이 표시됩니다
+
-
- )} -
+ )} +
+ )}
)}
{/* 모달 푸터 */}
-
- {queryResult && ( - - 📊 {queryResult.rows.length}개 데이터 로드됨 - - )} -
+
{queryResult && {queryResult.rows.length}개 데이터 로드됨}
- {currentStep > 1 && ( + {!isSimpleWidget && currentStep > 1 && ( {currentStep === 1 ? ( + // 1단계: 다음 버튼 (모든 타입 공통) ) : ( + // 2단계: 저장 버튼 (모든 타입 공통)
- {dataSource.queryParams && Object.keys(dataSource.queryParams).length > 0 ? ( -
- {Object.entries(dataSource.queryParams).map(([key, value]) => ( -
- updateQueryParam(key, e.target.value, value)} - className="flex-1" - /> - updateQueryParam(key, key, e.target.value)} - className="flex-1" - /> - -
- ))} -
- ) : ( -

추가된 파라미터가 없습니다

- )} + {(() => { + const params = normalizeQueryParams(); + return params.length > 0 ? ( +
+ {params.map((param) => ( +
+ updateQueryParam(param.id, { key: e.target.value })} + className="flex-1" + /> + updateQueryParam(param.id, { value: e.target.value })} + className="flex-1" + /> + +
+ ))} +
+ ) : ( +

추가된 파라미터가 없습니다

+ ); + })()}

예: category=electronics, limit=10

@@ -262,8 +297,9 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps variant="outline" size="sm" onClick={() => { + const headers = normalizeHeaders(); onChange({ - headers: { ...dataSource.headers, Authorization: "Bearer YOUR_TOKEN" }, + headers: [...headers, { id: `header_${Date.now()}`, key: "Authorization", value: "" }], }); }} > @@ -273,8 +309,9 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps variant="outline" size="sm" onClick={() => { + const headers = normalizeHeaders(); onChange({ - headers: { ...dataSource.headers, "Content-Type": "application/json" }, + headers: [...headers, { id: `header_${Date.now()}`, key: "Content-Type", value: "application/json" }], }); }} > @@ -282,32 +319,35 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
- {dataSource.headers && Object.keys(dataSource.headers).length > 0 ? ( -
- {Object.entries(dataSource.headers).map(([key, value]) => ( -
- updateHeader(key, e.target.value, value)} - className="flex-1" - /> - updateHeader(key, key, e.target.value)} - className="flex-1" - type={key.toLowerCase().includes("auth") ? "password" : "text"} - /> - -
- ))} -
- ) : ( -

추가된 헤더가 없습니다

- )} + {(() => { + const headers = normalizeHeaders(); + return headers.length > 0 ? ( +
+ {headers.map((header) => ( +
+ updateHeader(header.id, { key: e.target.value })} + className="flex-1" + /> + updateHeader(header.id, { value: e.target.value })} + className="flex-1" + type={header.key.toLowerCase().includes("auth") ? "password" : "text"} + /> + +
+ ))} +
+ ) : ( +

추가된 헤더가 없습니다

+ ); + })()} {/* JSON Path */} @@ -358,7 +398,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps {/* 테스트 결과 */} {testResult && ( -
✅ API 호출 성공
+
API 호출 성공
총 {testResult.rows.length}개의 데이터를 불러왔습니다
컬럼: {testResult.columns.join(", ")}
diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 6d08fa14..833c033a 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -19,11 +19,18 @@ export type ElementSubtype = | "calendar" | "calculator" | "vehicle-status" - | "vehicle-list" - | "vehicle-map" + | "vehicle-list" // (구버전 - 호환용) + | "vehicle-map" // (구버전 - 호환용) + | "map-summary" // 범용 지도 카드 (통합) | "delivery-status" + | "status-summary" // 범용 상태 카드 (통합) + // | "list-summary" // 범용 목록 카드 (다른 분 작업 중 - 임시 주석) + | "delivery-status-summary" // (구버전 - 호환용) + | "delivery-today-stats" // (구버전 - 호환용) + | "cargo-list" // (구버전 - 호환용) + | "customer-issues" // (구버전 - 호환용) | "risk-alert" - | "driver-management" + | "driver-management" // (구버전 - 호환용) | "todo" | "booking-alert" | "maintenance" @@ -66,6 +73,13 @@ export interface ResizeHandle { cursor: string; } +// 키-값 쌍 인터페이스 +export interface KeyValuePair { + id: string; // 고유 ID + key: string; // 키 + value: string; // 값 +} + export interface ChartDataSource { type: "database" | "api"; // 데이터 소스 타입 @@ -77,8 +91,8 @@ export interface ChartDataSource { // API 관련 endpoint?: string; // API URL method?: "GET"; // HTTP 메서드 (GET만 지원) - headers?: Record; // 커스텀 헤더 - queryParams?: Record; // URL 쿼리 파라미터 + headers?: KeyValuePair[]; // 커스텀 헤더 (배열) + queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열) jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results") // 공통 @@ -99,6 +113,18 @@ export interface ChartConfig { sortOrder?: "asc" | "desc"; // 정렬 순서 limit?: number; // 데이터 개수 제한 + // 데이터 필터 + dateFilter?: { + enabled: boolean; // 날짜 필터 활성화 + dateColumn?: string; // 날짜 컬럼 + startDate?: string; // 시작일 (YYYY-MM-DD) + endDate?: string; // 종료일 (YYYY-MM-DD) + quickRange?: "today" | "week" | "month" | "year"; // 빠른 선택 + }; + + // 안전장치 + autoLimit?: number; // 자동 LIMIT (기본: 1000) + // 스타일 colors?: string[]; // 차트 색상 팔레트 title?: string; // 차트 제목 @@ -112,6 +138,9 @@ export interface ChartConfig { // 애니메이션 enableAnimation?: boolean; // 애니메이션 활성화 + + // 상태 필터링 (커스텀 상태 카드용) + statusFilter?: string[]; // 표시할 상태 목록 (예: ["driving", "parked"]) animationDuration?: number; // 애니메이션 시간 (ms) // 툴팁 diff --git a/frontend/components/admin/dashboard/utils/queryHelpers.ts b/frontend/components/admin/dashboard/utils/queryHelpers.ts new file mode 100644 index 00000000..b5220eb4 --- /dev/null +++ b/frontend/components/admin/dashboard/utils/queryHelpers.ts @@ -0,0 +1,248 @@ +import { ChartConfig } from "../types"; + +/** + * 쿼리에 안전장치 LIMIT 추가 + */ +export function applySafetyLimit(query: string, limit: number = 1000): string { + const trimmedQuery = query.trim(); + + // 이미 LIMIT이 있으면 그대로 반환 + if (/\bLIMIT\b/i.test(trimmedQuery)) { + return trimmedQuery; + } + + return `${trimmedQuery} LIMIT ${limit}`; +} + +/** + * 날짜 필터를 쿼리에 적용 + */ +export function applyDateFilter(query: string, dateColumn: string, startDate?: string, endDate?: string): string { + if (!dateColumn || (!startDate && !endDate)) { + return query; + } + + const conditions: string[] = []; + + // NULL 값 제외 조건 추가 (필수) + conditions.push(`${dateColumn} IS NOT NULL`); + + if (startDate) { + conditions.push(`${dateColumn} >= '${startDate}'`); + } + + if (endDate) { + // 종료일은 해당 날짜의 23:59:59까지 포함 + conditions.push(`${dateColumn} <= '${endDate} 23:59:59'`); + } + + if (conditions.length === 0) { + return query; + } + + // FROM 절 이후의 WHERE, GROUP BY, ORDER BY, LIMIT 위치 파악 + // 줄바꿈 제거하여 한 줄로 만들기 (정규식 매칭을 위해) + let baseQuery = query.trim().replace(/\s+/g, " "); + let whereClause = ""; + let groupByClause = ""; + let orderByClause = ""; + let limitClause = ""; + + // LIMIT 추출 + const limitMatch = baseQuery.match(/\s+LIMIT\s+\d+\s*$/i); + if (limitMatch) { + limitClause = limitMatch[0]; + baseQuery = baseQuery.substring(0, limitMatch.index); + } + + // ORDER BY 추출 + const orderByMatch = baseQuery.match(/\s+ORDER\s+BY\s+.+$/i); + if (orderByMatch) { + orderByClause = orderByMatch[0]; + baseQuery = baseQuery.substring(0, orderByMatch.index); + } + + // GROUP BY 추출 + const groupByMatch = baseQuery.match(/\s+GROUP\s+BY\s+.+$/i); + if (groupByMatch) { + groupByClause = groupByMatch[0]; + baseQuery = baseQuery.substring(0, groupByMatch.index); + } + + // WHERE 추출 (있으면) + const whereMatch = baseQuery.match(/\s+WHERE\s+.+$/i); + if (whereMatch) { + whereClause = whereMatch[0]; + baseQuery = baseQuery.substring(0, whereMatch.index); + } + + // 날짜 필터 조건 추가 + const filterCondition = conditions.join(" AND "); + if (whereClause) { + // 기존 WHERE 절이 있으면 AND로 연결 + whereClause = `${whereClause} AND ${filterCondition}`; + } else { + // WHERE 절이 없으면 새로 생성 + whereClause = ` WHERE ${filterCondition}`; + } + + // 쿼리 재조립 + const finalQuery = `${baseQuery}${whereClause}${groupByClause}${orderByClause}${limitClause}`; + return finalQuery; +} + +/** + * 빠른 날짜 범위 계산 + */ +export function getQuickDateRange(range: "today" | "week" | "month" | "year"): { + startDate: string; + endDate: string; +} { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + switch (range) { + case "today": + return { + startDate: today.toISOString().split("T")[0], + endDate: today.toISOString().split("T")[0], + }; + + case "week": { + const weekStart = new Date(today); + weekStart.setDate(today.getDate() - today.getDay()); // 일요일부터 + return { + startDate: weekStart.toISOString().split("T")[0], + endDate: today.toISOString().split("T")[0], + }; + } + + case "month": { + const monthStart = new Date(today.getFullYear(), today.getMonth(), 1); + return { + startDate: monthStart.toISOString().split("T")[0], + endDate: today.toISOString().split("T")[0], + }; + } + + case "year": { + const yearStart = new Date(today.getFullYear(), 0, 1); + return { + startDate: yearStart.toISOString().split("T")[0], + endDate: today.toISOString().split("T")[0], + }; + } + + default: + return { + startDate: "", + endDate: "", + }; + } +} + +/** + * 쿼리에서 테이블명 추출 + */ +export function extractTableNameFromQuery(query: string): string | null { + const trimmedQuery = query.trim().toLowerCase(); + + // FROM 절 찾기 + const fromMatch = trimmedQuery.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i); + if (fromMatch) { + return fromMatch[1]; + } + + return null; +} + +/** + * 날짜 컬럼 자동 감지 (서버에서 테이블 스키마 조회 필요) + * 이 함수는 쿼리 결과가 아닌, 원본 테이블의 실제 컬럼 타입을 확인해야 합니다. + * + * 현재는 임시로 컬럼명 기반 추측만 수행합니다. + */ +export function detectDateColumns(columns: string[], rows: Record[]): string[] { + const dateColumns: string[] = []; + + // 컬럼명 기반 추측 (가장 안전한 방법) + columns.forEach((col) => { + const lowerCol = col.toLowerCase(); + if ( + lowerCol.includes("date") || + lowerCol.includes("time") || + lowerCol.includes("created") || + lowerCol.includes("updated") || + lowerCol.includes("modified") || + lowerCol === "reg_date" || + lowerCol === "regdate" || + lowerCol === "update_date" || + lowerCol === "updatedate" || + lowerCol.endsWith("_at") || // created_at, updated_at + lowerCol.endsWith("_date") || // birth_date, start_date + lowerCol.endsWith("_time") // start_time, end_time + ) { + dateColumns.push(col); + } + }); + + // 데이터가 있는 경우, 실제 값도 확인 (추가 검증) + if (rows.length > 0 && dateColumns.length > 0) { + const firstRow = rows[0]; + + // 컬럼명으로 감지된 것들 중에서 실제 날짜 형식인지 재확인 + const validatedColumns = dateColumns.filter((col) => { + const value = firstRow[col]; + + // null이면 스킵 (판단 불가) + if (value == null) return true; + + // Date 객체면 확실히 날짜 + if (value instanceof Date) return true; + + // 문자열이고 날짜 형식이면 날짜 + if (typeof value === "string") { + const parsed = Date.parse(value); + if (!isNaN(parsed)) return true; + } + + // 숫자면 날짜가 아님 (타임스탬프 제외) + if (typeof value === "number") { + // 타임스탬프인지 확인 (1970년 이후의 밀리초 또는 초) + if (value > 946684800000 || (value > 946684800 && value < 2147483647)) { + return true; + } + return false; + } + + return false; + }); + + return validatedColumns; + } + + return dateColumns; +} + +/** + * 쿼리에 필터와 안전장치를 모두 적용 + */ +export function applyQueryFilters(query: string, config?: ChartConfig): string { + let processedQuery = query; + + // 1. 날짜 필터 적용 + if (config?.dateFilter?.enabled && config.dateFilter.dateColumn) { + processedQuery = applyDateFilter( + processedQuery, + config.dateFilter.dateColumn, + config.dateFilter.startDate, + config.dateFilter.endDate, + ); + } + + // 2. 안전장치 LIMIT 적용 + const limit = config?.autoLimit ?? 1000; + processedQuery = applySafetyLimit(processedQuery, limit); + + return processedQuery; +} diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 390767f4..ec432299 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -187,8 +187,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { ); } - // 데이터 또는 설정 없음 - if (!data || config.columns.length === 0) { + // 데이터 없음 + if (!data) { return (
@@ -200,6 +200,17 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { ); } + // 컬럼 설정이 없으면 자동으로 모든 컬럼 표시 + const displayColumns = + config.columns.length > 0 + ? config.columns + : data.columns.map((col) => ({ + id: col, + name: col, + dataKey: col, + visible: true, + })); + // 페이지네이션 const totalPages = Math.ceil(data.rows.length / config.pageSize); const startIdx = (currentPage - 1) * config.pageSize; @@ -219,7 +230,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { {config.showHeader && ( - {config.columns + {displayColumns .filter((col) => col.visible) .map((col) => ( - {col.label} + {col.label || col.name} ))} @@ -237,7 +248,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { {paginatedRows.length === 0 ? ( col.visible).length} + colSpan={displayColumns.filter((col) => col.visible).length} className="text-center text-gray-500" > 데이터가 없습니다 @@ -246,14 +257,14 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { ) : ( paginatedRows.map((row, idx) => ( - {config.columns + {displayColumns .filter((col) => col.visible) .map((col) => ( - {String(row[col.field] ?? "")} + {String(row[col.dataKey || col.field] ?? "")} ))} @@ -279,15 +290,15 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { {paginatedRows.map((row, idx) => (
- {config.columns + {displayColumns .filter((col) => col.visible) .map((col) => (
-
{col.label}
+
{col.label || col.name}
- {String(row[col.field] ?? "")} + {String(row[col.dataKey || col.field] ?? "")}
))} diff --git a/frontend/components/dashboard/widgets/CargoListWidget.tsx b/frontend/components/dashboard/widgets/CargoListWidget.tsx new file mode 100644 index 00000000..86b7b726 --- /dev/null +++ b/frontend/components/dashboard/widgets/CargoListWidget.tsx @@ -0,0 +1,227 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface CargoListWidgetProps { + element: DashboardElement; +} + +interface Cargo { + id: string | number; + tracking_number?: string; + trackingNumber?: string; + customer_name?: string; + customerName?: string; + destination?: string; + status?: string; + weight?: number; +} + +/** + * 화물 목록 위젯 + * - 화물 목록 테이블 표시 + * - 상태별 배지 표시 + */ +export default function CargoListWidget({ element }: CargoListWidgetProps) { + const [cargoList, setCargoList] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError("쿼리가 설정되지 않았습니다"); + setLoading(false); + return; + } + + try { + setLoading(true); + 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) { + setCargoList(result.data.rows); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const getStatusBadge = (status: string) => { + const statusLower = status?.toLowerCase() || ""; + + if (statusLower.includes("배송중") || statusLower.includes("delivering")) { + return "bg-primary text-primary-foreground"; + } else if (statusLower.includes("완료") || statusLower.includes("delivered")) { + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"; + } else if (statusLower.includes("지연") || statusLower.includes("delayed")) { + return "bg-destructive text-destructive-foreground"; + } else if (statusLower.includes("픽업") || statusLower.includes("pending")) { + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"; + } + return "bg-muted text-muted-foreground"; + }; + + const filteredList = cargoList.filter((cargo) => { + if (!searchTerm) return true; + + const trackingNum = cargo.tracking_number || cargo.trackingNumber || ""; + const customerName = cargo.customer_name || cargo.customerName || ""; + const destination = cargo.destination || ""; + + const searchLower = searchTerm.toLowerCase(); + return ( + trackingNum.toLowerCase().includes(searchLower) || + customerName.toLowerCase().includes(searchLower) || + destination.toLowerCase().includes(searchLower) + ); + }); + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+

⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

📦 화물 목록

+
+ setSearchTerm(e.target.value)} + className="rounded-md border border-input bg-background px-3 py-1 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + +
+
+ + {/* 총 건수 */} +
+ 총 {filteredList.length}건 +
+ + {/* 테이블 */} +
+ + + + + + + + + + + + {filteredList.length === 0 ? ( + + + + ) : ( + filteredList.map((cargo, index) => ( + + + + + + + + )) + )} + +
운송장번호고객명목적지무게(kg)상태
+ {searchTerm ? "검색 결과가 없습니다" : "화물이 없습니다"} +
+ {cargo.tracking_number || cargo.trackingNumber || "-"} + + {cargo.customer_name || cargo.customerName || "-"} + + {cargo.destination || "-"} + + {cargo.weight ? `${cargo.weight}kg` : "-"} + + + {cargo.status || "알 수 없음"} + +
+
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx new file mode 100644 index 00000000..f7f50a43 --- /dev/null +++ b/frontend/components/dashboard/widgets/CustomerIssuesWidget.tsx @@ -0,0 +1,260 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface CustomerIssuesWidgetProps { + element: DashboardElement; +} + +interface Issue { + id: string | number; + issue_type?: string; + issueType?: string; + customer_name?: string; + customerName?: string; + description?: string; + priority?: string; + created_at?: string; + createdAt?: string; + status?: string; +} + +/** + * 고객 클레임/이슈 위젯 + * - 클레임/이슈 목록 표시 + * - 우선순위별 배지 표시 + */ +export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetProps) { + const [issues, setIssues] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterPriority, setFilterPriority] = useState("all"); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError("쿼리가 설정되지 않았습니다"); + setLoading(false); + return; + } + + try { + setLoading(true); + 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) { + setIssues(result.data.rows); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const getPriorityBadge = (priority: string) => { + const priorityLower = priority?.toLowerCase() || ""; + + if (priorityLower.includes("긴급") || priorityLower.includes("high") || priorityLower.includes("urgent")) { + return "bg-destructive text-destructive-foreground"; + } else if (priorityLower.includes("보통") || priorityLower.includes("medium") || priorityLower.includes("normal")) { + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"; + } else if (priorityLower.includes("낮음") || priorityLower.includes("low")) { + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"; + } + return "bg-muted text-muted-foreground"; + }; + + const getStatusBadge = (status: string) => { + const statusLower = status?.toLowerCase() || ""; + + if (statusLower.includes("처리중") || statusLower.includes("processing") || statusLower.includes("pending")) { + return "bg-primary text-primary-foreground"; + } else if (statusLower.includes("완료") || statusLower.includes("resolved") || statusLower.includes("closed")) { + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"; + } + return "bg-muted text-muted-foreground"; + }; + + const filteredIssues = filterPriority === "all" + ? issues + : issues.filter((issue) => { + const priority = (issue.priority || "").toLowerCase(); + return priority.includes(filterPriority); + }); + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+

⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

⚠️ 고객 클레임/이슈

+ +
+ + {/* 필터 버튼 */} +
+ + + + +
+ + {/* 총 건수 */} +
+ 총 {filteredIssues.length}건 +
+ + {/* 이슈 리스트 */} +
+ {filteredIssues.length === 0 ? ( +
+

이슈가 없습니다

+
+ ) : ( + filteredIssues.map((issue, index) => ( +
+
+
+
+ + {issue.priority || "보통"} + + + {issue.status || "처리중"} + +
+

+ {issue.issue_type || issue.issueType || "기타"} +

+
+
+ +

+ 고객: {issue.customer_name || issue.customerName || "-"} +

+ +

+ {issue.description || "설명 없음"} +

+ + {(issue.created_at || issue.createdAt) && ( +

+ {new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")} +

+ )} +
+ )) + )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/DeliveryStatusSummaryWidget.tsx b/frontend/components/dashboard/widgets/DeliveryStatusSummaryWidget.tsx new file mode 100644 index 00000000..74c93ad3 --- /dev/null +++ b/frontend/components/dashboard/widgets/DeliveryStatusSummaryWidget.tsx @@ -0,0 +1,214 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface DeliveryStatusSummaryWidgetProps { + element: DashboardElement; +} + +interface DeliveryStatus { + status: string; + count: number; +} + +/** + * 배송 상태 요약 위젯 + * - 배송중, 완료, 지연, 픽업 대기 상태별 카운트 표시 + */ +export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusSummaryWidgetProps) { + const [statusData, setStatusData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError("쿼리가 설정되지 않았습니다"); + setLoading(false); + return; + } + + try { + setLoading(true); + 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 statusCounts = rows.reduce((acc: any, row: any) => { + const status = row.status || "알 수 없음"; + acc[status] = (acc[status] || 0) + 1; + return acc; + }, {}); + + const formattedData: DeliveryStatus[] = [ + { status: "배송중", count: statusCounts["배송중"] || statusCounts["delivering"] || 0 }, + { status: "완료", count: statusCounts["완료"] || statusCounts["delivered"] || 0 }, + { status: "지연", count: statusCounts["지연"] || statusCounts["delayed"] || 0 }, + { status: "픽업 대기", count: statusCounts["픽업 대기"] || statusCounts["pending"] || 0 }, + ]; + + setStatusData(formattedData); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const getBorderColor = (status: string) => { + switch (status) { + case "배송중": + return "border-blue-500"; + case "완료": + return "border-green-500"; + case "지연": + return "border-red-500"; + case "픽업 대기": + return "border-yellow-500"; + default: + return "border-gray-500"; + } + }; + + const getDotColor = (status: string) => { + switch (status) { + case "배송중": + return "bg-blue-500"; + case "완료": + return "bg-green-500"; + case "지연": + return "bg-red-500"; + case "픽업 대기": + return "bg-yellow-500"; + default: + return "bg-gray-500"; + } + }; + + const getTextColor = (status: string) => { + switch (status) { + case "배송중": + return "text-blue-600"; + case "완료": + return "text-green-600"; + case "지연": + return "text-red-600"; + case "픽업 대기": + return "text-yellow-600"; + default: + return "text-gray-600"; + } + }; + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+

⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요

+
+
+ ); + } + + const totalCount = statusData.reduce((sum, item) => sum + item.count, 0); + + return ( +
+ {/* 헤더 */} +
+
+

📊 배송 상태 요약

+ {totalCount > 0 ? ( +

총 {totalCount.toLocaleString()}건

+ ) : ( +

⚙️ 데이터 연결 필요

+ )} +
+ +
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+ {/* 상태별 카드 */} +
+ {statusData.map((item) => ( +
+
+
+
{item.status}
+
+
{item.count.toLocaleString()}
+
+ ))} +
+
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx b/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx new file mode 100644 index 00000000..1d9e0367 --- /dev/null +++ b/frontend/components/dashboard/widgets/DeliveryTodayStatsWidget.tsx @@ -0,0 +1,164 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface DeliveryTodayStatsWidgetProps { + element: DashboardElement; +} + +interface TodayStats { + shipped: number; + delivered: number; +} + +/** + * 오늘 처리 현황 위젯 + * - 오늘 발송 건수 + * - 오늘 도착 건수 + */ +export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStatsWidgetProps) { + const [todayStats, setTodayStats] = useState({ shipped: 0, delivered: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError("쿼리가 설정되지 않았습니다"); + setLoading(false); + return; + } + + try { + setLoading(true); + 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 today = new Date().toISOString().split("T")[0]; + + // 오늘 발송 건수 (created_at 기준) + const shippedToday = rows.filter((row: any) => { + const createdDate = row.created_at?.split("T")[0] || row.createdAt?.split("T")[0]; + return createdDate === today; + }).length; + + // 오늘 도착 건수 (status === 'delivered' AND estimated_delivery 기준) + const deliveredToday = rows.filter((row: any) => { + const status = row.status || ""; + const deliveryDate = row.estimated_delivery?.split("T")[0] || row.estimatedDelivery?.split("T")[0]; + return (status === "delivered" || status === "완료") && deliveryDate === today; + }).length; + + setTodayStats({ + shipped: shippedToday, + delivered: deliveredToday, + }); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+

⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요

+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+

📅 오늘 처리 현황

+ +
+ + {/* 통계 카드 */} +
+ {/* 오늘 발송 */} +
+
📤
+

오늘 발송

+

{todayStats.shipped.toLocaleString()}

+

+
+ + {/* 오늘 도착 */} +
+
📥
+

오늘 도착

+

{todayStats.delivered.toLocaleString()}

+

+
+
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/ListSummaryWidget.tsx b/frontend/components/dashboard/widgets/ListSummaryWidget.tsx new file mode 100644 index 00000000..a9488f44 --- /dev/null +++ b/frontend/components/dashboard/widgets/ListSummaryWidget.tsx @@ -0,0 +1,326 @@ +/** + * ⚠️ 임시 주석 처리된 파일 + * 다른 분이 범용 리스트 작업 중이어서 충돌 방지를 위해 주석 처리 + * 나중에 merge 시 활성화 필요 + */ + +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface ListSummaryWidgetProps { + element: DashboardElement; +} + +interface ColumnInfo { + key: string; + label: string; +} + +// 컬럼명 한글 번역 +const translateColumnName = (colName: string): string => { + const columnTranslations: { [key: string]: string } = { + // 공통 + "id": "ID", + "name": "이름", + "status": "상태", + "created_at": "생성일", + "updated_at": "수정일", + "created_date": "생성일", + "updated_date": "수정일", + + // 기사 관련 + "driver_id": "기사ID", + "phone": "전화번호", + "license_number": "면허번호", + "vehicle_id": "차량ID", + "current_location": "현재위치", + "rating": "평점", + "total_deliveries": "총배송건수", + "average_delivery_time": "평균배송시간", + "total_distance": "총운행거리", + "join_date": "가입일", + "last_active": "마지막활동", + + // 차량 관련 + "vehicle_number": "차량번호", + "model": "모델", + "year": "연식", + "color": "색상", + "type": "종류", + + // 배송 관련 + "delivery_id": "배송ID", + "order_id": "주문ID", + "customer_name": "고객명", + "address": "주소", + "delivery_date": "배송일", + "estimated_time": "예상시간", + + // 제품 관련 + "product_id": "제품ID", + "product_name": "제품명", + "price": "가격", + "stock": "재고", + "category": "카테고리", + "description": "설명", + + // 주문 관련 + "order_date": "주문일", + "quantity": "수량", + "total_amount": "총금액", + "payment_status": "결제상태", + + // 고객 관련 + "customer_id": "고객ID", + "email": "이메일", + "company": "회사", + "department": "부서", + }; + + return columnTranslations[colName.toLowerCase()] || + columnTranslations[colName.replace(/_/g, '').toLowerCase()] || + colName; +}; + +/** + * 범용 목록 위젯 + * - SQL 쿼리 결과를 테이블 형식으로 표시 + * - 어떤 데이터든 표시 가능 (기사, 차량, 제품, 주문 등) + */ +export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) { + const [data, setData] = useState([]); + const [columns, setColumns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tableName, setTableName] = useState(null); + const [searchTerm, setSearchTerm] = useState(""); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + setError(null); + setLoading(false); + setTableName(null); + return; + } + + // 쿼리에서 테이블 이름 추출 + const extractTableName = (query: string): string | null => { + const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i); + if (fromMatch) { + return fromMatch[1]; + } + return null; + }; + + try { + setLoading(true); + const extractedTableName = extractTableName(element.dataSource.query); + setTableName(extractedTableName); + + 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; + + // 컬럼 정보 추출 (한글 번역 적용) + if (rows.length > 0) { + const cols: ColumnInfo[] = Object.keys(rows[0]).map((key) => ({ + key, + label: translateColumnName(key), + })); + setColumns(cols); + } + + setData(rows); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + // 테이블 이름 한글 번역 + const translateTableName = (name: string): string => { + const tableTranslations: { [key: string]: string } = { + "drivers": "기사", + "driver": "기사", + "vehicles": "차량", + "vehicle": "차량", + "products": "제품", + "product": "제품", + "orders": "주문", + "order": "주문", + "customers": "고객", + "customer": "고객", + "deliveries": "배송", + "delivery": "배송", + "users": "사용자", + "user": "사용자", + }; + + return tableTranslations[name.toLowerCase()] || + tableTranslations[name.replace(/_/g, '').toLowerCase()] || + name; + }; + + const displayTitle = tableName ? `${translateTableName(tableName)} 목록` : "데이터 목록"; + + // 검색 필터링 + const filteredData = data.filter((row) => + Object.values(row).some((value) => + String(value).toLowerCase().includes(searchTerm.toLowerCase()) + ) + ); + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+
📋
+

데이터 목록

+
+

📋 테이블 형식 데이터 표시 위젯

+
    +
  • • SQL 쿼리로 데이터를 불러옵니다
  • +
  • • 테이블 형식으로 자동 표시
  • +
  • • 검색 기능 지원
  • +
  • • 실시간 데이터 모니터링 가능
  • +
+
+
+

⚙️ 설정 방법

+

우측 상단 톱니바퀴 버튼을 클릭하여

+

SQL 쿼리를 입력하고 저장하세요

+
+
+
+ ); + } + + return ( +
+ {/* 헤더 */} +
+
+

📋 {displayTitle}

+

총 {filteredData.length.toLocaleString()}건

+
+ +
+ + {/* 검색 */} + {data.length > 0 && ( +
+ setSearchTerm(e.target.value)} + className="w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ )} + + {/* 테이블 */} +
+ {filteredData.length > 0 ? ( + + + + {columns.map((col) => ( + + ))} + + + + {filteredData.map((row, idx) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.label} +
+ {String(row[col.key] || "")} +
+ ) : ( +
+

검색 결과가 없습니다

+
+ )} +
+
+ ); +} + diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx new file mode 100644 index 00000000..53db0b8e --- /dev/null +++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx @@ -0,0 +1,224 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { DashboardElement } from "@/components/admin/dashboard/types"; +import "leaflet/dist/leaflet.css"; + +// Leaflet 아이콘 경로 설정 (엑박 방지) +if (typeof window !== "undefined") { + const L = require("leaflet"); + delete (L.Icon.Default.prototype as any)._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + }); +} + +// Leaflet 동적 import (SSR 방지) +const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false }); +const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false }); +const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false }); +const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false }); + +// 브이월드 API 키 +const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033"; + +interface MapSummaryWidgetProps { + element: DashboardElement; +} + +interface MarkerData { + lat: number; + lng: number; + name: string; + info: any; +} + +// 테이블명 한글 번역 +const translateTableName = (name: string): string => { + const tableTranslations: { [key: string]: string } = { + "vehicle_locations": "차량", + "vehicles": "차량", + "warehouses": "창고", + "warehouse": "창고", + "customers": "고객", + "customer": "고객", + "deliveries": "배송", + "delivery": "배송", + "drivers": "기사", + "driver": "기사", + "stores": "매장", + "store": "매장", + }; + + return tableTranslations[name.toLowerCase()] || + tableTranslations[name.replace(/_/g, '').toLowerCase()] || + name; +}; + +/** + * 범용 지도 위젯 (커스텀 지도 카드) + * - 위도/경도가 있는 모든 데이터를 지도에 표시 + * - 차량, 창고, 고객, 배송 등 모든 위치 데이터 지원 + * - Leaflet + 브이월드 지도 사용 + */ +export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { + const [markers, setMarkers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [tableName, setTableName] = useState(null); + + useEffect(() => { + if (element?.dataSource?.query) { + loadMapData(); + } + + // 자동 새로고침 (30초마다) + const interval = setInterval(() => { + if (element?.dataSource?.query) { + loadMapData(); + } + }, 30000); + + return () => clearInterval(interval); + }, [element]); + + const loadMapData = async () => { + if (!element?.dataSource?.query) { + return; + } + + // 쿼리에서 테이블 이름 추출 + const extractTableName = (query: string): string | null => { + const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i); + if (fromMatch) { + return fromMatch[1]; + } + return null; + }; + + try { + setLoading(true); + const extractedTableName = extractTableName(element.dataSource.query); + setTableName(extractedTableName); + + 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 latCol = element.chartConfig?.latitudeColumn || "latitude"; + const lngCol = element.chartConfig?.longitudeColumn || "longitude"; + + // 유효한 좌표 필터링 및 마커 데이터 생성 + const markerData = rows + .filter((row: any) => row[latCol] && row[lngCol]) + .map((row: any) => ({ + lat: parseFloat(row[latCol]), + lng: parseFloat(row[lngCol]), + name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음", + info: row, + })); + + setMarkers(markerData); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const displayTitle = tableName ? `${translateTableName(tableName)} 위치` : "위치 지도"; + + return ( +
+ {/* 헤더 */} +
+
+

📍 {displayTitle}

+ {element?.dataSource?.query ? ( +

총 {markers.length.toLocaleString()}개 마커

+ ) : ( +

⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요

+ )} +
+ +
+ + {/* 에러 메시지 (지도 위에 오버레이) */} + {error && ( +
+ ⚠️ {error} +
+ )} + + {/* 지도 (항상 표시) */} +
+ + {/* 브이월드 타일맵 */} + + + {/* 마커 표시 */} + {markers.map((marker, idx) => ( + + +
+
{marker.name}
+ {Object.entries(marker.info) + .filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase())) + .map(([key, value]) => ( +
+ {key}: {String(value)} +
+ ))} +
+
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx b/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx new file mode 100644 index 00000000..e9641eee --- /dev/null +++ b/frontend/components/dashboard/widgets/StatusSummaryWidget.tsx @@ -0,0 +1,399 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { DashboardElement } from "@/components/admin/dashboard/types"; + +interface StatusSummaryWidgetProps { + element: DashboardElement; + title?: string; + icon?: string; + bgGradient?: string; + statusConfig?: StatusConfig; +} + +interface StatusConfig { + [key: string]: { + label: string; + color: "blue" | "green" | "red" | "yellow" | "orange" | "purple" | "gray"; + }; +} + +// 영어 상태명 → 한글 자동 변환 +const statusTranslations: { [key: string]: string } = { + // 배송 관련 + "delayed": "지연", + "pickup_waiting": "픽업 대기", + "in_transit": "배송 중", + "delivered": "배송완료", + "pending": "대기중", + "processing": "처리중", + "completed": "완료", + "cancelled": "취소됨", + "failed": "실패", + + // 일반 상태 + "active": "활성", + "inactive": "비활성", + "enabled": "사용중", + "disabled": "사용안함", + "online": "온라인", + "offline": "오프라인", + "available": "사용가능", + "unavailable": "사용불가", + + // 승인 관련 + "approved": "승인됨", + "rejected": "거절됨", + "waiting": "대기중", + + // 차량 관련 + "driving": "운행중", + "parked": "주차", + "maintenance": "정비중", + + // 기사 관련 (존중하는 표현) + "waiting": "대기중", + "resting": "휴식중", + "unavailable": "운행불가", + + // 기사 평가 + "excellent": "우수", + "good": "양호", + "average": "보통", + "poor": "미흡", + + // 기사 경력 + "veteran": "베테랑", + "experienced": "숙련", + "intermediate": "중급", + "beginner": "초급", +}; + +// 영어 테이블명 → 한글 자동 변환 +const tableTranslations: { [key: string]: string } = { + // 배송/물류 관련 + "deliveries": "배송", + "delivery": "배송", + "shipments": "출하", + "shipment": "출하", + "orders": "주문", + "order": "주문", + "cargo": "화물", + "cargos": "화물", + "packages": "소포", + "package": "소포", + + // 차량 관련 + "vehicles": "차량", + "vehicle": "차량", + "vehicle_locations": "차량위치", + "vehicle_status": "차량상태", + "drivers": "기사", + "driver": "기사", + + // 사용자/고객 관련 + "users": "사용자", + "user": "사용자", + "customers": "고객", + "customer": "고객", + "members": "회원", + "member": "회원", + + // 제품/재고 관련 + "products": "제품", + "product": "제품", + "items": "항목", + "item": "항목", + "inventory": "재고", + "stock": "재고", + + // 업무 관련 + "tasks": "작업", + "task": "작업", + "projects": "프로젝트", + "project": "프로젝트", + "issues": "이슈", + "issue": "이슈", + "tickets": "티켓", + "ticket": "티켓", + + // 기타 + "logs": "로그", + "log": "로그", + "reports": "리포트", + "report": "리포트", + "alerts": "알림", + "alert": "알림", +}; + +interface StatusData { + status: string; + count: number; +} + +/** + * 범용 상태 요약 위젯 + * - 쿼리 결과를 상태별로 카운트해서 카드로 표시 + * - 색상과 라벨은 statusConfig로 커스터마이징 가능 + */ +export default function StatusSummaryWidget({ + element, + title = "상태 요약", + icon = "📊", + bgGradient = "from-slate-50 to-blue-50", + statusConfig +}: StatusSummaryWidgetProps) { + const [statusData, setStatusData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [tableName, setTableName] = useState(null); + + useEffect(() => { + loadData(); + + // 자동 새로고침 (30초마다) + const interval = setInterval(loadData, 30000); + return () => clearInterval(interval); + }, [element]); + + const loadData = async () => { + if (!element?.dataSource?.query) { + // 쿼리가 없으면 에러가 아니라 초기 상태로 처리 + setError(null); + setLoading(false); + setTableName(null); + return; + } + + // 쿼리에서 테이블 이름 추출 + const extractTableName = (query: string): string | null => { + const fromMatch = query.match(/FROM\s+([a-zA-Z0-9_가-힣]+)/i); + if (fromMatch) { + return fromMatch[1]; + } + return null; + }; + + try { + setLoading(true); + const extractedTableName = extractTableName(element.dataSource.query); + setTableName(extractedTableName); + + 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 statusCounts: { [key: string]: number } = {}; + + // GROUP BY 형식인지 확인 + const isGroupedData = rows.length > 0 && rows[0].count !== undefined; + + if (isGroupedData) { + // GROUP BY 형식: SELECT status, COUNT(*) as count + rows.forEach((row: any) => { + // 다양한 컬럼명 지원 (status, 상태, state 등) + let status = row.status || row.상태 || row.state || row.STATUS || row.label || row.name || "알 수 없음"; + // 영어 → 한글 자동 번역 + status = statusTranslations[status] || statusTranslations[status.toLowerCase()] || status; + const count = parseInt(row.count || row.개수 || row.COUNT || row.cnt) || 0; + statusCounts[status] = count; + }); + } else { + // SELECT * 형식: 전체 데이터를 가져와서 카운트 + rows.forEach((row: any) => { + // 다양한 컬럼명 지원 + let status = row.status || row.상태 || row.state || row.STATUS || row.label || row.name || "알 수 없음"; + // 영어 → 한글 자동 번역 + status = statusTranslations[status] || statusTranslations[status.toLowerCase()] || status; + statusCounts[status] = (statusCounts[status] || 0) + 1; + }); + } + + // statusConfig가 있으면 해당 순서대로, 없으면 전체 표시 + let formattedData: StatusData[]; + if (statusConfig) { + formattedData = Object.keys(statusConfig).map((key) => ({ + status: statusConfig[key].label, + count: statusCounts[key] || 0, + })); + } else { + formattedData = Object.entries(statusCounts).map(([status, count]) => ({ + status, + count, + })); + } + + setStatusData(formattedData); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "데이터 로딩 실패"); + } finally { + setLoading(false); + } + }; + + const getColorClasses = (status: string) => { + // statusConfig에서 색상 찾기 + let color: string = "gray"; + if (statusConfig) { + const configEntry = Object.entries(statusConfig).find(([_, v]) => v.label === status); + if (configEntry) { + color = configEntry[1].color; + } + } + + const colorMap = { + blue: { border: "border-blue-500", dot: "bg-blue-500", text: "text-blue-600" }, + green: { border: "border-green-500", dot: "bg-green-500", text: "text-green-600" }, + red: { border: "border-red-500", dot: "bg-red-500", text: "text-red-600" }, + yellow: { border: "border-yellow-500", dot: "bg-yellow-500", text: "text-yellow-600" }, + orange: { border: "border-orange-500", dot: "bg-orange-500", text: "text-orange-600" }, + purple: { border: "border-purple-500", dot: "bg-purple-500", text: "text-purple-600" }, + gray: { border: "border-gray-500", dot: "bg-gray-500", text: "text-gray-600" }, + }; + + return colorMap[color as keyof typeof colorMap] || colorMap.gray; + }; + + if (loading) { + return ( +
+
+
+

데이터 로딩 중...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!element?.dataSource?.query) { + return ( +
+
+
{icon}
+

{title}

+
+

📊 상태별 데이터 집계 위젯

+
    +
  • • SQL 쿼리로 데이터를 불러옵니다
  • +
  • • 상태별로 자동 집계하여 카드로 표시
  • +
  • • 실시간 데이터 모니터링 가능
  • +
  • • 색상과 라벨 커스터마이징 지원
  • +
+
+
+

⚙️ 설정 방법

+

우측 상단 톱니바퀴 버튼을 클릭하여

+

SQL 쿼리를 입력하고 저장하세요

+
+
+
+ ); + } + + const totalCount = statusData.reduce((sum, item) => sum + item.count, 0); + + // 테이블 이름이 있으면 제목을 테이블 이름으로 변경 + const translateTableName = (name: string): string => { + // 정확한 매칭 시도 + if (tableTranslations[name]) { + return tableTranslations[name]; + } + // 소문자로 변환하여 매칭 시도 + if (tableTranslations[name.toLowerCase()]) { + return tableTranslations[name.toLowerCase()]; + } + // 언더스코어 제거하고 매칭 시도 + const nameWithoutUnderscore = name.replace(/_/g, ''); + if (tableTranslations[nameWithoutUnderscore.toLowerCase()]) { + return tableTranslations[nameWithoutUnderscore.toLowerCase()]; + } + // 번역이 없으면 원본 반환 + return name; + }; + + const displayTitle = tableName ? `${translateTableName(tableName)} 현황` : title; + + return ( +
+ {/* 헤더 */} +
+
+

{icon} {displayTitle}

+ {totalCount > 0 ? ( +

총 {totalCount.toLocaleString()}건

+ ) : ( +

⚙️ 데이터 연결 필요

+ )} +
+ +
+ + {/* 스크롤 가능한 콘텐츠 영역 */} +
+ {/* 상태별 카드 */} +
+ {statusData.map((item) => { + const colors = getColorClasses(item.status); + return ( +
+
+
+
{item.status}
+
+
{item.count.toLocaleString()}
+
+ ); + })} +
+
+
+ ); +} + diff --git a/frontend/lib/api/dashboard.ts b/frontend/lib/api/dashboard.ts index 9cc8ee5b..f6365854 100644 --- a/frontend/lib/api/dashboard.ts +++ b/frontend/lib/api/dashboard.ts @@ -2,28 +2,28 @@ * 대시보드 API 클라이언트 */ -import { DashboardElement } from '@/components/admin/dashboard/types'; +import { DashboardElement } from "@/components/admin/dashboard/types"; // API 기본 설정 -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api"; // 토큰 가져오기 (실제 인증 시스템에 맞게 수정) function getAuthToken(): string | null { - if (typeof window === 'undefined') return null; - return localStorage.getItem('authToken') || sessionStorage.getItem('authToken'); + if (typeof window === "undefined") return null; + return localStorage.getItem("authToken") || sessionStorage.getItem("authToken"); } // API 요청 헬퍼 async function apiRequest( - endpoint: string, - options: RequestInit = {} + endpoint: string, + options: RequestInit = {}, ): Promise<{ success: boolean; data?: T; message?: string; pagination?: any }> { const token = getAuthToken(); - + const config: RequestInit = { headers: { - 'Content-Type': 'application/json', - ...(token && { 'Authorization': `Bearer ${token}` }), + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), ...options.headers, }, ...options, @@ -31,32 +31,32 @@ async function apiRequest( try { const response = await fetch(`${API_BASE_URL}${endpoint}`, config); - + // 응답이 JSON이 아닐 수도 있으므로 안전하게 처리 let result; try { result = await response.json(); } catch (jsonError) { - console.error('JSON Parse Error:', jsonError); + console.error("JSON Parse Error:", jsonError); throw new Error(`서버 응답을 파싱할 수 없습니다. Status: ${response.status}`); } - + if (!response.ok) { - console.error('API Error Response:', { + console.error("API Error Response:", { status: response.status, statusText: response.statusText, - result + result, }); throw new Error(result.message || `HTTP ${response.status}: ${response.statusText}`); } - + return result; } catch (error: any) { - console.error('API Request Error:', { + console.error("API Request Error:", { endpoint, error: error?.message || error, errorObj: error, - config + config, }); throw error; } @@ -99,154 +99,153 @@ export interface DashboardListQuery { // 대시보드 API 함수들 export const dashboardApi = { - /** * 대시보드 생성 */ async createDashboard(data: CreateDashboardRequest): Promise { - const result = await apiRequest('/dashboards', { - method: 'POST', + const result = await apiRequest("/dashboards", { + method: "POST", body: JSON.stringify(data), }); - + if (!result.success || !result.data) { - throw new Error(result.message || '대시보드 생성에 실패했습니다.'); + throw new Error(result.message || "대시보드 생성에 실패했습니다."); } - + return result.data; }, - + /** * 대시보드 목록 조회 */ async getDashboards(query: DashboardListQuery = {}) { const params = new URLSearchParams(); - - if (query.page) params.append('page', query.page.toString()); - if (query.limit) params.append('limit', query.limit.toString()); - if (query.search) params.append('search', query.search); - if (query.category) params.append('category', query.category); - if (typeof query.isPublic === 'boolean') params.append('isPublic', query.isPublic.toString()); - + + if (query.page) params.append("page", query.page.toString()); + if (query.limit) params.append("limit", query.limit.toString()); + if (query.search) params.append("search", query.search); + if (query.category) params.append("category", query.category); + if (typeof query.isPublic === "boolean") params.append("isPublic", query.isPublic.toString()); + const queryString = params.toString(); - const endpoint = `/dashboards${queryString ? `?${queryString}` : ''}`; - + const endpoint = `/dashboards${queryString ? `?${queryString}` : ""}`; + const result = await apiRequest(endpoint); - + if (!result.success) { - throw new Error(result.message || '대시보드 목록 조회에 실패했습니다.'); + throw new Error(result.message || "대시보드 목록 조회에 실패했습니다."); } - + return { dashboards: result.data || [], - pagination: result.pagination + pagination: result.pagination, }; }, - + /** * 내 대시보드 목록 조회 */ async getMyDashboards(query: DashboardListQuery = {}) { const params = new URLSearchParams(); - - if (query.page) params.append('page', query.page.toString()); - if (query.limit) params.append('limit', query.limit.toString()); - if (query.search) params.append('search', query.search); - if (query.category) params.append('category', query.category); - + + if (query.page) params.append("page", query.page.toString()); + if (query.limit) params.append("limit", query.limit.toString()); + if (query.search) params.append("search", query.search); + if (query.category) params.append("category", query.category); + const queryString = params.toString(); - const endpoint = `/dashboards/my${queryString ? `?${queryString}` : ''}`; - + const endpoint = `/dashboards/my${queryString ? `?${queryString}` : ""}`; + const result = await apiRequest(endpoint); - + if (!result.success) { - throw new Error(result.message || '내 대시보드 목록 조회에 실패했습니다.'); + throw new Error(result.message || "내 대시보드 목록 조회에 실패했습니다."); } - + return { dashboards: result.data || [], - pagination: result.pagination + pagination: result.pagination, }; }, - + /** * 대시보드 상세 조회 */ async getDashboard(id: string): Promise { const result = await apiRequest(`/dashboards/${id}`); - + if (!result.success || !result.data) { - throw new Error(result.message || '대시보드 조회에 실패했습니다.'); + throw new Error(result.message || "대시보드 조회에 실패했습니다."); } - + return result.data; }, - + /** * 공개 대시보드 조회 (인증 불필요) */ async getPublicDashboard(id: string): Promise { const result = await apiRequest(`/dashboards/public/${id}`); - + if (!result.success || !result.data) { - throw new Error(result.message || '대시보드 조회에 실패했습니다.'); + throw new Error(result.message || "대시보드 조회에 실패했습니다."); } - + return result.data; }, - + /** * 대시보드 수정 */ async updateDashboard(id: string, data: Partial): Promise { const result = await apiRequest(`/dashboards/${id}`, { - method: 'PUT', + method: "PUT", body: JSON.stringify(data), }); - + if (!result.success || !result.data) { - throw new Error(result.message || '대시보드 수정에 실패했습니다.'); + throw new Error(result.message || "대시보드 수정에 실패했습니다."); } - + return result.data; }, - + /** * 대시보드 삭제 */ async deleteDashboard(id: string): Promise { const result = await apiRequest(`/dashboards/${id}`, { - method: 'DELETE', + method: "DELETE", }); - + if (!result.success) { - throw new Error(result.message || '대시보드 삭제에 실패했습니다.'); + throw new Error(result.message || "대시보드 삭제에 실패했습니다."); } }, - + /** * 공개 대시보드 목록 조회 (인증 불필요) */ async getPublicDashboards(query: DashboardListQuery = {}) { const params = new URLSearchParams(); - - if (query.page) params.append('page', query.page.toString()); - if (query.limit) params.append('limit', query.limit.toString()); - if (query.search) params.append('search', query.search); - if (query.category) params.append('category', query.category); - + + if (query.page) params.append("page", query.page.toString()); + if (query.limit) params.append("limit", query.limit.toString()); + if (query.search) params.append("search", query.search); + if (query.category) params.append("category", query.category); + const queryString = params.toString(); - const endpoint = `/dashboards/public${queryString ? `?${queryString}` : ''}`; - + const endpoint = `/dashboards/public${queryString ? `?${queryString}` : ""}`; + const result = await apiRequest(endpoint); - + if (!result.success) { - throw new Error(result.message || '공개 대시보드 목록 조회에 실패했습니다.'); + throw new Error(result.message || "공개 대시보드 목록 조회에 실패했습니다."); } - + return { dashboards: result.data || [], - pagination: result.pagination + pagination: result.pagination, }; }, @@ -254,17 +253,41 @@ export const dashboardApi = { * 쿼리 실행 (차트 데이터 조회) */ async executeQuery(query: string): Promise<{ columns: string[]; rows: any[]; rowCount: number }> { - const result = await apiRequest<{ columns: string[]; rows: any[]; rowCount: number }>('/dashboards/execute-query', { - method: 'POST', + const result = await apiRequest<{ columns: string[]; rows: any[]; rowCount: number }>("/dashboards/execute-query", { + method: "POST", body: JSON.stringify({ query }), }); - + if (!result.success || !result.data) { - throw new Error(result.message || '쿼리 실행에 실패했습니다.'); + throw new Error(result.message || "쿼리 실행에 실패했습니다."); } - + return result.data; - } + }, + + /** + * 테이블 스키마 조회 (날짜 컬럼 감지용) + */ + async getTableSchema(tableName: string): Promise<{ + tableName: string; + columns: Array<{ name: string; type: string; udtName: string }>; + dateColumns: string[]; + }> { + const result = await apiRequest<{ + tableName: string; + columns: Array<{ name: string; type: string; udtName: string }>; + dateColumns: string[]; + }>("/dashboards/table-schema", { + method: "POST", + body: JSON.stringify({ tableName }), + }); + + if (!result.success || !result.data) { + throw new Error(result.message || "테이블 스키마 조회에 실패했습니다."); + } + + return result.data; + }, }; // 에러 처리 유틸리티 @@ -272,10 +295,10 @@ export function handleApiError(error: any): string { if (error.message) { return error.message; } - - if (typeof error === 'string') { + + if (typeof error === "string") { return error; } - - return '알 수 없는 오류가 발생했습니다.'; + + return "알 수 없는 오류가 발생했습니다."; } diff --git a/frontend/lib/registry/ComponentRegistry.ts b/frontend/lib/registry/ComponentRegistry.ts index 7a71947b..75f02a74 100644 --- a/frontend/lib/registry/ComponentRegistry.ts +++ b/frontend/lib/registry/ComponentRegistry.ts @@ -348,36 +348,16 @@ export class ComponentRegistry { // Hot Reload 제어 hotReload: { status: async () => { - try { - const hotReload = await import("../utils/hotReload"); - return { - active: hotReload.isHotReloadActive(), - componentCount: this.getComponentCount(), - timestamp: new Date(), - }; - } catch (error) { - console.warn("Hot Reload 모듈 로드 실패:", error); - return { - active: false, - componentCount: this.getComponentCount(), - timestamp: new Date(), - error: "Hot Reload 모듈을 로드할 수 없습니다", - }; - } + // hotReload 기능 제거 (불필요) + return { + active: false, + componentCount: this.getComponentCount(), + timestamp: new Date(), + }; }, force: async () => { - try { - // hotReload 모듈이 존재하는 경우에만 실행 - const hotReload = await import("../utils/hotReload").catch(() => null); - if (hotReload) { - hotReload.forceReloadComponents(); - console.log("✅ 강제 Hot Reload 실행 완료"); - } else { - console.log("⚠️ hotReload 모듈이 없어 건너뜀"); - } - } catch (error) { - console.error("❌ 강제 Hot Reload 실행 실패:", error); - } + // hotReload 기능 비활성화 (불필요) + console.log("⚠️ 강제 Hot Reload는 더 이상 필요하지 않습니다"); }, },