사이드바 방식 변경하면서 생긴 오류 해결
This commit is contained in:
parent
01ebb2550c
commit
994d9b70cd
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 푸터: 적용 버튼 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { DashboardElement } from "../types";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface YardWidgetConfigSidebarProps {
|
||||
element: DashboardElement;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (updates: Partial<DashboardElement>) => void;
|
||||
}
|
||||
|
||||
export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: YardWidgetConfigSidebarProps) {
|
||||
const [customTitle, setCustomTitle] = useState(element.customTitle || "");
|
||||
const [showHeader, setShowHeader] = useState(element.showHeader !== false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCustomTitle(element.customTitle || "");
|
||||
setShowHeader(element.showHeader !== false);
|
||||
}
|
||||
}, [isOpen, element]);
|
||||
|
||||
const handleApply = () => {
|
||||
onApply({
|
||||
customTitle,
|
||||
showHeader,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
||||
)}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
||||
<span className="text-primary text-xs font-bold">🏗️</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-gray-900">야드 관리 위젯 설정</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
{/* 위젯 제목 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">위젯 제목</div>
|
||||
<Input
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)"
|
||||
className="h-8 text-xs"
|
||||
style={{ fontSize: "12px" }}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-gray-500">기본 제목: 야드 관리 3D</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 */}
|
||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">헤더 표시</div>
|
||||
<RadioGroup
|
||||
value={showHeader ? "show" : "hide"}
|
||||
onValueChange={(value) => setShowHeader(value === "show")}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
|
||||
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
|
||||
표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
|
||||
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
|
||||
숨김
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ListColumn, QueryResult, ListWidgetConfig } from "../../types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
interface ColumnSelectorProps {
|
||||
availableColumns: string[];
|
||||
selectedColumns: ListColumn[];
|
||||
sampleData: Record<string, any>;
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
queryResult: QueryResult;
|
||||
config: ListWidgetConfig;
|
||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -23,15 +20,18 @@ interface ColumnSelectorProps {
|
|||
* - 정렬, 너비, 정렬 방향 설정
|
||||
* - 드래그 앤 드롭으로 순서 변경
|
||||
*/
|
||||
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
|
||||
export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSelectorProps) {
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const availableColumns = queryResult.columns;
|
||||
const selectedColumns = config.columns || [];
|
||||
const sampleData = queryResult.rows[0] || {};
|
||||
|
||||
// 컬럼 선택/해제
|
||||
const handleToggle = (field: string) => {
|
||||
const exists = selectedColumns.find((col) => col.field === field);
|
||||
if (exists) {
|
||||
onChange(selectedColumns.filter((col) => col.field !== field));
|
||||
onConfigChange({ columns: selectedColumns.filter((col) => col.field !== field) });
|
||||
} else {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${selectedColumns.length}`,
|
||||
|
|
@ -40,18 +40,22 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...selectedColumns, newCol]);
|
||||
onConfigChange({ columns: [...selectedColumns, newCol] });
|
||||
}
|
||||
};
|
||||
|
||||
// 컬럼 라벨 변경
|
||||
const handleLabelChange = (field: string, label: string) => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)));
|
||||
onConfigChange({
|
||||
columns: selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)),
|
||||
});
|
||||
};
|
||||
|
||||
// 정렬 방향 변경
|
||||
const handleAlignChange = (field: string, align: "left" | "center" | "right") => {
|
||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
|
||||
onConfigChange({
|
||||
columns: selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)),
|
||||
});
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
|
|
@ -64,40 +68,29 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
||||
|
||||
setDragOverIndex(hoverIndex);
|
||||
|
||||
const newColumns = [...selectedColumns];
|
||||
const draggedItem = newColumns[draggedIndex];
|
||||
newColumns.splice(draggedIndex, 1);
|
||||
newColumns.splice(hoverIndex, 0, draggedItem);
|
||||
|
||||
setDraggedIndex(hoverIndex);
|
||||
onChange(newColumns);
|
||||
onConfigChange({ columns: newColumns });
|
||||
};
|
||||
|
||||
// 드롭
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">컬럼 선택 및 설정</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
표시할 컬럼을 선택하고 이름을 변경하세요. 드래그하여 순서를 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="space-y-1.5">
|
||||
{/* 선택된 컬럼을 먼저 순서대로 표시 */}
|
||||
{selectedColumns.map((selectedCol, columnIndex) => {
|
||||
const field = selectedCol.field;
|
||||
|
|
@ -127,52 +120,74 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
handleDragEnd();
|
||||
e.currentTarget.style.cursor = "grab";
|
||||
}}
|
||||
className={`rounded-lg border p-4 transition-all ${
|
||||
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
|
||||
className={`group relative rounded-md border transition-all ${
|
||||
isSelected
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
||||
} ${isDraggable ? "cursor-grab active:cursor-grabbing" : ""} ${
|
||||
draggedIndex === columnIndex ? "opacity-50" : ""
|
||||
draggedIndex === columnIndex ? "scale-95 opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className={`h-4 w-4 ${isDraggable ? "text-blue-500" : "text-gray-400"}`} />
|
||||
<span className="font-medium text-gray-700">{field}</span>
|
||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggle(field)}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<GripVertical
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-colors ${
|
||||
isDraggable ? "group-hover:text-primary text-gray-400" : "text-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-900">{field}</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
{isSelected && selectedCol && (
|
||||
<div className="ml-7 grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름</Label>
|
||||
<Input
|
||||
value={selectedCol.label}
|
||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||
placeholder="컬럼명"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
value={selectedCol.label}
|
||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={selectedCol.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 정렬 */}
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
value={selectedCol.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[4rem]">
|
||||
<SelectItem value="left" className="py-1 text-[10px]">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" className="py-1 text-[10px]">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="py-1 text-[10px]">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -191,18 +206,23 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
const isSelected = false;
|
||||
const isDraggable = false;
|
||||
|
||||
return (
|
||||
<div key={field} className={`rounded-lg border border-gray-200 p-4 transition-all`}>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<Checkbox checked={false} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||
<span className="font-medium text-gray-700">{field}</span>
|
||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||
<div
|
||||
key={field}
|
||||
className="group rounded-md border border-gray-200 bg-white transition-all hover:border-gray-300 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Checkbox
|
||||
checked={false}
|
||||
onCheckedChange={() => handleToggle(field)}
|
||||
className="h-3.5 w-3.5 shrink-0"
|
||||
/>
|
||||
<GripVertical className="h-3.5 w-3.5 shrink-0 text-gray-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-600">{field}</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -212,10 +232,11 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
|||
</div>
|
||||
|
||||
{selectedColumns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-center text-sm text-yellow-700">
|
||||
⚠️ 최소 1개 이상의 컬럼을 선택해주세요
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">최소 1개 이상의 컬럼을 선택해주세요</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,70 +2,42 @@
|
|||
|
||||
import React from "react";
|
||||
import { ListWidgetConfig } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface ListTableOptionsProps {
|
||||
config: ListWidgetConfig;
|
||||
onChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 리스트 테이블 옵션 설정 컴포넌트
|
||||
* - 페이지 크기, 검색, 정렬 등 설정
|
||||
*/
|
||||
export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
|
||||
export function ListTableOptions({ config, onConfigChange }: ListTableOptionsProps) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">테이블 옵션</h3>
|
||||
<p className="text-sm text-gray-600">테이블 동작과 스타일을 설정하세요</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
{/* 뷰 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">뷰 모드</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">뷰 모드</Label>
|
||||
<RadioGroup
|
||||
value={config.viewMode}
|
||||
onValueChange={(value: "table" | "card") => onChange({ viewMode: value })}
|
||||
onValueChange={(value: "table" | "card") => onConfigChange({ viewMode: value })}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="table" id="table" />
|
||||
<Label htmlFor="table" className="cursor-pointer font-normal">
|
||||
📊 테이블 (기본)
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="table" id="table" className="h-3 w-3" />
|
||||
<Label htmlFor="table" className="cursor-pointer text-[11px] font-normal">
|
||||
테이블
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="card" id="card" />
|
||||
<Label htmlFor="card" className="cursor-pointer font-normal">
|
||||
🗂️ 카드
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 모드 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">컬럼 설정 방식</Label>
|
||||
<RadioGroup
|
||||
value={config.columnMode}
|
||||
onValueChange={(value: "auto" | "manual") => onChange({ columnMode: value })}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="auto" id="auto" />
|
||||
<Label htmlFor="auto" className="cursor-pointer font-normal">
|
||||
자동 (쿼리 결과에서 선택)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label htmlFor="manual" className="cursor-pointer font-normal">
|
||||
수동 (직접 추가 및 매핑)
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="card" id="card" className="h-3 w-3" />
|
||||
<Label htmlFor="card" className="cursor-pointer text-[11px] font-normal">
|
||||
카드
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
|
@ -74,94 +46,122 @@ export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
|
|||
{/* 카드 뷰 컬럼 수 */}
|
||||
{config.viewMode === "card" && (
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">카드 컬럼 수</Label>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">카드 컬럼 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="6"
|
||||
value={config.cardColumns || 3}
|
||||
onChange={(e) => onChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
||||
className="w-full"
|
||||
onChange={(e) => onConfigChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
||||
className="h-6 w-full px-1.5 text-[11px]"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||
<p className="mt-0.5 text-[9px] text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지 크기 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-sm font-medium">페이지당 행 수</Label>
|
||||
<Select value={String(config.pageSize)} onValueChange={(value) => onChange({ pageSize: parseInt(value) })}>
|
||||
<SelectTrigger>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">페이지당 행 수</Label>
|
||||
<Select
|
||||
value={String(config.pageSize)}
|
||||
onValueChange={(value) => onConfigChange({ pageSize: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-6 px-1.5 text-[11px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5개</SelectItem>
|
||||
<SelectItem value="10">10개</SelectItem>
|
||||
<SelectItem value="20">20개</SelectItem>
|
||||
<SelectItem value="50">50개</SelectItem>
|
||||
<SelectItem value="100">100개</SelectItem>
|
||||
<SelectItem value="5" className="text-[11px]">
|
||||
5개
|
||||
</SelectItem>
|
||||
<SelectItem value="10" className="text-[11px]">
|
||||
10개
|
||||
</SelectItem>
|
||||
<SelectItem value="20" className="text-[11px]">
|
||||
20개
|
||||
</SelectItem>
|
||||
<SelectItem value="50" className="text-[11px]">
|
||||
50개
|
||||
</SelectItem>
|
||||
<SelectItem value="100" className="text-[11px]">
|
||||
100개
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기능 활성화 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">기능</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="enablePagination"
|
||||
checked={config.enablePagination}
|
||||
onCheckedChange={(checked) => onChange({ enablePagination: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="enablePagination" className="cursor-pointer font-normal">
|
||||
페이지네이션
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">페이지네이션</Label>
|
||||
<RadioGroup
|
||||
value={config.enablePagination ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => onConfigChange({ enablePagination: value === "enabled" })}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="enabled" id="pagination-enabled" className="h-3 w-3" />
|
||||
<Label htmlFor="pagination-enabled" className="cursor-pointer text-[11px] font-normal">
|
||||
사용
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="disabled" id="pagination-disabled" className="h-3 w-3" />
|
||||
<Label htmlFor="pagination-disabled" className="cursor-pointer text-[11px] font-normal">
|
||||
사용 안 함
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 스타일 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">스타일</Label>
|
||||
<div className="space-y-2">
|
||||
{config.viewMode === "table" && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showHeader"
|
||||
checked={config.showHeader}
|
||||
onCheckedChange={(checked) => onChange({ showHeader: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="cursor-pointer font-normal">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="stripedRows"
|
||||
checked={config.stripedRows}
|
||||
onCheckedChange={(checked) => onChange({ stripedRows: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="stripedRows" className="cursor-pointer font-normal">
|
||||
줄무늬 행
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="compactMode"
|
||||
checked={config.compactMode}
|
||||
onCheckedChange={(checked) => onChange({ compactMode: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor="compactMode" className="cursor-pointer font-normal">
|
||||
압축 모드 (작은 크기)
|
||||
</Label>
|
||||
</div>
|
||||
{/* 헤더 표시 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">헤더 표시</Label>
|
||||
<RadioGroup
|
||||
value={config.showHeader ? "show" : "hide"}
|
||||
onValueChange={(value) => onConfigChange({ showHeader: value === "show" })}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
|
||||
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
|
||||
표시
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
|
||||
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
|
||||
숨김
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 줄무늬 행 */}
|
||||
{config.viewMode === "table" && (
|
||||
<div>
|
||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">줄무늬 행</Label>
|
||||
<RadioGroup
|
||||
value={config.stripedRows ? "enabled" : "disabled"}
|
||||
onValueChange={(value) => onConfigChange({ stripedRows: value === "enabled" })}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="enabled" id="striped-enabled" className="h-3 w-3" />
|
||||
<Label htmlFor="striped-enabled" className="cursor-pointer text-[11px] font-normal">
|
||||
사용
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="disabled" id="striped-disabled" className="h-3 w-3" />
|
||||
<Label htmlFor="striped-disabled" className="cursor-pointer text-[11px] font-normal">
|
||||
사용 안 함
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListColumn } from "../../types";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ListColumn, ListWidgetConfig } from "../../types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
|
||||
interface ManualColumnEditorProps {
|
||||
availableFields: string[];
|
||||
columns: ListColumn[];
|
||||
onChange: (columns: ListColumn[]) => void;
|
||||
config: ListWidgetConfig;
|
||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -21,30 +17,30 @@ interface ManualColumnEditorProps {
|
|||
* - 컬럼명과 데이터 필드 직접 매핑
|
||||
* - 드래그 앤 드롭으로 순서 변경
|
||||
*/
|
||||
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
|
||||
export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEditorProps) {
|
||||
const columns = config.columns || [];
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// 새 컬럼 추가
|
||||
const handleAddColumn = () => {
|
||||
const newCol: ListColumn = {
|
||||
id: `col_${Date.now()}`,
|
||||
label: `컬럼 ${columns.length + 1}`,
|
||||
field: availableFields[0] || "",
|
||||
field: "",
|
||||
align: "left",
|
||||
visible: true,
|
||||
};
|
||||
onChange([...columns, newCol]);
|
||||
onConfigChange({ columns: [...columns, newCol] });
|
||||
};
|
||||
|
||||
// 컬럼 삭제
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(columns.filter((col) => col.id !== id));
|
||||
onConfigChange({ columns: columns.filter((col) => col.id !== id) });
|
||||
};
|
||||
|
||||
// 컬럼 속성 업데이트
|
||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
||||
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
|
||||
onConfigChange({ columns: columns.map((col) => (col.id === id ? { ...col, ...updates } : col)) });
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
|
|
@ -57,46 +53,41 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
|
|||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
||||
|
||||
setDragOverIndex(hoverIndex);
|
||||
|
||||
const newColumns = [...columns];
|
||||
const draggedItem = newColumns[draggedIndex];
|
||||
newColumns.splice(draggedIndex, 1);
|
||||
newColumns.splice(hoverIndex, 0, draggedItem);
|
||||
|
||||
setDraggedIndex(hoverIndex);
|
||||
onChange(newColumns);
|
||||
onConfigChange({ columns: newColumns });
|
||||
};
|
||||
|
||||
// 드롭
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">수동 컬럼 편집</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
직접 컬럼을 추가하고 데이터 필드를 매핑하세요. 드래그하여 순서를 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<div>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-gray-500">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
컬럼 추가
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={col.id}
|
||||
|
|
@ -111,82 +102,72 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
|
|||
handleDragEnd();
|
||||
e.currentTarget.style.cursor = "grab";
|
||||
}}
|
||||
className={`cursor-grab rounded-lg border border-gray-200 bg-gray-50 p-4 transition-all active:cursor-grabbing ${
|
||||
draggedIndex === index ? "opacity-50" : ""
|
||||
}`}
|
||||
className={`group relative rounded-md border border-gray-200 bg-white shadow-sm transition-all hover:border-gray-300 hover:shadow-sm ${
|
||||
draggedIndex === index ? "scale-95 opacity-50" : ""
|
||||
} cursor-grab active:cursor-grabbing`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium text-gray-700">컬럼 {index + 1}</span>
|
||||
<Button
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
|
||||
<span className="text-[11px] font-medium text-gray-900">컬럼 {index + 1}</span>
|
||||
<button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto text-red-600 hover:text-red-700"
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* 컬럼명 */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 이름 *</Label>
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="예: 사용자 이름"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{/* 설정 영역 */}
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 데이터 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs">데이터 필드 *</Label>
|
||||
<Select value={col.field} onValueChange={(value) => handleUpdate(col.id, { field: value })}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
{/* 데이터 필드 */}
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
value={col.field}
|
||||
onChange={(e) => handleUpdate(col.id, { field: e.target.value })}
|
||||
placeholder="데이터 필드"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[4rem]">
|
||||
<SelectItem value="left" className="py-1 text-[10px]">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정렬 방향 */}
|
||||
<div>
|
||||
<Label className="text-xs">정렬</Label>
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={col.width || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdate(col.id, { width: e.target.value ? parseInt(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="자동"
|
||||
className="mt-1"
|
||||
/>
|
||||
<SelectItem value="center" className="py-1 text-[10px]">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="py-1 text-[10px]">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -194,13 +175,18 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
|
|||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-4 rounded-lg border border-gray-300 bg-gray-100 p-8 text-center">
|
||||
<div className="text-sm text-gray-600">컬럼을 추가하여 시작하세요</div>
|
||||
<Button onClick={handleAddColumn} size="sm" className="mt-3 gap-2">
|
||||
<Plus className="h-4 w-4" />첫 번째 컬럼 추가
|
||||
</Button>
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">컬럼을 추가하여 시작하세요</span>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 ml-auto flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ListColumn, ListWidgetConfig, QueryResult } from "../../types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
interface UnifiedColumnEditorProps {
|
||||
queryResult: QueryResult | null;
|
||||
config: ListWidgetConfig;
|
||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 컬럼 에디터
|
||||
* - 쿼리 실행 시 자동으로 컬럼 추출
|
||||
* - 모든 필드 편집 가능 (필드명, 표시 이름, 정렬)
|
||||
* - 수동으로 컬럼 추가 가능
|
||||
*/
|
||||
export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: UnifiedColumnEditorProps) {
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
|
||||
const columns = config.columns || [];
|
||||
const sampleData = queryResult?.rows[0] || {};
|
||||
|
||||
// 컬럼 추가
|
||||
const handleAddColumn = () => {
|
||||
const newColumn: ListColumn = {
|
||||
id: `col_${Date.now()}`,
|
||||
field: "",
|
||||
label: "",
|
||||
visible: true,
|
||||
align: "left",
|
||||
};
|
||||
|
||||
onConfigChange({
|
||||
columns: [...columns, newColumn],
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 삭제
|
||||
const handleRemove = (id: string) => {
|
||||
onConfigChange({
|
||||
columns: columns.filter((col) => col.id !== id),
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 업데이트
|
||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
||||
onConfigChange({
|
||||
columns: columns.map((col) => (col.id === id ? { ...col, ...updates } : col)),
|
||||
});
|
||||
};
|
||||
|
||||
// 컬럼 토글
|
||||
const handleToggle = (id: string) => {
|
||||
onConfigChange({
|
||||
columns: columns.map((col) => (col.id === id ? { ...col, visible: !col.visible } : col)),
|
||||
});
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
const newColumns = [...columns];
|
||||
const draggedItem = newColumns[draggedIndex];
|
||||
|
||||
newColumns.splice(draggedIndex, 1);
|
||||
newColumns.splice(index, 0, draggedItem);
|
||||
|
||||
onConfigChange({ columns: newColumns });
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
// 드롭
|
||||
const handleDrop = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
// 드래그 종료
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-gray-500">컬럼을 선택하고 편집하세요</p>
|
||||
<button
|
||||
onClick={handleAddColumn}
|
||||
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
컬럼 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{columns.map((col, index) => {
|
||||
const preview = sampleData[col.field];
|
||||
const previewText =
|
||||
preview !== undefined && preview !== null
|
||||
? typeof preview === "object"
|
||||
? JSON.stringify(preview).substring(0, 30)
|
||||
: String(preview).substring(0, 30)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
handleDragStart(index);
|
||||
e.currentTarget.style.cursor = "grabbing";
|
||||
}}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={(e) => {
|
||||
handleDragEnd();
|
||||
e.currentTarget.style.cursor = "grab";
|
||||
}}
|
||||
className={`group relative rounded-md border transition-all ${
|
||||
col.visible
|
||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
||||
} cursor-grab active:cursor-grabbing ${draggedIndex === index ? "scale-95 opacity-50" : ""}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
||||
<Checkbox
|
||||
checked={col.visible}
|
||||
onCheckedChange={() => handleToggle(col.id)}
|
||||
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary h-4 w-4 shrink-0 rounded-full"
|
||||
/>
|
||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[11px] font-medium text-gray-900">
|
||||
{col.field || "(필드명 없음)"}
|
||||
</span>
|
||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRemove(col.id)}
|
||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 설정 영역 */}
|
||||
{col.visible && (
|
||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{/* 표시 이름 */}
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||
placeholder="표시 이름"
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
||||
style={{ fontSize: "10px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
value={col.align}
|
||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[4rem]">
|
||||
<SelectItem value="left" className="py-1 text-[10px]">
|
||||
왼쪽
|
||||
</SelectItem>
|
||||
<SelectItem value="center" className="py-1 text-[10px]">
|
||||
가운데
|
||||
</SelectItem>
|
||||
<SelectItem value="right" className="py-1 text-[10px]">
|
||||
오른쪽
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{columns.length === 0 && (
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
||||
<span className="text-amber-600">⚠️</span>
|
||||
<span className="text-[10px] text-amber-700">쿼리를 실행하거나 컬럼을 추가하세요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue