Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
c42853f261
|
|
@ -547,4 +547,93 @@ export class DashboardController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 스키마 조회 (날짜 컬럼 감지용)
|
||||
* POST /api/dashboards/table-schema
|
||||
*/
|
||||
async getTableSchema(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
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
|
||||
: "스키마 조회 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ router.post(
|
|||
dashboardController.fetchExternalApi.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 테이블 스키마 조회 (날짜 컬럼 감지용)
|
||||
router.post(
|
||||
"/table-schema",
|
||||
dashboardController.getTableSchema.bind(dashboardController)
|
||||
);
|
||||
|
||||
// 인증이 필요한 라우트들
|
||||
router.use(authenticateToken);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="h-full">
|
||||
<DashboardDesigner dashboardId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import DashboardDesigner from "@/components/admin/dashboard/DashboardDesigner";
|
||||
|
||||
/**
|
||||
* 새 대시보드 생성 페이지
|
||||
*/
|
||||
export default function DashboardNewPage() {
|
||||
return (
|
||||
<div className="h-full">
|
||||
<DashboardDesigner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Dashboard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-medium text-gray-900">로딩 중...</div>
|
||||
<div className="mt-2 text-sm text-gray-500">대시보드 목록을 불러오고 있습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<DashboardDesigner />
|
||||
<div className="h-full overflow-auto bg-gray-50 p-6">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">대시보드 관리</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">대시보드를 생성하고 관리할 수 있습니다</p>
|
||||
</div>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="대시보드 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<Card className="mb-6 border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 대시보드 목록 */}
|
||||
{dashboards.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-full bg-gray-100">
|
||||
<Plus className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-medium text-gray-900">대시보드가 없습니다</h3>
|
||||
<p className="mb-6 text-sm text-gray-500">첫 번째 대시보드를 생성하여 데이터 시각화를 시작하세요</p>
|
||||
<Button onClick={() => router.push("/admin/dashboard/new")} className="gap-2">
|
||||
<Plus className="h-4 w-4" />새 대시보드 생성
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>제목</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead>요소 수</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>생성일</TableHead>
|
||||
<TableHead>수정일</TableHead>
|
||||
<TableHead className="w-[80px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dashboards.map((dashboard) => (
|
||||
<TableRow key={dashboard.id} className="cursor-pointer hover:bg-gray-50">
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{dashboard.title}
|
||||
{dashboard.isPublic && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
공개
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md truncate text-sm text-gray-500">
|
||||
{dashboard.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{dashboard.elementsCount || 0}개</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{dashboard.isPublic ? (
|
||||
<Badge className="bg-green-100 text-green-800">공개</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">비공개</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell>
|
||||
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/${dashboard.id}`)} className="gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
보기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => router.push(`/admin/dashboard/edit/${dashboard.id}`)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
편집
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopy(dashboard)} className="gap-2">
|
||||
<Copy className="h-4 w-4" />
|
||||
복사
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(dashboard.id, dashboard.title)}
|
||||
className="gap-2 text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,42 @@ const VehicleMapOnlyWidget = dynamic(() => import("@/components/dashboard/widget
|
|||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const DeliveryStatusWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusWidget"), {
|
||||
// 범용 지도 위젯 (차량, 창고, 고객 등 모든 위치 위젯 통합)
|
||||
const MapSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/MapSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 상태 요약 위젯 (차량, 배송 등 모든 상태 위젯 통합)
|
||||
const StatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/StatusSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
// 범용 목록 위젯 (차량, 기사, 제품 등 모든 목록 위젯 통합) - 다른 분 작업 중, 임시 주석
|
||||
/* const ListSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/ListSummaryWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
}); */
|
||||
|
||||
// 개별 위젯들 (주석 처리 - StatusSummaryWidget으로 통합됨)
|
||||
// const DeliveryStatusSummaryWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryStatusSummaryWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// });
|
||||
// const DeliveryTodayStatsWidget = dynamic(() => import("@/components/dashboard/widgets/DeliveryTodayStatsWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// });
|
||||
// const CargoListWidget = dynamic(() => import("@/components/dashboard/widgets/CargoListWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// });
|
||||
// const CustomerIssuesWidget = dynamic(() => import("@/components/dashboard/widgets/CustomerIssuesWidget"), {
|
||||
// ssr: false,
|
||||
// loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
// });
|
||||
|
||||
const RiskAlertWidget = dynamic(() => import("@/components/dashboard/widgets/RiskAlertWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
|
|
@ -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({
|
|||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleListWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "map-summary" ? (
|
||||
// 커스텀 지도 카드 - 범용 위젯
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<MapSummaryWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "vehicle-map" ? (
|
||||
// 차량 위치 지도 위젯 렌더링
|
||||
// 차량 위치 지도 위젯 렌더링 (구버전 - 호환용)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<VehicleMapOnlyWidget element={element} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-status" ? (
|
||||
// 배송/화물 현황 위젯 렌더링
|
||||
) : element.type === "widget" && element.subtype === "status-summary" ? (
|
||||
// 커스텀 상태 카드 - 범용 위젯
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<DeliveryStatusWidget element={element} />
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="상태 요약"
|
||||
icon="📊"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
/>
|
||||
</div>
|
||||
) : /* element.type === "widget" && element.subtype === "list-summary" ? (
|
||||
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<ListSummaryWidget element={element} />
|
||||
</div>
|
||||
) : */ element.type === "widget" && element.subtype === "delivery-status" ? (
|
||||
// 배송/화물 현황 위젯 - 범용 위젯 사용 (구버전 호환)
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="배송/화물 현황"
|
||||
icon="📦"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-status-summary" ? (
|
||||
// 배송 상태 요약 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="배송 상태 요약"
|
||||
icon="📊"
|
||||
bgGradient="from-slate-50 to-blue-50"
|
||||
statusConfig={{
|
||||
"배송중": { label: "배송중", color: "blue" },
|
||||
"완료": { label: "완료", color: "green" },
|
||||
"지연": { label: "지연", color: "red" },
|
||||
"픽업 대기": { label: "픽업 대기", color: "yellow" }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "delivery-today-stats" ? (
|
||||
// 오늘 처리 현황 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="오늘 처리 현황"
|
||||
icon="📈"
|
||||
bgGradient="from-slate-50 to-green-50"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "cargo-list" ? (
|
||||
// 화물 목록 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="화물 목록"
|
||||
icon="📦"
|
||||
bgGradient="from-slate-50 to-orange-50"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "customer-issues" ? (
|
||||
// 고객 클레임/이슈 - 범용 위젯 사용
|
||||
<div className="widget-interactive-area h-full w-full">
|
||||
<StatusSummaryWidget
|
||||
element={element}
|
||||
title="고객 클레임/이슈"
|
||||
icon="⚠️"
|
||||
bgGradient="from-slate-50 to-red-50"
|
||||
/>
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "risk-alert" ? (
|
||||
// 리스크/알림 위젯 렌더링
|
||||
|
|
|
|||
|
|
@ -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<ChartConfig>(config || {});
|
||||
const [dateColumns, setDateColumns] = useState<string[]>([]);
|
||||
|
||||
// 원형/도넛 차트 또는 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 (
|
||||
<div className="space-y-6">
|
||||
{/* 데이터 필드 매핑 */}
|
||||
|
|
@ -80,7 +114,7 @@ export function ChartConfigPanel({
|
|||
<Card className="border-blue-200 bg-blue-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||
<h4 className="font-semibold text-blue-900">📋 API 응답 데이터 미리보기</h4>
|
||||
<h4 className="font-semibold text-blue-900">API 응답 데이터 미리보기</h4>
|
||||
</div>
|
||||
<div className="rounded bg-white p-3 text-xs">
|
||||
<div className="mb-2 text-gray-600">총 {queryResult.totalRows}개 데이터 중 첫 번째 행:</div>
|
||||
|
|
@ -94,7 +128,7 @@ export function ChartConfigPanel({
|
|||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-semibold">⚠️ 차트에 사용할 수 없는 컬럼 감지</div>
|
||||
<div className="font-semibold">차트에 사용할 수 없는 컬럼 감지</div>
|
||||
<div className="mt-1 text-sm">
|
||||
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
|
|
@ -106,7 +140,7 @@ export function ChartConfigPanel({
|
|||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
💡 <strong>해결 방법:</strong> JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
|
||||
<strong>해결 방법:</strong> JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
|
||||
<br />
|
||||
예: <code className="rounded bg-gray-100 px-1">main</code> 또는{" "}
|
||||
<code className="rounded bg-gray-100 px-1">data.items</code>
|
||||
|
|
@ -138,7 +172,7 @@ export function ChartConfigPanel({
|
|||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="z-[99999]">
|
||||
{simpleColumns.map((col) => {
|
||||
const preview = sampleData[col];
|
||||
const previewText =
|
||||
|
|
@ -158,7 +192,7 @@ export function ChartConfigPanel({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
<p className="text-xs text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -176,17 +210,14 @@ export function ChartConfigPanel({
|
|||
{/* 숫자 타입 우선 표시 */}
|
||||
{numericColumns.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium text-green-700">✅ 숫자 타입 (권장)</div>
|
||||
<div className="mb-2 text-xs font-medium text-green-700">숫자 타입 (권장)</div>
|
||||
{numericColumns.map((col) => {
|
||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||
? currentConfig.yAxis.includes(col)
|
||||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col}
|
||||
className="flex items-center gap-2 rounded border-l-2 border-green-500 bg-green-50 p-2"
|
||||
>
|
||||
<div key={col} className="flex items-center gap-2 rounded border-green-500 bg-green-50 p-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
|
|
@ -226,7 +257,7 @@ export function ChartConfigPanel({
|
|||
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
||||
<>
|
||||
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">📝 기타 타입</div>
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">기타 타입</div>
|
||||
{simpleColumns
|
||||
.filter((col) => !numericColumns.includes(col))
|
||||
.map((col) => {
|
||||
|
|
@ -275,7 +306,7 @@ export function ChartConfigPanel({
|
|||
</div>
|
||||
</Card>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
<p className="text-xs text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||
|
|
@ -301,7 +332,7 @@ export function ChartConfigPanel({
|
|||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
||||
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
||||
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
||||
|
|
@ -311,7 +342,7 @@ export function ChartConfigPanel({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
💡 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||||
그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -328,7 +359,7 @@ export function ChartConfigPanel({
|
|||
<SelectTrigger>
|
||||
<SelectValue placeholder="없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
|
|
@ -387,44 +418,10 @@ export function ChartConfigPanel({
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* 설정 미리보기 */}
|
||||
<Card className="bg-gray-50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
설정 미리보기
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-gray-600">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">X축:</span>
|
||||
<span>{currentConfig.xAxis || "미설정"}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">Y축:</span>
|
||||
<span>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 0
|
||||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(", ")})`
|
||||
: currentConfig.yAxis || "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">집계:</span>
|
||||
<span>{currentConfig.aggregation || "없음"}</span>
|
||||
</div>
|
||||
{currentConfig.groupBy && (
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">그룹핑:</span>
|
||||
<span>{currentConfig.groupBy}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">데이터 행 수:</span>
|
||||
<Badge variant="secondary">{queryResult.rows.length}개</Badge>
|
||||
</div>
|
||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
||||
<div className="mt-2 text-blue-600">✨ 다중 시리즈 차트가 생성됩니다!</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{/* 날짜 필터 */}
|
||||
{dateColumns.length > 0 && (
|
||||
<DateFilterPanel config={currentConfig} dateColumns={dateColumns} onChange={updateConfig} />
|
||||
)}
|
||||
|
||||
{/* 필수 필드 확인 */}
|
||||
{!currentConfig.xAxis && (
|
||||
|
|
|
|||
|
|
@ -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<DashboardElement[]>([]);
|
||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||
const [elementCounter, setElementCounter] = useState(0);
|
||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
|
||||
const canvasRef = useRef<HTMLDivElement>(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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="w-[370px] overflow-y-auto border-l border-gray-200 bg-white p-6">
|
||||
<div className="w-[370px] overflow-y-auto border-l border-border bg-background p-5">
|
||||
{/* 차트 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📊 차트 종류</h3>
|
||||
<div className="mb-5">
|
||||
<button
|
||||
onClick={() => toggleSection("charts")}
|
||||
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span>차트 종류</span>
|
||||
{expandedSections.charts ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="바 차트"
|
||||
type="chart"
|
||||
subtype="bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-primary border-l-4"
|
||||
/>
|
||||
{expandedSections.charts && (
|
||||
<div className="space-y-2">
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="바 차트"
|
||||
type="chart"
|
||||
subtype="bar"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="수평 바 차트"
|
||||
type="chart"
|
||||
subtype="horizontal-bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📚"
|
||||
|
|
@ -45,7 +66,6 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="stacked-bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📈"
|
||||
|
|
@ -53,7 +73,6 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="line"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📉"
|
||||
|
|
@ -61,7 +80,6 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="area"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🥧"
|
||||
|
|
@ -69,7 +87,6 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="pie"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🍩"
|
||||
|
|
@ -77,39 +94,47 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="donut"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊📈"
|
||||
icon="📊"
|
||||
title="콤보 차트"
|
||||
type="chart"
|
||||
subtype="combo"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위젯 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">🔧 위젯 종류</h3>
|
||||
<div className="mb-5">
|
||||
<button
|
||||
onClick={() => toggleSection("widgets")}
|
||||
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span>위젯 종류</span>
|
||||
{expandedSections.widgets ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="💱"
|
||||
title="환율 위젯"
|
||||
type="widget"
|
||||
subtype="exchange"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-orange-500"
|
||||
/>
|
||||
{expandedSections.widgets && (
|
||||
<div className="space-y-2">
|
||||
<DraggableItem
|
||||
icon="💱"
|
||||
title="환율 위젯"
|
||||
type="widget"
|
||||
subtype="exchange"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="☁️"
|
||||
title="날씨 위젯"
|
||||
type="widget"
|
||||
subtype="weather"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-cyan-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🧮"
|
||||
|
|
@ -117,7 +142,6 @@ export function DashboardSidebar() {
|
|||
type="widget"
|
||||
subtype="calculator"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="⏰"
|
||||
|
|
@ -125,47 +149,28 @@ export function DashboardSidebar() {
|
|||
type="widget"
|
||||
subtype="clock"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-teal-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
title="차량 상태 현황"
|
||||
icon="📍"
|
||||
title="커스텀 지도 카드"
|
||||
type="widget"
|
||||
subtype="vehicle-status"
|
||||
subtype="map-summary"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-green-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
{/* 주석: 다른 분이 범용 리스트 작업 중 - 충돌 방지를 위해 임시 주석처리 */}
|
||||
{/* <DraggableItem
|
||||
icon="📋"
|
||||
title="차량 목록"
|
||||
title="커스텀 목록 카드"
|
||||
type="widget"
|
||||
subtype="vehicle-list"
|
||||
subtype="list-summary"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🗺️"
|
||||
title="차량 위치 지도"
|
||||
type="widget"
|
||||
subtype="vehicle-map"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-red-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📦"
|
||||
title="배송/화물 현황"
|
||||
type="widget"
|
||||
subtype="delivery-status"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-amber-500"
|
||||
/>
|
||||
/> */}
|
||||
<DraggableItem
|
||||
icon="⚠️"
|
||||
title="리스크/알림 위젯"
|
||||
type="widget"
|
||||
subtype="risk-alert"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-rose-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📅"
|
||||
|
|
@ -173,65 +178,71 @@ export function DashboardSidebar() {
|
|||
type="widget"
|
||||
subtype="calendar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-indigo-500"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🚗"
|
||||
title="기사 관리 위젯"
|
||||
icon="📊"
|
||||
title="커스텀 상태 카드"
|
||||
type="widget"
|
||||
subtype="driver-management"
|
||||
subtype="status-summary"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 운영/작업 지원 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📋 운영/작업 지원</h3>
|
||||
<div className="mb-5">
|
||||
<button
|
||||
onClick={() => toggleSection("operations")}
|
||||
className="mb-3 flex w-full items-center justify-between px-1 py-2.5 text-xl font-bold text-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
<span>운영/작업 지원</span>
|
||||
{expandedSections.operations ? (
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="✅"
|
||||
title="To-Do / 긴급 지시"
|
||||
type="widget"
|
||||
subtype="todo"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔔"
|
||||
title="예약 요청 알림"
|
||||
type="widget"
|
||||
subtype="booking-alert"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-rose-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔧"
|
||||
title="정비 일정 관리"
|
||||
type="widget"
|
||||
subtype="maintenance"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-teal-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📂"
|
||||
title="문서 다운로드"
|
||||
type="widget"
|
||||
subtype="document"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-purple-600"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="리스트 위젯"
|
||||
type="widget"
|
||||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-blue-600"
|
||||
/>
|
||||
</div>
|
||||
{expandedSections.operations && (
|
||||
<div className="space-y-2">
|
||||
<DraggableItem
|
||||
icon="✅"
|
||||
title="To-Do / 긴급 지시"
|
||||
type="widget"
|
||||
subtype="todo"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔔"
|
||||
title="예약 요청 알림"
|
||||
type="widget"
|
||||
subtype="booking-alert"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="🔧"
|
||||
title="정비 일정 관리"
|
||||
type="widget"
|
||||
subtype="maintenance"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📂"
|
||||
title="문서 다운로드"
|
||||
type="widget"
|
||||
subtype="document"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📋"
|
||||
title="리스트 위젯"
|
||||
type="widget"
|
||||
subtype="list"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -253,10 +264,9 @@ function DraggableItem({ icon, title, type, subtype, className = "", onDragStart
|
|||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`cursor-move rounded-lg border-2 border-gray-200 bg-white p-4 text-center text-sm font-medium transition-all duration-200 hover:translate-x-1 hover:border-green-500 hover:bg-gray-50 ${className} `}
|
||||
className="cursor-move rounded-md border border-border bg-card px-4 py-2.5 text-sm font-medium text-card-foreground transition-all duration-150 hover:border-primary hover:bg-accent"
|
||||
onDragStart={(e) => onDragStart(e, type, subtype)}
|
||||
>
|
||||
<span className="mr-2 text-lg">{icon}</span>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<ChartConfig>) => 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 (
|
||||
<Card className="p-4">
|
||||
<div className="flex cursor-pointer items-center justify-between" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-blue-600" />
|
||||
<Label className="cursor-pointer text-sm font-medium text-gray-700">데이터 필터 (선택)</Label>
|
||||
{dateFilter.enabled && <span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">활성</span>}
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* 필터 활성화 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enableDateFilter"
|
||||
checked={dateFilter.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({
|
||||
dateFilter: {
|
||||
...dateFilter,
|
||||
enabled: checked as boolean,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enableDateFilter" className="cursor-pointer text-sm font-normal">
|
||||
날짜 필터 사용
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{dateFilter.enabled && (
|
||||
<>
|
||||
{/* 날짜 컬럼 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 text-sm font-medium text-gray-700">날짜 컬럼</Label>
|
||||
<Select
|
||||
value={dateFilter.dateColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
onChange({
|
||||
dateFilter: {
|
||||
...dateFilter,
|
||||
dateColumn: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="날짜 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
{dateColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">감지된 날짜 컬럼: {dateColumns.join(", ")}</p>
|
||||
</div>
|
||||
|
||||
{/* 빠른 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 text-sm font-medium text-gray-700">빠른 선택</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={dateFilter.quickRange === "today" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleQuickRange("today")}
|
||||
>
|
||||
오늘
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={dateFilter.quickRange === "week" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleQuickRange("week")}
|
||||
>
|
||||
이번 주
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={dateFilter.quickRange === "month" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleQuickRange("month")}
|
||||
>
|
||||
이번 달
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={dateFilter.quickRange === "year" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleQuickRange("year")}
|
||||
>
|
||||
올해
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 직접 입력 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="mb-2 text-sm font-medium text-gray-700">시작일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFilter.startDate || ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
dateFilter: {
|
||||
...dateFilter,
|
||||
startDate: e.target.value,
|
||||
quickRange: undefined, // 직접 입력 시 빠른 선택 해제
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 text-sm font-medium text-gray-700">종료일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={dateFilter.endDate || ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
dateFilter: {
|
||||
...dateFilter,
|
||||
endDate: e.target.value,
|
||||
quickRange: undefined, // 직접 입력 시 빠른 선택 해제
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 정보 */}
|
||||
{dateFilter.startDate && dateFilter.endDate && (
|
||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-800">
|
||||
<strong>필터 적용:</strong> {dateFilter.dateColumn} 컬럼에서 {dateFilter.startDate}부터{" "}
|
||||
{dateFilter.endDate}까지 데이터를 가져옵니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ChartConfig>(element.chartConfig || {});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div
|
||||
className={`flex flex-col rounded-xl border bg-white shadow-2xl ${
|
||||
currentStep === 1 ? "h-auto max-h-[70vh] w-full max-w-3xl" : "h-[85vh] w-full max-w-5xl"
|
||||
|
|
@ -143,7 +182,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{element.title} 설정</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{currentStep === 1 ? "데이터 소스를 선택하세요" : "쿼리를 실행하고 차트를 설정하세요"}
|
||||
{isSimpleWidget
|
||||
? "데이터 소스를 설정하세요"
|
||||
: currentStep === 1
|
||||
? "데이터 소스를 선택하세요"
|
||||
: "쿼리를 실행하고 차트를 설정하세요"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
|
|
@ -151,16 +194,16 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 진행 상황 표시 */}
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
|
||||
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */}
|
||||
{!isSimpleWidget && (
|
||||
<div className="border-b bg-gray-50 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-700">
|
||||
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{Math.round((currentStep / 2) * 100)}% 완료</Badge>
|
||||
</div>
|
||||
<Progress value={(currentStep / 2) * 100} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
|
|
@ -169,7 +212,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
|
||||
{/* 왼쪽: 데이터 설정 */}
|
||||
<div className="space-y-6">
|
||||
{dataSource.type === "database" ? (
|
||||
|
|
@ -186,40 +229,53 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 차트 설정 */}
|
||||
<div>
|
||||
{queryResult && queryResult.rows.length > 0 ? (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||
{/* 오른쪽: 설정 패널 */}
|
||||
{!isSimpleWidget && (
|
||||
<div>
|
||||
{isMapWidget ? (
|
||||
// 지도 위젯: 위도/경도 매핑 패널
|
||||
queryResult && queryResult.rows.length > 0 ? (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 지도 설정이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : // 차트: 차트 설정 패널
|
||||
queryResult && queryResult.rows.length > 0 ? (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||
<div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모달 푸터 */}
|
||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||
<div>
|
||||
{queryResult && (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
📊 {queryResult.rows.length}개 데이터 로드됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>{queryResult && <Badge variant="default">{queryResult.rows.length}개 데이터 로드됨</Badge>}</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{currentStep > 1 && (
|
||||
{!isSimpleWidget && currentStep > 1 && (
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
이전
|
||||
|
|
@ -229,11 +285,13 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
|||
취소
|
||||
</Button>
|
||||
{currentStep === 1 ? (
|
||||
// 1단계: 다음 버튼 (모든 타입 공통)
|
||||
<Button onClick={handleNext}>
|
||||
다음
|
||||
<ChevronRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
// 2단계: 저장 버튼 (모든 타입 공통)
|
||||
<Button onClick={handleSave} disabled={!canSave}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
저장
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChartDataSource, QueryResult } from "./types";
|
||||
import { ChartDataSource, QueryResult, ChartConfig } from "./types";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Play, Loader2, Database, Code } from "lucide-react";
|
||||
import { applyQueryFilters } from "./utils/queryHelpers";
|
||||
|
||||
interface QueryEditorProps {
|
||||
dataSource?: ChartDataSource;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Chart } from "./Chart";
|
|||
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { applyQueryFilters } from "../utils/queryHelpers";
|
||||
|
||||
interface ChartRendererProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -105,10 +106,11 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
|||
} else if (element.dataSource.query) {
|
||||
// Database (현재 DB 또는 외부 DB)
|
||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||
// 외부 DB
|
||||
// 외부 DB - 필터 적용
|
||||
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
filteredQuery,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -122,8 +124,9 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
|||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
// 현재 DB
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
// 현재 DB - 필터 적용
|
||||
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
||||
const result = await dashboardApi.executeQuery(filteredQuery);
|
||||
queryResult = {
|
||||
columns: result.columns,
|
||||
rows: result.rows,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ChartDataSource, QueryResult, ApiResponse } from "../types";
|
||||
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -25,48 +25,72 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
const [testResult, setTestResult] = useState<QueryResult | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
// 헤더를 배열로 정규화 (객체 형식 호환)
|
||||
const normalizeHeaders = (): KeyValuePair[] => {
|
||||
if (!dataSource.headers) return [];
|
||||
if (Array.isArray(dataSource.headers)) return dataSource.headers;
|
||||
// 객체 형식이면 배열로 변환
|
||||
return Object.entries(dataSource.headers as Record<string, string>).map(([key, value]) => ({
|
||||
id: `header_${Date.now()}_${Math.random()}`,
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
};
|
||||
|
||||
// 헤더 추가
|
||||
const addHeader = () => {
|
||||
const headers = dataSource.headers || {};
|
||||
const newKey = `header_${Object.keys(headers).length + 1}`;
|
||||
onChange({ headers: { ...headers, [newKey]: "" } });
|
||||
const headers = normalizeHeaders();
|
||||
onChange({
|
||||
headers: [...headers, { id: `header_${Date.now()}`, key: "", value: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
// 헤더 제거
|
||||
const removeHeader = (key: string) => {
|
||||
const headers = { ...dataSource.headers };
|
||||
delete headers[key];
|
||||
onChange({ headers });
|
||||
const removeHeader = (id: string) => {
|
||||
const headers = normalizeHeaders();
|
||||
onChange({ headers: headers.filter((h) => h.id !== id) });
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
const updateHeader = (oldKey: string, newKey: string, value: string) => {
|
||||
const headers = { ...dataSource.headers };
|
||||
delete headers[oldKey];
|
||||
headers[newKey] = value;
|
||||
onChange({ headers });
|
||||
const updateHeader = (id: string, updates: Partial<KeyValuePair>) => {
|
||||
const headers = normalizeHeaders();
|
||||
onChange({
|
||||
headers: headers.map((h) => (h.id === id ? { ...h, ...updates } : h)),
|
||||
});
|
||||
};
|
||||
|
||||
// 쿼리 파라미터를 배열로 정규화 (객체 형식 호환)
|
||||
const normalizeQueryParams = (): KeyValuePair[] => {
|
||||
if (!dataSource.queryParams) return [];
|
||||
if (Array.isArray(dataSource.queryParams)) return dataSource.queryParams;
|
||||
// 객체 형식이면 배열로 변환
|
||||
return Object.entries(dataSource.queryParams as Record<string, string>).map(([key, value]) => ({
|
||||
id: `param_${Date.now()}_${Math.random()}`,
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 추가
|
||||
const addQueryParam = () => {
|
||||
const queryParams = dataSource.queryParams || {};
|
||||
const newKey = `param_${Object.keys(queryParams).length + 1}`;
|
||||
onChange({ queryParams: { ...queryParams, [newKey]: "" } });
|
||||
const queryParams = normalizeQueryParams();
|
||||
onChange({
|
||||
queryParams: [...queryParams, { id: `param_${Date.now()}`, key: "", value: "" }],
|
||||
});
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 제거
|
||||
const removeQueryParam = (key: string) => {
|
||||
const queryParams = { ...dataSource.queryParams };
|
||||
delete queryParams[key];
|
||||
onChange({ queryParams });
|
||||
const removeQueryParam = (id: string) => {
|
||||
const queryParams = normalizeQueryParams();
|
||||
onChange({ queryParams: queryParams.filter((p) => p.id !== id) });
|
||||
};
|
||||
|
||||
// 쿼리 파라미터 업데이트
|
||||
const updateQueryParam = (oldKey: string, newKey: string, value: string) => {
|
||||
const queryParams = { ...dataSource.queryParams };
|
||||
delete queryParams[oldKey];
|
||||
queryParams[newKey] = value;
|
||||
onChange({ queryParams });
|
||||
const updateQueryParam = (id: string, updates: Partial<KeyValuePair>) => {
|
||||
const queryParams = normalizeQueryParams();
|
||||
onChange({
|
||||
queryParams: queryParams.map((p) => (p.id === id ? { ...p, ...updates } : p)),
|
||||
});
|
||||
};
|
||||
|
||||
// API 테스트
|
||||
|
|
@ -82,14 +106,22 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
|
||||
try {
|
||||
// 쿼리 파라미터 구성
|
||||
const params = new URLSearchParams();
|
||||
if (dataSource.queryParams) {
|
||||
Object.entries(dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
const params: Record<string, string> = {};
|
||||
const normalizedQueryParams = normalizeQueryParams();
|
||||
normalizedQueryParams.forEach(({ key, value }) => {
|
||||
if (key && value) {
|
||||
params[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// 헤더 구성
|
||||
const headers: Record<string, string> = {};
|
||||
const normalizedHeaders = normalizeHeaders();
|
||||
normalizedHeaders.forEach(({ key, value }) => {
|
||||
if (key && value) {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||
|
|
@ -100,8 +132,8 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
body: JSON.stringify({
|
||||
url: dataSource.endpoint,
|
||||
method: "GET",
|
||||
headers: dataSource.headers || {},
|
||||
queryParams: Object.fromEntries(params),
|
||||
headers: headers,
|
||||
queryParams: params,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -217,31 +249,34 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{dataSource.queryParams && Object.keys(dataSource.queryParams).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.queryParams).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="key"
|
||||
value={key}
|
||||
onChange={(e) => updateQueryParam(key, e.target.value, value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => updateQueryParam(key, key, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(key)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
||||
)}
|
||||
{(() => {
|
||||
const params = normalizeQueryParams();
|
||||
return params.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{params.map((param) => (
|
||||
<div key={param.id} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="key"
|
||||
value={param.key}
|
||||
onChange={(e) => updateQueryParam(param.id, { key: e.target.value })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={param.value}
|
||||
onChange={(e) => updateQueryParam(param.id, { value: e.target.value })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(param.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
<p className="text-xs text-gray-500">예: category=electronics, limit=10</p>
|
||||
</Card>
|
||||
|
|
@ -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
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{dataSource.headers && Object.keys(dataSource.headers).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dataSource.headers).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Header Name"
|
||||
value={key}
|
||||
onChange={(e) => updateHeader(key, e.target.value, value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header Value"
|
||||
value={value}
|
||||
onChange={(e) => updateHeader(key, key, e.target.value)}
|
||||
className="flex-1"
|
||||
type={key.toLowerCase().includes("auth") ? "password" : "text"}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeHeader(key)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||
)}
|
||||
{(() => {
|
||||
const headers = normalizeHeaders();
|
||||
return headers.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{headers.map((header) => (
|
||||
<div key={header.id} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Header Name"
|
||||
value={header.key}
|
||||
onChange={(e) => updateHeader(header.id, { key: e.target.value })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Header Value"
|
||||
value={header.value}
|
||||
onChange={(e) => updateHeader(header.id, { value: e.target.value })}
|
||||
className="flex-1"
|
||||
type={header.key.toLowerCase().includes("auth") ? "password" : "text"}
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeHeader(header.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
|
||||
{/* JSON Path */}
|
||||
|
|
@ -358,7 +398,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
{/* 테스트 결과 */}
|
||||
{testResult && (
|
||||
<Card className="border-green-200 bg-green-50 p-4">
|
||||
<div className="mb-2 text-sm font-medium text-green-800">✅ API 호출 성공</div>
|
||||
<div className="mb-2 text-sm font-medium text-green-800">API 호출 성공</div>
|
||||
<div className="space-y-1 text-xs text-green-700">
|
||||
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
||||
<div>컬럼: {testResult.columns.join(", ")}</div>
|
||||
|
|
|
|||
|
|
@ -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<string, string>; // 커스텀 헤더
|
||||
queryParams?: Record<string, string>; // 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)
|
||||
|
||||
// 툴팁
|
||||
|
|
|
|||
|
|
@ -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, any>[]): 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;
|
||||
}
|
||||
|
|
@ -187,8 +187,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// 데이터 또는 설정 없음
|
||||
if (!data || config.columns.length === 0) {
|
||||
// 데이터 없음
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||
<div className="text-center">
|
||||
|
|
@ -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 && (
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{config.columns
|
||||
{displayColumns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<TableHead
|
||||
|
|
@ -227,7 +238,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||
>
|
||||
{col.label}
|
||||
{col.label || col.name}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
@ -237,7 +248,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
{paginatedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={config.columns.filter((col) => 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) => (
|
||||
<TableRow key={idx} className={config.stripedRows ? undefined : ""}>
|
||||
{config.columns
|
||||
{displayColumns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
{String(row[col.dataKey || col.field] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
@ -279,15 +290,15 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
{paginatedRows.map((row, idx) => (
|
||||
<Card key={idx} className="p-4 transition-shadow hover:shadow-md">
|
||||
<div className="space-y-2">
|
||||
{config.columns
|
||||
{displayColumns
|
||||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<div key={col.id}>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label}</div>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label || col.name}</div>
|
||||
<div
|
||||
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||
>
|
||||
{String(row[col.field] ?? "")}
|
||||
{String(row[col.dataKey || col.field] ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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<Cargo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">📦 화물 목록</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 건수 */}
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredList.length}</span>건
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="border-b border-border p-2 text-left font-medium">운송장번호</th>
|
||||
<th className="border-b border-border p-2 text-left font-medium">고객명</th>
|
||||
<th className="border-b border-border p-2 text-left font-medium">목적지</th>
|
||||
<th className="border-b border-border p-2 text-left font-medium">무게(kg)</th>
|
||||
<th className="border-b border-border p-2 text-left font-medium">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다" : "화물이 없습니다"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredList.map((cargo, index) => (
|
||||
<tr
|
||||
key={cargo.id || index}
|
||||
className="border-b border-border hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<td className="p-2 font-medium text-foreground">
|
||||
{cargo.tracking_number || cargo.trackingNumber || "-"}
|
||||
</td>
|
||||
<td className="p-2 text-foreground">
|
||||
{cargo.customer_name || cargo.customerName || "-"}
|
||||
</td>
|
||||
<td className="p-2 text-muted-foreground">
|
||||
{cargo.destination || "-"}
|
||||
</td>
|
||||
<td className="p-2 text-right text-muted-foreground">
|
||||
{cargo.weight ? `${cargo.weight}kg` : "-"}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${getStatusBadge(cargo.status || "")}`}
|
||||
>
|
||||
{cargo.status || "알 수 없음"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterPriority, setFilterPriority] = useState<string>("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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">⚠️ 고객 클레임/이슈</h3>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 필터 버튼 */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilterPriority("all")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "all"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterPriority("긴급")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "긴급"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
긴급
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterPriority("보통")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "보통"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
보통
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterPriority("낮음")}
|
||||
className={`rounded-md px-3 py-1 text-xs transition-colors ${
|
||||
filterPriority === "낮음"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
}`}
|
||||
>
|
||||
낮음
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 총 건수 */}
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredIssues.length}</span>건
|
||||
</div>
|
||||
|
||||
{/* 이슈 리스트 */}
|
||||
<div className="flex-1 space-y-2 overflow-auto">
|
||||
{filteredIssues.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||
<p>이슈가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredIssues.map((issue, index) => (
|
||||
<div
|
||||
key={issue.id || index}
|
||||
className="rounded-lg border border-border bg-card p-3 transition-all hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getPriorityBadge(issue.priority || "")}`}>
|
||||
{issue.priority || "보통"}
|
||||
</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusBadge(issue.status || "")}`}>
|
||||
{issue.status || "처리중"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{issue.issue_type || issue.issueType || "기타"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
고객: {issue.customer_name || issue.customerName || "-"}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{issue.description || "설명 없음"}
|
||||
</p>
|
||||
|
||||
{(issue.created_at || issue.createdAt) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<DeliveryStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount = statusData.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">📊 배송 상태 요약</h3>
|
||||
{totalCount > 0 ? (
|
||||
<p className="text-xs text-gray-500">총 {totalCount.toLocaleString()}건</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 데이터 연결 필요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 스크롤 가능한 콘텐츠 영역 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 상태별 카드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{statusData.map((item) => (
|
||||
<div
|
||||
key={item.status}
|
||||
className={`rounded border-l-2 bg-white p-1.5 shadow-sm ${getBorderColor(item.status)}`}
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${getDotColor(item.status)}`}></div>
|
||||
<div className="text-xs font-medium text-gray-600">{item.status}</div>
|
||||
</div>
|
||||
<div className={`text-lg font-bold ${getTextColor(item.status)}`}>{item.count.toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<TodayStats>({ shipped: 0, delivered: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-white p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-800">📅 오늘 처리 현황</h3>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-full p-1 text-gray-500 hover:bg-gray-100"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
{/* 오늘 발송 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 p-6">
|
||||
<div className="mb-2 text-4xl">📤</div>
|
||||
<p className="text-sm font-medium text-blue-700">오늘 발송</p>
|
||||
<p className="mt-2 text-4xl font-bold text-blue-800">{todayStats.shipped.toLocaleString()}</p>
|
||||
<p className="mt-1 text-xs text-blue-600">건</p>
|
||||
</div>
|
||||
|
||||
{/* 오늘 도착 */}
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-lg bg-gradient-to-br from-green-50 to-green-100 p-6">
|
||||
<div className="mb-2 text-4xl">📥</div>
|
||||
<p className="text-sm font-medium text-green-700">오늘 도착</p>
|
||||
<p className="mt-2 text-4xl font-bold text-green-800">{todayStats.delivered.toLocaleString()}</p>
|
||||
<p className="mt-1 text-xs text-green-600">건</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<any[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableName, setTableName] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<div className="text-3xl">📋</div>
|
||||
<h3 className="text-sm font-bold text-gray-900">데이터 목록</h3>
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<p className="font-medium">📋 테이블 형식 데이터 표시 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
||||
<li>• 테이블 형식으로 자동 표시</li>
|
||||
<li>• 검색 기능 지원</li>
|
||||
<li>• 실시간 데이터 모니터링 가능</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 className="mt-0.5">우측 상단 톱니바퀴 버튼을 클릭하여</p>
|
||||
<p>SQL 쿼리를 입력하고 저장하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">📋 {displayTitle}</h3>
|
||||
<p className="text-xs text-gray-500">총 {filteredData.length.toLocaleString()}건</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
{data.length > 0 && (
|
||||
<div className="mb-2 flex-shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filteredData.length > 0 ? (
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 bg-gray-100">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="border border-gray-300 px-2 py-1 text-left font-semibold text-gray-700"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
{filteredData.map((row, idx) => (
|
||||
<tr key={idx} className="hover:bg-gray-50">
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className="border border-gray-300 px-2 py-1 text-gray-800"
|
||||
>
|
||||
{String(row[col.key] || "")}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-gray-500">
|
||||
<p className="text-sm">검색 결과가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<MarkerData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableName, setTableName] = useState<string | null>(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 (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-gradient-to-br from-slate-50 to-blue-50 p-2">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">📍 {displayTitle}</h3>
|
||||
{element?.dataSource?.query ? (
|
||||
<p className="text-xs text-gray-500">총 {markers.length.toLocaleString()}개 마커</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadMapData}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={loading || !element?.dataSource?.query}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 (지도 위에 오버레이) */}
|
||||
{error && (
|
||||
<div className="mb-2 rounded border border-red-300 bg-red-50 p-2 text-center text-xs text-red-600">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지도 (항상 표시) */}
|
||||
<div className="flex-1 rounded border border-gray-300 bg-white overflow-hidden">
|
||||
<MapContainer
|
||||
center={[36.5, 127.5]}
|
||||
zoom={7}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
zoomControl={true}
|
||||
preferCanvas={true}
|
||||
>
|
||||
{/* 브이월드 타일맵 */}
|
||||
<TileLayer
|
||||
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
|
||||
attribution='© <a href="https://www.vworld.kr">VWorld (국토교통부)</a>'
|
||||
maxZoom={19}
|
||||
minZoom={7}
|
||||
updateWhenIdle={true}
|
||||
updateWhenZooming={false}
|
||||
keepBuffer={2}
|
||||
/>
|
||||
|
||||
{/* 마커 표시 */}
|
||||
{markers.map((marker, idx) => (
|
||||
<Marker key={idx} position={[marker.lat, marker.lng]}>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<StatusData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableName, setTableName] = useState<string | null>(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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-gray-500">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded bg-red-100 px-3 py-1 text-xs text-red-700 hover:bg-red-200"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!element?.dataSource?.query) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<div className="text-3xl">{icon}</div>
|
||||
<h3 className="text-sm font-bold text-gray-900">{title}</h3>
|
||||
<div className="space-y-1.5 text-xs text-gray-600">
|
||||
<p className="font-medium">📊 상태별 데이터 집계 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• SQL 쿼리로 데이터를 불러옵니다</li>
|
||||
<li>• 상태별로 자동 집계하여 카드로 표시</li>
|
||||
<li>• 실시간 데이터 모니터링 가능</li>
|
||||
<li>• 색상과 라벨 커스터마이징 지원</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 className="mt-0.5">우측 상단 톱니바퀴 버튼을 클릭하여</p>
|
||||
<p>SQL 쿼리를 입력하고 저장하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`flex h-full w-full flex-col overflow-hidden bg-gradient-to-br ${bgGradient} p-2`}>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-gray-900">{icon} {displayTitle}</h3>
|
||||
{totalCount > 0 ? (
|
||||
<p className="text-xs text-gray-500">총 {totalCount.toLocaleString()}건</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 데이터 연결 필요</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 스크롤 가능한 콘텐츠 영역 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 상태별 카드 */}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{statusData.map((item) => {
|
||||
const colors = getColorClasses(item.status);
|
||||
return (
|
||||
<div
|
||||
key={item.status}
|
||||
className="rounded border border-gray-200 bg-white p-1.5 shadow-sm"
|
||||
>
|
||||
<div className="mb-0.5 flex items-center gap-1">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${colors.dot}`}></div>
|
||||
<div className="text-xs font-medium text-gray-600">{item.status}</div>
|
||||
</div>
|
||||
<div className={`text-lg font-bold ${colors.text}`}>{item.count.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<T>(
|
||||
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<T>(
|
|||
|
||||
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<Dashboard> {
|
||||
const result = await apiRequest<Dashboard>('/dashboards', {
|
||||
method: 'POST',
|
||||
const result = await apiRequest<Dashboard>("/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<Dashboard[]>(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<Dashboard[]>(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<Dashboard> {
|
||||
const result = await apiRequest<Dashboard>(`/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<Dashboard> {
|
||||
const result = await apiRequest<Dashboard>(`/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<CreateDashboardRequest>): Promise<Dashboard> {
|
||||
const result = await apiRequest<Dashboard>(`/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<void> {
|
||||
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<Dashboard[]>(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 "알 수 없는 오류가 발생했습니다.";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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는 더 이상 필요하지 않습니다");
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue