"use client"; /** * pop-card-list 설정 패널 * * 2개 탭: * [기본 설정] - 테이블 선택 + 조인/정렬 + 레이아웃 설정 * [카드 템플릿] - 헤더/이미지/본문/입력/계산/담기 설정 */ import React, { useState, useEffect, useMemo } from "react"; import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react"; import type { GridMode } from "@/components/pop/designer/types/pop-layout"; import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { cn } from "@/lib/utils"; import type { PopCardListConfig, CardListDataSource, CardSortConfig, CardTemplateConfig, CardHeaderConfig, CardImageConfig, CardBodyConfig, CardFieldBinding, FieldValueType, FormulaOperator, FormulaRightType, CardColumnJoin, CardColumnFilter, CardScrollDirection, FilterOperator, CardInputFieldConfig, CardPackageConfig, CardCartActionConfig, CardResponsiveConfig, ResponsiveDisplayMode, } from "../types"; import { CARD_SCROLL_DIRECTION_LABELS, RESPONSIVE_DISPLAY_LABELS, DEFAULT_CARD_IMAGE, } from "../types"; import { fetchTableList, fetchTableColumns, type TableInfo, type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; // ===== 테이블별 그룹화된 컬럼 ===== interface ColumnGroup { tableName: string; displayName: string; columns: ColumnInfo[]; } // ===== Props ===== interface ConfigPanelProps { config: PopCardListConfig | undefined; onUpdate: (config: PopCardListConfig) => void; currentMode?: GridMode; currentColSpan?: number; } // ===== 기본값 ===== const DEFAULT_DATA_SOURCE: CardListDataSource = { tableName: "", }; const DEFAULT_HEADER: CardHeaderConfig = { codeField: undefined, titleField: undefined, }; const DEFAULT_IMAGE: CardImageConfig = { enabled: true, imageColumn: undefined, defaultImage: DEFAULT_CARD_IMAGE, }; const DEFAULT_BODY: CardBodyConfig = { fields: [], }; const DEFAULT_TEMPLATE: CardTemplateConfig = { header: DEFAULT_HEADER, image: DEFAULT_IMAGE, body: DEFAULT_BODY, }; const DEFAULT_CONFIG: PopCardListConfig = { dataSource: DEFAULT_DATA_SOURCE, cardTemplate: DEFAULT_TEMPLATE, scrollDirection: "vertical", gridColumns: 2, gridRows: 3, cardSize: "large", }; // ===== 색상 옵션 (본문 필드 텍스트 색상) ===== const COLOR_OPTIONS = [ { value: "__default__", label: "기본" }, { value: "#ef4444", label: "빨간색" }, { value: "#f97316", label: "주황색" }, { value: "#eab308", label: "노란색" }, { value: "#22c55e", label: "초록색" }, { value: "#3b82f6", label: "파란색" }, { value: "#8b5cf6", label: "보라색" }, { value: "#6b7280", label: "회색" }, ]; // ===== 메인 컴포넌트 ===== export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) { const [activeTab, setActiveTab] = useState<"basic" | "template">("basic"); const cfg: PopCardListConfig = config || DEFAULT_CONFIG; const updateConfig = (partial: Partial) => { onUpdate({ ...cfg, ...partial }); }; const hasTable = !!cfg.dataSource?.tableName; return (
{/* 탭 헤더 - 2탭 */}
{/* 탭 내용 */}
{activeTab === "basic" && ( )} {activeTab === "template" && ( )}
); } // ===== 기본 설정 탭 (테이블 + 레이아웃 통합) ===== function BasicSettingsTab({ config, onUpdate, currentMode, currentColSpan, }: { config: PopCardListConfig; onUpdate: (partial: Partial) => void; currentMode?: GridMode; currentColSpan?: number; }) { const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); const [joinColumnsMap, setJoinColumnsMap] = useState>({}); useEffect(() => { fetchTableList().then(setTables); }, []); useEffect(() => { if (dataSource.tableName) { fetchTableColumns(dataSource.tableName).then(setColumns); } else { setColumns([]); } }, [dataSource.tableName]); // 조인 테이블 컬럼 로드 const joinsKey = useMemo( () => JSON.stringify((dataSource.joins || []).map((j) => j.targetTable)), [dataSource.joins] ); useEffect(() => { const joins = dataSource.joins || []; const targetTables = joins .map((j) => j.targetTable) .filter((t): t is string => !!t); if (targetTables.length === 0) { setJoinColumnsMap({}); return; } Promise.all( targetTables.map(async (table) => { const cols = await fetchTableColumns(table); return { table, cols }; }) ).then((results) => { const map: Record = {}; results.forEach(({ table, cols }) => { map[table] = cols; }); setJoinColumnsMap(map); }); }, [joinsKey]); // eslint-disable-line react-hooks/exhaustive-deps const getTableDisplayName = (tableName: string) => { const found = tables.find((t) => t.tableName === tableName); return found?.displayName || tableName; }; const columnGroups: ColumnGroup[] = useMemo(() => { const groups: ColumnGroup[] = []; if (dataSource.tableName && columns.length > 0) { groups.push({ tableName: dataSource.tableName, displayName: getTableDisplayName(dataSource.tableName), columns, }); } (dataSource.joins || []).forEach((join) => { if (join.targetTable && joinColumnsMap[join.targetTable]) { groups.push({ tableName: join.targetTable, displayName: getTableDisplayName(join.targetTable), columns: joinColumnsMap[join.targetTable], }); } }); return groups; }, [dataSource.tableName, columns, dataSource.joins, joinColumnsMap, tables]); // eslint-disable-line react-hooks/exhaustive-deps const recommendation = useMemo(() => { if (!currentMode) return null; const cols = GRID_BREAKPOINTS[currentMode].columns; if (cols >= 8) return { rows: 3, cols: 2 }; if (cols >= 6) return { rows: 3, cols: 1 }; return { rows: 2, cols: 1 }; }, [currentMode]); const maxColumns = useMemo(() => { if (!currentColSpan) return 2; return currentColSpan >= 8 ? 2 : 1; }, [currentColSpan]); const modeLabel = currentMode ? GRID_BREAKPOINTS[currentMode].label : null; useEffect(() => { if (!recommendation) return; const currentRows = config.gridRows || 3; const currentCols = config.gridColumns || 2; if (currentRows !== recommendation.rows || currentCols !== recommendation.cols) { onUpdate({ gridRows: recommendation.rows, gridColumns: recommendation.cols, }); } }, [currentMode]); // eslint-disable-line react-hooks/exhaustive-deps const updateDataSource = (partial: Partial) => { onUpdate({ dataSource: { ...dataSource, ...partial } }); }; return (
{/* 테이블 선택 */}
{ onUpdate({ dataSource: { tableName: val, joins: undefined, filters: undefined, sort: undefined, limit: undefined, }, cardTemplate: DEFAULT_TEMPLATE, }); }} />
{dataSource.tableName && (
{dataSource.tableName}
)}
{/* 조인 설정 (테이블 선택 시만 표시) */} {dataSource.tableName && ( 0 ? `${dataSource.joins.length}개` : undefined } > )} {/* 정렬 기준 (테이블 선택 시만 표시) */} {dataSource.tableName && ( 0 ? `${dataSource.sort.length}개` : undefined : "1개" : undefined } > )} {/* 레이아웃 설정 */}
{modeLabel && (
현재: {modeLabel}
)}
{(["horizontal", "vertical"] as CardScrollDirection[]).map((dir) => ( ))}
onUpdate({ gridRows: parseInt(e.target.value, 10) || 3 }) } className="h-7 w-16 text-center text-xs" /> x onUpdate({ gridColumns: Math.min(parseInt(e.target.value, 10) || 1, maxColumns) }) } className="h-7 w-16 text-center text-xs" disabled={maxColumns === 1} />

{config.scrollDirection === "horizontal" ? "격자로 배치, 가로 스크롤" : "격자로 배치, 세로 스크롤"}

{maxColumns === 1 ? "현재 모드에서 열 최대 1 (모드 변경 시 자동 적용)" : "모드 변경 시 열/행 자동 적용 / 열 최대 2"}

); } // (DataSourceTab 제거됨 - 조인/정렬 설정이 BasicSettingsTab으로 통합) // ===== 카드 템플릿 탭 ===== function CardTemplateTab({ config, onUpdate, }: { config: PopCardListConfig; onUpdate: (partial: Partial) => void; }) { const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const template = config.cardTemplate || DEFAULT_TEMPLATE; const [tables, setTables] = useState([]); const [mainColumns, setMainColumns] = useState([]); const [joinColumnsMap, setJoinColumnsMap] = useState>({}); // 테이블 목록 로드 (한글명 표시용) useEffect(() => { fetchTableList().then(setTables); }, []); // 메인 테이블 컬럼 로드 useEffect(() => { if (dataSource.tableName) { fetchTableColumns(dataSource.tableName).then(setMainColumns); } else { setMainColumns([]); } }, [dataSource.tableName]); // 조인 테이블 컬럼 로드 const joinsKey = useMemo( () => JSON.stringify((dataSource.joins || []).map((j) => j.targetTable)), [dataSource.joins] ); useEffect(() => { const joins = dataSource.joins || []; const targetTables = joins .map((j) => j.targetTable) .filter((t): t is string => !!t); if (targetTables.length === 0) { setJoinColumnsMap({}); return; } Promise.all( targetTables.map(async (table) => { const cols = await fetchTableColumns(table); return { table, cols }; }) ).then((results) => { const map: Record = {}; results.forEach(({ table, cols }) => { map[table] = cols; }); setJoinColumnsMap(map); }); }, [joinsKey]); // eslint-disable-line react-hooks/exhaustive-deps const getTableDisplayName = (tableName: string) => { const found = tables.find((t) => t.tableName === tableName); return found?.displayName || tableName; }; // 테이블별 그룹화된 컬럼 목록 const columnGroups: ColumnGroup[] = useMemo(() => { const groups: ColumnGroup[] = []; if (dataSource.tableName && mainColumns.length > 0) { groups.push({ tableName: dataSource.tableName, displayName: getTableDisplayName(dataSource.tableName), columns: mainColumns, }); } const joins = dataSource.joins || []; joins.forEach((join) => { if (join.targetTable && joinColumnsMap[join.targetTable]) { groups.push({ tableName: join.targetTable, displayName: getTableDisplayName(join.targetTable), columns: joinColumnsMap[join.targetTable], }); } }); return groups; }, [dataSource.tableName, mainColumns, dataSource.joins, joinColumnsMap, tables]); // eslint-disable-line react-hooks/exhaustive-deps // 하위 호환: 단일 배열 (조인 없는 섹션용) const columns = mainColumns; const updateTemplate = (partial: Partial) => { onUpdate({ cardTemplate: { ...template, ...partial }, }); }; if (!dataSource.tableName) { return (

