diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 9c7af27a..a4bfc6ee 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3 } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -39,6 +39,12 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: BarChart3, description: "KPI, 차트, 게이지, 통계 집계", }, + { + type: "pop-card-list", + label: "카드 목록", + icon: LayoutGrid, + description: "테이블 데이터를 카드 형태로 표시", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index def9dbfc..8814fb3e 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list"; /** * 데이터 흐름 정의 @@ -344,6 +344,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record; + +export function PopCardListComponent({ + config, + className, +}: PopCardListComponentProps) { + const layoutMode = config?.layoutMode || "grid"; + const cardSize = config?.cardSize || "medium"; + const cardsPerRow = config?.cardsPerRow || 3; + const dataSource = config?.dataSource; + const template = config?.cardTemplate; + + // 데이터 상태 + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 이미지 URL 없는 항목 카운트 (toast 중복 방지용) + const missingImageCountRef = useRef(0); + const toastShownRef = useRef(false); + + // 데이터 조회 + useEffect(() => { + if (!dataSource?.tableName) { + setLoading(false); + setRows([]); + return; + } + + const fetchData = async () => { + setLoading(true); + setError(null); + missingImageCountRef.current = 0; + toastShownRef.current = false; + + try { + // 필터 조건 구성 + const filters: Record = {}; + if (dataSource.filters && dataSource.filters.length > 0) { + dataSource.filters.forEach((f) => { + if (f.column && f.value) { + // 간단한 = 연산자만 지원 (추후 확장 가능) + filters[f.column] = f.value; + } + }); + } + + // 정렬 조건 + const sortBy = dataSource.sort?.column; + const sortOrder = dataSource.sort?.direction; + + // 개수 제한 + const size = + dataSource.limit?.mode === "limited" && dataSource.limit?.count + ? dataSource.limit.count + : 100; + + // TODO: 조인 지원은 추후 구현 + // 현재는 단일 테이블 조회만 지원 + + const result = await dataApi.getTableData(dataSource.tableName, { + page: 1, + size, + sortBy: sortOrder ? sortBy : undefined, + sortOrder, + filters: Object.keys(filters).length > 0 ? filters : undefined, + }); + + setRows(result.data || []); + } catch (err) { + const message = err instanceof Error ? err.message : "데이터 조회 실패"; + setError(message); + setRows([]); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [dataSource]); + + // 이미지 URL 없는 항목 체크 및 toast 표시 + useEffect(() => { + if ( + !loading && + rows.length > 0 && + template?.image?.enabled && + template?.image?.imageColumn && + !toastShownRef.current + ) { + const imageColumn = template.image.imageColumn; + const missingCount = rows.filter((row) => !row[imageColumn]).length; + + if (missingCount > 0) { + missingImageCountRef.current = missingCount; + toastShownRef.current = true; + toast.warning( + `${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다` + ); + } + } + }, [loading, rows, template?.image]); + + + // 레이아웃 클래스 (스크롤 지원) + const layoutClass = + layoutMode === "vertical" + ? "flex flex-col gap-3 h-full overflow-y-auto" + : layoutMode === "horizontal" + ? "flex flex-row gap-3 h-full overflow-x-auto pb-2" + : "grid gap-3 h-full overflow-y-auto"; + + // 그리드 스타일 + const gridStyle = + layoutMode === "grid" + ? { gridTemplateColumns: `repeat(${cardsPerRow}, minmax(0, 1fr))` } + : undefined; + + // 설정 미완료 상태 + if (!dataSource?.tableName) { + return ( +
+

+ 데이터 소스를 설정해주세요. +

+
+ ); + } + + // 로딩 중 + if (loading) { + return ( +
+ +
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+

{error}

+
+ ); + } + + // 데이터 없음 + if (rows.length === 0) { + return ( +
+

데이터가 없습니다.

