refactor: complete canonical table cleanup
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s

This commit is contained in:
DDD1542
2026-05-21 11:55:08 +09:00
parent bd4286f7ac
commit 7d204bfffd
59 changed files with 5897 additions and 19093 deletions
@@ -429,7 +429,7 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
}
// 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드)
// canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip
// canonical table / widgetType=table 등 table-like 컴포넌트 모두 동일하게 skip
const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp));
if (hasTableWidget) {
@@ -3,7 +3,7 @@
* 좌측과 우측에 화면을 임베드합니다.
*
* 데이터 전달은 좌측 화면에 배치된 버튼의 transferData 액션으로 처리됩니다.
* 예: 좌측 화면에 TableListComponent + Button(transferData 액션) 배치
* 예: 좌측 화면에 canonical TableComponent + Button(transferData 액션) 배치
*/
"use client";
+1 -3
View File
@@ -4102,9 +4102,7 @@ export default function InvyoneStudio({
"divider-basic": 1, // 구분선 (100%)
"divider-line": 1, // 구분선 (100%)
"accordion-basic": 1, // 아코디언 (100%)
"table": 1, // canonical 테이블 (100%)
"table-list": 1, // legacy 테이블 리스트 (100%)
"v2-table-list": 1, // hidden legacy 테이블 리스트 (100%)
"table": 1, // canonical 테이블 (100%) — 옛 ID 들은 isTableLikeComponentType 헬퍼가 흡수
"data-table": 1, // 데이터 테이블 (100%)
"datatable": 1, // 데이터 테이블 (100%)
"image-display": 4 / 12, // 이미지 표시 (33%)
@@ -343,7 +343,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래
// 🔥 모든 컴포넌트를 픽셀 기준으로 통일 (스케일로만 조정)
const getWidth = () => {
// 모든 컴포넌트는 size.width 픽셀 사용 (table-list 포함)
// 모든 컴포넌트는 size.width 픽셀 사용 (canonical table 포함)
const width = `${size?.width || 100}px`;
return width;
};
@@ -373,8 +373,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
// 런타임 모드에서 컴포넌트 타입별 높이 처리
if (!isDesignMode) {
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
// ★ table 계열 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' /
// 'data-table' / 'datatable') 은 helper 로 통일. 그 외 layout/split/tabs 는 명시 목록.
// ★ table 계열 (canonical 'table' / 'data-table' / 'datatable') 은 helper 로 통일.
// 그 외 layout/split/tabs 는 명시 목록.
const fillParentExtraTypes = [
"container",
"grouped-table", "card-list",
@@ -396,7 +396,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
}
// 1순위: size.height가 있으면 우선 사용
// (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' 모두 최소 200px 보장)
// (canonical 'table' 등 table-like 컴포넌트 모두 최소 200px 보장)
if (size?.height && size.height > 0) {
if (isTableLikeComponentType(sizingType)) {
return `${Math.max(size.height, 200)}px`;
+2 -3
View File
@@ -225,11 +225,10 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
};
// ========== 컴포넌트 종류별 미니어처 색상 ==========
// componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / legacy 'table-list' /
// hidden 'v2-table-list' / 'button-primary' 등)
// componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / 'button-primary' 등)
const TABLE_LIKE_EXTRA_KINDS = ["grouped-table", "card-list", "data-grid"];
const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련 (canonical table / legacy table-list / hidden v2-table-list 등)
// 테이블/그리드 관련 (canonical table table-like 컴포넌트)
if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) {
return "bg-primary/20 border-primary/40";
}
@@ -30,7 +30,7 @@ import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/appro
import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types";
import { isTableLikeComponentType, isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
// canonical table / data-table / datatable 등 table-like 컴포넌트
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
@@ -368,10 +368,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
sourceTableName = getTableNameFromTableLikeComponent(comp) || null;
if (sourceTableName) break;
}
if (compType === "v2-list") {
sourceTableName = compConfig.dataSource?.table || compConfig.table_name || null;
if (sourceTableName) break;
}
// 옛 통합 목록 분기 폐기 (Phase F.8) — canonical table 사용.
}
setModalActionSourceTable(sourceTableName);
@@ -529,7 +526,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
}
}
// 테이블 계열 (canonical table / legacy table-list / hidden v2-table-list 모두)
// 테이블 계열 (canonical table table-like 컴포넌트 모두)
if (isTableLikeComponent(comp)) {
sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name;
if (sourceTableName) {
@@ -36,7 +36,7 @@ export interface DataTabProps {
>;
}
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
// canonical table / data-table / datatable 등 table-like 컴포넌트
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
@@ -55,6 +55,7 @@ import { Progress } from "@/components/ui/progress";
import { ComponentData } from "@/types/screen";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 다국어 키 타입
interface LangKey {
@@ -146,16 +147,16 @@ interface MultilangSettingsModalProps {
onSave: (updates: Array<{ componentId: string; path?: string; langKeyId: number; langKey: string }>) => void;
}
// 타입별 아이콘 매핑
// canonical table / legacy table-list / hidden v2-table-list 모두 같은 table 아이콘.
// 타입별 아이콘 매핑.
// Phase E.3 — canonical / legacy / hidden table-like 는 모두 isTableLikeComponentType 헬퍼로
// 흡수해 같은 Table2 아이콘. hard-coded 옛 ID literal 제거.
const getTypeIcon = (type: string) => {
if (isTableLikeComponentType(type)) {
return <Table2 className="h-4 w-4" />;
}
switch (type) {
case "button":
return <MousePointer className="h-4 w-4" />;
case "table":
case "table-list":
case "v2-table-list":
return <Table2 className="h-4 w-4" />;
case "split-panel-layout":
return <LayoutPanelLeft className="h-4 w-4" />;
case "filter":
@@ -194,12 +195,11 @@ const getTypeLabel = (type: string) => {
}
};
// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등)
// canonical table 및 hidden legacy v2-table-list 도 모두 non-input 으로 분류.
// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등).
// Phase E.3 — table-like 는 isTableLikeComponentType 헬퍼 (isInputComponent 안 분기) 가 흡수.
// 여기는 canonical "table" 만 명시 — 옛 ID literal 은 헬퍼가 처리.
const NON_INPUT_COMPONENT_TYPES = new Set([
"table",
"table-list",
"v2-table-list",
"split-panel-layout",
"tab-panel",
"container",
@@ -245,6 +245,12 @@ const isInputComponent = (comp: any): boolean => {
const compType = comp.componentType || comp.type;
const webType = comp.webType || comp.componentConfig?.webType;
// Phase E.3 — table-like (canonical "table" / legacy ID 등) 는 입력 아님.
// 헬퍼가 옛 ID 도 흡수하므로 NON_INPUT_COMPONENT_TYPES 에 직접 나열할 필요 없음.
if (isTableLikeComponentType(compType)) {
return false;
}
// 명시적으로 제외되는 컴포넌트 타입
if (NON_INPUT_COMPONENT_TYPES.has(compType)) {
return false;
@@ -39,18 +39,12 @@ export function ComponentsPanel({
}: ComponentsPanelProps) {
const [searchQuery, setSearchQuery] = useState("");
// 레지스트리에서 모든 컴포넌트 조회
// 레지스트리에서 모든 컴포넌트 조회.
// Phase E.3 — 새 생성 경로는 canonical 'table' (displayMode='table') 뿐.
// 옛 layout JSON 호환은 BlockRenderer / DynamicComponentRenderer / templateMigrate 의
// alias 라우팅 + TableComponent 의 early delegation 으로 처리되며 팔레트와는 무관.
const allComponents = useMemo(() => {
const components = ComponentRegistry.getAllComponents();
// ★ 새 생성 경로는 canonical 'table' (displayMode='table').
// v2-table-list / table-list registration shell 은 2026-05-20 cleanup 으로 삭제되어
// ComponentRegistry 에는 더 이상 등록되지 않는다. 옛 저장 layout 의 v2-table-list /
// table-list 는 BlockRenderer / DynamicComponentRenderer / templateMigrate 의 alias
// 라우팅으로 canonical 'table' 정의를 통해 들어온 뒤, TableComponent 의 early
// delegation 으로 _shared/{TableListComponent,V2TableListContainerWrapper} 본체에서
// 기능 손실 없이 렌더된다. 아래 hiddenComponents 의 옛 ID 들은 만약 외부 코드가
// register 를 추가하더라도 팔레트에는 노출되지 않도록 한 안전망이다.
return components;
return ComponentRegistry.getAllComponents();
}, []);
// ── 기본 컴포넌트 (v2 하드코딩) ──
@@ -142,7 +136,6 @@ export function ComponentsPanel({
"button-primary", // → v2-button-primary
"split-panel-layout", // → v2-split-panel-layout
// aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요
"table-list", // legacy hidden — 새 생성 경로는 canonical 'table'
"text-display", // → v2-text-display
"divider-line", // → v2-divider-line
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
@@ -176,9 +169,10 @@ export function ComponentsPanel({
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table`
"v2-table-list", // → table (displayMode='table')
// Phase E.3 — 옛 hidden table ID 들은 ComponentRegistry 에 등록되지 않으므로
// hidden 목록에 둘 필요 없음. canonical 'table' 만 새 생성 경로.
"v2-split-panel-layout", // → table (displayMode='split')
// table-list, split-panel-layout, split-panel-layout2, modal-repeater-table,
// split-panel-layout, split-panel-layout2, modal-repeater-table,
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
// v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget:
@@ -235,7 +229,6 @@ export function ComponentsPanel({
const s = 14;
const icons: Record<string, React.ReactNode> = {
table: <Table2 size={s} />,
"v2-table-list": <Table2 size={s} />,
stats: <BarChart3 size={s} />,
title: <Type size={s} />,
divider: <Minus size={s} />,
@@ -224,7 +224,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
if (componentId?.startsWith("v2-")) {
const v2ConfigPanels: Record<string, React.FC<{ config: any; onChange: (config: any) => void }>> = {
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. 하드코딩 매핑 제거.
// v2-date / v2-list / v2-repeater / v2-table-list 는 InvField 등 통합 — ComponentRegistry fallback 사용
// 옛 통합 목록 / repeater / 옛 표 형식 / v2-date 는 InvField · canonical table 등으로 흡수
// (ComponentRegistry fallback 으로 라우팅)
"v2-layout": require("@/components/v2/config-panels/V2LayoutConfigPanel").V2LayoutConfigPanel,
"v2-group": require("@/components/v2/config-panels/V2GroupConfigPanel").V2GroupConfigPanel,
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
@@ -249,9 +250,7 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
const extraProps: Record<string, any> = {};
const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName;
if (componentId === "v2-list") {
extraProps.currentTableName = currentTableName;
}
// 옛 통합 목록 extraProps 분기 폐기 (Phase F.8) — canonical table 로 흡수.
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
extraProps.currentTableName = currentTableName;
extraProps.screenTableName = resolvedTableName;
@@ -97,6 +97,9 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
setGroupSumEnabled(false);
setGroupByColumn("");
}
} else {
setGroupSumEnabled(false);
setGroupByColumn("");
}
if (savedFilters) {
@@ -252,9 +255,19 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
const groupSumConfig: GroupSumConfig = {
enabled: groupSumEnabled,
group_by_column: groupByColumn,
group_by_column_label: table.columns.find((col) => col.column_name === groupByColumn)?.column_label,
};
localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig));
// Phase D.8 — group-sum 콜백 호출 (canonical TableComponent 가 GroupedView 강제 + summary 렌더)
if (table.onGroupSumChange) {
if (groupSumEnabled && groupByColumn) {
table.onGroupSumChange(groupSumConfig);
} else {
table.onGroupSumChange(null);
}
}
// 활성화된 필터만 콜백
const activeFilters: TableFilter[] = columnFilters
.filter((f) => f.enabled)
@@ -265,6 +278,8 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
filter_type: f.filterType,
width: f.width && f.width >= 10 && f.width <= 100 ? f.width : 25,
}));
// Phase D.8 — canonical TableComponent.onFilterChange 도 호출 (이전엔 onFiltersApplied 만)
table.onFilterChange(activeFilters);
onFiltersApplied?.(activeFilters);
// 3. 그룹화 저장
@@ -657,4 +672,3 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
</Dialog>
);
};
@@ -144,7 +144,7 @@ export function TabsWidget({
const inlineComponents = tab.components || [];
if (inlineComponents.length > 0) {
// 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출
// (canonical table / legacy table-list / hidden v2-table-list 모두 인식,
// (canonical table table-like 컴포넌트 인식,
// camelCase / snake_case 양쪽 모두 처리)
const tableComp = inlineComponents.find((c) => isTableLikeComponent(c));
const selectedTable = getTableNameFromTableLikeComponent(tableComp);
@@ -11,14 +11,13 @@ import React, { forwardRef, useMemo } from "react";
import {
V2ComponentProps,
isV2Text,
isV2List,
isV2Layout,
isV2Group,
isV2Biz,
isV2Hierarchy,
} from "@/types/v2-components";
// 옛 입력/선택 import 는 Phase D.3 에서 제거. V2Media 는 Phase D.5 에서 제거 — canonical input 으로 흡수.
import { V2List } from "./V2List";
// V2List 는 Phase F.8 (2026-05-21) 에서 제거 — canonical table 로 흡수.
import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group";
import { V2Biz } from "./V2Biz";
@@ -48,10 +47,7 @@ export const V2ComponentRenderer = forwardRef<HTMLDivElement, V2ComponentRendere
}
// V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
if (isV2List(props)) {
return <V2List {...props} />;
}
// V2List — Phase F.8 폐기. canonical table 로 흡수.
if (isV2Layout(props)) {
return <V2Layout {...props} />;
+5 -46
View File
@@ -15,7 +15,7 @@ import { Separator } from "@/components/ui/separator";
import { ArrowLeft } from "lucide-react";
// V2 컴포넌트들 (옛 입력/선택은 Phase D.3 에서 폐기 — canonical `input` 으로 흡수됨)
import { V2List } from "./V2List";
// V2List 는 Phase F.8 에서 폐기 — canonical table 로 흡수.
import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group";
// V2Media — Phase D.5 폐기. canonical input 의 file 분기로 흡수.
@@ -32,17 +32,10 @@ interface V2ComponentsDemoProps {
}
export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
const [activeTab, setActiveTab] = useState("list");
// 데모용 상태 (옛 입력/선택 데모 state — Phase D.3 에서 제거됨)
const [activeTab, setActiveTab] = useState("layout");
// 샘플 데이터
const sampleTableData = [
{ id: 1, name: "홍길동", email: "hong@test.com", status: "active", date: "2024-01-15" },
{ id: 2, name: "김철수", email: "kim@test.com", status: "inactive", date: "2024-02-20" },
{ id: 3, name: "이영희", email: "lee@test.com", status: "active", date: "2024-03-10" },
{ id: 4, name: "박민수", email: "park@test.com", status: "pending", date: "2024-04-05" },
];
// 데모용 상태 (옛 입력/선택 데모 state — Phase D.3 에서 제거됨)
// sampleTableData / List 탭은 Phase F.8 에서 제거 — canonical table demo 별도 화면.
const sampleHierarchyData: HierarchyNode[] = [
{
@@ -92,7 +85,6 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
<div className="flex-1 overflow-auto p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6">
<TabsTrigger value="list">List</TabsTrigger>
<TabsTrigger value="layout">Layout</TabsTrigger>
<TabsTrigger value="group" className="hidden lg:flex">Group</TabsTrigger>
<TabsTrigger value="biz" className="hidden lg:flex">Biz</TabsTrigger>
@@ -101,40 +93,7 @@ export function V2ComponentsDemo({ onBack }: V2ComponentsDemoProps) {
{/* 조건부 동작 데모 탭 — Phase D.3 에서 폐기 (옛 입력/선택 의존) */}
{/* 옛 입력/선택 탭 — Phase D.3 에서 폐기. canonical `input` 데모는 별도 화면에서 확인 */}
{/* V2List 탭 */}
<TabsContent value="list" className="mt-6">
<Card>
<CardHeader>
<CardTitle>V2List</CardTitle>
<CardDescription>
- table, card, list
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<V2List
id="demo-list"
label="사용자 목록"
v2Type="V2List"
config={{
view_mode: "table",
searchable: true,
pageable: true,
page_size: 5,
columns: [
{ field: "id", header: "ID", width: 60, sortable: true },
{ field: "name", header: "이름", sortable: true },
{ field: "email", header: "이메일" },
{ field: "status", header: "상태" },
{ field: "date", header: "등록일", format: "date", sortable: true },
],
}}
data={sampleTableData}
onRowClick={(row) => console.log("Row clicked:", row)}
/>
</CardContent>
</Card>
</TabsContent>
{/* List 탭 — Phase F.8 에서 폐기. canonical table demo 는 별도 화면에서 확인 */}
{/* V2Layout 탭 */}
<TabsContent value="layout" className="mt-6">
-176
View File
@@ -1,176 +0,0 @@
"use client";
/**
* V2List
*
*
* TableListComponent를
*/
import React, { forwardRef, useMemo } from "react";
import { TableListComponent } from "@/lib/registry/components/table/_shared/TableListComponent";
import { V2ListProps } from "@/types/v2-components";
/**
* V2List
* TableListComponent의
*/
export const V2List = forwardRef<HTMLDivElement, V2ListProps>((props, ref) => {
const { id, style, size, config: configProp, onRowSelect } = props;
// config가 없으면 기본값 사용
const config = configProp || {
viewMode: "table" as const,
source: "static" as const,
columns: [],
};
// 테이블명 추출 (여러 가능한 경로에서 시도)
const tableName = config.dataSource?.table || (config as any).tableName || (props as any).tableName;
// columns 형식 변환 (V2ListConfigPanel 형식 -> TableListComponent 형식)
const tableColumns = useMemo(
() =>
(config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.field || "",
displayName: col.title || col.header || col.key || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: true,
sortable: true,
searchable: true,
align: "left" as const,
order: index,
isEntityJoin: col.isJoinColumn || false,
thousandSeparator: col.thousandSeparator !== false, // 천단위 구분자 (기본: true)
})),
[config.columns],
);
// TableListComponent에 전달할 component 객체 생성
const componentObj = useMemo(
() => ({
id: id || "v2-list",
type: "table-list",
config: {
selectedTable: tableName,
tableName: tableName,
columns: tableColumns,
displayMode: config.viewMode === "card" ? "card" : "table",
cardConfig: {
idColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "id",
titleColumn: config.cardConfig?.titleColumn || tableColumns[0]?.columnName || "",
subtitleColumn: config.cardConfig?.subtitleColumn || undefined,
descriptionColumn: config.cardConfig?.descriptionColumn || undefined,
imageColumn: config.cardConfig?.imageColumn || undefined,
cardsPerRow: config.cardConfig?.cardsPerRow || 3,
cardSpacing: 16,
showActions: false,
},
showHeader: config.viewMode !== "card", // 카드 모드에서는 테이블 헤더 숨김
showFooter: false,
checkbox: {
enabled: true, // 항상 체크박스 활성화 (modalDataStore에 자동 저장)
position: "left" as const,
showHeader: true,
},
height: "auto" as const, // auto로 변경하여 스크롤 가능하게
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
pagination: {
enabled: config.pagination !== false,
pageSize: config.pageSize || 10,
position: "bottom" as const,
showPageSize: true, // 사용자가 실제 화면에서 페이지 크기 변경 가능
pageSizeOptions: [5, 10, 20, 50, 100],
},
filter: {
enabled: false, // 필터 비활성화 (필요시 활성화)
position: "top" as const,
searchPlaceholder: "검색...",
},
actions: {
enabled: false,
items: [],
},
tableStyle: {
striped: false,
bordered: true,
hover: true,
compact: false,
},
toolbar: {
showRefresh: true,
showExport: false,
showColumnToggle: false,
},
},
style: {},
gridColumns: 1,
}),
[
id,
tableName,
tableColumns,
config.viewMode,
config.pagination,
config.pageSize,
config.cardConfig,
onRowSelect,
],
);
// 테이블이 없으면 안내 메시지
if (!tableName) {
return (
<div
ref={ref}
id={id}
className="bg-muted/20 flex items-center justify-center rounded-lg border p-8"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || "100%",
}}
>
<p className="text-muted-foreground text-sm"> .</p>
</div>
);
}
return (
<div
ref={ref}
id={id}
className="flex flex-col overflow-auto"
style={{
width: size?.width || style?.width || "100%",
height: size?.height || style?.height || "100%",
}}
>
<TableListComponent
component={componentObj}
tableName={tableName}
style={{
width: "100%",
minHeight: "100%",
display: "flex",
flexDirection: "column",
}}
onSelectedRowsChange={
onRowSelect
? (_, selectedData) => {
onRowSelect(selectedData);
}
: undefined
}
/>
</div>
);
});
V2List.displayName = "V2List";
@@ -1,35 +1,31 @@
"use client";
/**
* InvDataConfigPanel "데이터 조회/선택" ConfigPanel
* InvDataConfigPanel "데이터 입력" ConfigPanel
*
* (v2-* runtime id DB ):
* - v2-list (read) /
* - v2-table-list (read) / (legacy, hidden)
* - v2-repeater (write) /
*
* :
* = wrapper (cp brumb + )
* = brumb componentType + cp + resolver/writer
* Phase F.8 (2026-05-21):
* - read ( ) canonical TableComponent + InvTableConfigPanel .
* - write . v2-list v2-repeater .
*
* Reference: notes/gbpark/2026-04-28-cp-panel-standard.md, 2026-04-28-invdata-inventory.md
*/
import React from "react";
import { Database, MousePointerClick, Table2, Rows3, Columns3 } from "lucide-react";
import { MousePointerClick, Columns3 } from "lucide-react";
import { CPCrumb, type CPCrumbType, type CPCrumbKind } from "./_shared/cp";
import { V2ListConfigPanel } from "./V2ListConfigPanel";
import { V2TableListConfigPanel } from "./V2TableListConfigPanel";
import { InvRepeaterConfigPanel } from "./InvRepeaterConfigPanel";
type DataComponentType = "v2-list" | "v2-table-list" | "v2-repeater";
type DataKind = "read" | "write";
type DataComponentType = "v2-repeater";
type DataKind = "write";
interface InvDataConfigPanelProps {
config: any;
onChange: (config: any) => void;
/** 컴포넌트 타입 — runtime id (registerV2Components 에서 전달) */
componentType: DataComponentType;
componentType: DataComponentType | string;
/** 화면 메인 테이블명 */
screenTableName?: string;
/** 현재 테이블명 */
@@ -43,11 +39,6 @@ interface InvDataConfigPanelProps {
}
const KINDS: CPCrumbKind[] = [
{
id: "read",
name: "조회",
icon: <Database size={14} />,
},
{
id: "write",
name: "입력",
@@ -56,22 +47,6 @@ const KINDS: CPCrumbKind[] = [
];
const TYPES_BY_KIND: Record<DataKind, CPCrumbType[]> = {
read: [
{
id: "v2-list",
name: "통합 목록",
desc: "테이블/카드/칸반 등 다양한 표시",
icon: <Rows3 size={14} />,
col: "LIST",
},
{
id: "v2-table-list",
name: "테이블",
desc: "행/열 그리드 (legacy)",
icon: <Table2 size={14} />,
col: "TABLE",
},
],
write: [
{
id: "v2-repeater",
@@ -84,11 +59,16 @@ const TYPES_BY_KIND: Record<DataKind, CPCrumbType[]> = {
};
const KIND_OF_TYPE: Record<DataComponentType, DataKind> = {
"v2-list": "read",
"v2-table-list": "read",
"v2-repeater": "write",
};
// Phase F.8 — 옛 read 카테고리는 canonical table 로 흡수됨. 옛 저장 데이터가 본 패널에
// 들어오면 무조건 write/v2-repeater 로 정규화한다.
function _normalizeComponentType(t: string): DataComponentType {
if (t === "v2-repeater") return t;
return "v2-repeater";
}
export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
config,
onChange,
@@ -99,10 +79,11 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
menuObjid,
onComponentTypeChange,
}) => {
const currentKind: DataKind = KIND_OF_TYPE[componentType] || "read";
const normalizedType = _normalizeComponentType(componentType as string);
const currentKind: DataKind = KIND_OF_TYPE[normalizedType] || "write";
const handleKindChange = (nextKind: string) => {
// kind 가 바뀌면 해당 kind 의 첫 type 으로 전환
// kind 가 바뀌면 해당 kind 의 첫 type 으로 전환 (현재 write 만 존재)
const firstType = TYPES_BY_KIND[nextKind as DataKind]?.[0]?.id as DataComponentType | undefined;
if (!firstType || firstType === componentType) return;
if (onComponentTypeChange) {
@@ -116,13 +97,14 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
};
const handleTypeChange = (nextType: string) => {
if (nextType === componentType) return;
const normalizedNextType = _normalizeComponentType(nextType);
if (normalizedNextType === componentType) return;
if (onComponentTypeChange) {
onComponentTypeChange(nextType as DataComponentType);
onComponentTypeChange(normalizedNextType);
} else {
console.warn(
"[InvDataConfigPanel] onComponentTypeChange 미구현 — type 변경 적용 안 됨:",
nextType,
normalizedNextType,
);
}
};
@@ -134,28 +116,11 @@ export const InvDataConfigPanel: React.FC<InvDataConfigPanelProps> = ({
currentKind={currentKind}
onChangeKind={handleKindChange}
types={TYPES_BY_KIND[currentKind] || []}
value={componentType}
value={normalizedType}
onChange={handleTypeChange}
/>
{/* 본체 — 현재는 옛 패널 위임. 다음 단계에서 cp 이주 */}
{componentType === "v2-list" && (
<V2ListConfigPanel
config={config}
onChange={onChange}
currentTableName={currentTableName}
/>
)}
{componentType === "v2-table-list" && (
<V2TableListConfigPanel
config={config}
onChange={onChange}
screenTableName={screenTableName}
tableColumns={tableColumns}
menuObjid={menuObjid as number | undefined}
/>
)}
{componentType === "v2-repeater" && (
{normalizedType === "v2-repeater" && (
<InvRepeaterConfigPanel
config={config}
onChange={onChange}
@@ -113,7 +113,7 @@ const MODAL_SIZE_OPTIONS = [
{ value: "full", label: "전체" },
] as const;
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
// canonical table / data-table / datatable 등 table-like 컴포넌트
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
// 호환 대상으로 함께 인식.
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
@@ -1,333 +0,0 @@
"use client";
/**
* V2List
* UX: 테이블 -> (Switch) -> (Collapsible)
* / TableListConfigPanel에
*/
import React, { useState, useMemo } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Table2, Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { TableListConfigPanel } from "@/lib/registry/components/table/_shared/TableListConfigPanel";
import type { TableListConfig } from "@/lib/registry/components/table/_shared/tableListConfigTypes";
interface V2ListConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
currentTableName?: string;
}
export const V2ListConfigPanel: React.FC<V2ListConfigPanelProps> = ({
config,
onChange,
currentTableName,
}) => {
const [detailOpen, setDetailOpen] = useState(false);
const updateConfig = (field: string, value: any) => {
onChange({ ...config, [field]: value });
};
const tableName = config.tableName || config.dataSource?.table || currentTableName || "";
const columnCount = (config.columns || []).length;
// ─── V2List config → TableListConfig 변환 (기존 로직 100% 유지) ───
const tableListConfig: TableListConfig = useMemo(() => {
const columns = (config.columns || []).map((col: any, index: number) => ({
columnName: col.key || col.columnName || col.field || "",
displayName: col.title || col.header || col.displayName || col.key || col.columnName || col.field || "",
width: col.width ? parseInt(col.width, 10) : undefined,
visible: col.visible !== false,
sortable: col.sortable !== false,
searchable: col.searchable !== false,
align: col.align || "left",
order: index,
isEntityJoin: col.isJoinColumn || col.isEntityJoin || false,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
return {
selectedTable: config.tableName || config.dataSource?.table || currentTableName,
tableName: config.tableName || config.dataSource?.table || currentTableName,
columns,
useCustomTable: config.useCustomTable,
customTableName: config.customTableName,
isReadOnly: config.isReadOnly !== false,
displayMode: "table",
showHeader: true,
showFooter: false,
pagination: config.pagination !== false ? {
enabled: true,
pageSize: config.pageSize || 10,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [5, 10, 20, 50, 100],
} : {
enabled: false,
pageSize: 10,
showSizeSelector: false,
showPageInfo: false,
pageSizeOptions: [10],
},
filter: config.filter || { enabled: false, filters: [] },
dataFilter: config.dataFilter,
actions: config.actions || {
showActions: false,
actions: [],
bulkActions: false,
bulkActionList: [],
},
tableStyle: config.tableStyle || {
theme: "default",
headerStyle: "default",
rowHeight: "normal",
alternateRows: false,
hoverEffect: true,
borderStyle: "light",
},
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
},
height: "auto",
autoWidth: true,
stickyHeader: true,
autoLoad: true,
horizontalScroll: {
enabled: true,
minColumnWidth: 100,
maxColumnWidth: 300,
},
toolbar: config.toolbar,
linkedFilters: config.linkedFilters,
excludeFilter: config.excludeFilter,
defaultSort: config.defaultSort,
};
}, [config, currentTableName]);
// ─── TableListConfig 변경 → V2List config 변환 (기존 로직 100% 유지) ───
const handleConfigChange = (partialConfig: Partial<TableListConfig>) => {
const newConfig: Record<string, any> = { ...config };
if (partialConfig.selectedTable !== undefined) {
newConfig.tableName = partialConfig.selectedTable;
if (!newConfig.dataSource) newConfig.dataSource = {};
newConfig.dataSource.table = partialConfig.selectedTable;
}
if (partialConfig.tableName !== undefined) {
newConfig.tableName = partialConfig.tableName;
if (!newConfig.dataSource) newConfig.dataSource = {};
newConfig.dataSource.table = partialConfig.tableName;
}
if (partialConfig.useCustomTable !== undefined) {
newConfig.useCustomTable = partialConfig.useCustomTable;
}
if (partialConfig.customTableName !== undefined) {
newConfig.customTableName = partialConfig.customTableName;
}
if (partialConfig.isReadOnly !== undefined) {
newConfig.isReadOnly = partialConfig.isReadOnly;
}
if (partialConfig.columns !== undefined) {
newConfig.columns = partialConfig.columns.map((col: any) => ({
key: col.columnName,
field: col.columnName,
title: col.displayName,
header: col.displayName,
width: col.width ? String(col.width) : undefined,
visible: col.visible,
sortable: col.sortable,
searchable: col.searchable,
align: col.align,
isJoinColumn: col.isEntityJoin,
isEntityJoin: col.isEntityJoin,
thousandSeparator: col.thousandSeparator,
editable: col.editable,
entityDisplayConfig: col.entityDisplayConfig,
}));
}
if (partialConfig.pagination !== undefined) {
newConfig.pagination = partialConfig.pagination?.enabled;
newConfig.pageSize = partialConfig.pagination?.pageSize || 10;
}
if (partialConfig.filter !== undefined) {
newConfig.filter = partialConfig.filter;
}
if (partialConfig.dataFilter !== undefined) {
newConfig.dataFilter = partialConfig.dataFilter;
}
if (partialConfig.actions !== undefined) {
newConfig.actions = partialConfig.actions;
}
if (partialConfig.tableStyle !== undefined) {
newConfig.tableStyle = partialConfig.tableStyle;
}
if (partialConfig.toolbar !== undefined) {
newConfig.toolbar = partialConfig.toolbar;
}
if (partialConfig.linkedFilters !== undefined) {
newConfig.linkedFilters = partialConfig.linkedFilters;
}
if (partialConfig.excludeFilter !== undefined) {
newConfig.excludeFilter = partialConfig.excludeFilter;
}
if (partialConfig.defaultSort !== undefined) {
newConfig.defaultSort = partialConfig.defaultSort;
}
onChange(newConfig);
};
return (
<div className="space-y-4">
{/* ─── 1단계: 테이블 정보 ─── */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<Table2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium"> </span>
</div>
{tableName ? (
<div className="rounded-md border bg-background p-3">
<p className="text-xs text-muted-foreground"> </p>
<p className="mt-0.5 text-sm font-medium">{tableName}</p>
{columnCount > 0 && (
<p className="mt-1 text-[11px] text-muted-foreground">
{columnCount}
</p>
)}
</div>
) : (
<div className="rounded-md border-2 border-dashed p-4 text-center">
<Table2 className="mx-auto mb-2 h-8 w-8 opacity-30 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
)}
</div>
{/* ─── 2단계: 기본 옵션 (Switch + 설명) ─── */}
<div className="space-y-2">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.isReadOnly !== false}
onCheckedChange={(checked) => updateConfig("isReadOnly", checked)}
/>
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"></p>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
<Switch
checked={config.pagination !== false}
onCheckedChange={(checked) => {
updateConfig("pagination", checked);
}}
/>
</div>
{config.pagination !== false && (
<div className="ml-4 border-l-2 border-primary/20 pl-3">
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Select
value={String(config.pageSize || 10)}
onValueChange={(v) => updateConfig("pageSize", Number(v))}
>
<SelectTrigger className="h-8 w-[180px] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* ─── 3단계: 상세 설정 (컬럼, 필터, 테이블 선택 등) ─── */}
<Collapsible open={detailOpen} onOpenChange={setDetailOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
detailOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-2">
<p className="text-xs text-muted-foreground px-2 pb-2">
, ,
</p>
<TableListConfigPanel
config={tableListConfig}
onChange={handleConfigChange}
screenTableName={currentTableName}
/>
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
V2ListConfigPanel.displayName = "V2ListConfigPanel";
export default V2ListConfigPanel;
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
*/
// 옛 입력/선택 ConfigPanel 은 Phase D.3 에서 폐기됨 — InvFieldConfigPanel 사용.
export { V2ListConfigPanel } from "./V2ListConfigPanel";
// V2List ConfigPanel 은 Phase F.8 에서 폐기됨 — canonical InvTableConfigPanel 사용.
export { V2LayoutConfigPanel } from "./V2LayoutConfigPanel";
export { V2GroupConfigPanel } from "./V2GroupConfigPanel";
// V2MediaConfigPanel — Phase D.5 폐기. canonical InvFieldConfigPanel 의 attach/file 분기로 흡수.
+2 -7
View File
@@ -5,9 +5,9 @@
*/
// 옛 입력/선택 컴포넌트는 Phase D.3 (2026-05-12) 에서 폐기됨 — canonical `input` 으로 흡수.
// V2List 는 Phase F.8 (2026-05-21) 에서 폐기됨 — canonical table 로 흡수.
// Phase 2 컴포넌트
export { V2List } from "./V2List";
export { V2Layout } from "./V2Layout";
export { V2Group } from "./V2Group";
@@ -64,12 +64,7 @@ export type {
MutualExclusionConfig,
// (옛 입력/선택 타입은 Phase D.3 에서 제거됨 — canonical InputConfig 와 OptionFilter 로 이전)
// V2List 타입
V2ListViewMode,
ListColumn,
V2ListConfig,
V2ListProps,
// (V2List 타입은 Phase F.8 에서 제거됨 — canonical TableComponent + TableConfig 로 이전)
// V2Layout 타입
V2LayoutType,
+2 -21
View File
@@ -11,7 +11,6 @@ import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import { WebType } from "@/types/screen";
import { V2List } from "./V2List";
import { V2Layout } from "./V2Layout";
import { V2Group } from "./V2Group";
// V2Media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
@@ -19,35 +18,17 @@ import { V2Biz } from "./V2Biz";
import { V2Hierarchy } from "./V2Hierarchy";
import { V2Repeater } from "./V2Repeater";
import { V2ListConfigPanel } from "./config-panels/V2ListConfigPanel";
// V2List — Phase F.8 폐기. canonical TableComponent 직접 사용 (Phase E.1/E.2).
import { V2LayoutConfigPanel } from "./config-panels/V2LayoutConfigPanel";
import { V2GroupConfigPanel } from "./config-panels/V2GroupConfigPanel";
// V2MediaConfigPanel — Phase D.5 폐기.
import { V2BizConfigPanel } from "./config-panels/V2BizConfigPanel";
import { V2HierarchyConfigPanel } from "./config-panels/V2HierarchyConfigPanel";
import { InvRepeaterConfigPanel } from "./config-panels/InvRepeaterConfigPanel";
import { InvDataConfigPanel } from "./config-panels/InvDataConfigPanel";
// V2 컴포넌트 정의
const v2ComponentDefinitions: ComponentDefinition[] = [
{
id: "v2-list",
name: "통합 목록",
description: "테이블, 카드, 칸반, 리스트 등 다양한 데이터 표시 방식을 지원하는 통합 컴포넌트",
category: ComponentCategory.V2,
web_type: "list" as WebType,
component: V2List as any,
tags: ["list", "table", "card", "kanban", "data", "v2"],
default_size: { width: 600, height: 400 },
config_panel: InvDataConfigPanel as any,
default_config: {
viewMode: "table",
source: "static",
columns: [],
pagination: true,
sortable: true,
},
},
// v2-list — Phase F.8 폐기. canonical TableComponent 로 흡수됨 (Phase E.1/E.2).
{
id: "v2-layout",
name: "통합 레이아웃",
+24 -3
View File
@@ -2,6 +2,14 @@ import React, { createContext, useContext, useState, useCallback, useMemo, React
import { TableRegistration, TableOptionsContextValue } from "@/types/table-options";
import { useActiveTab } from "./ActiveTabContext";
/**
* TableOptionsContext TableSettingsModal / TableOptionsToolbar / ColumnVisibilityPanel /
* GroupingPanel / FilterPanel UI .
*
* contract type . `table_id` unique key
* canonical `lib/registry/components/table/TableComponent` (`table-${id}` prefix)
* ID Provider .
*/
const TableOptionsContext = createContext<TableOptionsContextValue | undefined>(undefined);
export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
@@ -64,8 +72,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ childr
setRegisteredTables((prev) => {
const table = prev.get(tableId);
if (table) {
// 기존 테이블 정보에 dataCount만 업데이트
const updatedTable = { ...table, dataCount: count };
// 기존 테이블 정보에 data_count만 업데이트 (TableRegistration contract)
const updatedTable = { ...table, data_count: count };
const newMap = new Map(prev);
newMap.set(tableId, updatedTable);
return newMap;
@@ -124,7 +132,8 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({ childr
};
/**
* Context Hook
* Context Hook Provider throw. canonical TableComponent Provider
* wrap .
*/
export const useTableOptions = () => {
const context = useContext(TableOptionsContext);
@@ -133,3 +142,15 @@ export const useTableOptions = () => {
}
return context;
};
/**
* Optional variant Provider ( , , embed iframe )
* mount canonical TableComponent . Provider context ,
* `undefined` . `if (!ctx) return;` .
*
* Phase B.1 (2026-05-20) canonical `table` contract
* register , dashboard Provider mount .
*/
export const useTableOptionsOptional = () => {
return useContext(TableOptionsContext);
};
+1 -1
View File
@@ -366,7 +366,7 @@ export interface LayoutItem {
y: number;
width: number;
height: number;
componentKind: string; // 정확한 컴포넌트 종류 (canonical 예: table / button — legacy 예: table-list / v2-table-list / button-primary)
componentKind: string; // 정확한 컴포넌트 종류 (canonical 예: table / button — legacy 예: button-primary)
widgetType: string; // 일반적인 위젯 타입 (button, text 등)
label?: string;
bindField?: string; // 바인딩된 필드명 (컬럼명)
+84 -19
View File
@@ -16,12 +16,19 @@
import type { FieldConfig } from "@/types/invyone-component";
/**
* FieldConfig[] table-like (canonical 'table' / legacy 'table-list' /
* hidden 'v2-table-list') ColumnConfig[] .
* FieldConfig[] snake_case ColumnConfig[] .
*
* (column_name / column_label / visible / display_order /
* width / align / sortable) . shared `_shared/V2TableListContainerWrapper`
* v2 columns .
* (Phase F.2/F.8 table-list .
* snake_case .)
*
* canonical `TableComponent` `fieldsToCanonicalColumns` (camelCase + )
* (Phase C.2 ).
*
* (column_name / column_label / visible / display_order / width / align /
* sortable / data_type / format / pk / editable) Phase C.2
* (`searchable` / `input_type` / `thousand_separator`) . FieldConfig
* legacy-only (`fixed` / `hidden` / entity )
* ConfigPanel set column .
*/
export function fieldsToColumns(
fields: FieldConfig[],
@@ -29,20 +36,78 @@ export function fieldsToColumns(
return [...fields]
.filter((f) => f.visible !== false && !f.system)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((f) => ({
column_name: f.column,
column_label: f.label,
label: f.label,
visible: f.visible !== false,
display_order: f.order ?? 0,
width: f.width,
align: f.align ?? (f.type === "number" ? "right" : "left"),
sortable: f.sortable ?? true,
data_type: f.type,
format: f.format,
pk: f.pk ?? false,
editable: f.editable ?? true,
}));
.map((f) => {
const isNumberFormat =
f.type === "number" || f.format === "number" || f.format === "currency";
// Phase D.10 — FieldConfig 자체엔 autoGeneration 이 없지만 legacy layout 의
// 원본 field 객체에 박혀들어올 수 있으므로 (any 캐스트로) 메타 보존.
const autoGen =
(f as any).autoGeneration ?? (f as any).auto_generation ?? undefined;
return {
column_name: f.column,
column_label: f.label,
label: f.label,
visible: f.visible !== false,
display_order: f.order ?? 0,
width: f.width,
align: f.align ?? (f.type === "number" ? "right" : "left"),
sortable: f.sortable ?? true,
searchable: f.searchable === true,
data_type: f.type,
input_type: f.type,
format: f.format,
pk: f.pk ?? false,
editable: f.editable ?? true,
thousand_separator: isNumberFormat ? true : undefined,
...(autoGen ? { autoGeneration: autoGen, auto_generation: autoGen } : {}),
};
});
}
/**
* FieldConfig[] canonical `TableColumn[]` (camelCase, `frontend/lib/registry/components/table/types.ts`).
*
* (Phase C.2 2026-05-20). canonical `TableComponent` fields columns .
* FieldConfig (fixed / fixedOrder / hidden / entity )
* undefined ConfigPanel set .
*
* Phase D.1 canonical table source columns render columns
* `visible === false` source . / `TableComponent`
* renderColumns derive .
*/
export function fieldsToCanonicalColumns(
fields: FieldConfig[],
): Array<Record<string, any>> {
return [...fields]
.filter((f) => !f.system)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((f) => {
const isNumberFormat =
f.type === "number" || f.format === "number" || f.format === "currency";
// Phase D.10 — autoGeneration 메타 보존. FieldConfig 자체엔 없지만 legacy field 가
// 옵션 필드로 들고 들어올 수 있어 any 캐스트로 흡수. runtime 적용은 별도 phase.
const autoGen =
(f as any).autoGeneration ?? (f as any).auto_generation ?? undefined;
return {
key: f.column,
label: f.label,
visible: f.visible !== false,
order: f.order ?? 0,
width: f.width,
align: f.align ?? (f.type === "number" ? "right" : "left"),
sortable: f.sortable ?? true,
searchable: f.searchable === true,
editable: f.editable ?? true,
format: f.format,
// FieldConfig.type 을 inputType / dataType 양쪽으로 미러링 (D.3/D.5 wiring).
// 사용자가 ConfigPanel 에서 명시 inputType 을 set 했으면 그쪽이 우선 (ConfigPanel
// 출력 columns 가 fields 머지보다 후위로 들어가므로).
inputType: f.type,
dataType: f.type,
thousandSeparator: isNumberFormat ? true : undefined,
...(autoGen ? { autoGeneration: autoGen } : {}),
};
});
}
/**
@@ -384,8 +384,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// stats
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
"v2-status-count": "stats",
// table
"v2-table-list": "table", "table-list": "table",
// container
"v2-tabs-widget": "container", "tabs-widget": "container", "tabs": "container", "v2-tabs": "container",
"v2-section-card": "container",
@@ -933,7 +931,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
const rendererInstance = new RendererClass(rendererProps);
renderedElement = rendererInstance.render();
} else {
// canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' / v2-repeater
// canonical 'table' / v2-repeater
// 는 refreshKey 변동 시 강제 remount 가 필요 (data refetch 트리거).
const needsKeyRefresh = isTableLikeComponentType(componentType) || componentType === "v2-repeater";
renderedElement = <NewComponentRenderer key={needsKeyRefresh ? refreshKey : component.id} {...rendererProps} />;
@@ -720,7 +720,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const allProviders = screenContext.getAllDataProviders();
// table-like (canonical table / legacy table-list / hidden v2-table-list 등) 우선 탐색
// table-like (canonical table 등) 우선 탐색
for (const [id, provider] of allProviders) {
if (isTableLikeComponentType(provider.component_type)) {
sourceProvider = provider;
+3 -5
View File
@@ -59,7 +59,7 @@ import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데
// ============================================================
// ★ 2026-05-19 canonical 정리: alias 라우팅으로 충분한 옛 Renderer 자동등록 제거.
// - aggregation-widget / v2-aggregation-widget / v2-status-count → canonical stats alias
// - table-list / v2-table-list → canonical table alias
// - table 계열 → canonical table alias (Phase F.6 에서 alias 도 제거 완료)
// - tabs / v2-tabs-widget → canonical container alias (containerType=tabs)
// - section-card / v2-section-card / section-paper / v2-section-paper → canonical container alias (containerType=section + sectionVariant)
import "./button-primary/ButtonPrimaryRenderer";
@@ -103,10 +103,8 @@ import "./grouped-table/GroupedTableRenderer"; // Phase G.3.1 — canonical 그
// form 컴포넌트는 롤백됨 (2026-04-11): "폼" 은 별도 컴포넌트가 아닌
// 화면 디자이너의 3뷰 탭(목록/등록 팝업/수정 팝업) 구조로 처리할 예정.
// 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.2
// canonical 'table' — 새 생성 경로. table-list / v2-table-list 등 옛 ID 는
// LEGACY_TO_UNIFIED + getComponentConfigPanel.CONFIG_PANEL_ALIAS 로 alias 라우팅 후
// TableComponent early delegation 에서 _shared/{TableListComponent,V2TableListContainerWrapper}
// 로 위임된다. shell 폴더 (table-list/, v2-table-list/) 는 2026-05-20 cleanup 으로 삭제됨.
// canonical 'table' — 새 생성/유일한 런타임 경로. 옛 table 계열 ID 와 본체는 Phase F
// (F.1~F.8) 에서 모두 흡수/삭제됐다.
import "./table/TableRenderer";
// canonical 'container' — 새 생성 경로. alias 흡수: tabs-widget / v2-tabs-widget /
// section-card / v2-section-card / section-paper / v2-section-paper / tabs / v2-tabs.
@@ -134,7 +134,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
// 우측 패널 대상: datatable, canonical table 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,259 +0,0 @@
"use client";
/**
* CardModeRenderer shared card grid renderer
*
* 2026-05-20 table-list / v2-table-list CardModeRenderer .
* `variant="v2"` prop v2 URL (objid getFilePreviewUrl,
* path getFullImageUrl) fallback DOM .
*
* legacy table-list / v2-table-list local type files import .
* shared `./tableListConfigTypes` .
*/
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
import type { CardDisplayConfig, ColumnConfig } from "./tableListConfigTypes";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
export type CardModeVariant = "default" | "v2";
interface CardModeRendererProps {
/** 시각/동작 분기 — 기본은 table-list 기존 동작, "v2" 는 v2-table-list 흡수 분기 */
variant?: CardModeVariant;
data: Record<string, any>[];
cardConfig: CardDisplayConfig;
visibleColumns: ColumnConfig[];
onRowClick?: (row: Record<string, any>, index: number, e: React.MouseEvent) => void;
onRowSelect?: (row: Record<string, any>, selected: boolean) => void;
selectedRows?: string[];
}
/**
*
*
*/
export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
variant = "default",
data,
cardConfig,
visibleColumns,
onRowClick,
selectedRows = [],
}) => {
const isV2 = variant === "v2";
// 기본값과 병합
const config = {
idColumn: cardConfig?.idColumn || "",
titleColumn: cardConfig?.titleColumn || "",
subtitleColumn: cardConfig?.subtitleColumn,
descriptionColumn: cardConfig?.descriptionColumn,
imageColumn: cardConfig?.imageColumn,
cardsPerRow: cardConfig?.cardsPerRow ?? 3,
cardSpacing: cardConfig?.cardSpacing ?? 16,
showActions: cardConfig?.showActions ?? true,
cardHeight: cardConfig?.cardHeight as number | "auto" | undefined,
};
// 디버깅: cardConfig 확인
console.log("🃏 CardModeRenderer config:", { cardConfig, mergedConfig: config });
// 카드 그리드 스타일 계산
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${config.cardsPerRow}, 1fr)`,
gap: `${config.cardSpacing}px`,
padding: `${config.cardSpacing}px`,
overflow: "auto",
};
// 카드 높이 스타일
const cardStyle: React.CSSProperties = {
height: config.cardHeight === "auto" ? "auto" : `${config.cardHeight}px`,
cursor: onRowClick ? "pointer" : "default",
};
// 컬럼 값 가져오기 함수
const getColumnValue = (row: Record<string, any>, columnName?: string): string => {
if (!columnName || !row) return "";
return String(row[columnName] || "");
};
// 액션 버튼 렌더링
const renderActions = (_row: Record<string, any>) => {
if (!config.showActions) return null;
return (
<div className="mt-3 flex items-center justify-end space-x-1 border-t border-border pt-3">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 상세보기 액션
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 편집 액션
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 삭제 액션
}}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 더보기 액션
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
);
};
// 데이터가 없는 경우
if (!data || data.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="bg-muted mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
<div className="bg-muted-foreground/20 h-8 w-8 rounded-lg"></div>
</div>
<div className="text-muted-foreground mb-1 text-sm font-medium"> </div>
<div className="text-muted-foreground/60 text-xs"> </div>
</div>
);
}
return (
<div style={gridStyle} className="w-full">
{data.map((row, index) => {
const idValue = getColumnValue(row, config.idColumn);
const titleValue = getColumnValue(row, config.titleColumn);
const subtitleValue = getColumnValue(row, config.subtitleColumn);
const descriptionValue = getColumnValue(row, config.descriptionColumn);
const imageValue = getColumnValue(row, config.imageColumn);
const isSelected = selectedRows.includes(idValue);
return (
<Card
key={`card-${index}-${idValue}`}
style={cardStyle}
className={`transition-all duration-200 hover:shadow-md ${
isSelected ? "bg-primary/10/30 ring-2 ring-ring" : ""
}`}
onClick={(e) => onRowClick?.(row, index, e)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-sm font-medium">{titleValue || "제목 없음"}</CardTitle>
{subtitleValue && <div className="mt-1 truncate text-xs text-muted-foreground">{subtitleValue}</div>}
</div>
{/* ID 뱃지 */}
{idValue && (
<Badge variant="secondary" className="ml-2 text-xs">
{idValue}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
{/* 이미지 표시 */}
{imageValue && (
<div className="mb-3">
<img
src={
isV2
? (() => {
// ★ v2 전용: 숫자 objid → getFilePreviewUrl, 그 외 path → getFullImageUrl 정규화
const strValue = String(imageValue);
const isObjid = /^\d+$/.test(strValue);
return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
})()
: imageValue
}
alt={titleValue}
className="h-24 w-full rounded-md bg-muted object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
// 이미지 로드 실패 시 폴백 표시
target.style.display = "none";
// ★ v2 전용: 폴백 DOM 삽입 (data-image-fallback). default 는 단순 hide 만.
if (!isV2) return;
const parent = target.parentElement;
if (parent && !parent.querySelector("[data-image-fallback]")) {
const fallback = document.createElement("div");
fallback.setAttribute("data-image-fallback", "true");
fallback.className = "flex items-center justify-center h-24 w-full rounded-md bg-muted text-muted-foreground";
fallback.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>`;
parent.appendChild(fallback);
}
}}
/>
</div>
)}
{/* 설명 표시 */}
{descriptionValue && <div className="mb-3 line-clamp-2 text-xs text-muted-foreground">{descriptionValue}</div>}
{/* 추가 필드들 표시 (선택적) */}
<div className="space-y-1">
{(visibleColumns || [])
.filter(
(col) =>
col.columnName !== config.idColumn &&
col.columnName !== config.titleColumn &&
col.columnName !== config.subtitleColumn &&
col.columnName !== config.descriptionColumn &&
col.columnName !== config.imageColumn &&
col.columnName !== "__checkbox__" &&
col.visible,
)
.slice(0, 3) // 최대 3개 추가 필드만 표시
.map((col) => {
const value = getColumnValue(row, col.columnName);
if (!value) return null;
return (
<div key={col.columnName} className="flex items-center justify-between text-xs">
<span className="truncate text-muted-foreground">{col.displayName}:</span>
<span className="ml-2 truncate font-medium">{value}</span>
</div>
);
})}
</div>
{/* 액션 버튼들 */}
{renderActions(row)}
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -3,16 +3,11 @@
/**
* SingleTableWithSticky shared table renderer
*
* 2026-05-19 FlowWidget hard blocker legacy
* `lib/registry/components/table-list/SingleTableWithSticky.tsx` .
* 2026-05-19 FlowWidget sticky .
* `frontend/components/screen/widgets/FlowWidget.tsx` .
*
* 2026-05-20 v2-table-list sticky . `variant="v2"` prop
* v2 // , (category/code select, date picker fallback,
* number input), mobile scroll/minWidth, null/undefined/"" "-" (0 ) .
*
* legacy `table-list` / `v2-table-list` / FlowWidget .
* legacy table-list / v2-table-list local type files import . minimal
* `ColumnConfig` export .
* minimal `ColumnConfig` export .
* canonical table runtime `frontend/lib/registry/components/table/TableComponent.tsx` .
*/
import React from "react";
@@ -24,10 +19,6 @@ import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
/**
* SingleTableWithSticky minimal .
*
* legacy table-list / v2-table-list `ColumnConfig`
* type superset . legacy
* .
*/
export interface ColumnConfig {
columnName: string;
@@ -42,11 +33,7 @@ export interface ColumnConfig {
[key: string]: any;
}
export type SingleTableVariant = "default" | "v2";
interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig> {
/** 시각/동작 분기 — 기본은 table-list / FlowWidget 기존 스타일, "v2" 는 v2-table-list 흡수 분기 */
variant?: SingleTableVariant;
visibleColumns?: TColumn[];
columns?: TColumn[];
data: Record<string, any>[];
@@ -62,7 +49,6 @@ interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode;
/** v2 에서는 ReactNode (이미지/JSX) 반환 가능. 기본 호출부는 string 반환해도 ReactNode subset 이라 호환 */
formatCellValue: (
value: any,
format?: string,
@@ -79,19 +65,12 @@ interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig
onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement | null>;
/** v2 전용: Enter/blur 시 저장 콜백 (date picker fallback 포함 통합 저장 경로) */
onEditSave?: () => void;
/** v2 전용: 컬럼별 inputType (select/category/code, number, date, datetime) */
columnMeta?: Record<string, { inputType?: string }>;
/** v2 전용: category/code 컬럼의 옵션 매핑 */
categoryMappings?: Record<string, Record<string, { label: string }>>;
searchHighlights?: Set<string>;
currentSearchIndex?: number;
searchTerm?: string;
}
export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfig>({
variant = "default",
visibleColumns,
columns,
data,
@@ -118,9 +97,6 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
onEditingValueChange,
onEditKeyDown,
editInputRef,
onEditSave,
columnMeta,
categoryMappings,
searchHighlights,
currentSearchIndex = 0,
searchTerm = "",
@@ -131,22 +107,6 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
const sortHandler = onSort || handleSort || (() => {});
const actualData = data || [];
const isV2 = variant === "v2";
// ── 컨테이너/스크롤 분기 (v2 만 mobile scroll + minWidth 적용) ──
const scrollContainerStyle: React.CSSProperties = isV2 ? { WebkitOverflowScrolling: "touch" } : {};
const tableStyle: React.CSSProperties = {
width: "100%",
tableLayout: "auto",
boxSizing: "border-box",
...(isV2 ? { minWidth: `${Math.max(actualColumns.length * 80, 400)}px` } : {}),
};
// ── 헤더 스타일 분기 ──
const headerBaseClass = isV2 ? "border-b border-border/60" : "bg-background border-b";
const headerStyle: React.CSSProperties = isV2 ? { backgroundColor: "hsl(var(--muted) / 0.4)" } : {};
const headerRowClass = isV2 ? "border-b border-border/60" : "border-b";
return (
<div
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
@@ -156,13 +116,19 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
boxSizing: "border-box",
}}
>
<div className="relative flex-1 overflow-auto" style={scrollContainerStyle}>
<Table noWrapper className="w-full" style={tableStyle}>
<div className="relative flex-1 overflow-auto">
<Table
noWrapper
className="w-full"
style={{ width: "100%", tableLayout: "auto", boxSizing: "border-box" }}
>
<TableHeader
className={cn(headerBaseClass, tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
style={headerStyle}
className={cn(
"bg-background border-b",
tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm",
)}
>
<TableRow className={headerRowClass}>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
const leftFixedWidth = actualColumns
.slice(0, colIndex)
@@ -178,20 +144,10 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
const isCheckboxCol = column.columnName === "__checkbox__";
const headCheckboxBaseClass = isV2
? "h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2";
const headDataBaseClass = isV2
? "text-muted-foreground hover:text-foreground h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-[10px] font-bold uppercase tracking-[0.04em] whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-xs"
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm";
const sortableHoverClass = isV2 ? "hover:bg-muted/50" : "hover:bg-primary/10";
// ── 셀 너비 / 헤더 width 분기 (v2 의 checkbox 48px 강제) ──
const checkboxFixedWidth = 48;
const headWidth = isV2 && isCheckboxCol ? checkboxFixedWidth : getColumnWidth(column);
const headMinWidth = isV2 && isCheckboxCol ? "48px" : "100px";
const headMaxWidth = isV2 && isCheckboxCol ? "48px" : "300px";
const headBackground = isV2 ? "hsl(var(--muted) / 0.4)" : "hsl(var(--background))";
const headCheckboxBaseClass =
"bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2";
const headDataBaseClass =
"text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm";
return (
<TableHead
@@ -199,45 +155,33 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
className={cn(
isCheckboxCol ? headCheckboxBaseClass : headDataBaseClass,
`text-${column.align}`,
column.sortable && sortableHoverClass,
column.sortable && "hover:bg-primary/10",
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
style={{
width: headWidth,
minWidth: headMinWidth,
maxWidth: headMaxWidth,
width: getColumnWidth(column),
minWidth: "100px",
maxWidth: "300px",
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: headBackground,
backgroundColor: "hsl(var(--background))",
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className={cn("flex items-center", isV2 && isCheckboxCol ? "justify-center" : "gap-2")}>
<div className="flex items-center gap-2">
{isCheckboxCol ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={
isV2
? {
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isAllSelected
? "hsl(var(--primary))"
: "hsl(var(--muted-foreground) / 0.5)",
zIndex: 1,
}
: { zIndex: 1 }
}
style={{ zIndex: 1 }}
/>
)
) : (
@@ -297,17 +241,10 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
</TableRow>
) : (
actualData.map((row, index) => {
// ── 행 className 분기 (v2 alternate background + hoverEffect 기본 true) ──
const rowClass = isV2
? cn(
"cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
tableConfig?.tableStyle?.hoverEffect !== false && "hover:bg-accent",
)
: cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50",
);
const rowClass = cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50",
);
return (
<TableRow key={`row-${index}`} className={rowClass} onClick={(e) => handleRowClick?.(row, index, e)}>
@@ -345,31 +282,10 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// ── 셀 값 분기 ──
// v2: null/undefined/"" → "-" 표시 (0 은 값 그대로), ReactElement 가능
// default: falsy 면 nbsp fallback
let rawCellValue: React.ReactNode;
let isReactElement = false;
if (isV2) {
const formatted = formatCellValue(row[column.columnName], column.format, column.columnName, row);
if (formatted === null || formatted === undefined || formatted === "") {
rawCellValue = <span className="text-muted-foreground/50">-</span>;
isReactElement = true;
} else {
rawCellValue = formatted;
isReactElement = typeof formatted === "object" && React.isValidElement(formatted);
}
} else {
rawCellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
}
const rawCellValue: React.ReactNode =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || " ";
const renderCellContent = () => {
// ReactElement (v2 의 이미지/JSX) 는 그대로 렌더
if (isReactElement) {
return rawCellValue;
}
if (!isHighlighted || !searchTerm || isCheckboxCol) {
return rawCellValue;
}
@@ -403,37 +319,15 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
);
};
// ── 셀 className 분기 ──
const cellClass = isV2
? cn(
"text-foreground h-10 align-middle text-[11px] transition-colors",
isCheckboxCol ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
!isReactElement && "whitespace-nowrap",
!isCheckboxCol && `text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
onCellDoubleClick && !isCheckboxCol && "cursor-text",
)
: cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
onCellDoubleClick && !isCheckboxCol && "cursor-text",
);
// ── 셀 width/style 분기 (v2 의 checkbox 48px) ──
const cellFixedWidth = isV2 && isCheckboxCol ? 48 : getColumnWidth(column);
const cellMinWidth = isV2 && isCheckboxCol ? "48px" : "100px";
const cellMaxWidth = isV2 && isCheckboxCol ? "48px" : "300px";
const cellOverflowStyle: React.CSSProperties =
isV2 && isReactElement
? {}
: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
const cellClass = cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
onCellDoubleClick && !isCheckboxCol && "cursor-text",
);
return (
<TableCell
@@ -441,11 +335,13 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cellClass}
style={{
width: cellFixedWidth,
minWidth: cellMinWidth,
maxWidth: cellMaxWidth,
width: getColumnWidth(column),
minWidth: "100px",
maxWidth: "300px",
boxSizing: "border-box",
...cellOverflowStyle,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
@@ -459,120 +355,24 @@ export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfi
{isCheckboxCol ? (
renderCheckboxCell?.(row, index)
) : isEditing ? (
isV2 ? (
// ── v2 인라인 편집: inputType 에 따라 select(category/code), date/datetime, number, text ──
(() => {
const meta = columnMeta?.[column.columnName];
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
const isNumeric = inputType === "number" || inputType === "decimal";
const isCategoryType = inputType === "category" || inputType === "code";
const categoryOptions = categoryMappings?.[column.columnName];
const hasCategoryOptions =
isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0;
const commonInputClass =
"border-primary bg-background focus:ring-primary h-8 w-full shrink-0 rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm";
const handleBlurSave = () => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
onEditSave?.();
};
if (hasCategoryOptions) {
const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({
value,
label: info.label,
}));
return (
<select
ref={editInputRef as unknown as React.RefObject<HTMLSelectElement>}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown as unknown as React.KeyboardEventHandler<HTMLSelectElement>}
onBlur={handleBlurSave}
className={cn(commonInputClass, "h-8")}
onClick={(e) => e.stopPropagation()}
>
<option value=""></option>
{selectOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
if (inputType === "date" || inputType === "datetime") {
try {
// 외부 의존 모듈 — runtime require 실패 시 일반 text input 으로 폴백
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
return (
<InlineCellDatePicker
value={editingValue ?? ""}
onChange={(v: string) => onEditingValueChange?.(v)}
onSave={handleBlurSave}
onKeyDown={onEditKeyDown}
inputRef={editInputRef}
/>
);
} catch {
return (
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
onClick={(e) => e.stopPropagation()}
/>
);
}
}
return (
<input
ref={editInputRef}
type={isNumeric ? "number" : "text"}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
style={isNumeric ? { textAlign: "right" } : undefined}
onClick={(e) => e.stopPropagation()}
/>
);
})()
) : (
// ── 기본 인라인 편집: 단순 text input (table-list / FlowWidget 기존 동작) ──
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
)
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,92 +0,0 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { TableListWrapper } from "./V2TableListComponent";
import { fieldsToColumns } from "@/lib/fieldConfig/adapters";
import type { FieldConfig } from "@/types/invyone-component";
/**
* v2-table-list (2026-04-10, Phase 1 Step 6)
*
* ResizeObserver `displayMode` .
* TableListComponent , props.config
* displayMode . CardModeRenderer .
*
* - width >= NARROW_BREAKPOINT wide ( )
* - width < NARROW_BREAKPOINT narrow ( CardModeRenderer)
*
* container-type: inline-size @container .
*
* INVYONE FieldConfig (Phase 1+)
* props.fields: FieldConfig[]
* config.columns .
* (config.columns) . .
*/
const NARROW_BREAKPOINT = 600;
type AnyProps = Record<string, any> & { fields?: FieldConfig[] };
export const TableListContainerWrapper: React.FC<AnyProps> = (props) => {
const rootRef = useRef<HTMLDivElement | null>(null);
const [mode, setMode] = useState<"wide" | "narrow">("wide");
useEffect(() => {
const el = rootRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const apply = (width: number) => {
setMode((prev) => {
const next = width < NARROW_BREAKPOINT ? "narrow" : "wide";
return prev === next ? prev : next;
});
};
apply(el.getBoundingClientRect().width);
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
apply(entry.contentRect.width);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// INVYONE FieldConfig 를 기존 columns 포맷으로 메모이즈 변환.
// fields 가 없거나 빈 배열이면 null → 기존 config.columns 경로 유지.
const derivedColumns = useMemo(() => {
if (!Array.isArray(props.fields) || props.fields.length === 0) return null;
return fieldsToColumns(props.fields);
}, [props.fields]);
const { fields: _fields, ...restProps } = props ?? ({} as AnyProps);
const originalConfig = (restProps?.config ?? {}) as Record<string, any>;
const effectiveConfig: Record<string, any> = (() => {
const base =
mode === "narrow"
? { ...originalConfig, displayMode: "card" }
: originalConfig;
return derivedColumns ? { ...base, columns: derivedColumns } : base;
})();
return (
<div
ref={rootRef}
data-v2-table-list-mode={mode}
style={{
containerType: "inline-size",
containerName: "v2-table-list",
width: "100%",
height: "100%",
}}
>
<TableListWrapper
{...(restProps as any)}
config={effectiveConfig as any}
/>
</div>
);
};
TableListContainerWrapper.displayName = "TableListContainerWrapper";
@@ -1,357 +0,0 @@
import type { ComponentConfig } from "@/types/component";
/**
* Entity
*/
export interface EntityJoinInfo {
sourceTable: string;
sourceColumn: string;
joinAlias: string;
}
/**
*
*/
export type AutoGenerationType =
| "uuid" // UUID 생성
| "current_user" // 현재 사용자 ID
| "current_time" // 현재 시간
| "sequence" // 시퀀스 번호
| "numbering_rule" // 채번 규칙
| "random_string" // 랜덤 문자열
| "random_number" // 랜덤 숫자
| "company_code" // 회사 코드
| "department" // 부서 코드
| "none"; // 자동생성 없음
/**
*
*/
export interface AutoGenerationConfig {
type: AutoGenerationType;
enabled: boolean;
options?: {
length?: number; // 랜덤 문자열/숫자 길이
prefix?: string; // 접두사
suffix?: string; // 접미사
format?: string; // 시간 형식 (current_time용)
startValue?: number; // 시퀀스 시작값
numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용)
};
}
/**
*
*/
export interface ColumnConfig {
columnName: string;
displayName: string;
visible: boolean;
sortable: boolean;
searchable: boolean;
width?: number;
align: "left" | "center" | "right";
format?: "text" | "number" | "date" | "currency" | "boolean";
order: number;
dataType?: string; // 컬럼 데이터 타입 (검색 컬럼 선택에 사용)
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
// 숫자 포맷팅 설정
thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true)
// 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정)
entityDisplayConfig?: {
displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블)
separator?: string; // 구분자 (기본: " - ")
sourceTable?: string; // 기본 테이블명
joinTable?: string; // 조인 테이블명
};
// 컬럼 고정 관련 속성
fixed?: "left" | "right" | false; // 컬럼 고정 위치 (왼쪽, 오른쪽, 고정 안함)
fixedOrder?: number; // 고정된 컬럼들 내에서의 순서
// 새로운 기능들
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
editable?: boolean; // 🆕 편집 가능 여부 (기본값: true, false면 인라인 편집 불가)
// 🆕 inputType — 컬럼 메타로부터 추론된 webType 캐시 (image / file / date 등)
// 런타임에서 columnMeta[col].inputType 또는 column.inputType 으로 읽혀서 cell rendering 분기에 쓰임.
// table shared 이동 시 노출된 타입 누락 보강 (2026-05-20 canonical cleanup).
inputType?: string;
// 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들)
additionalJoinInfo?: {
sourceTable: string; // 원본 테이블
sourceColumn: string; // 원본 컬럼 (예: dept_code)
referenceTable?: string; // 참조 테이블 (예: dept_info)
joinAlias: string; // 조인 별칭 (예: dept_code_company_name)
};
}
/**
*
*/
export interface CardDisplayConfig {
idColumn: string; // ID 컬럼 (사번 등)
titleColumn: string; // 제목 컬럼 (이름 등)
subtitleColumn?: string; // 부제목 컬럼 (부서 등)
descriptionColumn?: string; // 설명 컬럼
imageColumn?: string; // 이미지 컬럼
cardsPerRow: number; // 한 행당 카드 수 (기본: 3)
cardSpacing: number; // 카드 간격 (기본: 16px)
showActions: boolean; // 액션 버튼 표시 여부
cardHeight?: number; // 카드 높이 (기본: auto)
}
/**
*
*/
export interface FilterConfig {
enabled: boolean;
// 사용할 필터 목록 (DataTableFilter 타입 사용)
filters: Array<{
columnName: string;
widgetType: string;
label: string;
gridColumns: number;
numberFilterMode?: "exact" | "range"; // 숫자 필터 모드
codeInfo?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
// 필터와 리스트 사이 간격 (px 단위, 기본: 40)
bottomSpacing?: number;
}
/**
*
*/
export interface ActionConfig {
showActions: boolean;
actions: Array<{
type: "view" | "edit" | "delete" | "custom";
label: string;
icon?: string;
color?: string;
confirmMessage?: string;
targetScreen?: string;
}>;
bulkActions: boolean;
bulkActionList: string[];
}
/**
*
*/
export interface TableStyleConfig {
theme: "default" | "striped" | "bordered" | "minimal";
headerStyle: "default" | "dark" | "light";
rowHeight: "compact" | "normal" | "comfortable";
alternateRows: boolean;
hoverEffect: boolean;
borderStyle: "none" | "light" | "heavy";
}
/**
*
*/
export interface PaginationConfig {
enabled: boolean;
pageSize: number;
showSizeSelector: boolean;
showPageInfo: boolean;
pageSizeOptions: number[];
}
/**
*
*/
export interface ToolbarConfig {
showEditMode?: boolean; // 즉시 저장/배치 모드 버튼
showExcel?: boolean; // Excel 내보내기 버튼
showPdf?: boolean; // PDF 내보내기 버튼
showCopy?: boolean; // 복사 버튼
showSearch?: boolean; // 검색 버튼
showFilter?: boolean; // 필터 버튼
showRefresh?: boolean; // 상단 툴바 새로고침 버튼
showPaginationRefresh?: boolean; // 하단 페이지네이션 새로고침 버튼
}
/**
*
*/
export interface CheckboxConfig {
enabled: boolean; // 체크박스 활성화 여부
multiple: boolean; // 다중 선택 가능 여부 (true: 체크박스, false: 라디오)
position: "left" | "right"; // 체크박스 위치
selectAll: boolean; // 전체 선택/해제 버튼 표시 여부
}
/**
*
* ( )
*/
export interface LinkedFilterConfig {
sourceComponentId: string; // 소스 컴포넌트 ID (셀렉트박스 등)
sourceField?: string; // 소스 컴포넌트에서 가져올 필드명 (기본: value)
targetColumn: string; // 필터링할 테이블 컬럼명
operator?: "equals" | "contains" | "in"; // 필터 연산자 (기본: equals)
enabled?: boolean; // 활성화 여부 (기본: true)
}
/**
*
*
* : 거래처에
*/
export interface ExcludeFilterConfig {
enabled: boolean; // 제외 필터 활성화 여부
referenceTable: string; // 참조 테이블 (예: customer_item_mapping)
referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id)
sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number)
filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id)
filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url)
filterValueField?: string; // 필터 값 필드명 (예: customer_code)
}
/**
* TableList
*/
import type { DataFilterConfig } from "@/types/screen-management";
export interface TableListConfig extends ComponentConfig {
// 표시 모드 설정
displayMode?: "table" | "card"; // 기본: "table"
// 카드 디스플레이 설정 (displayMode가 "card"일 때 사용)
cardConfig?: CardDisplayConfig;
// 테이블 기본 설정
selectedTable?: string;
tableName?: string;
title?: string;
showHeader: boolean;
showFooter: boolean;
// 🆕 커스텀 테이블 설정 (화면 메인 테이블과 다른 테이블 사용 시)
customTableName?: string; // 컴포넌트가 사용할 커스텀 테이블명
useCustomTable?: boolean; // true면 customTableName 사용, false면 화면 메인 테이블 사용
isReadOnly?: boolean; // 읽기전용 여부 (조회용 테이블인 경우 true)
// 체크박스 설정
checkbox: CheckboxConfig;
// 높이 설정
height: "auto" | "fixed" | "viewport";
fixedHeight?: number;
// 컬럼 설정
columns: ColumnConfig[];
autoWidth: boolean;
stickyHeader: boolean;
// 가로 스크롤 및 컬럼 고정 설정
horizontalScroll: {
enabled: boolean; // 가로 스크롤 활성화 여부
maxVisibleColumns?: number; // 스크롤 없이 표시할 최대 컬럼 수 (이 수를 넘으면 가로 스크롤)
minColumnWidth?: number; // 컬럼 최소 너비 (px)
maxColumnWidth?: number; // 컬럼 최대 너비 (px)
};
// 페이지네이션
pagination: PaginationConfig & {
currentPage?: number; // 현재 페이지 (추가)
};
// 필터 설정
filter: FilterConfig;
// 액션 설정
actions: ActionConfig;
// 스타일 설정
tableStyle: TableStyleConfig;
// 데이터 로딩
autoLoad: boolean;
refreshInterval?: number; // 초 단위
// 🆕 기본 정렬 설정
defaultSort?: {
columnName: string; // 정렬할 컬럼명
direction: "asc" | "desc"; // 정렬 방향
};
// 🆕 툴바 버튼 표시 설정
toolbar?: ToolbarConfig;
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
linkedFilters?: LinkedFilterConfig[];
// 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
excludeFilter?: ExcludeFilterConfig;
// 이벤트 핸들러
onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void;
onSelectionChange?: (selectedRows: any[]) => void;
onPageChange?: (page: number, pageSize: number) => void;
onSortChange?: (column: string, direction: "asc" | "desc") => void;
onFilterChange?: (filters: any) => void;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
}
/**
*
*/
export interface TableDataResponse {
data: any[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
columns?: Array<{
name: string;
type: string;
nullable: boolean;
}>;
}
/**
* TableList Props
*/
export interface TableListProps {
id?: string;
config?: TableListConfig;
className?: string;
style?: React.CSSProperties;
// 데이터 관련
data?: any[];
loading?: boolean;
error?: string;
// 이벤트 핸들러
onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void;
onSelectionChange?: (selectedRows: any[]) => void;
onPageChange?: (page: number, pageSize: number) => void;
onSortChange?: (column: string, direction: "asc" | "desc") => void;
onFilterChange?: (filters: any) => void;
onRefresh?: () => void;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
}
@@ -0,0 +1,530 @@
"use client";
/**
* Phase D.5 (2026-05-20) canonical TableComponent helper.
*
* plain `String(row[col.key])` .
*
* :
* - image (TableCellImage)
* - file (TableCellFile)
* - entity (entityDisplayConfig)
* - number / date / boolean / currency
*
* `TableCellImage` / `TableCellFile` / `formatCellValue`
* canonical .
*/
import React, { useEffect, useState } from "react";
import { FileText } from "lucide-react";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import {
formatDate as centralFormatDate,
formatNumber as centralFormatNumber,
formatCurrency as centralFormatCurrency,
} from "@/lib/formatting";
import type { TableColumn } from "./types";
// ───────────────────────────────────────────────────────
// TableCellImage — 이미지 썸네일 셀
// ───────────────────────────────────────────────────────
export interface TableCellImageProps {
value: string;
isDesignMode?: boolean;
}
export const TableCellImage: React.FC<TableCellImageProps> = React.memo(
({ value, isDesignMode }) => {
const [imgSrc, setImgSrc] = useState<string | null>(null);
const [displayObjid, setDisplayObjid] = useState<string>("");
const [error, setError] = useState(false);
useEffect(() => {
let mounted = true;
setError(false);
const rawValue = String(value || "").trim();
if (!rawValue) {
setImgSrc(null);
setError(true);
return () => {
mounted = false;
};
}
const parts = rawValue.split(",").map((s) => s.trim()).filter(Boolean);
const first = parts[0] || rawValue;
setDisplayObjid(first);
const isObjid = /^\d+$/.test(first);
if (isDesignMode) {
// 디자인 모드: remote lookup 안 함. path 만 직접 src 로.
if (!isObjid) {
setImgSrc(getFullImageUrl(first));
} else {
setImgSrc(null);
}
return () => {
mounted = false;
};
}
if (isObjid) {
setImgSrc(getFilePreviewUrl(first));
} else {
setImgSrc(getFullImageUrl(first));
}
return () => {
mounted = false;
};
}, [value, isDesignMode]);
if (error || !imgSrc) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return (
<img
src={imgSrc}
alt=""
style={{
maxWidth: 36,
maxHeight: 36,
borderRadius: 3,
objectFit: "cover",
cursor: isDesignMode ? "default" : "pointer",
verticalAlign: "middle",
}}
onClick={(e) => {
if (isDesignMode) return;
e.stopPropagation();
const isObjid = /^\d+$/.test(displayObjid);
const openUrl = isObjid
? getFilePreviewUrl(displayObjid)
: getFullImageUrl(displayObjid);
if (typeof window !== "undefined") {
window.open(openUrl, "_blank");
}
}}
onError={() => setError(true)}
/>
);
},
);
TableCellImage.displayName = "TableCellImage";
// ───────────────────────────────────────────────────────
// TableCellFile — 파일 이름 + 다운로드 셀
// ───────────────────────────────────────────────────────
interface FileInfo {
objid: string;
name: string;
ext?: string;
}
function _parseFileValue(raw: string): FileInfo[] {
const trimmed = String(raw || "").trim();
if (!trimmed || trimmed === "-") return [];
// JSON 배열 시도
if (trimmed.startsWith("[")) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
return parsed.map((f: any) => ({
objid: String(f?.objid ?? f?.id ?? ""),
name: String(
f?.realFileName ?? f?.real_file_name ?? f?.name ?? "파일",
),
ext: String(f?.fileExt ?? f?.file_ext ?? "") || undefined,
}));
}
} catch {
/* fall through */
}
}
// 콤마 구분
const parts = trimmed.split(",").map((s) => s.trim()).filter(Boolean);
return parts.map((p) => ({
objid: /^\d+$/.test(p) ? p : "",
name: /^\d+$/.test(p) ? p : p.split("/").pop() || p,
}));
}
export interface TableCellFileProps {
value: string;
isDesignMode?: boolean;
}
export const TableCellFile: React.FC<TableCellFileProps> = React.memo(
({ value, isDesignMode }) => {
const [files, setFiles] = useState<FileInfo[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
setLoading(true);
const parsed = _parseFileValue(value);
if (parsed.length === 0) {
setFiles([]);
setLoading(false);
return () => {
mounted = false;
};
}
// JSON 으로 이미 이름이 있는 경우는 즉시 표시.
const hasNames = parsed.every((f) => f.name && f.name !== f.objid);
if (hasNames || isDesignMode) {
setFiles(parsed);
setLoading(false);
return () => {
mounted = false;
};
}
// 디자인 모드 X + objid 만 있음 → 비동기로 파일명 lookup (대표 1개만 — 보수적)
const objids = parsed.map((f) => f.objid).filter(Boolean);
if (objids.length === 0) {
setFiles(parsed);
setLoading(false);
return () => {
mounted = false;
};
}
(async () => {
try {
const { getFileInfoByObjid } = await import("@/lib/api/file");
const lookups = await Promise.all(
objids.map(async (oid) => {
try {
const info = await getFileInfoByObjid(oid);
if (info?.success && info.data) {
// backend snake_case + 임의 camelCase fallback 흡수
const d = info.data as any;
return {
objid: oid,
name:
d.real_file_name ||
d.realFileName ||
d.name ||
oid,
ext: d.file_ext || d.fileExt,
};
}
} catch {
/* noop */
}
return { objid: oid, name: oid };
}),
);
if (mounted) {
setFiles(lookups);
setLoading(false);
}
} catch {
if (mounted) {
setFiles(parsed);
setLoading(false);
}
}
})();
return () => {
mounted = false;
};
}, [value, isDesignMode]);
if (loading) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
...
</span>
);
}
if (files.length === 0) {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
// 단일 파일 inline link
if (files.length === 1) {
const f = files[0];
const url = f.objid && /^\d+$/.test(f.objid)
? getFilePreviewUrl(f.objid)
: null;
const content = (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: 12,
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={f.name}
>
<FileText size={12} style={{ flexShrink: 0 }} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{f.name}
</span>
</span>
);
if (url && !isDesignMode) {
return (
<a
href={url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
style={{
color: "hsl(var(--primary))",
textDecoration: "none",
}}
>
{content}
</a>
);
}
return content;
}
// 다중 — count 표시 + tooltip 으로 이름들
const allNames = files.map((f) => f.name).join(", ");
return (
<span
title={allNames}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: 12,
}}
>
<FileText size={12} />
<span>{files[0].name}</span>
<span
style={{
fontSize: 10,
color: "hsl(var(--muted-foreground))",
}}
>
{files.length - 1}
</span>
</span>
);
},
);
TableCellFile.displayName = "TableCellFile";
// ───────────────────────────────────────────────────────
// entityDisplayConfig 적용
// ───────────────────────────────────────────────────────
/**
* entity `entityDisplayConfig.displayColumns`
* row separator (`" - "` ) join.
*
* Key fallback ( displayColumn ):
* 1. `${column.key}_${displayColumn}`
* 2. `${entityJoinInfo?.sourceColumn}_${displayColumn}`
* 3. `${entityDisplayConfig?.joinTable}_${displayColumn}`
* 4. `${entityJoinInfo?.joinAlias}_${displayColumn}`
* 5. direct `displayColumn`
* 6. `displayColumn` `.` segment
*
* join null ( fallback ).
*/
function _applyEntityDisplayConfig(
column: TableColumn,
row: Record<string, any>,
): string | null {
const cfg = column.entityDisplayConfig;
if (!cfg) return null;
const displayColumns: string[] = Array.isArray(cfg.displayColumns)
? cfg.displayColumns
: Array.isArray((cfg as any).selectedColumns)
? ((cfg as any).selectedColumns as string[])
: [];
if (displayColumns.length === 0) return null;
const separator = cfg.separator || " - ";
const sourceColumn = column.entityJoinInfo?.sourceColumn;
const joinAlias = column.entityJoinInfo?.joinAlias;
const joinTable = cfg.joinTable;
const values: string[] = [];
for (const dc of displayColumns) {
const candidates: string[] = [
`${column.key}_${dc}`,
sourceColumn ? `${sourceColumn}_${dc}` : "",
joinTable ? `${joinTable}_${dc}` : "",
joinAlias ? `${joinAlias}_${dc}` : "",
dc,
].filter(Boolean);
if (dc.includes(".")) {
const last = dc.split(".").pop();
if (last) candidates.push(last);
}
let found: any = undefined;
for (const k of candidates) {
const v = row[k];
if (v !== undefined && v !== null && v !== "") {
found = v;
break;
}
}
if (found !== undefined && found !== null && found !== "") {
values.push(String(found));
}
}
const joined = values.join(separator).trim();
return joined.length > 0 ? joined : null;
}
// ───────────────────────────────────────────────────────
// renderTableCellValue — canonical 셀 디스플레이 진입점
// ───────────────────────────────────────────────────────
export interface RenderTableCellArgs {
value: any;
column: TableColumn;
row: Record<string, any>;
isDesignMode?: boolean;
}
export function renderTableCellValue(args: RenderTableCellArgs): React.ReactNode {
const { value, column, row, isDesignMode } = args;
// 1) entityDisplayConfig 우선 — value 가 비어도 다른 row 키에 표시값 있을 수 있음.
const entityDisplay = _applyEntityDisplayConfig(column, row);
if (entityDisplay) {
return entityDisplay;
}
// 2) image 셀
const isImage =
column.inputType === "image" || column.format === "image";
if (isImage) {
if (value === null || value === undefined || value === "") {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return <TableCellImage value={String(value)} isDesignMode={isDesignMode} />;
}
// 3) file / attachment 셀
const keyLower = (column.key || "").toLowerCase();
const looksLikeFileKey =
keyLower.includes("attachment") ||
/(^|[_-])files?($|[_-])/.test(keyLower);
const isFile =
column.inputType === "file" ||
column.inputType === "attachment" ||
column.format === "file" ||
column.format === "attachment" ||
looksLikeFileKey;
if (isFile) {
if (value === null || value === undefined || value === "") {
return (
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
-
</span>
);
}
return <TableCellFile value={String(value)} isDesignMode={isDesignMode} />;
}
// 4) null/empty
if (value === null || value === undefined || value === "") {
return isDesignMode ? (
<span style={{ color: "hsl(var(--muted-foreground))" }}>...</span>
) : (
""
);
}
// 5) boolean
const isBoolean =
column.format === "boolean" || column.inputType === "checkbox";
if (isBoolean) {
const v = value;
const truthy =
v === true ||
v === 1 ||
v === "1" ||
v === "Y" ||
v === "y" ||
v === "true" ||
v === "TRUE";
return truthy ? "예" : "아니오";
}
// 6) date / datetime
const isDate =
column.inputType === "date" ||
column.inputType === "datetime" ||
column.format === "date" ||
column.format === "datetime";
if (isDate) {
try {
const formatted = centralFormatDate(value, "display");
if (formatted && formatted !== String(value)) return formatted;
} catch {
/* fall through to default */
}
// fallback: ISO date 의 앞 10 자리
const s = String(value);
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
return s;
}
// 7) currency
if (column.format === "currency") {
try {
return centralFormatCurrency(value);
} catch {
const n = Number(value);
if (Number.isFinite(n)) return `${n.toLocaleString("ko-KR")}`;
}
return String(value);
}
// 8) number / decimal
const isNumber =
column.inputType === "number" ||
column.inputType === "decimal" ||
column.format === "number";
if (isNumber) {
const n = Number(value);
if (!Number.isFinite(n)) return String(value);
if (column.thousandSeparator === false) {
return String(n);
}
try {
return centralFormatNumber(value);
} catch {
return n.toLocaleString("ko-KR");
}
}
// 9) default — plain string
return String(value);
}
@@ -7,19 +7,18 @@ import { InvTableConfigPanel } from "./InvTableConfigPanel";
import type { TableConfig } from "./types";
/**
* Table (2026-04-11, Phase C-1)
* Table canonical data table component
*
* (9):
* - v2-table-list (base)
* - v2-table-grouped (displayMode='grouped')
* - v2-pivot-grid (displayMode='pivot')
* - v2-split-panel-layout (displayMode='split')
* - table-list, split-panel-layout, split-panel-layout2 (legacy)
* - modal-repeater-table, simple-repeater-table (legacy)
* - pivot-grid, tax-invoice-list (legacy)
* . 5 displayMode :
* - table ( )
* - split ( master-detail)
* - grouped ()
* - pivot ( )
* - card ( , fallback)
*
* :
* notes/gbpark/2026-04-11-component-unification-plan.md §3.1
* notes/gbpark/2026-05-20-table-canonical-cleanup-plan.md
*/
const DEFAULT_CONFIG: Partial<TableConfig> = {
+388 -14
View File
@@ -2,21 +2,17 @@
import { ComponentConfig } from "@/types/component";
import type { FieldConfig } from "@/types/invyone-component";
import type { DataFilterConfig } from "@/types/screen-management";
import type { AutoGenerationConfig } from "@/types/screen";
/**
* Table
*
* 9 ** **.
* displayMode (///).
* ** **.
* displayMode (////).
*
* (9):
* - v2-table-list (base, )
* - v2-table-grouped ( )
* - v2-pivot-grid ( )
* - v2-split-panel-layout ( 분할: 목록 | )
* - table-list, split-panel-layout, split-panel-layout2 (legacy)
* - modal-repeater-table, simple-repeater-table (legacy)
* - tax-invoice-list, pivot-grid (legacy)
* (table-list / v2-table-list / split-panel-layout ) Phase F
* canonical TableComponent . // .
*/
export type TableDisplayMode = "table" | "split" | "grouped" | "pivot" | "card";
@@ -36,15 +32,271 @@ export interface TableColumn {
sortable?: boolean;
/** 포맷 */
format?: string;
/** 표시 여부 */
/** 표시 여부 (false 면 운영/디자인 모두 안 보임) */
visible?: boolean;
// ─── Phase C.2 (2026-05-20) — legacy ColumnConfig 풀 옵션 흡수 ───
/**
* / sticky . `false` = .
* sticky CSS / scroll D.1 ( ) wiring. C.2 config .
*/
fixed?: "left" | "right" | false;
/**
* fixed (fixed = "left" "right" ).
* columns . D.1 wiring.
*/
fixedOrder?: number;
/**
* 릿, .
* `visible: false` (visible ). D.1 wiring.
*/
hidden?: boolean;
/**
* / .
* : `text` / `number` / `date` / `datetime` / `select` / `entity` / `checkbox` /
* `textarea` / `file` / `image` / `code`.
* `format` backend . D.3 ( ) / D.5 ( ) wiring.
*/
inputType?: string;
/**
* ( true). false .
* D.3 wiring. C.2 config .
*/
editable?: boolean;
/**
* ( true). `inputType === "number"`
* `format === "number" | "currency"` . D.5 ( ) wiring.
*/
thousandSeparator?: boolean;
/**
* / . C.3 () wiring.
*/
searchable?: boolean;
/**
* ( ). columns .
* D.1 wiring ( set).
*/
order?: number;
/**
* backend (string / number / date / boolean).
* , . C.3 / D.2 wiring.
*/
dataType?: string;
// ─── entity 조인 / 다중 표시 메타 (D.5 wiring, C.2 는 보존만) ───
/** entity 조인 컬럼 여부 */
isEntityJoin?: boolean;
/** entity 조인 상세 (sourceTable / sourceColumn / joinAlias) */
entityJoinInfo?: {
sourceTable: string;
sourceColumn: string;
joinAlias: string;
};
/**
* entity .
* D.5 .
*/
entityDisplayConfig?: {
displayColumns: string[];
separator?: string;
sourceTable?: string;
joinTable?: string;
};
/** 조인 탭에서 추가한 컬럼의 원본/참조 정보 */
additionalJoinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable?: string;
joinAlias: string;
};
// ─── Phase D.5 (2026-05-20) — 다국어 라벨 ───
/** 헤더 라벨 다국어 키 — `useScreenMultiLang().getTranslatedText(langKey, label)` 로 번역 */
langKey?: string;
/** `lang_keys` 테이블 PK — 일부 화면 메타가 langKey 와 함께 보존 */
langKeyId?: number;
// ─── Phase D.10 (2026-05-20) — 자동생성 메타 (legacy ColumnConfig.autoGeneration 흡수) ───
/**
* (uuid / current_user / current_time / sequence / numbering_rule /
* random_string / random_number / company_code / department).
*
* Phase D.10 ** **. canonical `TableComponent` persisted
* new-row create path runtime . row-create
* `AutoGenerationUtils.generateValue()` . `numbering_rule` real
* code render / fetch / receive phase .
*/
autoGeneration?: AutoGenerationConfig;
}
export interface TablePagination {
enabled?: boolean;
pageSize?: number;
/** 페이지 크기 변경 드롭다운 노출 (D.6 footer 에서 wiring) */
showSizeSelector?: boolean;
/** "1-20 / 총 100건" 식 페이지 정보 텍스트 노출 (D.6) */
showPageInfo?: boolean;
/** 사용자가 선택할 수 있는 페이지 크기 옵션 (예: [10, 20, 50, 100]) */
pageSizeOptions?: number[];
/** 현재 페이지 (런타임 상태 미러링용 — useTableData 가 권위) */
currentPage?: number;
/** 페이지네이션 위치 — D.6 footer/header 분리 시 사용 */
position?: "top" | "bottom";
}
/**
* Phase C.5 (legacy `TableStyleConfig` )
*
* `alternateRows` / `hoverEffect` root `striped` / `hoverable` .
* ConfigPanel UI "스타일" CPGroup
* alias. runtime : `tableStyle.*` root `striped/hoverable` default.
*
* `theme` / `headerStyle` / `borderStyle` D.6 ( ) wiring.
*/
export interface TableStyleConfig {
theme?: "default" | "striped" | "bordered" | "minimal";
headerStyle?: "default" | "dark" | "light";
borderStyle?: "none" | "light" | "heavy";
/** root `striped` 별칭 (Phase C.5) — runtime 우선순위가 높음 */
alternateRows?: boolean;
/** root `hoverable` 별칭 (Phase C.5) — runtime 우선순위가 높음 */
hoverEffect?: boolean;
}
/**
* Phase C.3 (2026-05-20) (legacy `FilterConfig` ).
*
* / . `widgetType`
* UI (`text` / `number` / `date` / `select` / `entity` / `code` / `checkbox`) .
*
* C.3 config + ConfigPanel UI . + Phase D.2.
*/
export interface TableFilterConfig {
enabled: boolean;
filters: Array<{
columnName: string;
widgetType: string;
label: string;
gridColumns: number;
/** number 위젯일 때 단일 입력 / 범위 (min~max) 분기 */
numberFilterMode?: "exact" | "range";
/** code 위젯의 공통코드 그룹 키 */
codeInfo?: string;
/** entity / select 위젯의 참조 테이블 메타 */
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
/** 필터 영역과 리스트 사이 간격 (px) */
bottomSpacing?: number;
}
/**
* Phase C.3 (2026-05-20) (legacy `LinkedFilterConfig` ).
*
* ( / / )
* . : 거래처 select customer_code .
*
* C.3 config + ConfigPanel . source / Phase D.2.
*/
export interface TableLinkedFilterConfig {
sourceComponentId: string;
sourceField?: string;
targetColumn: string;
operator?: "equals" | "contains" | "in";
enabled?: boolean;
}
/**
* Phase C.3 (2026-05-20) (legacy `ExcludeFilterConfig` ).
*
* row . : 거래처에
* .
*
* C.3 config + ConfigPanel . SQL `NOT EXISTS` sub-query Phase D.2.
*/
export interface TableExcludeFilterConfig {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValueSource?: "url" | "formData" | "parentData";
filterValueField?: string;
}
/**
* Phase C.4 (2026-05-20) (legacy `ActionConfig.actions[].type` ).
*
* `view` / `edit` (`targetScreen`) open, `delete` API,
* `custom` . wiring Phase D.4.
*/
export type TableActionType = "view" | "edit" | "delete" | "custom";
/**
* Phase C.4 (legacy `ActionConfig.actions[]` row ).
*
* . button + click (navigation /
* modal open / delete API / custom handler) Phase D.4 .
*/
export interface TableActionItemConfig {
type: TableActionType;
label: string;
/** lucide icon 이름 (예: "Eye" / "Pencil" / "Trash2") */
icon?: string;
/** primary / destructive / muted 등 의미 토큰 또는 hsl 값 */
color?: string;
/** delete 등 위험 액션의 확인 메시지 */
confirmMessage?: string;
/** view/edit 의 화면 이동 대상 (예: "/screen/123") */
targetScreen?: string;
}
/**
* Phase C.4 (legacy `ActionConfig` ).
*
* **Table-level row/bulk config.**
* (`cardStyle.showActions` / `cardStyle.show{View,Edit,Delete}Button` /
* `cardColumnMapping.actionColumns`) ** layer**.
* , `TableActionConfig` displayMode .
*
* C.4 config + ConfigPanel . + click + bulk
* wiring Phase D.4.
*/
export interface TableActionConfig {
/** 액션 컬럼 표시 여부 — D.4 에서 행 액션 영역 렌더 분기 */
showActions: boolean;
/** 각 행에 표시될 액션 버튼 목록 — 순서대로 렌더 (D.4) */
actions: TableActionItemConfig[];
/** 일괄 액션 영역 표시 여부 — 선택된 행들에 대한 일괄 작업 (D.4) */
bulkActions: boolean;
/** 일괄 액션 종류 목록 (예: ["delete", "export"]) — D.4 에서 매핑 */
bulkActionList: string[];
}
/**
* Phase C.5 (legacy `ToolbarConfig` )
*
* `showExcel` / `showRefresh` root (UI ). 6 D.6
* + wiring C.5 config .
*/
export interface TableToolbarConfig {
/** 즉시 저장 / 배치 모드 토글 (D.6) */
showEditMode?: boolean;
/** Excel 내보내기 (XLSX, D.6 dynamic import). root `showExcel` 의 별칭 */
showExcel?: boolean;
/** PDF 내보내기 (D.6) */
showPdf?: boolean;
/** 셀 / 행 복사 (clipboard, D.6) */
showCopy?: boolean;
/** 자체 검색바 (D.6) — 별도 Search 컴포넌트 사용 시 OFF */
showSearch?: boolean;
/** 자체 필터 패널 (D.6) */
showFilter?: boolean;
/** 상단 새로고침 버튼. root `showRefresh` 의 별칭 */
showRefresh?: boolean;
/** 페이지네이션 새로고침 버튼 (D.6 footer 영역) */
showPaginationRefresh?: boolean;
}
// ─── card 모드 보조 타입 ─────────────────────────────────────────────
@@ -61,6 +313,8 @@ export interface TableCardStyleConfig {
showViewButton?: boolean;
showEditButton?: boolean;
showDeleteButton?: boolean;
/** Phase D.7 — 카드 고정 높이. `"auto"` 또는 미지정이면 content 에 맞춤 */
cardHeight?: number | "auto";
}
export interface TableCardColumnMapping {
@@ -72,6 +326,8 @@ export interface TableCardColumnMapping {
displayColumns?: string[];
/** 액션 버튼 셀로 표시할 컬럼들 */
actionColumns?: string[];
/** Phase D.7 — 카드 헤더 우측 작은 ID 배지에 사용 (legacy cardConfig.idColumn 흡수) */
idColumn?: string;
}
// ─── pivot 모드 풍부한 필드 정의 ───────────────────────────────────
@@ -337,8 +593,33 @@ export interface PivotGridProps {
}
export interface TableConfig extends ComponentConfig {
/** 연결된 테이블명 (DB) */
/** 연결된 테이블명 (DB). canonical 정규 키. */
selectedTable?: string;
/**
* layout JSON `tableName` fallback. canonical
* `selectedTable` , `tableName` fallback. ConfigPanel UI .
*/
tableName?: string;
/**
* (`screenTableName`) .
* true `customTableName` effective table . false
* `selectedTable` ( ) . (Phase C.1)
*/
useCustomTable?: boolean;
/** `useCustomTable === true` 일 때 effective table 로 쓰이는 테이블명. (Phase C.1) */
customTableName?: string;
/**
* ( ).
* / / UX . readonly
* Phase D.3 ( ) . config + UI . (Phase C.1)
*/
isReadOnly?: boolean;
/**
* ( true).
* false `useTableData` enabled=false fetch .
* search / `refreshTrigger` DataPort Phase D.6 wiring. (Phase C.1)
*/
autoLoad?: boolean;
/** 표시 모드 (기본/분할/그룹/피벗/카드) */
displayMode?: TableDisplayMode;
/** 컬럼 설정 */
@@ -405,6 +686,13 @@ export interface TableConfig extends ComponentConfig {
cardStyle?: TableCardStyleConfig;
/** card 모드: 데이터 컬럼 → 카드 영역 매핑 */
cardColumnMapping?: TableCardColumnMapping;
/**
* Phase D.7 fallback (px).
* number: displayMode=table card (split/grouped/pivot X)
* `false` `0`: fallback
* 미지정: 기본 600
*/
responsiveCardBreakpoint?: number | false;
// ─── 빈 상태 / 로딩 ───
/** 빈 상태 메시지 */
@@ -413,8 +701,94 @@ export interface TableConfig extends ComponentConfig {
// ─── 툴바 ───
/** 툴바 표시 */
showToolbar?: boolean;
/** 엑셀 내보내기 버튼 */
/** 엑셀 내보내기 버튼 (legacy root key, `toolbar.showExcel` 와 alias) */
showExcel?: boolean;
/** 새로고침 버튼 */
/** 새로고침 버튼 (legacy root key, `toolbar.showRefresh` 와 alias) */
showRefresh?: boolean;
// ─── Phase C.5 (2026-05-20) — 스타일 / 툴바 / 데이터 동작 확장 ───
/**
* . `alternateRows` / `hoverEffect` root `striped` / `hoverable`
* ConfigPanel "스타일" CPGroup . runtime :
* `tableStyle.alternateRows` `striped` true / `tableStyle.hoverEffect` `hoverable` true.
* theme / headerStyle / borderStyle D.6 wiring.
*/
tableStyle?: TableStyleConfig;
/**
* 8 . `showExcel` / `showRefresh` root . 6 (`showEditMode` /
* `showPdf` / `showCopy` / `showSearch` / `showFilter` / `showPaginationRefresh`)
* D.6 + wiring. C.5 config .
*/
toolbar?: TableToolbarConfig;
/**
* useTableData initialSortBy / initialSortOrder .
* (state). (Phase C.5)
*/
defaultSort?: {
columnName: string;
direction: "asc" | "desc";
};
/**
* ( , > 0 ). useEffect setInterval
* `tableData.refresh()` . skip. (Phase C.5)
*/
refreshInterval?: number;
// ─── Phase D.1 (2026-05-20) — 컬럼 시스템 runtime 옵션 (legacy `TableListConfig` 흡수) ───
/**
* / minColumnWidth fallback (content-fit)
* . false. sticky offset fallback . (Phase D.1)
*/
autoWidth?: boolean;
/**
* sticky ( true canonical ). false .
* (Phase D.1)
*/
stickyHeader?: boolean;
/**
* + . `enabled` true `<table>` `minWidth`
* effective `maxVisibleColumns * minColumnWidth`
* . `maxVisibleColumns` ** cap **
* ( ) . (Phase D.1)
*/
horizontalScroll?: {
enabled?: boolean;
maxVisibleColumns?: number;
minColumnWidth?: number;
maxColumnWidth?: number;
};
// ─── Phase C.3 (2026-05-20) — 필터 config parity (runtime 적용은 Phase D.2) ───
/**
* ( ) .
* C.3 ConfigPanel . AdvancedSearchFilters + `tableData.search` D.2.
*/
filter?: TableFilterConfig;
/**
* ( ) .
* C.3 config . source + search params D.2.
*/
linkedFilters?: TableLinkedFilterConfig[];
/**
* row (sub-query / NOT EXISTS).
* C.3 config . / API D.2.
*/
excludeFilter?: TableExcludeFilterConfig;
/**
* `DataFilterConfig` 14 operator (equals / in / between /
* date_range_contains ) `match_type` (all/any) row .
* `screen-management.ts` `DataFilterConfig` / `ColumnFilter` (snake_case ).
* C.3 config + ConfigPanel . D.2.
*/
dataFilter?: DataFilterConfig;
// ─── Phase C.4 (2026-05-20) — 액션 config parity (runtime 적용은 Phase D.4) ───
/**
* Table-level row/bulk view/edit/delete/custom row + .
* (`cardStyle.showActions` / `cardStyle.show{View,Edit,Delete}Button` /
* `cardColumnMapping.actionColumns`) ** layer** ( vs ).
* C.4 config + ConfigPanel . + click (navigation / modal /
* delete API / custom handler) + bulk wiring Phase D.4.
*/
actions?: TableActionConfig;
}
@@ -1,14 +1,28 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { DataFilterConfig } from "@/types/screen-management";
/**
* useTableData table fetch
*
* entityJoinApi.getTableDataWithJoins() .
* , , .
*
* Phase D.2 (2026-05-20) `dataFilter` / `excludeFilter` entityJoinApi .
* ref memoize
* stable JSON string dep ref fetch .
*/
export interface ExcludeFilterPayload {
enabled: boolean;
referenceTable: string;
referenceColumn: string;
sourceColumn: string;
filterColumn?: string;
filterValue?: any;
}
export interface UseTableDataParams {
tableName?: string;
page?: number;
@@ -17,6 +31,10 @@ export interface UseTableDataParams {
sortOrder?: "asc" | "desc";
search?: Record<string, any>;
enabled?: boolean; // false면 fetch 안 함 (디자인 모드)
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
dataFilter?: DataFilterConfig;
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
excludeFilter?: ExcludeFilterPayload;
}
export interface UseTableDataResult {
@@ -36,6 +54,11 @@ export interface UseTableDataResult {
toggleSort: (col: string) => void;
setSearch: (s: Record<string, any>) => void;
refresh: () => void;
/**
* Phase D.9 (2026-05-20) DataReceivable.receiveData() local data override.
* append/replace/merge . fetch refresh . totalOverride length .
*/
setLocalData: (next: Record<string, any>[], totalOverride?: number) => void;
}
export function useTableData(params: UseTableDataParams): UseTableDataResult {
@@ -47,8 +70,21 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
sortOrder: initialSortOrder = "desc",
search: externalSearch,
enabled = true,
dataFilter,
excludeFilter,
} = params;
// D.2 — dataFilter / excludeFilter 객체 ref 가 매 렌더마다 신규여도 dep 으로 안 잡히도록
// stable JSON string 으로 변환해 fetchData dep 으로 사용. 호출자 책임 보강.
const dataFilterJson = useMemo(
() => (dataFilter && (dataFilter as any).enabled ? JSON.stringify(dataFilter) : null),
[dataFilter],
);
const excludeFilterJson = useMemo(
() => (excludeFilter && excludeFilter.enabled ? JSON.stringify(excludeFilter) : null),
[excludeFilter],
);
const [data, setData] = useState<Record<string, any>[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
@@ -59,14 +95,29 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
const [search, setSearch] = useState<Record<string, any>>(externalSearch || {});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refreshKey = useRef(0);
const initialStateRef = useRef({
tableName,
page: initialPage,
pageSize: initialPageSize,
sortBy: initialSortBy,
sortOrder: initialSortOrder,
});
// 외부 검색 조건 동기화
// 외부 검색 조건 동기화.
// D.2: 필터를 모두 clear 해서 externalSearch 가 undefined 로 바뀐 경우에도
// 내부 search state 를 비워야 stale 검색 조건이 남지 않는다.
useEffect(() => {
if (externalSearch) {
setSearch(externalSearch);
setPage(1);
}
const nextSearch = externalSearch || {};
setSearch((prev) => {
const prevKeys = Object.keys(prev);
const nextKeys = Object.keys(nextSearch);
const changed =
prevKeys.length !== nextKeys.length ||
nextKeys.some((key) => prev[key] !== nextSearch[key]);
if (!changed) return prev;
return nextSearch;
});
setPage(1);
}, [externalSearch]);
// 데이터 fetch
@@ -87,6 +138,11 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
sortOrder,
search: Object.keys(search).length > 0 ? search : undefined,
enableEntityJoin: true,
// D.2 — JSON 으로 변환된 stable string 이 dep 이지만 실제 payload 는 원본 객체 사용.
dataFilter:
dataFilter && (dataFilter as any).enabled ? dataFilter : undefined,
excludeFilter:
excludeFilter && excludeFilter.enabled ? excludeFilter : undefined,
});
setData(response.data || []);
@@ -101,18 +157,51 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
} finally {
setLoading(false);
}
}, [tableName, page, pageSize, sortBy, sortOrder, search, enabled, refreshKey.current]);
// dataFilter / excludeFilter 객체 ref 가 아닌 *Json string 만 dep — fetch 폭주 방지
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
tableName,
page,
pageSize,
sortBy,
sortOrder,
search,
enabled,
dataFilterJson,
excludeFilterJson,
]);
useEffect(() => {
fetchData();
}, [fetchData]);
// 테이블 변경 시 페이지 리셋
// 테이블 / config 초기값 변경 시 런타임 상태 동기화.
// 초기 mount 에서는 useState(initial*) 값이 이미 권위이므로 reset 하지 않는다.
useEffect(() => {
setPage(1);
setSortBy("");
setSearch({});
}, [tableName]);
const prev = initialStateRef.current;
const changed =
prev.tableName !== tableName ||
prev.page !== initialPage ||
prev.pageSize !== initialPageSize ||
prev.sortBy !== initialSortBy ||
prev.sortOrder !== initialSortOrder;
if (!changed) return;
initialStateRef.current = {
tableName,
page: initialPage,
pageSize: initialPageSize,
sortBy: initialSortBy,
sortOrder: initialSortOrder,
};
setPage(initialPage);
setPageSize(initialPageSize);
setSortBy(initialSortBy);
setSortOrder(initialSortOrder);
setSearch(externalSearch || {});
}, [tableName, initialPage, initialPageSize, initialSortBy, initialSortOrder, externalSearch]);
const toggleSort = useCallback((col: string) => {
setSortBy((prev) => {
@@ -126,10 +215,28 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
}, []);
const refresh = useCallback(() => {
refreshKey.current += 1;
fetchData();
}, [fetchData]);
const setPageSizeAction = useCallback((s: number) => {
setPageSize(s);
setPage(1);
}, []);
const setSearchAction = useCallback((s: Record<string, any>) => {
setSearch(s);
setPage(1);
}, []);
const setLocalData = useCallback((next: Record<string, any>[], totalOverride?: number) => {
const arr = Array.isArray(next) ? next : [];
setData(arr);
const t = typeof totalOverride === "number" && totalOverride >= 0 ? totalOverride : arr.length;
setTotal(t);
const ps = pageSize > 0 ? pageSize : 20;
setTotalPages(Math.max(1, Math.ceil(t / ps)));
}, [pageSize]);
return {
data,
total,
@@ -141,10 +248,12 @@ export function useTableData(params: UseTableDataParams): UseTableDataResult {
loading,
error,
setPage,
setPageSize: (s: number) => { setPageSize(s); setPage(1); },
setPageSize: setPageSizeAction,
setSortBy,
toggleSort,
setSearch: (s: Record<string, any>) => { setSearch(s); setPage(1); },
setSearch: setSearchAction,
refresh,
// Phase D.9 — 외부 receiveData() 가 local override. 다음 fetch 까지 유지.
setLocalData,
};
}
@@ -1,8 +1,15 @@
"use client";
import React from "react";
import React, { useMemo } from "react";
import { Eye, Pencil, Trash2 } from "lucide-react";
import type { TableConfig, TableCardStyleConfig } from "../types";
import type {
TableColumn,
TableConfig,
TableCardStyleConfig,
} from "../types";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
import { renderTableCellValue } from "../cell-renderers";
export interface CardViewProps {
config: TableConfig;
@@ -12,6 +19,10 @@ export interface CardViewProps {
onView?: (row: any) => void;
onEdit?: (row: any) => void;
onDelete?: (row: any) => void;
/** Phase D.7 — canonical columns 메타. inference / D.5 cell formatting / display 라벨 위해. */
columns?: TableColumn[];
/** Phase D.7 — column 라벨 (langKey 번역 반영) */
getColumnLabel?: (col: TableColumn) => string;
}
const DEFAULT_STYLE: Required<TableCardStyleConfig> = {
@@ -26,6 +37,7 @@ const DEFAULT_STYLE: Required<TableCardStyleConfig> = {
showViewButton: false,
showEditButton: false,
showDeleteButton: false,
cardHeight: "auto",
};
const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, number> = {
@@ -34,11 +46,67 @@ const IMAGE_SIZE_PX: Record<NonNullable<TableCardStyleConfig["imageSize"]>, numb
large: 200,
};
/**
* Phase D.7 image inference: key image/photo/thumbnail
* inputType/format "image".
*/
function _isImageColumn(c: TableColumn): boolean {
const key = (c.key || "").toLowerCase();
if (c.inputType === "image" || c.format === "image") return true;
return /image|photo|thumbnail/.test(key);
}
/**
* Phase D.7 file/attachment (image ): key attachment/file
* inputType/format "file"/"attachment".
*/
function _isFileColumn(c: TableColumn): boolean {
const key = (c.key || "").toLowerCase();
if (
c.inputType === "file" ||
c.inputType === "attachment" ||
c.format === "file" ||
c.format === "attachment"
)
return true;
return (
key.includes("attachment") ||
/(^|[_-])files?($|[_-])/.test(key)
);
}
/**
* Phase D.7 URL .
* - http(s):// → 그대로 (getFullImageUrl 가 그대로 반환)
* - objid getFilePreviewUrl
* - path getFullImageUrl
*/
function _normalizeImageUrl(raw: any): string | null {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim();
if (!s) return null;
// 콤마 구분 시 첫 값만 사용
const first = s.includes(",") ? s.split(",")[0].trim() : s;
if (!first) return null;
if (/^\d+$/.test(first)) {
return getFilePreviewUrl(first);
}
return getFullImageUrl(first);
}
/**
* CardView displayMode="card"
*
* . config.cardColumnMapping
* (title/subtitle/description/image) .
* . `config.cardColumnMapping`
* (title/subtitle/description/image/displayColumns) .
*
* Phase D.7 (2026-05-20) legacy :
* - `cardColumnMapping` columns inference (title/subtitle/description/image)
* - legacy `cardConfig.{titleColumn, idColumn, cardHeight}` fallback
* - URL normalize (objid getFilePreviewUrl, path getFullImageUrl, http(s) )
* - displayColumns D.5 `renderTableCellValue` ( / )
* - idColumn ID
* - cardHeight number
*/
export function CardView({
config,
@@ -48,6 +116,8 @@ export function CardView({
onView,
onEdit,
onDelete,
columns,
getColumnLabel,
}: CardViewProps) {
const cardsPerRow = config.cardsPerRow ?? 3;
const cardSpacing = config.cardSpacing ?? 12;
@@ -55,7 +125,69 @@ export function CardView({
...DEFAULT_STYLE,
...(config.cardStyle ?? {}),
};
const mapping = config.cardColumnMapping ?? {};
// Phase D.7 — mapping fallback (canonical → legacy cardConfig)
const legacyCardConfig =
(config as any).cardConfig && typeof (config as any).cardConfig === "object"
? ((config as any).cardConfig as Record<string, any>)
: {};
const explicitMapping = config.cardColumnMapping ?? {};
// Phase D.7 — inference (mapping 비어있을 때 columns 사용)
const inferred = useMemo(() => {
const cols = Array.isArray(columns) ? columns : [];
const nonMedia = cols.filter(
(c) => !_isImageColumn(c) && !_isFileColumn(c),
);
const imageCol = cols.find(_isImageColumn);
return {
titleColumn: nonMedia[0]?.key,
subtitleColumn: nonMedia[1]?.key,
descriptionColumn: nonMedia[2]?.key,
imageColumn: imageCol?.key,
};
}, [columns]);
const mapping = useMemo(
() => ({
titleColumn:
explicitMapping.titleColumn ||
(legacyCardConfig.titleColumn as string | undefined) ||
inferred.titleColumn,
subtitleColumn:
explicitMapping.subtitleColumn ||
(legacyCardConfig.subtitleColumn as string | undefined) ||
inferred.subtitleColumn,
descriptionColumn:
explicitMapping.descriptionColumn ||
(legacyCardConfig.descriptionColumn as string | undefined) ||
inferred.descriptionColumn,
imageColumn:
explicitMapping.imageColumn ||
(legacyCardConfig.imageColumn as string | undefined) ||
inferred.imageColumn,
idColumn:
explicitMapping.idColumn ||
(legacyCardConfig.idColumn as string | undefined),
displayColumns: explicitMapping.displayColumns,
}),
[explicitMapping, legacyCardConfig, inferred],
);
// Phase D.7 — legacy cardHeight (cardConfig.cardHeight) fallback
const cardHeight =
style.cardHeight !== "auto" && style.cardHeight !== undefined
? style.cardHeight
: (legacyCardConfig.cardHeight as number | undefined);
// Phase D.7 — column 메타 lookup (displayColumns 의 cell renderer 용)
const columnByKey = useMemo(() => {
const m = new Map<string, TableColumn>();
if (Array.isArray(columns)) {
for (const c of columns) m.set(c.key, c);
}
return m;
}, [columns]);
if (data.length === 0) {
return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>;
@@ -78,6 +210,9 @@ export function CardView({
row={row}
mapping={mapping}
style={style}
cardHeight={typeof cardHeight === "number" ? cardHeight : undefined}
columnByKey={columnByKey}
getColumnLabel={getColumnLabel}
onClick={onCardClick ? () => onCardClick(row) : undefined}
onView={style.showActions && style.showViewButton ? () => onView?.(row) : undefined}
onEdit={style.showActions && style.showEditButton ? () => onEdit?.(row) : undefined}
@@ -91,8 +226,18 @@ export function CardView({
interface CardItemProps {
row: any;
mapping: NonNullable<TableConfig["cardColumnMapping"]>;
mapping: {
titleColumn?: string;
subtitleColumn?: string;
descriptionColumn?: string;
imageColumn?: string;
idColumn?: string;
displayColumns?: string[];
};
style: Required<TableCardStyleConfig>;
cardHeight?: number;
columnByKey: Map<string, TableColumn>;
getColumnLabel?: (col: TableColumn) => string;
isDesignMode: boolean;
onClick?: () => void;
onView?: () => void;
@@ -100,15 +245,34 @@ interface CardItemProps {
onDelete?: () => void;
}
function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit, onDelete }: CardItemProps) {
function CardItem({
row,
mapping,
style,
cardHeight,
columnByKey,
getColumnLabel,
isDesignMode,
onClick,
onView,
onEdit,
onDelete,
}: CardItemProps) {
const [imgError, setImgError] = React.useState(false);
const title = mapping.titleColumn ? row?.[mapping.titleColumn] : undefined;
const subtitle = mapping.subtitleColumn ? row?.[mapping.subtitleColumn] : undefined;
const descriptionRaw = mapping.descriptionColumn ? row?.[mapping.descriptionColumn] : undefined;
const image = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined;
const imageRaw = mapping.imageColumn ? row?.[mapping.imageColumn] : undefined;
const idValue = mapping.idColumn ? row?.[mapping.idColumn] : undefined;
React.useEffect(() => {
setImgError(false);
}, [mapping.imageColumn, imageRaw]);
const description =
typeof descriptionRaw === "string" && descriptionRaw.length > style.maxDescriptionLength
? `${descriptionRaw.slice(0, style.maxDescriptionLength)}`
: descriptionRaw;
const imageUrl = _normalizeImageUrl(imageRaw);
const imagePx = IMAGE_SIZE_PX[style.imageSize];
const isHorizontal = style.imagePosition === "left" || style.imagePosition === "right";
@@ -121,45 +285,135 @@ function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit,
cursor: onClick ? "pointer" : "default",
display: isHorizontal ? "flex" : "block",
flexDirection: style.imagePosition === "right" ? "row-reverse" : "row",
...(typeof cardHeight === "number" && cardHeight > 0
? { height: `${cardHeight}px` }
: {}),
};
return (
<div style={cardStyle} onClick={onClick}>
{style.showImage && image && (
{style.showImage && imageUrl && !imgError && (
<div
style={{
width: isHorizontal ? imagePx : "100%",
height: imagePx,
background: "hsl(var(--muted))",
flexShrink: 0,
backgroundImage: `url(${image})`,
backgroundSize: "cover",
backgroundPosition: "center",
position: "relative",
overflow: "hidden",
}}
/>
>
<img
src={imageUrl}
alt=""
onError={() => setImgError(true)}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
</div>
)}
<div style={{ padding: 12, flex: 1, minWidth: 0 }}>
{style.showImage && imageRaw && imgError && (
<div
style={{
width: isHorizontal ? imagePx : "100%",
height: imagePx,
background: "hsl(var(--muted))",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "hsl(var(--muted-foreground))",
fontSize: 11,
}}
>
</div>
)}
<div
style={{
padding: 12,
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 4,
overflow: "hidden",
}}
>
{(idValue !== undefined && idValue !== null && idValue !== "") && (
// Phase D.7 — idColumn 배지 (헤더 영역 우측)
<div
style={{
display: "flex",
justifyContent: "flex-end",
marginBottom: 2,
}}
>
<span
style={{
fontSize: 10,
color: "hsl(var(--muted-foreground))",
background: "hsl(var(--muted))",
borderRadius: 3,
padding: "1px 6px",
}}
title={mapping.idColumn}
>
{String(idValue)}
</span>
</div>
)}
{style.showTitle && title !== undefined && title !== null && title !== "" && (
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 4 }}>{String(title)}</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{String(title)}</div>
)}
{style.showSubtitle && subtitle !== undefined && subtitle !== null && subtitle !== "" && (
<div style={{ fontSize: 11, color: "hsl(var(--muted-foreground))", marginBottom: 6 }}>
<div style={{ fontSize: 11, color: "hsl(var(--muted-foreground))" }}>
{String(subtitle)}
</div>
)}
{style.showDescription && description !== undefined && description !== null && description !== "" && (
<div style={{ fontSize: 12, lineHeight: 1.5, marginBottom: style.showActions ? 8 : 0 }}>
{String(description)}
</div>
<div style={{ fontSize: 12, lineHeight: 1.5 }}>{String(description)}</div>
)}
{(mapping.displayColumns?.length ?? 0) > 0 && (
<div style={{ marginTop: 6, fontSize: 11, color: "hsl(var(--muted-foreground))" }}>
{mapping.displayColumns!.map((col) => (
<div key={col} style={{ display: "flex", gap: 6 }}>
<span style={{ fontWeight: 500 }}>{col}:</span>
<span>{row?.[col] !== undefined && row?.[col] !== null ? String(row[col]) : "-"}</span>
</div>
))}
<div
style={{
marginTop: 6,
fontSize: 11,
color: "hsl(var(--muted-foreground))",
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{mapping.displayColumns!.map((colKey) => {
const col = columnByKey.get(colKey);
const label =
col && getColumnLabel ? getColumnLabel(col) : col?.label ?? colKey;
const cellValue = row?.[colKey];
// Phase D.7 — D.5 cell formatting 사용. col 메타 없으면 raw string.
const rendered = col
? renderTableCellValue({
value: cellValue,
column: col,
row,
isDesignMode,
})
: cellValue !== undefined && cellValue !== null
? String(cellValue)
: "-";
return (
<div key={colKey} style={{ display: "flex", gap: 6, minWidth: 0 }}>
<span style={{ fontWeight: 500, flexShrink: 0 }}>{label}:</span>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{rendered}
</span>
</div>
);
})}
</div>
)}
{style.showActions && (onView || onEdit || onDelete) && (
@@ -175,11 +429,15 @@ function CardItem({ row, mapping, style, isDesignMode, onClick, onView, onEdit,
)}
</div>
)}
{isDesignMode && Object.keys(mapping).length === 0 && (
<div style={{ fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
[ ] .
</div>
)}
{isDesignMode &&
!mapping.titleColumn &&
!mapping.subtitleColumn &&
!mapping.descriptionColumn &&
!mapping.imageColumn && (
<div style={{ fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
[ ] .
</div>
)}
</div>
</div>
);
@@ -3,6 +3,8 @@
import React, { useMemo, useState } from "react";
import { ChevronRight, ChevronDown } from "lucide-react";
import type { TableConfig, TableColumn } from "../types";
import type { GroupSumConfig } from "@/types/table-options";
import { renderTableCellValue } from "../cell-renderers";
export interface GroupedViewProps {
config: TableConfig;
@@ -11,13 +13,25 @@ export interface GroupedViewProps {
rowHeightPx?: string;
isDesignMode?: boolean;
onRowClick?: (row: any) => void;
/** Phase D.8 — TableOptions UI 가 set 한 grouping 컬럼 list. 첫 컬럼만 사용 (deep nesting 보류). */
groupByColumns?: string[];
/** Phase D.8 — group-sum 활성화 시 group 헤더에 sum/avg/count 표시 */
groupSumConfig?: GroupSumConfig | null;
/** Phase D.8 — column 라벨 (langKey 번역) */
getColumnLabel?: (col: TableColumn) => string;
}
/**
* GroupedView displayMode="grouped"
*
* config.groupBy / .
* / .
* + . .
*
* Phase D.8 (2026-05-20) groupByColumns / groupSumConfig :
* - groupByColumns (TableOptions UI set), config.groupBy
* - groupSumConfig.enabled numeric column sum/avg/count
* - footer row (groupSum enabled )
* - D.5 renderTableCellValue (image/file/entity/number/date/boolean)
*/
export function GroupedView({
config,
@@ -26,8 +40,28 @@ export function GroupedView({
rowHeightPx = "36px",
isDesignMode = false,
onRowClick,
groupByColumns,
groupSumConfig,
getColumnLabel,
}: GroupedViewProps) {
const groupBy = config.groupBy;
// Phase D.8 — groupByColumns 첫 컬럼 우선, 없으면 config.groupBy.
const groupBy =
Array.isArray(groupByColumns) && groupByColumns.length > 0
? groupByColumns[0]
: config.groupBy;
// numeric column 식별
const numericColumnKeys = useMemo<string[]>(() => {
return columns
.filter((c) => {
if (c.key === groupBy) return false;
if (c.inputType === "number" || c.inputType === "decimal") return true;
if (c.dataType === "number" || c.dataType === "decimal") return true;
if (c.format === "number" || c.format === "currency") return true;
return false;
})
.map((c) => c.key);
}, [columns, groupBy]);
const groups = useMemo<Array<{ key: string; rows: any[] }>>(() => {
if (!groupBy) return [{ key: "(전체)", rows: data }];
@@ -42,6 +76,59 @@ export function GroupedView({
return Array.from(map.entries()).map(([key, rows]) => ({ key, rows }));
}, [data, groupBy]);
// Phase D.8 — 그룹별 numeric summary (sum/avg/count) 계산. group-sum enabled 시만.
const groupSumEnabled = !!groupSumConfig?.enabled;
const summaryByGroup = useMemo(() => {
if (!groupSumEnabled || numericColumnKeys.length === 0) return new Map<string, Record<string, { sum: number; avg: number; count: number }>>();
const out = new Map<string, Record<string, { sum: number; avg: number; count: number }>>();
for (const { key, rows } of groups) {
const summary: Record<string, { sum: number; avg: number; count: number }> = {};
for (const colKey of numericColumnKeys) {
let sum = 0;
let count = 0;
for (const row of rows) {
const v = row?.[colKey];
const n = typeof v === "number" ? v : Number(v);
if (Number.isFinite(n)) {
sum += n;
count += 1;
}
}
summary[colKey] = {
sum,
avg: count > 0 ? sum / count : 0,
count,
};
}
out.set(key, summary);
}
return out;
}, [groups, numericColumnKeys, groupSumEnabled]);
// 전체 합계 (grand total)
const grandTotal = useMemo(() => {
if (!groupSumEnabled || numericColumnKeys.length === 0) return null;
const summary: Record<string, { sum: number; avg: number; count: number }> = {};
for (const colKey of numericColumnKeys) {
let sum = 0;
let count = 0;
for (const row of data) {
const v = row?.[colKey];
const n = typeof v === "number" ? v : Number(v);
if (Number.isFinite(n)) {
sum += n;
count += 1;
}
}
summary[colKey] = {
sum,
avg: count > 0 ? sum / count : 0,
count,
};
}
return summary;
}, [data, numericColumnKeys, groupSumEnabled]);
const [collapsedKeys, setCollapsedKeys] = useState<Set<string>>(new Set());
const toggle = (key: string) => {
setCollapsedKeys((prev) => {
@@ -64,6 +151,25 @@ export function GroupedView({
return <div style={emptyStyle}>{config.emptyMessage || "데이터 없음"}</div>;
}
// 그룹 헤더의 sum hint 텍스트 — numeric 컬럼들의 sum 을 짧게.
const formatSumHint = (
summary: Record<string, { sum: number; avg: number; count: number }>,
): string => {
const parts: string[] = [];
for (const colKey of numericColumnKeys) {
const s = summary[colKey];
if (!s || s.count === 0) continue;
const col = columns.find((c) => c.key === colKey);
const label = col && getColumnLabel ? getColumnLabel(col) : col?.label ?? colKey;
const display =
col?.thousandSeparator === false
? String(s.sum)
: s.sum.toLocaleString("ko-KR");
parts.push(`${label}: ${display}`);
}
return parts.join(" · ");
};
return (
<div style={{ overflow: "auto", flex: 1 }}>
<table style={tableStyle}>
@@ -79,7 +185,7 @@ export function GroupedView({
textAlign: col.align || "left",
}}
>
{col.label || col.key}
{getColumnLabel ? getColumnLabel(col) : col.label || col.key}
</th>
))}
</tr>
@@ -87,6 +193,7 @@ export function GroupedView({
<tbody>
{groups.map(({ key, rows }) => {
const collapsed = collapsedKeys.has(key);
const summary = summaryByGroup.get(key);
return (
<React.Fragment key={key}>
<tr style={groupHeaderRowStyle} onClick={() => toggle(key)}>
@@ -105,6 +212,19 @@ export function GroupedView({
<span style={{ color: "hsl(var(--muted-foreground))", fontSize: 11 }}>
({rows.length})
</span>
{/* Phase D.8 — group summary inline */}
{summary && (
<span
style={{
marginLeft: 8,
color: "hsl(var(--primary))",
fontSize: 11,
fontWeight: 500,
}}
>
{formatSumHint(summary)}
</span>
)}
</td>
</tr>
{!collapsed &&
@@ -120,32 +240,114 @@ export function GroupedView({
key={col.key}
style={{ ...tdStyle, textAlign: col.align || "left" }}
>
{formatCell(row?.[col.key], col.format)}
{renderTableCellValue({
value: row?.[col.key],
column: col,
row,
isDesignMode,
})}
</td>
))}
</tr>
))}
{/* Phase D.8 — group subtotal row (group-sum enabled + 펼친 상태 + numeric 컬럼 ≥1) */}
{!collapsed && summary && numericColumnKeys.length > 0 && (
<tr style={groupSubtotalRowStyle}>
<td style={tdStyle}></td>
{columns.map((col) => {
const s = summary[col.key];
if (!s || s.count === 0) {
return (
<td
key={col.key}
style={{ ...tdStyle, color: "hsl(var(--muted-foreground))" }}
/>
);
}
const display =
col.thousandSeparator === false
? String(s.sum)
: s.sum.toLocaleString("ko-KR");
return (
<td
key={col.key}
style={{
...tdStyle,
textAlign: col.align || "right",
fontWeight: 700,
color: "hsl(var(--primary))",
}}
title={`합계: ${display} / 평균: ${
col.thousandSeparator === false
? String(s.avg)
: s.avg.toLocaleString("ko-KR", { maximumFractionDigits: 2 })
} / 건수: ${s.count}`}
>
{display}
</td>
);
})}
</tr>
)}
</React.Fragment>
);
})}
{/* Phase D.8 — grand total row */}
{grandTotal && numericColumnKeys.length > 0 && (
<tr style={grandTotalRowStyle}>
<td style={tdStyle}></td>
{columns.map((col) => {
const s = grandTotal[col.key];
if (!s || s.count === 0) {
if (col === columns[0]) {
return (
<td
key={col.key}
style={{
...tdStyle,
fontWeight: 700,
color: "hsl(var(--foreground))",
}}
>
</td>
);
}
return <td key={col.key} style={tdStyle} />;
}
const display =
col.thousandSeparator === false
? String(s.sum)
: s.sum.toLocaleString("ko-KR");
return (
<td
key={col.key}
style={{
...tdStyle,
textAlign: col.align || "right",
fontWeight: 700,
color: "hsl(var(--foreground))",
background: "hsl(var(--muted) / 0.4)",
}}
>
{display}
</td>
);
})}
</tr>
)}
</tbody>
</table>
{isDesignMode && (
<div style={{ padding: "6px 10px", fontSize: 10.5, color: "hsl(var(--muted-foreground))" }}>
[ ] {groups.length}
{groupSumEnabled ? " (group-sum 활성)" : ""}
</div>
)}
</div>
);
}
function formatCell(value: any, _format?: string): React.ReactNode {
if (value === null || value === undefined) return "-";
if (typeof value === "boolean") return value ? "✓" : "✗";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
const tableStyle: React.CSSProperties = {
width: "100%",
borderCollapse: "collapse",
@@ -158,7 +360,7 @@ const thStyle: React.CSSProperties = {
fontWeight: 700,
color: "hsl(var(--foreground))",
textTransform: "uppercase",
letterSpacing: "0.03em",
letterSpacing: 0,
borderBottom: "1px solid hsl(var(--border))",
textAlign: "left",
whiteSpace: "nowrap",
@@ -179,6 +381,15 @@ const groupHeaderRowStyle: React.CSSProperties = {
cursor: "pointer",
};
const groupSubtotalRowStyle: React.CSSProperties = {
background: "hsl(var(--primary) / 0.05)",
};
const grandTotalRowStyle: React.CSSProperties = {
background: "hsl(var(--muted) / 0.7)",
borderTop: "2px solid hsl(var(--border))",
};
const emptyStyle: React.CSSProperties = {
padding: 24,
textAlign: "center",
@@ -107,7 +107,7 @@ export function UniversalFormModalConfigPanel({
const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type;
const compConfig = comp.componentConfig || {};
// 1. Table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
// 1. Table-like (canonical 'table' )
// + InteractiveDataTable - 테이블 컬럼 추출
if (isTableLikeComponentType(compType) || compType === "interactive-data-table") {
const tableName = compConfig.selectedTable || compConfig.tableName;
@@ -768,7 +768,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const allProviders = screenContext.getAllDataProviders();
// table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list') 우선 탐색
// table-like (canonical 'table' ) 우선 탐색
for (const [id, provider] of allProviders) {
if (isTableLikeComponentType(provider.component_type)) {
sourceProvider = provider;
@@ -114,7 +114,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 좌측 패널 대상: card-display만
return tableId.includes("card-display") || tableId.includes("card");
} else if (targetPanelPosition === "right") {
// 우측 패널 대상: datatable, table-list 등 (card-display 제외)
// 우측 패널 대상: datatable, canonical table 등 (card-display 제외)
const isCardDisplay = tableId.includes("card-display") || tableId.includes("card");
return !isCardDisplay;
}
+6 -63
View File
@@ -125,7 +125,7 @@ export function getComponentUrl(componentType: string): string {
// 컴포넌트 타입 추출 함수 (URL에서)
// ============================================
export function getComponentTypeFromUrl(componentUrl: string): string {
// "@/lib/registry/components/v2-table-list" → "v2-table-list"
// "@/lib/registry/components/table" → "table"
const parts = componentUrl.split("/");
return parts[parts.length - 1];
}
@@ -223,36 +223,6 @@ export type LayoutV2 = z.infer<typeof layoutV2Schema>;
// V2 컴포넌트 overrides 스키마 정의
// ============================================
// v2-table-list
const v2TableListOverridesSchema = z
.object({
displayMode: z.enum(["table", "card"]).default("table"),
showHeader: z.boolean().default(true),
showFooter: z.boolean().default(true),
height: z.string().default("auto"),
checkbox: z
.object({
enabled: z.boolean().default(true),
multiple: z.boolean().default(true),
position: z.string().default("left"),
selectAll: z.boolean().default(true),
})
.default({ enabled: true, multiple: true, position: "left", selectAll: true }),
columns: z.array(z.any()).default([]),
autoWidth: z.boolean().default(true),
stickyHeader: z.boolean().default(false),
pagination: z
.object({
enabled: z.boolean().default(true),
pageSize: z.number().default(20),
showSizeSelector: z.boolean().default(true),
showPageInfo: z.boolean().default(true),
})
.default({ enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true }),
autoLoad: z.boolean().default(true),
})
.passthrough();
// v2-button-primary
const v2ButtonPrimaryOverridesSchema = z
.object({
@@ -437,17 +407,7 @@ const v2V2RepeaterOverridesSchema = z
// V2 입력/선택 폐기 (Phase D.2, 2026-05-12) — input canonical 로 흡수.
// override schema / default config 모두 제거. fallback schema (default any.passthrough) 사용.
// v2-list
const v2ListOverridesSchema = z
.object({
viewMode: z.string().default("table"),
source: z.string().default("static"),
columns: z.array(z.any()).default([]),
pagination: z.boolean().default(true),
sortable: z.boolean().default(true),
})
.passthrough();
// v2-list schema 폐기 (Phase F.8, 2026-05-21) — canonical table 로 흡수.
// v2-layout
const v2LayoutOverridesSchema = z
@@ -551,7 +511,7 @@ const v2RepeaterOverridesSchema = z
// ============================================
const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string, any>>> = {
// V2 컴포넌트 (canonical alias 라우팅된 항목은 schema 제거됨 — 2026-05-19)
"v2-table-list": v2TableListOverridesSchema,
// 옛 table-list 계열 schema 폐기 (Phase F.4) — canonical "table" 사용
"v2-button-primary": v2ButtonPrimaryOverridesSchema,
"v2-text-display": v2TextDisplayOverridesSchema,
"v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema,
@@ -573,7 +533,7 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
}).passthrough(),
// V2 입력/선택 폐기 (Phase D.2) — schema 미제공.
"v2-list": v2ListOverridesSchema,
// 옛 통합 목록 schema 폐기 (Phase F.8) — canonical table 사용.
"v2-layout": v2LayoutOverridesSchema,
"v2-group": v2GroupOverridesSchema,
// v2-media 폐기 (Phase D.5) — schema 미제공.
@@ -586,18 +546,7 @@ const componentOverridesSchemaRegistry: Record<string, z.ZodType<Record<string,
// ============================================
const componentDefaultsRegistry: Record<string, Record<string, any>> = {
// V2 컴포넌트
"v2-table-list": {
displayMode: "table",
showHeader: true,
showFooter: true,
height: "auto",
checkbox: { enabled: true, multiple: true, position: "left", selectAll: true },
columns: [],
autoWidth: true,
stickyHeader: false,
pagination: { enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true },
autoLoad: true,
},
// 옛 table-list 계열 defaults 폐기 (Phase F.4) — canonical "table" 사용
"v2-button-primary": {
text: "저장",
actionType: "button",
@@ -669,13 +618,7 @@ const componentDefaultsRegistry: Record<string, Record<string, any>> = {
},
// v2-tabs-widget defaults 제거 (2026-05-19): canonical container alias (containerType=tabs)
// V2 컴포넌트 (V2 입력/선택 폐기, Phase D.2)
"v2-list": {
viewMode: "table",
source: "static",
columns: [],
pagination: true,
sortable: true,
},
// 옛 통합 목록 defaults 폐기 (Phase F.8) — canonical table 사용.
"v2-layout": {
layoutType: "grid",
columns: 2,
+6 -20
View File
@@ -3263,9 +3263,9 @@ export class ButtonActionExecutor {
if (autoDetectDataSource) {
dataSourceId = config.dataSourceId;
// TableList, V2List 또는 SplitPanelLayout에서 자동 감지
// canonical table 또는 SplitPanelLayout에서 자동 감지
if (!dataSourceId && context.allComponents) {
// 1. table-like 컴포넌트 찾기 (canonical table / legacy table-list / hidden v2-table-list 모두 인식)
// 1. table-like 컴포넌트 찾기 (canonical table 인식)
const tableLikeComponent = context.allComponents.find(
(comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp),
);
@@ -3273,22 +3273,8 @@ export class ButtonActionExecutor {
if (tableLikeComponent) {
dataSourceId = getTableNameFromTableLikeComponent(tableLikeComponent);
} else {
// 2. v2-list 컴포넌트 찾기
const v2ListComponent = context.allComponents.find(
(comp: any) =>
comp.componentType === "v2-list" &&
(comp.componentConfig?.dataSource?.table || comp.componentConfig?.tableName),
);
if (v2ListComponent) {
dataSourceId =
v2ListComponent.componentConfig.dataSource?.table || v2ListComponent.componentConfig.tableName;
console.log("✨ V2List 자동 감지:", {
componentId: v2ListComponent.id,
tableName: dataSourceId,
});
} else {
// 3. split-panel-layout 컴포넌트 찾기
{
// 2. split-panel-layout 컴포넌트 찾기
const splitPanelComponent = context.allComponents.find(
(comp: any) =>
comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName,
@@ -3461,7 +3447,7 @@ export class ButtonActionExecutor {
let dataSourceId = config.dataSourceId;
if (!dataSourceId && context.allComponents) {
// table-like 우선 감지 (canonical table / legacy table-list / hidden v2-table-list)
// table-like 우선 감지 (canonical table )
const tableLikeComponent = context.allComponents.find(
(comp: any) => isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp),
);
@@ -5280,7 +5266,7 @@ export class ButtonActionExecutor {
layoutData.components = JSON.parse(layoutData.components);
}
// 테이블 리스트 컴포넌트 찾기 (canonical table / legacy table-list / hidden v2-table-list 모두 인식)
// 테이블 컴포넌트 찾기 (canonical table table-like 컴포넌트 인식)
const findTableListComponent = (components: any[]): any => {
if (!Array.isArray(components)) return null;
+5 -11
View File
@@ -197,14 +197,11 @@ export const getComponentLabel = (component: ComponentData): string => {
*
* Canonical Table-like helpers
*
* INVYONE canonical data-view cleanup , `table-list`
* canonical-aware .
* INVYONE canonical data-view (F phase) ,
* canonical id id .
*
* - : `table` (canonical)
* - : `table-list`
* - hidden legacy: `v2-table-list` (registry/schema에 hard blocker로
* )
* - : `data-table`, `datatable`
* - canonical: `table`
* - (): `data-table`, `datatable`
*
* typeValue `isTableLikeComponentType`,
* `isTableLikeComponent`, table name `getTableNameFromTableLikeComponent`
@@ -212,13 +209,10 @@ export const getComponentLabel = (component: ComponentData): string => {
* */
/**
* canonical table alias .
* v2-table-list registry/schemas hard blocker .
* canonical table .
*/
const TABLE_LIKE_COMPONENT_TYPES: ReadonlySet<string> = new Set([
"table",
"table-list",
"v2-table-list",
"data-table",
"datatable",
]);
@@ -24,7 +24,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
// ========== V2 컴포넌트 ==========
// V2 입력/선택 폐기 (2026-05-12) — input canonical 로 흡수. alias / fallback / schema 미제공.
"v2-list": () => import("@/components/v2/config-panels/InvDataConfigPanel"),
// v2-list 폐기 (Phase F.8, 2026-05-21) — canonical table 로 흡수.
// v2-media — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수, ConfigPanel 미제공.
"v2-biz": () => import("@/components/v2/config-panels/V2BizConfigPanel"),
"v2-group": () => import("@/components/v2/config-panels/V2GroupConfigPanel"),
@@ -65,7 +65,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"v2-split-line": () => import("@/lib/registry/components/v2-split-line/SplitLineConfigPanel"),
// ========== 테이블/리스트 ==========
// ★ 2026-05-19 table-list / v2-table-list → CONFIG_PANEL_ALIAS["..."]="table" 로 라우팅
// table-list / v2-table-list alias 는 Phase F.6 에서 제거됨 — canonical "table" 만 사용
"table-search-widget": () => import("@/lib/registry/components/table-search-widget/TableSearchWidgetConfigPanel"),
"v2-table-search-widget": () => import("@/lib/registry/components/v2-table-search-widget/TableSearchWidgetConfigPanel"),
"tax-invoice-list": () => import("@/lib/registry/components/tax-invoice-list/TaxInvoiceListConfigPanel"),
@@ -143,7 +143,6 @@ const CONFIG_PANEL_ALIAS: Record<string, string> = {
"v2-table-search-widget": "search", "table-search-widget": "search",
"v2-aggregation-widget": "stats", "aggregation-widget": "stats",
"v2-status-count": "stats",
"v2-table-list": "table", "table-list": "table",
"v2-tabs-widget": "container", "tabs-widget": "container",
"tabs": "container", "v2-tabs": "container",
"v2-section-card": "container", "v2-section-paper": "container",
@@ -169,7 +168,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
// 모듈에서 ConfigPanel 컴포넌트 추출 (우선순위):
// 1차: PascalCase 변환된 이름 (예: mail-recipient-selector -> MailRecipientSelectorConfigPanel)
// 2차: v2- 접두사 제거 후 PascalCase (예: v2-table-list -> TableListConfigPanel)
// 2차: v2- 접두사 제거 후 PascalCase (예: v2-text-display -> TextDisplayConfigPanel)
// 3차: *ConfigPanel로 끝나는 첫 번째 named export
// 4차: default export
const pascalCaseName = `${toPascalCase(componentId)}ConfigPanel`;
+9 -3
View File
@@ -4,6 +4,7 @@
import { ComponentData } from "@/types/screen-management";
import { ResponsiveComponentConfig, BREAKPOINTS } from "@/types/responsive";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
/**
*
@@ -18,13 +19,13 @@ export function generateSmartDefaults(
screenWidth: number = 1920,
rowComponentCount: number = 1, // 같은 행에 있는 컴포넌트 개수
): ResponsiveComponentConfig["responsive"] {
// 특정 컴포넌트는 항상 전체 너비 (datatable, table-list 등)
// 특정 컴포넌트는 항상 전체 너비 (datatable, table 등).
// Phase E.3 — 옛 table ID literal 은 제거하고 table-like helper 로 호환 유지.
// ★ 2026-05-18: canonical data-view 추가 (table / grouped-table / card-list).
// chart 는 자연스럽게 작은 크기(예: 4컬럼)도 자주 쓰므로 제외하고 자동 추론.
const fullWidthComponents = [
"datatable",
"data-table",
"table-list",
"repeater-field-group",
// canonical (Phase G.3 ~)
"table",
@@ -34,7 +35,12 @@ export function generateSmartDefaults(
const componentId = (component as any).componentId || (component as any).id;
const componentType = (component as any).componentType || component.type;
if (fullWidthComponents.includes(componentId) || fullWidthComponents.includes(componentType)) {
if (
isTableLikeComponentType(componentId) ||
isTableLikeComponentType(componentType) ||
fullWidthComponents.includes(componentId) ||
fullWidthComponents.includes(componentType)
) {
return {
desktop: {
grid_columns: 12, // 전체 너비
-2
View File
@@ -41,8 +41,6 @@ const LEGACY_TO_UNIFIED: Record<string, string> = {
'v2-aggregation-widget': 'stats',
'aggregation-widget': 'stats',
'v2-status-count': 'stats',
'v2-table-list': 'table',
'table-list': 'table',
'v2-tabs-widget': 'container',
'tabs-widget': 'container',
'tabs': 'container',
+72
View File
@@ -1,6 +1,15 @@
/**
*
*
*
* Key (Phase B.4 2026-05-20, F.5 docstring ):
* - canonical `lib/registry/components/table/TableComponent` : `table-${component.id}` prefix
* (`setTableDataForComponent` / `getTableDataForComponent` helper )
* - prefix (`table-list-${component.id}`) fallback .
* ( Phase F.2/F.8 historical key store
* read fallback .)
* - consumer (Excel/Copy/Toolbar) component.id
* `getTableDataForComponent(componentId)` canonical + historical fallback .
*/
interface TableDisplayState {
@@ -24,6 +33,16 @@ class TableDisplayStore {
private state: Map<string, TableDisplayState> = new Map();
private listeners: Set<() => void> = new Set();
/** canonical TableComponent 가 쓰는 키 (`table-${componentId}`) */
private static canonicalKeyForComponent(componentId: string): string {
return `table-${componentId}`;
}
/** historical fallback 키 — 옛 본체가 직접 set 하던 prefix. 본체는 Phase F.2/F.8 에서 삭제됨. */
private static legacyKeyForComponent(componentId: string): string {
return `table-list-${componentId}`;
}
/**
*
* @param tableName
@@ -85,6 +104,59 @@ class TableDisplayStore {
this.notifyListeners();
}
/**
* canonical TableComponent set component.id key (Phase B.4)
*/
setTableDataForComponent(
componentId: string,
data: any[],
columnOrder: string[],
sortBy: string | null,
sortOrder: "asc" | "desc",
options?: {
filter_conditions?: Record<string, any>;
search_term?: string;
visible_columns?: string[];
column_labels?: Record<string, string>;
current_page?: number;
page_size?: number;
total_items?: number;
},
) {
this.setTableData(
TableDisplayStore.canonicalKeyForComponent(componentId),
data,
columnOrder,
sortBy,
sortOrder,
options,
);
}
/**
* component.id canonical + historical fallback (Phase B.4)
* consumer (Excel export, copy, toolbar) prefix component.id .
*/
getTableDataForComponent(componentId: string): TableDisplayState | undefined {
return (
this.state.get(TableDisplayStore.canonicalKeyForComponent(componentId)) ??
this.state.get(TableDisplayStore.legacyKeyForComponent(componentId))
);
}
/**
* component.id canonical + historical fallback (Phase B.4)
*/
clearTableDataForComponent(componentId: string) {
const canonical = TableDisplayStore.canonicalKeyForComponent(componentId);
const legacy = TableDisplayStore.legacyKeyForComponent(componentId);
const hadCanonical = this.state.delete(canonical);
const hadLegacy = this.state.delete(legacy);
if (hadCanonical || hadLegacy) {
this.notifyListeners();
}
}
/**
*
*/
+4 -4
View File
@@ -10,8 +10,8 @@
// ============================================================
/**
*
* 발행: v2-table-list
*
* 발행: canonical table
* 구독: stats, v2-repeat-container
*/
export interface TableListDataChangeDetail {
@@ -69,7 +69,7 @@ export interface RepeaterSaveDetail {
/**
*
* 발행: v2-button-primary, buttonActions
* 구독: v2-table-list, v2-split-panel-layout
* 구독: canonical table, v2-split-panel-layout
*/
export interface RefreshTableDetail {
table_name?: string;
@@ -113,7 +113,7 @@ export interface SplitPanelDataTransferDetail {
/**
*
* 발행: related-data-buttons
* 구독: v2-table-list
* 구독: canonical table
*/
export interface RelatedButtonSelectDetail {
target_table: string;
+1 -1
View File
@@ -826,7 +826,7 @@ export interface TemplateComponent {
/**
* ComponentRegistry ID .
* canonical : 'table', 'container', 'stats', 'button', 'input', 'search'
* legacy (alias ): 'v2-table-list', 'v2-button-primary', 'v2-bom-tree'
* legacy (alias ): 'v2-button-primary', 'v2-bom-tree'
*/
componentId: string;
+1 -1
View File
@@ -220,7 +220,7 @@ export interface ComponentComponent extends BaseComponent {
*/
export interface TabInlineComponent {
id: string;
component_type: string; // 컴포넌트 타입 (canonical 예: "table" / "container" / "stats" / "button" — legacy v2-text-display / v2-table-list 도 alias 라우팅으로 호환)
component_type: string; // 컴포넌트 타입 (canonical 예: "table" / "container" / "stats" / "button" — 일부 legacy id 도 alias 라우팅으로 호환)
label?: string;
position: Position; // 탭 내부에서의 위치
size: Size; // 컴포넌트 크기
+1 -1
View File
@@ -50,7 +50,7 @@ export interface TableColumn {
*
*/
export interface TableRegistration {
table_id: string; // 고유 ID (예: "table-list-123")
table_id: string; // 고유 ID (예: "table-123")
label: string; // 사용자에게 보이는 이름 (예: "품목 관리")
table_name: string; // 실제 DB 테이블명 (예: "item_info")
columns: TableColumn[];
+6 -69
View File
@@ -1,10 +1,7 @@
/**
* V2 ( 7)
* V2 ()
*
* - V2Text
* - V2List
* - V2Layout
* - V2Group
* - V2Biz
@@ -13,6 +10,7 @@
* / Phase D.3 (2026-05-12) canonical `input`
* (InputComponent + InvFieldConfigPanel) .
* V2Date InvField triple type=date 4 format(date/datetime/time/range) .
* V2List Phase F.8 (2026-05-21) canonical TableComponent .
* InputComponent + lib/registry/components/input/pickers.tsx.
*/
@@ -25,7 +23,6 @@ import { Position, Size, CommonStyle, ValidationRule } from "./v2-core";
*/
export type V2ComponentType =
| "V2Text"
| "V2List"
| "V2Layout"
| "V2Group"
| "V2Biz"
@@ -122,59 +119,7 @@ export interface V2TextProps extends V2BaseProps {
}
// V2Media 타입 정의는 Phase D.5 (2026-05-12) 에서 제거됨 — canonical input 의 file 분기로 흡수.
// ===== V2List =====
export type V2ListViewMode = "table" | "card" | "kanban" | "list";
export interface ListColumn {
field: string;
header: string;
width?: number;
sortable?: boolean;
filterable?: boolean;
editable?: boolean;
format?: string;
}
export interface V2ListCardConfig {
title_column?: string;
subtitle_column?: string;
description_column?: string;
image_column?: string;
cards_per_row?: number;
card_spacing?: number;
show_actions?: boolean;
}
export interface V2ListConfig {
view_mode: V2ListViewMode;
editable?: boolean;
searchable?: boolean;
pageable?: boolean;
page_size?: number;
sortable?: boolean;
pagination?: boolean;
source?: "static" | "db" | "api"; // 데이터 소스 타입
columns?: ListColumn[];
modal?: boolean;
card_config?: V2ListCardConfig;
// 데이터 소스
data_source?: {
table?: string;
api?: string;
filters?: Array<{ column: string; operator: string; value: unknown }>;
};
}
export interface V2ListProps extends V2BaseProps {
v2Type: "V2List";
config: V2ListConfig;
data?: Record<string, unknown>[];
selected_rows?: Record<string, unknown>[];
onRowSelect?: (rows: Record<string, unknown>[]) => void;
onRowClick?: (row: Record<string, unknown>) => void;
}
// V2List 타입 정의는 Phase F.8 (2026-05-21) 에서 제거됨 — canonical TableComponent + TableConfig 로 흡수.
// ===== V2Layout =====
@@ -291,7 +236,6 @@ export interface V2HierarchyProps extends V2BaseProps {
export type V2ComponentProps =
| V2TextProps
| V2ListProps
| V2LayoutProps
| V2GroupProps
| V2BizProps
@@ -304,10 +248,7 @@ export function isV2Text(props: V2ComponentProps): props is V2TextProps {
}
// isV2Media 는 Phase D.5 에서 제거됨 (canonical input 의 file 분기로 흡수)
export function isV2List(props: V2ComponentProps): props is V2ListProps {
return props.v2Type === "V2List";
}
// isV2List 는 Phase F.8 에서 제거됨 (canonical table 로 흡수)
export function isV2Layout(props: V2ComponentProps): props is V2LayoutProps {
return props.v2Type === "V2Layout";
@@ -353,12 +294,8 @@ export const LEGACY_TO_V2_MAP: Record<string, V2ComponentType> = {
// Media 계열 — Phase D.5 에서 canonical input 으로 흡수, 매핑 제거.
// List 계열
// ★ 2026-05-19 table-list 는 canonical table alias 로 라우팅 — 매핑 제거
"table-search-widget": "V2List",
"modal-repeater-table": "V2List",
"repeater-field-group": "V2List",
"card-display": "V2List",
// List 계열 — Phase F.8 (2026-05-21) 에서 매핑 제거. canonical table 사용.
// (table-search-widget / modal-repeater-table / repeater-field-group / card-display 매핑 폐기)
// Layout 계열
// ★ split-panel-layout 보존 (master-detail UX 다름)