테이블을 먼저 선택해주세요

기본 설정 탭에서 테이블을 선택하세요

); } return (
{/* 헤더 설정 */} updateTemplate({ header })} /> {/* 이미지 설정 */} updateTemplate({ image })} /> {/* 본문 필드 */} updateTemplate({ body })} /> {/* 입력 필드 설정 */} onUpdate({ inputField })} /> {/* 포장등록 설정 */} onUpdate({ packageConfig })} /> {/* 담기 버튼 설정 */} onUpdate({ cartAction })} /> {/* 반응형 표시 설정 */}
); } // ===== 테이블 검색 Combobox ===== function TableCombobox({ tables, value, onSelect, }: { tables: TableInfo[]; value: string; onSelect: (tableName: string) => void; }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const selectedLabel = useMemo(() => { const found = tables.find((t) => t.tableName === value); return found ? (found.displayName || found.tableName) : ""; }, [tables, value]); const filtered = useMemo(() => { if (!search) return tables; const q = search.toLowerCase(); return tables.filter( (t) => t.tableName.toLowerCase().includes(q) || (t.displayName && t.displayName.toLowerCase().includes(q)) ); }, [tables, search]); return ( 검색 결과가 없습니다. {filtered.map((table) => ( { onSelect(table.tableName); setOpen(false); setSearch(""); }} className="text-xs" >
{table.displayName || table.tableName} {table.displayName && ( {table.tableName} )}
))}
); } // ===== 테이블별 그룹화된 컬럼 셀렉트 ===== function GroupedColumnSelect({ columnGroups, value, onValueChange, placeholder = "컬럼 선택", allowNone = false, noneLabel = "선택 안함", className, }: { columnGroups: ColumnGroup[]; value: string | undefined; onValueChange: (value: string | undefined) => void; placeholder?: string; allowNone?: boolean; noneLabel?: string; className?: string; }) { return ( ); } // ===== 접기/펴기 섹션 컴포넌트 ===== function CollapsibleSection({ title, badge, defaultOpen = false, children, }: { title: string; badge?: string; defaultOpen?: boolean; children: React.ReactNode; }) { const [open, setOpen] = useState(defaultOpen); return (
{open &&
{children}
}
); } // ===== 헤더 설정 섹션 ===== function HeaderSettingsSection({ header, columnGroups, onUpdate, }: { header: CardHeaderConfig; columnGroups: ColumnGroup[]; onUpdate: (header: CardHeaderConfig) => void; }) { return (
{/* 코드 필드 */}
onUpdate({ ...header, codeField: val })} placeholder="컬럼 선택 (선택사항)" allowNone noneLabel="선택 안함" className="mt-1" />