+
+ ); + } + + return ( +
+ {rows.map((row, index) => ( + + ))} +
+ ); +} + +// ===== 카드 크기별 설정 ===== +const CARD_SIZE_CONFIG = { + small: { + minHeight: "min-h-[120px]", + minWidth: "min-w-[200px]", + imageSize: "h-14 w-14", + padding: "p-2", + gap: "gap-2", + headerPadding: "px-2 py-1.5", + codeText: "text-[10px]", + titleText: "text-xs", + }, + medium: { + minHeight: "min-h-[140px]", + minWidth: "min-w-[260px]", + imageSize: "h-16 w-16", + padding: "p-3", + gap: "gap-3", + headerPadding: "px-3 py-2", + codeText: "text-xs", + titleText: "text-sm", + }, + large: { + minHeight: "min-h-[180px]", + minWidth: "min-w-[320px]", + imageSize: "h-20 w-20", + padding: "p-4", + gap: "gap-4", + headerPadding: "px-4 py-2.5", + codeText: "text-xs", + titleText: "text-base", + }, +}; + +// ===== 카드 컴포넌트 ===== + +function Card({ + row, + template, + cardSize, + isHorizontal, +}: { + row: RowData; + template?: CardTemplateConfig; + cardSize: "small" | "medium" | "large"; + isHorizontal: boolean; +}) { + const header = template?.header; + const image = template?.image; + const body = template?.body; + + // 크기별 설정 + const sizeConfig = CARD_SIZE_CONFIG[cardSize]; + + // 헤더 값 추출 + const codeValue = header?.codeField ? row[header.codeField] : null; + const titleValue = header?.titleField ? row[header.titleField] : null; + + // 이미지 URL 결정 + const imageUrl = + image?.enabled && image?.imageColumn && row[image.imageColumn] + ? String(row[image.imageColumn]) + : image?.defaultImage || DEFAULT_CARD_IMAGE; + + return ( +
+ {/* 헤더 영역 */} + {(codeValue !== null || titleValue !== null) && ( +
+
+ {codeValue !== null && ( + + {formatValue(codeValue)} + + )} + {titleValue !== null && ( + + {formatValue(titleValue)} + + )} +
+
+ )} + + {/* 본문 영역 */} +
+ {/* 이미지 (왼쪽) */} + {image?.enabled && ( +
+
+ { + // 이미지 로드 실패 시 기본 이미지로 대체 + const target = e.target as HTMLImageElement; + if (target.src !== DEFAULT_CARD_IMAGE) { + target.src = DEFAULT_CARD_IMAGE; + } + }} + /> +
+
+ )} + + {/* 필드 목록 (오른쪽) */} +
+ {body?.fields && body.fields.length > 0 ? ( +
+ {body.fields.map((field) => ( + + ))} +
+ ) : ( +
+ 본문 필드를 추가하세요 +
+ )} +
+
+
+ ); +} + +// ===== 필드 행 컴포넌트 ===== + +function FieldRow({ + field, + row, + cardSize, +}: { + field: CardFieldBinding; + row: RowData; + cardSize: "small" | "medium" | "large"; +}) { + const value = row[field.columnName]; + + // 크기별 텍스트 설정 + const textSize = cardSize === "small" ? "text-[10px]" : "text-xs"; + const labelMinWidth = cardSize === "small" ? "min-w-[50px]" : "min-w-[60px]"; + + return ( +
+ {/* 라벨 */} + + {field.label} + + {/* 값 */} + + {formatValue(value)} + +
+ ); +} + +// ===== 값 포맷팅 ===== + +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return "-"; + } + if (typeof value === "number") { + return value.toLocaleString(); + } + if (typeof value === "boolean") { + return value ? "예" : "아니오"; + } + if (value instanceof Date) { + return value.toLocaleDateString(); + } + // ISO 날짜 문자열 감지 및 포맷 + if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { + const date = new Date(value); + if (!isNaN(date.getTime())) { + // MM-DD 형식으로 표시 + return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + } + return String(value); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx new file mode 100644 index 00000000..3d73070d --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -0,0 +1,1439 @@ +"use client"; + +/** + * pop-card-list 설정 패널 (V2 - 이미지 참조 기반 재설계) + * + * 3개 탭: + * [테이블] - 데이터 테이블 선택 + * [카드 템플릿] - 헤더/이미지/본문 필드 + 레이아웃 설정 + * [데이터 소스] - 조인/필터/정렬/개수 설정 + */ + +import React, { useState, useEffect } from "react"; +import { ChevronDown, ChevronRight, Plus, Trash2, Database } from "lucide-react"; +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, + CardSize, + CardLayoutMode, + FilterOperator, +} from "../types"; +import { + CARD_SIZE_LABELS, + CARD_LAYOUT_MODE_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; +} + +// ===== 기본값 ===== + +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, + layoutMode: "grid", + cardsPerRow: 3, + cardSize: "medium", +}; + +// ===== 색상 옵션 ===== + +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 }: ConfigPanelProps) { + // 3탭 구조: 테이블 선택 → 카드 템플릿 → 데이터 소스 + const [activeTab, setActiveTab] = useState<"table" | "template" | "dataSource">( + "table" + ); + + // config가 없으면 기본값 사용 + const cfg: PopCardListConfig = config || DEFAULT_CONFIG; + + // config 업데이트 헬퍼 + const updateConfig = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + // 테이블이 선택되었는지 확인 + const hasTable = !!cfg.dataSource?.tableName; + + return ( +
+ {/* 탭 헤더 - 3탭 구조 */} +
+ + + +
+ + {/* 탭 내용 */} +
+ {activeTab === "table" && ( + + )} + {activeTab === "template" && ( + + )} + {activeTab === "dataSource" && ( + + )} +
+
+ ); +} + +// ===== 테이블 선택 탭 ===== + +function TableSelectTab({ + config, + onUpdate, +}: { + config: PopCardListConfig; + onUpdate: (partial: Partial) => void; +}) { + const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; + const [tables, setTables] = useState([]); + + // 테이블 목록 로드 + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + return ( +
+ {/* 테이블 선택 */} +
+ +

