This commit is contained in:
hjjeong 2026-01-08 14:15:53 +09:00
commit 00006bf2e2
16 changed files with 2568 additions and 383 deletions

View File

@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관
import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제) import digitalTwinRoutes from "./routes/digitalTwinRoutes"; // 디지털 트윈 (야드 관제)
@ -220,6 +221,7 @@ app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes); app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/screen-files", screenFileRoutes); app.use("/api/screen-files", screenFileRoutes);
app.use("/api/batch-configs", batchRoutes); app.use("/api/batch-configs", batchRoutes);
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-management", batchManagementRoutes);
app.use("/api/batch-execution-logs", batchExecutionLogRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
// app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음

View File

@ -0,0 +1,208 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
import excelMappingService from "../services/excelMappingService";
import { logger } from "../utils/logger";
/**
* 릿
* POST /api/excel-mapping/find
*/
export async function findMappingByColumns(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !excelColumns || !Array.isArray(excelColumns)) {
res.status(400).json({
success: false,
message: "tableName과 excelColumns(배열)가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 조회 요청", {
tableName,
excelColumns,
companyCode,
userId: req.user?.userId,
});
const template = await excelMappingService.findMappingByColumns(
tableName,
excelColumns,
companyCode
);
if (template) {
res.json({
success: true,
data: template,
message: "기존 매핑 템플릿을 찾았습니다.",
});
} else {
res.json({
success: true,
data: null,
message: "일치하는 매핑 템플릿이 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿 (UPSERT)
* POST /api/excel-mapping/save
*/
export async function saveMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, excelColumns, columnMappings } = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
if (!tableName || !excelColumns || !columnMappings) {
res.status(400).json({
success: false,
message: "tableName, excelColumns, columnMappings가 필요합니다.",
});
return;
}
logger.info("엑셀 매핑 템플릿 저장 요청", {
tableName,
excelColumns,
columnMappings,
companyCode,
userId,
});
const template = await excelMappingService.saveMappingTemplate(
tableName,
excelColumns,
columnMappings,
companyCode,
userId
);
res.json({
success: true,
data: template,
message: "매핑 템플릿이 저장되었습니다.",
});
} catch (error: any) {
logger.error("매핑 템플릿 저장 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 저장 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿
* GET /api/excel-mapping/list/:tableName
*/
export async function getMappingTemplates(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!tableName) {
res.status(400).json({
success: false,
message: "tableName이 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 목록 조회 요청", {
tableName,
companyCode,
});
const templates = await excelMappingService.getMappingTemplates(
tableName,
companyCode
);
res.json({
success: true,
data: templates,
});
} catch (error: any) {
logger.error("매핑 템플릿 목록 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 릿
* DELETE /api/excel-mapping/:id
*/
export async function deleteMappingTemplate(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
if (!id) {
res.status(400).json({
success: false,
message: "id가 필요합니다.",
});
return;
}
logger.info("매핑 템플릿 삭제 요청", {
id,
companyCode,
});
const deleted = await excelMappingService.deleteMappingTemplate(
parseInt(id),
companyCode
);
if (deleted) {
res.json({
success: true,
message: "매핑 템플릿이 삭제되었습니다.",
});
} else {
res.status(404).json({
success: false,
message: "삭제할 매핑 템플릿을 찾을 수 없습니다.",
});
}
} catch (error: any) {
logger.error("매핑 템플릿 삭제 실패", { error: error.message });
res.status(500).json({
success: false,
message: "매핑 템플릿 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}

View File

@ -0,0 +1,25 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
findMappingByColumns,
saveMappingTemplate,
getMappingTemplates,
deleteMappingTemplate,
} from "../controllers/excelMappingController";
const router = Router();
// 엑셀 컬럼 구조로 매핑 템플릿 조회
router.post("/find", authenticateToken, findMappingByColumns);
// 매핑 템플릿 저장 (UPSERT)
router.post("/save", authenticateToken, saveMappingTemplate);
// 테이블의 매핑 템플릿 목록 조회
router.get("/list/:tableName", authenticateToken, getMappingTemplates);
// 매핑 템플릿 삭제
router.delete("/:id", authenticateToken, deleteMappingTemplate);
export default router;

View File

@ -1,6 +1,7 @@
import { query, queryOne, transaction, getPool } from "../database/db"; import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService"; import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService"; import { DataflowControlService } from "./dataflowControlService";
import tableCategoryValueService from "./tableCategoryValueService";
export interface FormDataResult { export interface FormDataResult {
id: number; id: number;
@ -427,6 +428,24 @@ export class DynamicFormService {
dataToInsert, dataToInsert,
}); });
// 카테고리 타입 컬럼의 라벨 값을 코드 값으로 변환 (엑셀 업로드 등 지원)
console.log("🏷️ 카테고리 라벨→코드 변환 시작...");
const companyCodeForCategory = company_code || "*";
const { convertedData: categoryConvertedData, conversions } =
await tableCategoryValueService.convertCategoryLabelsToCodesForData(
tableName,
companyCodeForCategory,
dataToInsert
);
if (conversions.length > 0) {
console.log(`🏷️ 카테고리 라벨→코드 변환 완료: ${conversions.length}`, conversions);
// 변환된 데이터로 교체
Object.assign(dataToInsert, categoryConvertedData);
} else {
console.log("🏷️ 카테고리 라벨→코드 변환 없음 (카테고리 컬럼 없거나 이미 코드 값)");
}
// 테이블 컬럼 정보 조회하여 타입 변환 적용 // 테이블 컬럼 정보 조회하여 타입 변환 적용
console.log("🔍 테이블 컬럼 정보 조회 중..."); console.log("🔍 테이블 컬럼 정보 조회 중...");
const columnInfo = await this.getTableColumnInfo(tableName); const columnInfo = await this.getTableColumnInfo(tableName);

View File

@ -0,0 +1,283 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import crypto from "crypto";
export interface ExcelMappingTemplate {
id?: number;
tableName: string;
excelColumns: string[];
excelColumnsHash: string;
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
companyCode: string;
createdDate?: Date;
updatedDate?: Date;
}
class ExcelMappingService {
/**
*
* MD5
*/
generateColumnsHash(columns: string[]): string {
// 컬럼 목록을 정렬하여 순서와 무관하게 동일한 해시 생성
const sortedColumns = [...columns].sort();
const columnsString = sortedColumns.join("|");
return crypto.createHash("md5").update(columnsString).digest("hex");
}
/**
* 릿
*
*/
async findMappingByColumns(
tableName: string,
excelColumns: string[],
companyCode: string
): Promise<ExcelMappingTemplate | null> {
try {
const hash = this.generateColumnsHash(excelColumns);
logger.info("엑셀 매핑 템플릿 조회", {
tableName,
excelColumns,
hash,
companyCode,
});
const pool = getPool();
// 회사별 매핑 먼저 조회, 없으면 공통(*) 매핑 조회
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND excel_columns_hash = $2
ORDER BY updated_date DESC
LIMIT 1
`;
params = [tableName, hash];
} else {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND excel_columns_hash = $2
AND (company_code = $3 OR company_code = '*')
ORDER BY
CASE WHEN company_code = $3 THEN 0 ELSE 1 END,
updated_date DESC
LIMIT 1
`;
params = [tableName, hash, companyCode];
}
const result = await pool.query(query, params);
if (result.rows.length > 0) {
logger.info("기존 매핑 템플릿 발견", {
id: result.rows[0].id,
tableName,
});
return result.rows[0];
}
logger.info("매핑 템플릿 없음 - 새 구조", { tableName, hash });
return null;
} catch (error: any) {
logger.error(`매핑 템플릿 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿 (UPSERT)
* ++ ,
*/
async saveMappingTemplate(
tableName: string,
excelColumns: string[],
columnMappings: Record<string, string | null>,
companyCode: string,
userId?: string
): Promise<ExcelMappingTemplate> {
try {
const hash = this.generateColumnsHash(excelColumns);
logger.info("엑셀 매핑 템플릿 저장 (UPSERT)", {
tableName,
excelColumns,
hash,
columnMappings,
companyCode,
});
const pool = getPool();
const query = `
INSERT INTO excel_mapping_template (
table_name,
excel_columns,
excel_columns_hash,
column_mappings,
company_code,
created_date,
updated_date
) VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (table_name, excel_columns_hash, company_code)
DO UPDATE SET
column_mappings = EXCLUDED.column_mappings,
updated_date = NOW()
RETURNING
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
`;
const result = await pool.query(query, [
tableName,
excelColumns,
hash,
JSON.stringify(columnMappings),
companyCode,
]);
logger.info("매핑 템플릿 저장 완료", {
id: result.rows[0].id,
tableName,
hash,
});
return result.rows[0];
} catch (error: any) {
logger.error(`매핑 템플릿 저장 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿
*/
async getMappingTemplates(
tableName: string,
companyCode: string
): Promise<ExcelMappingTemplate[]> {
try {
logger.info("테이블 매핑 템플릿 목록 조회", { tableName, companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
ORDER BY updated_date DESC
`;
params = [tableName];
} else {
query = `
SELECT
id,
table_name as "tableName",
excel_columns as "excelColumns",
excel_columns_hash as "excelColumnsHash",
column_mappings as "columnMappings",
company_code as "companyCode",
created_date as "createdDate",
updated_date as "updatedDate"
FROM excel_mapping_template
WHERE table_name = $1
AND (company_code = $2 OR company_code = '*')
ORDER BY updated_date DESC
`;
params = [tableName, companyCode];
}
const result = await pool.query(query, params);
logger.info(`매핑 템플릿 ${result.rows.length}개 조회`, { tableName });
return result.rows;
} catch (error: any) {
logger.error(`매핑 템플릿 목록 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
* 릿
*/
async deleteMappingTemplate(
id: number,
companyCode: string
): Promise<boolean> {
try {
logger.info("매핑 템플릿 삭제", { id, companyCode });
const pool = getPool();
let query: string;
let params: any[];
if (companyCode === "*") {
query = `DELETE FROM excel_mapping_template WHERE id = $1`;
params = [id];
} else {
query = `DELETE FROM excel_mapping_template WHERE id = $1 AND company_code = $2`;
params = [id, companyCode];
}
const result = await pool.query(query, params);
if (result.rowCount && result.rowCount > 0) {
logger.info("매핑 템플릿 삭제 완료", { id });
return true;
}
logger.warn("삭제할 매핑 템플릿 없음", { id, companyCode });
return false;
} catch (error: any) {
logger.error(`매핑 템플릿 삭제 실패: ${error.message}`, { error });
throw error;
}
}
}
export default new ExcelMappingService();

View File

@ -1398,6 +1398,220 @@ class TableCategoryValueService {
throw error; throw error;
} }
} }
/**
* ( )
*
*
*
* @param tableName -
* @param companyCode -
* @returns { [columnName]: { [label]: code } }
*/
async getCategoryLabelToCodeMapping(
tableName: string,
companyCode: string
): Promise<Record<string, Record<string, string>>> {
try {
logger.info("카테고리 라벨→코드 매핑 조회", { tableName, companyCode });
const pool = getPool();
// 1. 해당 테이블의 카테고리 타입 컬럼 조회
const categoryColumnsQuery = `
SELECT column_name
FROM table_type_columns
WHERE table_name = $1
AND input_type = 'category'
`;
const categoryColumnsResult = await pool.query(categoryColumnsQuery, [tableName]);
if (categoryColumnsResult.rows.length === 0) {
logger.info("카테고리 타입 컬럼 없음", { tableName });
return {};
}
const categoryColumns = categoryColumnsResult.rows.map(row => row.column_name);
logger.info(`카테고리 컬럼 ${categoryColumns.length}개 발견`, { categoryColumns });
// 2. 각 카테고리 컬럼의 라벨→코드 매핑 조회
const result: Record<string, Record<string, string>> = {};
for (const columnName of categoryColumns) {
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
`;
params = [tableName, columnName];
} else {
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
query = `
SELECT value_code, value_label
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND is_active = true
AND (company_code = $3 OR company_code = '*')
`;
params = [tableName, columnName, companyCode];
}
const valuesResult = await pool.query(query, params);
// { [label]: code } 형태로 변환
const labelToCodeMap: Record<string, string> = {};
for (const row of valuesResult.rows) {
// 라벨을 소문자로 변환하여 대소문자 구분 없이 매핑
labelToCodeMap[row.value_label] = row.value_code;
// 소문자 키도 추가 (대소문자 무시 검색용)
labelToCodeMap[row.value_label.toLowerCase()] = row.value_code;
}
if (Object.keys(labelToCodeMap).length > 0) {
result[columnName] = labelToCodeMap;
logger.info(`컬럼 ${columnName}의 라벨→코드 매핑 ${valuesResult.rows.length}개 조회`);
}
}
logger.info(`카테고리 라벨→코드 매핑 조회 완료`, {
tableName,
columnCount: Object.keys(result).length
});
return result;
} catch (error: any) {
logger.error(`카테고리 라벨→코드 매핑 조회 실패: ${error.message}`, { error });
throw error;
}
}
/**
*
*
* DB
*
* @param tableName -
* @param companyCode -
* @param data -
* @returns
*/
async convertCategoryLabelsToCodesForData(
tableName: string,
companyCode: string,
data: Record<string, any>
): Promise<{ convertedData: Record<string, any>; conversions: Array<{ column: string; label: string; code: string }> }> {
try {
// 라벨→코드 매핑 조회
const labelToCodeMapping = await this.getCategoryLabelToCodeMapping(tableName, companyCode);
if (Object.keys(labelToCodeMapping).length === 0) {
// 카테고리 컬럼 없음
return { convertedData: data, conversions: [] };
}
const convertedData = { ...data };
const conversions: Array<{ column: string; label: string; code: string }> = [];
for (const [columnName, labelCodeMap] of Object.entries(labelToCodeMapping)) {
const value = data[columnName];
if (value !== undefined && value !== null && value !== "") {
const stringValue = String(value).trim();
// 다중 값 확인 (쉼표로 구분된 경우)
if (stringValue.includes(",")) {
// 다중 카테고리 값 처리
const labels = stringValue.split(",").map(s => s.trim()).filter(s => s !== "");
const convertedCodes: string[] = [];
let allConverted = true;
for (const label of labels) {
// 정확한 라벨 매칭 시도
let matchedCode = labelCodeMap[label];
// 대소문자 무시 매칭
if (!matchedCode) {
matchedCode = labelCodeMap[label.toLowerCase()];
}
if (matchedCode) {
convertedCodes.push(matchedCode);
conversions.push({
column: columnName,
label: label,
code: matchedCode,
});
logger.info(`카테고리 라벨→코드 변환 (다중): ${columnName} "${label}" → "${matchedCode}"`);
} else {
// 이미 코드값인지 확인
const isAlreadyCode = Object.values(labelCodeMap).includes(label);
if (isAlreadyCode) {
// 이미 코드값이면 그대로 사용
convertedCodes.push(label);
} else {
// 라벨도 코드도 아니면 원래 값 유지
convertedCodes.push(label);
allConverted = false;
logger.warn(`카테고리 값 매핑 없음 (다중): ${columnName} = "${label}" (라벨도 코드도 아님)`);
}
}
}
// 변환된 코드들을 쉼표로 합쳐서 저장
convertedData[columnName] = convertedCodes.join(",");
logger.info(`다중 카테고리 변환 완료: ${columnName} "${stringValue}" → "${convertedData[columnName]}"`);
} else {
// 단일 값 처리
// 정확한 라벨 매칭 시도
let matchedCode = labelCodeMap[stringValue];
// 대소문자 무시 매칭
if (!matchedCode) {
matchedCode = labelCodeMap[stringValue.toLowerCase()];
}
if (matchedCode) {
// 라벨 값을 코드 값으로 변환
convertedData[columnName] = matchedCode;
conversions.push({
column: columnName,
label: stringValue,
code: matchedCode,
});
logger.info(`카테고리 라벨→코드 변환: ${columnName} "${stringValue}" → "${matchedCode}"`);
} else {
// 이미 코드값인지 확인 (역방향 확인)
const isAlreadyCode = Object.values(labelCodeMap).includes(stringValue);
if (!isAlreadyCode) {
logger.warn(`카테고리 값 매핑 없음: ${columnName} = "${stringValue}" (라벨도 코드도 아님)`);
}
// 변환 없이 원래 값 유지
}
}
}
}
logger.info(`카테고리 라벨→코드 변환 완료`, {
tableName,
conversionCount: conversions.length,
conversions,
});
return { convertedData, conversions };
} catch (error: any) {
logger.error(`카테고리 라벨→코드 변환 실패: ${error.message}`, { error });
// 실패 시 원본 데이터 반환
return { convertedData: data, conversions: [] };
}
}
} }
export default new TableCategoryValueService(); export default new TableCategoryValueService();

View File

@ -567,4 +567,47 @@ select {
scrollbar-width: none; scrollbar-width: none;
} }
/* ===== Marching Ants Animation (Excel Copy Border) ===== */
@keyframes marching-ants-h {
0% {
background-position: 0 0;
}
100% {
background-position: 16px 0;
}
}
@keyframes marching-ants-v {
0% {
background-position: 0 0;
}
100% {
background-position: 0 16px;
}
}
.animate-marching-ants-h {
background: repeating-linear-gradient(
90deg,
hsl(var(--primary)) 0,
hsl(var(--primary)) 4px,
transparent 4px,
transparent 8px
);
background-size: 16px 2px;
animation: marching-ants-h 0.4s linear infinite;
}
.animate-marching-ants-v {
background: repeating-linear-gradient(
180deg,
hsl(var(--primary)) 0,
hsl(var(--primary)) 4px,
transparent 4px,
transparent 8px
);
background-size: 2px 16px;
animation: marching-ants-v 0.4s linear infinite;
}
/* ===== End of Global Styles ===== */ /* ===== End of Global Styles ===== */

File diff suppressed because it is too large Load Diff

View File

@ -18,16 +18,12 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
Upload, Upload,
FileSpreadsheet, FileSpreadsheet,
AlertCircle, AlertCircle,
CheckCircle2, CheckCircle2,
Plus,
Minus,
ArrowRight, ArrowRight,
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
@ -35,6 +31,8 @@ import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport";
import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { getTableSchema, TableColumn } from "@/lib/api/tableSchema"; import { getTableSchema, TableColumn } from "@/lib/api/tableSchema";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping";
import { EditableSpreadsheet } from "./EditableSpreadsheet";
export interface ExcelUploadModalProps { export interface ExcelUploadModalProps {
open: boolean; open: boolean;
@ -62,34 +60,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}) => { }) => {
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
// 1단계: 파일 선택 // 1단계: 파일 선택 & 미리보기
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [sheetNames, setSheetNames] = useState<string[]>([]); const [sheetNames, setSheetNames] = useState<string[]>([]);
const [selectedSheet, setSelectedSheet] = useState<string>(""); const [selectedSheet, setSelectedSheet] = useState<string>("");
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// 2단계: 범위 지정
const [autoCreateColumn, setAutoCreateColumn] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<string>("");
const [selectedDataType, setSelectedDataType] = useState<string>("");
const [detectedRange, setDetectedRange] = useState<string>(""); const [detectedRange, setDetectedRange] = useState<string>("");
const [previewData, setPreviewData] = useState<Record<string, any>[]>([]);
const [allData, setAllData] = useState<Record<string, any>[]>([]); const [allData, setAllData] = useState<Record<string, any>[]>([]);
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]); const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
// 3단계: 컬럼 매핑 // 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
const [excelColumns, setExcelColumns] = useState<string[]>([]); const [excelColumns, setExcelColumns] = useState<string[]>([]);
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]); const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]); const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 4단계: 확인 // 3단계: 확인
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
// 파일 선택 핸들러 // 파일 선택 핸들러
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]; const selectedFile = e.target.files?.[0];
if (!selectedFile) return; if (!selectedFile) return;
await processFile(selectedFile);
};
// 파일 처리 공통 함수 (파일 선택 및 드래그 앤 드롭에서 공유)
const processFile = async (selectedFile: File) => {
const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase(); const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase();
if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) { if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) {
toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)"); toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)");
@ -105,7 +103,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const data = await importFromExcel(selectedFile, sheets[0]); const data = await importFromExcel(selectedFile, sheets[0]);
setAllData(data); setAllData(data);
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능) setDisplayData(data);
if (data.length > 0) { if (data.length > 0) {
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
@ -122,6 +120,30 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
}; };
// 드래그 앤 드롭 핸들러
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const droppedFile = e.dataTransfer.files?.[0];
if (droppedFile) {
await processFile(droppedFile);
}
};
// 시트 변경 핸들러 // 시트 변경 핸들러
const handleSheetChange = async (sheetName: string) => { const handleSheetChange = async (sheetName: string) => {
setSelectedSheet(sheetName); setSelectedSheet(sheetName);
@ -130,7 +152,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
try { try {
const data = await importFromExcel(file, sheetName); const data = await importFromExcel(file, sheetName);
setAllData(data); setAllData(data);
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능) setDisplayData(data);
if (data.length > 0) { if (data.length > 0) {
const columns = Object.keys(data[0]); const columns = Object.keys(data[0]);
@ -144,80 +166,66 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
}; };
// 행 추가 // 테이블 스키마 가져오기 (2단계 진입 시)
const handleAddRow = () => {
const newRow: Record<string, any> = {};
excelColumns.forEach((col) => {
newRow[col] = "";
});
setDisplayData([...displayData, newRow]);
toast.success("행이 추가되었습니다.");
};
// 행 삭제
const handleRemoveRow = () => {
if (displayData.length > 1) {
setDisplayData(displayData.slice(0, -1));
toast.success("마지막 행이 삭제되었습니다.");
} else {
toast.error("최소 1개의 행이 필요합니다.");
}
};
// 열 추가
const handleAddColumn = () => {
const newColName = `Column${excelColumns.length + 1}`;
setExcelColumns([...excelColumns, newColName]);
setDisplayData(
displayData.map((row) => ({
...row,
[newColName]: "",
}))
);
toast.success("열이 추가되었습니다.");
};
// 열 삭제
const handleRemoveColumn = () => {
if (excelColumns.length > 1) {
const lastCol = excelColumns[excelColumns.length - 1];
setExcelColumns(excelColumns.slice(0, -1));
setDisplayData(
displayData.map((row) => {
const { [lastCol]: removed, ...rest } = row;
return rest;
})
);
toast.success("마지막 열이 삭제되었습니다.");
} else {
toast.error("최소 1개의 열이 필요합니다.");
}
};
// 테이블 스키마 가져오기
useEffect(() => { useEffect(() => {
if (currentStep === 3 && tableName) { if (currentStep === 2 && tableName) {
loadTableSchema(); loadTableSchema();
} }
}, [currentStep, tableName]); }, [currentStep, tableName]);
// 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외)
const AUTO_GENERATED_COLUMNS = [
"id",
"created_date",
"updated_date",
"writer",
"company_code",
];
const loadTableSchema = async () => { const loadTableSchema = async () => {
try { try {
console.log("🔍 테이블 스키마 로드 시작:", { tableName }); console.log("🔍 테이블 스키마 로드 시작:", { tableName });
const response = await getTableSchema(tableName);
console.log("📊 테이블 스키마 응답:", response);
if (response.success && response.data) {
console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns);
setSystemColumns(response.data.columns);
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ const response = await getTableSchema(tableName);
excelColumn: col,
systemColumn: null, console.log("📊 테이블 스키마 응답:", response);
}));
setColumnMappings(initialMappings); if (response.success && response.data) {
// 자동 생성 컬럼 제외
const filteredColumns = response.data.columns.filter(
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
);
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
setSystemColumns(filteredColumns);
// 기존 매핑 템플릿 조회
console.log("🔍 매핑 템플릿 조회 중...", { tableName, excelColumns });
const mappingResponse = await findMappingByColumns(tableName, excelColumns);
if (mappingResponse.success && mappingResponse.data) {
// 저장된 매핑 템플릿이 있으면 자동 적용
console.log("✅ 기존 매핑 템플릿 발견:", mappingResponse.data);
const savedMappings = mappingResponse.data.columnMappings;
const appliedMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: savedMappings[col] || null,
}));
setColumnMappings(appliedMappings);
setIsAutoMappingLoaded(true);
const matchedCount = appliedMappings.filter((m) => m.systemColumn).length;
toast.success(`이전 매핑 템플릿이 적용되었습니다. (${matchedCount}개 컬럼)`);
} else {
// 매핑 템플릿이 없으면 초기 상태로 설정
console.log(" 매핑 템플릿 없음 - 새 엑셀 구조");
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
excelColumn: col,
systemColumn: null,
}));
setColumnMappings(initialMappings);
setIsAutoMappingLoaded(false);
}
} else { } else {
console.error("❌ 테이블 스키마 로드 실패:", response); console.error("❌ 테이블 스키마 로드 실패:", response);
} }
@ -231,10 +239,11 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const handleAutoMapping = () => { const handleAutoMapping = () => {
const newMappings = excelColumns.map((excelCol) => { const newMappings = excelColumns.map((excelCol) => {
const normalizedExcelCol = excelCol.toLowerCase().trim(); const normalizedExcelCol = excelCol.toLowerCase().trim();
// 1. 먼저 라벨로 매칭 시도 // 1. 먼저 라벨로 매칭 시도
let matchedSystemCol = systemColumns.find( let matchedSystemCol = systemColumns.find(
(sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol (sysCol) =>
sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol
); );
// 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도 // 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도
@ -259,9 +268,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
const handleMappingChange = (excelColumn: string, systemColumn: string | null) => { const handleMappingChange = (excelColumn: string, systemColumn: string | null) => {
setColumnMappings((prev) => setColumnMappings((prev) =>
prev.map((mapping) => prev.map((mapping) =>
mapping.excelColumn === excelColumn mapping.excelColumn === excelColumn ? { ...mapping, systemColumn } : mapping
? { ...mapping, systemColumn }
: mapping
) )
); );
}; };
@ -273,12 +280,48 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return; return;
} }
if (currentStep === 2 && displayData.length === 0) { if (currentStep === 1 && displayData.length === 0) {
toast.error("데이터가 없습니다."); toast.error("데이터가 없습니다.");
return; return;
} }
setCurrentStep((prev) => Math.min(prev + 1, 4)); // 1단계 → 2단계 전환 시: 빈 헤더 열 제외
if (currentStep === 1) {
// 빈 헤더가 아닌 열만 필터링
const validColumnIndices: number[] = [];
const validColumns: string[] = [];
excelColumns.forEach((col, index) => {
if (col && col.trim() !== "") {
validColumnIndices.push(index);
validColumns.push(col);
}
});
// 빈 헤더 열이 있었다면 데이터에서도 해당 열 제거
if (validColumns.length < excelColumns.length) {
const removedCount = excelColumns.length - validColumns.length;
// 새로운 데이터: 유효한 열만 포함
const cleanedData = displayData.map((row) => {
const newRow: Record<string, any> = {};
validColumns.forEach((colName) => {
newRow[colName] = row[colName];
});
return newRow;
});
setExcelColumns(validColumns);
setDisplayData(cleanedData);
setAllData(cleanedData);
if (removedCount > 0) {
toast.info(`빈 헤더 ${removedCount}개 열이 제외되었습니다.`);
}
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3));
}; };
// 이전 단계 // 이전 단계
@ -296,7 +339,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setIsUploading(true); setIsUploading(true);
try { try {
// allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만) // allData를 사용하여 전체 데이터 업로드
const mappedData = allData.map((row) => { const mappedData = allData.map((row) => {
const mappedRow: Record<string, any> = {}; const mappedRow: Record<string, any> = {};
columnMappings.forEach((mapping) => { columnMappings.forEach((mapping) => {
@ -307,10 +350,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return mappedRow; return mappedRow;
}); });
// 빈 행 필터링: 모든 값이 비어있거나 undefined/null인 행 제외
const filteredData = mappedData.filter((row) => {
const values = Object.values(row);
return values.some((value) => {
if (value === undefined || value === null) return false;
if (typeof value === "string" && value.trim() === "") return false;
return true;
});
});
console.log(
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}`
);
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
for (const row of mappedData) { for (const row of filteredData) {
try { try {
if (uploadMode === "insert") { if (uploadMode === "insert") {
const formData = { screenId: 0, tableName, data: row }; const formData = { screenId: 0, tableName, data: row };
@ -330,6 +387,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
toast.success( toast.success(
`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`
); );
// 매핑 템플릿 저장 (UPSERT - 자동 저장)
try {
const mappingsToSave: Record<string, string | null> = {};
columnMappings.forEach((mapping) => {
mappingsToSave[mapping.excelColumn] = mapping.systemColumn;
});
console.log("💾 매핑 템플릿 저장 중...", {
tableName,
excelColumns,
mappingsToSave,
});
const saveResult = await saveMappingTemplate(
tableName,
excelColumns,
mappingsToSave
);
if (saveResult.success) {
console.log("✅ 매핑 템플릿 저장 완료:", saveResult.data);
} else {
console.warn("⚠️ 매핑 템플릿 저장 실패:", saveResult.error);
}
} catch (error) {
console.warn("⚠️ 매핑 템플릿 저장 중 오류:", error);
}
onSuccess?.(); onSuccess?.();
} else { } else {
toast.error("업로드에 실패했습니다."); toast.error("업로드에 실패했습니다.");
@ -349,11 +434,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
setFile(null); setFile(null);
setSheetNames([]); setSheetNames([]);
setSelectedSheet(""); setSelectedSheet("");
setAutoCreateColumn(false); setIsAutoMappingLoaded(false);
setSelectedCompany("");
setSelectedDataType("");
setDetectedRange(""); setDetectedRange("");
setPreviewData([]);
setAllData([]); setAllData([]);
setDisplayData([]); setDisplayData([]);
setExcelColumns([]); setExcelColumns([]);
@ -381,17 +463,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
. . .
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{/* 스텝 인디케이터 */} {/* 스텝 인디케이터 (3단계) */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{[ {[
{ num: 1, label: "파일 선택" }, { num: 1, label: "파일 선택" },
{ num: 2, label: "범위 지정" }, { num: 2, label: "컬럼 매핑" },
{ num: 3, label: "컬럼 매핑" }, { num: 3, label: "확인" },
{ num: 4, label: "확인" },
].map((step, index) => ( ].map((step, index) => (
<React.Fragment key={step.num}> <React.Fragment key={step.num}>
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
@ -414,15 +495,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<span <span
className={cn( className={cn(
"text-[10px] font-medium sm:text-xs", "text-[10px] font-medium sm:text-xs",
currentStep === step.num currentStep === step.num ? "text-primary" : "text-muted-foreground"
? "text-primary"
: "text-muted-foreground"
)} )}
> >
{step.label} {step.label}
</span> </span>
</div> </div>
{index < 3 && ( {index < 2 && (
<div <div
className={cn( className={cn(
"h-0.5 flex-1 transition-colors", "h-0.5 flex-1 transition-colors",
@ -436,23 +515,61 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
{/* 스텝별 컨텐츠 */} {/* 스텝별 컨텐츠 */}
<div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto"> <div className="max-h-[calc(95vh-200px)] space-y-4 overflow-y-auto">
{/* 1단계: 파일 선택 */} {/* 1단계: 파일 선택 & 미리보기 (통합) */}
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-4"> <div className="space-y-4">
{/* 파일 선택 영역 */}
<div> <div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm"> <Label htmlFor="file-upload" className="text-xs sm:text-sm">
* *
</Label> </Label>
<div className="mt-2 flex items-center gap-2"> <div
<Button onDragOver={handleDragOver}
type="button" onDragLeave={handleDragLeave}
variant="outline" onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm" className={cn(
> "mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
<Upload className="mr-2 h-4 w-4" /> isDragOver
{file ? file.name : "파일 선택"} ? "border-primary bg-primary/5"
</Button> : 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 <input
ref={fileInputRef} ref={fileInputRef}
id="file-upload" id="file-upload"
@ -462,213 +579,71 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
className="hidden" className="hidden"
/> />
</div> </div>
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
형식: .xlsx, .xls, .csv
</p>
</div> </div>
{sheetNames.length > 0 && ( {/* 파일이 선택된 경우에만 미리보기 표시 */}
<div> {file && displayData.length > 0 && (
<Label htmlFor="sheet-select" className="text-xs sm:text-sm"> <>
{/* 시트 선택 */}
</Label> <div className="flex items-center gap-3">
<Select value={selectedSheet} onValueChange={handleSheetChange}> <div className="flex items-center gap-2">
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <Label className="text-xs text-muted-foreground sm:text-sm">
<SelectValue placeholder="시트를 선택하세요" /> :
</SelectTrigger> </Label>
<SelectContent> <Select value={selectedSheet} onValueChange={handleSheetChange}>
{sheetNames.map((sheetName) => ( <SelectTrigger className="h-8 w-[140px] text-xs sm:h-9 sm:w-[180px] sm:text-sm">
<SelectItem <SelectValue placeholder="Sheet1" />
key={sheetName} </SelectTrigger>
value={sheetName} <SelectContent>
className="text-xs sm:text-sm" {sheetNames.map((sheetName) => (
> <SelectItem
<FileSpreadsheet className="mr-2 inline h-4 w-4" /> key={sheetName}
{sheetName} value={sheetName}
</SelectItem> className="text-xs sm:text-sm"
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* 2단계: 범위 지정 */}
{currentStep === 2 && (
<div className="space-y-3">
{/* 상단: 3개 드롭다운 가로 배치 */}
<div className="grid grid-cols-3 gap-3">
<Select value={selectedSheet} onValueChange={handleSheetChange}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="Sheet1" />
</SelectTrigger>
<SelectContent>
{sheetNames.map((sheetName) => (
<SelectItem key={sheetName} value={sheetName} className="text-xs sm:text-sm">
{sheetName}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="company1" className="text-xs sm:text-sm">
ABC
</SelectItem>
<SelectItem value="company2" className="text-xs sm:text-sm">
XYZ
</SelectItem>
<SelectItem value="company3" className="text-xs sm:text-sm">
</SelectItem>
</SelectContent>
</Select>
<Select value={selectedDataType} onValueChange={setSelectedDataType}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="유형 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="type1" className="text-xs sm:text-sm">
1
</SelectItem>
<SelectItem value="type2" className="text-xs sm:text-sm">
2
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 중간: 체크박스 + 버튼들 한 줄 배치 */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="auto-create"
checked={autoCreateColumn}
onCheckedChange={(checked) => setAutoCreateColumn(checked as boolean)}
/>
<label
htmlFor="auto-create"
className="text-xs font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 sm:text-sm"
>
</label>
</div>
<div className="ml-auto flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddRow}
className="h-7 text-xs sm:h-8 sm:text-sm"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddColumn}
className="h-7 text-xs sm:h-8 sm:text-sm"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveRow}
className="h-7 text-xs sm:h-8 sm:text-sm"
>
<Minus className="mr-1 h-3 w-3" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRemoveColumn}
className="h-7 text-xs sm:h-8 sm:text-sm"
>
<Minus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 하단: 감지된 범위 + 테이블 */}
<div className="text-xs text-muted-foreground sm:text-sm">
: <span className="font-medium">{detectedRange}</span>
<span className="ml-2 text-[10px] sm:text-xs">
,
</span>
</div>
{displayData.length > 0 && (
<div className="max-h-[250px] overflow-auto rounded-md border border-border">
<table className="min-w-full text-[10px] sm:text-xs">
<thead className="sticky top-0 bg-muted">
<tr>
<th className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium">
</th>
{excelColumns.map((col, index) => (
<th
key={col}
className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium"
>
{String.fromCharCode(65 + index)}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="bg-primary/5">
<td className="whitespace-nowrap border-b border-r border-border bg-primary/10 px-2 py-1 text-center font-medium">
1
</td>
{excelColumns.map((col) => (
<td
key={col}
className="whitespace-nowrap border-b border-r border-border px-2 py-1 font-medium text-primary"
>
{col}
</td>
))}
</tr>
{displayData.map((row, rowIndex) => (
<tr key={rowIndex} className="border-b border-border last:border-0">
<td className="whitespace-nowrap border-r border-border bg-muted/50 px-2 py-1 text-center font-medium text-muted-foreground">
{rowIndex + 2}
</td>
{excelColumns.map((col) => (
<td
key={col}
className="max-w-[150px] truncate whitespace-nowrap border-r border-border px-2 py-1"
title={String(row[col])}
> >
{String(row[col] || "")} {sheetName}
</td> </SelectItem>
))} ))}
</tr> </SelectContent>
))} </Select>
</tbody> </div>
</table> <span className="text-xs text-muted-foreground">
</div> {displayData.length} · , Tab/Enter로
</span>
</div>
{/* 엑셀처럼 편집 가능한 스프레드시트 */}
<EditableSpreadsheet
columns={excelColumns}
data={displayData}
onColumnsChange={(newColumns) => {
setExcelColumns(newColumns);
// 범위 재계산
const lastCol =
newColumns.length > 0
? String.fromCharCode(64 + newColumns.length)
: "A";
setDetectedRange(`A1:${lastCol}${displayData.length + 1}`);
}}
onDataChange={(newData) => {
setDisplayData(newData);
setAllData(newData);
// 범위 재계산
const lastCol =
excelColumns.length > 0
? String.fromCharCode(64 + excelColumns.length)
: "A";
setDetectedRange(`A1:${lastCol}${newData.length + 1}`);
}}
maxHeight="320px"
/>
</>
)} )}
</div> </div>
)} )}
{/* 3단계: 컬럼 매핑 */} {/* 2단계: 컬럼 매핑 */}
{currentStep === 3 && ( {currentStep === 2 && (
<div className="space-y-4"> <div className="space-y-4">
{/* 상단: 제목 + 자동 매핑 버튼 */} {/* 상단: 제목 + 자동 매핑 버튼 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -693,9 +668,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<div> </div> <div> </div>
</div> </div>
<div className="max-h-[400px] space-y-2 overflow-y-auto"> <div className="max-h-[350px] space-y-2 overflow-y-auto">
{columnMappings.map((mapping, index) => ( {columnMappings.map((mapping, index) => (
<div key={index} className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"> <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"> <div className="rounded-md border border-border bg-muted px-3 py-2 text-xs font-medium sm:text-sm">
{mapping.excelColumn} {mapping.excelColumn}
</div> </div>
@ -713,7 +691,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<SelectValue placeholder="매핑 안함"> <SelectValue placeholder="매핑 안함">
{mapping.systemColumn {mapping.systemColumn
? (() => { ? (() => {
const col = systemColumns.find(c => c.name === mapping.systemColumn); const col = systemColumns.find(
(c) => c.name === mapping.systemColumn
);
return col?.label || mapping.systemColumn; return col?.label || mapping.systemColumn;
})() })()
: "매핑 안함"} : "매핑 안함"}
@ -738,11 +718,40 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
))} ))}
</div> </div>
</div> </div>
{/* 매핑 자동 저장 안내 */}
{isAutoMappingLoaded ? (
<div className="rounded-md border border-success bg-success/10 p-3">
<div className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-4 w-4 text-success" />
<div className="text-[10px] text-success sm:text-xs">
<p className="font-medium"> </p>
<p className="mt-1">
.
.
</p>
</div>
</div>
</div>
) : (
<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"> </p>
<p className="mt-1">
.
.
</p>
</div>
</div>
</div>
)}
</div> </div>
)} )}
{/* 4단계: 확인 */} {/* 3단계: 확인 */}
{currentStep === 4 && ( {currentStep === 3 && (
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-md border border-border bg-muted/50 p-4"> <div className="rounded-md border border-border bg-muted/50 p-4">
<h3 className="text-sm font-medium sm:text-base"> </h3> <h3 className="text-sm font-medium sm:text-base"> </h3>
@ -762,7 +771,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<p> <p>
<span className="font-medium">:</span>{" "} <span className="font-medium">:</span>{" "}
{uploadMode === "insert" {uploadMode === "insert"
? "삽입" ? "신규 등록"
: uploadMode === "update" : uploadMode === "update"
? "업데이트" ? "업데이트"
: "Upsert"} : "Upsert"}
@ -775,12 +784,17 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs"> <div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
{columnMappings {columnMappings
.filter((m) => m.systemColumn) .filter((m) => m.systemColumn)
.map((mapping, index) => ( .map((mapping, index) => {
<p key={index}> const col = systemColumns.find(
<span className="font-medium">{mapping.excelColumn}</span> {" "} (c) => c.name === mapping.systemColumn
{mapping.systemColumn} );
</p> return (
))} <p key={index}>
<span className="font-medium">{mapping.excelColumn}</span> {" "}
{col?.label || mapping.systemColumn}
</p>
);
})}
{columnMappings.filter((m) => m.systemColumn).length === 0 && ( {columnMappings.filter((m) => m.systemColumn).length === 0 && (
<p className="text-destructive"> .</p> <p className="text-destructive"> .</p>
)} )}
@ -793,7 +807,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<div className="text-[10px] text-warning sm:text-xs"> <div className="text-[10px] text-warning sm:text-xs">
<p className="font-medium"></p> <p className="font-medium"></p>
<p className="mt-1"> <p className="mt-1">
. ? .
?
</p> </p>
</div> </div>
</div> </div>
@ -811,10 +826,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
> >
{currentStep === 1 ? "취소" : "이전"} {currentStep === 1 ? "취소" : "이전"}
</Button> </Button>
{currentStep < 4 ? ( {currentStep < 3 ? (
<Button <Button
onClick={handleNext} onClick={handleNext}
disabled={isUploading} disabled={isUploading || (currentStep === 1 && !file)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
@ -822,10 +837,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
) : ( ) : (
<Button <Button
onClick={handleUpload} onClick={handleUpload}
disabled={isUploading || columnMappings.filter((m) => m.systemColumn).length === 0} disabled={
isUploading || columnMappings.filter((m) => m.systemColumn).length === 0
}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
{isUploading ? "업로드 중..." : "다음"} {isUploading ? "업로드 중..." : "업로드"}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>

View File

@ -97,9 +97,13 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
table.onColumnOrderChange(newOrder); table.onColumnOrderChange(newOrder);
} }
// 틀고정 컬럼 수 변경 콜백 호출 // 틀고정 컬럼 수 변경 콜백 호출 (현재 컬럼 상태도 함께 전달)
if (table?.onFrozenColumnCountChange) { if (table?.onFrozenColumnCountChange) {
table.onFrozenColumnCountChange(frozenColumnCount); const updatedColumns = localColumns.map((col) => ({
columnName: col.columnName,
visible: col.visible,
}));
table.onFrozenColumnCountChange(frozenColumnCount, updatedColumns);
} }
onClose(); onClose();

View File

@ -93,10 +93,15 @@ export class DynamicFormApi {
): Promise<ApiResponse<SaveFormDataResponse>> { ): Promise<ApiResponse<SaveFormDataResponse>> {
try { try {
console.log("🔄 폼 데이터 업데이트 요청:", { id, formData }); console.log("🔄 폼 데이터 업데이트 요청:", { id, formData });
console.log("🌐 API URL:", `/dynamic-form/${id}`);
console.log("📦 요청 본문:", JSON.stringify(formData, null, 2));
const response = await apiClient.put(`/dynamic-form/${id}`, formData); const response = await apiClient.put(`/dynamic-form/${id}`, formData);
console.log("✅ 폼 데이터 업데이트 성공:", response.data); console.log("✅ 폼 데이터 업데이트 성공:", response.data);
console.log("📊 응답 상태:", response.status);
console.log("📋 응답 헤더:", response.headers);
return { return {
success: true, success: true,
data: response.data, data: response.data,
@ -104,6 +109,8 @@ export class DynamicFormApi {
}; };
} catch (error: any) { } catch (error: any) {
console.error("❌ 폼 데이터 업데이트 실패:", error); console.error("❌ 폼 데이터 업데이트 실패:", error);
console.error("📊 에러 응답:", error.response?.data);
console.error("📊 에러 상태:", error.response?.status);
const errorMessage = error.response?.data?.message || error.message || "데이터 업데이트 중 오류가 발생했습니다."; const errorMessage = error.response?.data?.message || error.message || "데이터 업데이트 중 오류가 발생했습니다.";

View File

@ -0,0 +1,106 @@
import { apiClient } from "./client";
export interface ExcelMappingTemplate {
id?: number;
tableName: string;
excelColumns: string[];
excelColumnsHash: string;
columnMappings: Record<string, string | null>; // { "엑셀컬럼": "시스템컬럼" }
companyCode: string;
createdDate?: string;
updatedDate?: string;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
/**
* 릿
*
*/
export async function findMappingByColumns(
tableName: string,
excelColumns: string[]
): Promise<ApiResponse<ExcelMappingTemplate | null>> {
try {
const response = await apiClient.post("/excel-mapping/find", {
tableName,
excelColumns,
});
return response.data;
} catch (error: any) {
console.error("매핑 템플릿 조회 실패:", error);
return {
success: false,
error: error.message || "매핑 템플릿 조회 실패",
};
}
}
/**
* 릿 (UPSERT)
* + ,
*/
export async function saveMappingTemplate(
tableName: string,
excelColumns: string[],
columnMappings: Record<string, string | null>
): Promise<ApiResponse<ExcelMappingTemplate>> {
try {
const response = await apiClient.post("/excel-mapping/save", {
tableName,
excelColumns,
columnMappings,
});
return response.data;
} catch (error: any) {
console.error("매핑 템플릿 저장 실패:", error);
return {
success: false,
error: error.message || "매핑 템플릿 저장 실패",
};
}
}
/**
* 릿
*/
export async function getMappingTemplates(
tableName: string
): Promise<ApiResponse<ExcelMappingTemplate[]>> {
try {
const response = await apiClient.get(
`/excel-mapping/list/${encodeURIComponent(tableName)}`
);
return response.data;
} catch (error: any) {
console.error("매핑 템플릿 목록 조회 실패:", error);
return {
success: false,
error: error.message || "매핑 템플릿 목록 조회 실패",
};
}
}
/**
* 릿
*/
export async function deleteMappingTemplate(
id: number
): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/excel-mapping/${id}`);
return response.data;
} catch (error: any) {
console.error("매핑 템플릿 삭제 실패:", error);
return {
success: false,
error: error.message || "매핑 템플릿 삭제 실패",
};
}
}

View File

@ -1039,14 +1039,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정 onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
// 틀고정 컬럼 관련 // 틀고정 컬럼 관련
frozenColumnCount, // 현재 틀고정 컬럼 수 frozenColumnCount, // 현재 틀고정 컬럼 수
onFrozenColumnCountChange: (count: number) => { onFrozenColumnCountChange: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => {
setFrozenColumnCount(count); setFrozenColumnCount(count);
// 체크박스 컬럼은 항상 틀고정에 포함 // 체크박스 컬럼은 항상 틀고정에 포함
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정 // 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
const visibleCols = columnsToRegister // updatedColumns가 전달되면 그것을 사용, 아니면 columnsToRegister 사용
const colsToUse = updatedColumns || columnsToRegister;
const visibleCols = colsToUse
.filter((col) => col.visible !== false) .filter((col) => col.visible !== false)
.map((col) => col.columnName || col.field); .map((col) => col.columnName || (col as any).field);
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)]; const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
setFrozenColumns(newFrozenColumns); setFrozenColumns(newFrozenColumns);
}, },
@ -4754,9 +4756,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
setColumnWidths(newWidths); setColumnWidths(newWidths);
// 틀고정 컬럼 업데이트 // 틀고정 컬럼 업데이트 (보이는 컬럼 기준으로 처음 N개를 틀고정)
const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName); // 기존 frozen 개수를 유지하면서, 숨겨진 컬럼을 제외한 보이는 컬럼 중 처음 N개를 틀고정
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
const visibleCols = config.columns
.filter((col) => col.visible && col.columnName !== "__checkbox__")
.map((col) => col.columnName);
// 현재 설정된 frozen 컬럼 개수 (체크박스 제외)
const currentFrozenCount = config.columns.filter(
(col) => col.frozen && col.columnName !== "__checkbox__"
).length;
// 보이는 컬럼 중 처음 currentFrozenCount개를 틀고정으로 설정
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, currentFrozenCount)];
setFrozenColumns(newFrozenColumns); setFrozenColumns(newFrozenColumns);
setFrozenColumnCount(currentFrozenCount);
// 그리드선 표시 업데이트 // 그리드선 표시 업데이트
setShowGridLines(config.showGridLines); setShowGridLines(config.showGridLines);
@ -5819,13 +5834,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{visibleColumns.map((column, columnIndex) => { {visibleColumns.map((column, columnIndex) => {
const columnWidth = columnWidths[column.columnName]; const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName); const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
// 틀고정된 컬럼의 left 위치 계산 // 숨겨진 컬럼은 제외하고 보이는 틀고정 컬럼만 포함
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
let leftPosition = 0; let leftPosition = 0;
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정 // 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth; leftPosition += frozenColWidth;
@ -6131,13 +6151,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isNumeric = inputType === "number" || inputType === "decimal"; const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName); const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0; let leftPosition = 0;
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정 // 체크박스 컬럼은 48px 고정
const frozenColWidth = const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
@ -6284,7 +6308,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isNumeric = inputType === "number" || inputType === "decimal"; const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName); const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
// 셀 포커스 상태 // 셀 포커스 상태
const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex; const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex;
@ -6298,11 +6327,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 검색 하이라이트 여부 // 🆕 검색 하이라이트 여부
const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0; let leftPosition = 0;
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정 // 체크박스 컬럼은 48px 고정
const frozenColWidth = const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
@ -6462,13 +6490,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const summary = summaryData[column.columnName]; const summary = summaryData[column.columnName];
const columnWidth = columnWidths[column.columnName]; const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName); const isFrozen = frozenColumns.includes(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0; let leftPosition = 0;
if (isFrozen && frozenIndex > 0) { if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) { for (let i = 0; i < frozenIndex; i++) {
const frozenCol = frozenColumns[i]; const frozenCol = visibleFrozenColumns[i];
// 체크박스 컬럼은 48px 고정 // 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth; leftPosition += frozenColWidth;

View File

@ -333,6 +333,14 @@ export function UniversalFormModalComponent({
} }
} }
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
for (const [key, value] of Object.entries(formData)) {
if (key.startsWith("_tableSection_") && Array.isArray(value)) {
event.detail.formData[key] = value;
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
}
}
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용) // 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
if (originalGroupedData.length > 0) { if (originalGroupedData.length > 0) {
event.detail.formData._originalGroupedData = originalGroupedData; event.detail.formData._originalGroupedData = originalGroupedData;
@ -355,15 +363,9 @@ export function UniversalFormModalComponent({
// 테이블 타입 섹션 찾기 // 테이블 타입 섹션 찾기
const tableSection = config.sections.find((s) => s.type === "table"); const tableSection = config.sections.find((s) => s.type === "table");
if (!tableSection) { if (!tableSection) {
// console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
return; return;
} }
// console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
// sectionId: tableSection.id,
// itemCount: _groupedData.length,
// });
// 원본 데이터 저장 (수정/삭제 추적용) // 원본 데이터 저장 (수정/삭제 추적용)
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData))); setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));

View File

@ -724,11 +724,16 @@ export class ButtonActionExecutor {
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0; const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
const isUpdate = hasRealOriginalData && !!primaryKeyValue;
// 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
const isUpdate = (hasRealOriginalData || hasIdInFormData) && !!primaryKeyValue;
console.log("🔍 [handleSave] INSERT/UPDATE 판단:", { console.log("🔍 [handleSave] INSERT/UPDATE 판단:", {
hasOriginalData: !!originalData, hasOriginalData: !!originalData,
hasRealOriginalData, hasRealOriginalData,
hasIdInFormData,
originalDataKeys: originalData ? Object.keys(originalData) : [], originalDataKeys: originalData ? Object.keys(originalData) : [],
primaryKeyValue, primaryKeyValue,
isUpdate, isUpdate,
@ -741,18 +746,18 @@ export class ButtonActionExecutor {
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우) // UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
console.log("🔄 UPDATE 모드로 저장:", { console.log("🔄 UPDATE 모드로 저장:", {
primaryKeyValue, primaryKeyValue,
formData,
originalData,
hasOriginalData: !!originalData, hasOriginalData: !!originalData,
hasIdInFormData,
updateReason: hasRealOriginalData ? "originalData 존재" : "formData.id 존재 (폴백)",
}); });
if (originalData) { if (hasRealOriginalData) {
// 부분 업데이트: 변경된 필드만 업데이트 // 부분 업데이트: 변경된 필드만 업데이트
console.log("📝 부분 업데이트 실행 (변경된 필드만)"); console.log("📝 부분 업데이트 실행 (변경된 필드만)");
saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName); saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName);
} else { } else {
// 전체 업데이트 (기존 방식) // 전체 업데이트 (originalData 없이 id로 UPDATE 판단된 경우)
console.log("📝 전체 업데이트 실행 (모든 필드)"); console.log("📝 전체 업데이트 실행 (originalData 없음 - 폴백 모드)");
saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, { saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, {
tableName, tableName,
data: formData, data: formData,
@ -1862,37 +1867,45 @@ export class ButtonActionExecutor {
const originalItem = originalGroupedData.find((orig) => orig.id === item.id); const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
if (!originalItem) { if (!originalItem) {
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`); // 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도
// 원본이 없으면 신규로 처리 // originalGroupedData 전달이 누락된 경우를 처리
const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`);
Object.keys(rowToSave).forEach((key) => {
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함
// item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록
// 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터)
const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo };
Object.keys(rowToUpdate).forEach((key) => {
if (key.startsWith("_")) { if (key.startsWith("_")) {
delete rowToSave[key]; delete rowToUpdate[key];
} }
}); });
delete rowToSave.id; // id 제거하여 INSERT
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우) console.log("📝 [UPDATE 폴백] 저장할 데이터:", {
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { id: item.id,
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
}
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: saveTableName, tableName: saveTableName,
data: rowToSave, commonFieldsData,
itemFields: Object.keys(item).filter(k => !k.startsWith("_")),
rowToUpdate,
}); });
if (!saveResult.success) { // id를 유지하고 UPDATE 실행
throw new Error(saveResult.message || "품목 저장 실패"); const updateResult = await DynamicFormApi.updateFormData(item.id, {
tableName: saveTableName,
data: rowToUpdate,
});
if (!updateResult.success) {
throw new Error(updateResult.message || "품목 수정 실패");
} }
insertedCount++; updatedCount++;
continue; continue;
} }
// 변경 사항 확인 (공통 필드 포함) // 변경 사항 확인 (공통 필드 포함)
const currentDataWithCommon = { ...commonFieldsData, ...item }; // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀)
const currentDataWithCommon = { ...item, ...commonFieldsData };
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) { if (hasChanges) {
@ -1917,13 +1930,14 @@ export class ButtonActionExecutor {
} }
// 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목) // 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean)); // ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id)); const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean));
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(String(orig.id)));
for (const deletedItem of deletedItems) { for (const deletedItem of deletedItems) {
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`); console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(saveTableName, deletedItem.id); const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName);
if (!deleteResult.success) { if (!deleteResult.success) {
throw new Error(deleteResult.message || "품목 삭제 실패"); throw new Error(deleteResult.message || "품목 삭제 실패");

View File

@ -66,7 +66,7 @@ export interface TableRegistration {
onGroupChange: (groups: string[]) => void; onGroupChange: (groups: string[]) => void;
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경 onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
onFrozenColumnCountChange?: (count: number) => void; // 틀고정 컬럼 수 변경 onFrozenColumnCountChange?: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => void; // 틀고정 컬럼 수 변경
// 현재 설정 값 (읽기 전용) // 현재 설정 값 (읽기 전용)
frozenColumnCount?: number; // 현재 틀고정 컬럼 수 frozenColumnCount?: number; // 현재 틀고정 컬럼 수