카드 헤더 왼쪽에 표시될 코드 (예: ITEM032)

{/* 제목 필드 */}
onUpdate({ ...header, titleField: val })} placeholder="컬럼 선택 (선택사항)" allowNone noneLabel="선택 안함" className="mt-1" />

카드 헤더 오른쪽에 표시될 제목 (예: 너트 M10)

); } // ===== 이미지 설정 섹션 ===== function ImageSettingsSection({ image, columnGroups, onUpdate, }: { image: CardImageConfig; columnGroups: ColumnGroup[]; onUpdate: (image: CardImageConfig) => void; }) { return (
{/* 이미지 사용 여부 */}
onUpdate({ ...image, enabled: checked })} />
{image.enabled && ( <> {/* 기본 이미지 미리보기 */}
기본 이미지 미리보기
기본 이미지
{/* 기본 이미지 URL */}
onUpdate({ ...image, defaultImage: e.target.value || DEFAULT_CARD_IMAGE, }) } placeholder="이미지 URL 입력" className="mt-1 h-7 text-xs" />

이미지가 없는 항목에 표시될 기본 이미지

{/* 이미지 컬럼 선택 */}
onUpdate({ ...image, imageColumn: val })} placeholder="컬럼 선택 (선택사항)" allowNone noneLabel="선택 안함 (기본 이미지 사용)" className="mt-1" />

