엑셀 업로드,다운로드 기능 개선
This commit is contained in:
@@ -34,6 +34,35 @@ import { cn } from "@/lib/utils";
|
||||
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
|
||||
import { EditableSpreadsheet } from "./EditableSpreadsheet";
|
||||
|
||||
// 마스터-디테일 엑셀 업로드 설정 (버튼 설정에서 설정)
|
||||
export interface MasterDetailExcelConfig {
|
||||
// 테이블 정보
|
||||
masterTable?: string;
|
||||
detailTable?: string;
|
||||
masterKeyColumn?: string;
|
||||
detailFkColumn?: string;
|
||||
// 채번
|
||||
numberingRuleId?: string;
|
||||
// 업로드 전 사용자가 선택할 마스터 테이블 필드
|
||||
masterSelectFields?: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
required: boolean;
|
||||
inputType: "entity" | "date" | "text" | "select";
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string;
|
||||
}>;
|
||||
// 엑셀에서 매핑할 디테일 테이블 필드
|
||||
detailExcelFields?: Array<{
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
required: boolean;
|
||||
}>;
|
||||
masterDefaults?: Record<string, any>;
|
||||
detailDefaults?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ExcelUploadModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -53,6 +82,8 @@ export interface ExcelUploadModalProps {
|
||||
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||
};
|
||||
// 🆕 마스터-디테일 엑셀 업로드 설정
|
||||
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
||||
}
|
||||
|
||||
interface ColumnMapping {
|
||||
@@ -71,6 +102,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
screenId,
|
||||
isMasterDetail = false,
|
||||
masterDetailRelation,
|
||||
masterDetailExcelConfig,
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
@@ -93,6 +125,116 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
// 3단계: 확인
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 🆕 마스터-디테일 모드: 마스터 필드 입력값
|
||||
const [masterFieldValues, setMasterFieldValues] = useState<Record<string, any>>({});
|
||||
const [entitySearchData, setEntitySearchData] = useState<Record<string, any[]>>({});
|
||||
const [entitySearchLoading, setEntitySearchLoading] = useState<Record<string, boolean>>({});
|
||||
const [entityDisplayColumns, setEntityDisplayColumns] = useState<Record<string, string>>({});
|
||||
|
||||
// 🆕 엔티티 참조 데이터 로드
|
||||
useEffect(() => {
|
||||
console.log("🔍 엔티티 데이터 로드 체크:", {
|
||||
masterSelectFields: masterDetailExcelConfig?.masterSelectFields,
|
||||
open,
|
||||
isMasterDetail,
|
||||
});
|
||||
|
||||
if (!masterDetailExcelConfig?.masterSelectFields) return;
|
||||
|
||||
const loadEntityData = async () => {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
|
||||
for (const field of masterDetailExcelConfig.masterSelectFields!) {
|
||||
console.log("🔍 필드 처리:", field);
|
||||
|
||||
if (field.inputType === "entity") {
|
||||
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: true }));
|
||||
try {
|
||||
let refTable = field.referenceTable;
|
||||
console.log("🔍 초기 refTable:", refTable);
|
||||
|
||||
let displayCol = field.displayColumn;
|
||||
|
||||
// referenceTable 또는 displayColumn이 없으면 DB에서 동적으로 조회
|
||||
if ((!refTable || !displayCol) && masterDetailExcelConfig.masterTable) {
|
||||
console.log("🔍 DB에서 referenceTable/displayColumn 조회 시도:", masterDetailExcelConfig.masterTable);
|
||||
const colResponse = await apiClient.get(
|
||||
`/table-management/tables/${masterDetailExcelConfig.masterTable}/columns`
|
||||
);
|
||||
console.log("🔍 컬럼 조회 응답:", colResponse.data);
|
||||
|
||||
if (colResponse.data?.success && colResponse.data?.data?.columns) {
|
||||
const colInfo = colResponse.data.data.columns.find(
|
||||
(c: any) => (c.columnName || c.column_name) === field.columnName
|
||||
);
|
||||
console.log("🔍 찾은 컬럼 정보:", colInfo);
|
||||
if (colInfo) {
|
||||
if (!refTable) {
|
||||
refTable = colInfo.referenceTable || colInfo.reference_table;
|
||||
console.log("🔍 DB에서 가져온 refTable:", refTable);
|
||||
}
|
||||
if (!displayCol) {
|
||||
displayCol = colInfo.displayColumn || colInfo.display_column;
|
||||
console.log("🔍 DB에서 가져온 displayColumn:", displayCol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// displayColumn 저장 (Select 렌더링 시 사용)
|
||||
if (displayCol) {
|
||||
setEntityDisplayColumns((prev) => ({ ...prev, [field.columnName]: displayCol }));
|
||||
}
|
||||
|
||||
if (refTable) {
|
||||
console.log("🔍 엔티티 데이터 조회:", refTable);
|
||||
const response = await DynamicFormApi.getTableData(refTable, {
|
||||
page: 1,
|
||||
pageSize: 1000,
|
||||
});
|
||||
console.log("🔍 엔티티 데이터 응답:", response);
|
||||
// getTableData는 { success, data: [...] } 형식으로 반환
|
||||
const rows = response.data?.rows || response.data;
|
||||
if (response.success && rows && Array.isArray(rows)) {
|
||||
setEntitySearchData((prev) => ({
|
||||
...prev,
|
||||
[field.columnName]: rows,
|
||||
}));
|
||||
console.log("✅ 엔티티 데이터 로드 성공:", field.columnName, rows.length, "개");
|
||||
}
|
||||
} else {
|
||||
console.warn("❌ 엔티티 필드의 referenceTable을 찾을 수 없음:", field.columnName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 엔티티 데이터 로드 실패:", field.columnName, error);
|
||||
} finally {
|
||||
setEntitySearchLoading((prev) => ({ ...prev, [field.columnName]: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (open && isMasterDetail && masterDetailExcelConfig?.masterSelectFields?.length > 0) {
|
||||
loadEntityData();
|
||||
}
|
||||
}, [open, isMasterDetail, masterDetailExcelConfig]);
|
||||
|
||||
// 마스터-디테일 모드에서 마스터 필드 입력 여부 확인
|
||||
const isSimpleMasterDetailMode = isMasterDetail && masterDetailExcelConfig;
|
||||
const hasMasterSelectFields = isSimpleMasterDetailMode &&
|
||||
(masterDetailExcelConfig?.masterSelectFields?.length ?? 0) > 0;
|
||||
|
||||
// 마스터 필드가 모두 입력되었는지 확인
|
||||
const isMasterFieldsValid = () => {
|
||||
if (!hasMasterSelectFields) return true;
|
||||
return masterDetailExcelConfig!.masterSelectFields!.every((field) => {
|
||||
if (!field.required) return true;
|
||||
const value = masterFieldValues[field.columnName];
|
||||
return value !== undefined && value !== null && value !== "";
|
||||
});
|
||||
};
|
||||
|
||||
// 파일 선택 핸들러
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
@@ -198,12 +340,51 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
|
||||
const loadTableSchema = async () => {
|
||||
try {
|
||||
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail });
|
||||
console.log("🔍 테이블 스키마 로드 시작:", { tableName, isMasterDetail, isSimpleMasterDetailMode });
|
||||
|
||||
let allColumns: TableColumn[] = [];
|
||||
|
||||
// 🆕 마스터-디테일 모드: 두 테이블의 컬럼 합치기
|
||||
if (isMasterDetail && masterDetailRelation) {
|
||||
// 🆕 마스터-디테일 간단 모드: 디테일 테이블 컬럼만 로드 (마스터 필드는 UI에서 선택)
|
||||
if (isSimpleMasterDetailMode && masterDetailRelation) {
|
||||
const { detailTable, detailFkColumn } = masterDetailRelation;
|
||||
|
||||
console.log("📊 마스터-디테일 간단 모드 스키마 로드 (디테일만):", { detailTable });
|
||||
|
||||
// 디테일 테이블 스키마만 로드 (마스터 정보는 UI에서 선택)
|
||||
const detailResponse = await getTableSchema(detailTable);
|
||||
if (detailResponse.success && detailResponse.data) {
|
||||
// 설정된 detailExcelFields가 있으면 해당 필드만, 없으면 전체
|
||||
const configuredFields = masterDetailExcelConfig?.detailExcelFields;
|
||||
|
||||
const detailCols = detailResponse.data.columns
|
||||
.filter((col) => {
|
||||
// 자동 생성 컬럼, FK 컬럼 제외
|
||||
if (AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())) return false;
|
||||
if (col.name === detailFkColumn) return false;
|
||||
|
||||
// 설정된 필드가 있으면 해당 필드만
|
||||
if (configuredFields && configuredFields.length > 0) {
|
||||
return configuredFields.some((f) => f.columnName === col.name);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((col) => {
|
||||
// 설정에서 라벨 찾기
|
||||
const configField = configuredFields?.find((f) => f.columnName === col.name);
|
||||
return {
|
||||
...col,
|
||||
label: configField?.columnLabel || col.label || col.name,
|
||||
originalName: col.name,
|
||||
sourceTable: detailTable,
|
||||
};
|
||||
});
|
||||
allColumns = detailCols;
|
||||
}
|
||||
|
||||
console.log("✅ 마스터-디테일 간단 모드 컬럼 로드 완료:", allColumns.length);
|
||||
}
|
||||
// 🆕 마스터-디테일 기존 모드: 두 테이블의 컬럼 합치기
|
||||
else if (isMasterDetail && masterDetailRelation) {
|
||||
const { masterTable, detailTable, detailFkColumn } = masterDetailRelation;
|
||||
|
||||
console.log("📊 마스터-디테일 스키마 로드:", { masterTable, detailTable });
|
||||
@@ -365,6 +546,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 마스터-디테일 간단 모드: 마스터 필드 유효성 검사
|
||||
if (currentStep === 1 && hasMasterSelectFields && !isMasterFieldsValid()) {
|
||||
toast.error("마스터 정보를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1단계 → 2단계 전환 시: 빈 헤더 열 제외
|
||||
if (currentStep === 1) {
|
||||
// 빈 헤더가 아닌 열만 필터링
|
||||
@@ -449,8 +636,39 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
||||
);
|
||||
|
||||
// 🆕 마스터-디테일 모드 처리
|
||||
if (isMasterDetail && screenId && masterDetailRelation) {
|
||||
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
|
||||
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
||||
console.log("📊 마스터-디테일 간단 모드 업로드:", {
|
||||
masterDetailRelation,
|
||||
masterFieldValues,
|
||||
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
||||
});
|
||||
|
||||
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
|
||||
screenId,
|
||||
filteredData,
|
||||
masterFieldValues,
|
||||
masterDetailExcelConfig?.numberingRuleId || undefined
|
||||
);
|
||||
|
||||
if (uploadResult.success && uploadResult.data) {
|
||||
const { masterInserted, detailInserted, generatedKey, errors } = uploadResult.data;
|
||||
|
||||
toast.success(
|
||||
`마스터 ${masterInserted}건(${generatedKey || ""}), 디테일 ${detailInserted}건 처리되었습니다.` +
|
||||
(errors?.length > 0 ? ` (오류: ${errors.length}건)` : "")
|
||||
);
|
||||
|
||||
// 매핑 템플릿 저장
|
||||
await saveMappingTemplateInternal();
|
||||
|
||||
onSuccess?.();
|
||||
} else {
|
||||
toast.error(uploadResult.message || "마스터-디테일 업로드에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
// 🆕 마스터-디테일 기존 모드 처리
|
||||
else if (isMasterDetail && screenId && masterDetailRelation) {
|
||||
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
|
||||
|
||||
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
|
||||
@@ -558,6 +776,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
setExcelColumns([]);
|
||||
setSystemColumns([]);
|
||||
setColumnMappings([]);
|
||||
// 🆕 마스터-디테일 모드 초기화
|
||||
setMasterFieldValues({});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -647,6 +867,87 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
{/* 🆕 마스터-디테일 간단 모드: 마스터 필드 입력 */}
|
||||
{hasMasterSelectFields && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{masterDetailExcelConfig?.masterSelectFields?.map((field) => (
|
||||
<div key={field.columnName} className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
{field.columnLabel}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
</Label>
|
||||
{field.inputType === "entity" ? (
|
||||
<Select
|
||||
value={masterFieldValues[field.columnName]?.toString() || ""}
|
||||
onValueChange={(value) =>
|
||||
setMasterFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.columnName]: value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder={`${field.columnLabel} 선택`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{entitySearchLoading[field.columnName] ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
로딩 중...
|
||||
</SelectItem>
|
||||
) : (
|
||||
entitySearchData[field.columnName]?.map((item: any) => {
|
||||
const keyValue = item[field.referenceColumn || "id"];
|
||||
// displayColumn: 저장된 값 → DB에서 조회한 값 → referenceColumn → id
|
||||
const displayColName =
|
||||
field.displayColumn ||
|
||||
entityDisplayColumns[field.columnName] ||
|
||||
field.referenceColumn ||
|
||||
"id";
|
||||
const displayValue = item[displayColName] || keyValue;
|
||||
return (
|
||||
<SelectItem
|
||||
key={keyValue}
|
||||
value={keyValue?.toString()}
|
||||
className="text-xs"
|
||||
>
|
||||
{displayValue}
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : field.inputType === "date" ? (
|
||||
<input
|
||||
type="date"
|
||||
value={masterFieldValues[field.columnName] || ""}
|
||||
onChange={(e) =>
|
||||
setMasterFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.columnName]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-9 w-full rounded-md border px-3 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={masterFieldValues[field.columnName] || ""}
|
||||
onChange={(e) =>
|
||||
setMasterFieldValues((prev) => ({
|
||||
...prev,
|
||||
[field.columnName]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={field.columnLabel}
|
||||
className="h-9 w-full rounded-md border px-3 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 선택 영역 */}
|
||||
<div>
|
||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||
|
||||
Reference in New Issue
Block a user