"use client"; /** * pop-card-list 설정 패널 * * 3개 탭: * [테이블] - 데이터 테이블 선택 * [카드 템플릿] - 헤더/이미지/본문 필드 + 레이아웃 설정 * [데이터 소스] - 조인/필터/정렬/개수 설정 */ import React, { useState, useEffect, useMemo } from "react"; import { ChevronDown, ChevronRight, Plus, Trash2, Database } 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, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import type { PopCardListConfig, CardListDataSource, CardTemplateConfig, CardHeaderConfig, CardImageConfig, CardBodyConfig, CardFieldBinding, CardColumnJoin, CardColumnFilter, CardScrollDirection, FilterOperator, CardInputFieldConfig, CardCalculatedFieldConfig, CardCartActionConfig, } from "../types"; import { CARD_SCROLL_DIRECTION_LABELS, DEFAULT_CARD_IMAGE, } from "../types"; import { fetchTableList, fetchTableColumns, type TableInfo, type ColumnInfo, } from "../pop-dashboard/utils/dataFetcher"; // ===== 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) { // 3탭 구조: 기본 설정 (테이블+레이아웃) → 데이터 소스 → 카드 템플릿 const [activeTab, setActiveTab] = useState<"basic" | "template" | "dataSource">( "basic" ); // config가 없으면 기본값 사용 const cfg: PopCardListConfig = config || DEFAULT_CONFIG; // config 업데이트 헬퍼 const updateConfig = (partial: Partial) => { onUpdate({ ...cfg, ...partial }); }; // 테이블이 선택되었는지 확인 const hasTable = !!cfg.dataSource?.tableName; return (
{/* 탭 헤더 - 3탭 구조 */}
{/* 탭 내용 */}
{activeTab === "basic" && ( )} {activeTab === "dataSource" && ( )} {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([]); // 테이블 목록 로드 useEffect(() => { fetchTableList().then(setTables); }, []); // 모드별 추천값 계산 const recommendation = useMemo(() => { if (!currentMode) return null; const columns = GRID_BREAKPOINTS[currentMode].columns; if (columns >= 8) return { rows: 3, cols: 2 }; if (columns >= 6) return { rows: 3, cols: 1 }; return { rows: 2, cols: 1 }; }, [currentMode]); // 열 최대값: colSpan 기반 제한 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 return (
{/* 테이블 선택 섹션 */}
{dataSource.tableName && (
{dataSource.tableName}
)}
{/* 레이아웃 설정 섹션 */}
{/* 현재 모드 뱃지 */} {modeLabel && (
현재: {modeLabel}
)} {/* 스크롤 방향 */}
{(["horizontal", "vertical"] as CardScrollDirection[]).map((dir) => ( ))}
{/* 그리드 배치 설정 (행 x 열) */}
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"}

); } // ===== 데이터 소스 탭 ===== function DataSourceTab({ config, onUpdate, }: { config: PopCardListConfig; onUpdate: (partial: Partial) => void; }) { const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const [tables, setTables] = useState([]); const [columns, setColumns] = useState([]); // 테이블 목록 로드 useEffect(() => { fetchTableList().then(setTables); }, []); // 테이블 선택 시 컬럼 목록 로드 useEffect(() => { if (dataSource.tableName) { fetchTableColumns(dataSource.tableName).then(setColumns); } else { setColumns([]); } }, [dataSource.tableName]); const updateDataSource = (partial: Partial) => { onUpdate({ dataSource: { ...dataSource, ...partial }, }); }; // 테이블이 선택되지 않은 경우 if (!dataSource.tableName) { return (

먼저 테이블 탭에서 테이블을 선택하세요

); } return (
{/* 현재 선택된 테이블 표시 */}
{dataSource.tableName}
{/* 조인 설정 */} 0 ? `${dataSource.joins.length}개` : "없음" } > {/* 필터 설정 */} 0 ? `${dataSource.filters.length}개` : "없음" } > {/* 정렬 설정 */} {/* 표시 개수 */}
); } // ===== 카드 템플릿 탭 ===== function CardTemplateTab({ config, onUpdate, }: { config: PopCardListConfig; onUpdate: (partial: Partial) => void; }) { const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; const template = config.cardTemplate || DEFAULT_TEMPLATE; const [columns, setColumns] = useState([]); // 테이블 컬럼 로드 useEffect(() => { if (dataSource.tableName) { fetchTableColumns(dataSource.tableName).then(setColumns); } else { setColumns([]); } }, [dataSource.tableName]); const updateTemplate = (partial: Partial) => { onUpdate({ cardTemplate: { ...template, ...partial }, }); }; // 테이블 미선택 시 안내 if (!dataSource.tableName) { return (

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

데이터 소스 탭에서 테이블을 선택하세요

); } return (
{/* 헤더 설정 */} updateTemplate({ header })} /> {/* 이미지 설정 */} updateTemplate({ image })} /> {/* 본문 필드 */} updateTemplate({ body })} /> {/* 입력 필드 설정 */} onUpdate({ inputField })} /> {/* 계산 필드 설정 */} onUpdate({ calculatedField })} /> {/* 담기 버튼 설정 */} onUpdate({ cartAction })} />
); } // ===== 접기/펴기 섹션 컴포넌트 ===== 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, columns, onUpdate, }: { header: CardHeaderConfig; columns: ColumnInfo[]; onUpdate: (header: CardHeaderConfig) => void; }) { return (
{/* 코드 필드 */}

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

{/* 제목 필드 */}

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

); } // ===== 이미지 설정 섹션 ===== function ImageSettingsSection({ image, columns, onUpdate, }: { image: CardImageConfig; columns: ColumnInfo[]; 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" />

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

{/* 이미지 컬럼 선택 */}

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

)}
); } // ===== 본문 필드 섹션 ===== function BodyFieldsSection({ body, columns, onUpdate, }: { body: CardBodyConfig; columns: ColumnInfo[]; onUpdate: (body: CardBodyConfig) => void; }) { const fields = body.fields || []; // 필드 추가 const addField = () => { const newField: CardFieldBinding = { id: `field-${Date.now()}`, columnName: "", label: "", textColor: undefined, }; 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)} /> ))}
)} {/* 필드 추가 버튼 */}
); } // ===== 필드 편집기 ===== function FieldEditor({ field, index, columns, totalCount, onUpdate, onDelete, onMove, }: { field: CardFieldBinding; index: number; columns: ColumnInfo[]; totalCount: number; onUpdate: (field: CardFieldBinding) => void; onDelete: () => void; onMove: (direction: "up" | "down") => void; }) { return (
{/* 순서 이동 버튼 */}
{/* 필드 설정 */}
{/* 라벨 */}
onUpdate({ ...field, label: e.target.value })} placeholder="예: 발주일" className="mt-1 h-7 text-xs" />
{/* 컬럼 */}
{/* 텍스트 색상 */}
{/* 삭제 버튼 */}
); } // ===== 입력 필드 설정 섹션 ===== function InputFieldSettingsSection({ inputField, columns, onUpdate, }: { inputField?: CardInputFieldConfig; columns: ColumnInfo[]; onUpdate: (inputField: CardInputFieldConfig) => void; }) { const field = inputField || { enabled: false, label: "발주 수량", unit: "EA", defaultValue: 0, min: 0, max: 999999, step: 1, }; const updateField = (partial: Partial) => { onUpdate({ ...field, ...partial }); }; return (
{/* 활성화 스위치 */}
updateField({ enabled })} />
{field.enabled && ( <> {/* 라벨 */}
updateField({ label: e.target.value })} className="mt-1 h-7 text-xs" placeholder="발주 수량" />
{/* 단위 */}
updateField({ unit: e.target.value })} className="mt-1 h-7 text-xs" placeholder="EA" />
{/* 기본값 */}
updateField({ defaultValue: parseInt(e.target.value, 10) || 0 })} className="mt-1 h-7 text-xs" placeholder="0" />
{/* 최소/최대값 */}
updateField({ min: parseInt(e.target.value, 10) || 0 })} className="mt-1 h-7 text-xs" placeholder="0" />
updateField({ max: parseInt(e.target.value, 10) || 999999 })} className="mt-1 h-7 text-xs" placeholder="999999" />
{/* 최대값 컬럼 */}

