From a6e6a14fd18b0357c29fa0f07923d43e18c3a28d Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 17 Nov 2025 12:23:45 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A0=ED=83=9D=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=83=81=EA=B2=8C=EC=9E=85=EB=A0=A5=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/screens/[screenId]/page.tsx | 62 ++- .../screen/RealtimePreviewDynamic.tsx | 18 +- .../config-panels/ButtonConfigPanel.tsx | 131 +++++ .../components/webtypes/RepeaterInput.tsx | 1 + .../lib/registry/DynamicComponentRenderer.tsx | 11 + .../ConditionalContainerComponent.tsx | 26 +- frontend/lib/registry/components/index.ts | 1 + .../selected-items-detail-input/README.md | 248 +++++++++ .../SelectedItemsDetailInputComponent.tsx | 396 +++++++++++++++ .../SelectedItemsDetailInputConfigPanel.tsx | 474 ++++++++++++++++++ .../SelectedItemsDetailInputRenderer.tsx | 51 ++ .../selected-items-detail-input/index.ts | 47 ++ .../selected-items-detail-input/types.ts | 102 ++++ .../table-list/TableListComponent.tsx | 48 ++ frontend/lib/utils/buttonActions.ts | 89 ++++ .../lib/utils/getComponentConfigPanel.tsx | 4 + frontend/stores/modalDataStore.ts | 163 ++++++ 선택항목_상세입력_컴포넌트_완성_가이드.md | 413 +++++++++++++++ 18 files changed, 2279 insertions(+), 6 deletions(-) create mode 100644 frontend/lib/registry/components/selected-items-detail-input/README.md create mode 100644 frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx create mode 100644 frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx create mode 100644 frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputRenderer.tsx create mode 100644 frontend/lib/registry/components/selected-items-detail-input/index.ts create mode 100644 frontend/lib/registry/components/selected-items-detail-input/types.ts create mode 100644 frontend/stores/modalDataStore.ts create mode 100644 선택항목_상세입력_컴포넌트_완성_가이드.md diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index ebfbd3e7..3b75f262 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -65,6 +65,9 @@ function ScreenViewPage() { // 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨) const [flowRefreshKey, setFlowRefreshKey] = useState(0); + // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) + const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); + // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); const [editModalConfig, setEditModalConfig] = useState<{ @@ -402,19 +405,39 @@ function ScreenViewPage() { (c) => (c as any).componentId === "table-search-widget" ); - // TableSearchWidget 높이 차이를 계산하여 Y 위치 조정 + // 디버그: 모든 컴포넌트 타입 확인 + console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({ + id: c.id, + type: c.type, + componentType: (c as any).componentType, + componentId: (c as any).componentId, + }))); + + // 🆕 조건부 컨테이너들을 찾기 + const conditionalContainers = regularComponents.filter( + (c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container" + ); + + console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({ + id: c.id, + y: c.position.y, + size: c.size, + }))); + + // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 const adjustedComponents = regularComponents.map((component) => { const isTableSearchWidget = (component as any).componentId === "table-search-widget"; + const isConditionalContainer = (component as any).componentId === "conditional-container"; - if (isTableSearchWidget) { - // TableSearchWidget 자체는 조정하지 않음 + if (isTableSearchWidget || isConditionalContainer) { + // 자기 자신은 조정하지 않음 return component; } let totalHeightAdjustment = 0; + // TableSearchWidget 높이 조정 for (const widget of tableSearchWidgets) { - // 현재 컴포넌트가 이 위젯 아래에 있는지 확인 const isBelow = component.position.y > widget.position.y; const heightDiff = getHeightDiff(screenId, widget.id); @@ -423,6 +446,31 @@ function ScreenViewPage() { } } + // 🆕 조건부 컨테이너 높이 조정 + for (const container of conditionalContainers) { + const isBelow = component.position.y > container.position.y; + const actualHeight = conditionalContainerHeights[container.id]; + const originalHeight = container.size?.height || 200; + const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0; + + console.log(`🔍 높이 조정 체크:`, { + componentId: component.id, + componentY: component.position.y, + containerY: container.position.y, + isBelow, + actualHeight, + originalHeight, + heightDiff, + containerId: container.id, + containerSize: container.size, + }); + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`); + } + } + if (totalHeightAdjustment > 0) { return { ...component, @@ -491,6 +539,12 @@ function ScreenViewPage() { onFormDataChange={(fieldName, value) => { setFormData((prev) => ({ ...prev, [fieldName]: value })); }} + onHeightChange={(componentId, newHeight) => { + setConditionalContainerHeights((prev) => ({ + ...prev, + [componentId]: newHeight, + })); + }} > {/* 자식 컴포넌트들 */} {(component.type === "group" || component.type === "container" || component.type === "area") && diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 9ae2db82..57a27da0 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -60,6 +60,9 @@ interface RealtimePreviewProps { sortBy?: string; sortOrder?: "asc" | "desc"; columnOrder?: string[]; + + // 🆕 조건부 컨테이너 높이 변화 콜백 + onHeightChange?: (componentId: string, newHeight: number) => void; } // 동적 위젯 타입 아이콘 (레지스트리에서 조회) @@ -123,6 +126,7 @@ export const RealtimePreviewDynamic: React.FC = ({ onFlowRefresh, formData, onFormDataChange, + onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백 }) => { const [actualHeight, setActualHeight] = React.useState(null); const contentRef = React.useRef(null); @@ -225,6 +229,12 @@ export const RealtimePreviewDynamic: React.FC = ({ }; const getHeight = () => { + // 🆕 조건부 컨테이너는 높이를 자동으로 설정 (내용물에 따라 자동 조정) + const isConditionalContainer = (component as any).componentType === "conditional-container"; + if (isConditionalContainer && !isDesignMode) { + return "auto"; // 런타임에서는 내용물 높이에 맞춤 + } + // 플로우 위젯의 경우 측정된 높이 사용 const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget"; if (isFlowWidget && actualHeight) { @@ -325,7 +335,12 @@ export const RealtimePreviewDynamic: React.FC = ({ (contentRef as any).current = node; } }} - className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`} + className={`${ + (component.type === "component" && (component as any).componentType === "flow-widget") || + ((component as any).componentType === "conditional-container" && !isDesignMode) + ? "h-auto" + : "h-full" + } overflow-visible`} style={{ width: "100%", maxWidth: "100%" }} > = ({ sortBy={sortBy} sortOrder={sortOrder} columnOrder={columnOrder} + onHeightChange={onHeightChange} /> diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 20e6f81b..7bbc4dbe 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -274,6 +274,7 @@ export const ButtonConfigPanel: React.FC = ({ 편집 복사 (품목코드 초기화) 페이지 이동 + 데이터 전달 + 모달 열기 🆕 모달 열기 제어 흐름 테이블 이력 보기 @@ -409,6 +410,136 @@ export const ButtonConfigPanel: React.FC = ({ )} + {/* 🆕 데이터 전달 + 모달 열기 액션 설정 */} + {component.componentConfig?.action?.type === "openModalWithData" && ( +
+

데이터 전달 + 모달 설정

+

+ TableList에서 선택된 데이터를 다음 모달로 전달합니다 +

+ +
+ + { + onUpdateProperty("componentConfig.action.dataSourceId", e.target.value); + }} + /> +

