선택항목 상게입력 컴포넌트 구현

This commit is contained in:
kjs
2025-11-17 12:23:45 +09:00
parent 2c099feea0
commit a6e6a14fd1
18 changed files with 2279 additions and 6 deletions

View File

@@ -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<DynamicComponentRendererProps> =
onConfigChange,
isPreview,
autoGeneration,
onHeightChange, // 🆕 높이 변화 콜백
...restProps
} = props;
@@ -299,6 +302,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 숨김 값 추출
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<DynamicComponentRendererProps> =
tableDisplayData, // 🆕 화면 표시 데이터
// 플로우 선택된 데이터 정보 전달
flowSelectedData,
// 🆕 조건부 컨테이너 높이 변화 콜백
onHeightChange: handleHeightChange,
componentId: component.id,
flowSelectedStepId,
onFlowSelectedDataChange,
// 설정 변경 핸들러 전달

View File

@@ -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<HTMLDivElement>(null);
const previousHeightRef = useRef<number>(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 (
<div
ref={containerRef}
className={cn("h-full w-full flex flex-col", spacingClass, className)}
className={cn("w-full flex flex-col", spacingClass, className)}
style={style}
>
{/* 제어 셀렉트박스 */}

View File

@@ -53,6 +53,7 @@ import "./order-registration-modal/OrderRegistrationModalRenderer";
// 🆕 조건부 컨테이너 컴포넌트
import "./conditional-container/ConditionalContainerRenderer";
import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
/**
* 컴포넌트 초기화 함수

View File

@@ -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] 품목 선택
<TableList id="item-selection-table" multiSelect={true} />
<Button action="openModalWithData" targetScreenId="detail-input-modal" dataSourceId="item-selection-table" />
// [모달 2] 상세 입력
<SelectedItemsDetailInput
dataSourceId="item-selection-table"
displayColumns={["item_code", "item_name", "spec"]}
additionalFields={[
{ name: "customer_item_code", label: "거래처 품번", type: "text" },
{ name: "unit_price", label: "단가", type: "number", required: true },
{ name: "quantity", label: "수량", type: "number", required: true }
]}
targetTable="sales_detail"
/>
<Button action="save" />
```
## 주의사항
1. **dataSourceId 일치**: 첫 번째 모달의 TableList ID와 두 번째 모달의 dataSourceId가 정확히 일치해야 합니다.
2. **컬럼명 정확성**: displayColumns와 additionalFields의 name은 실제 데이터베이스 컬럼명과 일치해야 합니다.
3. **필수 필드 검증**: required=true인 필드는 반드시 입력해야 저장이 가능합니다.
4. **데이터 정리**: 모달이 닫힐 때 modalDataStore의 데이터가 자동으로 정리됩니다.
## 향후 개선 사항
- [ ] 일괄 수정 기능 (모든 항목에 같은 값 적용)
- [ ] 엑셀 업로드로 일괄 입력
- [ ] 조건부 필드 표시 (특정 조건에서만 필드 활성화)
- [ ] 커스텀 검증 규칙
- [ ] 실시간 계산 필드 (단가 × 수량 = 금액)

View File

@@ -0,0 +1,396 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { ComponentRendererProps } from "@/types/component";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
import { useModalDataStore, ModalDataItem } from "@/stores/modalDataStore";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps {
config?: SelectedItemsDetailInputConfig;
}
/**
* SelectedItemsDetailInput 컴포넌트
* 선택된 항목들의 상세 정보를 입력하는 컴포넌트
*/
export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInputComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
screenId,
...props
}) => {
// 컴포넌트 설정
const componentConfig = useMemo(() => ({
dataSourceId: component.id || "default",
displayColumns: [],
additionalFields: [],
layout: "grid",
showIndex: true,
allowRemove: false,
emptyMessage: "전달받은 데이터가 없습니다.",
targetTable: "",
...config,
...component.config,
} as SelectedItemsDetailInputConfig), [config, component.config, component.id]);
// 모달 데이터 스토어에서 데이터 가져오기
// dataSourceId를 안정적으로 유지
const dataSourceId = useMemo(
() => componentConfig.dataSourceId || component.id || "default",
[componentConfig.dataSourceId, component.id]
);
// 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피)
const dataRegistry = useModalDataStore((state) => state.dataRegistry);
const modalData = useMemo(
() => dataRegistry[dataSourceId] || [],
[dataRegistry, dataSourceId]
);
const updateItemData = useModalDataStore((state) => state.updateItemData);
// 로컬 상태로 데이터 관리
const [items, setItems] = useState<ModalDataItem[]>([]);
// 모달 데이터가 변경되면 로컬 상태 업데이트
useEffect(() => {
if (modalData && modalData.length > 0) {
console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData);
setItems(modalData);
// formData에도 반영 (초기 로드 시에만)
if (onFormDataChange && items.length === 0) {
onFormDataChange({ [component.id || "selected_items"]: modalData });
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalData, component.id]); // onFormDataChange는 의존성에서 제외
// 스타일 계산
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
componentStyle.padding = "16px";
componentStyle.borderRadius = "8px";
}
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// 필드 값 변경 핸들러
const handleFieldChange = useCallback((itemId: string | number, fieldName: string, value: any) => {
// 상태 업데이트
setItems((prevItems) => {
const updatedItems = prevItems.map((item) =>
item.id === itemId
? {
...item,
additionalData: {
...item.additionalData,
[fieldName]: value,
},
}
: item
);
// formData에도 반영 (디바운스 없이 즉시 반영)
if (onFormDataChange) {
onFormDataChange({ [component.id || "selected_items"]: updatedItems });
}
return updatedItems;
});
// 스토어에도 업데이트
updateItemData(dataSourceId, itemId, { [fieldName]: value });
}, [dataSourceId, updateItemData, onFormDataChange, component.id]);
// 항목 제거 핸들러
const handleRemoveItem = (itemId: string | number) => {
setItems((prevItems) => prevItems.filter((item) => item.id !== itemId));
};
// 개별 필드 렌더링
const renderField = (field: AdditionalFieldDefinition, item: ModalDataItem) => {
const value = item.additionalData?.[field.name] || field.defaultValue || "";
const commonProps = {
value: value || "",
disabled: componentConfig.disabled || componentConfig.readonly,
placeholder: field.placeholder,
required: field.required,
};
switch (field.type) {
case "select":
return (
<Select
value={value || ""}
onValueChange={(val) => handleFieldChange(item.id, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "textarea":
return (
<Textarea
{...commonProps}
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
rows={2}
className="resize-none text-xs sm:text-sm"
/>
);
case "date":
return (
<Input
{...commonProps}
type="date"
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
);
case "number":
return (
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
);
case "checkbox":
return (
<Checkbox
checked={value === true || value === "true"}
onCheckedChange={(checked) => handleFieldChange(item.id, field.name, checked)}
disabled={componentConfig.disabled || componentConfig.readonly}
/>
);
default: // text
return (
<Input
{...commonProps}
type="text"
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
maxLength={field.validation?.maxLength}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
);
}
};
// 빈 상태 렌더링
if (items.length === 0) {
return (
<div style={componentStyle} className={className} onClick={handleClick}>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
<p className="text-sm text-muted-foreground">{componentConfig.emptyMessage}</p>
{isDesignMode && (
<p className="mt-2 text-xs text-muted-foreground">
💡 "다음" .
</p>
)}
</div>
</div>
);
}
// Grid 레이아웃 렌더링
const renderGridLayout = () => {
return (
<div className="overflow-auto bg-card">
<Table>
<TableHeader>
<TableRow className="bg-background">
{componentConfig.showIndex && (
<TableHead className="h-12 w-12 px-4 py-3 text-center text-xs font-semibold sm:text-sm">#</TableHead>
)}
{/* 원본 데이터 컬럼 */}
{componentConfig.displayColumns?.map((colName) => (
<TableHead key={colName} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{colName}
</TableHead>
))}
{/* 추가 입력 필드 컬럼 */}
{componentConfig.additionalFields?.map((field) => (
<TableHead key={field.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
</TableHead>
))}
{componentConfig.allowRemove && (
<TableHead className="h-12 w-20 px-4 py-3 text-center text-xs font-semibold sm:text-sm"></TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id} className="bg-background transition-colors hover:bg-muted/50">
{/* 인덱스 번호 */}
{componentConfig.showIndex && (
<TableCell className="h-14 px-4 py-3 text-center text-xs font-medium sm:text-sm">
{index + 1}
</TableCell>
)}
{/* 원본 데이터 표시 */}
{componentConfig.displayColumns?.map((colName) => (
<TableCell key={colName} className="h-14 px-4 py-3 text-xs sm:text-sm">
{item.originalData[colName] || "-"}
</TableCell>
))}
{/* 추가 입력 필드 */}
{componentConfig.additionalFields?.map((field) => (
<TableCell key={field.name} className="h-14 px-4 py-3">
{renderField(field, item)}
</TableCell>
))}
{/* 삭제 버튼 */}
{componentConfig.allowRemove && (
<TableCell className="h-14 px-4 py-3 text-center">
{!componentConfig.disabled && !componentConfig.readonly && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(item.id)}
className="h-7 w-7 text-destructive hover:bg-destructive/10 hover:text-destructive sm:h-8 sm:w-8"
title="항목 제거"
>
<X className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
// Card 레이아웃 렌더링
const renderCardLayout = () => {
return (
<div className="space-y-4">
{items.map((item, index) => (
<Card key={item.id} className="relative">
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-sm font-semibold sm:text-base">
{componentConfig.showIndex && `${index + 1}. `}
{item.originalData[componentConfig.displayColumns?.[0] || "name"] || `항목 ${index + 1}`}
</CardTitle>
{componentConfig.allowRemove && !componentConfig.disabled && !componentConfig.readonly && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(item.id)}
className="h-7 w-7 text-destructive hover:bg-destructive/10 sm:h-8 sm:w-8"
>
<X className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
)}
</CardHeader>
<CardContent className="space-y-3">
{/* 원본 데이터 표시 */}
{componentConfig.displayColumns?.map((colName) => (
<div key={colName} className="flex items-center justify-between text-xs sm:text-sm">
<span className="font-medium text-muted-foreground">{colName}:</span>
<span>{item.originalData[colName] || "-"}</span>
</div>
))}
{/* 추가 입력 필드 */}
{componentConfig.additionalFields?.map((field) => (
<div key={field.name} className="space-y-1">
<label className="text-xs font-medium sm:text-sm">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
</label>
{renderField(field, item)}
</div>
))}
</CardContent>
</Card>
))}
</div>
);
};
return (
<div style={componentStyle} className={cn("space-y-4", className)} onClick={handleClick}>
{/* 레이아웃에 따라 렌더링 */}
{componentConfig.layout === "grid" ? renderGridLayout() : renderCardLayout()}
{/* 항목 수 표시 */}
<div className="flex justify-between text-xs text-muted-foreground">
<span> {items.length} </span>
{componentConfig.targetTable && <span> : {componentConfig.targetTable}</span>}
</div>
</div>
);
};
/**
* SelectedItemsDetailInput 래퍼 컴포넌트
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
*/
export const SelectedItemsDetailInputWrapper: React.FC<SelectedItemsDetailInputComponentProps> = (props) => {
return <SelectedItemsDetailInputComponent {...props} />;
};

View File

@@ -0,0 +1,474 @@
"use client";
import React, { useState, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Plus, X } from "lucide-react";
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectedItemsDetailInputConfigPanelProps {
config: SelectedItemsDetailInputConfig;
onChange: (config: Partial<SelectedItemsDetailInputConfig>) => void;
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
allTables?: Array<{ tableName: string; displayName?: string }>;
onTableChange?: (tableName: string) => void;
}
/**
* SelectedItemsDetailInput 설정 패널
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
*/
export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailInputConfigPanelProps> = ({
config,
onChange,
tableColumns = [],
allTables = [],
onTableChange,
}) => {
const [localFields, setLocalFields] = useState<AdditionalFieldDefinition[]>(config.additionalFields || []);
const [displayColumns, setDisplayColumns] = useState<string[]>(config.displayColumns || []);
const [fieldPopoverOpen, setFieldPopoverOpen] = useState<Record<number, boolean>>({});
const handleChange = (key: keyof SelectedItemsDetailInputConfig, value: any) => {
onChange({ [key]: value });
};
const handleFieldsChange = (fields: AdditionalFieldDefinition[]) => {
setLocalFields(fields);
handleChange("additionalFields", fields);
};
const handleDisplayColumnsChange = (columns: string[]) => {
setDisplayColumns(columns);
handleChange("displayColumns", columns);
};
// 필드 추가
const addField = () => {
const newField: AdditionalFieldDefinition = {
name: `field_${localFields.length + 1}`,
label: `필드 ${localFields.length + 1}`,
type: "text",
};
handleFieldsChange([...localFields, newField]);
};
// 필드 제거
const removeField = (index: number) => {
handleFieldsChange(localFields.filter((_, i) => i !== index));
};
// 필드 수정
const updateField = (index: number, updates: Partial<AdditionalFieldDefinition>) => {
const newFields = [...localFields];
newFields[index] = { ...newFields[index], ...updates };
handleFieldsChange(newFields);
};
// 표시 컬럼 추가
const addDisplayColumn = (columnName: string) => {
if (!displayColumns.includes(columnName)) {
handleDisplayColumnsChange([...displayColumns, columnName]);
}
};
// 표시 컬럼 제거
const removeDisplayColumn = (columnName: string) => {
handleDisplayColumnsChange(displayColumns.filter((col) => col !== columnName));
};
// 사용되지 않은 컬럼 목록
const availableColumns = useMemo(() => {
const usedColumns = new Set([...displayColumns, ...localFields.map((f) => f.name)]);
return tableColumns.filter((col) => !usedColumns.has(col.columnName));
}, [tableColumns, displayColumns, localFields]);
// 테이블 선택 Combobox 상태
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [tableSearchValue, setTableSearchValue] = useState("");
// 필터링된 테이블 목록
const filteredTables = useMemo(() => {
if (!tableSearchValue) return allTables;
const searchLower = tableSearchValue.toLowerCase();
return allTables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchLower) || table.displayName?.toLowerCase().includes(searchLower),
);
}, [allTables, tableSearchValue]);
// 선택된 테이블 표시명
const selectedTableLabel = useMemo(() => {
if (!config.targetTable) return "테이블을 선택하세요";
const table = allTables.find((t) => t.tableName === config.targetTable);
return table ? table.displayName || table.tableName : config.targetTable;
}, [config.targetTable, allTables]);
return (
<div className="space-y-4">
{/* 데이터 소스 ID */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> ID</Label>
<Input
value={config.dataSourceId || ""}
onChange={(e) => handleChange("dataSourceId", e.target.value)}
placeholder="table-list-123"
className="h-7 text-xs sm:h-8 sm:text-sm"
/>
<p className="text-[10px] text-gray-500 sm:text-xs">
💡 ID ( TableList의 ID)
</p>
</div>
{/* 저장 대상 테이블 */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSelectOpen}
className="h-7 w-full justify-between text-xs sm:h-8 sm:text-sm"
>
{selectedTableLabel}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50 sm:h-4 sm:w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="테이블 검색..."
value={tableSearchValue}
onValueChange={setTableSearchValue}
className="text-xs sm:text-sm"
/>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup className="max-h-48 overflow-auto sm:max-h-64">
{filteredTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
handleChange("targetTable", currentValue);
setTableSelectOpen(false);
setTableSearchValue("");
if (onTableChange) {
onTableChange(currentValue);
}
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-3 w-3 sm:h-4 sm:w-4",
config.targetTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-gray-500 sm:text-xs"> </p>
</div>
{/* 표시할 원본 데이터 컬럼 */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
<div className="space-y-2">
{displayColumns.map((colName, index) => (
<div key={index} className="flex items-center gap-2">
<Input value={colName} readOnly className="h-7 flex-1 text-xs sm:h-8 sm:text-sm" />
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeDisplayColumn(colName)}
className="h-6 w-6 text-red-500 hover:bg-red-50 sm:h-7 sm:w-7"
>
<X className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
))}
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 w-full border-dashed text-xs sm:text-sm"
>
<Plus className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup className="max-h-48 overflow-auto sm:max-h-64">
{availableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => addDisplayColumn(column.columnName)}
className="text-xs sm:text-sm"
>
<div>
<div className="font-medium">{column.columnLabel || column.columnName}</div>
{column.dataType && <div className="text-[10px] text-gray-500">{column.dataType}</div>}
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<p className="text-[10px] text-gray-500 sm:text-xs">
(: 품목코드, )
</p>
</div>
{/* 추가 입력 필드 정의 */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"> </Label>
{localFields.map((field, index) => (
<Card key={index} className="border-2">
<CardContent className="space-y-2 pt-3 sm:space-y-3 sm:pt-4">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-gray-700 sm:text-sm"> {index + 1}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(index)}
className="h-5 w-5 text-red-500 hover:bg-red-50 sm:h-6 sm:w-6"
>
<X className="h-2 w-2 sm:h-3 sm:w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"> ()</Label>
<Popover
open={fieldPopoverOpen[index] || false}
onOpenChange={(open) => setFieldPopoverOpen({ ...fieldPopoverOpen, [index]: open })}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-6 w-full justify-between text-[10px] sm:h-7 sm:text-xs"
>
{field.name || "컬럼 선택"}
<ChevronsUpDown className="ml-1 h-2 w-2 shrink-0 opacity-50 sm:h-3 sm:w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0 sm:w-[200px]">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-6 text-[10px] sm:h-7 sm:text-xs" />
<CommandEmpty className="text-[10px] sm:text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[150px] overflow-auto sm:max-h-[200px]">
{availableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateField(index, {
name: column.columnName,
label: column.columnLabel || column.columnName,
});
setFieldPopoverOpen({ ...fieldPopoverOpen, [index]: false });
}}
className="text-[10px] sm:text-xs"
>
<Check
className={cn(
"mr-1 h-2 w-2 sm:mr-2 sm:h-3 sm:w-3",
field.name === column.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div>
<div className="font-medium">{column.columnLabel}</div>
<div className="text-[9px] text-gray-500">{column.columnName}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"></Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="필드 라벨"
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2 sm:gap-3">
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs"></Label>
<Select
value={field.type}
onValueChange={(value) =>
updateField(index, { type: value as AdditionalFieldDefinition["type"] })
}
>
<SelectTrigger className="h-6 w-full text-[10px] sm:h-7 sm:text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="number" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="date" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="select" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="checkbox" className="text-[10px] sm:text-xs">
</SelectItem>
<SelectItem value="textarea" className="text-[10px] sm:text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] sm:text-xs">Placeholder</Label>
<Input
value={field.placeholder || ""}
onChange={(e) => updateField(index, { placeholder: e.target.value })}
placeholder="입력 안내"
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${index}`}
checked={field.required ?? false}
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
/>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-[10px] font-normal sm:text-xs">
</Label>
</div>
</CardContent>
</Card>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addField}
className="h-7 w-full border-dashed text-xs sm:text-sm"
>
<Plus className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
</Button>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-2">
<Label className="text-xs font-semibold sm:text-sm"></Label>
<Select
value={config.layout || "grid"}
onValueChange={(value) => handleChange("layout", value as "grid" | "card")}
>
<SelectTrigger className="h-7 text-xs sm:h-8 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="grid" className="text-xs sm:text-sm">
(Grid)
</SelectItem>
<SelectItem value="card" className="text-xs sm:text-sm">
(Card)
</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground sm:text-xs">
{config.layout === "grid" ? "행 단위로 데이터를 표시합니다" : "각 항목을 카드로 표시합니다"}
</p>
</div>
{/* 옵션 */}
<div className="space-y-2 rounded-lg border p-3 sm:p-4">
<div className="flex items-center space-x-2">
<Checkbox
id="show-index"
checked={config.showIndex ?? true}
onCheckedChange={(checked) => handleChange("showIndex", checked as boolean)}
/>
<Label htmlFor="show-index" className="cursor-pointer text-[10px] font-normal sm:text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="allow-remove"
checked={config.allowRemove ?? false}
onCheckedChange={(checked) => handleChange("allowRemove", checked as boolean)}
/>
<Label htmlFor="allow-remove" className="cursor-pointer text-[10px] font-normal sm:text-xs">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="disabled"
checked={config.disabled ?? false}
onCheckedChange={(checked) => handleChange("disabled", checked as boolean)}
/>
<Label htmlFor="disabled" className="cursor-pointer text-[10px] font-normal sm:text-xs">
( )
</Label>
</div>
</div>
{/* 사용 예시 */}
<div className="rounded-lg bg-blue-50 p-2 text-xs sm:p-3 sm:text-sm">
<p className="mb-1 font-medium text-blue-900">💡 </p>
<ul className="space-y-1 text-[10px] text-blue-700 sm:text-xs">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
);
};
SelectedItemsDetailInputConfigPanel.displayName = "SelectedItemsDetailInputConfigPanel";

View File

@@ -0,0 +1,51 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SelectedItemsDetailInputDefinition } from "./index";
import { SelectedItemsDetailInputComponent } from "./SelectedItemsDetailInputComponent";
/**
* SelectedItemsDetailInput 렌더러
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
*/
export class SelectedItemsDetailInputRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SelectedItemsDetailInputDefinition;
render(): React.ReactElement {
return <SelectedItemsDetailInputComponent {...this.props} renderer={this} />;
}
/**
* 컴포넌트별 특화 메서드들
*/
// text 타입 특화 속성 처리
protected getSelectedItemsDetailInputProps() {
const baseProps = this.getWebTypeProps();
// text 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 text 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
SelectedItemsDetailInputRenderer.registerSelf();

View File

@@ -0,0 +1,47 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { SelectedItemsDetailInputWrapper } from "./SelectedItemsDetailInputComponent";
import { SelectedItemsDetailInputConfigPanel } from "./SelectedItemsDetailInputConfigPanel";
import { SelectedItemsDetailInputConfig } from "./types";
/**
* SelectedItemsDetailInput 컴포넌트 정의
* 선택된 항목들의 상세 정보를 입력하는 컴포넌트
*/
export const SelectedItemsDetailInputDefinition = createComponentDefinition({
id: "selected-items-detail-input",
name: "선택 항목 상세입력",
nameEng: "SelectedItemsDetailInput Component",
description: "선택된 항목들의 상세 정보를 입력하는 컴포넌트",
category: ComponentCategory.INPUT,
webType: "text",
component: SelectedItemsDetailInputWrapper,
defaultConfig: {
dataSourceId: "",
displayColumns: [],
additionalFields: [],
targetTable: "",
layout: "grid",
showIndex: true,
allowRemove: false,
emptyMessage: "전달받은 데이터가 없습니다.",
disabled: false,
readonly: false,
} as SelectedItemsDetailInputConfig,
defaultSize: { width: 800, height: 400 },
configPanel: SelectedItemsDetailInputConfigPanel,
icon: "Table",
tags: ["선택", "상세입력", "반복", "테이블", "데이터전달"],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/selected-items-detail-input",
});
// 컴포넌트는 SelectedItemsDetailInputRenderer에서 자동 등록됩니다
// 타입 내보내기
export type { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";

View File

@@ -0,0 +1,102 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* 추가 입력 필드 정의
*/
export interface AdditionalFieldDefinition {
/** 필드명 (컬럼명) */
name: string;
/** 필드 라벨 */
label: string;
/** 입력 타입 */
type: "text" | "number" | "date" | "select" | "checkbox" | "textarea";
/** 필수 입력 여부 */
required?: boolean;
/** 플레이스홀더 */
placeholder?: string;
/** 기본값 */
defaultValue?: any;
/** 선택 옵션 (type이 select일 때) */
options?: Array<{ label: string; value: string }>;
/** 필드 너비 (px 또는 %) */
width?: string;
/** 검증 규칙 */
validation?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
};
}
/**
* SelectedItemsDetailInput 컴포넌트 설정 타입
*/
export interface SelectedItemsDetailInputConfig extends ComponentConfig {
/**
* 데이터 소스 ID (TableList 컴포넌트 ID 등)
* 이 ID를 통해 modalDataStore에서 데이터를 가져옴
*/
dataSourceId?: string;
/**
* 표시할 원본 데이터 컬럼들
* 예: ["item_code", "item_name", "spec", "unit"]
*/
displayColumns?: string[];
/**
* 추가 입력 필드 정의
*/
additionalFields?: AdditionalFieldDefinition[];
/**
* 저장 대상 테이블
*/
targetTable?: string;
/**
* 레이아웃 모드
* - grid: 테이블 형식 (기본)
* - card: 카드 형식
*/
layout?: "grid" | "card";
/**
* 항목 번호 표시 여부
*/
showIndex?: boolean;
/**
* 항목 삭제 허용 여부
*/
allowRemove?: boolean;
/**
* 빈 상태 메시지
*/
emptyMessage?: string;
// 공통 설정
disabled?: boolean;
readonly?: boolean;
}
/**
* SelectedItemsDetailInput 컴포넌트 Props 타입
*/
export interface SelectedItemsDetailInputProps {
id?: string;
name?: string;
value?: any;
config?: SelectedItemsDetailInputConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onSave?: (data: any[]) => void;
}

View File

@@ -1107,6 +1107,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 🆕 modalDataStore에 선택된 데이터 자동 저장 (테이블명 기반 dataSourceId)
if (tableConfig.selectedTable && selectedRowsData.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
const modalItems = selectedRowsData.map((row, idx) => ({
id: getRowKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
console.log("✅ [TableList] modalDataStore에 데이터 저장:", {
dataSourceId: tableConfig.selectedTable,
count: modalItems.length,
});
});
} else if (tableConfig.selectedTable && selectedRowsData.length === 0) {
// 선택 해제 시 데이터 제거
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
console.log("🗑️ [TableList] modalDataStore 데이터 제거:", tableConfig.selectedTable);
});
}
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0);
};
@@ -1127,6 +1150,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
selectedRowsData: data,
});
}
// 🆕 modalDataStore에 전체 데이터 저장
if (tableConfig.selectedTable && data.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
const modalItems = data.map((row, idx) => ({
id: getRowKey(row, idx),
originalData: row,
additionalData: {},
}));
useModalDataStore.getState().setData(tableConfig.selectedTable!, modalItems);
console.log("✅ [TableList] modalDataStore에 전체 데이터 저장:", {
dataSourceId: tableConfig.selectedTable,
count: modalItems.length,
});
});
}
} else {
setSelectedRows(new Set());
setIsAllSelected(false);
@@ -1137,6 +1177,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (onFormDataChange) {
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
}
// 🆕 modalDataStore 데이터 제거
if (tableConfig.selectedTable) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().clearData(tableConfig.selectedTable!);
console.log("🗑️ [TableList] modalDataStore 전체 데이터 제거:", tableConfig.selectedTable);
});
}
}
};

View File

@@ -16,6 +16,7 @@ export type ButtonActionType =
| "edit" // 편집
| "copy" // 복사 (품목코드 초기화)
| "navigate" // 페이지 이동
| "openModalWithData" // 🆕 데이터를 전달하면서 모달 열기
| "modal" // 모달 열기
| "control" // 제어 흐름
| "view_table_history" // 테이블 이력 보기
@@ -44,6 +45,7 @@ export interface ButtonActionConfig {
modalSize?: "sm" | "md" | "lg" | "xl";
popupWidth?: number;
popupHeight?: number;
dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용)
// 확인 메시지
confirmMessage?: string;
@@ -149,6 +151,9 @@ export class ButtonActionExecutor {
case "navigate":
return this.handleNavigate(config, context);
case "openModalWithData":
return await this.handleOpenModalWithData(config, context);
case "modal":
return await this.handleModal(config, context);
@@ -667,6 +672,83 @@ export class ButtonActionExecutor {
return true;
}
/**
* 🆕 데이터를 전달하면서 모달 열기 액션 처리
*/
private static async handleOpenModalWithData(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<boolean> {
console.log("📦 데이터와 함께 모달 열기:", {
title: config.modalTitle,
size: config.modalSize,
targetScreenId: config.targetScreenId,
dataSourceId: config.dataSourceId,
});
// 1. dataSourceId 확인 (없으면 selectedRows에서 데이터 전달)
const dataSourceId = config.dataSourceId || context.tableName || "default";
// 2. modalDataStore에서 데이터 확인
try {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const modalData = useModalDataStore.getState().dataRegistry[dataSourceId] || [];
if (modalData.length === 0) {
console.warn("⚠️ 전달할 데이터가 없습니다:", dataSourceId);
toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요.");
return false;
}
console.log("✅ 전달할 데이터:", {
dataSourceId,
count: modalData.length,
data: modalData,
});
} catch (error) {
console.error("❌ 데이터 확인 실패:", error);
toast.error("데이터 확인 중 오류가 발생했습니다.");
return false;
}
// 3. 모달 열기
if (config.targetScreenId) {
// config에 modalDescription이 있으면 우선 사용
let description = config.modalDescription || "";
// config에 없으면 화면 정보에서 가져오기
if (!description) {
try {
const screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
}
}
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "데이터 입력",
description: description,
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
},
});
window.dispatchEvent(modalEvent);
// 성공 메시지 (간단하게)
toast.success(config.successMessage || "다음 단계로 진행합니다.");
return true;
} else {
console.error("모달로 열 화면이 지정되지 않았습니다.");
toast.error("대상 화면이 지정되지 않았습니다.");
return false;
}
}
/**
* 새 창 액션 처리
*/
@@ -2599,6 +2681,13 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
navigate: {
type: "navigate",
},
openModalWithData: {
type: "openModalWithData",
modalSize: "md",
confirmMessage: "다음 단계로 진행하시겠습니까?",
successMessage: "데이터가 전달되었습니다.",
errorMessage: "데이터 전달 중 오류가 발생했습니다.",
},
modal: {
type: "modal",
modalSize: "md",

View File

@@ -36,6 +36,9 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
// 🆕 조건부 컨테이너
"conditional-container": () =>
import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
// 🆕 선택 항목 상세입력
"selected-items-detail-input": () =>
import("@/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel"),
};
// ConfigPanel 컴포넌트 캐시
@@ -66,6 +69,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
module.RepeaterConfigPanel || // repeater-field-group의 export명
module.FlowWidgetConfigPanel || // flow-widget의 export명
module.CustomerItemMappingConfigPanel || // customer-item-mapping의 export명
module.SelectedItemsDetailInputConfigPanel || // selected-items-detail-input의 export명
module.default;
if (!ConfigPanelComponent) {