[agent-pipeline] pipe-20260311151253-nyk7 round-3
This commit is contained in:
parent
5eb10fd9a9
commit
08402bf730
|
|
@ -0,0 +1,779 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* V2CardDisplay 설정 패널
|
||||
* 토스식 단계별 UX: 테이블 선택 -> 컬럼 매핑 -> 카드 스타일 -> 고급 설정(접힘)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Database,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
|
||||
// ─── 한 행당 카드 수 카드 정의 ───
|
||||
const CARDS_PER_ROW_OPTIONS = [
|
||||
{ value: 1, label: "1개" },
|
||||
{ value: 2, label: "2개" },
|
||||
{ value: 3, label: "3개" },
|
||||
{ value: 4, label: "4개" },
|
||||
{ value: 5, label: "5개" },
|
||||
{ value: 6, label: "6개" },
|
||||
] as const;
|
||||
|
||||
interface EntityJoinColumn {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
joinAlias: string;
|
||||
suggestedLabel: string;
|
||||
}
|
||||
|
||||
interface JoinTable {
|
||||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
joinConfig?: { sourceColumn: string };
|
||||
availableColumns: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
dataType: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface V2CardDisplayConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
screenTableName?: string;
|
||||
tableColumns?: any[];
|
||||
}
|
||||
|
||||
export const V2CardDisplayConfigPanel: React.FC<V2CardDisplayConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
screenTableName,
|
||||
tableColumns = [],
|
||||
}) => {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [availableColumns, setAvailableColumns] = useState<any[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [entityJoinColumns, setEntityJoinColumns] = useState<{
|
||||
availableColumns: EntityJoinColumn[];
|
||||
joinTables: JoinTable[];
|
||||
}>({ availableColumns: [], joinTables: [] });
|
||||
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
|
||||
|
||||
const targetTableName = useMemo(() => {
|
||||
if (config.useCustomTable && config.customTableName) {
|
||||
return config.customTableName;
|
||||
}
|
||||
return config.tableName || screenTableName;
|
||||
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
|
||||
|
||||
const updateConfig = (field: string, value: any) => {
|
||||
const newConfig = { ...config, [field]: value };
|
||||
onChange(newConfig);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", { detail: { config: newConfig } })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const updateNestedConfig = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
const newConfig = { ...config };
|
||||
let current = newConfig;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) current[keys[i]] = {};
|
||||
current[keys[i]] = { ...current[keys[i]] };
|
||||
current = current[keys[i]];
|
||||
}
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await tableManagementApi.getTableList();
|
||||
if (response.success && response.data) {
|
||||
setAllTables(
|
||||
response.data.map((t: any) => ({
|
||||
tableName: t.tableName || t.table_name,
|
||||
displayName: t.tableLabel || t.displayName || t.tableName || t.table_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* 무시 */
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadAllTables();
|
||||
}, []);
|
||||
|
||||
// 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setAvailableColumns([]);
|
||||
return;
|
||||
}
|
||||
if (!config.useCustomTable && tableColumns && tableColumns.length > 0) {
|
||||
setAvailableColumns(tableColumns);
|
||||
return;
|
||||
}
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableManagementApi.getColumnList(targetTableName);
|
||||
if (result.success && result.data?.columns) {
|
||||
setAvailableColumns(
|
||||
result.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setAvailableColumns([]);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [targetTableName, config.useCustomTable, tableColumns]);
|
||||
|
||||
// 엔티티 조인 컬럼 로드
|
||||
useEffect(() => {
|
||||
const fetchEntityJoinColumns = async () => {
|
||||
if (!targetTableName) {
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
return;
|
||||
}
|
||||
setLoadingEntityJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinColumns(targetTableName);
|
||||
setEntityJoinColumns({
|
||||
availableColumns: result.availableColumns || [],
|
||||
joinTables: result.joinTables || [],
|
||||
});
|
||||
} catch {
|
||||
setEntityJoinColumns({ availableColumns: [], joinTables: [] });
|
||||
} finally {
|
||||
setLoadingEntityJoins(false);
|
||||
}
|
||||
};
|
||||
fetchEntityJoinColumns();
|
||||
}, [targetTableName]);
|
||||
|
||||
const handleTableSelect = (tableName: string, isScreenTable: boolean) => {
|
||||
if (isScreenTable) {
|
||||
onChange({
|
||||
...config,
|
||||
useCustomTable: false,
|
||||
customTableName: undefined,
|
||||
tableName: tableName,
|
||||
columnMapping: { displayColumns: [] },
|
||||
});
|
||||
} else {
|
||||
onChange({
|
||||
...config,
|
||||
useCustomTable: true,
|
||||
customTableName: tableName,
|
||||
tableName: tableName,
|
||||
columnMapping: { displayColumns: [] },
|
||||
});
|
||||
}
|
||||
setTableComboboxOpen(false);
|
||||
};
|
||||
|
||||
const getSelectedTableDisplay = () => {
|
||||
if (!targetTableName) return "테이블을 선택하세요";
|
||||
const found = allTables.find((t) => t.tableName === targetTableName);
|
||||
return found?.displayName || targetTableName;
|
||||
};
|
||||
|
||||
// 컬럼 선택 시 조인 컬럼이면 joinColumns도 업데이트
|
||||
const handleColumnSelect = (path: string, columnName: string) => {
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === columnName
|
||||
);
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const exists = joinColumnsConfig.find((jc: any) => jc.columnName === columnName);
|
||||
if (!exists) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
const newJoinColumnConfig = {
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
columnMapping: { ...config.columnMapping, [path.split(".")[1]]: columnName },
|
||||
joinColumns: [...joinColumnsConfig, newJoinColumnConfig],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateNestedConfig(path, columnName);
|
||||
};
|
||||
|
||||
// 표시 컬럼 관리
|
||||
const addDisplayColumn = () => {
|
||||
const current = config.columnMapping?.displayColumns || [];
|
||||
updateNestedConfig("columnMapping.displayColumns", [...current, ""]);
|
||||
};
|
||||
|
||||
const removeDisplayColumn = (index: number) => {
|
||||
const current = [...(config.columnMapping?.displayColumns || [])];
|
||||
current.splice(index, 1);
|
||||
updateNestedConfig("columnMapping.displayColumns", current);
|
||||
};
|
||||
|
||||
const updateDisplayColumn = (index: number, value: string) => {
|
||||
const current = [...(config.columnMapping?.displayColumns || [])];
|
||||
current[index] = value;
|
||||
|
||||
const joinColumn = entityJoinColumns.availableColumns.find(
|
||||
(col) => col.joinAlias === value
|
||||
);
|
||||
if (joinColumn) {
|
||||
const joinColumnsConfig = config.joinColumns || [];
|
||||
const exists = joinColumnsConfig.find((jc: any) => jc.columnName === value);
|
||||
if (!exists) {
|
||||
const joinTableInfo = entityJoinColumns.joinTables?.find(
|
||||
(jt) => jt.tableName === joinColumn.tableName
|
||||
);
|
||||
onChange({
|
||||
...config,
|
||||
columnMapping: { ...config.columnMapping, displayColumns: current },
|
||||
joinColumns: [
|
||||
...joinColumnsConfig,
|
||||
{
|
||||
columnName: joinColumn.joinAlias,
|
||||
label: joinColumn.suggestedLabel || joinColumn.columnLabel,
|
||||
sourceColumn: joinTableInfo?.joinConfig?.sourceColumn || "",
|
||||
referenceTable: joinColumn.tableName,
|
||||
referenceColumn: joinColumn.columnName,
|
||||
isJoinColumn: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
updateNestedConfig("columnMapping.displayColumns", current);
|
||||
};
|
||||
|
||||
// 테이블별 조인 컬럼 그룹화
|
||||
const joinColumnsByTable: Record<string, EntityJoinColumn[]> = {};
|
||||
entityJoinColumns.availableColumns.forEach((col) => {
|
||||
if (!joinColumnsByTable[col.tableName]) joinColumnsByTable[col.tableName] = [];
|
||||
joinColumnsByTable[col.tableName].push(col);
|
||||
});
|
||||
|
||||
const currentTableColumns = config.useCustomTable
|
||||
? availableColumns
|
||||
: tableColumns.length > 0
|
||||
? tableColumns
|
||||
: availableColumns;
|
||||
|
||||
// 컬럼 선택 Select 렌더링
|
||||
const renderColumnSelect = (
|
||||
value: string,
|
||||
onChangeHandler: (value: string) => void,
|
||||
placeholder: string = "컬럼 선택"
|
||||
) => (
|
||||
<Select
|
||||
value={value || "__none__"}
|
||||
onValueChange={(val) => onChangeHandler(val === "__none__" ? "" : val)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[11px]">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" className="text-[11px] text-muted-foreground">
|
||||
선택 안함
|
||||
</SelectItem>
|
||||
{currentTableColumns.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[10px] font-semibold text-muted-foreground">
|
||||
기본 컬럼
|
||||
</SelectLabel>
|
||||
{currentTableColumns.map((column: any) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName} className="text-[11px]">
|
||||
{column.columnLabel || column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{Object.entries(joinColumnsByTable).map(([tableName, columns]) => (
|
||||
<SelectGroup key={tableName}>
|
||||
<SelectLabel className="text-[10px] font-semibold text-primary">
|
||||
{tableName} (조인)
|
||||
</SelectLabel>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.joinAlias} value={col.joinAlias} className="text-[11px]">
|
||||
{col.suggestedLabel || col.columnLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ─── 1단계: 테이블 선택 ─── */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">데이터 소스</p>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{loadingTables ? "로딩 중..." : getSelectedTableDisplay()}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
|
||||
테이블을 찾을 수 없어요
|
||||
</CommandEmpty>
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
value={screenTableName}
|
||||
onSelect={() => handleTableSelect(screenTableName, true)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTableName === screenTableName && !config.useCustomTable
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-primary" />
|
||||
{allTables.find((t) => t.tableName === screenTableName)?.displayName ||
|
||||
screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{allTables
|
||||
.filter((t) => t.tableName !== screenTableName)
|
||||
.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => handleTableSelect(table.tableName, false)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.useCustomTable && targetTableName === table.tableName
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{table.displayName}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.useCustomTable && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시해요
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── 2단계: 컬럼 매핑 ─── */}
|
||||
{(currentTableColumns.length > 0 || loadingColumns) && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">컬럼 매핑</p>
|
||||
|
||||
{(loadingEntityJoins || loadingColumns) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{loadingColumns ? "컬럼 로딩 중..." : "조인 컬럼 로딩 중..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">타이틀</span>
|
||||
<div className="w-[180px]">
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.titleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.titleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">서브타이틀</span>
|
||||
<div className="w-[180px]">
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.subtitleColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.subtitleColumn", value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">설명</span>
|
||||
<div className="w-[180px]">
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.descriptionColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.descriptionColumn", value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">이미지</span>
|
||||
<div className="w-[180px]">
|
||||
{renderColumnSelect(
|
||||
config.columnMapping?.imageColumn || "",
|
||||
(value) => handleColumnSelect("columnMapping.imageColumn", value)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 표시 컬럼 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">추가 표시 컬럼</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addDisplayColumn}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(config.columnMapping?.displayColumns || []).length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{(config.columnMapping?.displayColumns || []).map(
|
||||
(column: string, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
{renderColumnSelect(column, (value) =>
|
||||
updateDisplayColumn(index, value)
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeDisplayColumn(index)}
|
||||
className="h-7 w-7 shrink-0 p-0 text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/30 py-3 text-center text-xs text-muted-foreground">
|
||||
추가 버튼으로 표시할 컬럼을 추가해요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 3단계: 카드 레이아웃 ─── */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">카드 레이아웃</p>
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">한 행당 카드 수</span>
|
||||
<Select
|
||||
value={String(config.cardsPerRow || 3)}
|
||||
onValueChange={(v) => updateConfig("cardsPerRow", parseInt(v))}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[100px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CARDS_PER_ROW_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">카드 간격 (px)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
value={config.cardSpacing ?? 16}
|
||||
onChange={(e) => updateConfig("cardSpacing", parseInt(e.target.value))}
|
||||
className="h-7 w-[100px] text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 4단계: 표시 요소 토글 ─── */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">표시 요소</p>
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">타이틀</p>
|
||||
<p className="text-[11px] text-muted-foreground">카드 상단 제목</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showTitle ?? true}
|
||||
onCheckedChange={(checked) => updateNestedConfig("cardStyle.showTitle", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">서브타이틀</p>
|
||||
<p className="text-[11px] text-muted-foreground">제목 아래 보조 텍스트</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showSubtitle ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showSubtitle", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">설명</p>
|
||||
<p className="text-[11px] text-muted-foreground">카드 본문 텍스트</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showDescription ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showDescription", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">이미지</p>
|
||||
<p className="text-[11px] text-muted-foreground">카드 이미지 영역</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showImage ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showImage", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">액션 버튼</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
상세보기, 편집, 삭제 버튼
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showActions ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showActions", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(config.cardStyle?.showActions ?? true) && (
|
||||
<div className="ml-4 border-l-2 border-primary/20 pl-3 space-y-2">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">상세보기</span>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showViewButton ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showViewButton", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">편집</span>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showEditButton ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showEditButton", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">삭제</span>
|
||||
<Switch
|
||||
checked={config.cardStyle?.showDeleteButton ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateNestedConfig("cardStyle.showDeleteButton", checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── 5단계: 고급 설정 (기본 접혀있음) ─── */}
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">고급 설정</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||
advancedOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs text-muted-foreground">설명 최대 길이</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={500}
|
||||
value={config.cardStyle?.maxDescriptionLength ?? 100}
|
||||
onChange={(e) =>
|
||||
updateNestedConfig(
|
||||
"cardStyle.maxDescriptionLength",
|
||||
parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
className="h-7 w-[100px] text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">비활성화</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
카드 상호작용을 비활성화해요
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.disabled || false}
|
||||
onCheckedChange={(checked) => updateConfig("disabled", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<p className="text-sm">읽기 전용</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
데이터 수정을 막아요
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.readonly || false}
|
||||
onCheckedChange={(checked) => updateConfig("readonly", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2CardDisplayConfigPanel.displayName = "V2CardDisplayConfigPanel";
|
||||
|
||||
export default V2CardDisplayConfigPanel;
|
||||
|
|
@ -5,7 +5,7 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
|
|||
import { ComponentCategory } from "@/types/component";
|
||||
import type { WebType } from "@/types/screen";
|
||||
import { CardDisplayComponent } from "./CardDisplayComponent";
|
||||
import { CardDisplayConfigPanel } from "./CardDisplayConfigPanel";
|
||||
import { V2CardDisplayConfigPanel } from "@/components/v2/config-panels/V2CardDisplayConfigPanel";
|
||||
import { CardDisplayConfig } from "./types";
|
||||
|
||||
/**
|
||||
|
|
@ -38,7 +38,7 @@ export const V2CardDisplayDefinition = createComponentDefinition({
|
|||
staticData: [],
|
||||
},
|
||||
defaultSize: { width: 800, height: 400 },
|
||||
configPanel: CardDisplayConfigPanel,
|
||||
configPanel: V2CardDisplayConfigPanel,
|
||||
icon: "Grid3x3",
|
||||
tags: ["card", "display", "table", "grid"],
|
||||
version: "1.0.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue