Merge pull request '사이드바 방식으로 변경' (#130) from feat/dashboard into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/130
This commit is contained in:
commit
f5dddc99eb
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, QueryResult } from "./types";
|
||||
import { DashboardElement, QueryResult, Position } from "./types";
|
||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
|
|
@ -105,7 +105,7 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
|
|||
// 기사 관리 위젯 임포트
|
||||
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
||||
import { ListWidget } from "./widgets/ListWidget";
|
||||
import { MoreHorizontal, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// 야드 관리 3D 위젯
|
||||
|
|
@ -137,12 +137,11 @@ interface CanvasElementProps {
|
|||
canvasWidth?: number;
|
||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
|
||||
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void; // 🔥 다중 드래그 시작
|
||||
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void; // 🔥 다중 드래그 중
|
||||
onMultiDragEnd?: () => void; // 🔥 다중 드래그 종료
|
||||
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void;
|
||||
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void;
|
||||
onMultiDragEnd?: () => void;
|
||||
onRemove: (id: string) => void;
|
||||
onSelect: (id: string | null) => void;
|
||||
onConfigure?: (element: DashboardElement) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -167,7 +166,6 @@ export function CanvasElement({
|
|||
onMultiDragEnd,
|
||||
onRemove,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
}: CanvasElementProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
|
@ -226,10 +224,10 @@ export function CanvasElement({
|
|||
};
|
||||
setDragStart(startPos);
|
||||
dragStartRef.current = startPos; // 🔥 ref에도 저장
|
||||
|
||||
|
||||
// 🔥 드래그 시작 시 마우스 위치 초기화 (화면 중간)
|
||||
lastMouseYRef.current = window.innerHeight / 2;
|
||||
|
||||
|
||||
// 🔥 다중 선택된 경우, 다른 위젯들의 오프셋 계산
|
||||
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragStart) {
|
||||
const offsets: Record<string, { x: number; y: number }> = {};
|
||||
|
|
@ -246,10 +244,19 @@ export function CanvasElement({
|
|||
});
|
||||
onMultiDragStart(element.id, offsets);
|
||||
}
|
||||
|
||||
|
||||
e.preventDefault();
|
||||
},
|
||||
[element.id, element.position.x, element.position.y, onSelect, isSelected, selectedElements, allElements, onMultiDragStart],
|
||||
[
|
||||
element.id,
|
||||
element.position.x,
|
||||
element.position.y,
|
||||
onSelect,
|
||||
isSelected,
|
||||
selectedElements,
|
||||
allElements,
|
||||
onMultiDragStart,
|
||||
],
|
||||
);
|
||||
|
||||
// 리사이즈 핸들 마우스다운
|
||||
|
|
@ -280,22 +287,23 @@ export function CanvasElement({
|
|||
(e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
// 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리
|
||||
const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
|
||||
|
||||
const isFirstSelectedElement =
|
||||
!selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
|
||||
|
||||
if (isFirstSelectedElement) {
|
||||
const scrollThreshold = 100;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const mouseY = e.clientY;
|
||||
|
||||
|
||||
// 🔥 항상 마우스 위치 업데이트
|
||||
lastMouseYRef.current = mouseY;
|
||||
// console.log("🖱️ 마우스 위치 업데이트:", { mouseY, viewportHeight, top: scrollThreshold, bottom: viewportHeight - scrollThreshold });
|
||||
}
|
||||
|
||||
|
||||
// 🔥 현재 스크롤 위치를 고려한 deltaY 계산
|
||||
const currentScrollY = window.pageYOffset;
|
||||
const scrollDelta = currentScrollY - dragStartRef.current.initialScrollY;
|
||||
|
||||
|
||||
const deltaX = e.clientX - dragStartRef.current.x;
|
||||
const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영
|
||||
|
||||
|
|
@ -312,7 +320,7 @@ export function CanvasElement({
|
|||
const snappedY = Math.round(rawY / subGridSize) * subGridSize;
|
||||
|
||||
setTempPosition({ x: snappedX, y: snappedY });
|
||||
|
||||
|
||||
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
|
||||
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
|
||||
onMultiDragMove(element, { x: snappedX, y: snappedY });
|
||||
|
|
@ -410,11 +418,20 @@ export function CanvasElement({
|
|||
.map((id) => {
|
||||
const targetElement = allElements.find((el) => el.id === id);
|
||||
if (!targetElement) return null;
|
||||
|
||||
|
||||
// 현재 요소와의 상대적 위치 유지
|
||||
const relativeX = targetElement.position.x - dragStart.elementX;
|
||||
const relativeY = targetElement.position.y - dragStart.elementY;
|
||||
|
||||
|
||||
const newPosition: Position = {
|
||||
x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)),
|
||||
y: Math.max(0, finalY + relativeY),
|
||||
};
|
||||
|
||||
if (targetElement.position.z !== undefined) {
|
||||
newPosition.z = targetElement.position.z;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
updates: {
|
||||
|
|
@ -425,8 +442,8 @@ export function CanvasElement({
|
|||
},
|
||||
};
|
||||
})
|
||||
.filter((update): update is { id: string; updates: Partial<DashboardElement> } => update !== null);
|
||||
|
||||
.filter((update): update is { id: string; updates: { position: Position } } => update !== null);
|
||||
|
||||
if (updates.length > 0) {
|
||||
// console.log("🔥 다중 선택 요소 함께 이동:", updates);
|
||||
onUpdateMultiple(updates);
|
||||
|
|
@ -434,7 +451,7 @@ export function CanvasElement({
|
|||
}
|
||||
|
||||
setTempPosition(null);
|
||||
|
||||
|
||||
// 🔥 다중 드래그 종료
|
||||
if (onMultiDragEnd) {
|
||||
onMultiDragEnd();
|
||||
|
|
@ -464,7 +481,7 @@ export function CanvasElement({
|
|||
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
|
||||
|
||||
// 🔥 자동 스크롤 정리
|
||||
autoScrollDirectionRef.current = null;
|
||||
if (autoScrollFrameRef.current) {
|
||||
|
|
@ -501,32 +518,32 @@ export function CanvasElement({
|
|||
const autoScrollLoop = (currentTime: number) => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const lastMouseY = lastMouseYRef.current;
|
||||
|
||||
|
||||
// 🔥 스크롤 방향 결정
|
||||
let shouldScroll = false;
|
||||
let scrollDirection = 0;
|
||||
|
||||
if (lastMouseY < scrollThreshold) {
|
||||
// 위쪽 영역
|
||||
shouldScroll = true;
|
||||
scrollDirection = -scrollSpeed;
|
||||
// console.log("⬆️ 위로 스크롤 조건 만족:", { lastMouseY, scrollThreshold });
|
||||
} else if (lastMouseY > viewportHeight - scrollThreshold) {
|
||||
// 아래쪽 영역
|
||||
shouldScroll = true;
|
||||
scrollDirection = scrollSpeed;
|
||||
// console.log("⬇️ 아래로 스크롤 조건 만족:", { lastMouseY, boundary: viewportHeight - scrollThreshold });
|
||||
}
|
||||
|
||||
// 🔥 프레임 간격 계산
|
||||
const deltaTime = currentTime - lastTime;
|
||||
|
||||
// 🔥 10ms 간격으로 스크롤
|
||||
if (shouldScroll && deltaTime >= 10) {
|
||||
window.scrollBy(0, scrollDirection);
|
||||
// console.log("✅ 스크롤 실행:", { scrollDirection, deltaTime });
|
||||
lastTime = currentTime;
|
||||
}
|
||||
|
||||
if (lastMouseY < scrollThreshold) {
|
||||
// 위쪽 영역
|
||||
shouldScroll = true;
|
||||
scrollDirection = -scrollSpeed;
|
||||
// console.log("⬆️ 위로 스크롤 조건 만족:", { lastMouseY, scrollThreshold });
|
||||
} else if (lastMouseY > viewportHeight - scrollThreshold) {
|
||||
// 아래쪽 영역
|
||||
shouldScroll = true;
|
||||
scrollDirection = scrollSpeed;
|
||||
// console.log("⬇️ 아래로 스크롤 조건 만족:", { lastMouseY, boundary: viewportHeight - scrollThreshold });
|
||||
}
|
||||
|
||||
// 🔥 프레임 간격 계산
|
||||
const deltaTime = currentTime - lastTime;
|
||||
|
||||
// 🔥 10ms 간격으로 스크롤
|
||||
if (shouldScroll && deltaTime >= 10) {
|
||||
window.scrollBy(0, scrollDirection);
|
||||
// console.log("✅ 스크롤 실행:", { scrollDirection, deltaTime });
|
||||
lastTime = currentTime;
|
||||
}
|
||||
|
||||
// 계속 반복
|
||||
animationFrameId = requestAnimationFrame(autoScrollLoop);
|
||||
|
|
@ -671,10 +688,15 @@ export function CanvasElement({
|
|||
|
||||
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||||
// 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선)
|
||||
const displayPosition = tempPosition || (multiDragOffset && !isDragging ? {
|
||||
x: element.position.x + multiDragOffset.x,
|
||||
y: element.position.y + multiDragOffset.y,
|
||||
} : element.position);
|
||||
const displayPosition: Position =
|
||||
tempPosition ||
|
||||
(multiDragOffset && !isDragging
|
||||
? {
|
||||
x: element.position.x + multiDragOffset.x,
|
||||
y: element.position.y + multiDragOffset.y,
|
||||
...(element.position.z !== undefined && { z: element.position.z }),
|
||||
}
|
||||
: element.position);
|
||||
const displaySize = tempSize || element.size;
|
||||
|
||||
return (
|
||||
|
|
@ -696,18 +718,6 @@ export function CanvasElement({
|
|||
<div className="flex cursor-move items-center justify-between p-3">
|
||||
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
|
||||
<div className="flex gap-1">
|
||||
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
|
||||
{onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-gray-400"
|
||||
onClick={() => onConfigure(element)}
|
||||
title="설정"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { ChartConfig, QueryResult } from "./types";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -97,32 +96,17 @@ export function ChartConfigPanel({
|
|||
// (SELECT에 없어도 WHERE 절에 사용 가능)
|
||||
setDateColumns(schema.dateColumns);
|
||||
})
|
||||
.catch((error) => {
|
||||
// console.error("❌ 테이블 스키마 조회 실패:", error);
|
||||
.catch(() => {
|
||||
// 실패 시 빈 배열 (날짜 필터 비활성화)
|
||||
setDateColumns([]);
|
||||
});
|
||||
}, [query, queryResult, dataSourceType]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{/* 데이터 필드 매핑 */}
|
||||
{queryResult && (
|
||||
<>
|
||||
{/* API 응답 미리보기 */}
|
||||
{queryResult.rows && queryResult.rows.length > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
<div className="rounded bg-white p-3 text-xs">
|
||||
<div className="mb-2 text-gray-600">총 {queryResult.totalRows}개 데이터 중 첫 번째 행:</div>
|
||||
<pre className="overflow-x-auto text-gray-800">{JSON.stringify(sampleData, null, 2)}</pre>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 복잡한 타입 경고 */}
|
||||
{complexColumns.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
|
|
@ -150,26 +134,27 @@ export function ChartConfigPanel({
|
|||
)}
|
||||
|
||||
{/* 차트 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label>차트 제목</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">차트 제목</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={currentConfig.title || ""}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="차트 제목을 입력하세요"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* X축 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
X축 (카테고리)
|
||||
<span className="ml-1 text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
|
|
@ -183,41 +168,41 @@ export function ChartConfigPanel({
|
|||
: "";
|
||||
|
||||
return (
|
||||
<SelectItem key={col} value={col}>
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
{previewText && <span className="ml-2 text-xs text-gray-500">(예: {previewText})</span>}
|
||||
{previewText && <span className="ml-1.5 text-[10px] text-gray-500">(예: {previewText})</span>}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
<p className="text-[11px] text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Y축 설정 (다중 선택 가능) */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
Y축 (값) - 여러 개 선택 가능
|
||||
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
||||
{(isPieChart || isApiSource) && (
|
||||
<span className="ml-2 text-xs text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||
<span className="ml-1.5 text-[11px] text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||
)}
|
||||
</Label>
|
||||
<Card className="max-h-60 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2">
|
||||
<div className="space-y-1.5">
|
||||
{/* 숫자 타입 우선 표시 */}
|
||||
{numericColumns.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium text-green-700">숫자 타입 (권장)</div>
|
||||
<div className="mb-1.5 text-[11px] 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-green-500 bg-green-50 p-2">
|
||||
<div key={col} className="flex items-center gap-1.5 rounded border-green-500 bg-green-50 p-1.5">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
|
|
@ -241,10 +226,10 @@ export function ChartConfigPanel({
|
|||
updateConfig({ yAxis: newYAxis });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
<Label className="flex-1 cursor-pointer text-xs font-normal">
|
||||
<span className="font-medium">{col}</span>
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-2 text-xs text-gray-600">(예: {sampleData[col]})</span>
|
||||
<span className="ml-1.5 text-[10px] text-gray-600">(예: {sampleData[col]})</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -256,8 +241,8 @@ 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>
|
||||
{numericColumns.length > 0 && <div className="my-1.5 border-t"></div>}
|
||||
<div className="mb-1.5 text-[11px] font-medium text-gray-600">기타 타입</div>
|
||||
{simpleColumns
|
||||
.filter((col) => !numericColumns.includes(col))
|
||||
.map((col) => {
|
||||
|
|
@ -266,7 +251,7 @@ export function ChartConfigPanel({
|
|||
: currentConfig.yAxis === col;
|
||||
|
||||
return (
|
||||
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
|
||||
<div key={col} className="flex items-center gap-1.5 rounded p-1.5 hover:bg-gray-50">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => {
|
||||
|
|
@ -290,10 +275,10 @@ export function ChartConfigPanel({
|
|||
updateConfig({ yAxis: newYAxis });
|
||||
}}
|
||||
/>
|
||||
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||
<Label className="flex-1 cursor-pointer text-xs font-normal">
|
||||
{col}
|
||||
{sampleData[col] !== undefined && (
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
<span className="ml-1.5 text-[10px] text-gray-500">
|
||||
(예: {String(sampleData[col]).substring(0, 30)})
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -304,11 +289,11 @@ export function ChartConfigPanel({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{simpleColumns.length === 0 && (
|
||||
<p className="text-xs text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
<p className="text-[11px] text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-[11px] text-gray-500">
|
||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -316,10 +301,10 @@ export function ChartConfigPanel({
|
|||
<Separator />
|
||||
|
||||
{/* 집계 함수 */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
집계 함수
|
||||
<span className="ml-2 text-xs text-gray-500">(데이터 처리 방식)</span>
|
||||
<span className="ml-1.5 text-[11px] text-gray-500">(데이터 처리 방식)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.aggregation || "none"}
|
||||
|
|
@ -329,40 +314,54 @@ export function ChartConfigPanel({
|
|||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
||||
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
||||
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
||||
<SelectItem value="count">개수 (COUNT) - 데이터 개수</SelectItem>
|
||||
<SelectItem value="max">최대값 (MAX) - 가장 큰 값</SelectItem>
|
||||
<SelectItem value="min">최소값 (MIN) - 가장 작은 값</SelectItem>
|
||||
<SelectItem value="none" className="text-xs">
|
||||
없음 - SQL에서 집계됨
|
||||
</SelectItem>
|
||||
<SelectItem value="sum" className="text-xs">
|
||||
합계 (SUM) - 모든 값을 더함
|
||||
</SelectItem>
|
||||
<SelectItem value="avg" className="text-xs">
|
||||
평균 (AVG) - 평균값 계산
|
||||
</SelectItem>
|
||||
<SelectItem value="count" className="text-xs">
|
||||
개수 (COUNT) - 데이터 개수
|
||||
</SelectItem>
|
||||
<SelectItem value="max" className="text-xs">
|
||||
최대값 (MAX) - 가장 큰 값
|
||||
</SelectItem>
|
||||
<SelectItem value="min" className="text-xs">
|
||||
최소값 (MIN) - 가장 작은 값
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-[11px] text-gray-500">
|
||||
그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹핑 필드 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">
|
||||
그룹핑 필드 (선택사항)
|
||||
<span className="ml-2 text-xs text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
||||
<span className="ml-1.5 text-[11px] text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={currentConfig.groupBy || undefined}
|
||||
onValueChange={(value) => updateConfig({ groupBy: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="없음" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectItem value="__none__">없음</SelectItem>
|
||||
<SelectItem value="__none__" className="text-xs">
|
||||
없음
|
||||
</SelectItem>
|
||||
{availableColumns.map((col) => (
|
||||
<SelectItem key={col} value={col}>
|
||||
<SelectItem key={col} value={col} className="text-xs">
|
||||
{col}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -373,8 +372,8 @@ export function ChartConfigPanel({
|
|||
<Separator />
|
||||
|
||||
{/* 차트 색상 */}
|
||||
<div className="space-y-2">
|
||||
<Label>차트 색상</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">차트 색상</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[
|
||||
["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ import React, { useState, useRef, useCallback } from "react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { DashboardCanvas } from "./DashboardCanvas";
|
||||
import { DashboardTopMenu } from "./DashboardTopMenu";
|
||||
import { ElementConfigModal } from "./ElementConfigModal";
|
||||
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
|
||||
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
|
||||
import { ElementConfigSidebar } from "./ElementConfigSidebar";
|
||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils";
|
||||
|
|
@ -44,9 +42,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
const { refreshMenus } = useMenu();
|
||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 🔥 다중 선택
|
||||
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 다중 선택
|
||||
const [elementCounter, setElementCounter] = useState(0);
|
||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -59,6 +56,10 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
|
||||
// 사이드바 상태
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [sidebarElement, setSidebarElement] = useState<DashboardElement | null>(null);
|
||||
|
||||
// 클립보드 (복사/붙여넣기용)
|
||||
const [clipboard, setClipboard] = useState<DashboardElement | null>(null);
|
||||
|
||||
|
|
@ -290,8 +291,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
if (selectedElement === id) {
|
||||
setSelectedElement(null);
|
||||
}
|
||||
// 삭제된 요소의 사이드바가 열려있으면 닫기
|
||||
if (sidebarElement?.id === id) {
|
||||
setSidebarOpen(false);
|
||||
setSidebarElement(null);
|
||||
}
|
||||
},
|
||||
[selectedElement],
|
||||
[selectedElement, sidebarElement],
|
||||
);
|
||||
|
||||
// 키보드 단축키 핸들러들
|
||||
|
|
@ -336,7 +342,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
onDelete: handleDeleteSelected,
|
||||
onCopy: handleCopyElement,
|
||||
onPaste: handlePasteElement,
|
||||
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen,
|
||||
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen && !sidebarOpen,
|
||||
});
|
||||
|
||||
// 전체 삭제 확인 모달 열기
|
||||
|
|
@ -352,32 +358,32 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
setClearConfirmOpen(false);
|
||||
}, []);
|
||||
|
||||
// 요소 설정 모달 열기
|
||||
const openConfigModal = useCallback((element: DashboardElement) => {
|
||||
setConfigModalElement(element);
|
||||
}, []);
|
||||
|
||||
// 요소 설정 모달 닫기
|
||||
const closeConfigModal = useCallback(() => {
|
||||
setConfigModalElement(null);
|
||||
}, []);
|
||||
|
||||
// 요소 설정 저장
|
||||
const saveElementConfig = useCallback(
|
||||
(updatedElement: DashboardElement) => {
|
||||
updateElement(updatedElement.id, updatedElement);
|
||||
},
|
||||
[updateElement],
|
||||
);
|
||||
|
||||
// 리스트 위젯 설정 저장 (Partial 업데이트)
|
||||
const saveListWidgetConfig = useCallback(
|
||||
// 리스트/야드 위젯 설정 저장 (Partial 업데이트)
|
||||
const saveWidgetConfig = useCallback(
|
||||
(updates: Partial<DashboardElement>) => {
|
||||
if (configModalElement) {
|
||||
updateElement(configModalElement.id, updates);
|
||||
if (sidebarElement) {
|
||||
updateElement(sidebarElement.id, updates);
|
||||
}
|
||||
},
|
||||
[configModalElement, updateElement],
|
||||
[sidebarElement, updateElement],
|
||||
);
|
||||
|
||||
// 사이드바 닫기
|
||||
const handleCloseSidebar = useCallback(() => {
|
||||
setSidebarOpen(false);
|
||||
setSidebarElement(null);
|
||||
setSelectedElement(null);
|
||||
}, []);
|
||||
|
||||
// 사이드바 적용
|
||||
const handleApplySidebar = useCallback(
|
||||
(updatedElement: DashboardElement) => {
|
||||
updateElement(updatedElement.id, updatedElement);
|
||||
// 사이드바는 열린 채로 유지하여 연속 수정 가능
|
||||
// 단, sidebarElement도 업데이트해서 최신 상태 반영
|
||||
setSidebarElement(updatedElement);
|
||||
},
|
||||
[updateElement],
|
||||
);
|
||||
|
||||
// 레이아웃 저장
|
||||
|
|
@ -560,14 +566,22 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
onRemoveElement={removeElement}
|
||||
onSelectElement={(id) => {
|
||||
setSelectedElement(id);
|
||||
setSelectedElements([]); // 단일 선택 시 다중 선택 해제
|
||||
setSelectedElements([]);
|
||||
|
||||
// 선택된 요소 찾아서 사이드바 열기
|
||||
const element = elements.find((el) => el.id === id);
|
||||
if (element) {
|
||||
setSidebarElement(element);
|
||||
setSidebarOpen(true);
|
||||
}
|
||||
}}
|
||||
onSelectMultiple={(ids) => {
|
||||
console.log("🎯 DashboardDesigner - onSelectMultiple 호출:", ids);
|
||||
setSelectedElements(ids);
|
||||
setSelectedElement(null); // 다중 선택 시 단일 선택 해제
|
||||
setSelectedElement(null);
|
||||
setSidebarOpen(false);
|
||||
setSidebarElement(null);
|
||||
}}
|
||||
onConfigureElement={openConfigModal}
|
||||
onConfigureElement={() => {}}
|
||||
backgroundColor={canvasBackgroundColor}
|
||||
canvasWidth={canvasConfig.width}
|
||||
canvasHeight={dynamicCanvasHeight}
|
||||
|
|
@ -575,33 +589,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 요소 설정 모달 */}
|
||||
{configModalElement && (
|
||||
<>
|
||||
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
|
||||
<ListWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveListWidgetConfig}
|
||||
/>
|
||||
) : configModalElement.type === "widget" && configModalElement.subtype === "yard-management-3d" ? (
|
||||
<YardWidgetConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveListWidgetConfig}
|
||||
/>
|
||||
) : (
|
||||
<ElementConfigModal
|
||||
element={configModalElement}
|
||||
isOpen={true}
|
||||
onClose={closeConfigModal}
|
||||
onSave={saveElementConfig}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 요소 설정 사이드바 (리스트/야드 위젯 포함) */}
|
||||
<ElementConfigSidebar
|
||||
element={sidebarElement}
|
||||
isOpen={sidebarOpen}
|
||||
onClose={handleCloseSidebar}
|
||||
onApply={handleApplySidebar}
|
||||
/>
|
||||
|
||||
{/* 저장 모달 */}
|
||||
<DashboardSaveModal
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
"use client";
|
||||
|
||||
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 { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
|
||||
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface ElementConfigSidebarProps {
|
||||
element: DashboardElement | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (element: DashboardElement) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소 설정 사이드바 컴포넌트
|
||||
* - 왼쪽에서 슬라이드 인/아웃
|
||||
* - 캔버스 위에 오버레이
|
||||
* - "적용" 버튼으로 명시적 저장
|
||||
*/
|
||||
export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) {
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [customTitle, setCustomTitle] = useState<string>("");
|
||||
const [showHeader, setShowHeader] = useState<boolean>(true);
|
||||
|
||||
// 사이드바가 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen && element) {
|
||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
||||
setChartConfig(element.chartConfig || {});
|
||||
setQueryResult(null);
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
// Esc 키로 사이드바 닫기
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEsc);
|
||||
return () => window.removeEventListener("keydown", handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
} else {
|
||||
setDataSource({
|
||||
type: "api",
|
||||
method: "GET",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
|
||||
setQueryResult(null);
|
||||
setChartConfig({});
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 차트 설정 변경 처리
|
||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
||||
setChartConfig(newConfig);
|
||||
}, []);
|
||||
|
||||
// 쿼리 테스트 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
setChartConfig({});
|
||||
}, []);
|
||||
|
||||
// 적용 처리
|
||||
const handleApply = useCallback(() => {
|
||||
if (!element) return;
|
||||
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
dataSource,
|
||||
chartConfig,
|
||||
customTitle: customTitle.trim() || undefined,
|
||||
showHeader,
|
||||
};
|
||||
|
||||
onApply(updatedElement);
|
||||
// 사이드바는 열린 채로 유지 (연속 수정 가능)
|
||||
}, [element, dataSource, chartConfig, customTitle, showHeader, onApply]);
|
||||
|
||||
// 요소가 없으면 렌더링하지 않음
|
||||
if (!element) return null;
|
||||
|
||||
// 리스트 위젯은 별도 사이드바로 처리
|
||||
if (element.subtype === "list") {
|
||||
return (
|
||||
<ListWidgetConfigSidebar
|
||||
element={element}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onApply={(updatedElement) => {
|
||||
onApply(updatedElement);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 야드 위젯은 사이드바로 처리
|
||||
if (element.subtype === "yard-management-3d") {
|
||||
return (
|
||||
<YardWidgetConfigSidebar
|
||||
element={element}
|
||||
isOpen={isOpen}
|
||||
onApply={(updates) => {
|
||||
onApply({ ...element, ...updates });
|
||||
}}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
||||
const isSimpleWidget =
|
||||
element.subtype === "todo" ||
|
||||
element.subtype === "booking-alert" ||
|
||||
element.subtype === "maintenance" ||
|
||||
element.subtype === "document" ||
|
||||
element.subtype === "risk-alert" ||
|
||||
element.subtype === "vehicle-status" ||
|
||||
element.subtype === "vehicle-list" ||
|
||||
element.subtype === "status-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" ||
|
||||
element.subtype === "work-history" ||
|
||||
element.subtype === "transport-stats";
|
||||
|
||||
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
||||
const isSelfContainedWidget =
|
||||
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
|
||||
|
||||
// 지도 위젯 (위도/경도 매핑 필요)
|
||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
||||
|
||||
// 헤더 전용 위젯
|
||||
const isHeaderOnlyWidget =
|
||||
element.type === "widget" &&
|
||||
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
||||
|
||||
// 저장 가능 여부 확인
|
||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||
const isApiSource = dataSource.type === "api";
|
||||
|
||||
const hasYAxis =
|
||||
chartConfig.yAxis &&
|
||||
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||
|
||||
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
||||
const isHeaderChanged = showHeader !== (element.showHeader !== false);
|
||||
|
||||
const canApply =
|
||||
isTitleChanged ||
|
||||
isHeaderChanged ||
|
||||
(isSimpleWidget
|
||||
? queryResult && queryResult.rows.length > 0
|
||||
: isMapWidget
|
||||
? queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
||||
: queryResult &&
|
||||
queryResult.rows.length > 0 &&
|
||||
chartConfig.xAxis &&
|
||||
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||
<span className="text-primary text-xs font-bold">⚙</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-900">{element.title}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문: 스크롤 가능 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{/* 기본 설정 카드 */}
|
||||
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">기본 설정</div>
|
||||
<div className="space-y-2">
|
||||
{/* 커스텀 제목 입력 */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="위젯 제목"
|
||||
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 옵션 */}
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 transition-colors hover:border-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showHeader"
|
||||
checked={showHeader}
|
||||
onChange={(e) => setShowHeader(e.target.checked)}
|
||||
className="text-primary focus:ring-primary h-3 w-3 rounded border-gray-300"
|
||||
/>
|
||||
<span className="text-xs text-gray-700">헤더 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
|
||||
{!isHeaderOnlyWidget && (
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={dataSource.type}
|
||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
|
||||
<TabsTrigger
|
||||
value="database"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
>
|
||||
데이터베이스
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="api"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
>
|
||||
REST API
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="database" className="mt-2 space-y-2">
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api" className="mt-2 space-y-2">
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{isMapWidget ? (
|
||||
<VehicleMapConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
) : (
|
||||
<ChartConfigPanel
|
||||
config={chartConfig}
|
||||
queryResult={queryResult}
|
||||
onConfigChange={handleChartConfigChange}
|
||||
chartType={element.subtype}
|
||||
dataSourceType={dataSource.type}
|
||||
query={dataSource.query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 데이터 로드 상태 */}
|
||||
{queryResult && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-[10px] font-medium text-green-700">
|
||||
{queryResult.rows.length}개 데이터 로드됨
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터: 적용 버튼 */}
|
||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={isHeaderOnlyWidget ? false : !canApply}
|
||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,8 @@ import { Card } from "@/components/ui/card";
|
|||
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { applyQueryFilters } from "./utils/queryHelpers";
|
||||
|
||||
interface QueryEditorProps {
|
||||
|
|
@ -32,6 +33,7 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
|||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sampleQueryOpen, setSampleQueryOpen] = useState(false);
|
||||
|
||||
// 쿼리 실행
|
||||
const executeQuery = useCallback(async () => {
|
||||
|
|
@ -155,55 +157,75 @@ ORDER BY 하위부서수 DESC`,
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{/* 쿼리 에디터 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
<h4 className="text-lg font-semibold text-gray-800">SQL 쿼리 에디터</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Database className="h-3.5 w-3.5 text-blue-600" />
|
||||
<h4 className="text-xs font-semibold text-gray-800">SQL 쿼리 에디터</h4>
|
||||
</div>
|
||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm">
|
||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
|
||||
{isExecuting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
실행 중...
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
실행 중
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
<Play className="mr-1.5 h-3 w-3" />
|
||||
실행
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 샘플 쿼리 버튼들 */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label className="text-sm text-gray-600">샘플 쿼리:</Label>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("users")}>
|
||||
<Code className="mr-2 h-3 w-3" />
|
||||
부서별 사용자
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("dept")}>
|
||||
<Code className="mr-2 h-3 w-3" />
|
||||
부서 정보
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("usersByDate")}>
|
||||
월별 가입 추이
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("usersByPosition")}>
|
||||
직급별 분포
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("deptHierarchy")}>
|
||||
부서 계층
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
{/* 샘플 쿼리 아코디언 */}
|
||||
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100">
|
||||
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
샘플 쿼리
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => insertSampleQuery("users")}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
부서별 사용자
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("dept")}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
부서 정보
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("usersByDate")}
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
>
|
||||
월별 가입 추이
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("usersByPosition")}
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
>
|
||||
직급별 분포
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("deptHierarchy")}
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
||||
>
|
||||
부서 계층
|
||||
</button>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* SQL 쿼리 입력 영역 */}
|
||||
<div className="space-y-2">
|
||||
<Label>SQL 쿼리</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">SQL 쿼리</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={query}
|
||||
|
|
@ -213,14 +235,14 @@ ORDER BY 하위부서수 DESC`,
|
|||
e.stopPropagation();
|
||||
}}
|
||||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
||||
className="h-40 resize-none font-mono text-sm"
|
||||
className="h-32 resize-none font-mono text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새로고침 간격 설정 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm">자동 새로고침:</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs">자동 새로고침:</Label>
|
||||
<Select
|
||||
value={String(dataSource?.refreshInterval ?? 0)}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -232,26 +254,38 @@ ORDER BY 하위부서수 DESC`,
|
|||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectTrigger className="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectItem value="0">수동</SelectItem>
|
||||
<SelectItem value="10000">10초</SelectItem>
|
||||
<SelectItem value="30000">30초</SelectItem>
|
||||
<SelectItem value="60000">1분</SelectItem>
|
||||
<SelectItem value="300000">5분</SelectItem>
|
||||
<SelectItem value="600000">10분</SelectItem>
|
||||
<SelectItem value="0" className="text-xs">
|
||||
수동
|
||||
</SelectItem>
|
||||
<SelectItem value="10000" className="text-xs">
|
||||
10초
|
||||
</SelectItem>
|
||||
<SelectItem value="30000" className="text-xs">
|
||||
30초
|
||||
</SelectItem>
|
||||
<SelectItem value="60000" className="text-xs">
|
||||
1분
|
||||
</SelectItem>
|
||||
<SelectItem value="300000" className="text-xs">
|
||||
5분
|
||||
</SelectItem>
|
||||
<SelectItem value="600000" className="text-xs">
|
||||
10분
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertDescription>
|
||||
<div className="text-sm font-medium">오류</div>
|
||||
<div className="mt-1 text-sm">{error}</div>
|
||||
<div className="text-xs font-medium">오류</div>
|
||||
<div className="mt-0.5 text-xs">{error}</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
|
@ -259,24 +293,28 @@ ORDER BY 하위부서수 DESC`,
|
|||
{/* 쿼리 결과 미리보기 */}
|
||||
{queryResult && (
|
||||
<Card>
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-2 py-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">쿼리 결과</span>
|
||||
<Badge variant="secondary">{queryResult.rows.length}행</Badge>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-gray-700">쿼리 결과</span>
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{queryResult.rows.length}행
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">실행 시간: {queryResult.executionTime}ms</span>
|
||||
<span className="text-[10px] text-gray-500">실행 시간: {queryResult.executionTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
<div className="p-2">
|
||||
{queryResult.rows.length > 0 ? (
|
||||
<div className="max-h-60 overflow-auto">
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{queryResult.columns.map((col, idx) => (
|
||||
<TableHead key={idx}>{col}</TableHead>
|
||||
<TableHead key={idx} className="h-7 text-[11px]">
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -284,7 +322,9 @@ ORDER BY 하위부서수 DESC`,
|
|||
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
||||
<TableRow key={idx}>
|
||||
{queryResult.columns.map((col, colIdx) => (
|
||||
<TableCell key={colIdx}>{String(row[col] ?? "")}</TableCell>
|
||||
<TableCell key={colIdx} className="py-1 text-[11px]">
|
||||
{String(row[col] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
@ -292,13 +332,13 @@ ORDER BY 하위부서수 DESC`,
|
|||
</Table>
|
||||
|
||||
{queryResult.rows.length > 10 && (
|
||||
<div className="mt-3 text-center text-xs text-gray-500">
|
||||
<div className="mt-2 text-center text-[10px] text-gray-500">
|
||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">결과가 없습니다.</div>
|
||||
<div className="py-6 text-center text-xs text-gray-500">결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -29,13 +29,13 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
const sampleData = queryResult?.rows?.[0] || {};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-lg font-semibold text-gray-800">🗺️ 지도 설정</h4>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-gray-800">🗺️ 지도 설정</h4>
|
||||
|
||||
{/* 쿼리 결과가 없을 때 */}
|
||||
{!queryResult && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-sm">
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-yellow-800 text-xs">
|
||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 지도를 설정할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -45,27 +45,27 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
{queryResult && (
|
||||
<>
|
||||
{/* 지도 제목 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">지도 제목</label>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">지도 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentConfig.title || ''}
|
||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||
placeholder="차량 위치 지도"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 위도 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
위도 컬럼 (Latitude)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.latitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -77,15 +77,15 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
</div>
|
||||
|
||||
{/* 경도 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
경도 컬럼 (Longitude)
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.longitudeColumn || ''}
|
||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -97,14 +97,14 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
</div>
|
||||
|
||||
{/* 라벨 컬럼 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
라벨 컬럼 (마커 표시명)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.labelColumn || ''}
|
||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -116,14 +116,14 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
</div>
|
||||
|
||||
{/* 상태 컬럼 (선택사항) */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-gray-700">
|
||||
상태 컬럼 (마커 색상)
|
||||
</label>
|
||||
<select
|
||||
value={currentConfig.statusColumn || ''}
|
||||
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
||||
>
|
||||
<option value="">선택하세요 (선택사항)</option>
|
||||
{availableColumns.map((col) => (
|
||||
|
|
@ -136,7 +136,7 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
|
||||
{/* 설정 미리보기 */}
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
||||
<div><strong>경도:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
|
||||
|
|
@ -149,7 +149,7 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
|||
{/* 필수 필드 확인 */}
|
||||
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="text-red-800 text-sm">
|
||||
<div className="text-red-800 text-xs">
|
||||
⚠️ 위도와 경도 컬럼을 반드시 선택해야 지도에 표시할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ChartDataSource, QueryResult, KeyValuePair } 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";
|
||||
|
|
@ -314,55 +313,48 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">2단계: REST API 설정</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터를 가져올 설정을 입력하세요</p>
|
||||
</div>
|
||||
|
||||
{/* 외부 커넥션 선택 */}
|
||||
{apiConnections.length > 0 && (
|
||||
<Card className="space-y-4 p-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||
<SelectTrigger className="mt-2">
|
||||
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="manual">직접 입력</SelectItem>
|
||||
{apiConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||
{conn.connection_name}
|
||||
{conn.description && <span className="ml-2 text-xs text-gray-500">({conn.description})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-gray-500">외부 커넥션 관리에서 저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
직접 입력
|
||||
</SelectItem>
|
||||
{apiConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
||||
{conn.connection_name}
|
||||
{conn.description && <span className="ml-1.5 text-[10px] text-gray-500">({conn.description})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-gray-500">저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API URL */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-gray-700">API URL *</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/data"
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-gray-700">API URL *</Label>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder="https://api.example.com/data"
|
||||
value={dataSource.endpoint || ""}
|
||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 파라미터 */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">URL 쿼리 파라미터</Label>
|
||||
<Button variant="outline" size="sm" onClick={addQueryParam}>
|
||||
<Label className="text-xs font-medium text-gray-700">URL 쿼리 파라미터</Label>
|
||||
<Button variant="outline" size="sm" onClick={addQueryParam} className="h-6 text-[11px]">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
|
|
@ -371,39 +363,42 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
{(() => {
|
||||
const params = normalizeQueryParams();
|
||||
return params.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
{params.map((param) => (
|
||||
<div key={param.id} className="flex gap-2">
|
||||
<div key={param.id} className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder="key"
|
||||
value={param.key}
|
||||
onChange={(e) => updateQueryParam(param.id, { key: e.target.value })}
|
||||
className="flex-1"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={param.value}
|
||||
onChange={(e) => updateQueryParam(param.id, { value: e.target.value })}
|
||||
className="flex-1"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(param.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => removeQueryParam(param.id)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
||||
<p className="py-2 text-center text-[11px] text-gray-500">추가된 파라미터가 없습니다</p>
|
||||
);
|
||||
})()}
|
||||
|
||||
<p className="text-xs text-gray-500">예: category=electronics, limit=10</p>
|
||||
</Card>
|
||||
<p className="text-[11px] text-gray-500">예: category=electronics, limit=10</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">요청 헤더</Label>
|
||||
<Label className="text-xs font-medium text-gray-700">요청 헤더</Label>
|
||||
<Button variant="outline" size="sm" onClick={addHeader}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
|
|
@ -467,22 +462,22 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* JSON Path */}
|
||||
<Card className="space-y-2 p-4">
|
||||
<Label className="text-sm font-medium text-gray-700">JSON Path (선택)</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-gray-700">JSON Path (선택)</Label>
|
||||
<Input
|
||||
placeholder="data.results"
|
||||
value={dataSource.jsonPath || ""}
|
||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-[11px] text-gray-500">
|
||||
JSON 응답에서 데이터 배열의 경로 (예: data.results, items, response.data)
|
||||
<br />
|
||||
비워두면 전체 응답을 사용합니다
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 테스트 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
|
|
@ -503,7 +498,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
|
||||
{/* 테스트 오류 */}
|
||||
{testError && (
|
||||
<Card className="border-red-200 bg-red-50 p-4">
|
||||
<div className="rounded bg-red-50 px-2 py-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<div>
|
||||
|
|
@ -511,18 +506,18 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
|||
<div className="mt-1 text-sm text-red-700">{testError}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테스트 결과 */}
|
||||
{testResult && (
|
||||
<Card className="border-green-200 bg-green-50 p-4">
|
||||
<div className="rounded bg-green-50 px-2 py-2">
|
||||
<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>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChartDataSource } from "../types";
|
||||
import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Database, Server } from "lucide-react";
|
||||
|
||||
interface DatabaseConfigProps {
|
||||
|
|
@ -20,6 +19,7 @@ interface DatabaseConfigProps {
|
|||
* - 외부 커넥션 목록 불러오기
|
||||
*/
|
||||
export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||
const router = useRouter();
|
||||
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -49,93 +49,87 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
const selectedConnection = connections.find((conn) => String(conn.id) === dataSource.externalConnectionId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">2단계: 데이터베이스 설정</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">데이터를 조회할 데이터베이스를 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 현재 DB vs 외부 DB 선택 */}
|
||||
<Card className="p-4">
|
||||
<Label className="mb-3 block text-sm font-medium text-gray-700">데이터베이스 선택</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant={dataSource.connectionType === "current" ? "default" : "outline"}
|
||||
className="h-auto justify-start py-3"
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-gray-700">데이터베이스 선택</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onChange({ connectionType: "current", externalConnectionId: undefined });
|
||||
}}
|
||||
className={`flex flex-1 items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-colors ${
|
||||
dataSource.connectionType === "current"
|
||||
? "bg-primary border-primary text-white"
|
||||
: "border-gray-200 bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">현재 데이터베이스</div>
|
||||
<div className="text-xs opacity-80">애플리케이션 기본 DB</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Database className="h-3 w-3" />
|
||||
현재 DB
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant={dataSource.connectionType === "external" ? "default" : "outline"}
|
||||
className="h-auto justify-start py-3"
|
||||
<button
|
||||
onClick={() => {
|
||||
onChange({ connectionType: "external" });
|
||||
}}
|
||||
className={`flex flex-1 items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-colors ${
|
||||
dataSource.connectionType === "external"
|
||||
? "bg-primary border-primary text-white"
|
||||
: "border-gray-200 bg-white hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">외부 데이터베이스</div>
|
||||
<div className="text-xs opacity-80">등록된 외부 커넥션</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Server className="h-3 w-3" />
|
||||
외부 DB
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 외부 DB 선택 시 커넥션 목록 */}
|
||||
{dataSource.connectionType === "external" && (
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-gray-700">외부 커넥션 선택</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션</Label>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open("/admin/external-connections", "_blank");
|
||||
router.push("/admin/external-connections");
|
||||
}}
|
||||
className="text-xs"
|
||||
className="flex items-center gap-1 text-[11px] text-blue-600 transition-colors hover:text-blue-700"
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
커넥션 관리
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
||||
<span className="ml-2 text-sm text-gray-600">커넥션 목록 불러오는 중...</span>
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
||||
<span className="ml-2 text-xs text-gray-600">로딩 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||
<div className="text-sm text-red-800">❌ {error}</div>
|
||||
<Button variant="ghost" size="sm" onClick={loadExternalConnections} className="mt-2 text-xs">
|
||||
<div className="rounded bg-red-50 px-2 py-1.5">
|
||||
<div className="text-xs text-red-800">{error}</div>
|
||||
<button
|
||||
onClick={loadExternalConnections}
|
||||
className="mt-1 text-[11px] text-red-600 underline hover:no-underline"
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && connections.length === 0 && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-center">
|
||||
<div className="mb-2 text-sm text-yellow-800">등록된 외부 커넥션이 없습니다</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<div className="rounded bg-yellow-50 px-2 py-2 text-center">
|
||||
<div className="mb-1 text-xs text-yellow-800">등록된 커넥션이 없습니다</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
window.open("/admin/external-connections", "_blank");
|
||||
router.push("/admin/external-connections");
|
||||
}}
|
||||
className="text-[11px] text-yellow-700 underline hover:no-underline"
|
||||
>
|
||||
<ExternalLink className="mr-1 h-3 w-3" />
|
||||
커넥션 등록하기
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -147,15 +141,15 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
onChange({ externalConnectionId: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
<SelectTrigger className="h-8 w-full text-xs">
|
||||
<SelectValue placeholder="커넥션 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.db_type.toUpperCase()})</span>
|
||||
<span className="text-[10px] text-gray-500">({conn.db_type.toUpperCase()})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
|
@ -163,27 +157,17 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
|||
</Select>
|
||||
|
||||
{selectedConnection && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">커넥션명:</span> {selectedConnection.connection_name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">타입:</span> {selectedConnection.db_type.toUpperCase()}
|
||||
</div>
|
||||
<div className="space-y-0.5 rounded bg-gray-50 px-2 py-1.5 text-[11px] text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">커넥션:</span> {selectedConnection.connection_name}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">타입:</span> {selectedConnection.db_type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 다음 단계 안내 */}
|
||||
{(dataSource.connectionType === "current" ||
|
||||
(dataSource.connectionType === "external" && dataSource.externalConnectionId)) && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="text-sm text-blue-800">✅ 데이터베이스가 선택되었습니다. 아래에서 SQL 쿼리를 작성하세요.</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export type ElementSubtype =
|
|||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
z?: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
|
|
@ -255,7 +256,7 @@ export interface ChartDataset {
|
|||
|
||||
// 리스트 위젯 설정
|
||||
export interface ListWidgetConfig {
|
||||
columnMode: "auto" | "manual"; // 컬럼 설정 방식 (자동 or 수동)
|
||||
columnMode?: "auto" | "manual"; // [Deprecated] 더 이상 사용하지 않음 (하위 호환성을 위해 유지)
|
||||
viewMode: "table" | "card"; // 뷰 모드 (테이블 or 카드) (기본: table)
|
||||
columns: ListColumn[]; // 컬럼 정의
|
||||
pageSize: number; // 페이지당 행 수 (기본: 10)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
||||
import { DashboardElement, QueryResult, ListColumn } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface ListWidgetProps {
|
||||
element: DashboardElement;
|
||||
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -17,7 +16,7 @@ interface ListWidgetProps {
|
|||
* - 테이블 형태로 데이터 표시
|
||||
* - 페이지네이션, 정렬, 검색 기능
|
||||
*/
|
||||
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||
export function ListWidget({ element }: ListWidgetProps) {
|
||||
const [data, setData] = useState<QueryResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -53,7 +52,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
if (element.dataSource.queryParams) {
|
||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||
if (key && value) {
|
||||
params.append(key, value);
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -114,13 +113,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
parseInt(element.dataSource.externalConnectionId),
|
||||
element.dataSource.query,
|
||||
);
|
||||
if (!externalResult.success) {
|
||||
if (!externalResult.success || !externalResult.data) {
|
||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||
}
|
||||
|
||||
const resultData = externalResult.data as unknown as {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
};
|
||||
queryResult = {
|
||||
columns: externalResult.data.columns,
|
||||
rows: externalResult.data.rows,
|
||||
totalRows: externalResult.data.rowCount,
|
||||
columns: resultData.columns,
|
||||
rows: resultData.rows,
|
||||
totalRows: resultData.rowCount,
|
||||
executionTime: 0,
|
||||
};
|
||||
} else {
|
||||
|
|
@ -154,13 +159,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
const interval = setInterval(loadData, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [
|
||||
element.dataSource?.query,
|
||||
element.dataSource?.connectionType,
|
||||
element.dataSource?.externalConnectionId,
|
||||
element.dataSource?.endpoint,
|
||||
element.dataSource?.refreshInterval,
|
||||
]);
|
||||
}, [element.dataSource]);
|
||||
|
||||
// 로딩 중
|
||||
if (isLoading) {
|
||||
|
|
@ -192,23 +191,22 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">📋</div>
|
||||
<div className="text-sm font-medium text-gray-700">리스트를 설정하세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||
<div className="mt-1 text-xs text-gray-500">데이터와 컬럼을 설정해주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
|
||||
const displayColumns =
|
||||
const displayColumns: ListColumn[] =
|
||||
config.columns.length > 0
|
||||
? config.columns
|
||||
: data.columns.map((col) => ({
|
||||
id: col,
|
||||
name: col,
|
||||
dataKey: col,
|
||||
label: col,
|
||||
field: col,
|
||||
visible: true,
|
||||
align: "left" as const,
|
||||
}));
|
||||
|
||||
// 페이지네이션
|
||||
|
|
@ -239,7 +237,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.name}
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
@ -265,7 +263,7 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
key={col.id}
|
||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||
>
|
||||
{String(row[col.dataKey || col.field] ?? "")}
|
||||
{String(row[col.field] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
|
@ -295,11 +293,11 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
|||
.filter((col) => col.visible)
|
||||
.map((col) => (
|
||||
<div key={col.id}>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label || col.name}</div>
|
||||
<div className="text-xs font-medium text-gray-500">{col.label}</div>
|
||||
<div
|
||||
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||
>
|
||||
{String(row[col.dataKey || col.field] ?? "")}
|
||||
{String(row[col.field] ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig } from "../types";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "../data-sources/ApiConfig";
|
||||
import { QueryEditor } from "../QueryEditor";
|
||||
import { UnifiedColumnEditor } from "./list-widget/UnifiedColumnEditor";
|
||||
import { ListTableOptions } from "./list-widget/ListTableOptions";
|
||||
|
||||
interface ListWidgetConfigSidebarProps {
|
||||
element: DashboardElement;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (element: DashboardElement) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 위젯 설정 사이드바
|
||||
*/
|
||||
export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: ListWidgetConfigSidebarProps) {
|
||||
const [title, setTitle] = useState(element.title || "📋 리스트");
|
||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
||||
);
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
|
||||
element.listConfig || {
|
||||
viewMode: "table",
|
||||
columns: [],
|
||||
pageSize: 10,
|
||||
enablePagination: true,
|
||||
showHeader: true,
|
||||
stripedRows: true,
|
||||
compactMode: false,
|
||||
cardColumns: 3,
|
||||
},
|
||||
);
|
||||
|
||||
// 사이드바 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTitle(element.title || "📋 리스트");
|
||||
if (element.dataSource) {
|
||||
setDataSource(element.dataSource);
|
||||
}
|
||||
if (element.listConfig) {
|
||||
setListConfig(element.listConfig);
|
||||
}
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
// Esc 키로 닫기
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEsc);
|
||||
return () => window.removeEventListener("keydown", handleEsc);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 데이터 소스 타입 변경
|
||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
||||
if (type === "database") {
|
||||
setDataSource({
|
||||
type: "database",
|
||||
connectionType: "current",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
} else {
|
||||
setDataSource({
|
||||
type: "api",
|
||||
method: "GET",
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
setQueryResult(null);
|
||||
}, []);
|
||||
|
||||
// 데이터 소스 업데이트
|
||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 쿼리 실행 결과 처리
|
||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||
setQueryResult(result);
|
||||
|
||||
// 쿼리 결과의 컬럼을 자동으로 listConfig.columns에 추가 (기존 컬럼은 유지)
|
||||
setListConfig((prev) => {
|
||||
const existingFields = prev.columns.map((col) => col.field);
|
||||
const newColumns = result.columns
|
||||
.filter((col) => !existingFields.includes(col))
|
||||
.map((col, idx) => ({
|
||||
id: `col_${Date.now()}_${idx}`,
|
||||
field: col,
|
||||
label: col,
|
||||
visible: true,
|
||||
align: "left" as const,
|
||||
}));
|
||||
|
||||
return {
|
||||
...prev,
|
||||
columns: [...prev.columns, ...newColumns],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 컬럼 설정 변경
|
||||
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
|
||||
setListConfig((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// 적용
|
||||
const handleApply = useCallback(() => {
|
||||
const updatedElement: DashboardElement = {
|
||||
...element,
|
||||
title,
|
||||
dataSource,
|
||||
listConfig,
|
||||
};
|
||||
|
||||
onApply(updatedElement);
|
||||
}, [element, title, dataSource, listConfig, onApply]);
|
||||
|
||||
// 저장 가능 여부
|
||||
const canApply = listConfig.columns.length > 0 && listConfig.columns.some((col) => col.visible && col.field);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||
<span className="text-primary text-xs font-bold">📋</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-900">리스트 위젯 설정</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문: 스크롤 가능 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{/* 기본 설정 */}
|
||||
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">기본 설정</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="리스트 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 소스 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={dataSource.type}
|
||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
|
||||
<TabsTrigger
|
||||
value="database"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
>
|
||||
데이터베이스
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="api"
|
||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
||||
>
|
||||
REST API
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="database" className="mt-2 space-y-2">
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="api" className="mt-2 space-y-2">
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 데이터 로드 상태 */}
|
||||
{queryResult && (
|
||||
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<span className="text-[10px] font-medium text-green-700">{queryResult.rows.length}개 데이터 로드됨</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
||||
{queryResult && (
|
||||
<div className="mt-3 rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">컬럼 설정</div>
|
||||
<UnifiedColumnEditor
|
||||
queryResult={queryResult}
|
||||
config={listConfig}
|
||||
onConfigChange={handleListConfigChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 옵션 - 컬럼이 있을 때만 표시 */}
|
||||
{listConfig.columns.length > 0 && (
|
||||
<div className="mt-3 rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">테이블 옵션</div>
|
||||
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터: 적용 버튼 */}
|
||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={!canApply}
|
||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { DashboardElement } from "../types";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface YardWidgetConfigSidebarProps {
|
||||
element: DashboardElement;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (updates: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: YardWidgetConfigSidebarProps) {
|
||||
const [customTitle, setCustomTitle] = useState(element.customTitle || "");
|
||||
const [showHeader, setShowHeader] = useState(element.showHeader !== false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
const handleApply = () => {
|
||||
onApply({
|
||||
customTitle,
|
||||
showHeader,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||
<span className="text-primary text-xs font-bold">🏗️</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-900">야드 관리 위젯 설정</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
{/* 위젯 제목 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">위젯 제목</div>
|
||||
<Input
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)"
|
||||
className="h-8 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500">기본 제목: 야드 관리 3D</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">헤더 표시</div>
|
||||
<RadioGroup
|
||||
value={showHeader ? "show" : "hide"}
|
||||
onValueChange={(value) => setShowHeader(value === "show")}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
|
||||
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
|
||||
표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
|
||||
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
|
||||
숨김
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ListColumn, QueryResult, ListWidgetConfig } from "../../types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
interface ColumnSelectorProps {
|
||||
availableColumns: string[];
|
||||
selectedColumns: ListColumn[];
|
||||
sampleData: Record<string, any>;
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
queryResult: QueryResult;
|
||||
config: ListWidgetConfig;
|
||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -23,15 +20,18 @@ interface ColumnSelectorProps {
|
|||
* - 정렬, 너비, 정렬 방향 설정
|
||||
* - 드래그 앤 드롭으로 순서 변경
|
||||
*/
|
||||
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
|
||||
export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSelectorProps) {
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const availableColumns = queryResult.columns;
|
||||
const selectedColumns = config.columns || [];
|
||||
const sampleData = queryResult.rows[0] || {};
|
||||
|
||||
// 컬럼 선택/해제
|
||||
const handleToggle = (field: string) => {
|
||||
const exists = selectedColumns.find((col) => col.field === field);
|
||||
if (exists) {
|
||||
onChange(selectedColumns.filter((col) => col.field !== field));
|
||||
onConfigChange({ columns: selectedColumns.filter((col) => col.field !== field) });
|
||||
} else {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${selectedColumns.length}`,
|
||||
|
|
@ -40,18 +40,22 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...selectedColumns, newCol]);
|
||||
onConfigChange({ columns: [...selectedColumns, newCol] });
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼 라벨 변경
|
||||
const handleLabelChange = (field: string, label: string) => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)));
|
||||
onConfigChange({
|
||||
columns: selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)),
|
||||
});
|
||||
};
|
||||
|
||||
// 정렬 방향 변경
|
||||
const handleAlignChange = (field: string, align: "left" | "center" | "right") => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
|
||||
onConfigChange({
|
||||
columns: selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)),
|
||||
});
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
|
|
@ -64,40 +68,29 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
||||
|
||||
setDragOverIndex(hoverIndex);
|
||||
|
||||
const newColumns = [...selectedColumns];
|
||||
const draggedItem = newColumns[draggedIndex];
|
||||
newColumns.splice(draggedIndex, 1);
|
||||
newColumns.splice(hoverIndex, 0, draggedItem);
|
||||
|
||||
setDraggedIndex(hoverIndex);
|
||||
onChange(newColumns);
|
||||
onConfigChange({ columns: newColumns });
|
||||
};
|
||||
|
||||
// 드롭
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">컬럼 선택 및 설정</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
표시할 컬럼을 선택하고 이름을 변경하세요. 드래그하여 순서를 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
{/* 선택된 컬럼을 먼저 순서대로 표시 */}
|
||||
{selectedColumns.map((selectedCol, columnIndex) => {
|
||||
const field = selectedCol.field;
|
||||
|
|
@ -127,52 +120,74 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
handleDragEnd();
|
||||
e.currentTarget.style.cursor = "grab";
|
||||
}}
|
||||
className={`rounded-lg border p-4 transition-all ${
|
||||
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
|
||||
className={`group relative rounded-md border transition-all ${
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
||||
} ${isDraggable ? "cursor-grab active:cursor-grabbing" : ""} ${
|
||||
draggedIndex === columnIndex ? "opacity-50" : ""
|
||||
draggedIndex === columnIndex ? "scale-95 opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className={`h-4 w-4 ${isDraggable ? "text-blue-500" : "text-gray-400"}`} />
|
||||
<span className="font-medium text-gray-700">{field}</span>
|
||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggle(field)}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<GripVertical
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-colors ${
|
||||
isDraggable ? "group-hover:text-primary text-gray-400" : "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-900">{field}</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
{isSelected && selectedCol && (
|
||||
<div className="ml-7 grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름</Label>
|
||||
<Input
|
||||
value={selectedCol.label}
|
||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||
placeholder="컬럼명"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
value={selectedCol.label}
|
||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={selectedCol.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 정렬 */}
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
value={selectedCol.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[4rem]">
|
||||
<SelectItem value="left" className="py-1 text-[10px]">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" className="py-1 text-[10px]">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="py-1 text-[10px]">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -191,18 +206,23 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
const isSelected = false;
|
||||
const isDraggable = false;
|
||||
|
||||
return (
|
||||
<div key={field} className={`rounded-lg border border-gray-200 p-4 transition-all`}>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<Checkbox checked={false} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{field}</span>
|
||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||
<div
|
||||
key={field}
|
||||
className="group rounded-md border border-gray-200 bg-white transition-all hover:border-gray-300 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={() => handleToggle(field)}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<GripVertical className="h-3.5 w-3.5 shrink-0 text-gray-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-600">{field}</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -212,10 +232,11 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
</div>
|
||||
|
||||
{selectedColumns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-center text-sm text-yellow-700">
|
||||
⚠️ 최소 1개 이상의 컬럼을 선택해주세요
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">최소 1개 이상의 컬럼을 선택해주세요</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,70 +2,42 @@
|
|||
|
||||
import React from "react";
|
||||
import { ListWidgetConfig } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ListTableOptionsProps {
|
||||
config: ListWidgetConfig;
|
||||
onChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 테이블 옵션 설정 컴포넌트
|
||||
* - 페이지 크기, 검색, 정렬 등 설정
|
||||
*/
|
||||
export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
|
||||
export function ListTableOptions({ config, onConfigChange }: ListTableOptionsProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">테이블 옵션</h3>
|
||||
<p className="text-sm text-gray-600">테이블 동작과 스타일을 설정하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
{/* 뷰 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">뷰 모드</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">뷰 모드</Label>
|
||||
<RadioGroup
|
||||
value={config.viewMode}
|
||||
onValueChange={(value: "table" | "card") => onChange({ viewMode: value })}
|
||||
onValueChange={(value: "table" | "card") => onConfigChange({ viewMode: value })}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="table" id="table" />
|
||||
<Label htmlFor="table" className="cursor-pointer font-normal">
|
||||
📊 테이블 (기본)
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="table" id="table" className="h-3 w-3" />
|
||||
<Label htmlFor="table" className="cursor-pointer text-[11px] font-normal">
|
||||
테이블
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="card" id="card" />
|
||||
<Label htmlFor="card" className="cursor-pointer font-normal">
|
||||
🗂️ 카드
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">컬럼 설정 방식</Label>
|
||||
<RadioGroup
|
||||
value={config.columnMode}
|
||||
onValueChange={(value: "auto" | "manual") => onChange({ columnMode: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="auto" />
|
||||
<Label htmlFor="auto" className="cursor-pointer font-normal">
|
||||
자동 (쿼리 결과에서 선택)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label htmlFor="manual" className="cursor-pointer font-normal">
|
||||
수동 (직접 추가 및 매핑)
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="card" id="card" className="h-3 w-3" />
|
||||
<Label htmlFor="card" className="cursor-pointer text-[11px] font-normal">
|
||||
카드
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
|
@ -74,94 +46,122 @@ export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
|
|||
{/* 카드 뷰 컬럼 수 */}
|
||||
{config.viewMode === "card" && (
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">카드 컬럼 수</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">카드 컬럼 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
value={config.cardColumns || 3}
|
||||
onChange={(e) => onChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
||||
className="w-full"
|
||||
onChange={(e) => onConfigChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
||||
className="h-6 w-full px-1.5 text-[11px]"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||
<p className="mt-0.5 text-[9px] text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지 크기 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">페이지당 행 수</Label>
|
||||
<Select value={String(config.pageSize)} onValueChange={(value) => onChange({ pageSize: parseInt(value) })}>
|
||||
<SelectTrigger>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">페이지당 행 수</Label>
|
||||
<Select
|
||||
value={String(config.pageSize)}
|
||||
onValueChange={(value) => onConfigChange({ pageSize: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-6 px-1.5 text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5개</SelectItem>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
<SelectItem value="5" className="text-[11px]">
|
||||
5개
|
||||
</SelectItem>
|
||||
<SelectItem value="10" className="text-[11px]">
|
||||
10개
|
||||
</SelectItem>
|
||||
<SelectItem value="20" className="text-[11px]">
|
||||
20개
|
||||
</SelectItem>
|
||||
<SelectItem value="50" className="text-[11px]">
|
||||
50개
|
||||
</SelectItem>
|
||||
<SelectItem value="100" className="text-[11px]">
|
||||
100개
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기능 활성화 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">기능</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enablePagination"
|
||||
checked={config.enablePagination}
|
||||
onCheckedChange={(checked) => onChange({ enablePagination: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="enablePagination" className="cursor-pointer font-normal">
|
||||
페이지네이션
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">페이지네이션</Label>
|
||||
<RadioGroup
|
||||
value={config.enablePagination ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => onConfigChange({ enablePagination: value === "enabled" })}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="enabled" id="pagination-enabled" className="h-3 w-3" />
|
||||
<Label htmlFor="pagination-enabled" className="cursor-pointer text-[11px] font-normal">
|
||||
사용
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="disabled" id="pagination-disabled" className="h-3 w-3" />
|
||||
<Label htmlFor="pagination-disabled" className="cursor-pointer text-[11px] font-normal">
|
||||
사용 안 함
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 스타일 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">스타일</Label>
|
||||
<div className="space-y-2">
|
||||
{config.viewMode === "table" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={config.showHeader}
|
||||
onCheckedChange={(checked) => onChange({ showHeader: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="cursor-pointer font-normal">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="stripedRows"
|
||||
checked={config.stripedRows}
|
||||
onCheckedChange={(checked) => onChange({ stripedRows: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="stripedRows" className="cursor-pointer font-normal">
|
||||
줄무늬 행
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compactMode"
|
||||
checked={config.compactMode}
|
||||
onCheckedChange={(checked) => onChange({ compactMode: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="compactMode" className="cursor-pointer font-normal">
|
||||
압축 모드 (작은 크기)
|
||||
</Label>
|
||||
</div>
|
||||
{/* 헤더 표시 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">헤더 표시</Label>
|
||||
<RadioGroup
|
||||
value={config.showHeader ? "show" : "hide"}
|
||||
onValueChange={(value) => onConfigChange({ showHeader: value === "show" })}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
|
||||
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
|
||||
표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
|
||||
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
|
||||
숨김
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 줄무늬 행 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">줄무늬 행</Label>
|
||||
<RadioGroup
|
||||
value={config.stripedRows ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => onConfigChange({ stripedRows: value === "enabled" })}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="enabled" id="striped-enabled" className="h-3 w-3" />
|
||||
<Label htmlFor="striped-enabled" className="cursor-pointer text-[11px] font-normal">
|
||||
사용
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="disabled" id="striped-disabled" className="h-3 w-3" />
|
||||
<Label htmlFor="striped-disabled" className="cursor-pointer text-[11px] font-normal">
|
||||
사용 안 함
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ListColumn, ListWidgetConfig } from "../../types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
|
||||
interface ManualColumnEditorProps {
|
||||
availableFields: string[];
|
||||
columns: ListColumn[];
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
config: ListWidgetConfig;
|
||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -21,30 +17,30 @@ interface ManualColumnEditorProps {
|
|||
* - 컬럼명과 데이터 필드 직접 매핑
|
||||
* - 드래그 앤 드롭으로 순서 변경
|
||||
*/
|
||||
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
|
||||
export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEditorProps) {
|
||||
const columns = config.columns || [];
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// 새 컬럼 추가
|
||||
const handleAddColumn = () => {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${Date.now()}`,
|
||||
label: `컬럼 ${columns.length + 1}`,
|
||||
field: availableFields[0] || "",
|
||||
field: "",
|
||||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...columns, newCol]);
|
||||
onConfigChange({ columns: [...columns, newCol] });
|
||||
};
|
||||
|
||||
// 컬럼 삭제
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(columns.filter((col) => col.id !== id));
|
||||
onConfigChange({ columns: columns.filter((col) => col.id !== id) });
|
||||
};
|
||||
|
||||
// 컬럼 속성 업데이트
|
||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
||||
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
|
||||
onConfigChange({ columns: columns.map((col) => (col.id === id ? { ...col, ...updates } : col)) });
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
|
|
@ -57,46 +53,41 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
|
|||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
||||
|
||||
setDragOverIndex(hoverIndex);
|
||||
|
||||
const newColumns = [...columns];
|
||||
const draggedItem = newColumns[draggedIndex];
|
||||
newColumns.splice(draggedIndex, 1);
|
||||
newColumns.splice(hoverIndex, 0, draggedItem);
|
||||
|
||||
setDraggedIndex(hoverIndex);
|
||||
onChange(newColumns);
|
||||
onConfigChange({ columns: newColumns });
|
||||
};
|
||||
|
||||
// 드롭
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">수동 컬럼 편집</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
직접 컬럼을 추가하고 데이터 필드를 매핑하세요. 드래그하여 순서를 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<div>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-gray-500">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={col.id}
|
||||
|
|
@ -111,82 +102,72 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
|
|||
handleDragEnd();
|
||||
e.currentTarget.style.cursor = "grab";
|
||||
}}
|
||||
className={`cursor-grab rounded-lg border border-gray-200 bg-gray-50 p-4 transition-all active:cursor-grabbing ${
|
||||
draggedIndex === index ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`group relative rounded-md border border-gray-200 bg-white shadow-sm transition-all hover:border-gray-300 hover:shadow-sm ${
|
||||
draggedIndex === index ? "scale-95 opacity-50" : ""
|
||||
} cursor-grab active:cursor-grabbing`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium text-gray-700">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
|
||||
<span className="text-[11px] font-medium text-gray-900">컬럼 {index + 1}</span>
|
||||
<button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto text-red-600 hover:text-red-700"
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름 *</Label>
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="예: 사용자 이름"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{/* 설정 영역 */}
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">데이터 필드 *</Label>
|
||||
<Select value={col.field} onValueChange={(value) => handleUpdate(col.id, { field: value })}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
{/* 데이터 필드 */}
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
value={col.field}
|
||||
onChange={(e) => handleUpdate(col.id, { field: e.target.value })}
|
||||
placeholder="데이터 필드"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[4rem]">
|
||||
<SelectItem value="left" className="py-1 text-[10px]">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={col.width || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdate(col.id, { width: e.target.value ? parseInt(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="자동"
|
||||
className="mt-1"
|
||||
/>
|
||||
<SelectItem value="center" className="py-1 text-[10px]">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="py-1 text-[10px]">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -194,13 +175,18 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
|
|||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-gray-300 bg-gray-100 p-8 text-center">
|
||||
<div className="text-sm text-gray-600">컬럼을 추가하여 시작하세요</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="mt-3 gap-2">
|
||||
<Plus className="h-4 w-4" />첫 번째 컬럼 추가
|
||||
</Button>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">컬럼을 추가하여 시작하세요</span>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 ml-auto flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListColumn, ListWidgetConfig, QueryResult } from "../../types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface UnifiedColumnEditorProps {
|
||||
queryResult: QueryResult | null;
|
||||
config: ListWidgetConfig;
|
||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 컬럼 에디터
|
||||
* - 쿼리 실행 시 자동으로 컬럼 추출
|
||||
* - 모든 필드 편집 가능 (필드명, 표시 이름, 정렬)
|
||||
* - 수동으로 컬럼 추가 가능
|
||||
*/
|
||||
export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: UnifiedColumnEditorProps) {
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
|
||||
const columns = config.columns || [];
|
||||
const sampleData = queryResult?.rows[0] || {};
|
||||
|
||||
// 컬럼 추가
|
||||
const handleAddColumn = () => {
|
||||
const newColumn: ListColumn = {
|
||||
id: `col_${Date.now()}`,
|
||||
field: "",
|
||||
label: "",
|
||||
visible: true,
|
||||
align: "left",
|
||||
};
|
||||
|
||||
onConfigChange({
|
||||
columns: [...columns, newColumn],
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 삭제
|
||||
const handleRemove = (id: string) => {
|
||||
onConfigChange({
|
||||
columns: columns.filter((col) => col.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 업데이트
|
||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
||||
onConfigChange({
|
||||
columns: columns.map((col) => (col.id === id ? { ...col, ...updates } : col)),
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 토글
|
||||
const handleToggle = (id: string) => {
|
||||
onConfigChange({
|
||||
columns: columns.map((col) => (col.id === id ? { ...col, visible: !col.visible } : col)),
|
||||
});
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const newColumns = [...columns];
|
||||
const draggedItem = newColumns[draggedIndex];
|
||||
|
||||
newColumns.splice(draggedIndex, 1);
|
||||
newColumns.splice(index, 0, draggedItem);
|
||||
|
||||
onConfigChange({ columns: newColumns });
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
// 드롭
|
||||
const handleDrop = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-gray-500">컬럼을 선택하고 편집하세요</p>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
컬럼 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{columns.map((col, index) => {
|
||||
const preview = sampleData[col.field];
|
||||
const previewText =
|
||||
preview !== undefined && preview !== null
|
||||
? typeof preview === "object"
|
||||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
handleDragStart(index);
|
||||
e.currentTarget.style.cursor = "grabbing";
|
||||
}}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={(e) => {
|
||||
handleDragEnd();
|
||||
e.currentTarget.style.cursor = "grab";
|
||||
}}
|
||||
className={`group relative rounded-md border transition-all ${
|
||||
col.visible
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
||||
} cursor-grab active:cursor-grabbing ${draggedIndex === index ? "scale-95 opacity-50" : ""}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Checkbox
|
||||
checked={col.visible}
|
||||
onCheckedChange={() => handleToggle(col.id)}
|
||||
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary h-4 w-4 shrink-0 rounded-full"
|
||||
/>
|
||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-900">
|
||||
{col.field || "(필드명 없음)"}
|
||||
</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
{col.visible && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[4rem]">
|
||||
<SelectItem value="left" className="py-1 text-[10px]">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" className="py-1 text-[10px]">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="py-1 text-[10px]">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">쿼리를 실행하거나 컬럼을 추가하세요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
|
|
@ -63,11 +63,11 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
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 : "데이터 로딩 실패");
|
||||
|
|
@ -78,7 +78,7 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
|
||||
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")) {
|
||||
|
|
@ -93,11 +93,11 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
|
||||
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) ||
|
||||
|
|
@ -111,7 +111,7 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
<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>
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -120,11 +120,11 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-destructive">
|
||||
<div className="text-destructive text-center">
|
||||
<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"
|
||||
className="bg-destructive/10 hover:bg-destructive/20 mt-2 rounded-md px-3 py-1 text-xs"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -136,29 +136,29 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
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 className="text-muted-foreground text-center">
|
||||
<p className="text-sm">데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||
<div className="bg-background flex h-full flex-col overflow-hidden p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">📦 화물 목록</h3>
|
||||
<h3 className="text-foreground text-lg font-semibold">📦 화물 목록</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"
|
||||
className="border-input bg-background placeholder:text-muted-foreground focus:ring-ring rounded-md border px-3 py-1 text-sm focus:ring-2 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground rounded-full p-1"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
|
|
@ -167,47 +167,38 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
</div>
|
||||
|
||||
{/* 총 건수 */}
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredList.length}</span>건
|
||||
<div className="text-muted-foreground mb-3 text-sm">
|
||||
총 <span className="text-foreground font-semibold">{filteredList.length}</span>건
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto rounded-md border border-border">
|
||||
<div className="border-border flex-1 overflow-auto rounded-md 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>
|
||||
<th className="border-border border-b p-2 text-left font-medium">운송장번호</th>
|
||||
<th className="border-border border-b p-2 text-left font-medium">고객명</th>
|
||||
<th className="border-border border-b p-2 text-left font-medium">목적지</th>
|
||||
<th className="border-border border-b p-2 text-left font-medium">무게(kg)</th>
|
||||
<th className="border-border border-b 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">
|
||||
<td colSpan={5} className="text-muted-foreground p-8 text-center">
|
||||
{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">
|
||||
<tr key={cargo.id || index} className="border-border hover:bg-muted/30 border-b transition-colors">
|
||||
<td className="text-foreground p-2 font-medium">
|
||||
{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="text-foreground p-2">{cargo.customer_name || cargo.customerName || "-"}</td>
|
||||
<td className="text-muted-foreground p-2">{cargo.destination || "-"}</td>
|
||||
<td className="text-muted-foreground p-2 text-right">{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 || "")}`}
|
||||
|
|
@ -224,4 +215,3 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,11 +41,11 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
const lastQueryRef = React.useRef<string>(""); // 마지막 쿼리 추적
|
||||
|
||||
// localStorage 키 생성 (쿼리 기반으로 고유하게 - 편집/보기 모드 공유)
|
||||
const queryHash = element?.dataSource?.query
|
||||
const queryHash = element?.dataSource?.query
|
||||
? btoa(element.dataSource.query) // 전체 쿼리를 base64로 인코딩
|
||||
: "default";
|
||||
const storageKey = `custom-stats-widget-${queryHash}`;
|
||||
|
||||
|
||||
// console.log("🔑 storageKey:", storageKey, "(쿼리:", element?.dataSource?.query?.substring(0, 30) + "...)");
|
||||
|
||||
// 쿼리가 변경되면 초기화 상태 리셋
|
||||
|
|
@ -148,174 +148,174 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
// 3. 컬럼명 한글 번역 매핑
|
||||
const columnNameTranslation: { [key: string]: string } = {
|
||||
// 일반
|
||||
"id": "ID",
|
||||
"name": "이름",
|
||||
"title": "제목",
|
||||
"description": "설명",
|
||||
"status": "상태",
|
||||
"type": "유형",
|
||||
"category": "카테고리",
|
||||
"date": "날짜",
|
||||
"time": "시간",
|
||||
"created_at": "생성일",
|
||||
"updated_at": "수정일",
|
||||
"deleted_at": "삭제일",
|
||||
|
||||
id: "ID",
|
||||
name: "이름",
|
||||
title: "제목",
|
||||
description: "설명",
|
||||
status: "상태",
|
||||
type: "유형",
|
||||
category: "카테고리",
|
||||
date: "날짜",
|
||||
time: "시간",
|
||||
created_at: "생성일",
|
||||
updated_at: "수정일",
|
||||
deleted_at: "삭제일",
|
||||
|
||||
// 물류/운송
|
||||
"tracking_number": "운송장 번호",
|
||||
"customer": "고객",
|
||||
"origin": "출발지",
|
||||
"destination": "목적지",
|
||||
"estimated_delivery": "예상 도착",
|
||||
"actual_delivery": "실제 도착",
|
||||
"delay_reason": "지연 사유",
|
||||
"priority": "우선순위",
|
||||
"cargo_weight": "화물 중량",
|
||||
"total_weight": "총 중량",
|
||||
"weight": "중량",
|
||||
"distance": "거리",
|
||||
"total_distance": "총 거리",
|
||||
"delivery_time": "배송 시간",
|
||||
"delivery_duration": "배송 소요시간",
|
||||
"is_on_time": "정시 도착 여부",
|
||||
"on_time": "정시",
|
||||
|
||||
tracking_number: "운송장 번호",
|
||||
customer: "고객",
|
||||
origin: "출발지",
|
||||
destination: "목적지",
|
||||
estimated_delivery: "예상 도착",
|
||||
actual_delivery: "실제 도착",
|
||||
delay_reason: "지연 사유",
|
||||
priority: "우선순위",
|
||||
cargo_weight: "화물 중량",
|
||||
total_weight: "총 중량",
|
||||
weight: "중량",
|
||||
distance: "거리",
|
||||
total_distance: "총 거리",
|
||||
delivery_time: "배송 시간",
|
||||
delivery_duration: "배송 소요시간",
|
||||
is_on_time: "정시 도착 여부",
|
||||
on_time: "정시",
|
||||
|
||||
// 수량/금액
|
||||
"quantity": "수량",
|
||||
"qty": "수량",
|
||||
"amount": "금액",
|
||||
"price": "가격",
|
||||
"cost": "비용",
|
||||
"fee": "수수료",
|
||||
"total": "합계",
|
||||
"sum": "총합",
|
||||
|
||||
quantity: "수량",
|
||||
qty: "수량",
|
||||
amount: "금액",
|
||||
price: "가격",
|
||||
cost: "비용",
|
||||
fee: "수수료",
|
||||
total: "합계",
|
||||
sum: "총합",
|
||||
|
||||
// 비율/효율
|
||||
"rate": "비율",
|
||||
"ratio": "비율",
|
||||
"percent": "퍼센트",
|
||||
"percentage": "백분율",
|
||||
"efficiency": "효율",
|
||||
|
||||
rate: "비율",
|
||||
ratio: "비율",
|
||||
percent: "퍼센트",
|
||||
percentage: "백분율",
|
||||
efficiency: "효율",
|
||||
|
||||
// 생산/처리
|
||||
"throughput": "처리량",
|
||||
"output": "산출량",
|
||||
"production": "생산량",
|
||||
"volume": "용량",
|
||||
|
||||
throughput: "처리량",
|
||||
output: "산출량",
|
||||
production: "생산량",
|
||||
volume: "용량",
|
||||
|
||||
// 재고/설비
|
||||
"stock": "재고",
|
||||
"inventory": "재고",
|
||||
"equipment": "설비",
|
||||
"facility": "시설",
|
||||
"machine": "기계",
|
||||
|
||||
stock: "재고",
|
||||
inventory: "재고",
|
||||
equipment: "설비",
|
||||
facility: "시설",
|
||||
machine: "기계",
|
||||
|
||||
// 평가
|
||||
"score": "점수",
|
||||
"rating": "평점",
|
||||
"point": "점수",
|
||||
"grade": "등급",
|
||||
|
||||
score: "점수",
|
||||
rating: "평점",
|
||||
point: "점수",
|
||||
grade: "등급",
|
||||
|
||||
// 기타
|
||||
"temperature": "온도",
|
||||
"temp": "온도",
|
||||
"speed": "속도",
|
||||
"velocity": "속도",
|
||||
"count": "개수",
|
||||
"number": "번호",
|
||||
temperature: "온도",
|
||||
temp: "온도",
|
||||
speed: "속도",
|
||||
velocity: "속도",
|
||||
count: "개수",
|
||||
number: "번호",
|
||||
};
|
||||
|
||||
// 4. 키워드 기반 자동 라벨링 및 단위 설정
|
||||
const columnConfig: {
|
||||
[key: string]: {
|
||||
keywords: string[];
|
||||
unit: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
[key: string]: {
|
||||
keywords: string[];
|
||||
unit: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
aggregation: "sum" | "avg" | "max" | "min"; // 집계 방식
|
||||
koreanLabel?: string; // 한글 라벨
|
||||
};
|
||||
} = {
|
||||
// 무게/중량 - 합계
|
||||
weight: {
|
||||
keywords: ["weight", "cargo_weight", "total_weight", "tonnage", "ton"],
|
||||
unit: "톤",
|
||||
color: "green",
|
||||
weight: {
|
||||
keywords: ["weight", "cargo_weight", "total_weight", "tonnage", "ton"],
|
||||
unit: "톤",
|
||||
color: "green",
|
||||
icon: "⚖️",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "총 운송량"
|
||||
koreanLabel: "총 운송량",
|
||||
},
|
||||
// 거리 - 합계
|
||||
distance: {
|
||||
keywords: ["distance", "total_distance", "km", "kilometer"],
|
||||
unit: "km",
|
||||
color: "blue",
|
||||
distance: {
|
||||
keywords: ["distance", "total_distance", "km", "kilometer"],
|
||||
unit: "km",
|
||||
color: "blue",
|
||||
icon: "🛣️",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "누적 거리"
|
||||
koreanLabel: "누적 거리",
|
||||
},
|
||||
// 시간/기간 - 평균
|
||||
time: {
|
||||
keywords: ["time", "duration", "delivery_time", "delivery_duration", "hour", "minute"],
|
||||
unit: "분",
|
||||
color: "orange",
|
||||
icon: "⏱️",
|
||||
time: {
|
||||
keywords: ["time", "duration", "delivery_time", "delivery_duration", "hour", "minute"],
|
||||
unit: "분",
|
||||
color: "orange",
|
||||
icon: "⏱️",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 배송시간"
|
||||
koreanLabel: "평균 배송시간",
|
||||
},
|
||||
// 수량/개수 - 합계
|
||||
quantity: {
|
||||
keywords: ["quantity", "qty", "count", "number"],
|
||||
unit: "개",
|
||||
color: "purple",
|
||||
quantity: {
|
||||
keywords: ["quantity", "qty", "count", "number"],
|
||||
unit: "개",
|
||||
color: "purple",
|
||||
icon: "📦",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "총 수량"
|
||||
koreanLabel: "총 수량",
|
||||
},
|
||||
// 금액/가격 - 합계
|
||||
amount: {
|
||||
keywords: ["amount", "price", "cost", "fee", "total", "sum"],
|
||||
unit: "원",
|
||||
color: "yellow",
|
||||
amount: {
|
||||
keywords: ["amount", "price", "cost", "fee", "total", "sum"],
|
||||
unit: "원",
|
||||
color: "yellow",
|
||||
icon: "💰",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "총 금액"
|
||||
koreanLabel: "총 금액",
|
||||
},
|
||||
// 비율/퍼센트 - 평균
|
||||
rate: {
|
||||
keywords: ["rate", "ratio", "percent", "efficiency", "%"],
|
||||
unit: "%",
|
||||
color: "cyan",
|
||||
icon: "📈",
|
||||
rate: {
|
||||
keywords: ["rate", "ratio", "percent", "efficiency", "%"],
|
||||
unit: "%",
|
||||
color: "cyan",
|
||||
icon: "📈",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 비율"
|
||||
koreanLabel: "평균 비율",
|
||||
},
|
||||
// 처리량 - 합계
|
||||
throughput: {
|
||||
keywords: ["throughput", "output", "production", "volume"],
|
||||
unit: "개",
|
||||
color: "pink",
|
||||
throughput: {
|
||||
keywords: ["throughput", "output", "production", "volume"],
|
||||
unit: "개",
|
||||
color: "pink",
|
||||
icon: "⚡",
|
||||
aggregation: "sum",
|
||||
koreanLabel: "총 처리량"
|
||||
koreanLabel: "총 처리량",
|
||||
},
|
||||
// 재고 - 평균 (현재 재고는 평균이 의미있음)
|
||||
stock: {
|
||||
keywords: ["stock", "inventory"],
|
||||
unit: "개",
|
||||
color: "teal",
|
||||
stock: {
|
||||
keywords: ["stock", "inventory"],
|
||||
unit: "개",
|
||||
color: "teal",
|
||||
icon: "📦",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 재고"
|
||||
koreanLabel: "평균 재고",
|
||||
},
|
||||
// 설비/장비 - 평균
|
||||
equipment: {
|
||||
keywords: ["equipment", "facility", "machine"],
|
||||
unit: "대",
|
||||
color: "gray",
|
||||
equipment: {
|
||||
keywords: ["equipment", "facility", "machine"],
|
||||
unit: "대",
|
||||
color: "gray",
|
||||
icon: "🏭",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 가동 설비"
|
||||
koreanLabel: "평균 가동 설비",
|
||||
},
|
||||
// 점수/평점 - 평균
|
||||
score: {
|
||||
|
|
@ -324,7 +324,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
color: "indigo",
|
||||
icon: "⭐",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 점수"
|
||||
koreanLabel: "평균 점수",
|
||||
},
|
||||
// 온도 - 평균
|
||||
temperature: {
|
||||
|
|
@ -333,7 +333,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
color: "red",
|
||||
icon: "🌡️",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 온도"
|
||||
koreanLabel: "평균 온도",
|
||||
},
|
||||
// 속도 - 평균
|
||||
speed: {
|
||||
|
|
@ -342,7 +342,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
color: "blue",
|
||||
icon: "🚀",
|
||||
aggregation: "avg",
|
||||
koreanLabel: "평균 속도"
|
||||
koreanLabel: "평균 속도",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -363,17 +363,19 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
icon = config.icon;
|
||||
aggregation = config.aggregation;
|
||||
matchedConfig = config;
|
||||
|
||||
|
||||
// 한글 라벨 사용 또는 자동 변환
|
||||
if (config.koreanLabel) {
|
||||
label = config.koreanLabel;
|
||||
} else {
|
||||
// 집계 방식에 따라 접두어 추가
|
||||
const prefix = aggregation === "avg" ? "평균 " : aggregation === "sum" ? "총 " : "";
|
||||
label = prefix + key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.trim();
|
||||
label =
|
||||
prefix +
|
||||
key
|
||||
.replace(/_/g, " ")
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.trim();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -383,41 +385,45 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
if (!matchedConfig) {
|
||||
// 컬럼명 번역 시도
|
||||
const translatedName = columnNameTranslation[key.toLowerCase()];
|
||||
|
||||
|
||||
if (translatedName) {
|
||||
// 번역된 이름이 있으면 사용
|
||||
label = translatedName;
|
||||
} else {
|
||||
// 컬럼명에 avg, average, mean이 포함되면 평균으로 간주
|
||||
if (key.toLowerCase().includes("avg") ||
|
||||
key.toLowerCase().includes("average") ||
|
||||
key.toLowerCase().includes("mean")) {
|
||||
if (
|
||||
key.toLowerCase().includes("avg") ||
|
||||
key.toLowerCase().includes("average") ||
|
||||
key.toLowerCase().includes("mean")
|
||||
) {
|
||||
aggregation = "avg";
|
||||
|
||||
|
||||
// 언더스코어로 분리된 각 단어 번역 시도
|
||||
const cleanKey = key.replace(/avg|average|mean/gi, "").replace(/_/g, " ").trim();
|
||||
const cleanKey = key
|
||||
.replace(/avg|average|mean/gi, "")
|
||||
.replace(/_/g, " ")
|
||||
.trim();
|
||||
const words = cleanKey.split(/[_\s]+/);
|
||||
const translatedWords = words.map(word =>
|
||||
columnNameTranslation[word.toLowerCase()] || word
|
||||
);
|
||||
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
|
||||
label = "평균 " + translatedWords.join(" ");
|
||||
}
|
||||
}
|
||||
// total, sum이 포함되면 합계로 간주
|
||||
else if (key.toLowerCase().includes("total") || key.toLowerCase().includes("sum")) {
|
||||
aggregation = "sum";
|
||||
|
||||
|
||||
// 언더스코어로 분리된 각 단어 번역 시도
|
||||
const cleanKey = key.replace(/total|sum/gi, "").replace(/_/g, " ").trim();
|
||||
const cleanKey = key
|
||||
.replace(/total|sum/gi, "")
|
||||
.replace(/_/g, " ")
|
||||
.trim();
|
||||
const words = cleanKey.split(/[_\s]+/);
|
||||
const translatedWords = words.map(word =>
|
||||
columnNameTranslation[word.toLowerCase()] || word
|
||||
);
|
||||
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
|
||||
label = "총 " + translatedWords.join(" ");
|
||||
}
|
||||
// 기본값 - 각 단어별로 번역 시도
|
||||
else {
|
||||
const words = key.split(/[_\s]+/);
|
||||
const translatedWords = words.map(word => {
|
||||
const translatedWords = words.map((word) => {
|
||||
const translated = columnNameTranslation[word.toLowerCase()];
|
||||
if (translated) {
|
||||
return translated;
|
||||
|
|
@ -473,25 +479,25 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
Object.keys(firstRow).forEach((key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
const matchedKey = Object.keys(booleanMapping).find((k) => lowerKey.includes(k));
|
||||
|
||||
|
||||
if (matchedKey) {
|
||||
const label = booleanMapping[matchedKey];
|
||||
|
||||
|
||||
// 이미 추가된 라벨이면 스킵
|
||||
if (addedBooleanLabels.has(label)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const validItems = data.filter((item: any) => item[key] !== null && item[key] !== undefined);
|
||||
|
||||
|
||||
if (validItems.length > 0) {
|
||||
const trueCount = validItems.filter((item: any) => {
|
||||
const val = item[key];
|
||||
return val === true || val === "true" || val === 1 || val === "1" || val === "Y";
|
||||
}).length;
|
||||
|
||||
|
||||
const rate = (trueCount / validItems.length) * 100;
|
||||
|
||||
|
||||
statsItems.push({
|
||||
label,
|
||||
value: rate,
|
||||
|
|
@ -499,7 +505,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
color: "purple",
|
||||
icon: "✅",
|
||||
});
|
||||
|
||||
|
||||
addedBooleanLabels.add(label);
|
||||
}
|
||||
}
|
||||
|
|
@ -507,27 +513,27 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
|
||||
// console.log("📊 생성된 통계 항목:", statsItems.map(s => s.label));
|
||||
setAllStats(statsItems);
|
||||
|
||||
|
||||
// 초기화가 아직 안됐으면 localStorage에서 설정 불러오기
|
||||
if (!isInitializedRef.current) {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
// console.log("💾 저장된 설정:", saved);
|
||||
|
||||
|
||||
if (saved) {
|
||||
try {
|
||||
const savedLabels = JSON.parse(saved);
|
||||
// console.log("✅ 저장된 라벨:", savedLabels);
|
||||
|
||||
|
||||
const filtered = statsItems.filter((s) => savedLabels.includes(s.label));
|
||||
// console.log("🔍 필터링된 통계:", filtered.map(s => s.label));
|
||||
// console.log(`📊 일치율: ${filtered.length}/${savedLabels.length} (${Math.round(filtered.length / savedLabels.length * 100)}%)`);
|
||||
|
||||
|
||||
// 50% 이상 일치하면 저장된 설정 사용
|
||||
const matchRate = filtered.length / savedLabels.length;
|
||||
if (matchRate >= 0.5 && filtered.length > 0) {
|
||||
setStats(filtered);
|
||||
// 실제 표시되는 라벨로 업데이트
|
||||
const actualLabels = filtered.map(s => s.label);
|
||||
const actualLabels = filtered.map((s) => s.label);
|
||||
setSelectedStats(actualLabels);
|
||||
selectedStatsRef.current = actualLabels;
|
||||
// localStorage도 업데이트하여 다음에는 정확히 일치하도록
|
||||
|
|
@ -562,11 +568,11 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
// 이미 초기화됐으면 현재 선택된 통계 유지
|
||||
const currentSelected = selectedStatsRef.current;
|
||||
// console.log("🔄 현재 선택된 통계:", currentSelected);
|
||||
|
||||
|
||||
if (currentSelected.length > 0) {
|
||||
const filtered = statsItems.filter((s) => currentSelected.includes(s.label));
|
||||
// console.log("🔍 필터링 결과:", filtered.map(s => s.label));
|
||||
|
||||
|
||||
if (filtered.length > 0) {
|
||||
setStats(filtered);
|
||||
} else {
|
||||
|
|
@ -624,9 +630,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||
{!element?.dataSource?.query && (
|
||||
<div className="mt-2 text-xs text-gray-500">톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요</div>
|
||||
)}
|
||||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
|
|
@ -652,9 +656,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
|
||||
const handleToggleStat = (label: string) => {
|
||||
setSelectedStats((prev) => {
|
||||
const newStats = prev.includes(label)
|
||||
? prev.filter((l) => l !== label)
|
||||
: [...prev, label];
|
||||
const newStats = prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label];
|
||||
// console.log("🔘 토글:", label, "→", newStats.length + "개 선택");
|
||||
return newStats;
|
||||
});
|
||||
|
|
@ -663,14 +665,14 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
const handleApplySettings = () => {
|
||||
// console.log("💾 설정 적용:", selectedStats);
|
||||
// console.log("📊 전체 통계:", allStats.map(s => s.label));
|
||||
|
||||
|
||||
const filtered = allStats.filter((s) => selectedStats.includes(s.label));
|
||||
// console.log("✅ 필터링 결과:", filtered.map(s => s.label));
|
||||
|
||||
|
||||
setStats(filtered);
|
||||
selectedStatsRef.current = selectedStats; // ref도 업데이트
|
||||
setShowSettings(false);
|
||||
|
||||
|
||||
// localStorage에 설정 저장
|
||||
localStorage.setItem(storageKey, JSON.stringify(selectedStats));
|
||||
// console.log("💾 localStorage 저장 완료:", selectedStats.length + "개");
|
||||
|
|
@ -693,7 +695,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
<button
|
||||
onClick={() => {
|
||||
// 설정 모달 열 때 현재 표시 중인 통계로 동기화
|
||||
const currentLabels = stats.map(s => s.label);
|
||||
const currentLabels = stats.map((s) => s.label);
|
||||
// console.log("⚙️ 설정 모달 열기 - 현재 표시 중:", currentLabels);
|
||||
setSelectedStats(currentLabels);
|
||||
setShowSettings(true);
|
||||
|
|
@ -713,9 +715,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
const colors = getColorClasses(stat.color);
|
||||
return (
|
||||
<div key={index} className={`rounded-lg border ${colors.bg} p-4 text-center`}>
|
||||
<div className="text-sm text-gray-600">
|
||||
{stat.label}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{stat.label}</div>
|
||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
||||
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()}
|
||||
<span className="ml-1 text-lg">{stat.unit}</span>
|
||||
|
|
@ -737,9 +737,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
표시하고 싶은 통계를 선택하세요 (최대 제한 없음)
|
||||
</div>
|
||||
<div className="mb-4 text-sm text-gray-600">표시하고 싶은 통계를 선택하세요 (최대 제한 없음)</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{allStats.map((stat, index) => {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
|
|
@ -65,11 +65,11 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
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 : "데이터 로딩 실패");
|
||||
|
|
@ -80,7 +80,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
|
||||
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")) {
|
||||
|
|
@ -93,7 +93,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
|
||||
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")) {
|
||||
|
|
@ -102,19 +102,20 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
return "bg-muted text-muted-foreground";
|
||||
};
|
||||
|
||||
const filteredIssues = filterPriority === "all"
|
||||
? issues
|
||||
: issues.filter((issue) => {
|
||||
const priority = (issue.priority || "").toLowerCase();
|
||||
return priority.includes(filterPriority);
|
||||
});
|
||||
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>
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -123,11 +124,11 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-destructive">
|
||||
<div className="text-destructive text-center">
|
||||
<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"
|
||||
className="bg-destructive/10 hover:bg-destructive/20 mt-2 rounded-md px-3 py-1 text-xs"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -139,21 +140,21 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
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 className="text-muted-foreground text-center">
|
||||
<p className="text-sm">클릭하여 데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||
<div className="bg-background flex h-full flex-col overflow-hidden p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-foreground">고객 클레임/이슈</h3>
|
||||
<h3 className="text-foreground text-lg font-semibold">고객 클레임/이슈</h3>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground rounded-full p-1"
|
||||
title="새로고침"
|
||||
>
|
||||
🔄
|
||||
|
|
@ -205,48 +206,48 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
</div>
|
||||
|
||||
{/* 총 건수 */}
|
||||
<div className="mb-3 text-sm text-muted-foreground">
|
||||
총 <span className="font-semibold text-foreground">{filteredIssues.length}</span>건
|
||||
<div className="text-muted-foreground mb-3 text-sm">
|
||||
총 <span className="text-foreground font-semibold">{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">
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center text-center">
|
||||
<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"
|
||||
className="border-border bg-card rounded-lg border 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 || "")}`}>
|
||||
<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 || "")}`}>
|
||||
<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>
|
||||
<p className="text-foreground text-sm font-medium">{issue.issue_type || issue.issueType || "기타"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
고객: {issue.customer_name || issue.customerName || "-"}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{issue.description || "설명 없음"}
|
||||
</p>
|
||||
|
||||
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">{issue.description || "설명 없음"}</p>
|
||||
|
||||
{(issue.created_at || issue.createdAt) && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -257,4 +258,3 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
|
|
@ -55,11 +55,11 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
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 || "알 수 없음";
|
||||
|
|
@ -76,7 +76,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
|
||||
setStatusData(formattedData);
|
||||
}
|
||||
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
|
|
@ -161,7 +161,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
<p className="text-sm">데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -183,7 +183,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
</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"
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -211,4 +211,3 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
|
|
@ -56,7 +56,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
if (!response.ok) throw new Error("데이터 로딩 실패");
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
// 데이터 처리
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
|
|
@ -80,7 +80,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
delivered: deliveredToday,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
|
|
@ -120,7 +120,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||
<p className="text-sm">데이터를 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -131,11 +131,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
{/* 헤더 */}
|
||||
<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 onClick={loadData} className="rounded-full p-1 text-gray-500 hover:bg-gray-100" title="새로고침">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -161,4 +157,3 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,66 +22,66 @@ interface ColumnInfo {
|
|||
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": "마지막활동",
|
||||
|
||||
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": "종류",
|
||||
|
||||
vehicle_number: "차량번호",
|
||||
model: "모델",
|
||||
year: "연식",
|
||||
color: "색상",
|
||||
type: "종류",
|
||||
|
||||
// 배송 관련
|
||||
"delivery_id": "배송ID",
|
||||
"order_id": "주문ID",
|
||||
"customer_name": "고객명",
|
||||
"address": "주소",
|
||||
"delivery_date": "배송일",
|
||||
"estimated_time": "예상시간",
|
||||
|
||||
delivery_id: "배송ID",
|
||||
order_id: "주문ID",
|
||||
customer_name: "고객명",
|
||||
address: "주소",
|
||||
delivery_date: "배송일",
|
||||
estimated_time: "예상시간",
|
||||
|
||||
// 제품 관련
|
||||
"product_id": "제품ID",
|
||||
"product_name": "제품명",
|
||||
"price": "가격",
|
||||
"stock": "재고",
|
||||
"category": "카테고리",
|
||||
"description": "설명",
|
||||
|
||||
product_id: "제품ID",
|
||||
product_name: "제품명",
|
||||
price: "가격",
|
||||
stock: "재고",
|
||||
category: "카테고리",
|
||||
description: "설명",
|
||||
|
||||
// 주문 관련
|
||||
"order_date": "주문일",
|
||||
"quantity": "수량",
|
||||
"total_amount": "총금액",
|
||||
"payment_status": "결제상태",
|
||||
|
||||
order_date: "주문일",
|
||||
quantity: "수량",
|
||||
total_amount: "총금액",
|
||||
payment_status: "결제상태",
|
||||
|
||||
// 고객 관련
|
||||
"customer_id": "고객ID",
|
||||
"email": "이메일",
|
||||
"company": "회사",
|
||||
"department": "부서",
|
||||
customer_id: "고객ID",
|
||||
email: "이메일",
|
||||
company: "회사",
|
||||
department: "부서",
|
||||
};
|
||||
|
||||
return columnTranslations[colName.toLowerCase()] ||
|
||||
columnTranslations[colName.replace(/_/g, '').toLowerCase()] ||
|
||||
colName;
|
||||
|
||||
return (
|
||||
columnTranslations[colName.toLowerCase()] || columnTranslations[colName.replace(/_/g, "").toLowerCase()] || colName
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -99,7 +99,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
|
|
@ -126,7 +126,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
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",
|
||||
|
|
@ -144,10 +144,10 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
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) => ({
|
||||
|
|
@ -156,10 +156,10 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
}));
|
||||
setColumns(cols);
|
||||
}
|
||||
|
||||
|
||||
setData(rows);
|
||||
}
|
||||
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
|
|
@ -171,34 +171,30 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
// 테이블 이름 한글 번역
|
||||
const translateTableName = (name: string): string => {
|
||||
const tableTranslations: { [key: string]: string } = {
|
||||
"drivers": "기사",
|
||||
"driver": "기사",
|
||||
"vehicles": "차량",
|
||||
"vehicle": "차량",
|
||||
"products": "제품",
|
||||
"product": "제품",
|
||||
"orders": "주문",
|
||||
"order": "주문",
|
||||
"customers": "고객",
|
||||
"customer": "고객",
|
||||
"deliveries": "배송",
|
||||
"delivery": "배송",
|
||||
"users": "사용자",
|
||||
"user": "사용자",
|
||||
drivers: "기사",
|
||||
driver: "기사",
|
||||
vehicles: "차량",
|
||||
vehicle: "차량",
|
||||
products: "제품",
|
||||
product: "제품",
|
||||
orders: "주문",
|
||||
order: "주문",
|
||||
customers: "고객",
|
||||
customer: "고객",
|
||||
deliveries: "배송",
|
||||
delivery: "배송",
|
||||
users: "사용자",
|
||||
user: "사용자",
|
||||
};
|
||||
|
||||
return tableTranslations[name.toLowerCase()] ||
|
||||
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
|
||||
name;
|
||||
|
||||
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())
|
||||
)
|
||||
Object.values(row).some((value) => String(value).toLowerCase().includes(searchTerm.toLowerCase())),
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -244,8 +240,6 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
</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>
|
||||
|
|
@ -263,7 +257,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
</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"
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -278,7 +272,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
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"
|
||||
className="focus:border-primary focus:ring-primary w-full rounded border border-gray-300 px-2 py-1 text-xs focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -290,10 +284,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
<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"
|
||||
>
|
||||
<th key={col.key} className="border border-gray-300 px-2 py-1 text-left font-semibold text-gray-700">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
|
|
@ -303,10 +294,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
{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"
|
||||
>
|
||||
<td key={col.key} className="border border-gray-300 px-2 py-1 text-gray-800">
|
||||
{String(row[col.key] || "")}
|
||||
</td>
|
||||
))}
|
||||
|
|
@ -323,4 +311,3 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,23 +39,21 @@ interface MarkerData {
|
|||
// 테이블명 한글 번역
|
||||
const translateTableName = (name: string): string => {
|
||||
const tableTranslations: { [key: string]: string } = {
|
||||
"vehicle_locations": "차량",
|
||||
"vehicles": "차량",
|
||||
"warehouses": "창고",
|
||||
"warehouse": "창고",
|
||||
"customers": "고객",
|
||||
"customer": "고객",
|
||||
"deliveries": "배송",
|
||||
"delivery": "배송",
|
||||
"drivers": "기사",
|
||||
"driver": "기사",
|
||||
"stores": "매장",
|
||||
"store": "매장",
|
||||
vehicle_locations: "차량",
|
||||
vehicles: "차량",
|
||||
warehouses: "창고",
|
||||
warehouse: "창고",
|
||||
customers: "고객",
|
||||
customer: "고객",
|
||||
deliveries: "배송",
|
||||
delivery: "배송",
|
||||
drivers: "기사",
|
||||
driver: "기사",
|
||||
stores: "매장",
|
||||
store: "매장",
|
||||
};
|
||||
|
||||
return tableTranslations[name.toLowerCase()] ||
|
||||
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
|
||||
name;
|
||||
|
||||
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -74,14 +72,14 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
if (element?.dataSource?.query) {
|
||||
loadMapData();
|
||||
}
|
||||
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(() => {
|
||||
if (element?.dataSource?.query) {
|
||||
loadMapData();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [element]);
|
||||
|
||||
|
|
@ -124,7 +122,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
|
||||
if (result.success && result.data?.rows) {
|
||||
const rows = result.data.rows;
|
||||
|
||||
|
||||
// 위도/경도 컬럼 찾기
|
||||
const latCol = element.chartConfig?.latitudeColumn || "latitude";
|
||||
const lngCol = element.chartConfig?.longitudeColumn || "longitude";
|
||||
|
|
@ -162,12 +160,12 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
{element?.dataSource?.query ? (
|
||||
<p className="text-xs text-gray-500">총 {markers.length.toLocaleString()}개 마커</p>
|
||||
) : (
|
||||
<p className="text-xs text-orange-500">⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요</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"
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
disabled={loading || !element?.dataSource?.query}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -182,7 +180,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
)}
|
||||
|
||||
{/* 지도 (항상 표시) */}
|
||||
<div className="relative flex-1 rounded border border-gray-300 bg-white overflow-hidden z-0">
|
||||
<div className="relative z-0 flex-1 overflow-hidden rounded border border-gray-300 bg-white">
|
||||
<MapContainer
|
||||
key={`map-${element.id}`}
|
||||
center={[36.5, 127.5]}
|
||||
|
|
|
|||
|
|
@ -21,109 +21,109 @@ interface StatusConfig {
|
|||
// 영어 상태명 → 한글 자동 변환
|
||||
const statusTranslations: { [key: string]: string } = {
|
||||
// 배송 관련
|
||||
"delayed": "지연",
|
||||
"pickup_waiting": "픽업 대기",
|
||||
"in_transit": "배송 중",
|
||||
"delivered": "배송완료",
|
||||
"pending": "대기중",
|
||||
"processing": "처리중",
|
||||
"completed": "완료",
|
||||
"cancelled": "취소됨",
|
||||
"failed": "실패",
|
||||
|
||||
delayed: "지연",
|
||||
pickup_waiting: "픽업 대기",
|
||||
in_transit: "배송 중",
|
||||
delivered: "배송완료",
|
||||
pending: "대기중",
|
||||
processing: "처리중",
|
||||
completed: "완료",
|
||||
cancelled: "취소됨",
|
||||
failed: "실패",
|
||||
|
||||
// 일반 상태
|
||||
"active": "활성",
|
||||
"inactive": "비활성",
|
||||
"enabled": "사용중",
|
||||
"disabled": "사용안함",
|
||||
"online": "온라인",
|
||||
"offline": "오프라인",
|
||||
"available": "사용가능",
|
||||
"unavailable": "사용불가",
|
||||
|
||||
active: "활성",
|
||||
inactive: "비활성",
|
||||
enabled: "사용중",
|
||||
disabled: "사용안함",
|
||||
online: "온라인",
|
||||
offline: "오프라인",
|
||||
available: "사용가능",
|
||||
unavailable: "사용불가",
|
||||
|
||||
// 승인 관련
|
||||
"approved": "승인됨",
|
||||
"rejected": "거절됨",
|
||||
"waiting": "대기중",
|
||||
|
||||
approved: "승인됨",
|
||||
rejected: "거절됨",
|
||||
waiting: "대기중",
|
||||
|
||||
// 차량 관련
|
||||
"driving": "운행중",
|
||||
"parked": "주차",
|
||||
"maintenance": "정비중",
|
||||
|
||||
driving: "운행중",
|
||||
parked: "주차",
|
||||
maintenance: "정비중",
|
||||
|
||||
// 기사 관련 (존중하는 표현)
|
||||
"waiting": "대기중",
|
||||
"resting": "휴식중",
|
||||
"unavailable": "운행불가",
|
||||
|
||||
waiting: "대기중",
|
||||
resting: "휴식중",
|
||||
unavailable: "운행불가",
|
||||
|
||||
// 기사 평가
|
||||
"excellent": "우수",
|
||||
"good": "양호",
|
||||
"average": "보통",
|
||||
"poor": "미흡",
|
||||
|
||||
excellent: "우수",
|
||||
good: "양호",
|
||||
average: "보통",
|
||||
poor: "미흡",
|
||||
|
||||
// 기사 경력
|
||||
"veteran": "베테랑",
|
||||
"experienced": "숙련",
|
||||
"intermediate": "중급",
|
||||
"beginner": "초급",
|
||||
veteran: "베테랑",
|
||||
experienced: "숙련",
|
||||
intermediate: "중급",
|
||||
beginner: "초급",
|
||||
};
|
||||
|
||||
// 영어 테이블명 → 한글 자동 변환
|
||||
const tableTranslations: { [key: string]: string } = {
|
||||
// 배송/물류 관련
|
||||
"deliveries": "배송",
|
||||
"delivery": "배송",
|
||||
"shipments": "출하",
|
||||
"shipment": "출하",
|
||||
"orders": "주문",
|
||||
"order": "주문",
|
||||
"cargo": "화물",
|
||||
"cargos": "화물",
|
||||
"packages": "소포",
|
||||
"package": "소포",
|
||||
|
||||
deliveries: "배송",
|
||||
delivery: "배송",
|
||||
shipments: "출하",
|
||||
shipment: "출하",
|
||||
orders: "주문",
|
||||
order: "주문",
|
||||
cargo: "화물",
|
||||
cargos: "화물",
|
||||
packages: "소포",
|
||||
package: "소포",
|
||||
|
||||
// 차량 관련
|
||||
"vehicles": "차량",
|
||||
"vehicle": "차량",
|
||||
"vehicle_locations": "차량위치",
|
||||
"vehicle_status": "차량상태",
|
||||
"drivers": "기사",
|
||||
"driver": "기사",
|
||||
|
||||
vehicles: "차량",
|
||||
vehicle: "차량",
|
||||
vehicle_locations: "차량위치",
|
||||
vehicle_status: "차량상태",
|
||||
drivers: "기사",
|
||||
driver: "기사",
|
||||
|
||||
// 사용자/고객 관련
|
||||
"users": "사용자",
|
||||
"user": "사용자",
|
||||
"customers": "고객",
|
||||
"customer": "고객",
|
||||
"members": "회원",
|
||||
"member": "회원",
|
||||
|
||||
users: "사용자",
|
||||
user: "사용자",
|
||||
customers: "고객",
|
||||
customer: "고객",
|
||||
members: "회원",
|
||||
member: "회원",
|
||||
|
||||
// 제품/재고 관련
|
||||
"products": "제품",
|
||||
"product": "제품",
|
||||
"items": "항목",
|
||||
"item": "항목",
|
||||
"inventory": "재고",
|
||||
"stock": "재고",
|
||||
|
||||
products: "제품",
|
||||
product: "제품",
|
||||
items: "항목",
|
||||
item: "항목",
|
||||
inventory: "재고",
|
||||
stock: "재고",
|
||||
|
||||
// 업무 관련
|
||||
"tasks": "작업",
|
||||
"task": "작업",
|
||||
"projects": "프로젝트",
|
||||
"project": "프로젝트",
|
||||
"issues": "이슈",
|
||||
"issue": "이슈",
|
||||
"tickets": "티켓",
|
||||
"ticket": "티켓",
|
||||
|
||||
tasks: "작업",
|
||||
task: "작업",
|
||||
projects: "프로젝트",
|
||||
project: "프로젝트",
|
||||
issues: "이슈",
|
||||
issue: "이슈",
|
||||
tickets: "티켓",
|
||||
ticket: "티켓",
|
||||
|
||||
// 기타
|
||||
"logs": "로그",
|
||||
"log": "로그",
|
||||
"reports": "리포트",
|
||||
"report": "리포트",
|
||||
"alerts": "알림",
|
||||
"alert": "알림",
|
||||
logs: "로그",
|
||||
log: "로그",
|
||||
reports: "리포트",
|
||||
report: "리포트",
|
||||
alerts: "알림",
|
||||
alert: "알림",
|
||||
};
|
||||
|
||||
interface StatusData {
|
||||
|
|
@ -136,12 +136,12 @@ interface StatusData {
|
|||
* - 쿼리 결과를 상태별로 카운트해서 카드로 표시
|
||||
* - 색상과 라벨은 statusConfig로 커스터마이징 가능
|
||||
*/
|
||||
export default function StatusSummaryWidget({
|
||||
element,
|
||||
export default function StatusSummaryWidget({
|
||||
element,
|
||||
title = "상태 요약",
|
||||
icon = "📊",
|
||||
bgGradient = "from-slate-50 to-blue-50",
|
||||
statusConfig
|
||||
statusConfig,
|
||||
}: StatusSummaryWidgetProps) {
|
||||
const [statusData, setStatusData] = useState<StatusData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -150,7 +150,7 @@ export default function StatusSummaryWidget({
|
|||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
|
|
@ -178,7 +178,7 @@ export default function StatusSummaryWidget({
|
|||
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",
|
||||
|
|
@ -196,17 +196,17 @@ export default function StatusSummaryWidget({
|
|||
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) => {
|
||||
|
|
@ -244,7 +244,7 @@ export default function StatusSummaryWidget({
|
|||
|
||||
setStatusData(formattedData);
|
||||
}
|
||||
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 실패");
|
||||
|
|
@ -320,7 +320,6 @@ export default function StatusSummaryWidget({
|
|||
</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>
|
||||
|
|
@ -341,7 +340,7 @@ export default function StatusSummaryWidget({
|
|||
return tableTranslations[name.toLowerCase()];
|
||||
}
|
||||
// 언더스코어 제거하고 매칭 시도
|
||||
const nameWithoutUnderscore = name.replace(/_/g, '');
|
||||
const nameWithoutUnderscore = name.replace(/_/g, "");
|
||||
if (tableTranslations[nameWithoutUnderscore.toLowerCase()]) {
|
||||
return tableTranslations[nameWithoutUnderscore.toLowerCase()];
|
||||
}
|
||||
|
|
@ -357,7 +356,9 @@ export default function StatusSummaryWidget({
|
|||
{/* 헤더 */}
|
||||
<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>
|
||||
<h3 className="text-sm font-bold text-gray-900">
|
||||
{icon} {displayTitle}
|
||||
</h3>
|
||||
{totalCount > 0 ? (
|
||||
<p className="text-xs text-gray-500">총 {totalCount.toLocaleString()}건</p>
|
||||
) : (
|
||||
|
|
@ -366,7 +367,7 @@ export default function StatusSummaryWidget({
|
|||
</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"
|
||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "⏳" : "🔄"}
|
||||
|
|
@ -380,10 +381,7 @@ export default function StatusSummaryWidget({
|
|||
{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 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>
|
||||
|
|
@ -397,4 +395,3 @@ export default function StatusSummaryWidget({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,14 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
|||
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
|
||||
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
|
||||
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
|
||||
const deliveryTimeKeys = ["delivery_duration", "delivery_time", "duration", "배송시간", "소요시간", "배송소요시간"];
|
||||
const deliveryTimeKeys = [
|
||||
"delivery_duration",
|
||||
"delivery_time",
|
||||
"duration",
|
||||
"배송시간",
|
||||
"소요시간",
|
||||
"배송소요시간",
|
||||
];
|
||||
|
||||
// 총 운송량 찾기
|
||||
let total_weight = 0;
|
||||
|
|
@ -143,7 +150,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
|||
|
||||
// 평균 배송시간 계산
|
||||
let avg_delivery_time = 0;
|
||||
|
||||
|
||||
// 1. 먼저 배송시간 컬럼이 있는지 확인
|
||||
let foundTimeColumn = false;
|
||||
for (const key of Object.keys(numericColumns)) {
|
||||
|
|
@ -167,7 +174,14 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
|||
// 2. 배송시간 컬럼이 없으면 날짜 컬럼에서 자동 계산
|
||||
if (!foundTimeColumn) {
|
||||
const startTimeKeys = ["created_at", "start_time", "departure_time", "출발시간", "시작시간"];
|
||||
const endTimeKeys = ["actual_delivery", "end_time", "arrival_time", "도착시간", "완료시간", "estimated_delivery"];
|
||||
const endTimeKeys = [
|
||||
"actual_delivery",
|
||||
"end_time",
|
||||
"arrival_time",
|
||||
"도착시간",
|
||||
"완료시간",
|
||||
"estimated_delivery",
|
||||
];
|
||||
|
||||
let startKey = null;
|
||||
let endKey = null;
|
||||
|
|
@ -247,9 +261,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
|||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error || "데이터 없음"}</div>
|
||||
{!element?.dataSource?.query && (
|
||||
<div className="mt-2 text-xs text-gray-500">톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요</div>
|
||||
)}
|
||||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
|
|
|
|||
|
|
@ -123,7 +123,12 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
loadVehicles();
|
||||
const interval = setInterval(loadVehicles, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [element?.dataSource?.query, element?.chartConfig?.latitudeColumn, element?.chartConfig?.longitudeColumn, refreshInterval]);
|
||||
}, [
|
||||
element?.dataSource?.query,
|
||||
element?.chartConfig?.latitudeColumn,
|
||||
element?.chartConfig?.longitudeColumn,
|
||||
refreshInterval,
|
||||
]);
|
||||
|
||||
// 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거)
|
||||
|
||||
|
|
@ -172,7 +177,7 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
|
||||
{/* 지도 영역 - 브이월드 타일맵 */}
|
||||
<div className="h-[calc(100%-60px)]">
|
||||
<div className="relative h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white z-0">
|
||||
<div className="relative z-0 h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white">
|
||||
<MapContainer
|
||||
key={`vehicle-map-${element.id}`}
|
||||
center={[36.5, 127.5]}
|
||||
|
|
@ -182,54 +187,54 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
preferCanvas={true}
|
||||
className="z-0"
|
||||
>
|
||||
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
|
||||
<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}
|
||||
/>
|
||||
{/* 브이월드 타일맵 (HTTPS, 캐싱 적용) */}
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* 차량 마커 */}
|
||||
{vehicles.map((vehicle) => (
|
||||
<React.Fragment key={vehicle.id}>
|
||||
<Circle
|
||||
center={[vehicle.lat, vehicle.lng]}
|
||||
radius={150}
|
||||
pathOptions={{
|
||||
color: getStatusColor(vehicle.status),
|
||||
fillColor: getStatusColor(vehicle.status),
|
||||
fillOpacity: 0.3,
|
||||
}}
|
||||
/>
|
||||
<Marker position={[vehicle.lat, vehicle.lng]}>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-sm font-bold">{vehicle.name}</div>
|
||||
<div>
|
||||
<strong>기사:</strong> {vehicle.driver}
|
||||
</div>
|
||||
<div>
|
||||
<strong>상태:</strong> {getStatusText(vehicle.status)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>속도:</strong> {vehicle.speed} km/h
|
||||
</div>
|
||||
<div>
|
||||
<strong>목적지:</strong> {vehicle.destination}
|
||||
</div>
|
||||
{/* 차량 마커 */}
|
||||
{vehicles.map((vehicle) => (
|
||||
<React.Fragment key={vehicle.id}>
|
||||
<Circle
|
||||
center={[vehicle.lat, vehicle.lng]}
|
||||
radius={150}
|
||||
pathOptions={{
|
||||
color: getStatusColor(vehicle.status),
|
||||
fillColor: getStatusColor(vehicle.status),
|
||||
fillOpacity: 0.3,
|
||||
}}
|
||||
/>
|
||||
<Marker position={[vehicle.lat, vehicle.lng]}>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-sm font-bold">{vehicle.name}</div>
|
||||
<div>
|
||||
<strong>기사:</strong> {vehicle.driver}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MapContainer>
|
||||
<div>
|
||||
<strong>상태:</strong> {getStatusText(vehicle.status)}
|
||||
</div>
|
||||
<div>
|
||||
<strong>속도:</strong> {vehicle.speed} km/h
|
||||
</div>
|
||||
<div>
|
||||
<strong>목적지:</strong> {vehicle.destination}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MapContainer>
|
||||
|
||||
{/* 지도 정보 */}
|
||||
<div className="absolute right-2 top-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="absolute top-2 right-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="mb-1 font-semibold">🗺️ 브이월드 (VWorld)</div>
|
||||
<div className="text-xs">국토교통부 공식 지도</div>
|
||||
|
|
@ -241,9 +246,7 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
{vehicles.length > 0 ? (
|
||||
<div className="text-xs font-semibold text-gray-900">총 {vehicles.length}대 모니터링 중</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-600">
|
||||
⚙️ 톱니바퀴 클릭하여 데이터 연결
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">데이터를 연결하세요</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -251,4 +254,3 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,7 @@
|
|||
|
||||
import { useState, useEffect } from "react";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import {
|
||||
WORK_TYPE_LABELS,
|
||||
WORK_STATUS_LABELS,
|
||||
WORK_STATUS_COLORS,
|
||||
WorkType,
|
||||
WorkStatus,
|
||||
} from "@/types/workHistory";
|
||||
import { WORK_TYPE_LABELS, WORK_STATUS_LABELS, WORK_STATUS_COLORS, WorkType, WorkStatus } from "@/types/workHistory";
|
||||
|
||||
interface WorkHistoryWidgetProps {
|
||||
element: DashboardElement;
|
||||
|
|
@ -97,11 +91,7 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
|||
<div className="text-center">
|
||||
<div className="mb-2 text-4xl">⚠️</div>
|
||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||
{!element.dataSource?.query && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요
|
||||
</div>
|
||||
)}
|
||||
{!element.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||
|
|
|
|||
Loading…
Reference in New Issue