선택항목 상게입력 컴포넌트 구현
This commit is contained in:
parent
2c099feea0
commit
a6e6a14fd1
|
|
@ -65,6 +65,9 @@ function ScreenViewPage() {
|
||||||
// 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨)
|
// 플로우 새로고침을 위한 키 (값이 변경되면 플로우 데이터가 리렌더링됨)
|
||||||
const [flowRefreshKey, setFlowRefreshKey] = useState(0);
|
const [flowRefreshKey, setFlowRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||||||
|
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
// 편집 모달 상태
|
// 편집 모달 상태
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [editModalConfig, setEditModalConfig] = useState<{
|
const [editModalConfig, setEditModalConfig] = useState<{
|
||||||
|
|
@ -402,19 +405,39 @@ function ScreenViewPage() {
|
||||||
(c) => (c as any).componentId === "table-search-widget"
|
(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 adjustedComponents = regularComponents.map((component) => {
|
||||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||||
|
const isConditionalContainer = (component as any).componentId === "conditional-container";
|
||||||
|
|
||||||
if (isTableSearchWidget) {
|
if (isTableSearchWidget || isConditionalContainer) {
|
||||||
// TableSearchWidget 자체는 조정하지 않음
|
// 자기 자신은 조정하지 않음
|
||||||
return component;
|
return component;
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalHeightAdjustment = 0;
|
let totalHeightAdjustment = 0;
|
||||||
|
|
||||||
|
// TableSearchWidget 높이 조정
|
||||||
for (const widget of tableSearchWidgets) {
|
for (const widget of tableSearchWidgets) {
|
||||||
// 현재 컴포넌트가 이 위젯 아래에 있는지 확인
|
|
||||||
const isBelow = component.position.y > widget.position.y;
|
const isBelow = component.position.y > widget.position.y;
|
||||||
const heightDiff = getHeightDiff(screenId, widget.id);
|
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) {
|
if (totalHeightAdjustment > 0) {
|
||||||
return {
|
return {
|
||||||
...component,
|
...component,
|
||||||
|
|
@ -491,6 +539,12 @@ function ScreenViewPage() {
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||||
}}
|
}}
|
||||||
|
onHeightChange={(componentId, newHeight) => {
|
||||||
|
setConditionalContainerHeights((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[componentId]: newHeight,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* 자식 컴포넌트들 */}
|
{/* 자식 컴포넌트들 */}
|
||||||
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
{(component.type === "group" || component.type === "container" || component.type === "area") &&
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,9 @@ interface RealtimePreviewProps {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "asc" | "desc";
|
sortOrder?: "asc" | "desc";
|
||||||
columnOrder?: string[];
|
columnOrder?: string[];
|
||||||
|
|
||||||
|
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
|
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
// 동적 위젯 타입 아이콘 (레지스트리에서 조회)
|
||||||
|
|
@ -123,6 +126,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
onFlowRefresh,
|
onFlowRefresh,
|
||||||
formData,
|
formData,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
|
onHeightChange, // 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
}) => {
|
}) => {
|
||||||
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
const [actualHeight, setActualHeight] = React.useState<number | null>(null);
|
||||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -225,6 +229,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const getHeight = () => {
|
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";
|
const isFlowWidget = component.type === "component" && (component as any).componentType === "flow-widget";
|
||||||
if (isFlowWidget && actualHeight) {
|
if (isFlowWidget && actualHeight) {
|
||||||
|
|
@ -325,7 +335,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
(contentRef as any).current = node;
|
(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%" }}
|
style={{ width: "100%", maxWidth: "100%" }}
|
||||||
>
|
>
|
||||||
<DynamicComponentRenderer
|
<DynamicComponentRenderer
|
||||||
|
|
@ -361,6 +376,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
|
onHeightChange={onHeightChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectItem value="edit">편집</SelectItem>
|
<SelectItem value="edit">편집</SelectItem>
|
||||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||||
|
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기 🆕</SelectItem>
|
||||||
<SelectItem value="modal">모달 열기</SelectItem>
|
<SelectItem value="modal">모달 열기</SelectItem>
|
||||||
<SelectItem value="control">제어 흐름</SelectItem>
|
<SelectItem value="control">제어 흐름</SelectItem>
|
||||||
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||||
|
|
@ -409,6 +410,136 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</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" && (
|
{(component.componentConfig?.action?.type || "save") === "edit" && (
|
||||||
<div className="mt-4 space-y-4 rounded-lg border bg-success/10 p-4">
|
<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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
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 { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,8 @@ export interface DynamicComponentRendererProps {
|
||||||
screenId?: number;
|
screenId?: number;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요)
|
menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요)
|
||||||
|
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
|
onHeightChange?: (componentId: string, newHeight: number) => void;
|
||||||
menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번)
|
menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번)
|
||||||
selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용)
|
selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용)
|
||||||
userId?: string; // 🆕 현재 사용자 ID
|
userId?: string; // 🆕 현재 사용자 ID
|
||||||
|
|
@ -254,6 +256,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
isPreview,
|
isPreview,
|
||||||
autoGeneration,
|
autoGeneration,
|
||||||
|
onHeightChange, // 🆕 높이 변화 콜백
|
||||||
...restProps
|
...restProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
|
@ -299,6 +302,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 숨김 값 추출
|
// 숨김 값 추출
|
||||||
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
const hiddenValue = component.hidden || component.componentConfig?.hidden;
|
||||||
|
|
||||||
|
// 🆕 조건부 컨테이너용 높이 변화 핸들러
|
||||||
|
const handleHeightChange = props.onHeightChange ? (newHeight: number) => {
|
||||||
|
props.onHeightChange!(component.id, newHeight);
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
const rendererProps = {
|
const rendererProps = {
|
||||||
component,
|
component,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
|
@ -347,6 +355,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
tableDisplayData, // 🆕 화면 표시 데이터
|
tableDisplayData, // 🆕 화면 표시 데이터
|
||||||
// 플로우 선택된 데이터 정보 전달
|
// 플로우 선택된 데이터 정보 전달
|
||||||
flowSelectedData,
|
flowSelectedData,
|
||||||
|
// 🆕 조건부 컨테이너 높이 변화 콜백
|
||||||
|
onHeightChange: handleHeightChange,
|
||||||
|
componentId: component.id,
|
||||||
flowSelectedStepId,
|
flowSelectedStepId,
|
||||||
onFlowSelectedDataChange,
|
onFlowSelectedDataChange,
|
||||||
// 설정 변경 핸들러 전달
|
// 설정 변경 핸들러 전달
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import { ConditionalContainerProps, ConditionalSection } from "./types";
|
||||||
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
console.log("🚀 ConditionalContainerComponent 모듈 로드됨!");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조건부 컨테이너 컴포넌트
|
* 조건부 컨테이너 컴포넌트
|
||||||
* 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시
|
* 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시
|
||||||
|
|
@ -39,6 +41,12 @@ export function ConditionalContainerComponent({
|
||||||
style,
|
style,
|
||||||
className,
|
className,
|
||||||
}: ConditionalContainerProps) {
|
}: ConditionalContainerProps) {
|
||||||
|
console.log("🎯 ConditionalContainerComponent 렌더링!", {
|
||||||
|
isDesignMode,
|
||||||
|
hasOnHeightChange: !!onHeightChange,
|
||||||
|
componentId,
|
||||||
|
});
|
||||||
|
|
||||||
// config prop 우선, 없으면 개별 prop 사용
|
// config prop 우선, 없으면 개별 prop 사용
|
||||||
const controlField = config?.controlField || propControlField || "condition";
|
const controlField = config?.controlField || propControlField || "condition";
|
||||||
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
|
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
|
||||||
|
|
@ -76,8 +84,24 @@ export function ConditionalContainerComponent({
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const previousHeightRef = useRef<number>(0);
|
const previousHeightRef = useRef<number>(0);
|
||||||
|
|
||||||
|
// 🔍 디버그: props 확인
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔍 ConditionalContainer props:", {
|
||||||
|
isDesignMode,
|
||||||
|
hasOnHeightChange: !!onHeightChange,
|
||||||
|
componentId,
|
||||||
|
selectedValue,
|
||||||
|
});
|
||||||
|
}, [isDesignMode, onHeightChange, componentId, selectedValue]);
|
||||||
|
|
||||||
// 높이 변화 감지 및 콜백 호출
|
// 높이 변화 감지 및 콜백 호출
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log("🔍 ResizeObserver 등록 조건:", {
|
||||||
|
hasContainer: !!containerRef.current,
|
||||||
|
isDesignMode,
|
||||||
|
hasOnHeightChange: !!onHeightChange,
|
||||||
|
});
|
||||||
|
|
||||||
if (!containerRef.current || isDesignMode || !onHeightChange) return;
|
if (!containerRef.current || isDesignMode || !onHeightChange) return;
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
|
@ -110,7 +134,7 @@ export function ConditionalContainerComponent({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn("h-full w-full flex flex-col", spacingClass, className)}
|
className={cn("w-full flex flex-col", spacingClass, className)}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{/* 제어 셀렉트박스 */}
|
{/* 제어 셀렉트박스 */}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||||
|
|
||||||
// 🆕 조건부 컨테이너 컴포넌트
|
// 🆕 조건부 컨테이너 컴포넌트
|
||||||
import "./conditional-container/ConditionalContainerRenderer";
|
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)));
|
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||||
setIsAllSelected(allRowsSelected && data.length > 0);
|
setIsAllSelected(allRowsSelected && data.length > 0);
|
||||||
};
|
};
|
||||||
|
|
@ -1127,6 +1150,23 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
selectedRowsData: data,
|
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 {
|
} else {
|
||||||
setSelectedRows(new Set());
|
setSelectedRows(new Set());
|
||||||
setIsAllSelected(false);
|
setIsAllSelected(false);
|
||||||
|
|
@ -1137,6 +1177,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
onFormDataChange({ selectedRows: [], selectedRowsData: [] });
|
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" // 편집
|
| "edit" // 편집
|
||||||
| "copy" // 복사 (품목코드 초기화)
|
| "copy" // 복사 (품목코드 초기화)
|
||||||
| "navigate" // 페이지 이동
|
| "navigate" // 페이지 이동
|
||||||
|
| "openModalWithData" // 🆕 데이터를 전달하면서 모달 열기
|
||||||
| "modal" // 모달 열기
|
| "modal" // 모달 열기
|
||||||
| "control" // 제어 흐름
|
| "control" // 제어 흐름
|
||||||
| "view_table_history" // 테이블 이력 보기
|
| "view_table_history" // 테이블 이력 보기
|
||||||
|
|
@ -44,6 +45,7 @@ export interface ButtonActionConfig {
|
||||||
modalSize?: "sm" | "md" | "lg" | "xl";
|
modalSize?: "sm" | "md" | "lg" | "xl";
|
||||||
popupWidth?: number;
|
popupWidth?: number;
|
||||||
popupHeight?: number;
|
popupHeight?: number;
|
||||||
|
dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용)
|
||||||
|
|
||||||
// 확인 메시지
|
// 확인 메시지
|
||||||
confirmMessage?: string;
|
confirmMessage?: string;
|
||||||
|
|
@ -149,6 +151,9 @@ export class ButtonActionExecutor {
|
||||||
case "navigate":
|
case "navigate":
|
||||||
return this.handleNavigate(config, context);
|
return this.handleNavigate(config, context);
|
||||||
|
|
||||||
|
case "openModalWithData":
|
||||||
|
return await this.handleOpenModalWithData(config, context);
|
||||||
|
|
||||||
case "modal":
|
case "modal":
|
||||||
return await this.handleModal(config, context);
|
return await this.handleModal(config, context);
|
||||||
|
|
||||||
|
|
@ -667,6 +672,83 @@ export class ButtonActionExecutor {
|
||||||
return true;
|
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: {
|
navigate: {
|
||||||
type: "navigate",
|
type: "navigate",
|
||||||
},
|
},
|
||||||
|
openModalWithData: {
|
||||||
|
type: "openModalWithData",
|
||||||
|
modalSize: "md",
|
||||||
|
confirmMessage: "다음 단계로 진행하시겠습니까?",
|
||||||
|
successMessage: "데이터가 전달되었습니다.",
|
||||||
|
errorMessage: "데이터 전달 중 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
modal: {
|
modal: {
|
||||||
type: "modal",
|
type: "modal",
|
||||||
modalSize: "md",
|
modalSize: "md",
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||||
// 🆕 조건부 컨테이너
|
// 🆕 조건부 컨테이너
|
||||||
"conditional-container": () =>
|
"conditional-container": () =>
|
||||||
import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
import("@/lib/registry/components/conditional-container/ConditionalContainerConfigPanel"),
|
||||||
|
// 🆕 선택 항목 상세입력
|
||||||
|
"selected-items-detail-input": () =>
|
||||||
|
import("@/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ConfigPanel 컴포넌트 캐시
|
// ConfigPanel 컴포넌트 캐시
|
||||||
|
|
@ -66,6 +69,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
||||||
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
||||||
module.FlowWidgetConfigPanel || // flow-widget의 export명
|
module.FlowWidgetConfigPanel || // flow-widget의 export명
|
||||||
module.CustomerItemMappingConfigPanel || // customer-item-mapping의 export명
|
module.CustomerItemMappingConfigPanel || // customer-item-mapping의 export명
|
||||||
|
module.SelectedItemsDetailInputConfigPanel || // selected-items-detail-input의 export명
|
||||||
module.default;
|
module.default;
|
||||||
|
|
||||||
if (!ConfigPanelComponent) {
|
if (!ConfigPanelComponent) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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시간
|
||||||
|
|
||||||
Loading…
Reference in New Issue