UnifiedRepeater 및 관련 컴포넌트에서 마스터 레코드 ID와 커스텀 테이블 설정 기능을 추가했습니다. 데이터 저장 시 마스터 레코드 ID를 포함하여 FK 자동 연결을 지원하며, 커스텀 테이블 사용 여부에 따라 저장 대상을 설정할 수 있도록 개선했습니다.

This commit is contained in:
kjs 2026-01-15 14:47:49 +09:00
parent 321c52a1f8
commit bed7f5f5c4
8 changed files with 382 additions and 22 deletions

View File

@ -542,10 +542,17 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
const response = await dynamicFormApi.saveData(saveData);
if (response.success) {
const masterRecordId = response.data?.id || formData.id;
// 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: { parentId: response.data?.id || formData.id },
detail: {
parentId: masterRecordId,
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
mainFormData: formData,
tableName: screenInfo.tableName,
},
}),
);

View File

@ -812,26 +812,34 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectValue placeholder="버튼 액션 선택" />
</SelectTrigger>
<SelectContent>
{/* 핵심 액션 */}
<SelectItem value="save"></SelectItem>
<SelectItem value="delete"></SelectItem>
<SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="transferData"> </SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="openModalWithData" className="text-muted-foreground">
(deprecated) +
</SelectItem>
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="transferData"> </SelectItem>
{/* 엑셀 관련 */}
<SelectItem value="excel_download"> </SelectItem>
<SelectItem value="excel_upload"> </SelectItem>
{/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem>
<SelectItem value="control"> </SelectItem>
{/* 특수 기능 (필요 시 사용) */}
<SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
{/* <SelectItem value="empty_vehicle">공차등록</SelectItem> */}
<SelectItem value="operation_control"> </SelectItem>
{/* 🔒 - , UI
<SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="openRelatedModal"> </SelectItem>
<SelectItem value="openModalWithData">(deprecated) + </SelectItem>
<SelectItem value="view_table_history"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem>
<SelectItem value="empty_vehicle"></SelectItem>
*/}
</SelectContent>
</Select>
</div>

View File

@ -104,14 +104,30 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// 저장 이벤트 리스너
useEffect(() => {
const handleSaveEvent = async (event: CustomEvent) => {
const tableName = config.dataSource?.tableName;
// 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용
const tableName = config.useCustomTable && config.mainTableName
? config.mainTableName
: config.dataSource?.tableName;
const eventParentId = event.detail?.parentId;
const mainFormData = event.detail?.mainFormData;
// 🆕 마스터 테이블에서 생성된 ID (FK 연결용)
const masterRecordId = event.detail?.masterRecordId || mainFormData?.id;
if (!tableName || data.length === 0) {
console.log("📋 UnifiedRepeater 저장 스킵:", { tableName, dataLength: data.length });
return;
}
console.log("📋 UnifiedRepeater 저장 시작:", {
tableName,
useCustomTable: config.useCustomTable,
mainTableName: config.mainTableName,
foreignKeyColumn: config.foreignKeyColumn,
masterRecordId,
dataLength: data.length
});
try {
// 테이블 유효 컬럼 조회
let validColumns: Set<string> = new Set();
@ -130,12 +146,25 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// 내부 필드 제거
const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_")));
// 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
const mergedData = {
...mainFormDataWithoutId,
...cleanRow,
};
// 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함)
let mergedData: Record<string, any>;
if (config.useCustomTable && config.mainTableName) {
// 커스텀 테이블: 리피터 데이터만 저장
mergedData = { ...cleanRow };
// 🆕 FK 자동 연결
if (config.foreignKeyColumn && masterRecordId) {
mergedData[config.foreignKeyColumn] = masterRecordId;
console.log(`📎 FK 자동 연결: ${config.foreignKeyColumn} = ${masterRecordId}`);
}
} else {
// 기존 방식: 메인 폼 데이터 병합
const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {};
mergedData = {
...mainFormDataWithoutId,
...cleanRow,
};
}
// 유효하지 않은 컬럼 제거
const filteredData: Record<string, any> = {};
@ -148,9 +177,9 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData);
}
console.log("UnifiedRepeater 저장 완료:", data.length, "건");
console.log("UnifiedRepeater 저장 완료:", data.length, "건", tableName);
} catch (error) {
console.error("UnifiedRepeater 저장 실패:", error);
console.error("UnifiedRepeater 저장 실패:", error);
throw error;
}
};
@ -159,7 +188,7 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
return () => {
window.removeEventListener("repeaterSave" as any, handleSaveEvent);
};
}, [data, config.dataSource?.tableName, parentId]);
}, [data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, parentId]);
// 현재 테이블 컬럼 정보 로드
useEffect(() => {
@ -631,6 +660,107 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
};
}, [config.fieldName]);
// 🆕 데이터 전달 이벤트 리스너 (transferData 버튼 액션용)
useEffect(() => {
// componentDataTransfer: 특정 컴포넌트 ID로 데이터 전달
const handleComponentDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { targetComponentId, data: transferData, mappingRules, mode } = customEvent.detail || {};
// 이 컴포넌트가 대상인지 확인
if (targetComponentId !== parentId && targetComponentId !== config.fieldName) {
return;
}
console.log("📥 [UnifiedRepeater] componentDataTransfer 수신:", {
targetComponentId,
dataCount: transferData?.length,
mode,
myId: parentId,
});
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
// 매핑 규칙이 있으면 적용
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
// 매핑 규칙 없으면 그대로 복사
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else if (mode === "merge") {
// 중복 제거 후 병합 (id 기준)
const existingIds = new Set(data.map((row) => row.id || row._id));
const newItems = mappedData.filter((row: any) => !existingIds.has(row.id || row._id));
handleDataChange([...data, ...newItems]);
} else {
// 기본: append
handleDataChange([...data, ...mappedData]);
}
};
// splitPanelDataTransfer: 분할 패널에서 전역 이벤트로 전달
const handleSplitPanelDataTransfer = async (event: Event) => {
const customEvent = event as CustomEvent;
const { data: transferData, mappingRules, mode, sourcePosition } = customEvent.detail || {};
console.log("📥 [UnifiedRepeater] splitPanelDataTransfer 수신:", {
dataCount: transferData?.length,
mode,
sourcePosition,
});
if (!transferData || transferData.length === 0) {
return;
}
// 데이터 매핑 처리
const mappedData = transferData.map((item: any, index: number) => {
const newRow: any = { _id: `transfer_${Date.now()}_${index}` };
if (mappingRules && mappingRules.length > 0) {
mappingRules.forEach((rule: any) => {
newRow[rule.targetField] = item[rule.sourceField];
});
} else {
Object.assign(newRow, item);
}
return newRow;
});
// mode에 따라 데이터 처리
if (mode === "replace") {
handleDataChange(mappedData);
} else {
handleDataChange([...data, ...mappedData]);
}
};
window.addEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
window.removeEventListener("componentDataTransfer", handleComponentDataTransfer as EventListener);
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
};
}, [parentId, config.fieldName, data, handleDataChange]);
return (
<div className={cn("space-y-4", className)}>
{/* 헤더 영역 */}

View File

@ -529,6 +529,103 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
<Separator />
{/* 저장 대상 테이블 설정 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<p className="text-[10px] text-muted-foreground">
</p>
<div className="flex items-center space-x-2">
<Checkbox
id="useCustomTable"
checked={config.useCustomTable || false}
onCheckedChange={(checked) => {
if (!checked) {
updateConfig({
useCustomTable: false,
mainTableName: undefined,
foreignKeyColumn: undefined,
foreignKeySourceColumn: undefined,
});
} else {
updateConfig({ useCustomTable: true });
}
}}
/>
<label htmlFor="useCustomTable" className="text-xs"> </label>
</div>
{config.useCustomTable && (
<div className="space-y-2 rounded border border-orange-200 bg-orange-50 p-2">
{/* 저장 테이블 선택 */}
<div className="space-y-1">
<Label className="text-[10px]"> *</Label>
<Select
value={config.mainTableName || ""}
onValueChange={(value) => updateConfig({ mainTableName: value })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="테이블 선택..." />
</SelectTrigger>
<SelectContent>
{currentTableColumns.length === 0 ? (
<div className="p-2 text-xs text-gray-500"> ...</div>
) : (
<SelectItem value={currentTableName || ""} disabled>
<span className="text-gray-400">( - )</span>
</SelectItem>
)}
</SelectContent>
</Select>
<Input
value={config.mainTableName || ""}
onChange={(e) => updateConfig({ mainTableName: e.target.value })}
placeholder="테이블명 직접 입력 (예: receiving_detail)"
className="h-7 text-xs"
/>
</div>
{/* FK 컬럼 설정 */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]">FK ( )</Label>
<Input
value={config.foreignKeyColumn || ""}
onChange={(e) => updateConfig({ foreignKeyColumn: e.target.value })}
placeholder="예: receiving_id"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]">PK ( )</Label>
<Input
value={config.foreignKeySourceColumn || "id"}
onChange={(e) => updateConfig({ foreignKeySourceColumn: e.target.value })}
placeholder="예: id"
className="h-7 text-xs"
/>
</div>
</div>
{config.mainTableName && config.foreignKeyColumn && (
<div className="text-[10px] text-orange-700">
<strong> :</strong> {config.mainTableName}.{config.foreignKeyColumn}
{currentTableName}.{config.foreignKeySourceColumn || "id"} ( )
</div>
)}
</div>
)}
{!config.useCustomTable && (
<div className="text-[10px] text-muted-foreground">
현재: 화면 ({currentTableName || "미설정"})
</div>
)}
</div>
<Separator />
{/* 현재 화면 정보 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>

View File

@ -743,6 +743,111 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
<div className="text-sm font-medium"> </div>
<div className="space-y-6">
{/* 커스텀 테이블 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
{/* 커스텀 테이블 사용 여부 */}
<div className="flex items-center space-x-2">
<Checkbox
id="useCustomTable"
checked={config.useCustomTable || false}
onCheckedChange={(checked) => {
handleChange("useCustomTable", checked);
if (!checked) {
// 커스텀 테이블 해제 시 화면 메인 테이블로 복원
handleChange("customTableName", undefined);
handleChange("selectedTable", screenTableName);
}
}}
/>
<Label htmlFor="useCustomTable" className="text-xs"> </Label>
</div>
{config.useCustomTable && (
<div className="space-y-2">
{/* 테이블 선택 */}
<div className="space-y-1">
<Label className="text-xs"> *</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
{config.customTableName
? availableTables.find((t) => t.tableName === config.customTableName)?.displayName ||
config.customTableName
: "테이블을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
handleChange("customTableName", table.tableName);
handleChange("selectedTable", table.tableName);
// 테이블 변경 시 컬럼 초기화
handleChange("columns", []);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.customTableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 읽기전용 설정 */}
<div className="flex items-center space-x-2">
<Checkbox
id="isReadOnly"
checked={config.isReadOnly || false}
onCheckedChange={(checked) => handleChange("isReadOnly", checked)}
/>
<Label htmlFor="isReadOnly" className="text-xs"> ( , )</Label>
</div>
{config.customTableName && (
<div className="bg-muted/50 text-muted-foreground rounded p-2 text-[10px]">
<strong> :</strong> {config.customTableName}
{config.isReadOnly && " (읽기전용)"}
</div>
)}
</div>
)}
{!config.useCustomTable && screenTableName && (
<div className="bg-muted/50 text-muted-foreground rounded p-2 text-[10px]">
<strong>:</strong> ({screenTableName})
</div>
)}
</div>
{/* 테이블 제목 설정 */}
<div className="space-y-3">
<div>

