데이터전달 모달열기 액션에 컬럼 매핑기능 추가

This commit is contained in:
kjs 2025-12-08 15:50:58 +09:00
parent 274078ef2c
commit ec65ad6b9e
4 changed files with 324 additions and 8 deletions

View File

@ -2239,10 +2239,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`, calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`,
}); });
// 🆕 라벨을 기반으로 기본 columnName 생성 (한글 → 스네이크 케이스)
// 예: "창고코드" → "warehouse_code" 또는 그대로 유지
const generateDefaultColumnName = (label: string): string => {
// 한글 라벨의 경우 그대로 사용 (나중에 사용자가 수정 가능)
// 영문의 경우 스네이크 케이스로 변환
if (/[가-힣]/.test(label)) {
// 한글이 포함된 경우: 공백을 언더스코어로, 소문자로 변환
return label.replace(/\s+/g, "_").toLowerCase();
}
// 영문의 경우: 카멜케이스/파스칼케이스를 스네이크 케이스로 변환
return label
.replace(/([a-z])([A-Z])/g, "$1_$2")
.replace(/\s+/g, "_")
.toLowerCase();
};
const newComponent: ComponentData = { const newComponent: ComponentData = {
id: generateComponentId(), id: generateComponentId(),
type: "component", // ✅ 새 컴포넌트 시스템 사용 type: "component", // ✅ 새 컴포넌트 시스템 사용
label: component.name, label: component.name,
columnName: generateDefaultColumnName(component.name), // 🆕 기본 columnName 자동 생성
widgetType: component.webType, widgetType: component.webType,
componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용) componentType: component.id, // 새 컴포넌트 시스템의 ID (DynamicComponentRenderer용)
position: snappedPosition, position: snappedPosition,

View File

@ -91,6 +91,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({}); const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({}); const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
// 🆕 openModalWithData 전용 필드 매핑 상태
const [modalSourceColumns, setModalSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalTargetColumns, setModalTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [modalSourcePopoverOpen, setModalSourcePopoverOpen] = useState<Record<number, boolean>>({});
const [modalTargetPopoverOpen, setModalTargetPopoverOpen] = useState<Record<number, boolean>>({});
const [modalSourceSearch, setModalSourceSearch] = useState<Record<number, string>>({});
const [modalTargetSearch, setModalTargetSearch] = useState<Record<number, string>>({});
// 🎯 플로우 위젯이 화면에 있는지 확인 // 🎯 플로우 위젯이 화면에 있는지 확인
const hasFlowWidget = useMemo(() => { const hasFlowWidget = useMemo(() => {
const found = allComponents.some((comp: any) => { const found = allComponents.some((comp: any) => {
@ -318,6 +326,88 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
loadColumns(); loadColumns();
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
// 🆕 openModalWithData 소스/타겟 테이블 컬럼 로드
useEffect(() => {
const actionType = config.action?.type;
if (actionType !== "openModalWithData") return;
const loadModalMappingColumns = async () => {
// 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지
// allComponents에서 split-panel-layout 또는 table-list 찾기
let sourceTableName: string | null = null;
for (const comp of allComponents) {
const compType = comp.componentType || (comp as any).componentConfig?.type;
if (compType === "split-panel-layout" || compType === "screen-split-panel") {
// 분할 패널의 좌측 테이블명
sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName ||
(comp as any).componentConfig?.leftTableName;
break;
}
if (compType === "table-list") {
sourceTableName = (comp as any).componentConfig?.tableName;
break;
}
}
// 소스 테이블 컬럼 로드
if (sourceTableName) {
try {
const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setModalSourceColumns(columns);
console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length);
}
}
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
}
}
// 타겟 화면의 테이블 컬럼 로드
const targetScreenId = config.action?.targetScreenId;
if (targetScreenId) {
try {
// 타겟 화면 정보 가져오기
const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`);
if (screenResponse.data.success && screenResponse.data.data) {
const targetTableName = screenResponse.data.data.tableName;
if (targetTableName) {
const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`);
if (columnResponse.data.success) {
let columnData = columnResponse.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setModalTargetColumns(columns);
console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length);
}
}
}
}
} catch (error) {
console.error("타겟 화면 테이블 컬럼 로드 실패:", error);
}
}
};
loadModalMappingColumns();
}, [config.action?.type, config.action?.targetScreenId, allComponents]);
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
useEffect(() => { useEffect(() => {
const fetchScreens = async () => { const fetchScreens = async () => {
@ -1024,6 +1114,194 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
SelectedItemsDetailInput SelectedItemsDetailInput
</p> </p>
</div> </div>
{/* 🆕 필드 매핑 설정 (소스 컬럼 → 타겟 컬럼) */}
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> ()</Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentMappings = config.action?.fieldMappings || [];
const newMapping = { sourceField: "", targetField: "" };
onUpdateProperty("componentConfig.action.fieldMappings", [...currentMappings, newMapping]);
}}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
.
<br />
: warehouse_code warehouse_id ( ID에 )
</p>
{/* 컬럼 로드 상태 표시 */}
{modalSourceColumns.length > 0 || modalTargetColumns.length > 0 ? (
<div className="text-[10px] text-muted-foreground bg-muted/50 p-2 rounded">
: {modalSourceColumns.length} / : {modalTargetColumns.length}
</div>
) : (
<div className="text-[10px] text-amber-600 bg-amber-50 p-2 rounded dark:bg-amber-950/20">
.
</div>
)}
{(config.action?.fieldMappings || []).length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-xs text-muted-foreground">
.
</p>
</div>
) : (
<div className="space-y-2">
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => (
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
{/* 소스 필드 선택 (Combobox) */}
<div className="flex-1">
<Popover
open={modalSourcePopoverOpen[index] || false}
onOpenChange={(open) => setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{mapping.sourceField
? modalSourceColumns.find((c) => c.name === mapping.sourceField)?.label || mapping.sourceField
: "소스 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={modalSourceSearch[index] || ""}
onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{modalSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const mappings = [...(config.action?.fieldMappings || [])];
mappings[index] = { ...mappings[index], sourceField: col.name };
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.sourceField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-xs text-muted-foreground"></span>
{/* 타겟 필드 선택 (Combobox) */}
<div className="flex-1">
<Popover
open={modalTargetPopoverOpen[index] || false}
onOpenChange={(open) => setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{mapping.targetField
? modalTargetColumns.find((c) => c.name === mapping.targetField)?.label || mapping.targetField
: "타겟 컬럼 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={modalTargetSearch[index] || ""}
onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{modalTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const mappings = [...(config.action?.fieldMappings || [])];
mappings[index] = { ...mappings[index], targetField: col.name };
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mapping.targetField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
onClick={() => {
const mappings = [...(config.action?.fieldMappings || [])];
mappings.splice(index, 1);
onUpdateProperty("componentConfig.action.fieldMappings", mappings);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div> </div>
)} )}

View File

@ -584,20 +584,23 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{selectedComponent.type === "widget" && ( {(selectedComponent.type === "widget" || selectedComponent.type === "component") && (
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="columnName" className="text-xs font-medium"> <Label htmlFor="columnName" className="text-xs font-medium">
( ) ()
</Label> </Label>
<Input <Input
id="columnName" id="columnName"
value={selectedComponent.columnName || ""} value={selectedComponent.columnName || ""}
readOnly onChange={(e) => onUpdateProperty("columnName", e.target.value)}
placeholder="데이터베이스 컬럼명" placeholder="formData에서 사용할 필드명"
className="bg-muted/50 text-muted-foreground h-8" className="h-8"
title="컬럼명은 변경할 수 없습니다" title="분할 패널에서 데이터를 전달받을 때 사용되는 필드명입니다"
/> />
<p className="text-muted-foreground text-xs">
</p>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@ -59,6 +59,7 @@ export interface ButtonActionConfig {
popupWidth?: number; popupWidth?: number;
popupHeight?: number; popupHeight?: number;
dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용) dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용)
fieldMappings?: Array<{ sourceField: string; targetField: string }>; // 🆕 필드 매핑 (openModalWithData용)
// 확인 메시지 // 확인 메시지
confirmMessage?: string; confirmMessage?: string;
@ -1548,10 +1549,27 @@ export class ButtonActionExecutor {
} }
// 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용) // 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용)
const parentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {}; const rawParentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {};
// 🆕 필드 매핑 적용 (소스 컬럼 → 타겟 컬럼)
let parentData = { ...rawParentData };
if (config.fieldMappings && Array.isArray(config.fieldMappings) && config.fieldMappings.length > 0) {
console.log("🔄 [openModalWithData] 필드 매핑 적용:", config.fieldMappings);
config.fieldMappings.forEach((mapping: { sourceField: string; targetField: string }) => {
if (mapping.sourceField && mapping.targetField && rawParentData[mapping.sourceField] !== undefined) {
// 타겟 필드에 소스 필드 값 복사
parentData[mapping.targetField] = rawParentData[mapping.sourceField];
console.log(`${mapping.sourceField}${mapping.targetField}: ${rawParentData[mapping.sourceField]}`);
}
});
}
console.log("📦 [openModalWithData] 부모 데이터 전달:", { console.log("📦 [openModalWithData] 부모 데이터 전달:", {
dataSourceId, dataSourceId,
parentData, rawParentData,
mappedParentData: parentData,
fieldMappings: config.fieldMappings,
}); });
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함) // 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)