+ 카드 리스트에 표시할 데이터가 있는 테이블을 선택하세요 +

+ +
+ + {/* 선택된 테이블 정보 */} + {dataSource.tableName && ( +
+
+
+ +
+
+

{dataSource.tableName}

+

선택된 테이블

+
+
+
+ )} +
+ ); +} + +// ===== 데이터 소스 탭 ===== + +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 })} + /> + + + {/* 레이아웃 설정 */} + + + +
+ ); +} + +// ===== 접기/펴기 섹션 컴포넌트 ===== + +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 LayoutSettingsSection({ + config, + onUpdate, +}: { + config: PopCardListConfig; + onUpdate: (partial: Partial) => void; +}) { + const isGridMode = config.layoutMode === "grid"; + + return ( +
+ {/* 카드 크기 */} +
+ +
+ {(["small", "medium", "large"] as CardSize[]).map((size) => ( + + ))} +
+
+ + {/* 배치 방식 */} +
+ +
+ {(["grid", "horizontal", "vertical"] as CardLayoutMode[]).map( + (mode) => ( + + ) + )} +
+
+ + {/* 격자 배치일 때만 한 줄 카드 수 표시 */} + {isGridMode && ( +
+ + +
+ )} +
+ ); +} + +// ===== 조인 설정 섹션 ===== + +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" + /> +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx new file mode 100644 index 00000000..6b77c59e --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx @@ -0,0 +1,200 @@ +"use client"; + +/** + * pop-card-list 디자인 모드 미리보기 컴포넌트 (V2) + * + * 디자이너 캔버스에서 표시되는 미리보기 + * 이미지 참조 기반 카드 구조 반영 + */ + +import React from "react"; +import { LayoutGrid, Package } from "lucide-react"; +import type { PopCardListConfig } from "../types"; +import { + CARD_LAYOUT_MODE_LABELS, + CARD_SIZE_LABELS, + DEFAULT_CARD_IMAGE, +} from "../types"; + +interface PopCardListPreviewProps { + config?: PopCardListConfig; +} + +export function PopCardListPreviewComponent({ + config, +}: PopCardListPreviewProps) { + const layoutMode = config?.layoutMode || "grid"; + const cardSize = config?.cardSize || "medium"; + const cardsPerRow = config?.cardsPerRow || 3; + const dataSource = config?.dataSource; + const template = config?.cardTemplate; + + // 설정 상태 확인 + const hasTable = !!dataSource?.tableName; + const hasHeader = + !!template?.header?.codeField || !!template?.header?.titleField; + const hasImage = template?.image?.enabled ?? true; + const fieldCount = template?.body?.fields?.length || 0; + + // 샘플 카드 개수 (미리보기용) + const sampleCardCount = + layoutMode === "grid" ? Math.min(cardsPerRow, 3) : 2; + + return ( +
+ {/* 헤더 */} +
+
+ + 카드 목록 +
+ + {/* 설정 배지 */} +
+ + {CARD_LAYOUT_MODE_LABELS[layoutMode]} + + + {CARD_SIZE_LABELS[cardSize]} + +
+
+ + {/* 테이블 미선택 시 안내 */} + {!hasTable ? ( +
+
+ +

+ 데이터 소스를 설정하세요 +

+
+
+ ) : ( + <> + {/* 테이블 정보 */} +
+ + {dataSource.tableName} + +
+ + {/* 카드 미리보기 */} +
+ {Array.from({ length: sampleCardCount }).map((_, idx) => ( + + ))} +
+ + {/* 필드 정보 */} + {fieldCount > 0 && ( +
+ + {fieldCount}개 필드 설정됨 + +
+ )} + + )} +
+ ); +} + +// ===== 미리보기 카드 컴포넌트 ===== + +function PreviewCard({ + index, + hasHeader, + hasImage, + fieldCount, + cardSize, + layoutMode, +}: { + index: number; + hasHeader: boolean; + hasImage: boolean; + fieldCount: number; + cardSize: string; + layoutMode: string; +}) { + // 카드 크기 + const sizeClass = + cardSize === "small" + ? "min-h-[60px]" + : cardSize === "large" + ? "min-h-[100px]" + : "min-h-[80px]"; + + const widthClass = + layoutMode === "vertical" + ? "w-full" + : layoutMode === "horizontal" + ? "min-w-[140px] flex-shrink-0" + : "w-[140px]"; + + return ( +
+ {/* 헤더 */} + {hasHeader && ( +
+
+ + +
+
+ )} + + {/* 본문 */} +
+ {/* 이미지 */} + {hasImage && ( +
+
+ +
+
+ )} + + {/* 필드 목록 */} +
+ {fieldCount > 0 ? ( + Array.from({ length: Math.min(fieldCount, 3) }).map((_, i) => ( +
+ + +
+ )) + ) : ( +
+ + 필드 추가 + +
+ )} +
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx new file mode 100644 index 00000000..742ccf05 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -0,0 +1,56 @@ +"use client"; + +/** + * pop-card-list 컴포넌트 레지스트리 등록 진입점 (V2) + * + * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopCardListComponent } from "./PopCardListComponent"; +import { PopCardListConfigPanel } from "./PopCardListConfig"; +import { PopCardListPreviewComponent } from "./PopCardListPreview"; +import type { PopCardListConfig } from "../types"; +import { DEFAULT_CARD_IMAGE } from "../types"; + +// 기본 설정값 (V2 구조) +const defaultConfig: PopCardListConfig = { + // 데이터 소스 (테이블 단위) + dataSource: { + tableName: "", + }, + // 카드 템플릿 + cardTemplate: { + header: { + codeField: undefined, + titleField: undefined, + }, + image: { + enabled: true, + imageColumn: undefined, + defaultImage: DEFAULT_CARD_IMAGE, + }, + body: { + fields: [], + }, + }, + // 레이아웃 설정 + layoutMode: "grid", + cardsPerRow: 3, + cardSize: "medium", +}; + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-card-list", + name: "카드 목록", + description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)", + category: "display", + icon: "LayoutGrid", + component: PopCardListComponent, + configPanel: PopCardListConfigPanel, + preview: PopCardListPreviewComponent, + defaultProps: defaultConfig, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index cee5fb06..ae70aed1 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -342,3 +342,114 @@ export interface PopDashboardConfig { // 데이터 소스 (아이템 공통) dataSource?: DataSourceConfig; } + +// ============================================= +// pop-card-list 전용 타입 (V2 - 이미지 참조 기반 재설계) +// ============================================= + +// ----- 조인 설정 ----- + +export interface CardColumnJoin { + targetTable: string; + joinType: "LEFT" | "INNER" | "RIGHT"; + sourceColumn: string; // 메인 테이블 컬럼 + targetColumn: string; // 조인 테이블 컬럼 +} + +// ----- 필터 설정 ----- + +export interface CardColumnFilter { + column: string; + operator: FilterOperator; + value: string; +} + +// ----- 본문 필드 바인딩 (라벨-값 쌍) ----- + +export interface CardFieldBinding { + id: string; + columnName: string; // DB 컬럼명 + label: string; // 표시 라벨 (예: "발주일") + textColor?: string; // 텍스트 색상 (예: "#ef4444" 빨간색) +} + +// ----- 카드 헤더 설정 (코드 + 제목) ----- + +export interface CardHeaderConfig { + codeField?: string; // 코드로 표시할 컬럼 선택 + titleField?: string; // 제목으로 표시할 컬럼 선택 +} + +// ----- 카드 이미지 설정 ----- + +// 기본 이미지 URL (박스 아이콘) +export const DEFAULT_CARD_IMAGE = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='1.5'%3E%3Cpath d='M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4'/%3E%3C/svg%3E"; + +export interface CardImageConfig { + enabled: boolean; + imageColumn?: string; // 이미지 URL 컬럼 (선택) + defaultImage: string; // 기본 이미지 (자동 설정, 필수) +} + +// ----- 카드 본문 설정 ----- + +export interface CardBodyConfig { + fields: CardFieldBinding[]; // 라벨-값 쌍 목록 +} + +// ----- 카드 템플릿 (헤더 + 이미지 + 본문) ----- + +export interface CardTemplateConfig { + header: CardHeaderConfig; + image: CardImageConfig; + body: CardBodyConfig; +} + +// ----- 데이터 소스 (테이블 단위) ----- + +export interface CardListDataSource { + tableName: string; + joins?: CardColumnJoin[]; + filters?: CardColumnFilter[]; + sort?: { column: string; direction: "asc" | "desc" }; + limit?: { mode: "all" | "limited"; count?: number }; +} + +// ----- 카드 크기 ----- + +export type CardSize = "small" | "medium" | "large"; + +export const CARD_SIZE_LABELS: Record = { + small: "작게", + medium: "보통", + large: "크게", +}; + +// ----- 카드 배치 방식 (방향 기반) ----- + +export type CardLayoutMode = "grid" | "horizontal" | "vertical"; +// grid: 격자 배치 (행/열로 정렬) +// horizontal: 가로 배치 (가로 스크롤) +// vertical: 세로 배치 (세로 스크롤) + +export const CARD_LAYOUT_MODE_LABELS: Record = { + grid: "격자 배치", + horizontal: "가로 배치", + vertical: "세로 배치", +}; + +// ----- pop-card-list 전체 설정 ----- + +export interface PopCardListConfig { + // 데이터 소스 (테이블 단위) + dataSource: CardListDataSource; + + // 카드 템플릿 (헤더 + 이미지 + 본문) + cardTemplate: CardTemplateConfig; + + // 레이아웃 설정 + layoutMode: CardLayoutMode; + cardsPerRow?: number; // 격자 배치일 때만 사용 + cardSize: CardSize; +}