jskim-node #404

Merged
kjs merged 3 commits from jskim-node into main 2026-03-05 19:30:39 +09:00
11 changed files with 2384 additions and 69 deletions
Showing only changes of commit 0e8c68a9ff - Show all commits

View File

@ -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

View File

@ -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)

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_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>
); );
}; };

View File

@ -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({

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; 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: "우측 패널 데이터를 불러올 수 없습니다.",

View File

@ -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",

View File

@ -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);