2025-10-15 11:17:09 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
|
|
|
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig, ListColumn } from "../types";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { ChevronLeft, ChevronRight, Save, X } from "lucide-react";
|
|
|
|
|
import { DataSourceSelector } from "../data-sources/DataSourceSelector";
|
|
|
|
|
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
|
|
|
|
import { ApiConfig } from "../data-sources/ApiConfig";
|
|
|
|
|
import { QueryEditor } from "../QueryEditor";
|
|
|
|
|
import { ColumnSelector } from "./list-widget/ColumnSelector";
|
|
|
|
|
import { ManualColumnEditor } from "./list-widget/ManualColumnEditor";
|
|
|
|
|
import { ListTableOptions } from "./list-widget/ListTableOptions";
|
|
|
|
|
|
|
|
|
|
interface ListWidgetConfigModalProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
element: DashboardElement;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSave: (updates: Partial<DashboardElement>) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 리스트 위젯 설정 모달
|
|
|
|
|
* - 3단계 설정: 데이터 소스 → 데이터 가져오기 → 컬럼 설정
|
|
|
|
|
*/
|
|
|
|
|
export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: ListWidgetConfigModalProps) {
|
|
|
|
|
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
|
|
|
|
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 || {
|
|
|
|
|
columnMode: "auto",
|
2025-10-15 11:29:53 +09:00
|
|
|
viewMode: "table",
|
2025-10-15 11:17:09 +09:00
|
|
|
columns: [],
|
|
|
|
|
pageSize: 10,
|
|
|
|
|
enablePagination: true,
|
|
|
|
|
showHeader: true,
|
|
|
|
|
stripedRows: true,
|
|
|
|
|
compactMode: false,
|
2025-10-15 11:29:53 +09:00
|
|
|
cardColumns: 3,
|
2025-10-15 11:17:09 +09:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 모달 열릴 때 element에서 설정 로드 (한 번만)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isOpen) {
|
|
|
|
|
// element가 변경되었을 때만 설정을 다시 로드
|
|
|
|
|
setTitle(element.title || "📋 리스트");
|
|
|
|
|
|
|
|
|
|
// 기존 dataSource가 있으면 그대로 사용, 없으면 기본값
|
|
|
|
|
if (element.dataSource) {
|
|
|
|
|
setDataSource(element.dataSource);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기존 listConfig가 있으면 그대로 사용, 없으면 기본값
|
|
|
|
|
if (element.listConfig) {
|
|
|
|
|
setListConfig(element.listConfig);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 현재 스텝은 1로 초기화
|
|
|
|
|
setCurrentStep(1);
|
|
|
|
|
}
|
2025-10-15 11:29:53 +09:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2025-10-15 11:17:09 +09:00
|
|
|
}, [isOpen, element.id]); // element.id가 변경될 때만 재실행
|
|
|
|
|
|
|
|
|
|
// 데이터 소스 타입 변경
|
|
|
|
|
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
|
|
|
|
if (type === "database") {
|
|
|
|
|
setDataSource((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
type: "database",
|
|
|
|
|
connectionType: "current",
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
setDataSource((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
type: "api",
|
|
|
|
|
method: "GET",
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 데이터 소스 타입 변경 시에는 쿼리 결과만 초기화 (컬럼 설정은 유지)
|
|
|
|
|
setQueryResult(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 데이터 소스 업데이트
|
|
|
|
|
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
|
|
|
|
setDataSource((prev) => ({ ...prev, ...updates }));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 쿼리 실행 결과 처리
|
|
|
|
|
const handleQueryTest = useCallback(
|
|
|
|
|
(result: QueryResult) => {
|
|
|
|
|
setQueryResult(result);
|
|
|
|
|
|
|
|
|
|
// 자동 모드이고 기존 컬럼이 없을 때만 자동 생성
|
|
|
|
|
if (listConfig.columnMode === "auto" && result.columns.length > 0 && listConfig.columns.length === 0) {
|
|
|
|
|
const autoColumns: ListColumn[] = result.columns.map((col, idx) => ({
|
|
|
|
|
id: `col_${idx}`,
|
|
|
|
|
label: col,
|
|
|
|
|
field: col,
|
|
|
|
|
align: "left",
|
|
|
|
|
visible: true,
|
|
|
|
|
}));
|
|
|
|
|
setListConfig((prev) => ({ ...prev, columns: autoColumns }));
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[listConfig.columnMode, listConfig.columns.length],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 다음 단계
|
|
|
|
|
const handleNext = () => {
|
|
|
|
|
if (currentStep < 3) {
|
|
|
|
|
setCurrentStep((prev) => (prev + 1) as 1 | 2 | 3);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 이전 단계
|
|
|
|
|
const handlePrev = () => {
|
|
|
|
|
if (currentStep > 1) {
|
|
|
|
|
setCurrentStep((prev) => (prev - 1) as 1 | 2 | 3);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 저장
|
|
|
|
|
const handleSave = () => {
|
|
|
|
|
onSave({
|
2025-10-17 14:52:08 +09:00
|
|
|
customTitle: title,
|
2025-10-15 11:17:09 +09:00
|
|
|
dataSource,
|
|
|
|
|
listConfig,
|
|
|
|
|
});
|
|
|
|
|
onClose();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 저장 가능 여부
|
|
|
|
|
const canSave = queryResult && queryResult.rows.length > 0 && listConfig.columns.length > 0;
|
|
|
|
|
|
|
|
|
|
if (!isOpen) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
|
|
|
<div className="flex max-h-[90vh] w-[90vw] max-w-6xl flex-col rounded-xl border bg-white shadow-2xl">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="space-y-4 border-b px-6 py-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-xl font-bold">📋 리스트 위젯 설정</h2>
|
|
|
|
|
<p className="mt-1 text-sm text-gray-600">데이터 소스와 컬럼을 설정하세요</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-gray-100">
|
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 제목 입력 */}
|
|
|
|
|
<div>
|
|
|
|
|
<Label htmlFor="list-title" className="text-sm font-medium">
|
|
|
|
|
리스트 이름
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="list-title"
|
|
|
|
|
value={title}
|
|
|
|
|
onChange={(e) => setTitle(e.target.value)}
|
2025-10-17 14:52:08 +09:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
// 모든 키보드 이벤트를 input 필드 내부에서만 처리
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
}}
|
2025-10-15 11:17:09 +09:00
|
|
|
placeholder="예: 사용자 목록"
|
|
|
|
|
className="mt-1"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-10-17 14:52:08 +09:00
|
|
|
|
|
|
|
|
{/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */}
|
|
|
|
|
<div className="rounded bg-blue-50 p-2 text-xs text-blue-700">
|
|
|
|
|
💡 리스트 위젯은 제목이 항상 표시됩니다
|
|
|
|
|
</div>
|
2025-10-15 11:17:09 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 진행 상태 표시 */}
|
|
|
|
|
<div className="border-b bg-gray-50 px-6 py-4">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-4">
|
|
|
|
|
<div className={`flex items-center gap-2 ${currentStep >= 1 ? "text-blue-600" : "text-gray-400"}`}>
|
|
|
|
|
<div
|
|
|
|
|
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 1 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
|
|
|
|
>
|
|
|
|
|
1
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-sm font-medium">데이터 소스</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-0.5 w-12 bg-gray-300" />
|
|
|
|
|
<div className={`flex items-center gap-2 ${currentStep >= 2 ? "text-blue-600" : "text-gray-400"}`}>
|
|
|
|
|
<div
|
|
|
|
|
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 2 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
|
|
|
|
>
|
|
|
|
|
2
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-sm font-medium">데이터 가져오기</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-0.5 w-12 bg-gray-300" />
|
|
|
|
|
<div className={`flex items-center gap-2 ${currentStep >= 3 ? "text-blue-600" : "text-gray-400"}`}>
|
|
|
|
|
<div
|
|
|
|
|
className={`flex h-8 w-8 items-center justify-center rounded-full ${currentStep >= 3 ? "bg-blue-600 text-white" : "bg-gray-300"}`}
|
|
|
|
|
>
|
|
|
|
|
3
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-sm font-medium">컬럼 설정</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 컨텐츠 */}
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
|
|
|
{currentStep === 1 && (
|
|
|
|
|
<DataSourceSelector dataSource={dataSource} onTypeChange={handleDataSourceTypeChange} />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{currentStep === 2 && (
|
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
|
|
|
{/* 왼쪽: 데이터 소스 설정 */}
|
|
|
|
|
<div>
|
|
|
|
|
{dataSource.type === "database" ? (
|
|
|
|
|
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
|
|
|
|
) : (
|
|
|
|
|
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{dataSource.type === "database" && (
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<QueryEditor
|
|
|
|
|
dataSource={dataSource}
|
|
|
|
|
onDataSourceChange={handleDataSourceUpdate}
|
|
|
|
|
onQueryTest={handleQueryTest}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 오른쪽: 데이터 미리보기 */}
|
|
|
|
|
<div>
|
|
|
|
|
{queryResult && queryResult.rows.length > 0 ? (
|
|
|
|
|
<div className="rounded-lg border bg-gray-50 p-4">
|
|
|
|
|
<h3 className="mb-3 font-semibold text-gray-800">📋 데이터 미리보기</h3>
|
|
|
|
|
<div className="overflow-x-auto rounded bg-white p-3">
|
|
|
|
|
<Badge variant="secondary" className="mb-2">
|
|
|
|
|
{queryResult.totalRows}개 데이터
|
|
|
|
|
</Badge>
|
|
|
|
|
<pre className="text-xs text-gray-700">
|
|
|
|
|
{JSON.stringify(queryResult.rows.slice(0, 3), null, 2)}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 미리보기가 표시됩니다</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{currentStep === 3 && queryResult && (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{listConfig.columnMode === "auto" ? (
|
|
|
|
|
<ColumnSelector
|
|
|
|
|
availableColumns={queryResult.columns}
|
|
|
|
|
selectedColumns={listConfig.columns}
|
|
|
|
|
sampleData={queryResult.rows[0]}
|
|
|
|
|
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<ManualColumnEditor
|
|
|
|
|
availableFields={queryResult.columns}
|
|
|
|
|
columns={listConfig.columns}
|
|
|
|
|
onChange={(columns) => setListConfig((prev) => ({ ...prev, columns }))}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<ListTableOptions
|
|
|
|
|
config={listConfig}
|
|
|
|
|
onChange={(updates) => setListConfig((prev) => ({ ...prev, ...updates }))}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 푸터 */}
|
|
|
|
|
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
|
|
|
|
<div>
|
|
|
|
|
{queryResult && (
|
|
|
|
|
<Badge variant="default" className="bg-green-600">
|
|
|
|
|
📊 {queryResult.rows.length}개 데이터 로드됨
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
{currentStep > 1 && (
|
|
|
|
|
<Button variant="outline" onClick={handlePrev}>
|
|
|
|
|
<ChevronLeft className="mr-2 h-4 w-4" />
|
|
|
|
|
이전
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button variant="outline" onClick={onClose}>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
{currentStep < 3 ? (
|
|
|
|
|
<Button onClick={handleNext} disabled={currentStep === 2 && !queryResult}>
|
|
|
|
|
다음
|
|
|
|
|
<ChevronRight className="ml-2 h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button onClick={handleSave} disabled={!canSave}>
|
|
|
|
|
<Save className="mr-2 h-4 w-4" />
|
|
|
|
|
저장
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|