jskim-node #404
|
|
@ -1,6 +1,7 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { dataService } from "../services/dataService";
|
import { dataService } from "../services/dataService";
|
||||||
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
import { masterDetailExcelService } from "../services/masterDetailExcelService";
|
||||||
|
import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import { AuthenticatedRequest } from "../types/auth";
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
import { auditLogService } from "../services/auditLogService";
|
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
|
// 기존 데이터 API
|
||||||
// ================================
|
// ================================
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3783,15 +3783,15 @@ export class TableManagementService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (operator === "equals") {
|
} else if (operator === "equals") {
|
||||||
// 🔧 equals 연산자: 정확히 일치
|
// 🔧 equals 연산자: 메인 테이블의 FK 컬럼에서 직접 매칭 (연결 필터용)
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
`${alias}.${joinConfig.displayColumn}::text = '${safeValue}'`
|
`main.${joinConfig.sourceColumn}::text = '${safeValue}'`
|
||||||
);
|
);
|
||||||
entitySearchColumns.push(
|
entitySearchColumns.push(
|
||||||
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
|
`${key} (main.${joinConfig.sourceColumn})`
|
||||||
);
|
);
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 Entity 조인 정확히 일치 검색: ${key} → ${joinConfig.referenceTable}.${joinConfig.displayColumn} = '${safeValue}' (별칭: ${alias})`
|
`🎯 Entity 조인 직접 FK 매칭: ${key} → main.${joinConfig.sourceColumn} = '${safeValue}'`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 기본: 부분 일치 (ILIKE)
|
// 기본: 부분 일치 (ILIKE)
|
||||||
|
|
|
||||||
|
|
@ -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]로 표시
|
||||||
|
- 현재 진행 중인 테스트는 [진행중]으로 표시
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -851,6 +851,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
{/* 엑셀 관련 */}
|
{/* 엑셀 관련 */}
|
||||||
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
<SelectItem value="excel_download">엑셀 다운로드</SelectItem>
|
||||||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||||
|
<SelectItem value="multi_table_excel_upload">다중 테이블 엑셀 업로드</SelectItem>
|
||||||
|
|
||||||
{/* 고급 기능 */}
|
{/* 고급 기능 */}
|
||||||
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
<SelectItem value="quickInsert">즉시 저장</SelectItem>
|
||||||
|
|
@ -2430,6 +2431,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 다중 테이블 엑셀 업로드: 설정 불필요 (버튼 클릭 시 화면 테이블에서 자동 감지) */}
|
||||||
|
|
||||||
{/* 바코드 스캔 액션 설정 */}
|
{/* 바코드 스캔 액션 설정 */}
|
||||||
{localInputs.actionType === "barcode_scan" && (
|
{localInputs.actionType === "barcode_scan" && (
|
||||||
<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">
|
||||||
|
|
@ -3997,8 +4000,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="border-border mt-8 border-t pt-6">
|
||||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4687,3 +4690,4 @@ const ExcelUploadConfigSection: React.FC<{
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,14 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
fetchEntityJoinColumns();
|
fetchEntityJoinColumns();
|
||||||
}, [entityJoinTargetTable]);
|
}, [entityJoinTargetTable]);
|
||||||
|
|
||||||
|
// 설정 업데이트 헬퍼
|
||||||
|
const updateConfig = useCallback(
|
||||||
|
(updates: Partial<V2RepeaterConfig>) => {
|
||||||
|
onChange({ ...config, ...updates });
|
||||||
|
},
|
||||||
|
[config, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
// Entity 조인 컬럼 토글 (추가/제거)
|
// Entity 조인 컬럼 토글 (추가/제거)
|
||||||
const toggleEntityJoinColumn = useCallback(
|
const toggleEntityJoinColumn = useCallback(
|
||||||
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
|
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string) => {
|
||||||
|
|
@ -423,14 +431,6 @@ export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
|
||||||
[config.entityJoins],
|
[config.entityJoins],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 설정 업데이트 헬퍼
|
|
||||||
const updateConfig = useCallback(
|
|
||||||
(updates: Partial<V2RepeaterConfig>) => {
|
|
||||||
onChange({ ...config, ...updates });
|
|
||||||
},
|
|
||||||
[config, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateDataSource = useCallback(
|
const updateDataSource = useCallback(
|
||||||
(field: string, value: any) => {
|
(field: string, value: any) => {
|
||||||
updateConfig({
|
updateConfig({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1235,6 +1235,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
if (!leftItem) return;
|
if (!leftItem) return;
|
||||||
|
|
||||||
setIsLoadingRight(true);
|
setIsLoadingRight(true);
|
||||||
|
setRightData([]);
|
||||||
try {
|
try {
|
||||||
// detail / join 모두 동일한 필터링 로직 사용
|
// detail / join 모두 동일한 필터링 로직 사용
|
||||||
// (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함)
|
// (차이점: 초기 로드 여부만 다름 - detail은 초기 로드 안 함)
|
||||||
|
|
@ -1343,34 +1344,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
enableEntityJoin: true,
|
enableEntityJoin: true,
|
||||||
size: 1000,
|
size: 1000,
|
||||||
companyCodeOverride: companyCode,
|
companyCodeOverride: companyCode,
|
||||||
additionalJoinColumns: rightJoinColumns, // 🆕 Entity 조인 컬럼 전달
|
additionalJoinColumns: rightJoinColumns,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
console.log("🔗 [분할패널] 복합키 조회 결과:", result);
|
||||||
|
|
||||||
// 추가 dataFilter 적용
|
setRightData(result.data || []);
|
||||||
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);
|
|
||||||
} else {
|
} else {
|
||||||
// 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원)
|
// 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원)
|
||||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||||
|
|
@ -1380,9 +1359,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const leftValue = leftItem[leftColumn];
|
const leftValue = leftItem[leftColumn];
|
||||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
// 단일키를 복합키 형식으로 변환
|
console.log("🔗 [분할패널] 단일키 조건:", { leftColumn, rightColumn, leftValue, rightTableName });
|
||||||
|
|
||||||
|
// 단일키를 복합키 형식으로 변환 (entity 컬럼이므로 equals 연산자 필수)
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
searchConditions[rightColumn] = leftValue;
|
searchConditions[rightColumn] = { value: leftValue, operator: "equals" };
|
||||||
|
|
||||||
// Entity 조인 컬럼 추출
|
// Entity 조인 컬럼 추출
|
||||||
const rightJoinColumnsLegacy = extractAdditionalJoinColumns(
|
const rightJoinColumnsLegacy = extractAdditionalJoinColumns(
|
||||||
|
|
@ -1401,35 +1382,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
additionalJoinColumns: rightJoinColumnsLegacy,
|
additionalJoinColumns: rightJoinColumnsLegacy,
|
||||||
});
|
});
|
||||||
|
|
||||||
let filteredDataLegacy = result.data || [];
|
setRightData(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 || []);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("우측 데이터 로드 실패:", error);
|
console.error("우측 데이터 로드 실패:", error);
|
||||||
|
setRightData([]);
|
||||||
toast({
|
toast({
|
||||||
title: "데이터 로드 실패",
|
title: "데이터 로드 실패",
|
||||||
description: "우측 패널 데이터를 불러올 수 없습니다.",
|
description: "우측 패널 데이터를 불러올 수 없습니다.",
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export type ButtonActionType =
|
||||||
| "view_table_history" // 테이블 이력 보기
|
| "view_table_history" // 테이블 이력 보기
|
||||||
| "excel_download" // 엑셀 다운로드
|
| "excel_download" // 엑셀 다운로드
|
||||||
| "excel_upload" // 엑셀 업로드
|
| "excel_upload" // 엑셀 업로드
|
||||||
|
| "multi_table_excel_upload" // 다중 테이블 엑셀 업로드
|
||||||
| "barcode_scan" // 바코드 스캔
|
| "barcode_scan" // 바코드 스캔
|
||||||
| "code_merge" // 코드 병합
|
| "code_merge" // 코드 병합
|
||||||
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
|
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
|
||||||
|
|
@ -428,6 +429,9 @@ export class ButtonActionExecutor {
|
||||||
case "excel_upload":
|
case "excel_upload":
|
||||||
return await this.handleExcelUpload(config, context);
|
return await this.handleExcelUpload(config, context);
|
||||||
|
|
||||||
|
case "multi_table_excel_upload":
|
||||||
|
return await this.handleMultiTableExcelUpload(config, context);
|
||||||
|
|
||||||
case "barcode_scan":
|
case "barcode_scan":
|
||||||
return await this.handleBarcodeScan(config, context);
|
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: "엑셀 파일이 업로드되었습니다.",
|
successMessage: "엑셀 파일이 업로드되었습니다.",
|
||||||
errorMessage: "엑셀 업로드 중 오류가 발생했습니다.",
|
errorMessage: "엑셀 업로드 중 오류가 발생했습니다.",
|
||||||
},
|
},
|
||||||
|
multi_table_excel_upload: {
|
||||||
|
type: "multi_table_excel_upload",
|
||||||
|
successMessage: "다중 테이블 엑셀 업로드가 완료되었습니다.",
|
||||||
|
errorMessage: "다중 테이블 엑셀 업로드 중 오류가 발생했습니다.",
|
||||||
|
},
|
||||||
barcode_scan: {
|
barcode_scan: {
|
||||||
type: "barcode_scan",
|
type: "barcode_scan",
|
||||||
barcodeFormat: "all",
|
barcodeFormat: "all",
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,8 @@ export async function importFromExcel(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 워크북 읽기
|
// 워크북 읽기 (cellDates: 엑셀 시리얼 날짜를 JS Date 객체로 변환)
|
||||||
const workbook = XLSX.read(data, { type: "binary" });
|
const workbook = XLSX.read(data, { type: "binary", cellDates: true });
|
||||||
|
|
||||||
// 시트 선택 (지정된 시트 또는 첫 번째 시트)
|
// 시트 선택 (지정된 시트 또는 첫 번째 시트)
|
||||||
const targetSheetName = sheetName || workbook.SheetNames[0];
|
const targetSheetName = sheetName || workbook.SheetNames[0];
|
||||||
|
|
@ -118,15 +118,31 @@ export async function importFromExcel(
|
||||||
|
|
||||||
// JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지)
|
// JSON으로 변환 (빈 셀도 포함하여 모든 컬럼 키 유지)
|
||||||
const jsonData = XLSX.utils.sheet_to_json(worksheet, {
|
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("✅ 엑셀 가져오기 완료:", {
|
console.log("✅ 엑셀 가져오기 완료:", {
|
||||||
sheetName: targetSheetName,
|
sheetName: targetSheetName,
|
||||||
rowCount: jsonData.length,
|
rowCount: processedData.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve(jsonData as Record<string, any>[]);
|
resolve(processedData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 엑셀 가져오기 실패:", error);
|
console.error("❌ 엑셀 가져오기 실패:", error);
|
||||||
reject(error);
|
reject(error);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue