사이드바 방식 변경하면서 생긴 오류 해결

This commit is contained in:
dohyeons 2025-10-22 15:29:57 +09:00
parent 01ebb2550c
commit 994d9b70cd
8 changed files with 679 additions and 367 deletions

View File

@ -8,7 +8,7 @@ import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
import { ApiConfig } from "./data-sources/ApiConfig";
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -131,16 +131,16 @@ export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: Elem
);
}
// 야드 위젯은 별도 모달로 처리
// 야드 위젯은 사이드바로 처리
if (element.subtype === "yard-management-3d") {
return (
<YardWidgetConfigModal
<YardWidgetConfigSidebar
element={element}
isOpen={isOpen}
onClose={onClose}
onSave={(updatedElement) => {
onApply({ ...element, ...updatedElement });
onApply={(updates) => {
onApply({ ...element, ...updates });
}}
onClose={onClose}
/>
);
}

View File

@ -256,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)

View File

@ -8,8 +8,7 @@ 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 { ColumnSelector } from "./list-widget/ColumnSelector";
import { ManualColumnEditor } from "./list-widget/ManualColumnEditor";
import { UnifiedColumnEditor } from "./list-widget/UnifiedColumnEditor";
import { ListTableOptions } from "./list-widget/ListTableOptions";
interface ListWidgetConfigSidebarProps {
@ -30,7 +29,6 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
element.listConfig || {
columnMode: "auto",
viewMode: "table",
columns: [],
pageSize: 10,
@ -96,22 +94,23 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
const handleQueryTest = useCallback((result: QueryResult) => {
setQueryResult(result);
// 자동 모드인 경우: 쿼리 결과의 컬럼을 자동으로 listConfig.columns에 반영
// 쿼리 결과의 컬럼을 자동으로 listConfig.columns에 추가 (기존 컬럼은 유지)
setListConfig((prev) => {
if (prev.columnMode === "auto") {
return {
...prev,
columns: result.columns.map((col, idx) => ({
id: `col_${idx}`,
key: col,
label: col,
visible: true,
width: "auto",
align: "left",
})),
};
}
return 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],
};
});
}, []);
@ -133,7 +132,7 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
}, [element, title, dataSource, listConfig, onApply]);
// 저장 가능 여부
const canApply = queryResult && queryResult.rows.length > 0 && listConfig.columns.length > 0;
const canApply = listConfig.columns.length > 0 && listConfig.columns.some((col) => col.visible && col.field);
return (
<div
@ -208,62 +207,10 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
onDataSourceChange={handleDataSourceUpdate}
onQueryTest={handleQueryTest}
/>
{/* 컬럼 설정 */}
{queryResult && queryResult.rows.length > 0 && (
<div className="mt-2">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
{listConfig.columnMode === "auto" ? (
<ColumnSelector
queryResult={queryResult}
config={listConfig}
onConfigChange={handleListConfigChange}
/>
) : (
<ManualColumnEditor config={listConfig} onConfigChange={handleListConfigChange} />
)}
</div>
)}
{/* 테이블 옵션 */}
{queryResult && queryResult.rows.length > 0 && (
<div className="mt-2">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">
</div>
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
</div>
)}
</TabsContent>
<TabsContent value="api" className="mt-2 space-y-2">
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
{/* 컬럼 설정 */}
{queryResult && queryResult.rows.length > 0 && (
<div className="mt-2">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase"> </div>
{listConfig.columnMode === "auto" ? (
<ColumnSelector
queryResult={queryResult}
config={listConfig}
onConfigChange={handleListConfigChange}
/>
) : (
<ManualColumnEditor config={listConfig} onConfigChange={handleListConfigChange} />
)}
</div>
)}
{/* 테이블 옵션 */}
{queryResult && queryResult.rows.length > 0 && (
<div className="mt-2">
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">
</div>
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
</div>
)}
</TabsContent>
</Tabs>
@ -275,6 +222,26 @@ export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: L
</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>
{/* 푸터: 적용 버튼 */}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}