선택항목 상게입력 컴포넌트 구현
This commit is contained in:
@@ -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") &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
163
frontend/stores/modalDataStore.ts
Normal file
163
frontend/stores/modalDataStore.ts
Normal 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);
|
||||
};
|
||||
|
||||
413
선택항목_상세입력_컴포넌트_완성_가이드.md
Normal file
413
선택항목_상세입력_컴포넌트_완성_가이드.md
Normal 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시간
|
||||
|
||||
Reference in New Issue
Block a user