; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin 2026-01-12 09:25:13 +09:00
commit 31e87e0bca
7 changed files with 451 additions and 160 deletions

View File

@ -84,6 +84,11 @@ export interface ExcelUploadModalProps {
}; };
// 🆕 마스터-디테일 엑셀 업로드 설정 // 🆕 마스터-디테일 엑셀 업로드 설정
masterDetailExcelConfig?: MasterDetailExcelConfig; masterDetailExcelConfig?: MasterDetailExcelConfig;
// 🆕 단일 테이블 채번 설정
numberingRuleId?: string;
numberingTargetColumn?: string;
// 🆕 업로드 후 제어 실행 설정
afterUploadFlows?: Array<{ flowId: string; order: number }>;
} }
interface ColumnMapping { interface ColumnMapping {
@ -103,6 +108,11 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
isMasterDetail = false, isMasterDetail = false,
masterDetailRelation, masterDetailRelation,
masterDetailExcelConfig, masterDetailExcelConfig,
// 단일 테이블 채번 설정
numberingRuleId,
numberingTargetColumn,
// 업로드 후 제어 실행 설정
afterUploadFlows,
}) => { }) => {
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
@ -695,13 +705,48 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
} else { } else {
// 기존 단일 테이블 업로드 로직 // 기존 단일 테이블 업로드 로직
console.log("📊 단일 테이블 업로드 시작:", {
tableName,
uploadMode,
numberingRuleId,
numberingTargetColumn,
dataCount: filteredData.length,
});
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
// 🆕 단일 테이블 채번 설정 확인
const hasNumbering = numberingRuleId && numberingTargetColumn;
console.log("📊 채번 설정:", { hasNumbering, numberingRuleId, numberingTargetColumn });
for (const row of filteredData) { for (const row of filteredData) {
try { try {
let dataToSave = { ...row };
// 🆕 채번 적용: 각 행마다 채번 API 호출
if (hasNumbering && uploadMode === "insert") {
try {
const { apiClient } = await import("@/lib/api/client");
console.log(`📊 채번 API 호출: /numbering-rules/${numberingRuleId}/allocate`);
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
console.log(`📊 채번 API 응답:`, numberingResponse.data);
// 응답 구조: { success: true, data: { generatedCode: "..." } }
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
if (numberingResponse.data?.success && generatedCode) {
dataToSave[numberingTargetColumn] = generatedCode;
console.log(`✅ 채번 적용: ${numberingTargetColumn} = ${generatedCode}`);
} else {
console.warn(`⚠️ 채번 실패: 응답에 코드 없음`, numberingResponse.data);
}
} catch (numError) {
console.error("채번 오류:", numError);
// 채번 실패 시에도 계속 진행 (채번 컬럼만 비워둠)
}
}
if (uploadMode === "insert") { if (uploadMode === "insert") {
const formData = { screenId: 0, tableName, data: row }; const formData = { screenId: 0, tableName, data: dataToSave };
const result = await DynamicFormApi.saveFormData(formData); const result = await DynamicFormApi.saveFormData(formData);
if (result.success) { if (result.success) {
successCount++; successCount++;
@ -714,6 +759,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
} }
// 🆕 업로드 후 제어 실행
if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) {
console.log("🔄 업로드 후 제어 실행:", afterUploadFlows);
try {
const { apiClient } = await import("@/lib/api/client");
// 순서대로 실행
const sortedFlows = [...afterUploadFlows].sort((a, b) => a.order - b.order);
for (const flow of sortedFlows) {
await apiClient.post(`/dataflow/node-flows/${flow.flowId}/execute`, {
sourceData: { tableName, uploadedCount: successCount },
});
console.log(`✅ 제어 실행 완료: flowId=${flow.flowId}`);
}
} catch (controlError) {
console.error("제어 실행 오류:", controlError);
}
}
if (successCount > 0) { if (successCount > 0) {
toast.success( toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`

View File

@ -175,13 +175,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (editData) { if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
// 🆕 배열인 경우 (그룹 레코드) vs 단일 객체 처리 // 🆕 배열인 경우 두 가지 데이터를 설정:
// 1. formData: 첫 번째 요소(객체) - 일반 입력 필드용 (TextInput 등)
// 2. selectedData: 전체 배열 - 다중 항목 컴포넌트용 (SelectedItemsDetailInput 등)
if (Array.isArray(editData)) { if (Array.isArray(editData)) {
console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정`); const firstRecord = editData[0] || {};
setFormData(editData as any); // 배열 그대로 전달 (SelectedItemsDetailInput에서 처리) console.log(`📝 [ScreenModal] 그룹 레코드 ${editData.length}개 설정:`, {
setOriginalData(editData[0] || null); // 첫 번째 레코드를 원본으로 저장 formData: "첫 번째 레코드 (일반 입력 필드용)",
selectedData: "전체 배열 (다중 항목 컴포넌트용)",
});
setFormData(firstRecord); // 🔧 일반 입력 필드용 (객체)
setSelectedData(editData); // 🔧 다중 항목 컴포넌트용 (배열) - groupedData로 전달됨
setOriginalData(firstRecord); // 첫 번째 레코드를 원본으로 저장
} else { } else {
setFormData(editData); setFormData(editData);
setSelectedData([editData]); // 🔧 단일 객체도 배열로 변환하여 저장
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} }
} else { } else {

View File

@ -5,7 +5,6 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -2026,7 +2025,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 엑셀 업로드 액션 설정 */} {/* 엑셀 업로드 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "excel_upload" && ( {(component.componentConfig?.action?.type || "save") === "excel_upload" && (
<ExcelUploadConfigSection config={config} onUpdateProperty={onUpdateProperty} allComponents={allComponents} /> <ExcelUploadConfigSection
config={config}
onUpdateProperty={onUpdateProperty}
allComponents={allComponents}
currentTableName={currentTableName}
/>
)} )}
{/* 바코드 스캔 액션 설정 */} {/* 바코드 스캔 액션 설정 */}
@ -3311,7 +3315,6 @@ const MasterDetailExcelUploadConfig: React.FC<{
onUpdateProperty: (path: string, value: any) => void; onUpdateProperty: (path: string, value: any) => void;
allComponents: ComponentData[]; allComponents: ComponentData[];
}> = ({ config, onUpdateProperty, allComponents }) => { }> = ({ config, onUpdateProperty, allComponents }) => {
const [numberingRules, setNumberingRules] = useState<any[]>([]);
const [relationInfo, setRelationInfo] = useState<{ const [relationInfo, setRelationInfo] = useState<{
masterTable: string; masterTable: string;
detailTable: string; detailTable: string;
@ -3319,7 +3322,6 @@ const MasterDetailExcelUploadConfig: React.FC<{
detailFkColumn: string; detailFkColumn: string;
} | null>(null); } | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [numberingRuleOpen, setNumberingRuleOpen] = useState(false);
const [masterColumns, setMasterColumns] = useState< const [masterColumns, setMasterColumns] = useState<
Array<{ Array<{
columnName: string; columnName: string;
@ -3357,22 +3359,6 @@ const MasterDetailExcelUploadConfig: React.FC<{
const masterTable = splitPanelInfo?.leftPanel?.tableName || ""; const masterTable = splitPanelInfo?.leftPanel?.tableName || "";
const detailTable = splitPanelInfo?.rightPanel?.tableName || ""; const detailTable = splitPanelInfo?.rightPanel?.tableName || "";
// 채번 규칙 로드
useEffect(() => {
const loadNumberingRules = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get("/numbering-rules");
if (response.data?.success && response.data?.data) {
setNumberingRules(response.data.data);
}
} catch (error) {
console.error("채번 규칙 로드 실패:", error);
}
};
loadNumberingRules();
}, []);
// 마스터 테이블 컬럼 로드 // 마스터 테이블 컬럼 로드
useEffect(() => { useEffect(() => {
if (!masterTable) { if (!masterTable) {
@ -3547,86 +3533,12 @@ const MasterDetailExcelUploadConfig: React.FC<{
)} )}
</div> </div>
{/* 채번 규칙 선택 - 유일하게 사용자가 설정하는 항목 */} {/* 마스터 키 자동 생성 안내 */}
{relationInfo && ( {relationInfo && (
<div> <p className="text-muted-foreground border-t pt-2 text-xs">
<Label className="text-xs"> </Label> <strong>{relationInfo.masterKeyColumn}</strong>
<Popover open={numberingRuleOpen} onOpenChange={setNumberingRuleOpen}> .
<PopoverTrigger asChild> </p>
<Button
variant="outline"
role="combobox"
aria-expanded={numberingRuleOpen}
className="h-8 w-full justify-between text-xs"
>
{masterDetailConfig.numberingRuleId
? numberingRules.find(
(rule) => String(rule.rule_id || rule.ruleId) === String(masterDetailConfig.numberingRuleId),
)?.rule_name ||
numberingRules.find(
(rule) => String(rule.rule_id || rule.ruleId) === String(masterDetailConfig.numberingRuleId),
)?.ruleName ||
"선택됨"
: "채번 없음 (수동 입력)"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="채번 규칙 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
updateMasterDetailConfig({ numberingRuleId: undefined });
setNumberingRuleOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!masterDetailConfig.numberingRuleId ? "opacity-100" : "opacity-0",
)}
/>
( )
</CommandItem>
{numberingRules
.filter((rule) => rule.table_name === masterTable || !rule.table_name)
.map((rule, idx) => {
const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`);
const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)";
return (
<CommandItem
key={ruleId}
value={ruleName}
onSelect={() => {
updateMasterDetailConfig({ numberingRuleId: ruleId });
setNumberingRuleOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
String(masterDetailConfig.numberingRuleId) === ruleId ? "opacity-100" : "opacity-0",
)}
/>
{ruleName}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs">
<strong>{relationInfo.masterKeyColumn}</strong>
</p>
</div>
)} )}
{/* 마스터 필드 선택 - 사용자가 엑셀 업로드 시 입력할 필드 */} {/* 마스터 필드 선택 - 사용자가 엑셀 업로드 시 입력할 필드 */}
@ -3726,14 +3638,6 @@ const MasterDetailExcelUploadConfig: React.FC<{
<p className="text-muted-foreground text-xs"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
)} )}
{/* 업로드 후 제어 실행 설정 */}
<AfterUploadControlConfig
config={config}
onUpdateProperty={onUpdateProperty}
masterDetailConfig={masterDetailConfig}
updateMasterDetailConfig={updateMasterDetailConfig}
/>
</div> </div>
)} )}
</div> </div>
@ -3741,23 +3645,181 @@ const MasterDetailExcelUploadConfig: React.FC<{
}; };
/** /**
* * ( /- )
*
*/ */
const AfterUploadControlConfig: React.FC<{ const ExcelNumberingRuleConfig: React.FC<{
config: any; config: { numberingRuleId?: string; numberingTargetColumn?: string };
onUpdateProperty: (path: string, value: any) => void; updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void;
masterDetailConfig: any; tableName?: string; // 단일 테이블인 경우 테이블명
updateMasterDetailConfig: (updates: any) => void; hasSplitPanel?: boolean; // 분할 패널 여부 (마스터-디테일)
}> = ({ masterDetailConfig, updateMasterDetailConfig }) => { }> = ({ config, updateConfig, tableName, hasSplitPanel }) => {
const [nodeFlows, setNodeFlows] = useState< const [numberingRules, setNumberingRules] = useState<any[]>([]);
Array<{ flowId: number; flowName: string; flowDescription?: string }> const [ruleSelectOpen, setRuleSelectOpen] = useState(false);
>([]); const [isLoading, setIsLoading] = useState(false);
const [tableColumns, setTableColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 채번 규칙 목록 로드
useEffect(() => {
const loadNumberingRules = async () => {
setIsLoading(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get("/numbering-rules");
if (response.data?.success && response.data?.data) {
setNumberingRules(response.data.data);
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
} finally {
setIsLoading(false);
}
};
loadNumberingRules();
}, []);
// 단일 테이블인 경우 컬럼 목록 로드
useEffect(() => {
if (!tableName || hasSplitPanel) {
setTableColumns([]);
return;
}
const loadColumns = async () => {
setColumnsLoading(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
if (response.data?.success && response.data?.data?.columns) {
const cols = response.data.data.columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
}));
setTableColumns(cols);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
} finally {
setColumnsLoading(false);
}
};
loadColumns();
}, [tableName, hasSplitPanel]);
const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId));
return (
<div className="border-t pt-3">
<Label className="text-xs"> </Label>
<p className="text-muted-foreground mb-2 text-xs">
/ .
</p>
<Popover open={ruleSelectOpen} onOpenChange={setRuleSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={ruleSelectOpen}
className="h-8 w-full justify-between text-xs"
disabled={isLoading}
>
{isLoading ? "로딩 중..." : selectedRule?.rule_name || selectedRule?.ruleName || "채번 없음"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="채번 규칙 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
onSelect={() => {
updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined });
setRuleSelectOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-4 w-4", !config.numberingRuleId ? "opacity-100" : "opacity-0")} />
</CommandItem>
{numberingRules.map((rule, idx) => {
const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`);
const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)";
return (
<CommandItem
key={ruleId}
value={ruleName}
onSelect={() => {
updateConfig({ numberingRuleId: ruleId });
setRuleSelectOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
String(config.numberingRuleId) === ruleId ? "opacity-100" : "opacity-0",
)}
/>
{ruleName}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* 단일 테이블이고 채번 규칙이 선택된 경우, 적용할 컬럼 선택 */}
{config.numberingRuleId && !hasSplitPanel && tableName && (
<div className="mt-2">
<Label className="text-xs"> </Label>
<Select
value={config.numberingTargetColumn || ""}
onValueChange={(value) => updateConfig({ numberingTargetColumn: value || undefined })}
disabled={columnsLoading}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={columnsLoading ? "로딩 중..." : "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs"> .</p>
</div>
)}
{/* 분할 패널인 경우 안내 메시지 */}
{config.numberingRuleId && hasSplitPanel && (
<p className="text-muted-foreground mt-1 text-xs">- .</p>
)}
</div>
);
};
/**
* ( /- )
*/
const ExcelAfterUploadControlConfig: React.FC<{
config: { afterUploadFlows?: Array<{ flowId: string; order: number }> };
updateConfig: (updates: { afterUploadFlows?: Array<{ flowId: string; order: number }> }) => void;
}> = ({ config, updateConfig }) => {
const [nodeFlows, setNodeFlows] = useState<Array<{ flowId: number; flowName: string; flowDescription?: string }>>([]);
const [flowSelectOpen, setFlowSelectOpen] = useState(false); const [flowSelectOpen, setFlowSelectOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// 선택된 제어 목록 (배열로 관리) const selectedFlows = config.afterUploadFlows || [];
const selectedFlows: Array<{ flowId: string; order: number }> = masterDetailConfig.afterUploadFlows || [];
// 노드 플로우 목록 로드 // 노드 플로우 목록 로드
useEffect(() => { useEffect(() => {
@ -3779,53 +3841,39 @@ const AfterUploadControlConfig: React.FC<{
loadNodeFlows(); loadNodeFlows();
}, []); }, []);
// 제어 추가
const addFlow = (flowId: string) => { const addFlow = (flowId: string) => {
if (selectedFlows.some((f) => f.flowId === flowId)) return; if (selectedFlows.some((f) => f.flowId === flowId)) return;
const newFlows = [...selectedFlows, { flowId, order: selectedFlows.length + 1 }]; const newFlows = [...selectedFlows, { flowId, order: selectedFlows.length + 1 }];
updateMasterDetailConfig({ afterUploadFlows: newFlows }); updateConfig({ afterUploadFlows: newFlows });
setFlowSelectOpen(false); setFlowSelectOpen(false);
}; };
// 제어 제거
const removeFlow = (flowId: string) => { const removeFlow = (flowId: string) => {
const newFlows = selectedFlows const newFlows = selectedFlows.filter((f) => f.flowId !== flowId).map((f, idx) => ({ ...f, order: idx + 1 }));
.filter((f) => f.flowId !== flowId) updateConfig({ afterUploadFlows: newFlows });
.map((f, idx) => ({ ...f, order: idx + 1 }));
updateMasterDetailConfig({ afterUploadFlows: newFlows });
}; };
// 순서 변경 (위로)
const moveUp = (index: number) => { const moveUp = (index: number) => {
if (index === 0) return; if (index === 0) return;
const newFlows = [...selectedFlows]; const newFlows = [...selectedFlows];
[newFlows[index - 1], newFlows[index]] = [newFlows[index], newFlows[index - 1]]; [newFlows[index - 1], newFlows[index]] = [newFlows[index], newFlows[index - 1]];
updateMasterDetailConfig({ updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) });
afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })),
});
}; };
// 순서 변경 (아래로)
const moveDown = (index: number) => { const moveDown = (index: number) => {
if (index === selectedFlows.length - 1) return; if (index === selectedFlows.length - 1) return;
const newFlows = [...selectedFlows]; const newFlows = [...selectedFlows];
[newFlows[index], newFlows[index + 1]] = [newFlows[index + 1], newFlows[index]]; [newFlows[index], newFlows[index + 1]] = [newFlows[index + 1], newFlows[index]];
updateMasterDetailConfig({ updateConfig({ afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })) });
afterUploadFlows: newFlows.map((f, idx) => ({ ...f, order: idx + 1 })),
});
}; };
// 선택되지 않은 플로우만 필터링
const availableFlows = nodeFlows.filter((f) => !selectedFlows.some((s) => s.flowId === String(f.flowId))); const availableFlows = nodeFlows.filter((f) => !selectedFlows.some((s) => s.flowId === String(f.flowId)));
return ( return (
<div className="border-t pt-3"> <div className="border-t pt-3">
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<p className="text-muted-foreground mb-2 text-xs"> <p className="text-muted-foreground mb-2 text-xs"> .</p>
.
</p>
{/* 선택된 제어 목록 */}
{selectedFlows.length > 0 && ( {selectedFlows.length > 0 && (
<div className="mb-2 space-y-1"> <div className="mb-2 space-y-1">
{selectedFlows.map((selected, index) => { {selectedFlows.map((selected, index) => {
@ -3852,7 +3900,12 @@ const AfterUploadControlConfig: React.FC<{
> >
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</Button> </Button>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 text-red-500" onClick={() => removeFlow(selected.flowId)}> <Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-red-500"
onClick={() => removeFlow(selected.flowId)}
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
@ -3861,7 +3914,6 @@ const AfterUploadControlConfig: React.FC<{
</div> </div>
)} )}
{/* 제어 추가 버튼 */}
<Popover open={flowSelectOpen} onOpenChange={setFlowSelectOpen}> <Popover open={flowSelectOpen} onOpenChange={setFlowSelectOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@ -3917,7 +3969,126 @@ const ExcelUploadConfigSection: React.FC<{
config: any; config: any;
onUpdateProperty: (path: string, value: any) => void; onUpdateProperty: (path: string, value: any) => void;
allComponents: ComponentData[]; allComponents: ComponentData[];
}> = ({ config, onUpdateProperty, allComponents }) => { currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달)
}> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => {
// 엑셀 업로드 설정 상태 관리
const [excelUploadConfig, setExcelUploadConfig] = useState<{
numberingRuleId?: string;
numberingTargetColumn?: string;
afterUploadFlows?: Array<{ flowId: string; order: number }>;
}>({
numberingRuleId: config.action?.excelNumberingRuleId,
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
});
// 분할 패널 감지
const splitPanelInfo = useMemo(() => {
const findSplitPanel = (components: any[]): any => {
for (const comp of components) {
const compId = comp.componentId || comp.componentType;
if (compId === "split-panel-layout") {
return comp.componentConfig;
}
if (comp.children && comp.children.length > 0) {
const found = findSplitPanel(comp.children);
if (found) return found;
}
}
return null;
};
return findSplitPanel(allComponents as any[]);
}, [allComponents]);
const hasSplitPanel = !!splitPanelInfo;
// 단일 테이블 감지 (props 우선, 없으면 컴포넌트에서 탐색)
const singleTableName = useMemo(() => {
if (hasSplitPanel) return undefined;
// props로 전달된 테이블명 우선 사용
if (propTableName) return propTableName;
// 컴포넌트에서 테이블명 탐색
const findTableName = (components: any[]): string | undefined => {
for (const comp of components) {
const compId = comp.componentId || comp.componentType;
const compConfig = comp.componentConfig || comp.config || comp;
// 테이블 패널이나 데이터 테이블에서 테이블명 찾기
if (
compId === "table-panel" ||
compId === "data-table" ||
compId === "table-list" ||
compId === "simple-table"
) {
const tableName = compConfig?.tableName || compConfig?.table;
if (tableName) return tableName;
}
// 폼 컴포넌트에서 테이블명 찾기
if (compId === "form-panel" || compId === "input-form" || compId === "form" || compId === "detail-form") {
const tableName = compConfig?.tableName || compConfig?.table;
if (tableName) return tableName;
}
// 범용적으로 tableName 속성이 있는 컴포넌트 찾기
if (compConfig?.tableName) {
return compConfig.tableName;
}
if (comp.children && comp.children.length > 0) {
const found = findTableName(comp.children);
if (found) return found;
}
}
return undefined;
};
return findTableName(allComponents as any[]);
}, [allComponents, hasSplitPanel, propTableName]);
// 디버깅: 감지된 테이블명 로그
useEffect(() => {
console.log(
"[ExcelUploadConfigSection] 분할 패널:",
hasSplitPanel,
"단일 테이블:",
singleTableName,
"(props:",
propTableName,
")",
);
}, [hasSplitPanel, singleTableName, propTableName]);
// 설정 업데이트 함수
const updateExcelUploadConfig = (updates: Partial<typeof excelUploadConfig>) => {
const newConfig = { ...excelUploadConfig, ...updates };
setExcelUploadConfig(newConfig);
if (updates.numberingRuleId !== undefined) {
onUpdateProperty("componentConfig.action.excelNumberingRuleId", updates.numberingRuleId);
}
if (updates.numberingTargetColumn !== undefined) {
onUpdateProperty("componentConfig.action.excelNumberingTargetColumn", updates.numberingTargetColumn);
}
if (updates.afterUploadFlows !== undefined) {
onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows);
}
};
// config 변경 시 로컬 상태 동기화
useEffect(() => {
setExcelUploadConfig({
numberingRuleId: config.action?.excelNumberingRuleId,
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
});
}, [
config.action?.excelNumberingRuleId,
config.action?.excelNumberingTargetColumn,
config.action?.excelAfterUploadFlows,
]);
return ( return (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4"> <div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
<h4 className="text-foreground text-sm font-medium"> </h4> <h4 className="text-foreground text-sm font-medium"> </h4>
@ -3955,6 +4126,17 @@ const ExcelUploadConfigSection: React.FC<{
</div> </div>
)} )}
{/* 채번 규칙 설정 (항상 표시) */}
<ExcelNumberingRuleConfig
config={excelUploadConfig}
updateConfig={updateExcelUploadConfig}
tableName={singleTableName}
hasSplitPanel={hasSplitPanel}
/>
{/* 업로드 후 제어 실행 (항상 표시) */}
<ExcelAfterUploadControlConfig config={excelUploadConfig} updateConfig={updateExcelUploadConfig} />
{/* 마스터-디테일 설정 (분할 패널 자동 감지) */} {/* 마스터-디테일 설정 (분할 패널 자동 감지) */}
<MasterDetailExcelUploadConfig <MasterDetailExcelUploadConfig
config={config} config={config}

View File

@ -281,10 +281,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트의 columnName에 해당하는 formData 값 추출 // 컴포넌트의 columnName에 해당하는 formData 값 추출
const fieldName = (component as any).columnName || component.id; const fieldName = (component as any).columnName || component.id;
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화 // 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
let currentValue; let currentValue;
if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal") { if (componentType === "modal-repeater-table" ||
// EditModal에서 전달된 groupedData가 있으면 우선 사용 componentType === "repeat-screen-modal" ||
componentType === "selected-items-detail-input") {
// EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || []; currentValue = props.groupedData || formData?.[fieldName] || [];
} else { } else {
currentValue = formData?.[fieldName] || ""; currentValue = formData?.[fieldName] || "";

View File

@ -42,6 +42,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
screenId, screenId,
...props ...props
}) => { }) => {
// 🆕 groupedData 추출 (DynamicComponentRenderer에서 전달)
const groupedData = (props as any).groupedData || (props as any)._groupedData;
// 🆕 URL 파라미터에서 dataSourceId 읽기 // 🆕 URL 파라미터에서 dataSourceId 읽기
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const urlDataSourceId = searchParams?.get("dataSourceId") || undefined; const urlDataSourceId = searchParams?.get("dataSourceId") || undefined;
@ -225,24 +227,32 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조) // 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조)
useEffect(() => { useEffect(() => {
// 🆕 수정 모드: formData에서 데이터 로드 (URL에 mode=edit이 있으면) // 🆕 수정 모드: groupedData 또는 formData에서 데이터 로드 (URL에 mode=edit이 있으면)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode"); const mode = urlParams.get("mode");
if (mode === "edit" && formData) { // 🔧 데이터 소스 우선순위: groupedData > formData (배열) > formData (객체)
const sourceData = groupedData && Array.isArray(groupedData) && groupedData.length > 0
? groupedData
: formData;
if (mode === "edit" && sourceData) {
// 배열인지 단일 객체인지 확인 // 배열인지 단일 객체인지 확인
const isArray = Array.isArray(formData); const isArray = Array.isArray(sourceData);
const dataArray = isArray ? formData : [formData]; const dataArray = isArray ? sourceData : [sourceData];
if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) { if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) {
console.warn("⚠️ [SelectedItemsDetailInput] formData가 비어있음"); console.warn("⚠️ [SelectedItemsDetailInput] 데이터가 비어있음");
return; return;
} }
console.log( console.log(
`📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? "그룹 레코드" : "단일 레코드"} (${dataArray.length}개)`, `📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? "그룹 레코드" : "단일 레코드"} (${dataArray.length}개)`,
); );
console.log("📝 [SelectedItemsDetailInput] formData (JSON):", JSON.stringify(dataArray, null, 2)); console.log("📝 [SelectedItemsDetailInput] 데이터 소스:", {
fromGroupedData: groupedData && Array.isArray(groupedData) && groupedData.length > 0,
dataArray: JSON.stringify(dataArray, null, 2),
});
const groups = componentConfig.fieldGroups || []; const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || []; const additionalFields = componentConfig.additionalFields || [];
@ -423,7 +433,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}); });
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalData, component.id, componentConfig.fieldGroups, formData]); // formData 의존성 추가 }, [modalData, component.id, componentConfig.fieldGroups, formData, groupedData]); // groupedData 의존성 추가
// 🆕 Cartesian Product 생성 함수 (items에서 모든 그룹의 조합을 생성) // 🆕 Cartesian Product 생성 함수 (items에서 모든 그룹의 조합을 생성)
const generateCartesianProduct = useCallback( const generateCartesianProduct = useCallback(

View File

@ -51,6 +51,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// 숨김 상태 (props에서 전달받은 값 우선 사용) // 숨김 상태 (props에서 전달받은 값 우선 사용)
const isHidden = props.hidden !== undefined ? props.hidden : component.hidden || componentConfig.hidden || false; const isHidden = props.hidden !== undefined ? props.hidden : component.hidden || componentConfig.hidden || false;
// 수정 모드 여부 확인 (originalData가 있으면 수정 모드)
const originalData = props.originalData || (props as any)._originalData;
const isEditMode = originalData && Object.keys(originalData).length > 0;
// 자동생성된 값 상태 // 자동생성된 값 상태
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>(""); const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
@ -99,6 +103,16 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
return; return;
} }
// 🆕 수정 모드일 때는 채번 규칙 스킵 (기존 값 유지)
if (isEditMode) {
console.log("⏭️ 수정 모드 - 채번 규칙 스킵:", {
columnName: component.columnName,
originalValue: originalData?.[component.columnName],
});
hasGeneratedRef.current = true; // 생성 완료로 표시하여 재실행 방지
return;
}
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") { if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음 // 폼 데이터에 이미 값이 있으면 자동생성하지 않음
const currentFormValue = formData?.[component.columnName]; const currentFormValue = formData?.[component.columnName];
@ -171,7 +185,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}; };
generateAutoValue(); generateAutoValue();
}, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive]); }, [testAutoGeneration.enabled, testAutoGeneration.type, component.columnName, isInteractive, isEditMode]);
// 실제 화면에서 숨김 처리된 컴포넌트는 렌더링하지 않음 // 실제 화면에서 숨김 처리된 컴포넌트는 렌더링하지 않음
if (isHidden && !isDesignMode) { if (isHidden && !isDesignMode) {

View File

@ -88,6 +88,9 @@ export interface ButtonActionConfig {
// 엑셀 업로드 관련 // 엑셀 업로드 관련
excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드 excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드
excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼 excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼
excelNumberingRuleId?: string; // 채번 규칙 ID (단일 테이블용)
excelNumberingTargetColumn?: string; // 채번 적용 컬럼 (단일 테이블용)
excelAfterUploadFlows?: Array<{ flowId: string; order: number }>; // 업로드 후 제어 실행
// 바코드 스캔 관련 // 바코드 스캔 관련
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명 barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
@ -4838,6 +4841,10 @@ export class ButtonActionExecutor {
userId: context.userId, userId: context.userId,
tableName: context.tableName, tableName: context.tableName,
screenId: context.screenId, screenId: context.screenId,
// 채번 설정 디버깅
numberingRuleId: config.excelNumberingRuleId,
numberingTargetColumn: config.excelNumberingTargetColumn,
afterUploadFlows: config.excelAfterUploadFlows,
}); });
// 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지) // 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
@ -4909,7 +4916,7 @@ export class ButtonActionExecutor {
savedSize: localStorage.getItem(storageKey), savedSize: localStorage.getItem(storageKey),
}); });
root.render( root.render(
React.createElement(ExcelUploadModal, { React.createElement(ExcelUploadModal, {
open: true, open: true,
onOpenChange: (open: boolean) => { onOpenChange: (open: boolean) => {
@ -4931,6 +4938,11 @@ export class ButtonActionExecutor {
isMasterDetail, isMasterDetail,
masterDetailRelation, masterDetailRelation,
masterDetailExcelConfig, masterDetailExcelConfig,
// 🆕 단일 테이블 채번 설정
numberingRuleId: config.excelNumberingRuleId,
numberingTargetColumn: config.excelNumberingTargetColumn,
// 🆕 업로드 후 제어 실행 설정
afterUploadFlows: config.excelAfterUploadFlows,
onSuccess: () => { onSuccess: () => {
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨 // 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
context.onRefresh?.(); context.onRefresh?.();