DB에서 이미지 URL을 가져올 컬럼. URL이 없으면 기본 이미지 사용

)}
); } // ===== 본문 필드 섹션 ===== function BodyFieldsSection({ body, columnGroups, onUpdate, }: { body: CardBodyConfig; columnGroups: ColumnGroup[]; onUpdate: (body: CardBodyConfig) => void; }) { const fields = body.fields || []; const addField = () => { const newField: CardFieldBinding = { id: `field-${Date.now()}`, label: "", valueType: "column", columnName: "", }; onUpdate({ fields: [...fields, newField] }); }; // 필드 업데이트 const updateField = (index: number, updated: CardFieldBinding) => { const newFields = [...fields]; newFields[index] = updated; onUpdate({ fields: newFields }); }; // 필드 삭제 const deleteField = (index: number) => { const newFields = fields.filter((_, i) => i !== index); onUpdate({ fields: newFields }); }; // 필드 순서 이동 const moveField = (index: number, direction: "up" | "down") => { const newIndex = direction === "up" ? index - 1 : index + 1; if (newIndex < 0 || newIndex >= fields.length) return; const newFields = [...fields]; [newFields[index], newFields[newIndex]] = [ newFields[newIndex], newFields[index], ]; onUpdate({ fields: newFields }); }; return (
{/* 필드 목록 */} {fields.length === 0 ? (

본문에 표시할 필드를 추가하세요

) : (
{fields.map((field, index) => ( updateField(index, updated)} onDelete={() => deleteField(index)} onMove={(dir) => moveField(index, dir)} /> ))}
)} {/* 필드 추가 버튼 */}
); } // ===== 필드 편집기 ===== const VALUE_TYPE_OPTIONS: { value: FieldValueType; label: string }[] = [ { value: "column", label: "DB 컬럼" }, { value: "formula", label: "계산식" }, ]; const FORMULA_OPERATOR_OPTIONS: { value: FormulaOperator; label: string }[] = [ { value: "+", label: "+ (더하기)" }, { value: "-", label: "- (빼기)" }, { value: "*", label: "* (곱하기)" }, { value: "/", label: "/ (나누기)" }, ]; const FORMULA_RIGHT_TYPE_OPTIONS: { value: FormulaRightType; label: string }[] = [ { value: "column", label: "DB 컬럼" }, { value: "input", label: "입력값" }, ]; function FieldEditor({ field, index, columnGroups, totalCount, onUpdate, onDelete, onMove, }: { field: CardFieldBinding; index: number; columnGroups: ColumnGroup[]; totalCount: number; onUpdate: (field: CardFieldBinding) => void; onDelete: () => void; onMove: (direction: "up" | "down") => void; }) { const valueType = field.valueType || "column"; const rightType = field.formulaRightType || "input"; return (
{/* 순서 이동 버튼 */}
{/* 필드 설정 */}
{/* 1행: 라벨 + 값 유형 */}
onUpdate({ ...field, label: e.target.value })} placeholder="예: 미입고" className="mt-1 h-7 text-xs" />
{/* 2행: 컬럼 선택 또는 수식 빌더 */} {valueType === "column" ? (
onUpdate({ ...field, columnName: val || "" })} placeholder="컬럼 선택" className="mt-1" />
) : ( <> {/* 왼쪽 값: DB 컬럼 */}
onUpdate({ ...field, formulaLeft: val || undefined })} placeholder="컬럼 선택" className="mt-1" />
{/* 연산자 */}
{/* 오른쪽 값 유형 + 값 */}
{rightType === "column" && ( onUpdate({ ...field, formulaRight: val || undefined })} placeholder="컬럼 선택" /> )} {rightType === "input" && (

카드의 숫자 입력 필드 값이 사용됩니다

)}
{/* 단위 */}
onUpdate({ ...field, unit: e.target.value })} className="mt-1 h-7 text-xs" placeholder="EA" />
{/* 수식 미리보기 */} {field.formulaLeft && field.formulaOperator && (

수식 미리보기

{field.formulaLeft} {field.formulaOperator} {rightType === "input" ? "$input" : (field.formulaRight || "?")}

)} )} {/* 텍스트 색상 */}
{/* 삭제 버튼 */}
); } // ===== 입력 필드 설정 섹션 ===== function InputFieldSettingsSection({ inputField, columns, tables, onUpdate, }: { inputField?: CardInputFieldConfig; columns: ColumnInfo[]; tables: TableInfo[]; onUpdate: (inputField: CardInputFieldConfig) => void; }) { const field = inputField || { enabled: false, unit: "EA", }; // 하위 호환: maxColumn -> limitColumn 마이그레이션 const effectiveLimitColumn = field.limitColumn || field.maxColumn; const updateField = (partial: Partial) => { onUpdate({ ...field, ...partial }); }; // 저장 테이블 컬럼 로드 const [saveTableColumns, setSaveTableColumns] = useState([]); useEffect(() => { if (field.saveTable) { fetchTableColumns(field.saveTable).then(setSaveTableColumns); } else { setSaveTableColumns([]); } }, [field.saveTable]); return (
{/* 활성화 스위치 */}
updateField({ enabled })} />
{field.enabled && ( <> {/* 단위 */}
updateField({ unit: e.target.value })} className="mt-1 h-7 text-xs" placeholder="EA" />
{/* 제한 기준 컬럼 */}

각 카드 행의 해당 컬럼 값이 숫자패드 최대값 (예: order_qty)

{/* 저장 대상 테이블 (검색 가능) */}
updateField({ saveTable: tableName || undefined, saveColumn: undefined, }) } />
{/* 저장 대상 컬럼 */} {field.saveTable && (

입력값이 저장될 DB 컬럼

)} )}
); } // ===== 포장등록 설정 섹션 ===== import { PACKAGE_UNITS } from "./PackageUnitModal"; function PackageSettingsSection({ packageConfig, onUpdate, }: { packageConfig?: CardPackageConfig; onUpdate: (config: CardPackageConfig) => void; }) { const config: CardPackageConfig = packageConfig || { enabled: false, }; const updateConfig = (partial: Partial) => { onUpdate({ ...config, ...partial }); }; const enabledSet = new Set(config.enabledUnits ?? PACKAGE_UNITS.map((u) => u.value)); const toggleUnit = (value: string) => { const next = new Set(enabledSet); if (next.has(value)) { next.delete(value); } else { next.add(value); } updateConfig({ enabledUnits: Array.from(next) }); }; const addCustomUnit = () => { const existing = config.customUnits || []; const id = `custom_${Date.now()}`; updateConfig({ customUnits: [...existing, { id, label: "" }], }); }; const updateCustomUnit = (id: string, label: string) => { const existing = config.customUnits || []; updateConfig({ customUnits: existing.map((cu) => (cu.id === id ? { ...cu, label } : cu)), }); }; const removeCustomUnit = (id: string) => { const existing = config.customUnits || []; updateConfig({ customUnits: existing.filter((cu) => cu.id !== id), }); }; return (
updateConfig({ enabled })} />
{config.enabled && ( <> {/* 기본 포장 단위 체크박스 */}
{PACKAGE_UNITS.map((unit) => ( ))}
{/* 커스텀 단위 */}
{(config.customUnits || []).map((cu) => (
updateCustomUnit(cu.id, e.target.value)} className="h-7 flex-1 text-xs" placeholder="단위 이름 (예: 파렛트)" />
))}
{/* 계산 결과 안내 메시지 */}
updateConfig({ showSummaryMessage: checked })} />

