Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-unified-renewal
This commit is contained in:
@@ -937,11 +937,17 @@ export class DynamicFormService {
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
// 🆕 JSONB 타입 값은 JSON 문자열로 변환
|
||||
// 🆕 JSONB 타입 값은 JSON 문자열로 변환, 빈 문자열은 null로 변환
|
||||
const values: any[] = Object.keys(changedFields).map((key) => {
|
||||
const value = changedFields[key];
|
||||
const dataType = columnTypes[key];
|
||||
|
||||
// 🔧 빈 문자열은 null로 변환 (날짜 필드 등에서 값을 지울 때 필요)
|
||||
if (value === "" || value === undefined) {
|
||||
console.log(`🔄 빈 값 → null 변환: ${key}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
|
||||
if (
|
||||
(dataType === "jsonb" || dataType === "json") &&
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
Zap,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
|
||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
|
||||
@@ -94,6 +96,8 @@ export interface ExcelUploadModalProps {
|
||||
interface ColumnMapping {
|
||||
excelColumn: string;
|
||||
systemColumn: string | null;
|
||||
// 중복 체크 설정 (해당 컬럼을 중복 체크 키로 사용할지)
|
||||
checkDuplicate?: boolean;
|
||||
}
|
||||
|
||||
export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
@@ -131,6 +135,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
const [excelColumns, setExcelColumns] = useState<string[]>([]);
|
||||
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
|
||||
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
||||
|
||||
// 중복 처리 방법 (전역 설정)
|
||||
const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip");
|
||||
|
||||
// 3단계: 확인
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -544,6 +551,20 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 중복 체크 설정 변경
|
||||
const handleDuplicateCheckChange = (excelColumn: string, checkDuplicate: boolean) => {
|
||||
setColumnMappings((prev) =>
|
||||
prev.map((mapping) =>
|
||||
mapping.excelColumn === excelColumn
|
||||
? { ...mapping, checkDuplicate }
|
||||
: mapping
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 중복 체크 설정된 컬럼 수
|
||||
const duplicateCheckCount = columnMappings.filter((m) => m.checkDuplicate && m.systemColumn).length;
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = () => {
|
||||
if (currentStep === 1 && !file) {
|
||||
@@ -707,16 +728,96 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
// 기존 단일 테이블 업로드 로직
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let skipCount = 0;
|
||||
let overwriteCount = 0;
|
||||
|
||||
// 단일 테이블 채번 설정 확인
|
||||
const hasNumbering = numberingRuleId && numberingTargetColumn;
|
||||
|
||||
// 중복 체크 설정 확인
|
||||
const duplicateCheckMappings = columnMappings.filter(
|
||||
(m) => m.checkDuplicate && m.systemColumn
|
||||
);
|
||||
const hasDuplicateCheck = duplicateCheckMappings.length > 0;
|
||||
|
||||
// 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만)
|
||||
let existingDataMap: Map<string, any> = new Map();
|
||||
if (hasDuplicateCheck) {
|
||||
try {
|
||||
// 중복 체크할 컬럼들의 값 조회
|
||||
const checkColumns = duplicateCheckMappings.map((m) => {
|
||||
let colName = m.systemColumn!;
|
||||
if (isMasterDetail && colName.includes(".")) {
|
||||
colName = colName.split(".")[1];
|
||||
}
|
||||
return colName;
|
||||
});
|
||||
|
||||
// DynamicFormApi.getTableData 사용
|
||||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||||
page: 1,
|
||||
pageSize: 10000,
|
||||
});
|
||||
|
||||
console.log("📊 중복 체크용 기존 데이터 조회 결과:", existingResponse);
|
||||
|
||||
// getTableData는 { success, data: [...] } 또는 { success, data: { rows: [...] } } 형식
|
||||
const rows = existingResponse.data?.rows || existingResponse.data;
|
||||
if (existingResponse.success && rows && Array.isArray(rows)) {
|
||||
// 중복 체크 컬럼 값을 키로 하는 맵 생성
|
||||
rows.forEach((row: any) => {
|
||||
const keyParts = checkColumns.map((col) => String(row[col] || "").trim());
|
||||
const key = keyParts.join("|||");
|
||||
existingDataMap.set(key, row);
|
||||
});
|
||||
console.log(`📊 중복 체크용 기존 데이터 로드: ${existingDataMap.size}건`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중복 체크 데이터 조회 오류:", error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of filteredData) {
|
||||
try {
|
||||
let dataToSave = { ...row };
|
||||
let shouldSkip = false;
|
||||
let shouldUpdate = false;
|
||||
let existingRow: any = null;
|
||||
|
||||
// 채번 적용: 각 행마다 채번 API 호출
|
||||
if (hasNumbering && uploadMode === "insert") {
|
||||
// 중복 체크
|
||||
if (hasDuplicateCheck) {
|
||||
const checkColumns = duplicateCheckMappings.map((m) => {
|
||||
let colName = m.systemColumn!;
|
||||
if (isMasterDetail && colName.includes(".")) {
|
||||
colName = colName.split(".")[1];
|
||||
}
|
||||
return colName;
|
||||
});
|
||||
|
||||
const keyParts = checkColumns.map((col) => String(dataToSave[col] || "").trim());
|
||||
const key = keyParts.join("|||");
|
||||
|
||||
if (existingDataMap.has(key)) {
|
||||
existingRow = existingDataMap.get(key);
|
||||
// 중복 발견 - 전역 설정에 따라 처리
|
||||
if (duplicateAction === "skip") {
|
||||
shouldSkip = true;
|
||||
skipCount++;
|
||||
console.log(`⏭️ 중복으로 건너뛰기: ${key}`);
|
||||
} else {
|
||||
shouldUpdate = true;
|
||||
console.log(`🔄 중복으로 덮어쓰기: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 건너뛰기 처리
|
||||
if (shouldSkip) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만)
|
||||
if (hasNumbering && uploadMode === "insert" && !shouldUpdate) {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
|
||||
@@ -729,7 +830,22 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadMode === "insert") {
|
||||
if (shouldUpdate && existingRow) {
|
||||
// 덮어쓰기: 기존 데이터 업데이트
|
||||
const formData = {
|
||||
screenId: 0,
|
||||
tableName,
|
||||
data: dataToSave,
|
||||
};
|
||||
const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
|
||||
if (result.success) {
|
||||
overwriteCount++;
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} else if (uploadMode === "insert") {
|
||||
// 신규 등록
|
||||
const formData = { screenId: 0, tableName, data: dataToSave };
|
||||
const result = await DynamicFormApi.saveFormData(formData);
|
||||
if (result.success) {
|
||||
@@ -743,7 +859,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 업로드 후 제어 실행
|
||||
// 업로드 후 제어 실행
|
||||
if (afterUploadFlows && afterUploadFlows.length > 0 && successCount > 0) {
|
||||
console.log("🔄 업로드 후 제어 실행:", afterUploadFlows);
|
||||
try {
|
||||
@@ -761,10 +877,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(
|
||||
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
|
||||
);
|
||||
if (successCount > 0 || skipCount > 0) {
|
||||
// 상세 결과 메시지 생성
|
||||
let message = "";
|
||||
if (successCount > 0) {
|
||||
message += `${successCount}개 행 업로드`;
|
||||
if (overwriteCount > 0) {
|
||||
message += ` (덮어쓰기 ${overwriteCount}건)`;
|
||||
}
|
||||
}
|
||||
if (skipCount > 0) {
|
||||
message += message ? `, ` : "";
|
||||
message += `중복 건너뛰기 ${skipCount}개`;
|
||||
}
|
||||
if (failCount > 0) {
|
||||
message += ` (실패: ${failCount}개)`;
|
||||
}
|
||||
|
||||
toast.success(message);
|
||||
|
||||
// 매핑 템플릿 저장
|
||||
await saveMappingTemplateInternal();
|
||||
@@ -825,6 +955,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
setExcelColumns([]);
|
||||
setSystemColumns([]);
|
||||
setColumnMappings([]);
|
||||
setDuplicateAction("skip");
|
||||
// 🆕 마스터-디테일 모드 초기화
|
||||
setMasterFieldValues({});
|
||||
}
|
||||
@@ -928,12 +1059,39 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
{field.inputType === "entity" ? (
|
||||
<Select
|
||||
value={masterFieldValues[field.columnName]?.toString() || ""}
|
||||
onValueChange={(value) =>
|
||||
setMasterFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.columnName]: value,
|
||||
}))
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
// 선택한 item 찾기
|
||||
const selectedItem = entitySearchData[field.columnName]?.find(
|
||||
(item: any) => item[field.referenceColumn || "id"]?.toString() === value
|
||||
);
|
||||
|
||||
// displayColumn에서 name 값도 가져오기
|
||||
const displayColName =
|
||||
field.displayColumn ||
|
||||
entityDisplayColumns[field.columnName] ||
|
||||
field.referenceColumn ||
|
||||
"id";
|
||||
const displayValue = selectedItem?.[displayColName];
|
||||
|
||||
// code와 name 컬럼명 추출 (예: supplier_code → supplier_name)
|
||||
const codeColName = field.columnName; // supplier_code
|
||||
const nameColName = codeColName.replace(/_code$/, "_name"); // supplier_name
|
||||
|
||||
setMasterFieldValues((prev) => {
|
||||
const newValues = {
|
||||
...prev,
|
||||
[codeColName]: value,
|
||||
};
|
||||
|
||||
// _code로 끝나는 컬럼이면 _name도 함께 저장
|
||||
if (codeColName.endsWith("_code") && displayValue) {
|
||||
newValues[nameColName] = displayValue;
|
||||
console.log(`🔗 엔티티 연동: ${codeColName}=${value}, ${nameColName}=${displayValue}`);
|
||||
}
|
||||
|
||||
return newValues;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder={`${field.columnLabel} 선택`} />
|
||||
@@ -1141,17 +1299,18 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
|
||||
{/* 매핑 리스트 */}
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
|
||||
<div className="grid grid-cols-[1fr_auto_1fr_80px] gap-2 text-[10px] font-medium text-muted-foreground sm:text-xs">
|
||||
<div>엑셀 컬럼</div>
|
||||
<div></div>
|
||||
<div>시스템 컬럼</div>
|
||||
<div className="text-center">중복 키</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[350px] space-y-2 overflow-y-auto">
|
||||
<div className="max-h-[300px] space-y-2 overflow-y-auto">
|
||||
{columnMappings.map((mapping, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"
|
||||
className="grid grid-cols-[1fr_auto_1fr_80px] items-center gap-2"
|
||||
>
|
||||
<div className="rounded-md border border-border bg-muted px-3 py-2 text-xs font-medium sm:text-sm">
|
||||
{mapping.excelColumn}
|
||||
@@ -1193,11 +1352,78 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 중복 체크 체크박스 */}
|
||||
<div className="flex justify-center">
|
||||
{mapping.systemColumn ? (
|
||||
<Checkbox
|
||||
checked={mapping.checkDuplicate || false}
|
||||
onCheckedChange={(checked) =>
|
||||
handleDuplicateCheckChange(mapping.excelColumn, checked as boolean)
|
||||
}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중복 체크 안내 */}
|
||||
{duplicateCheckCount > 0 ? (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Copy className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||
<div className="text-[10px] text-blue-700 sm:text-xs">
|
||||
<p className="font-medium">
|
||||
중복 키: {columnMappings
|
||||
.filter((m) => m.checkDuplicate && m.systemColumn)
|
||||
.map((m) => {
|
||||
const col = systemColumns.find((c) => c.name === m.systemColumn);
|
||||
return col?.label || m.systemColumn;
|
||||
})
|
||||
.join(" + ")}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
위 컬럼 값이 모두 일치하는 기존 데이터가 있으면 중복으로 처리합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] text-blue-700 sm:text-xs">중복 시:</span>
|
||||
<Select
|
||||
value={duplicateAction}
|
||||
onValueChange={(value) => setDuplicateAction(value as "overwrite" | "skip")}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[100px] text-[10px] sm:text-xs border-blue-300 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skip" className="text-xs">건너뛰기</SelectItem>
|
||||
<SelectItem value="overwrite" className="text-xs">덮어쓰기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-muted bg-muted/30 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Copy className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||
<p className="font-medium">중복 체크 (선택사항)</p>
|
||||
<p className="mt-1">
|
||||
"중복 키" 체크박스를 선택하면 해당 컬럼 값으로 기존 데이터와 비교합니다.
|
||||
여러 컬럼을 선택하면 복합 키로 중복을 판단합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 매핑 자동 저장 안내 */}
|
||||
{isAutoMappingLoaded ? (
|
||||
<div className="rounded-md border border-success bg-success/10 p-3">
|
||||
@@ -1271,6 +1497,11 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
<p key={index}>
|
||||
<span className="font-medium">{mapping.excelColumn}</span> →{" "}
|
||||
{col?.label || mapping.systemColumn}
|
||||
{mapping.checkDuplicate && (
|
||||
<span className="ml-2 text-blue-600">
|
||||
(중복 체크: {mapping.duplicateAction === "overwrite" ? "덮어쓰기" : "건너뛰기"})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
@@ -1280,6 +1511,29 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중복 체크 요약 */}
|
||||
{duplicateCheckCount > 0 && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-4">
|
||||
<h3 className="text-sm font-medium text-blue-800 sm:text-base">중복 체크 설정</h3>
|
||||
<div className="mt-2 space-y-1 text-[10px] text-blue-700 sm:text-xs">
|
||||
<p>
|
||||
<span className="font-medium">중복 키:</span>{" "}
|
||||
{columnMappings
|
||||
.filter((m) => m.checkDuplicate && m.systemColumn)
|
||||
.map((m) => {
|
||||
const col = systemColumns.find((c) => c.name === m.systemColumn);
|
||||
return col?.label || m.systemColumn;
|
||||
})
|
||||
.join(" + ")}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">중복 시 처리:</span>{" "}
|
||||
{duplicateAction === "overwrite" ? "덮어쓰기 (기존 데이터 업데이트)" : "건너뛰기 (해당 행 무시)"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-warning bg-warning/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 text-warning" />
|
||||
|
||||
@@ -1064,8 +1064,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
}
|
||||
);
|
||||
|
||||
// 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시)
|
||||
// _tableSection_ 데이터가 있으면 buttonActions.ts의 handleUniversalFormModalTableSectionSave가 처리
|
||||
const hasTableSectionData = Object.keys(formData).some(k =>
|
||||
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
|
||||
);
|
||||
|
||||
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
|
||||
const shouldUseEditModalSave = groupData.length > 0 || !hasUniversalFormModal;
|
||||
// 단, _tableSection_ 데이터가 있으면 EditModal.handleSave 사용하지 않음 (buttonActions.ts가 처리)
|
||||
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
||||
|
||||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||||
const enrichedFormData = {
|
||||
|
||||
@@ -84,8 +84,20 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
// 날짜 순서 자동 정렬
|
||||
let finalValue = { ...tempValue };
|
||||
|
||||
if (finalValue.from && finalValue.to) {
|
||||
// from이 to보다 나중이면 swap
|
||||
if (finalValue.from > finalValue.to) {
|
||||
const temp = finalValue.from;
|
||||
finalValue.from = finalValue.to;
|
||||
finalValue.to = temp;
|
||||
}
|
||||
}
|
||||
|
||||
// 확인 버튼을 눌렀을 때만 onChange 호출
|
||||
onChange(tempValue);
|
||||
onChange(finalValue);
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
@@ -951,23 +951,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
// 추가 dataFilter 적용
|
||||
let filteredData = result.data || [];
|
||||
const dataFilter = componentConfig.rightPanel?.dataFilter;
|
||||
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||
// 🔧 filters 또는 conditions 배열 모두 지원
|
||||
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
|
||||
if (dataFilter?.enabled && filterConditions.length > 0) {
|
||||
console.log(`🔍 [기본탭] dataFilter 설정:`, JSON.stringify(dataFilter, null, 2));
|
||||
console.log(`🔍 [기본탭] 필터 전 데이터 수:`, filteredData.length);
|
||||
filteredData = filteredData.filter((item: any) => {
|
||||
return dataFilter.conditions.every((cond: any) => {
|
||||
const value = item[cond.column];
|
||||
return filterConditions.every((cond: any) => {
|
||||
// 🔧 columnName 또는 column 필드 모두 지원
|
||||
const columnName = cond.columnName || cond.column;
|
||||
const value = item[columnName];
|
||||
const condValue = cond.value;
|
||||
let result = true;
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return value === condValue;
|
||||
result = value === condValue;
|
||||
break;
|
||||
case "notEquals":
|
||||
return value !== condValue;
|
||||
result = value !== condValue;
|
||||
break;
|
||||
case "contains":
|
||||
return String(value).includes(String(condValue));
|
||||
result = String(value).includes(String(condValue));
|
||||
break;
|
||||
case "is_null":
|
||||
case "NULL":
|
||||
result = value === null || value === undefined || value === "";
|
||||
break;
|
||||
case "is_not_null":
|
||||
case "NOT NULL":
|
||||
result = value !== null && value !== undefined && value !== "";
|
||||
break;
|
||||
default:
|
||||
return true;
|
||||
result = true;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
});
|
||||
console.log(`🔍 [기본탭] 필터 후 데이터 수:`, filteredData.length);
|
||||
}
|
||||
|
||||
setRightData(filteredData);
|
||||
@@ -1080,23 +1100,48 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
|
||||
// 데이터 필터 적용
|
||||
const dataFilter = tabConfig.dataFilter;
|
||||
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] dataFilter 설정:`, JSON.stringify(dataFilter, null, 2));
|
||||
// 🔧 filters 또는 conditions 배열 모두 지원 (DataFilterConfigPanel은 filters 사용)
|
||||
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] filterConditions:`, filterConditions);
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] 필터 전 데이터 수:`, resultData.length);
|
||||
if (dataFilter?.enabled && filterConditions.length > 0) {
|
||||
const beforeCount = resultData.length;
|
||||
resultData = resultData.filter((item: any) => {
|
||||
return dataFilter.conditions.every((cond: any) => {
|
||||
const value = item[cond.column];
|
||||
return filterConditions.every((cond: any) => {
|
||||
// 🔧 columnName 또는 column 필드 모두 지원
|
||||
const columnName = cond.columnName || cond.column;
|
||||
const value = item[columnName];
|
||||
const condValue = cond.value;
|
||||
let result = true;
|
||||
switch (cond.operator) {
|
||||
case "equals":
|
||||
return value === condValue;
|
||||
result = value === condValue;
|
||||
break;
|
||||
case "notEquals":
|
||||
return value !== condValue;
|
||||
result = value !== condValue;
|
||||
break;
|
||||
case "contains":
|
||||
return String(value).includes(String(condValue));
|
||||
result = String(value).includes(String(condValue));
|
||||
break;
|
||||
case "is_null":
|
||||
case "NULL":
|
||||
result = value === null || value === undefined || value === "";
|
||||
break;
|
||||
case "is_not_null":
|
||||
case "NOT NULL":
|
||||
result = value !== null && value !== undefined && value !== "";
|
||||
break;
|
||||
default:
|
||||
return true;
|
||||
result = true;
|
||||
}
|
||||
console.log(`🔍 [필터 체크] ${columnName}=${JSON.stringify(value)}, operator=${cond.operator}, result=${result}`);
|
||||
return result;
|
||||
});
|
||||
});
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] 필터 후 데이터 수: ${beforeCount} → ${resultData.length}`);
|
||||
} else {
|
||||
console.log(`🔍 [추가탭 ${tabIndex}] 필터 비활성화 또는 조건 없음 (enabled=${dataFilter?.enabled}, conditions=${filterConditions.length})`);
|
||||
}
|
||||
|
||||
// 중복 제거 적용
|
||||
@@ -1557,6 +1602,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
// 추가 버튼 핸들러
|
||||
const handleAddClick = useCallback(
|
||||
(panel: "left" | "right") => {
|
||||
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
|
||||
setAddModalPanel(panel);
|
||||
|
||||
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
|
||||
@@ -1567,124 +1613,183 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
componentConfig.rightPanel?.rightColumn
|
||||
) {
|
||||
const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn];
|
||||
setAddModalFormData({
|
||||
const initialData = {
|
||||
[componentConfig.rightPanel.rightColumn]: leftColumnValue,
|
||||
});
|
||||
};
|
||||
console.log("🆕 [추가모달] 초기 데이터 설정:", initialData);
|
||||
setAddModalFormData(initialData);
|
||||
} else {
|
||||
console.log("🆕 [추가모달] 빈 데이터로 초기화");
|
||||
setAddModalFormData({});
|
||||
}
|
||||
|
||||
setShowAddModal(true);
|
||||
},
|
||||
[selectedLeftItem, componentConfig],
|
||||
[selectedLeftItem, componentConfig, activeTabIndex],
|
||||
);
|
||||
|
||||
// 수정 버튼 핸들러
|
||||
const handleEditClick = useCallback(
|
||||
(panel: "left" | "right", item: any) => {
|
||||
// 🆕 우측 패널 수정 버튼 설정 확인
|
||||
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
|
||||
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
|
||||
// 🔧 현재 활성 탭에 따라 해당 탭의 editButton 설정 사용
|
||||
if (panel === "right") {
|
||||
// 기본 탭(0)이면 rightPanel.editButton, 추가 탭이면 additionalTabs의 editButton 사용
|
||||
const editButtonConfig =
|
||||
activeTabIndex === 0
|
||||
? componentConfig.rightPanel?.editButton
|
||||
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.editButton;
|
||||
|
||||
if (modalScreenId) {
|
||||
// 커스텀 모달 화면 열기
|
||||
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
||||
// 해당 탭의 테이블명 가져오기
|
||||
const currentTableName =
|
||||
activeTabIndex === 0
|
||||
? componentConfig.rightPanel?.tableName || ""
|
||||
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1]?.tableName || "";
|
||||
|
||||
// Primary Key 찾기 (우선순위: 설정값 > id > ID > non-null 필드)
|
||||
// 🔧 설정에서 primaryKeyColumn 지정 가능
|
||||
const configuredPrimaryKey = componentConfig.rightPanel?.editButton?.primaryKeyColumn;
|
||||
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - 현재 탭 설정 확인:", {
|
||||
activeTabIndex,
|
||||
editButtonConfig,
|
||||
currentTableName,
|
||||
isModalMode: editButtonConfig?.mode === "modal",
|
||||
});
|
||||
|
||||
let primaryKeyName = "id";
|
||||
let primaryKeyValue: any;
|
||||
if (editButtonConfig?.mode === "modal") {
|
||||
const modalScreenId = editButtonConfig.modalScreenId;
|
||||
|
||||
if (configuredPrimaryKey && item[configuredPrimaryKey] !== undefined && item[configuredPrimaryKey] !== null) {
|
||||
// 설정된 Primary Key 사용
|
||||
primaryKeyName = configuredPrimaryKey;
|
||||
primaryKeyValue = item[configuredPrimaryKey];
|
||||
} else if (item.id !== undefined && item.id !== null) {
|
||||
primaryKeyName = "id";
|
||||
primaryKeyValue = item.id;
|
||||
} else if (item.ID !== undefined && item.ID !== null) {
|
||||
primaryKeyName = "ID";
|
||||
primaryKeyValue = item.ID;
|
||||
} else {
|
||||
// 🔧 첫 번째 non-null 필드를 Primary Key로 간주
|
||||
const keys = Object.keys(item);
|
||||
let found = false;
|
||||
for (const key of keys) {
|
||||
if (item[key] !== undefined && item[key] !== null) {
|
||||
primaryKeyName = key;
|
||||
primaryKeyValue = item[key];
|
||||
found = true;
|
||||
break;
|
||||
if (modalScreenId) {
|
||||
// 커스텀 모달 화면 열기
|
||||
|
||||
// Primary Key 찾기 (우선순위: 설정값 > id > ID > non-null 필드)
|
||||
// 🔧 설정에서 primaryKeyColumn 지정 가능
|
||||
const configuredPrimaryKey = editButtonConfig.primaryKeyColumn;
|
||||
|
||||
let primaryKeyName = "id";
|
||||
let primaryKeyValue: any;
|
||||
|
||||
if (configuredPrimaryKey && item[configuredPrimaryKey] !== undefined && item[configuredPrimaryKey] !== null) {
|
||||
// 설정된 Primary Key 사용
|
||||
primaryKeyName = configuredPrimaryKey;
|
||||
primaryKeyValue = item[configuredPrimaryKey];
|
||||
} else if (item.id !== undefined && item.id !== null) {
|
||||
primaryKeyName = "id";
|
||||
primaryKeyValue = item.id;
|
||||
} else if (item.ID !== undefined && item.ID !== null) {
|
||||
primaryKeyName = "ID";
|
||||
primaryKeyValue = item.ID;
|
||||
} else {
|
||||
// 🔧 첫 번째 non-null 필드를 Primary Key로 간주
|
||||
const keys = Object.keys(item);
|
||||
let found = false;
|
||||
for (const key of keys) {
|
||||
if (item[key] !== undefined && item[key] !== null) {
|
||||
primaryKeyName = key;
|
||||
primaryKeyValue = item[key];
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 모든 필드가 null이면 첫 번째 필드 사용
|
||||
if (!found && keys.length > 0) {
|
||||
primaryKeyName = keys[0];
|
||||
primaryKeyValue = item[keys[0]];
|
||||
}
|
||||
}
|
||||
// 모든 필드가 null이면 첫 번째 필드 사용
|
||||
if (!found && keys.length > 0) {
|
||||
primaryKeyName = keys[0];
|
||||
primaryKeyValue = item[keys[0]];
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✅ 수정 모달 열기:", {
|
||||
tableName: rightTableName,
|
||||
primaryKeyName,
|
||||
primaryKeyValue,
|
||||
screenId: modalScreenId,
|
||||
fullItem: item,
|
||||
});
|
||||
console.log("✅ 수정 모달 열기:", {
|
||||
activeTabIndex,
|
||||
tableName: currentTableName,
|
||||
primaryKeyName,
|
||||
primaryKeyValue,
|
||||
screenId: modalScreenId,
|
||||
fullItem: item,
|
||||
});
|
||||
|
||||
// modalDataStore에도 저장 (호환성 유지)
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
useModalDataStore.getState().setData(rightTableName, [item]);
|
||||
});
|
||||
// modalDataStore에도 저장 (호환성 유지)
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
useModalDataStore.getState().setData(currentTableName, [item]);
|
||||
});
|
||||
|
||||
// 🆕 groupByColumns 추출
|
||||
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
|
||||
// 🆕 groupByColumns 추출
|
||||
const groupByColumns = editButtonConfig.groupByColumns || [];
|
||||
|
||||
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
||||
groupByColumns,
|
||||
editButtonConfig: componentConfig.rightPanel?.editButton,
|
||||
hasGroupByColumns: groupByColumns.length > 0,
|
||||
});
|
||||
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
||||
groupByColumns,
|
||||
editButtonConfig,
|
||||
hasGroupByColumns: groupByColumns.length > 0,
|
||||
});
|
||||
|
||||
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns + primaryKeyColumn 전달)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: modalScreenId,
|
||||
urlParams: {
|
||||
mode: "edit",
|
||||
editId: primaryKeyValue,
|
||||
tableName: rightTableName,
|
||||
primaryKeyColumn: primaryKeyName, // 🆕 Primary Key 컬럼명 전달
|
||||
...(groupByColumns.length > 0 && {
|
||||
groupByColumns: JSON.stringify(groupByColumns),
|
||||
}),
|
||||
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns + primaryKeyColumn 전달)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: modalScreenId,
|
||||
urlParams: {
|
||||
mode: "edit",
|
||||
editId: primaryKeyValue,
|
||||
tableName: currentTableName,
|
||||
primaryKeyColumn: primaryKeyName, // 🆕 Primary Key 컬럼명 전달
|
||||
...(groupByColumns.length > 0 && {
|
||||
groupByColumns: JSON.stringify(groupByColumns),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", {
|
||||
screenId: modalScreenId,
|
||||
editId: primaryKeyValue,
|
||||
tableName: rightTableName,
|
||||
primaryKeyColumn: primaryKeyName,
|
||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||
});
|
||||
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", {
|
||||
screenId: modalScreenId,
|
||||
editId: primaryKeyValue,
|
||||
tableName: currentTableName,
|
||||
primaryKeyColumn: primaryKeyName,
|
||||
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||
});
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 자동 편집 모드 (인라인 편집 모달)
|
||||
setEditModalPanel(panel);
|
||||
setEditModalItem(item);
|
||||
setEditModalFormData({ ...item });
|
||||
|
||||
// 🔧 우측 패널(추가탭 포함) 수정 시 selectedLeftItem의 FK 값 병합
|
||||
let mergedItem = { ...item };
|
||||
if (panel === "right" && selectedLeftItem) {
|
||||
// 현재 활성 탭의 relation 설정 가져오기
|
||||
const currentTabConfig =
|
||||
activeTabIndex === 0
|
||||
? componentConfig.rightPanel
|
||||
: componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||
|
||||
const relationKeys = currentTabConfig?.relation?.keys;
|
||||
const leftColumn = currentTabConfig?.relation?.leftColumn;
|
||||
const rightColumn = currentTabConfig?.relation?.foreignKey || currentTabConfig?.relation?.rightColumn;
|
||||
|
||||
if (relationKeys && relationKeys.length > 0) {
|
||||
// 복합키인 경우
|
||||
relationKeys.forEach((key: any) => {
|
||||
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] !== undefined) {
|
||||
// item에 해당 FK 값이 없거나 빈 값이면 selectedLeftItem에서 가져옴
|
||||
if (mergedItem[key.rightColumn] === undefined || mergedItem[key.rightColumn] === null || mergedItem[key.rightColumn] === "") {
|
||||
mergedItem[key.rightColumn] = selectedLeftItem[key.leftColumn];
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (leftColumn && rightColumn) {
|
||||
// 단일키인 경우
|
||||
if (selectedLeftItem[leftColumn] !== undefined) {
|
||||
if (mergedItem[rightColumn] === undefined || mergedItem[rightColumn] === null || mergedItem[rightColumn] === "") {
|
||||
mergedItem[rightColumn] = selectedLeftItem[leftColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setEditModalFormData(mergedItem);
|
||||
setShowEditModal(true);
|
||||
},
|
||||
[componentConfig],
|
||||
[componentConfig, selectedLeftItem, activeTabIndex],
|
||||
);
|
||||
|
||||
// 수정 모달 저장
|
||||
|
||||
@@ -380,6 +380,16 @@ export function UniversalFormModalComponent({
|
||||
const handleBeforeFormSave = (event: Event) => {
|
||||
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
|
||||
|
||||
// 필수값 검증 실행
|
||||
const validation = validateRequiredFields();
|
||||
if (!validation.valid) {
|
||||
event.detail.validationFailed = true;
|
||||
event.detail.validationErrors = validation.missingFields;
|
||||
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
|
||||
console.log("[UniversalFormModal] 필수값 검증 실패:", validation.missingFields);
|
||||
return; // 검증 실패 시 데이터 병합 중단
|
||||
}
|
||||
|
||||
// 설정에 정의된 필드 columnName 목록 수집
|
||||
const configuredFields = new Set<string>();
|
||||
config.sections.forEach((section) => {
|
||||
@@ -439,6 +449,12 @@ export function UniversalFormModalComponent({
|
||||
event.detail.formData[normalizedKey] = value;
|
||||
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`);
|
||||
}
|
||||
|
||||
// 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용)
|
||||
if (key.startsWith("_originalTableSectionData_") && Array.isArray(value)) {
|
||||
event.detail.formData[key] = value;
|
||||
console.log(`[UniversalFormModal] 원본 테이블 섹션 데이터 병합: ${key}, ${value.length}개 항목`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
|
||||
@@ -928,17 +944,19 @@ export function UniversalFormModalComponent({
|
||||
newFormData[tableSectionKey] = items;
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${tableSectionKey}]에 저장됨`);
|
||||
|
||||
// 🆕 원본 그룹 데이터 저장 (삭제 추적용)
|
||||
// groupedDataInitializedRef가 false일 때만 설정 (true면 _groupedData useEffect에서 이미 처리됨)
|
||||
// DB에서 로드한 데이터를 originalGroupedData에 저장해야 삭제 시 비교 가능
|
||||
// 🆕 테이블 섹션 원본 데이터 저장 (삭제 추적용)
|
||||
// 각 테이블 섹션별로 별도의 키에 원본 데이터 저장 (groupedDataInitializedRef와 무관하게 항상 저장)
|
||||
const originalTableSectionKey = `_originalTableSectionData_${section.id}`;
|
||||
newFormData[originalTableSectionKey] = JSON.parse(JSON.stringify(items));
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${originalTableSectionKey}]에 원본 ${items.length}건 저장`);
|
||||
|
||||
// 기존 originalGroupedData에도 추가 (하위 호환성)
|
||||
if (!groupedDataInitializedRef.current) {
|
||||
setOriginalGroupedData((prev) => {
|
||||
const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))];
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`);
|
||||
return newOriginal;
|
||||
});
|
||||
} else {
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id}: _groupedData로 이미 초기화됨, originalGroupedData 설정 스킵`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user