feat(pop-string-list): 리스트 목록 컴포넌트 MVP 구현
테이블 데이터를 리스트/카드 두 가지 모드로 표시하는 pop-string-list 컴포넌트 전체 구현 - 6단계 Stepper 설정 패널 (모드 선택, 헤더/오버플로우, 데이터+컬럼 선택, 조인 설정, 카드/리스트 레이아웃, 필터/정렬) - 카드 모드: 시각적 그리드 편집기 (드래그 너비/높이 조절, 셀 병합, 셀별 컬럼/스타일 설정) - 리스트 모드: 드래그앤드롭 컬럼 순서 변경, 너비 조절, 런타임 컬럼 전환 설정 - 조인 설정: Combobox 테이블 검색, 자동 연결 가능 컬럼 발견, 타입 기반 필터링, 가져올 컬럼 선택 - CardColumnJoin에 selectedTargetColumns 필드 추가 - 디자이너 팔레트/에디터/렌더러에 pop-string-list 등록 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
84426b82cf
commit
6842a00890
|
|
@ -56,8 +56,10 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
|||
"pop-text": "텍스트",
|
||||
"pop-icon": "아이콘",
|
||||
"pop-dashboard": "대시보드",
|
||||
"pop-card-list": "카드 목록",
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-list": "리스트",
|
||||
"pop-indicator": "인디케이터",
|
||||
"pop-scanner": "스캐너",
|
||||
|
|
|
|||
|
|
@ -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, LayoutGrid, MousePointerClick } from "lucide-react";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -51,6 +51,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: MousePointerClick,
|
||||
description: "액션 버튼 (저장/삭제/API/모달)",
|
||||
},
|
||||
{
|
||||
type: "pop-string-list",
|
||||
label: "리스트 목록",
|
||||
icon: List,
|
||||
description: "테이블 데이터를 리스트/카드로 표시",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-text": "텍스트",
|
||||
"pop-icon": "아이콘",
|
||||
"pop-dashboard": "대시보드",
|
||||
"pop-card-list": "카드 목록",
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button";
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list";
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
|
|
@ -346,6 +346,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
|||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import "./pop-dashboard";
|
|||
import "./pop-card-list";
|
||||
|
||||
import "./pop-button";
|
||||
import "./pop-string-list";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// import "./pop-field";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,406 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-string-list 런타임 컴포넌트
|
||||
*
|
||||
* 리스트 모드: 엑셀형 행/열 (CSS Grid)
|
||||
* 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan)
|
||||
* 오버플로우: visibleRows 제한 + "전체보기" 확장
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ChevronDown, ChevronUp, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import type {
|
||||
PopStringListConfig,
|
||||
CardGridConfig,
|
||||
ListColumnConfig,
|
||||
CardCellDefinition,
|
||||
} from "./types";
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
interface PopStringListComponentProps {
|
||||
config?: PopStringListConfig;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 테이블 행 데이터 타입
|
||||
type RowData = Record<string, unknown>;
|
||||
|
||||
// ===== 메인 컴포넌트 =====
|
||||
|
||||
export function PopStringListComponent({
|
||||
config,
|
||||
className,
|
||||
}: PopStringListComponentProps) {
|
||||
const displayMode = config?.displayMode || "list";
|
||||
const header = config?.header;
|
||||
const overflow = config?.overflow;
|
||||
const dataSource = config?.dataSource;
|
||||
const listColumns = config?.listColumns || [];
|
||||
const cardGrid = config?.cardGrid;
|
||||
|
||||
// 데이터 상태
|
||||
const [rows, setRows] = useState<RowData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// 오버플로우 계산 (JSON 복원 시 string 유입 방어)
|
||||
const visibleRows = Number(overflow?.visibleRows) || 5;
|
||||
const maxExpandRows = Number(overflow?.maxExpandRows) || 20;
|
||||
const showExpandButton = overflow?.showExpandButton ?? true;
|
||||
|
||||
// 표시할 데이터 슬라이스
|
||||
const visibleData = expanded
|
||||
? rows.slice(0, maxExpandRows)
|
||||
: rows.slice(0, visibleRows);
|
||||
const hasMore = rows.length > visibleRows;
|
||||
|
||||
// 확장/축소 토글
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// 데이터 조회
|
||||
useEffect(() => {
|
||||
if (!dataSource?.tableName) {
|
||||
setLoading(false);
|
||||
setRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 필터 조건 구성
|
||||
const filters: Record<string, unknown> = {};
|
||||
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;
|
||||
|
||||
// 개수 제한 (string 유입 방어: Number 캐스팅)
|
||||
const size =
|
||||
dataSource.limit?.mode === "limited" && dataSource.limit?.count
|
||||
? Number(dataSource.limit.count)
|
||||
: maxExpandRows;
|
||||
|
||||
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, maxExpandRows]);
|
||||
|
||||
// 로딩 상태
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
<span className="text-xs text-destructive">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 테이블 미선택
|
||||
if (!dataSource?.tableName) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
테이블을 선택하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 없음
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
데이터가 없습니다
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex h-full w-full flex-col overflow-hidden ${className || ""}`}>
|
||||
{/* 헤더 */}
|
||||
{header?.enabled && header.label && (
|
||||
<div className="shrink-0 border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">{header.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{displayMode === "list" ? (
|
||||
<ListModeView
|
||||
columns={listColumns}
|
||||
data={visibleData}
|
||||
/>
|
||||
) : (
|
||||
<CardModeView
|
||||
cardGrid={cardGrid}
|
||||
data={visibleData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 전체보기 버튼 */}
|
||||
{showExpandButton && hasMore && (
|
||||
<div className="shrink-0 border-t px-3 py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className="h-7 w-full text-xs text-muted-foreground"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronUp className="mr-1 h-3 w-3" />
|
||||
접기
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="mr-1 h-3 w-3" />
|
||||
전체보기 ({rows.length}건)
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 리스트 모드 =====
|
||||
|
||||
interface ListModeViewProps {
|
||||
columns: ListColumnConfig[];
|
||||
data: RowData[];
|
||||
}
|
||||
|
||||
function ListModeView({ columns, data }: ListModeViewProps) {
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
컬럼을 설정하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols = columns.map((c) => c.width || "1fr").join(" ");
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 헤더 행 */}
|
||||
<div
|
||||
className="border-b bg-muted/50"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="px-2 py-1.5 text-xs font-medium text-muted-foreground"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행 */}
|
||||
{data.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="px-2 py-1.5 text-xs truncate"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{String(row[col.columnName] ?? "")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 카드 모드 =====
|
||||
|
||||
interface CardModeViewProps {
|
||||
cardGrid?: CardGridConfig;
|
||||
data: RowData[];
|
||||
}
|
||||
|
||||
function CardModeView({ cardGrid, data }: CardModeViewProps) {
|
||||
if (!cardGrid || (cardGrid.cells || []).length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
카드 레이아웃을 설정하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 p-2">
|
||||
{data.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-md border"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns:
|
||||
cardGrid.colWidths && cardGrid.colWidths.length > 0
|
||||
? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ")
|
||||
: "1fr",
|
||||
gridTemplateRows:
|
||||
cardGrid.rowHeights && cardGrid.rowHeights.length > 0
|
||||
? cardGrid.rowHeights
|
||||
.map((h) => {
|
||||
if (!h) return "32px";
|
||||
// px 값은 직접 사용, fr 값은 마이그레이션 호환
|
||||
return h.endsWith("px")
|
||||
? h
|
||||
: `${Math.round(parseFloat(h) * 32) || 32}px`;
|
||||
})
|
||||
.join(" ")
|
||||
: `repeat(${Number(cardGrid.rows) || 1}, 32px)`,
|
||||
gap: `${Number(cardGrid.gap) || 0}px`,
|
||||
}}
|
||||
>
|
||||
{(cardGrid.cells || []).map((cell) => {
|
||||
// 가로 정렬 매핑
|
||||
const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const;
|
||||
const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const;
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="overflow-hidden p-1.5"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: alignItemsMap[cell.verticalAlign || "top"],
|
||||
alignItems: justifyMap[cell.align || "left"],
|
||||
gridColumn: `${Number(cell.col) || 1} / span ${Number(cell.colSpan) || 1}`,
|
||||
gridRow: `${Number(cell.row) || 1} / span ${Number(cell.rowSpan) || 1}`,
|
||||
border: cardGrid.showBorder
|
||||
? "1px solid hsl(var(--border))"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
{renderCellContent(cell, row)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 셀 컨텐츠 렌더링 =====
|
||||
|
||||
function renderCellContent(cell: CardCellDefinition, row: RowData): React.ReactNode {
|
||||
const value = row[cell.columnName];
|
||||
const displayValue = value != null ? String(value) : "";
|
||||
|
||||
switch (cell.type) {
|
||||
case "image":
|
||||
return displayValue ? (
|
||||
<img
|
||||
src={displayValue}
|
||||
alt={cell.label || cell.columnName}
|
||||
className="h-full w-full object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted rounded">
|
||||
<span className="text-[10px] text-muted-foreground">No Image</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "badge":
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
{displayValue}
|
||||
</span>
|
||||
);
|
||||
|
||||
case "button":
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="h-6 text-[10px]">
|
||||
{cell.label || displayValue}
|
||||
</Button>
|
||||
);
|
||||
|
||||
case "text":
|
||||
default: {
|
||||
// 글자 크기 매핑
|
||||
const fontSizeClass =
|
||||
cell.fontSize === "sm"
|
||||
? "text-[10px]"
|
||||
: cell.fontSize === "lg"
|
||||
? "text-sm"
|
||||
: "text-xs"; // md (기본)
|
||||
const isLabelLeft = cell.labelPosition === "left";
|
||||
|
||||
return (
|
||||
<div className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}>
|
||||
{cell.label && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{cell.label}{isLabelLeft ? ":" : ""}
|
||||
</span>
|
||||
)}
|
||||
<span className={`${fontSizeClass} truncate`}>{displayValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-string-list 디자이너 미리보기
|
||||
*
|
||||
* 디자인 모드에서 캔버스에 표시되는 간략한 미리보기.
|
||||
* 실제 데이터는 가져오지 않고 더미 데이터로 레이아웃만 시각화.
|
||||
*/
|
||||
|
||||
import type { PopStringListConfig } from "./types";
|
||||
|
||||
interface PopStringListPreviewProps {
|
||||
config?: PopStringListConfig;
|
||||
}
|
||||
|
||||
export function PopStringListPreviewComponent({
|
||||
config,
|
||||
}: PopStringListPreviewProps) {
|
||||
const displayMode = config?.displayMode || "list";
|
||||
const header = config?.header;
|
||||
const tableName = config?.dataSource?.tableName;
|
||||
const listColumns = config?.listColumns || [];
|
||||
const cardGrid = config?.cardGrid;
|
||||
|
||||
// 테이블 미선택
|
||||
if (!tableName) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
테이블을 선택하세요
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
{header?.enabled && (
|
||||
<div className="shrink-0 border-b px-2 py-1">
|
||||
<span className="text-[10px] font-medium">
|
||||
{header.label || "리스트 목록"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모드별 미리보기 */}
|
||||
<div className="flex-1 overflow-hidden p-1">
|
||||
{displayMode === "list" ? (
|
||||
<ListPreview columns={listColumns} />
|
||||
) : (
|
||||
<CardPreview cardGrid={cardGrid} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 모드 라벨 */}
|
||||
<div className="shrink-0 border-t px-2 py-0.5">
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
{displayMode === "list" ? "리스트" : "카드"} | {tableName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 리스트 미리보기 =====
|
||||
|
||||
function ListPreview({
|
||||
columns,
|
||||
}: {
|
||||
columns: PopStringListConfig["listColumns"];
|
||||
}) {
|
||||
const cols = columns || [];
|
||||
|
||||
if (cols.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="text-[8px] text-muted-foreground">컬럼 미설정</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols = cols.map((c) => c.width || "1fr").join(" ");
|
||||
const dummyRows = 3;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="border-b bg-muted/50"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||
>
|
||||
{cols.map((col) => (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="px-1 py-0.5 text-[8px] font-medium text-muted-foreground truncate"
|
||||
>
|
||||
{col.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 더미 행 */}
|
||||
{Array.from({ length: dummyRows }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-b last:border-b-0"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||
>
|
||||
{cols.map((col) => (
|
||||
<div
|
||||
key={col.columnName}
|
||||
className="px-1 py-0.5"
|
||||
>
|
||||
<div className="h-2 w-3/4 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 카드 미리보기 =====
|
||||
|
||||
function CardPreview({
|
||||
cardGrid,
|
||||
}: {
|
||||
cardGrid: PopStringListConfig["cardGrid"];
|
||||
}) {
|
||||
if (!cardGrid || cardGrid.cells.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="text-[8px] text-muted-foreground">
|
||||
카드 레이아웃 미설정
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 더미 카드 2장
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{[0, 1].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded border"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: cardGrid.colWidths.join(" "),
|
||||
gridTemplateRows:
|
||||
cardGrid.rowHeights?.join(" ") ||
|
||||
`repeat(${cardGrid.rows}, 1fr)`,
|
||||
gap: `${cardGrid.gap}px`,
|
||||
minHeight: "24px",
|
||||
}}
|
||||
>
|
||||
{cardGrid.cells.map((cell) => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
gridColumn: `${cell.col} / span ${cell.colSpan}`,
|
||||
gridRow: `${cell.row} / span ${cell.rowSpan}`,
|
||||
border: cardGrid.showBorder
|
||||
? "1px dashed hsl(var(--border))"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<div className="h-2 w-3/4 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-string-list 컴포넌트 레지스트리 등록 진입점
|
||||
*
|
||||
* 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨
|
||||
*/
|
||||
|
||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||
import { PopStringListComponent } from "./PopStringListComponent";
|
||||
import { PopStringListConfigPanel } from "./PopStringListConfig";
|
||||
import { PopStringListPreviewComponent } from "./PopStringListPreview";
|
||||
import type { PopStringListConfig } from "./types";
|
||||
|
||||
// 기본 설정값
|
||||
const defaultConfig: PopStringListConfig = {
|
||||
displayMode: "list",
|
||||
header: { enabled: true, label: "" },
|
||||
overflow: { visibleRows: 5, showExpandButton: true, maxExpandRows: 20 },
|
||||
dataSource: { tableName: "" },
|
||||
listColumns: [],
|
||||
cardGrid: undefined,
|
||||
};
|
||||
|
||||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-string-list",
|
||||
name: "리스트 목록",
|
||||
description: "테이블 데이터를 리스트 또는 카드 형태로 표시",
|
||||
category: "display",
|
||||
icon: "List",
|
||||
component: PopStringListComponent,
|
||||
configPanel: PopStringListConfigPanel,
|
||||
preview: PopStringListPreviewComponent,
|
||||
defaultProps: defaultConfig,
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
// ===== pop-string-list 전용 타입 =====
|
||||
// pop-card-list와 완전 독립. 공유하는 것은 CardListDataSource 타입만 import.
|
||||
|
||||
import type { CardListDataSource } from "../types";
|
||||
|
||||
/** 표시 모드 */
|
||||
export type StringListDisplayMode = "list" | "card";
|
||||
|
||||
/** 카드 내부 셀 1개 정의 */
|
||||
export interface CardCellDefinition {
|
||||
id: string;
|
||||
row: number; // 1부터
|
||||
col: number; // 1부터
|
||||
rowSpan: number; // 행 병합 (기본 1)
|
||||
colSpan: number; // 열 병합 (기본 1)
|
||||
columnName: string; // 바인딩할 DB 컬럼명
|
||||
label?: string; // 셀 위에 표시할 라벨 (선택)
|
||||
labelPosition?: "top" | "left"; // 라벨 위치 (기본 top)
|
||||
type: "text" | "image" | "badge" | "button"; // 셀 렌더링 타입
|
||||
fontSize?: "sm" | "md" | "lg"; // 글자 크기: 작게(10px) / 보통(12px) / 크게(14px)
|
||||
align?: "left" | "center" | "right"; // 가로 정렬 (기본 left)
|
||||
verticalAlign?: "top" | "middle" | "bottom"; // 세로 정렬 (기본 top)
|
||||
}
|
||||
|
||||
/** 카드 그리드 레이아웃 설정 */
|
||||
export interface CardGridConfig {
|
||||
rows: number; // 행 수
|
||||
cols: number; // 열 수
|
||||
colWidths: string[]; // fr 단위 배열 (예: ["2fr", "1fr", "1fr"])
|
||||
rowHeights?: string[]; // px 단위 배열 (예: ["32px", "48px"], 기본 32px 균등)
|
||||
gap: number; // 셀 간격 (px, 2~8 권장)
|
||||
showBorder: boolean; // 셀 보더 표시
|
||||
cells: CardCellDefinition[]; // 셀 목록
|
||||
}
|
||||
|
||||
/** 리스트 모드 컬럼 1개 설정 */
|
||||
export interface ListColumnConfig {
|
||||
columnName: string; // DB 컬럼명
|
||||
label: string; // 헤더 라벨
|
||||
width?: string; // fr 단위 (기본 "1fr")
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
alternateColumns?: string[]; // 런타임에서 전환 가능한 대체 컬럼 목록
|
||||
}
|
||||
|
||||
/** 오버플로우 설정 */
|
||||
export interface OverflowConfig {
|
||||
visibleRows: number; // 기본 표시 행 수
|
||||
showExpandButton: boolean; // "전체보기" 버튼 표시
|
||||
maxExpandRows: number; // 확장 시 최대 행 수
|
||||
}
|
||||
|
||||
/** 헤더 설정 */
|
||||
export interface StringListHeaderConfig {
|
||||
enabled: boolean; // 헤더 표시 여부
|
||||
label?: string; // 헤더 라벨 텍스트
|
||||
}
|
||||
|
||||
/** pop-string-list 전체 설정 */
|
||||
export interface PopStringListConfig {
|
||||
displayMode: StringListDisplayMode;
|
||||
header: StringListHeaderConfig;
|
||||
overflow: OverflowConfig;
|
||||
dataSource: CardListDataSource; // 기존 타입 재활용
|
||||
selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속)
|
||||
listColumns?: ListColumnConfig[]; // 리스트 모드 전용
|
||||
cardGrid?: CardGridConfig; // 카드 모드 전용
|
||||
}
|
||||
|
|
@ -354,6 +354,7 @@ export interface CardColumnJoin {
|
|||
joinType: "LEFT" | "INNER" | "RIGHT";
|
||||
sourceColumn: string; // 메인 테이블 컬럼
|
||||
targetColumn: string; // 조인 테이블 컬럼
|
||||
selectedTargetColumns?: string[]; // 가져올 대상 테이블 컬럼 목록
|
||||
}
|
||||
|
||||
// ----- 필터 설정 -----
|
||||
|
|
|
|||
Loading…
Reference in New Issue