포장 등록 시 계산 결과를 안내 메시지로 표시합니다

)}
); } // ===== 조인 설정 섹션 (테이블 선택 -> 컬럼 자동 매칭) ===== // 두 테이블 간 매칭 가능한 컬럼 쌍 찾기 function findMatchingColumns( sourceCols: ColumnInfo[], targetCols: ColumnInfo[], ): Array<{ source: string; target: string; confidence: "high" | "medium" }> { const matches: Array<{ source: string; target: string; confidence: "high" | "medium" }> = []; const sourceNames = sourceCols.map((c) => c.name); const targetNames = targetCols.map((c) => c.name); for (const src of sourceNames) { for (const tgt of targetNames) { // 정확히 같은 이름 (예: item_code = item_code) if (src === tgt) { matches.push({ source: src, target: tgt, confidence: "high" }); continue; } // 소스가 _id/_code/_no 로 끝나고, 타겟 테이블에 같은 이름이 있는 경우 // 예: source.customer_code -> target.customer_code (이미 위에서 처리됨) // 소스 컬럼명이 타겟 컬럼명을 포함하거나, 타겟이 소스를 포함 const suffixes = ["_id", "_code", "_no", "_number", "_key"]; const srcBase = suffixes.reduce((name, s) => name.endsWith(s) ? name.slice(0, -s.length) : name, src); const tgtBase = suffixes.reduce((name, s) => name.endsWith(s) ? name.slice(0, -s.length) : name, tgt); if (srcBase && tgtBase && srcBase === tgtBase && src !== tgt) { matches.push({ source: src, target: tgt, confidence: "medium" }); } } } // confidence 높은 순 정렬 return matches.sort((a, b) => (a.confidence === "high" ? -1 : 1) - (b.confidence === "high" ? -1 : 1)); } function JoinSettingsSection({ dataSource, tables, onUpdate, }: { dataSource: CardListDataSource; tables: TableInfo[]; onUpdate: (partial: Partial) => void; }) { const joins = dataSource.joins || []; const [sourceColumns, setSourceColumns] = useState([]); const [targetColumnsMap, setTargetColumnsMap] = useState>({}); useEffect(() => { if (dataSource.tableName) { fetchTableColumns(dataSource.tableName).then(setSourceColumns); } else { setSourceColumns([]); } }, [dataSource.tableName]); const getTableLabel = (tableName: string) => { const found = tables.find((t) => t.tableName === tableName); return found?.displayName || tableName; }; // 대상 테이블 컬럼 로드 + 자동 매칭 const loadTargetAndAutoMatch = (index: number, join: CardColumnJoin, targetTable: string) => { const updated = { ...join, targetTable, sourceColumn: "", targetColumn: "" }; const doMatch = (targetCols: ColumnInfo[]) => { const matches = findMatchingColumns(sourceColumns, targetCols); if (matches.length > 0) { updated.sourceColumn = matches[0].source; updated.targetColumn = matches[0].target; } const newJoins = [...joins]; newJoins[index] = updated; onUpdate({ joins: newJoins }); }; if (targetColumnsMap[targetTable]) { doMatch(targetColumnsMap[targetTable]); } else { fetchTableColumns(targetTable).then((cols) => { setTargetColumnsMap((prev) => ({ ...prev, [targetTable]: cols })); doMatch(cols); }); } }; const updateJoin = (index: number, updated: CardColumnJoin) => { const newJoins = [...joins]; newJoins[index] = updated; onUpdate({ joins: newJoins }); }; const deleteJoin = (index: number) => { const newJoins = joins.filter((_, i) => i !== index); onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined }); }; const addJoin = () => { onUpdate({ joins: [...joins, { targetTable: "", joinType: "LEFT", sourceColumn: "", targetColumn: "" }] }); }; return (
{joins.length === 0 ? (

다른 테이블을 연결하면 추가 정보를 카드에 표시할 수 있습니다

) : (
{joins.map((join, index) => { const targetCols = targetColumnsMap[join.targetTable] || []; const matchingPairs = join.targetTable ? findMatchingColumns(sourceColumns, targetCols) : []; const hasAutoMatch = join.sourceColumn && join.targetColumn; return (
{join.targetTable ? getTableLabel(join.targetTable) : "테이블 선택"}
{/* 대상 테이블 선택 (검색 가능) */} t.tableName !== dataSource.tableName)} value={join.targetTable || ""} onSelect={(val) => loadTargetAndAutoMatch(index, join, val)} /> {/* 자동 매칭 결과 또는 수동 선택 */} {join.targetTable && (
{/* 자동 매칭 성공: 결과 표시 */} {hasAutoMatch ? (
자동 연결됨: {join.sourceColumn} = {join.targetColumn}
) : (
자동 매칭되는 컬럼이 없습니다. 직접 선택하세요.
)} {/* 다른 매칭 후보가 있으면 표시 */} {matchingPairs.length > 1 && (
다른 연결 기준: {matchingPairs.map((pair) => { const isActive = join.sourceColumn === pair.source && join.targetColumn === pair.target; if (isActive) return null; return ( ); })}
)} {/* 수동 선택 (펼치기) */}
직접 컬럼 선택
=
)}
); })}
)}
); } // ===== 필터 설정 섹션 ===== function FilterSettingsSection({ dataSource, columns, onUpdate, }: { dataSource: CardListDataSource; columns: ColumnInfo[]; onUpdate: (partial: Partial) => void; }) { const filters = dataSource.filters || []; const operators: { value: FilterOperator; label: string }[] = [ { value: "=", label: "=" }, { value: "!=", label: "!=" }, { value: ">", label: ">" }, { value: ">=", label: ">=" }, { value: "<", label: "<" }, { value: "<=", label: "<=" }, { value: "like", label: "LIKE" }, ]; // 필터 추가 const addFilter = () => { const newFilter: CardColumnFilter = { column: "", operator: "=", value: "", }; onUpdate({ filters: [...filters, newFilter] }); }; // 필터 업데이트 const updateFilter = (index: number, updated: CardColumnFilter) => { const newFilters = [...filters]; newFilters[index] = updated; onUpdate({ filters: newFilters }); }; // 필터 삭제 const deleteFilter = (index: number) => { const newFilters = filters.filter((_, i) => i !== index); onUpdate({ filters: newFilters.length > 0 ? newFilters : undefined }); }; return (
{filters.length === 0 ? (

필터 조건을 추가하여 데이터를 필터링할 수 있습니다

) : (
{filters.map((filter, index) => (
updateFilter(index, { ...filter, value: e.target.value }) } placeholder="값" className="h-7 flex-1 text-xs" />
))}
)}
); } // ===== 정렬 설정 섹션 (다중 정렬) ===== function SortSettingsSection({ dataSource, columnGroups, onUpdate, }: { dataSource: CardListDataSource; columnGroups: ColumnGroup[]; onUpdate: (partial: Partial) => void; }) { // 하위 호환: 이전 형식(단일 객체)이 저장되어 있을 수 있음 const sorts: CardSortConfig[] = Array.isArray(dataSource.sort) ? dataSource.sort : dataSource.sort && typeof dataSource.sort === "object" ? [dataSource.sort as CardSortConfig] : []; const addSort = () => { onUpdate({ sort: [...sorts, { column: "", direction: "desc" }] }); }; const updateSort = (index: number, updated: CardSortConfig) => { const newSorts = [...sorts]; newSorts[index] = updated; onUpdate({ sort: newSorts }); }; const deleteSort = (index: number) => { const newSorts = sorts.filter((_, i) => i !== index); onUpdate({ sort: newSorts.length > 0 ? newSorts : undefined }); }; return (

