feat: Add multi-table Excel upload functionality

- Implemented new API endpoints for multi-table Excel upload and auto-detection of table chains.
- The GET endpoint `/api/data/multi-table/auto-detect` allows automatic detection of foreign key relationships based on the provided root table.
- The POST endpoint `/api/data/multi-table/upload` handles the upload of multi-table data, including validation and logging of the upload process.
- Updated the frontend to include options for multi-table Excel upload in the button configuration panel and integrated the corresponding action handler.

This feature enhances the data management capabilities by allowing users to upload and manage data across multiple related tables efficiently.
This commit is contained in:
kjs 2026-03-05 19:17:35 +09:00
parent 7e2ae4335e
commit 0e8c68a9ff
11 changed files with 2384 additions and 69 deletions

View File

@ -1,6 +1,7 @@
import express from "express";
import { dataService } from "../services/dataService";
import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import { auditLogService } from "../services/auditLogService";
@ -260,6 +261,117 @@ router.post(
}
);
// ================================
// 다중 테이블 엑셀 업로드 API
// ================================
/**
*
* GET /api/data/multi-table/auto-detect?rootTable=customer_mng
*
* FK
* TableChainConfig를 .
*/
router.get(
"/multi-table/auto-detect",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const rootTable = req.query.rootTable as string;
const screenId = req.query.screenId ? Number(req.query.screenId) : undefined;
const companyCode = req.user?.companyCode || "*";
if (!rootTable) {
return res.status(400).json({
success: false,
message: "rootTable 파라미터가 필요합니다.",
});
}
const config = await multiTableExcelService.autoDetectTableChain(
rootTable,
companyCode,
screenId
);
return res.json({ success: true, data: config });
} catch (error: any) {
console.error("다중 테이블 자동 감지 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "자동 감지 중 오류가 발생했습니다.",
});
}
}
);
/**
*
* POST /api/data/multi-table/upload
*
* Body: { config: TableChainConfig, modeId: string, rows: Record<string, any>[] }
*/
router.post(
"/multi-table/upload",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { config, modeId, rows } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
if (!config || !modeId || !rows || !Array.isArray(rows)) {
return res.status(400).json({
success: false,
message: "config, modeId, rows 배열이 필요합니다.",
});
}
if (rows.length === 0) {
return res.status(400).json({
success: false,
message: "업로드할 데이터가 없습니다.",
});
}
console.log(`다중 테이블 엑셀 업로드:`, {
configId: config.id,
modeId,
rowCount: rows.length,
companyCode,
userId,
});
const result = await multiTableExcelService.uploadMultiTable(
config as TableChainConfig,
modeId,
rows,
companyCode,
userId
);
const summaryParts = result.results.map(
(r) => `${r.tableName}: 신규 ${r.inserted}건, 수정 ${r.updated}`
);
return res.json({
success: result.success,
data: result,
message: result.success
? summaryParts.join(" / ")
: "업로드 중 오류가 발생했습니다.",
});
} catch (error: any) {
console.error("다중 테이블 업로드 오류:", error);
return res.status(500).json({
success: false,
message: "다중 테이블 업로드 중 오류가 발생했습니다.",
error: error.message,
});
}
}
);
// ================================
// 기존 데이터 API
// ================================

File diff suppressed because it is too large Load Diff

View File