View File

@ -235,6 +235,11 @@ export interface TableListConfig extends ComponentConfig {
showHeader: boolean;
showFooter: boolean;
// 🆕 커스텀 테이블 설정 (화면 메인 테이블과 다른 테이블 사용 시)
customTableName?: string; // 컴포넌트가 사용할 커스텀 테이블명
useCustomTable?: boolean; // true면 customTableName 사용, false면 화면 메인 테이블 사용
isReadOnly?: boolean; // 읽기전용 여부 (조회용 테이블인 경우 true)
// 체크박스 설정
checkbox: CheckboxConfig;

View File

@ -1533,6 +1533,7 @@ export class ButtonActionExecutor {
tableName: context.tableName,
mainFormDataKeys: Object.keys(mainFormData),
saveResultData: saveResult?.data,
masterRecordId: savedId, // 🆕 마스터 레코드 ID (FK 연결용)
});
window.dispatchEvent(
new CustomEvent("repeaterSave", {
@ -1540,6 +1541,7 @@ export class ButtonActionExecutor {
parentId: savedId,
tableName: context.tableName,
mainFormData, // 🆕 메인 폼 데이터 전달
masterRecordId: savedId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
},
}),
);

View File

@ -139,6 +139,12 @@ export interface UnifiedRepeaterConfig {
// 렌더링 모드
renderMode: RepeaterRenderMode;
// 🆕 저장 대상 테이블 설정 (화면 메인 테이블과 다른 테이블에 저장할 경우)
mainTableName?: string; // 리피터 데이터가 저장될 테이블명 (미설정 시 화면 메인 테이블)
useCustomTable?: boolean; // true면 mainTableName 사용
foreignKeyColumn?: string; // 마스터 테이블과 연결할 FK 컬럼명 (예: receiving_id)
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼명 (예: id) - 자동 연결용
// 데이터 소스 설정
dataSource: RepeaterDataSource;