화면 로드 시 적용되는 기본 정렬 순서입니다. 위에 있는 항목이 우선 적용됩니다.

{sorts.length === 0 ? (

정렬 기준이 없습니다

) : (
{sorts.map((sort, index) => (
{index + 1}
updateSort(index, { ...sort, column: val || "" })} placeholder="컬럼 선택" />
))}
)}
); } // ===== 표시 개수 설정 섹션 ===== function LimitSettingsSection({ dataSource, onUpdate, }: { dataSource: CardListDataSource; onUpdate: (partial: Partial) => void; }) { const limit = dataSource.limit || { mode: "all" as const }; const isLimited = limit.mode === "limited"; return (
{/* 모드 선택 */}
{isLimited && (
onUpdate({ limit: { mode: "limited", count: parseInt(e.target.value, 10) || 10, }, }) } className="mt-1 h-7 text-xs" />
)}
); } // ===== 담기 버튼 설정 섹션 ===== function CartActionSettingsSection({ cartAction, onUpdate, }: { cartAction?: CardCartActionConfig; onUpdate: (cartAction: CardCartActionConfig) => void; }) { const action: CardCartActionConfig = cartAction || { navigateMode: "none", iconType: "lucide", iconValue: "ShoppingCart", label: "담기", cancelLabel: "취소", }; const update = (partial: Partial) => { onUpdate({ ...action, ...partial }); }; return (
{/* 네비게이션 모드 */}
{/* 대상 화면 ID (screen 모드일 때만) */} {action.navigateMode === "screen" && (
update({ targetScreenId: e.target.value })} placeholder="예: 15" className="mt-1 h-7 text-xs" />

담기 클릭 시 이동할 POP 화면의 screenId

)} {/* 아이콘 타입 */}
{/* 아이콘 값 */}
update({ iconValue: e.target.value })} placeholder={ action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart" } className="mt-1 h-7 text-xs" /> {action.iconType === "lucide" && (

PascalCase로 입력 (ShoppingCart, Package, Truck 등)

)}
{/* 담기 라벨 */}
update({ label: e.target.value })} placeholder="담기" className="mt-1 h-7 text-xs" />
{/* 취소 라벨 */}
update({ cancelLabel: e.target.value })} placeholder="취소" className="mt-1 h-7 text-xs" />
); } // ===== 반응형 표시 설정 섹션 ===== const RESPONSIVE_MODES: ResponsiveDisplayMode[] = ["required", "shrink", "hidden"]; function ResponsiveDisplaySection({ config, onUpdate, }: { config: PopCardListConfig; onUpdate: (partial: Partial) => void; }) { const template = config.cardTemplate || { header: {}, image: { enabled: false }, body: { fields: [] } }; const responsive = config.responsiveDisplay || {}; const bodyFields = template.body?.fields || []; const hasHeader = !!template.header?.codeField || !!template.header?.titleField; const hasImage = !!template.image?.enabled; const hasFields = bodyFields.length > 0; const updateResponsive = (partial: Partial) => { onUpdate({ responsiveDisplay: { ...responsive, ...partial } }); }; const updateFieldMode = (fieldId: string, mode: ResponsiveDisplayMode) => { updateResponsive({ fields: { ...(responsive.fields || {}), [fieldId]: mode }, }); }; if (!hasHeader && !hasImage && !hasFields) { return (

카드 템플릿에 항목을 먼저 추가하세요

); } return (

화면이 좁아질 때 각 항목이 어떻게 표시될지 설정합니다.

{RESPONSIVE_MODES.map((mode) => ( {RESPONSIVE_DISPLAY_LABELS[mode]} {mode === "required" && " = 항상 표시"} {mode === "shrink" && " = 축소 가능"} {mode === "hidden" && " = 숨김 가능"} ))}
{template.header?.codeField && ( updateResponsive({ code: mode })} /> )} {template.header?.titleField && ( updateResponsive({ title: mode })} /> )} {hasImage && ( updateResponsive({ image: mode })} /> )} {bodyFields.map((field) => ( updateFieldMode(field.id, mode)} /> ))}
); } function ResponsiveDisplayRow({ label, sublabel, value, onChange, }: { label: string; sublabel?: string; value: ResponsiveDisplayMode; onChange: (mode: ResponsiveDisplayMode) => void; }) { return (
{label} {sublabel && ( {sublabel} )}
{RESPONSIVE_MODES.map((mode) => ( ))}
); }