@ -3783,15 +3783,15 @@ export class TableManagementService {
);
}
} else if (operator === "equals") {
// 🔧 equals 연산자: 정확히 일치
// 🔧 equals 연산자: 메인 테이블의 FK 컬럼에서 직접 매칭 (연결 필터용)
whereConditions.push(
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
`main.${joinConfig.sourceColumn}::text = '${safeValue}'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
`${key} (main.${joinConfig.sourceColumn})`
);
logger.info(
`🎯 Entity 조인 정확히 일치 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
`🎯 Entity 조인 직접 FK 매칭: ${key} → main.${joinConfig.sourceColumn} = '${safeValue}'`
);
} else {
// 기본: 부분 일치 (ILIKE)

View File

@ -0,0 +1,194 @@
# 다중 테이블 엑셀 업로드 범용 시스템
## 개요
하나의 플랫 엑셀 파일로 계층적 다중 테이블(2~N개)에 데이터를 일괄 등록하는 범용 시스템.
거래처 관리(customer_mng → customer_item_mapping → customer_item_prices)를 첫 번째 적용 대상으로 하되,
공급업체, BOM 등 다른 화면에서도 재사용 가능하도록 설계한다.
## 핵심 기능
1. 모드 선택: 어느 레벨까지 등록할지 사용자가 선택
2. 템플릿 다운로드: 모드에 맞는 엑셀 양식 자동 생성
3. 파일 업로드: 플랫 엑셀 → 계층 그룹핑 → 트랜잭션 UPSERT
4. 컬럼 매핑: 엑셀 헤더 ↔ DB 컬럼 자동/수동 매핑
## DB 테이블 관계 (거래처 관리)
```
customer_mng (Level 1 - 루트)
PK: id (SERIAL)
UNIQUE: customer_code
└─ customer_item_mapping (Level 2)
PK: id (UUID)
FK: customer_id → customer_mng.id
UPSERT키: customer_id + customer_item_code
└─ customer_item_prices (Level 3)
PK: id (UUID)
FK: mapping_id → customer_item_mapping.id
항상 INSERT (기간별 단가 이력)
```
## 범용 설정 구조 (TableChainConfig)
```typescript
interface TableLevel {
tableName: string;
label: string;
// 부모와의 관계
parentFkColumn?: string; // 이 테이블에서 부모를 참조하는 FK 컬럼
parentRefColumn?: string; // 부모 테이블에서 참조되는 컬럼 (PK 또는 UNIQUE)
// UPSERT 설정
upsertMode: 'upsert' | 'insert'; // upsert: 기존 데이터 있으면 UPDATE, insert: 항상 신규
upsertKeyColumns?: string[]; // UPSERT 매칭 키 (예: ['customer_code'])
// 엑셀 매핑 컬럼
columns: Array<{
dbColumn: string;
excelHeader: string;
required: boolean;
defaultValue?: any;
}>;
}
interface TableChainConfig {
id: string;
name: string;
description: string;
levels: TableLevel[]; // 0 = 루트, 1 = 자식, 2 = 손자...
uploadModes: Array<{
id: string;
label: string;
description: string;
activeLevels: number[]; // 이 모드에서 활성화되는 레벨 인덱스
}>;
}
```
## 거래처 관리 설정 예시
```typescript
const customerChainConfig: TableChainConfig = {
id: 'customer_management',
name: '거래처 관리',
description: '거래처, 품목매핑, 단가 일괄 등록',
levels: [
{
tableName: 'customer_mng',
label: '거래처',
upsertMode: 'upsert',
upsertKeyColumns: ['customer_code'],
columns: [
{ dbColumn: 'customer_code', excelHeader: '거래처코드', required: true },
{ dbColumn: 'customer_name', excelHeader: '거래처명', required: true },
{ dbColumn: 'division', excelHeader: '구분', required: false },
{ dbColumn: 'contact_person', excelHeader: '담당자', required: false },
{ dbColumn: 'contact_phone', excelHeader: '연락처', required: false },
{ dbColumn: 'email', excelHeader: '이메일', required: false },
{ dbColumn: 'business_number', excelHeader: '사업자번호', required: false },
{ dbColumn: 'address', excelHeader: '주소', required: false },
],
},
{
tableName: 'customer_item_mapping',
label: '품목매핑',
parentFkColumn: 'customer_id',
parentRefColumn: 'id',
upsertMode: 'upsert',
upsertKeyColumns: ['customer_id', 'customer_item_code'],
columns: [
{ dbColumn: 'customer_item_code', excelHeader: '거래처품번', required: true },
{ dbColumn: 'customer_item_name', excelHeader: '거래처품명', required: true },
{ dbColumn: 'item_id', excelHeader: '품목ID', required: false },
],
},
{
tableName: 'customer_item_prices',
label: '단가',
parentFkColumn: 'mapping_id',
parentRefColumn: 'id',
upsertMode: 'insert',
columns: [
{ dbColumn: 'base_price', excelHeader: '기준단가', required: true },
{ dbColumn: 'discount_type', excelHeader: '할인유형', required: false },
{ dbColumn: 'discount_value', excelHeader: '할인값', required: false },
{ dbColumn: 'start_date', excelHeader: '적용시작일', required: false },
{ dbColumn: 'end_date', excelHeader: '적용종료일', required: false },
{ dbColumn: 'currency_code', excelHeader: '통화', required: false },
],
},
],
uploadModes: [
{ id: 'customer_only', label: '거래처만 등록', description: '거래처 기본정보만', activeLevels: [0] },
{ id: 'customer_item', label: '거래처 + 품목정보', description: '거래처와 품목매핑', activeLevels: [0, 1] },
{ id: 'customer_item_price', label: '거래처 + 품목 + 단가', description: '전체 등록', activeLevels: [0, 1, 2] },
],
};
```
## 처리 로직 (백엔드)
### 1단계: 그룹핑
엑셀의 플랫 행을 계층별 그룹으로 변환:
- Level 0 (거래처): customer_code 기준 그룹핑
- Level 1 (품목매핑): customer_code + customer_item_code 기준 그룹핑
- Level 2 (단가): 매 행마다 INSERT
### 2단계: 계단식 UPSERT (트랜잭션)
```
BEGIN TRANSACTION
FOR EACH unique customer_code:
1. customer_mng UPSERT → 결과에서 id 획득 (returnedId)
FOR EACH unique customer_item_code (해당 거래처):
2. customer_item_mapping의 customer_id = returnedId 주입
UPSERT → 결과에서 id 획득 (mappingId)
FOR EACH price row (해당 품목매핑):
3. customer_item_prices의 mapping_id = mappingId 주입
INSERT
COMMIT (전체 성공) or ROLLBACK (하나라도 실패)
```
### 3단계: 결과 반환
```json
{
"success": true,
"results": {
"customer_mng": { "inserted": 2, "updated": 1 },
"customer_item_mapping": { "inserted": 5, "updated": 2 },
"customer_item_prices": { "inserted": 12 }
},
"errors": []
}
```
## 테스트 계획
### 1단계: 백엔드 서비스
- [x] plan.md 작성
- [ ] multiTableExcelService.ts 기본 구조 작성
- [ ] 그룹핑 로직 구현
- [ ] 계단식 UPSERT 로직 구현
- [ ] 트랜잭션 처리
- [ ] 에러 핸들링
### 2단계: API 엔드포인트
- [ ] POST /api/data/multi-table/upload 추가
- [ ] POST /api/data/multi-table/template 추가 (템플릿 다운로드)
- [ ] 입력값 검증
### 3단계: 프론트엔드
- [ ] MultiTableExcelUploadModal.tsx 컴포넌트 작성
- [ ] 모드 선택 UI
- [ ] 템플릿 다운로드 버튼
- [ ] 파일 업로드 + 미리보기
- [ ] 컬럼 매핑 UI
- [ ] 업로드 결과 표시
### 4단계: 통합
- [ ] 거래처 관리 화면에 연결
- [ ] 실제 데이터로 테스트
## 진행 상태
- 완료된 테스트는 [x]로 표시
- 현재 진행 중인 테스트는 [진행중]으로 표시

View File

@ -0,0 +1,786 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
AlertCircle,
CheckCircle2,
ArrowRight,
Zap,
Download,
Loader2,
} from "lucide-react";
import { importFromExcel, getExcelSheetNames, exportToExcel } from "@/lib/utils/excelExport";
import { cn } from "@/lib/utils";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
import {
TableChainConfig,
uploadMultiTableExcel,
} from "@/lib/api/multiTableExcel";
export interface MultiTableExcelUploadModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
config: TableChainConfig;
onSuccess?: () => void;
}
interface ColumnMapping {
excelColumn: string;
targetColumn: string | null;
}
export const MultiTableExcelUploadModal: React.FC<MultiTableExcelUploadModalProps> = ({
open,
onOpenChange,
config,
onSuccess,
}) => {
// 스텝: 1=모드선택+파일, 2=컬럼매핑, 3=확인
const [currentStep, setCurrentStep] = useState(1);
// 모드 선택
const [selectedModeId, setSelectedModeId] = useState<string>(
config.uploadModes[0]?.id || ""
);
// 파일
const [file, setFile] = useState<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState("");
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [allData, setAllData] = useState<Record<string, any>[]>([]);
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
const [excelColumns, setExcelColumns] = useState<string[]>([]);
// 매핑
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 업로드
const [isUploading, setIsUploading] = useState(false);
const selectedMode = config.uploadModes.find((m) => m.id === selectedModeId);
// 선택된 모드에서 활성화되는 컬럼 목록
const activeColumns = React.useMemo(() => {
if (!selectedMode) return [];
const cols: Array<{ dbColumn: string; excelHeader: string; required: boolean; levelLabel: string }> = [];
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
for (const col of level.columns) {
cols.push({
...col,
levelLabel: level.label,
});
}
}
return cols;
}, [selectedMode, config.levels]);
// 템플릿 다운로드
const handleDownloadTemplate = () => {
if (!selectedMode) return;
const headers: string[] = [];
const sampleRow: Record<string, string> = {};
const sampleRow2: Record<string, string> = {};
for (const levelIdx of selectedMode.activeLevels) {
const level = config.levels[levelIdx];
if (!level) continue;
for (const col of level.columns) {
headers.push(col.excelHeader);
sampleRow[col.excelHeader] = col.required ? "(필수)" : "";
sampleRow2[col.excelHeader] = "";
}
}
// 예시 데이터 생성 (config에 맞춰)
exportToExcel(
[sampleRow, sampleRow2],
`${config.name}_${selectedMode.label}_템플릿.xlsx`,
"Sheet1"
);
toast.success("템플릿 파일이 다운로드되었습니다.");
};
// 파일 처리
const processFile = async (selectedFile: File) => {
const ext = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(ext || "")) {
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
return;
}
setFile(selectedFile);
try {
const sheets = await getExcelSheetNames(selectedFile);
setSheetNames(sheets);
setSelectedSheet(sheets[0] || "");
const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data);
setDisplayData(data);
if (data.length > 0) {
setExcelColumns(Object.keys(data[0]));
}
toast.success(`파일 선택 완료: ${selectedFile.name}`);
} catch (error) {
console.error("파일 읽기 오류:", error);
toast.error("파일을 읽는 중 오류가 발생했습니다.");
setFile(null);
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) await processFile(selectedFile);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) await processFile(droppedFile);
};
const handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName);
if (!file) return;
try {
const data = await importFromExcel(file, sheetName);
setAllData(data);
setDisplayData(data);
if (data.length > 0) {
setExcelColumns(Object.keys(data[0]));
}
} catch (error) {
console.error("시트 읽기 오류:", error);
toast.error("시트를 읽는 중 오류가 발생했습니다.");
}
};
// 2단계 진입 시 자동 매핑 시도
useEffect(() => {
if (currentStep === 2 && excelColumns.length > 0) {
performAutoMapping();
}
}, [currentStep]);
const performAutoMapping = () => {
const newMappings: ColumnMapping[] = excelColumns.map((excelCol) => {
const normalizedExcel = excelCol.toLowerCase().trim();
const matched = activeColumns.find((ac) => {
return (
ac.excelHeader.toLowerCase().trim() === normalizedExcel ||
ac.dbColumn.toLowerCase().trim() === normalizedExcel
);
});
return {
excelColumn: excelCol,
targetColumn: matched ? matched.excelHeader : null,
};
});
setColumnMappings(newMappings);
const matchedCount = newMappings.filter((m) => m.targetColumn).length;
if (matchedCount > 0) {
toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`);
}
};
const handleMappingChange = (excelColumn: string, targetColumn: string | null) => {
setColumnMappings((prev) =>
prev.map((m) =>
m.excelColumn === excelColumn ? { ...m, targetColumn } : m
)
);
};
// 업로드 실행
const handleUpload = async () => {
if (!file || !selectedMode) return;
setIsUploading(true);
try {
// 엑셀 데이터를 excelHeader 기준으로 변환
const mappedRows = allData.map((row) => {
const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => {
if (mapping.targetColumn) {
mappedRow[mapping.targetColumn] = row[mapping.excelColumn];
}
});
return mappedRow;
});
// 빈 행 필터링
const filteredRows = mappedRows.filter((row) =>
Object.values(row).some(
(v) => v !== undefined && v !== null && (typeof v !== "string" || v.trim() !== "")
)
);
console.log(`다중 테이블 업로드: ${filteredRows.length}`);
const result = await uploadMultiTableExcel({
config,
modeId: selectedModeId,
rows: filteredRows,
});
if (result.success && result.data) {
const { results, errors } = result.data;
const summaryParts = results
.filter((r) => r.inserted + r.updated > 0)
.map((r) => {
const parts: string[] = [];
if (r.inserted > 0) parts.push(`신규 ${r.inserted}`);
if (r.updated > 0) parts.push(`수정 ${r.updated}`);
return `${r.tableName}: ${parts.join(", ")}`;
});
const msg = summaryParts.join(" / ");
const errorMsg = errors.length > 0 ? ` (오류: ${errors.length}건)` : "";
toast.success(`업로드 완료: ${msg}${errorMsg}`);
if (errors.length > 0) {
console.warn("업로드 오류 목록:", errors);
}
onSuccess?.();
onOpenChange(false);
} else {
toast.error(result.message || "업로드에 실패했습니다.");
}
} catch (error) {
console.error("다중 테이블 업로드 실패:", error);
toast.error("업로드 중 오류가 발생했습니다.");
} finally {
setIsUploading(false);
}
};
// 다음/이전 단계
const handleNext = () => {
if (currentStep === 1) {
if (!file) {
toast.error("파일을 선택해주세요.");
return;
}
if (displayData.length === 0) {
toast.error("데이터가 없습니다.");
return;
}
}
if (currentStep === 2) {
// 필수 컬럼 매핑 확인
const mappedTargets = new Set(
columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn)
);
const unmappedRequired = activeColumns
.filter((ac) => ac.required && !mappedTargets.has(ac.excelHeader))
.map((ac) => `${ac.excelHeader}`);
if (unmappedRequired.length > 0) {
toast.error(`필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`);
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
};
const handlePrevious = () => {
setCurrentStep((prev) => Math.max(prev - 1, 1));
};
// 모달 닫기 시 초기화
useEffect(() => {
if (!open) {
setCurrentStep(1);
setSelectedModeId(config.uploadModes[0]?.id || "");
setFile(null);
setSheetNames([]);
setSelectedSheet("");
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
setColumnMappings([]);
}
}, [open, config.uploadModes]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[95vh] max-w-[95vw] sm:max-w-[1200px]"
style={{ width: "1000px", height: "700px", minWidth: "700px", minHeight: "500px", maxWidth: "1400px", maxHeight: "900px" }}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
<FileSpreadsheet className="h-5 w-5" />
{config.name} -
<span className="ml-2 rounded bg-indigo-100 px-2 py-0.5 text-xs font-normal text-indigo-700">
</span>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{config.description}
</DialogDescription>
</DialogHeader>
{/* 스텝 인디케이터 */}
<div className="flex items-center justify-between">
{[
{ num: 1, label: "모드 선택 / 파일" },
{ num: 2, label: "컬럼 매핑" },
{ num: 3, label: "확인" },
].map((step, index) => (
<React.Fragment key={step.num}>
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors sm:h-10 sm:w-10",
currentStep === step.num
? "bg-primary text-primary-foreground"
: currentStep > step.num
? "bg-success text-white"
: "bg-muted text-muted-foreground"
)}
>
{currentStep > step.num ? (
<CheckCircle2 className="h-4 w-4 sm:h-5 sm:w-5" />
) : (
step.num
)}
</div>
<span
className={cn(
"text-[10px] font-medium sm:text-xs",
currentStep === step.num ? "text-primary" : "text-muted-foreground"
)}
>
{step.label}
</span>
</div>
{index < 2 && (
<div
className={cn(
"h-0.5 flex-1 transition-colors",
currentStep > step.num ? "bg-success" : "bg-muted"
)}
/>
)}
</React.Fragment>
))}
</div>
{/* 스텝별 컨텐츠 */}
<div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto">
{/* 1단계: 모드 선택 + 파일 선택 */}
{currentStep === 1 && (
<div className="space-y-4">
{/* 업로드 모드 선택 */}
<div>
<Label className="text-xs font-medium sm:text-sm"> *</Label>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{config.uploadModes.map((mode) => (
<button
key={mode.id}
type="button"
onClick={() => {
setSelectedModeId(mode.id);
setFile(null);
setAllData([]);
setDisplayData([]);
setExcelColumns([]);
}}
className={cn(
"rounded-lg border p-3 text-left transition-all",
selectedModeId === mode.id
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-border hover:border-primary/50 hover:bg-muted/50"
)}
>
<p className="text-xs font-semibold sm:text-sm">{mode.label}</p>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
{mode.description}
</p>
</button>
))}
</div>
</div>
{/* 템플릿 다운로드 */}
<div className="flex items-center justify-between rounded-md border border-muted bg-muted/30 p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground sm:text-sm">
<Download className="h-4 w-4" />
<span> </span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownloadTemplate}
className="h-8 text-xs sm:text-sm"
>
<Download className="mr-1 h-3 w-3" />
릿
</Button>
</div>
{/* 파일 선택 */}
<div>
<Label htmlFor="multi-file-upload" className="text-xs sm:text-sm">
*
</Label>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
"mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
isDragOver
? "border-primary bg-primary/5"
: file
? "border-green-500 bg-green-50"
: "border-muted-foreground/25 hover:border-primary hover:bg-muted/50"
)}
>
{file ? (
<div className="flex items-center gap-3">
<FileSpreadsheet className="h-8 w-8 text-green-600" />
<div>
<p className="text-sm font-medium text-green-700">{file.name}</p>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
) : (
<>
<Upload
className={cn(
"mb-2 h-8 w-8",
isDragOver ? "text-primary" : "text-muted-foreground"
)}
/>
<p
className={cn(
"text-sm font-medium",
isDragOver ? "text-primary" : "text-muted-foreground"
)}
>
{isDragOver ? "파일을 놓으세요" : "파일을 드래그하거나 클릭하여 선택"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
형식: .xlsx, .xls, .csv
</p>
</>
)}
<input
ref={fileInputRef}
id="multi-file-upload"
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
{/* 미리보기 */}
{file && displayData.length > 0 && (
<>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Label className="text-xs text-muted-foreground sm:text-sm">:</Label>
<Select value={selectedSheet} onValueChange={handleSheetChange}>
<SelectTrigger className="h-8 w-[140px] text-xs sm:h-9 sm:w-[180px] sm:text-sm">
<SelectValue placeholder="Sheet1" />
</SelectTrigger>
<SelectContent>
{sheetNames.map((name) => (
<SelectItem key={name} value={name} className="text-xs sm:text-sm">
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-xs text-muted-foreground">
{displayData.length}
</span>
</div>
<EditableSpreadsheet
columns={excelColumns}
data={displayData}
onColumnsChange={setExcelColumns}
onDataChange={(newData) => {
setDisplayData(newData);
setAllData(newData);
}}
maxHeight="250px"
/>
</>
)}
</div>
)}
{/* 2단계: 컬럼 매핑 */}
{currentStep === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold sm:text-base"> </h3>
<Button
type="button"
variant="default"
size="sm"
onClick={performAutoMapping}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
<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> </div>
<div></div>
<div> </div>
</div>
<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"
>
<div className="rounded-md border border-border bg-muted px-3 py-2 text-xs font-medium sm:text-sm">
{mapping.excelColumn}
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<Select
value={mapping.targetColumn || "none"}
onValueChange={(value) =>
handleMappingChange(
mapping.excelColumn,
value === "none" ? null : value
)
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="매핑 안함">
{mapping.targetColumn || "매핑 안함"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="text-xs sm:text-sm">
</SelectItem>
{activeColumns.map((ac) => (
<SelectItem
key={`${ac.levelLabel}-${ac.dbColumn}`}
value={ac.excelHeader}
className="text-xs sm:text-sm"
>
{ac.required && (
<span className="mr-1 text-destructive">*</span>
)}
[{ac.levelLabel}] {ac.excelHeader} ({ac.dbColumn})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</div>
{/* 미매핑 필수 컬럼 경고 */}
{(() => {
const mappedTargets = new Set(
columnMappings.filter((m) => m.targetColumn).map((m) => m.targetColumn)
);
const missing = activeColumns.filter(
(ac) => ac.required && !mappedTargets.has(ac.excelHeader)
);
if (missing.length === 0) return null;
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
<div className="text-[10px] text-destructive sm:text-xs">
<p className="font-medium"> :</p>
<p className="mt-1">
{missing.map((m) => `[${m.levelLabel}] ${m.excelHeader}`).join(", ")}
</p>
</div>
</div>
</div>
);
})()}
{/* 모드 정보 */}
{selectedMode && (
<div className="rounded-md border border-muted bg-muted/30 p-3">
<div className="flex items-start gap-2">
<Zap 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">: {selectedMode.label}</p>
<p className="mt-1">
:{" "}
{selectedMode.activeLevels
.map((i) => config.levels[i]?.label)
.filter(Boolean)
.join(" → ")}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* 3단계: 확인 */}
{currentStep === 3 && (
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
<p><span className="font-medium">:</span> {file?.name}</p>
<p><span className="font-medium">:</span> {selectedSheet}</p>
<p><span className="font-medium"> :</span> {allData.length}</p>
<p><span className="font-medium">:</span> {selectedMode?.label}</p>
<p>
<span className="font-medium"> :</span>{" "}
{selectedMode?.activeLevels
.map((i) => {
const level = config.levels[i];
return level
? `${level.label}(${level.tableName})`
: "";
})
.filter(Boolean)
.join(" → ")}
</p>
</div>
</div>
<div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3>
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
{columnMappings
.filter((m) => m.targetColumn)
.map((mapping, idx) => {
const ac = activeColumns.find(
(c) => c.excelHeader === mapping.targetColumn
);
return (
<p key={idx}>
<span className="font-medium">{mapping.excelColumn}</span>{" "}
[{ac?.levelLabel}] {mapping.targetColumn}
</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" />
<div className="text-[10px] text-warning sm:text-xs">
<p className="font-medium"></p>
<p className="mt-1">
.
.
</p>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={currentStep === 1 ? () => onOpenChange(false) : handlePrevious}
disabled={isUploading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{currentStep === 1 ? "취소" : "이전"}
</Button>
{currentStep < 3 ? (
<Button
onClick={handleNext}
disabled={isUploading || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
) : (
<Button
onClick={handleUpload}
disabled={isUploading || columnMappings.filter((m) => m.targetColumn).length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isUploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"업로드"
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -851,6 +851,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
{/* 엑셀 관련 */}
<SelectItem value="excel_download"> </SelectItem>
<SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="multi_table_excel_upload"> </SelectItem>
{/* 고급 기능 */}
<SelectItem value="quickInsert"> </SelectItem>
@ -2430,6 +2431,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
/>
)}
{/* 다중 테이블 엑셀 업로드: 설정 불필요 (버튼 클릭 시 화면 테이블에서 자동 감지) */}
{/* 바코드 스캔 액션 설정 */}
{localInputs.actionType === "barcode_scan" && (
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
@ -3997,8 +4000,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
)}
</div>
{/* 제어 기능 섹션 - 엑셀 업로드 아닐 때만 표시 */}
{localInputs.actionType !== "excel_upload" && (
{/* 제어 기능 섹션 - 엑셀 업로드 계열이 아닐 때만 표시 */}
{localInputs.actionType !== "excel_upload" && localInputs.actionType !== "multi_table_excel_upload" && (
<div className="border-border mt-8 border-t pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
</div>
@ -4687,3 +4690,4 @@ const ExcelUploadConfigSection: React.FC<{
</div>
);
};

View File

@ -365,6 +365,14 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
fetchEntityJoinColumns();
}, [entityJoinTargetTable]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<V2RepeaterConfig>) => {
onChange({ ...config, ...updates });
},
[config, onChange],
);
// Entity 조인 컬럼 토글 (추가/제거)
const toggleEntityJoinColumn = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
@ -423,14 +431,6 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
[config.entityJoins],
);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<V2RepeaterConfig>) => {
onChange({ ...config, ...updates });
},
[config, onChange],
);
const updateDataSource = useCallback(
(field: string, value: any) => {
updateConfig({

View File

@ -0,0 +1,98 @@
/**
* API
*/
import { apiClient } from "./client";
/** 테이블 계층 레벨 설정 */
export interface TableLevel {
tableName: string;
label: string;
parentFkColumn?: string;
parentRefColumn?: string;
upsertMode: "upsert" | "insert";
upsertKeyColumns?: string[];
columns: ColumnDef[];
}
/** 컬럼 정의 */
export interface ColumnDef {
dbColumn: string;
excelHeader: string;
required: boolean;
defaultValue?: any;
}
/** 업로드 모드 정의 */
export interface UploadMode {
id: string;
label: string;
description: string;
activeLevels: number[];
}
/** 테이블 체인 설정 */
export interface TableChainConfig {
id: string;
name: string;
description: string;
levels: TableLevel[];
uploadModes: UploadMode[];
}
/** 레벨별 결과 */
export interface LevelResult {
tableName: string;
inserted: number;
updated: number;
}
/** 업로드 결과 */
export interface MultiTableUploadResult {
success: boolean;
results: LevelResult[];
totalRows: number;
errors: string[];
}
/**
* TableChainConfig를
* DB FK +
*/
export async function autoDetectMultiTableConfig(
rootTable: string,
screenId?: number
): Promise<{ success: boolean; data?: TableChainConfig; message?: string }> {
try {
const params: Record<string, any> = { rootTable };
if (screenId) params.screenId = screenId;
const response = await apiClient.get("/data/multi-table/auto-detect", {
params,
});
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}
/**
*
*/
export async function uploadMultiTableExcel(params: {
config: TableChainConfig;
modeId: string;
rows: Record<string, any>[];
}): Promise<{ success: boolean; data?: MultiTableUploadResult; message?: string }> {
try {
const response = await apiClient.post("/data/multi-table/upload", params);
return response.data;
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message,
};
}
}

View File

@ -1235,6 +1235,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (!leftItem) return;
setIsLoadingRight(true);
setRightData([]);
try {
// detail / join 모두 동일한 필터링 로직 사용
// (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함)
@ -1343,34 +1344,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
enableEntityJoin: true,
size: 1000,
companyCodeOverride: companyCode,
additionalJoinColumns: rightJoinColumns, // 🆕 Entity 조인 컬럼 전달
additionalJoinColumns: rightJoinColumns,
});
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
// 추가 dataFilter 적용
let filteredData = result.data || [];
const dataFilter = componentConfig.rightPanel?.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
setRightData(filteredData);
setRightData(result.data || []);
} else {
// 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
@ -1380,9 +1359,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const leftValue = leftItem[leftColumn];
const { entityJoinApi } = await import("@/lib/api/entityJoin");
// 단일키를 복합키 형식으로 변환
console.log("🔗 [분할패널] 단일키 조건:", { leftColumn, rightColumn, leftValue, rightTableName });
// 단일키를 복합키 형식으로 변환 (entity 컬럼이므로 equals 연산자 필수)
const searchConditions: Record<string, any> = {};
searchConditions[rightColumn] = leftValue;
searchConditions[rightColumn] = { value: leftValue, operator: "equals" };
// Entity 조인 컬럼 추출
const rightJoinColumnsLegacy = extractAdditionalJoinColumns(
@ -1401,35 +1382,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
additionalJoinColumns: rightJoinColumnsLegacy,
});
let filteredDataLegacy = result.data || [];
// 데이터 필터 적용
const dataFilterLegacy = componentConfig.rightPanel?.dataFilter;
if (dataFilterLegacy?.enabled && dataFilterLegacy.conditions?.length > 0) {
filteredDataLegacy = filteredDataLegacy.filter((item: any) => {
return dataFilterLegacy.conditions.every((cond: any) => {
const value = item[cond.column];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
return value === condValue;
case "notEquals":
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
default:
return true;
}
});
});
}
setRightData(filteredDataLegacy || []);
setRightData(result.data || []);
}
}
}
} catch (error) {
console.error("우측 데이터 로드 실패:", error);
setRightData([]);
toast({
title: "데이터 로드 실패",
description: "우측 패널 데이터를 불러올 수 없습니다.",

View File

@ -49,6 +49,7 @@ export type ButtonActionType =
| "view_table_history" // 테이블 이력 보기
| "excel_download" // 엑셀 다운로드
| "excel_upload" // 엑셀 업로드
| "multi_table_excel_upload" // 다중 테이블 엑셀 업로드
| "barcode_scan" // 바코드 스캔
| "code_merge" // 코드 병합
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
@ -428,6 +429,9 @@ export class ButtonActionExecutor {
case "excel_upload":
return await this.handleExcelUpload(config, context);
case "multi_table_excel_upload":
return await this.handleMultiTableExcelUpload(config, context);
case "barcode_scan":
return await this.handleBarcodeScan(config, context);
@ -5604,6 +5608,69 @@ export class ButtonActionExecutor {
}
}
/**
*
*/
private static async handleMultiTableExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
const rootTable = context.tableName;
if (!rootTable) {
toast.error("화면에 테이블이 설정되지 않았습니다. 화면 설정을 확인하세요.");
return false;
}
toast.loading("테이블 구조를 분석하고 있습니다...", { id: "multi-table-detect" });
const { autoDetectMultiTableConfig } = await import("@/lib/api/multiTableExcel");
const result = await autoDetectMultiTableConfig(rootTable, context.screenId);
toast.dismiss("multi-table-detect");
if (!result.success || !result.data) {
toast.error(result.message || `테이블 구조를 분석할 수 없습니다: ${rootTable}`);
return false;
}
const chainConfig = result.data;
const { MultiTableExcelUploadModal } = await import("@/components/common/MultiTableExcelUploadModal");
const { createRoot } = await import("react-dom/client");
const modalContainer = document.createElement("div");
document.body.appendChild(modalContainer);
const root = createRoot(modalContainer);
const closeModal = () => {
root.unmount();
document.body.removeChild(modalContainer);
};
root.render(
React.createElement(MultiTableExcelUploadModal, {
open: true,
onOpenChange: (open: boolean) => {
if (!open) closeModal();
},
config: chainConfig,
onSuccess: () => {
context.onRefresh?.();
},
}),
);
return true;
} catch (error) {
toast.dismiss("multi-table-detect");
console.error("다중 테이블 엑셀 업로드 모달 열기 실패:", error);
showErrorToast(config.errorMessage || "다중 테이블 엑셀 업로드 화면을 열 수 없습니다", error, {
guidance: "잠시 후 다시 시도해 주세요.",
});
return false;
}
}
/**
*
*/
@ -7700,6 +7767,11 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
successMessage: "엑셀 파일이 업로드되었습니다.",
errorMessage: "엑셀 업로드 중 오류가 발생했습니다.",
},
multi_table_excel_upload: {
type: "multi_table_excel_upload",
successMessage: "다중 테이블 엑셀 업로드가 완료되었습니다.",
errorMessage: "다중 테이블 엑셀 업로드 중 오류가 발생했습니다.",
},
barcode_scan: {
type: "barcode_scan",
barcodeFormat: "all",

View File

@ -104,8 +104,8 @@ export async function importFromExcel(
return;
}
// 워크북 읽기
const workbook = XLSX.read(data, { type: "binary" });
// 워크북 읽기 (cellDates: 엑셀 시리얼 날짜를 JS Date 객체로 변환)
const workbook = XLSX.read(data, { type: "binary", cellDates: true });
// 시트 선택 (지정된 시트 또는 첫 번째 시트)
const targetSheetName = sheetName || workbook.SheetNames[0];
@ -118,15 +118,31 @@ export async function importFromExcel(
// JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지)
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
defval: "", // 빈 셀에 빈 문자열 할당
defval: "",
});
// Date 객체를 yyyy-mm-dd 문자열로 변환
const processedData = (jsonData as Record<string, any>[]).map((row) => {
const newRow: Record<string, any> = {};
for (const [key, value] of Object.entries(row)) {
if (value instanceof Date && !isNaN(value.getTime())) {
const y = value.getUTCFullYear();
const m = String(value.getUTCMonth() + 1).padStart(2, "0");
const d = String(value.getUTCDate()).padStart(2, "0");
newRow[key] = `${y}-${m}-${d}`;
} else {
newRow[key] = value;
}
}
return newRow;
});
console.log("✅ 엑셀 가져오기 완료:", {
sheetName: targetSheetName,
rowCount: jsonData.length,
rowCount: processedData.length,
});
resolve(jsonData as Record<string, any>[]);
resolve(processedData);
} catch (error) {
console.error("❌ 엑셀 가져오기 실패:", error);
reject(error);