+ TableList에서 데이터를 저장한 ID와 동일해야 합니다 (보통 테이블명) +

+
+ +
+ + { + const newValue = e.target.value; + setLocalInputs((prev) => ({ ...prev, modalTitle: newValue })); + onUpdateProperty("componentConfig.action.modalTitle", newValue); + }} + /> +
+ +
+ + +
+ +
+ + + + + + +
+
+ + setModalSearchTerm(e.target.value)} + className="border-0 p-0 focus-visible:ring-0" + /> +
+
+ {(() => { + const filteredScreens = filterScreens(modalSearchTerm); + if (screensLoading) { + return
화면 목록을 불러오는 중...
; + } + if (filteredScreens.length === 0) { + return
검색 결과가 없습니다.
; + } + return filteredScreens.map((screen, index) => ( +
{ + onUpdateProperty("componentConfig.action.targetScreenId", screen.id); + setModalScreenOpen(false); + setModalSearchTerm(""); + }} + > + +
+ {screen.name} + {screen.description && {screen.description}} +
+
+ )); + })()} +
+
+
+
+

+ SelectedItemsDetailInput 컴포넌트가 있는 화면을 선택하세요 +

+
+
+ )} + {/* 수정 액션 설정 */} {(component.componentConfig?.action?.type || "save") === "edit" && (
diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 5560e2a3..f81e8c9c 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Separator } from "@/components/ui/separator"; import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater"; import { cn } from "@/lib/utils"; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 6ef0028a..35eaa071 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -98,6 +98,8 @@ export interface DynamicComponentRendererProps { screenId?: number; tableName?: string; menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요) + // 🆕 조건부 컨테이너 높이 변화 콜백 + onHeightChange?: (componentId: string, newHeight: number) => void; menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번) selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용) userId?: string; // 🆕 현재 사용자 ID @@ -254,6 +256,7 @@ export const DynamicComponentRenderer: React.FC = onConfigChange, isPreview, autoGeneration, + onHeightChange, // 🆕 높이 변화 콜백 ...restProps } = props; @@ -299,6 +302,11 @@ export const DynamicComponentRenderer: React.FC = // 숨김 값 추출 const hiddenValue = component.hidden || component.componentConfig?.hidden; + // 🆕 조건부 컨테이너용 높이 변화 핸들러 + const handleHeightChange = props.onHeightChange ? (newHeight: number) => { + props.onHeightChange!(component.id, newHeight); + } : undefined; + const rendererProps = { component, isSelected, @@ -347,6 +355,9 @@ export const DynamicComponentRenderer: React.FC = tableDisplayData, // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 전달 flowSelectedData, + // 🆕 조건부 컨테이너 높이 변화 콜백 + onHeightChange: handleHeightChange, + componentId: component.id, flowSelectedStepId, onFlowSelectedDataChange, // 설정 변경 핸들러 전달 diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index ea50c849..d1aff6de 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -13,6 +13,8 @@ import { ConditionalContainerProps, ConditionalSection } from "./types"; import { ConditionalSectionViewer } from "./ConditionalSectionViewer"; import { cn } from "@/lib/utils"; +console.log("🚀 ConditionalContainerComponent 모듈 로드됨!"); + /** * 조건부 컨테이너 컴포넌트 * 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시 @@ -39,6 +41,12 @@ export function ConditionalContainerComponent({ style, className, }: ConditionalContainerProps) { + console.log("🎯 ConditionalContainerComponent 렌더링!", { + isDesignMode, + hasOnHeightChange: !!onHeightChange, + componentId, + }); + // config prop 우선, 없으면 개별 prop 사용 const controlField = config?.controlField || propControlField || "condition"; const controlLabel = config?.controlLabel || propControlLabel || "조건 선택"; @@ -76,8 +84,24 @@ export function ConditionalContainerComponent({ const containerRef = useRef(null); const previousHeightRef = useRef(0); + // 🔍 디버그: props 확인 + useEffect(() => { + console.log("🔍 ConditionalContainer props:", { + isDesignMode, + hasOnHeightChange: !!onHeightChange, + componentId, + selectedValue, + }); + }, [isDesignMode, onHeightChange, componentId, selectedValue]); + // 높이 변화 감지 및 콜백 호출 useEffect(() => { + console.log("🔍 ResizeObserver 등록 조건:", { + hasContainer: !!containerRef.current, + isDesignMode, + hasOnHeightChange: !!onHeightChange, + }); + if (!containerRef.current || isDesignMode || !onHeightChange) return; const resizeObserver = new ResizeObserver((entries) => { @@ -110,7 +134,7 @@ export function ConditionalContainerComponent({ return (
{/* 제어 셀렉트박스 */} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 8b6f94b1..8584932b 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -53,6 +53,7 @@ import "./order-registration-modal/OrderRegistrationModalRenderer"; // 🆕 조건부 컨테이너 컴포넌트 import "./conditional-container/ConditionalContainerRenderer"; +import "./selected-items-detail-input/SelectedItemsDetailInputRenderer"; /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/selected-items-detail-input/README.md b/frontend/lib/registry/components/selected-items-detail-input/README.md new file mode 100644 index 00000000..1b116d60 --- /dev/null +++ b/frontend/lib/registry/components/selected-items-detail-input/README.md @@ -0,0 +1,248 @@ +# SelectedItemsDetailInput 컴포넌트 + +선택된 항목들의 상세 정보를 입력하는 컴포넌트입니다. + +## 개요 + +이 컴포넌트는 다음과 같은 흐름에서 사용됩니다: + +1. **첫 번째 모달**: TableList에서 여러 항목 선택 (체크박스) +2. **버튼 클릭**: "다음" 버튼 클릭 → 선택된 데이터를 modalDataStore에 저장 +3. **두 번째 모달**: SelectedItemsDetailInput이 자동으로 데이터를 읽어와서 표시 +4. **추가 입력**: 각 항목별로 추가 정보 입력 (거래처 품번, 단가 등) +5. **저장**: 모든 데이터를 백엔드로 일괄 전송 + +## 주요 기능 + +- ✅ 전달받은 원본 데이터 표시 (읽기 전용) +- ✅ 각 항목별 추가 입력 필드 제공 +- ✅ Grid/Table 레이아웃 또는 Card 레이아웃 지원 +- ✅ 필드별 타입 지정 (text, number, date, select, checkbox, textarea) +- ✅ 필수 입력 검증 +- ✅ 항목 삭제 기능 (선택적) + +## 사용 방법 + +### 1단계: 첫 번째 모달 (품목 선택) + +```tsx +// TableList 컴포넌트 설정 +{ + type: "table-list", + config: { + selectedTable: "item_info", + multiSelect: true, // 다중 선택 활성화 + columns: [ + { columnName: "item_code", label: "품목코드" }, + { columnName: "item_name", label: "품목명" }, + { columnName: "spec", label: "규격" }, + { columnName: "unit", label: "단위" }, + { columnName: "price", label: "단가" } + ] + } +} + +// "다음" 버튼 설정 +{ + type: "button-primary", + config: { + text: "다음 (상세정보 입력)", + action: { + type: "openModalWithData", // 새 액션 타입 + targetScreenId: "123", // 두 번째 모달 화면 ID + dataSourceId: "table-list-456" // TableList 컴포넌트 ID + } + } +} +``` + +### 2단계: 두 번째 모달 (상세 입력) + +```tsx +// SelectedItemsDetailInput 컴포넌트 설정 +{ + type: "selected-items-detail-input", + config: { + dataSourceId: "table-list-456", // 첫 번째 모달의 TableList ID + targetTable: "sales_detail", // 최종 저장 테이블 + layout: "grid", // 테이블 형식 + + // 전달받은 원본 데이터 중 표시할 컬럼 + displayColumns: ["item_code", "item_name", "spec", "unit"], + + // 추가 입력 필드 정의 + additionalFields: [ + { + name: "customer_item_code", + label: "거래처 품번", + type: "text", + required: false + }, + { + name: "customer_item_name", + label: "거래처 품명", + type: "text", + required: false + }, + { + name: "year", + label: "연도", + type: "select", + required: true, + options: [ + { value: "2024", label: "2024년" }, + { value: "2025", label: "2025년" } + ] + }, + { + name: "currency", + label: "통화단위", + type: "select", + required: true, + options: [ + { value: "KRW", label: "KRW (원)" }, + { value: "USD", label: "USD (달러)" } + ] + }, + { + name: "unit_price", + label: "단가", + type: "number", + required: true + }, + { + name: "quantity", + label: "수량", + type: "number", + required: true + } + ], + + showIndex: true, + allowRemove: true // 항목 삭제 허용 + } +} +``` + +### 3단계: 저장 버튼 + +```tsx +{ + type: "button-primary", + config: { + text: "저장", + action: { + type: "save", + targetTable: "sales_detail", + // formData에 selected_items 데이터가 자동으로 포함됨 + } + } +} +``` + +## 데이터 구조 + +### 전달되는 데이터 형식 + +```typescript +const modalData: ModalDataItem[] = [ + { + id: "SALE-003", // 항목 ID + originalData: { // 원본 데이터 (TableList에서 선택한 행) + item_code: "SALE-003", + item_name: "와셔 M8", + spec: "M8", + unit: "EA", + price: 50 + }, + additionalData: { // 사용자가 입력한 추가 데이터 + customer_item_code: "ABC-001", + customer_item_name: "와셔", + year: "2025", + currency: "KRW", + unit_price: 50, + quantity: 100 + } + }, + // ... 더 많은 항목들 +]; +``` + +## 설정 옵션 + +| 속성 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `dataSourceId` | string | - | 데이터를 전달하는 컴포넌트 ID (필수) | +| `displayColumns` | string[] | [] | 표시할 원본 데이터 컬럼명 | +| `additionalFields` | AdditionalFieldDefinition[] | [] | 추가 입력 필드 정의 | +| `targetTable` | string | - | 최종 저장 대상 테이블 | +| `layout` | "grid" \| "card" | "grid" | 레이아웃 모드 | +| `showIndex` | boolean | true | 항목 번호 표시 여부 | +| `allowRemove` | boolean | false | 항목 삭제 허용 여부 | +| `emptyMessage` | string | "전달받은 데이터가 없습니다." | 빈 상태 메시지 | +| `disabled` | boolean | false | 비활성화 여부 | +| `readonly` | boolean | false | 읽기 전용 여부 | + +## 추가 필드 정의 + +```typescript +interface AdditionalFieldDefinition { + name: string; // 필드명 (컬럼명) + label: string; // 필드 라벨 + type: "text" | "number" | "date" | "select" | "checkbox" | "textarea"; + required?: boolean; // 필수 입력 여부 + placeholder?: string; // 플레이스홀더 + defaultValue?: any; // 기본값 + options?: Array<{ label: string; value: string }>; // 선택 옵션 (select 타입일 때) + validation?: { // 검증 규칙 + min?: number; + max?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + }; +} +``` + +## 실전 예시: 수주 등록 화면 + +### 시나리오 +1. 품목 선택 모달에서 여러 품목 선택 +2. "다음" 버튼 클릭 +3. 각 품목별로 거래처 정보, 단가, 수량 입력 +4. "저장" 버튼으로 일괄 저장 + +### 구현 +```tsx +// [모달 1] 품목 선택 + +