선택항목 상게입력 컴포넌트 구현
This commit is contained in:
@@ -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,
|
||||
// 설정 변경 핸들러 전달
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
{/* 제어 셀렉트박스 */}
|
||||
|
||||
@@ -53,6 +53,7 @@ import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||
|
||||
// 🆕 조건부 컨테이너 컴포넌트
|
||||
import "./conditional-container/ConditionalContainerRenderer";
|
||||
import "./selected-items-detail-input/SelectedItemsDetailInputRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
||||
@@ -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의 데이터가 자동으로 정리됩니다.
|
||||
|
||||
## 향후 개선 사항
|
||||
|
||||
- [ ] 일괄 수정 기능 (모든 항목에 같은 값 적용)
|
||||
- [ ] 엑셀 업로드로 일괄 입력
|
||||
- [ ] 조건부 필드 표시 (특정 조건에서만 필드 활성화)
|
||||
- [ ] 커스텀 검증 규칙
|
||||
- [ ] 실시간 계산 필드 (단가 × 수량 = 금액)
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user