설정 시 각 카드 행의 해당 컬럼 값이 숫자패드 최대값으로 사용됨 (예: unreceived_qty)

{/* 저장 컬럼 (선택사항) */}

입력값을 저장할 DB 컬럼 (현재는 로컬 상태만 유지)

)}
); } // ===== 계산 필드 설정 섹션 ===== function CalculatedFieldSettingsSection({ calculatedField, columns, onUpdate, }: { calculatedField?: CardCalculatedFieldConfig; columns: ColumnInfo[]; onUpdate: (calculatedField: CardCalculatedFieldConfig) => void; }) { const field = calculatedField || { enabled: false, label: "미입고", formula: "", sourceColumns: [], unit: "EA", }; const updateField = (partial: Partial) => { onUpdate({ ...field, ...partial }); }; return (
{/* 활성화 스위치 */}
updateField({ enabled })} />
{field.enabled && ( <> {/* 라벨 */}
updateField({ label: e.target.value })} className="mt-1 h-7 text-xs" placeholder="미입고" />
{/* 계산식 */}
updateField({ formula: e.target.value })} className="mt-1 h-7 text-xs font-mono" placeholder="$input - received_qty" />

사용 가능: 컬럼명, $input (입력값), +, -, *, /

{/* 단위 */}
updateField({ unit: e.target.value })} className="mt-1 h-7 text-xs" placeholder="EA" />
{/* 사용 가능한 컬럼 목록 */}
{columns.map((col) => ( { // 클릭 시 계산식에 컬럼명 추가 const currentFormula = field.formula || ""; updateField({ formula: currentFormula + col.name }); }} > {col.name} ))}

클릭하면 계산식에 추가됩니다

)}
); } // ===== 조인 설정 섹션 ===== 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< Record >({}); // 소스 테이블 컬럼 로드 useEffect(() => { if (dataSource.tableName) { fetchTableColumns(dataSource.tableName).then(setSourceColumns); } }, [dataSource.tableName]); // 조인 추가 const addJoin = () => { const newJoin: CardColumnJoin = { targetTable: "", joinType: "LEFT", sourceColumn: "", targetColumn: "", }; onUpdate({ joins: [...joins, newJoin] }); }; // 조인 업데이트 const updateJoin = (index: number, updated: CardColumnJoin) => { const newJoins = [...joins]; newJoins[index] = updated; onUpdate({ joins: newJoins }); // 대상 테이블 컬럼 로드 if (updated.targetTable && !targetColumnsMap[updated.targetTable]) { fetchTableColumns(updated.targetTable).then((cols) => { setTargetColumnsMap((prev) => ({ ...prev, [updated.targetTable]: cols, })); }); } }; // 조인 삭제 const deleteJoin = (index: number) => { const newJoins = joins.filter((_, i) => i !== index); onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined }); }; return (
{joins.length === 0 ? (

다른 테이블과 조인하여 추가 컬럼을 사용할 수 있습니다

) : (
{joins.map((join, index) => (
조인 {index + 1}
{/* 조인 타입 */} {/* 대상 테이블 */} {/* ON 조건 */} {join.targetTable && (
=
)}
))}
)}
); } // ===== 필터 설정 섹션 ===== 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, columns, onUpdate, }: { dataSource: CardListDataSource; columns: ColumnInfo[]; onUpdate: (partial: Partial) => void; }) { const sort = dataSource.sort; return (
{/* 정렬 사용 여부 */}
{sort && (
{/* 정렬 컬럼 */}
{/* 정렬 방향 */}
)}
); } // ===== 표시 개수 설정 섹션 ===== 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" />
); }