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

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

@ -65,6 +65,9 @@ function ScreenViewPage() {
// 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨)
const [flowRefreshKey, setFlowRefreshKey] = useState(0);
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{
@ -402,19 +405,39 @@ function ScreenViewPage() {
(c) => (c as any).componentId === "table-search-widget"
);
// TableSearchWidget 높이 차이를 계산하여 Y 위치 조정
// 디버그: 모든 컴포넌트 타입 확인
console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({
id: c.id,
type: c.type,
componentType: (c as any).componentType,
componentId: (c as any).componentId,
})));
// 🆕 조건부 컨테이너들을 찾기
const conditionalContainers = regularComponents.filter(
(c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container"
);
console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({
id: c.id,
y: c.position.y,
size: c.size,
})));
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
const adjustedComponents = regularComponents.map((component) => {
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
const isConditionalContainer = (component as any).componentId === "conditional-container";
if (isTableSearchWidget) {
// TableSearchWidget 자체는 조정하지 않음
if (isTableSearchWidget || isConditionalContainer) {
// 자기 자신은 조정하지 않음
return component;
}
let totalHeightAdjustment = 0;
// TableSearchWidget 높이 조정
for (const widget of tableSearchWidgets) {
// 현재 컴포넌트가 이 위젯 아래에 있는지 확인
const isBelow = component.position.y > widget.position.y;
const heightDiff = getHeightDiff(screenId, widget.id);
@ -423,6 +446,31 @@ function ScreenViewPage() {
}
}
// 🆕 조건부 컨테이너 높이 조정
for (const container of conditionalContainers) {
const isBelow = component.position.y > container.position.y;
const actualHeight = conditionalContainerHeights[container.id];
const originalHeight = container.size?.height || 200;
const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0;
console.log(`🔍 높이 조정 체크:`, {
componentId: component.id,
componentY: component.position.y,
containerY: container.position.y,
isBelow,
actualHeight,
originalHeight,
heightDiff,
containerId: container.id,
containerSize: container.size,
});
if (isBelow && heightDiff > 0) {
totalHeightAdjustment += heightDiff;
console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`);
}
}
if (totalHeightAdjustment > 0) {
return {
...component,
@ -491,6 +539,12 @@ function ScreenViewPage() {
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
}}
onHeightChange={(componentId, newHeight) => {
setConditionalContainerHeights((prev) => ({
...prev,
[componentId]: newHeight,
}));
}}
>
{/* 자식 컴포넌트들 */}
{(component.type === "group" || component.type === "container" || component.type === "area") &&

View File

@ -60,6 +60,9 @@ interface RealtimePreviewProps {
sortBy?: string;
sortOrder?: "asc" | "desc";
columnOrder?: string[];
// 🆕 조건부 컨테이너 높이 변화 콜백
onHeightChange?: (componentId: string, newHeight: number) => void;
}
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
@ -123,6 +126,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onFlowRefresh,
formData,
onFormDataChange,
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
}) => {
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
@ -225,6 +229,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
};
const getHeight = () => {
// 🆕 조건부 컨테이너는 높이를 자동으로 설정 (내용물에 따라 자동 조정)
const isConditionalContainer = (component as any).componentType === "conditional-container";
if (isConditionalContainer && !isDesignMode) {
return "auto"; // 런타임에서는 내용물 높이에 맞춤
}
// 플로우 위젯의 경우 측정된 높이 사용
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
if (isFlowWidget && actualHeight) {
@ -325,7 +335,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
(contentRef as any).current = node;
}
}}
className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`}
className={`${
(component.type === "component" && (component as any).componentType === "flow-widget") ||
((component as any).componentType === "conditional-container" && !isDesignMode)
? "h-auto"
: "h-full"
} overflow-visible`}
style={{ width: "100%", maxWidth: "100%" }}
>
<DynamicComponentRenderer
@ -361,6 +376,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
sortBy={sortBy}
sortOrder={sortOrder}
columnOrder={columnOrder}
onHeightChange={onHeightChange}
/>
</div>

View File

@ -274,6 +274,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="openModalWithData"> + 🆕</SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
@ -409,6 +410,136 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div>
)}
{/* 🆕 데이터 전달 + 모달 열기 액션 설정 */}
{component.componentConfig?.action?.type === "openModalWithData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4 dark:bg-blue-950/20">
<h4 className="text-sm font-medium text-foreground"> + </h4>
<p className="text-xs text-muted-foreground">
TableList에서
</p>
<div>
<Label htmlFor="data-source-id"> ID</Label>
<Input
id="data-source-id"
placeholder="예: item_info (테이블명과 동일하게 입력)"
value={component.componentConfig?.action?.dataSourceId || ""}
onChange={(e) => {
onUpdateProperty("componentConfig.action.dataSourceId", e.target.value);
}}
/>
<p className="mt-1 text-xs text-muted-foreground">
TableList에서 ID와 ( )
</p>
</div>
<div>
<Label htmlFor="modal-title-with-data"> </Label>
<Input
id="modal-title-with-data"
placeholder="예: 상세 정보 입력"
value={localInputs.modalTitle}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, modalTitle: newValue }));
onUpdateProperty("componentConfig.action.modalTitle", newValue);
}}
/>
</div>
<div>
<Label htmlFor="modal-size-with-data"> </Label>
<Select
value={component.componentConfig?.action?.modalSize || "lg"}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.modalSize", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="모달 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"> (Small)</SelectItem>
<SelectItem value="md"> (Medium)</SelectItem>
<SelectItem value="lg"> (Large) - </SelectItem>
<SelectItem value="xl"> (Extra Large)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="target-screen-with-data"> </Label>
<Popover open={modalScreenOpen} onOpenChange={setModalScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modalScreenOpen}
className="h-6 w-full justify-between px-2 py-0"
style={{ fontSize: "12px" }}
disabled={screensLoading}
>
{config.action?.targetScreenId
? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..."
: "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<div className="flex flex-col">
<div className="flex items-center border-b px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<Input
placeholder="화면 검색..."
value={modalSearchTerm}
onChange={(e) => setModalSearchTerm(e.target.value)}
className="border-0 p-0 focus-visible:ring-0"
/>
</div>
<div className="max-h-[200px] overflow-auto">
{(() => {
const filteredScreens = filterScreens(modalSearchTerm);
if (screensLoading) {
return <div className="p-3 text-sm text-muted-foreground"> ...</div>;
}
if (filteredScreens.length === 0) {
return <div className="p-3 text-sm text-muted-foreground"> .</div>;
}
return filteredScreens.map((screen, index) => (
<div
key={`modal-data-screen-${screen.id}-${index}`}
className="flex cursor-pointer items-center px-3 py-2 hover:bg-muted"
onClick={() => {
onUpdateProperty("componentConfig.action.targetScreenId", screen.id);
setModalScreenOpen(false);
setModalSearchTerm("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.name}</span>
{screen.description && <span className="text-xs text-muted-foreground">{screen.description}</span>}
</div>
</div>
));
})()}
</div>
</div>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-muted-foreground">
SelectedItemsDetailInput
</p>
</div>
</div>
)}
{/* 수정 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "edit" && (
<div className="mt-4 space-y-4 rounded-lg border bg-success/10 p-4">

View File

@ -7,6 +7,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator";
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
import { cn } from "@/lib/utils";

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) {

View File

@ -0,0 +1,163 @@
"use client";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
/**
*
*
* :
* 1.
* 2.
* 3.
*/
export interface ModalDataItem {
/**
* ID ( ID나 )
*/
id: string | number;
/**
* ( )
*/
originalData: Record<string, any>;
/**
* ( )
*/
additionalData?: Record<string, any>;
}
interface ModalDataState {
/**
*
* key: 소스 ID (: "table-list-123")
* value: 선택된
*/
dataRegistry: Record<string, ModalDataItem[]>;
/**
* ( )
* @param sourceId ID
* @param items
*/
setData: (sourceId: string, items: ModalDataItem[]) => void;
/**
* ( )
* @param sourceId ID
* @returns ( )
*/
getData: (sourceId: string) => ModalDataItem[];
/**
* ( )
* @param sourceId ID
*/
clearData: (sourceId: string) => void;
/**
*
*/
clearAll: () => void;
/**
*
* @param sourceId ID
* @param itemId ID
* @param additionalData
*/
updateItemData: (sourceId: string, itemId: string | number, additionalData: Record<string, any>) => void;
}
export const useModalDataStore = create<ModalDataState>()(
devtools(
(set, get) => ({
dataRegistry: {},
setData: (sourceId, items) => {
console.log("📦 [ModalDataStore] 데이터 저장:", { sourceId, itemCount: items.length, items });
set((state) => ({
dataRegistry: {
...state.dataRegistry,
[sourceId]: items,
},
}));
},
getData: (sourceId) => {
const items = get().dataRegistry[sourceId] || [];
console.log("📭 [ModalDataStore] 데이터 조회:", { sourceId, itemCount: items.length });
return items;
},
clearData: (sourceId) => {
console.log("🗑️ [ModalDataStore] 데이터 정리:", { sourceId });
set((state) => {
const { [sourceId]: _, ...rest } = state.dataRegistry;
return { dataRegistry: rest };
});
},
clearAll: () => {
console.log("🗑️ [ModalDataStore] 모든 데이터 정리");
set({ dataRegistry: {} });
},
updateItemData: (sourceId, itemId, additionalData) => {
set((state) => {
const items = state.dataRegistry[sourceId] || [];
const updatedItems = items.map((item) =>
item.id === itemId
? { ...item, additionalData: { ...item.additionalData, ...additionalData } }
: item
);
console.log("✏️ [ModalDataStore] 항목 데이터 업데이트:", { sourceId, itemId, additionalData });
return {
dataRegistry: {
...state.dataRegistry,
[sourceId]: updatedItems,
},
};
});
},
}),
{ name: "ModalDataStore" }
)
);
/**
* Hook
*
* @example
* const items = useModalData("table-list-123");
*/
export const useModalData = (sourceId: string) => {
return useModalDataStore((state) => state.getData(sourceId));
};
/**
* Hook
*
* @example
* const setModalData = useSetModalData();
* setModalData("table-list-123", selectedItems);
*/
export const useSetModalData = () => {
return useModalDataStore((state) => state.setData);
};
/**
* Hook
*
* @example
* const clearModalData = useClearModalData();
* clearModalData("table-list-123");
*/
export const useClearModalData = () => {
return useModalDataStore((state) => state.clearData);
};

View File

@ -0,0 +1,413 @@
# 선택 항목 상세입력 컴포넌트 - 완성 가이드
## 📦 구현 완료 사항
### ✅ 1. Zustand 스토어 생성 (modalDataStore)
- 파일: `frontend/stores/modalDataStore.ts`
- 기능:
- 모달 간 데이터 전달 관리
- `setData()`: 데이터 저장
- `getData()`: 데이터 조회
- `clearData()`: 데이터 정리
- `updateItemData()`: 항목별 추가 데이터 업데이트
### ✅ 2. SelectedItemsDetailInput 컴포넌트 생성
- 디렉토리: `frontend/lib/registry/components/selected-items-detail-input/`
- 파일들:
- `types.ts`: 타입 정의
- `SelectedItemsDetailInputComponent.tsx`: 메인 컴포넌트
- `SelectedItemsDetailInputConfigPanel.tsx`: 설정 패널
- `SelectedItemsDetailInputRenderer.tsx`: 렌더러
- `index.ts`: 컴포넌트 정의
- `README.md`: 사용 가이드
### ✅ 3. 컴포넌트 기능
- 전달받은 원본 데이터 표시 (읽기 전용)
- 각 항목별 추가 입력 필드 제공
- Grid/Table 레이아웃 및 Card 레이아웃 지원
- 6가지 입력 타입 지원 (text, number, date, select, checkbox, textarea)
- 필수 입력 검증
- 항목 삭제 기능
### ✅ 4. 설정 패널 기능
- 데이터 소스 ID 설정
- 저장 대상 테이블 선택 (검색 가능한 Combobox)
- 표시할 원본 데이터 컬럼 선택
- 추가 입력 필드 정의 (필드명, 라벨, 타입, 필수 여부 등)
- 레이아웃 모드 선택 (Grid/Card)
- 옵션 설정 (번호 표시, 삭제 허용, 비활성화)
---
## 🚧 남은 작업 (구현 필요)
### 1. TableList에서 선택된 행 데이터를 스토어에 저장
**필요한 수정 파일:**
- `frontend/lib/registry/components/table-list/TableListComponent.tsx`
**구현 방법:**
```typescript
import { useModalDataStore } from "@/stores/modalDataStore";
// TableList 컴포넌트 내부
const setModalData = useModalDataStore((state) => state.setData);
// 선택된 행이 변경될 때마다 스토어에 저장
useEffect(() => {
if (selectedRows.length > 0) {
const modalDataItems = selectedRows.map((row) => ({
id: row[primaryKeyColumn] || row.id,
originalData: row,
additionalData: {},
}));
// 컴포넌트 ID를 키로 사용하여 저장
setModalData(component.id || "default", modalDataItems);
console.log("📦 [TableList] 선택된 데이터 저장:", modalDataItems);
}
}, [selectedRows, component.id, setModalData]);
```
**참고:**
- `selectedRows`: TableList의 체크박스로 선택된 행들
- `component.id`: 컴포넌트 고유 ID
- 이 ID가 SelectedItemsDetailInput의 `dataSourceId`와 일치해야 함
---
### 2. ButtonPrimary에 'openModalWithData' 액션 타입 추가
**필요한 수정 파일:**
- `frontend/lib/registry/components/button-primary/types.ts`
- `frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx`
- `frontend/lib/registry/components/button-primary/ButtonPrimaryConfigPanel.tsx`
#### A. types.ts 수정
```typescript
export interface ButtonPrimaryConfig extends ComponentConfig {
action?: {
type:
| "save"
| "delete"
| "popup"
| "navigate"
| "custom"
| "openModalWithData"; // 🆕 새 액션 타입
// 기존 필드들...
// 🆕 모달 데이터 전달용 필드
targetScreenId?: number; // 열릴 모달 화면 ID
dataSourceId?: string; // 데이터를 전달할 컴포넌트 ID
};
}
```
#### B. ButtonPrimaryComponent.tsx 수정
```typescript
import { useModalDataStore } from "@/stores/modalDataStore";
// 컴포넌트 내부
const modalData = useModalDataStore((state) => state.getData);
// handleClick 함수 수정
const handleClick = async () => {
// ... 기존 코드 ...
// openModalWithData 액션 처리
if (processedConfig.action?.type === "openModalWithData") {
const { targetScreenId, dataSourceId } = processedConfig.action;
if (!targetScreenId) {
toast.error("대상 화면이 설정되지 않았습니다.");
return;
}
if (!dataSourceId) {
toast.error("데이터 소스가 설정되지 않았습니다.");
return;
}
// 데이터 확인
const data = modalData(dataSourceId);
if (!data || data.length === 0) {
toast.warning("전달할 데이터가 없습니다. 먼저 항목을 선택해주세요.");
return;
}
console.log("📦 [ButtonPrimary] 데이터와 함께 모달 열기:", {
targetScreenId,
dataSourceId,
dataCount: data.length,
});
// 모달 열기 (기존 popup 액션과 동일)
toast.success(`${data.length}개 항목을 전달합니다.`);
// TODO: 실제 모달 열기 로직 (popup 액션 참고)
window.open(`/screens/${targetScreenId}`, "_blank");
return;
}
// ... 기존 액션 처리 코드 ...
};
```
#### C. ButtonPrimaryConfigPanel.tsx 수정
설정 패널에 openModalWithData 액션 설정 UI 추가:
```typescript
{config.action?.type === "openModalWithData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium">데이터 전달 설정</h4>
{/* 대상 화면 선택 */}
<div>
<Label htmlFor="target-screen">열릴 모달 화면</Label>
<Popover open={screenOpen} onOpenChange={setScreenOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between">
{config.action?.targetScreenId
? screens.find((s) => s.id === config.action?.targetScreenId)?.name || "화면 선택"
: "화면 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent>
{/* 화면 목록 표시 */}
</PopoverContent>
</Popover>
</div>
{/* 데이터 소스 ID 입력 */}
<div>
<Label htmlFor="data-source-id">데이터 소스 ID</Label>
<Input
id="data-source-id"
value={config.action?.dataSourceId || ""}
onChange={(e) =>
updateActionConfig("dataSourceId", e.target.value)
}
placeholder="table-list-123"
/>
<p className="text-xs text-gray-500 mt-1">
💡 데이터를 전달할 컴포넌트의 ID (예: TableList의 ID)
</p>
</div>
</div>
)}
```
---
### 3. 저장 기능 구현
**방법 1: 기존 save 액션 활용**
SelectedItemsDetailInput의 데이터는 자동으로 `formData`에 포함되므로, 기존 save 액션을 그대로 사용할 수 있습니다:
```typescript
// formData 구조
{
"selected-items-component-id": [
{
id: "SALE-003",
originalData: { item_code: "SALE-003", ... },
additionalData: { customer_item_code: "ABC-001", unit_price: 50, ... }
},
// ... 더 많은 항목들
]
}
```
백엔드에서 이 데이터를 받아서 각 항목을 개별 INSERT하면 됩니다.
**방법 2: 전용 save 로직 추가**
더 나은 UX를 위해 전용 저장 로직을 추가할 수 있습니다:
```typescript
// ButtonPrimary의 save 액션에서
if (config.action?.type === "save") {
// formData에서 SelectedItemsDetailInput 데이터 찾기
const selectedItemsKey = Object.keys(formData).find(
(key) => Array.isArray(formData[key]) && formData[key][0]?.originalData
);
if (selectedItemsKey) {
const items = formData[selectedItemsKey] as ModalDataItem[];
// 저장할 데이터 변환
const dataToSave = items.map((item) => ({
...item.originalData,
...item.additionalData,
}));
// 백엔드 API 호출
const response = await apiClient.post(`/api/table-data/${targetTable}`, {
data: dataToSave,
batchInsert: true,
});
if (response.data.success) {
toast.success(`${dataToSave.length}개 항목이 저장되었습니다.`);
onClose?.();
}
}
}
```
---
## 🎯 통합 테스트 시나리오
### 시나리오: 수주 등록 - 품목 상세 입력
#### 1단계: 화면 구성
**[모달 1] 품목 선택 화면 (screen_id: 100)**
- TableList 컴포넌트
- ID: `item-selection-table`
- multiSelect: `true`
- selectedTable: `item_info`
- columns: 품목코드, 품목명, 규격, 단위, 단가
- ButtonPrimary 컴포넌트
- text: "다음 (상세정보 입력)"
- action.type: `openModalWithData`
- action.targetScreenId: `101` (두 번째 모달)
- action.dataSourceId: `item-selection-table`
**[모달 2] 상세 입력 화면 (screen_id: 101)**
- SelectedItemsDetailInput 컴포넌트
- ID: `selected-items-detail`
- dataSourceId: `item-selection-table`
- displayColumns: `["item_code", "item_name", "spec", "unit"]`
- additionalFields:
```json
[
{ "name": "customer_item_code", "label": "거래처 품번", "type": "text" },
{ "name": "customer_item_name", "label": "거래처 품명", "type": "text" },
{ "name": "year", "label": "연도", "type": "select", "options": [...] },
{ "name": "currency", "label": "통화", "type": "select", "options": [...] },
{ "name": "unit_price", "label": "단가", "type": "number", "required": true },
{ "name": "quantity", "label": "수량", "type": "number", "required": true }
]
```
- targetTable: `sales_detail`
- layout: `grid`
- ButtonPrimary 컴포넌트 (저장)
- text: "저장"
- action.type: `save`
- action.targetTable: `sales_detail`
#### 2단계: 테스트 절차
1. [모달 1] 품목 선택 화면 열기
2. TableList에서 3개 품목 체크박스 선택
3. "다음" 버튼 클릭
- ✅ modalDataStore에 3개 항목 저장 확인 (콘솔 로그)
- ✅ 모달 2가 열림
4. [모달 2] SelectedItemsDetailInput에 3개 항목 자동 표시 확인
- ✅ 원본 데이터 (품목코드, 품목명, 규격, 단위) 표시
- ✅ 추가 입력 필드 (거래처 품번, 단가, 수량 등) 빈 상태
5. 각 항목별로 추가 정보 입력
- 거래처 품번: "ABC-001", "ABC-002", "ABC-003"
- 단가: 50, 200, 3000
- 수량: 100, 50, 200
6. "저장" 버튼 클릭
- ✅ formData에 전체 데이터 포함 확인
- ✅ 백엔드 API 호출
- ✅ 저장 성공 토스트 메시지
- ✅ 모달 닫힘
#### 3단계: 데이터 검증
데이터베이스에 다음과 같이 저장되어야 합니다:
```sql
SELECT * FROM sales_detail;
-- 결과:
-- item_code | item_name | spec | unit | customer_item_code | unit_price | quantity
-- SALE-003 | 와셔 M8 | M8 | EA | ABC-001 | 50 | 100
-- SALE-005 | 육각 볼트 | M10 | EA | ABC-002 | 200 | 50
-- SIL-003 | 실리콘 | 325 | kg | ABC-003 | 3000 | 200
```
---
## 📚 추가 참고 자료
### 관련 파일 위치
- 스토어: `frontend/stores/modalDataStore.ts`
- 컴포넌트: `frontend/lib/registry/components/selected-items-detail-input/`
- TableList: `frontend/lib/registry/components/table-list/`
- ButtonPrimary: `frontend/lib/registry/components/button-primary/`
### 디버깅 팁
콘솔에서 다음 명령어로 상태 확인:
```javascript
// 모달 데이터 확인
__MODAL_DATA_STORE__.getState().dataRegistry
// 컴포넌트 등록 확인
__COMPONENT_REGISTRY__.get("selected-items-detail-input")
// TableList 선택 상태 확인
// (TableList 컴포넌트 내부에 로그 추가 필요)
```
### 예상 문제 및 해결
1. **데이터가 전달되지 않음**
- dataSourceId가 정확히 일치하는지 확인
- modalDataStore에 데이터가 저장되었는지 콘솔 로그 확인
2. **컴포넌트가 표시되지 않음**
- `frontend/lib/registry/components/index.ts`에 import 추가되었는지 확인
- 브라우저 새로고침 후 재시도
3. **저장이 안 됨**
- formData에 데이터가 포함되어 있는지 확인
- 백엔드 API 응답 확인
- targetTable이 올바른지 확인
---
## ✅ 완료 체크리스트
- [x] Zustand 스토어 생성 (modalDataStore)
- [x] SelectedItemsDetailInput 컴포넌트 생성
- [x] 컴포넌트 렌더링 로직 구현
- [x] 설정 패널 구현
- [ ] TableList에서 선택된 데이터를 스토어에 저장
- [ ] ButtonPrimary에 openModalWithData 액션 추가
- [ ] 저장 기능 구현
- [ ] 통합 테스트
- [ ] 사용자 매뉴얼 작성
---
## 🚀 다음 단계
1. TableList 컴포넌트에 modalDataStore 연동 추가
2. ButtonPrimary에 openModalWithData 액션 구현
3. 수주 등록 화면에서 실제 테스트
4. 문제 발견 시 디버깅 및 수정
5. 문서 업데이트 및 배포
**예상 소요 시간**: 2~3시간