refactor: complete canonical table cleanup
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s
Build & Deploy to K8s / build-and-deploy (push) Failing after 14m3s
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 분기로 흡수.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "통합 레이아웃",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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; // 바인딩된 필드명 (컬럼명)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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, // 전체 너비
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 데이터 삭제
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; // 컴포넌트 크기
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 다름)
|
||||
|
||||
Reference in New Issue
Block a user