사이드바 디자인 다듬기
This commit is contained in:
parent
8a421cfced
commit
85987af65e
|
|
@ -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"], // 기본
|
||||
|
|
|
|||
|
|
@ -7,8 +7,6 @@ import { ChartConfigPanel } from "./ChartConfigPanel";
|
|||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
|
@ -174,137 +172,169 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-96 flex-col bg-white shadow-2xl transition-transform duration-300 ease-in-out",
|
||||
"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 border-b p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{element.title} 설정</h3>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<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-4">
|
||||
{/* 기본 설정 */}
|
||||
<div className="mb-4 space-y-3">
|
||||
{/* 커스텀 제목 입력 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">위젯 제목</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="제목을 입력해주세요."
|
||||
className="focus:border-primary focus:ring-primary w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-1 focus:outline-none"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 헤더 표시 옵션 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showHeader"
|
||||
checked={showHeader}
|
||||
onChange={(e) => setShowHeader(e.target.checked)}
|
||||
className="text-primary focus:ring-primary h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="showHeader" className="text-sm font-medium text-gray-700">
|
||||
위젯 헤더 표시
|
||||
{/* 헤더 표시 옵션 */}
|
||||
<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 && (
|
||||
<Tabs
|
||||
defaultValue={dataSource.type}
|
||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="database">데이터베이스</TabsTrigger>
|
||||
<TabsTrigger value="api">REST API</TabsTrigger>
|
||||
</TabsList>
|
||||
<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>
|
||||
|
||||
<TabsContent value="database" className="mt-4 space-y-4">
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
||||
<div className="mt-4">
|
||||
{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="database" className="mt-2 space-y-2">
|
||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
||||
<QueryEditor
|
||||
dataSource={dataSource}
|
||||
onDataSourceChange={handleDataSourceUpdate}
|
||||
onQueryTest={handleQueryTest}
|
||||
/>
|
||||
|
||||
<TabsContent value="api" className="mt-4 space-y-4">
|
||||
<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>
|
||||
|
||||
{/* 차트/지도 설정 */}
|
||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
||||
<div className="mt-4">
|
||||
{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>
|
||||
)}
|
||||
<TabsContent value="api" className="mt-2 space-y-2">
|
||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
||||
|
||||
{/* 데이터 로드 상태 */}
|
||||
{queryResult && (
|
||||
<div className="mt-4">
|
||||
<Badge variant="default">{queryResult.rows.length}개 데이터 로드됨</Badge>
|
||||
{/* 차트/지도 설정 */}
|
||||
{!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 items-center justify-end gap-2 border-t p-4">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<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 size="sm" onClick={handleApply} disabled={isHeaderOnlyWidget ? false : !canApply}>
|
||||
</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>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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">현재 DB</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">외부 DB</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,31 +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">
|
||||
데이터베이스가 선택되었습니다.
|
||||
<br />
|
||||
아래에서 SQL 쿼리를 작성하세요.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue