diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index e928f96c..80e406b9 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -58,6 +58,7 @@ import riskAlertRoutes from "./routes/riskAlertRoutes"; // 리스크/알림 관 import todoRoutes from "./routes/todoRoutes"; // To-Do 관리 import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리 import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리 +import excelMappingRoutes from "./routes/excelMappingRoutes"; // 엑셀 매핑 템플릿 import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 3D 필드 //import materialRoutes from "./routes/materialRoutes"; // 자재 관리 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/screen-files", screenFileRoutes); app.use("/api/batch-configs", batchRoutes); +app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿 app.use("/api/batch-management", batchManagementRoutes); app.use("/api/batch-execution-logs", batchExecutionLogRoutes); // app.use("/api/db-type-categories", dbTypeCategoryRoutes); // 파일이 존재하지 않음 diff --git a/backend-node/src/controllers/excelMappingController.ts b/backend-node/src/controllers/excelMappingController.ts new file mode 100644 index 00000000..e29d4fe2 --- /dev/null +++ b/backend-node/src/controllers/excelMappingController.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + }); + } +} + diff --git a/backend-node/src/routes/excelMappingRoutes.ts b/backend-node/src/routes/excelMappingRoutes.ts new file mode 100644 index 00000000..cbcecc15 --- /dev/null +++ b/backend-node/src/routes/excelMappingRoutes.ts @@ -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; + diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 68c30252..8337ed74 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1,6 +1,7 @@ import { query, queryOne, transaction, getPool } from "../database/db"; import { EventTriggerService } from "./eventTriggerService"; import { DataflowControlService } from "./dataflowControlService"; +import tableCategoryValueService from "./tableCategoryValueService"; export interface FormDataResult { id: number; @@ -427,6 +428,24 @@ export class DynamicFormService { 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("🔍 테이블 컬럼 정보 조회 중..."); const columnInfo = await this.getTableColumnInfo(tableName); diff --git a/backend-node/src/services/excelMappingService.ts b/backend-node/src/services/excelMappingService.ts new file mode 100644 index 00000000..a63a027b --- /dev/null +++ b/backend-node/src/services/excelMappingService.ts @@ -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; // { "엑셀컬럼": "시스템컬럼" } + 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 { + 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, + companyCode: string, + userId?: string + ): Promise { + 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 { + 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 { + 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(); + diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 1638a417..edeb55b2 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1398,6 +1398,220 @@ class TableCategoryValueService { throw error; } } + + /** + * 테이블의 카테고리 타입 컬럼과 해당 값 매핑 조회 (라벨 → 코드 변환용) + * + * 엑셀 업로드 등에서 라벨 값을 코드 값으로 변환할 때 사용 + * + * @param tableName - 테이블명 + * @param companyCode - 회사 코드 + * @returns { [columnName]: { [label]: code } } 형태의 매핑 객체 + */ + async getCategoryLabelToCodeMapping( + tableName: string, + companyCode: string + ): Promise>> { + 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> = {}; + + 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 = {}; + 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 + ): Promise<{ convertedData: Record; 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(); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b332f5a0..2fbbe7c5 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -567,4 +567,47 @@ select { 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 ===== */ diff --git a/frontend/components/common/EditableSpreadsheet.tsx b/frontend/components/common/EditableSpreadsheet.tsx new file mode 100644 index 00000000..67e7f80b --- /dev/null +++ b/frontend/components/common/EditableSpreadsheet.tsx @@ -0,0 +1,1209 @@ +"use client"; + +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { cn } from "@/lib/utils"; + +interface EditableSpreadsheetProps { + columns: string[]; + data: Record[]; + onColumnsChange: (columns: string[]) => void; + onDataChange: (data: Record[]) => void; + maxHeight?: string; +} + +// 셀 범위 정의 +interface CellRange { + startRow: number; + startCol: number; + endRow: number; + endCol: number; +} + +/** + * 엑셀처럼 편집 가능한 스프레드시트 컴포넌트 + * - 셀 클릭으로 편집 + * - Tab/Enter로 다음 셀 이동 + * - 마지막 행/열에서 자동 추가 + * - 헤더(컬럼명)도 편집 가능 + * - 다중 셀 선택 (드래그) + * - 자동 채우기 (드래그 핸들) - 다중 셀 지원 + */ +export const EditableSpreadsheet: React.FC = ({ + columns, + data, + onColumnsChange, + onDataChange, + maxHeight = "350px", +}) => { + // 현재 편집 중인 셀 (row: -1은 헤더) + const [editingCell, setEditingCell] = useState<{ + row: number; + col: number; + } | null>(null); + const [editValue, setEditValue] = useState(""); + + // 선택 범위 (다중 셀 선택) + const [selection, setSelection] = useState(null); + + // 셀 선택 드래그 중 + const [isDraggingSelection, setIsDraggingSelection] = useState(false); + + // 자동 채우기 드래그 상태 + const [isDraggingFill, setIsDraggingFill] = useState(false); + const [fillPreviewEnd, setFillPreviewEnd] = useState(null); + + // 복사된 범위 (점선 애니메이션 표시용) + const [copiedRange, setCopiedRange] = useState(null); + + // Undo/Redo 히스토리 + interface HistoryState { + columns: string[]; + data: Record[]; + } + const [history, setHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); + const [isUndoRedo, setIsUndoRedo] = useState(false); + + const inputRef = useRef(null); + const tableRef = useRef(null); + + // 히스토리에 현재 상태 저장 + const saveToHistory = useCallback(() => { + if (isUndoRedo) return; + + const newState: HistoryState = { + columns: [...columns], + data: data.map(row => ({ ...row })), + }; + + setHistory(prev => { + // 현재 인덱스 이후의 히스토리는 삭제 (새로운 분기) + const newHistory = prev.slice(0, historyIndex + 1); + newHistory.push(newState); + // 최대 50개까지만 유지 + if (newHistory.length > 50) { + newHistory.shift(); + return newHistory; + } + return newHistory; + }); + setHistoryIndex(prev => Math.min(prev + 1, 49)); + }, [columns, data, historyIndex, isUndoRedo]); + + // 초기 상태 저장 + useEffect(() => { + if (history.length === 0 && (columns.length > 0 || data.length > 0)) { + setHistory([{ columns: [...columns], data: data.map(row => ({ ...row })) }]); + setHistoryIndex(0); + } + }, []); + + // 데이터 변경 시 히스토리 저장 (Undo/Redo가 아닌 경우) + useEffect(() => { + if (!isUndoRedo && historyIndex >= 0) { + const currentState = history[historyIndex]; + if (currentState) { + const columnsChanged = JSON.stringify(columns) !== JSON.stringify(currentState.columns); + const dataChanged = JSON.stringify(data) !== JSON.stringify(currentState.data); + if (columnsChanged || dataChanged) { + saveToHistory(); + } + } + } + setIsUndoRedo(false); + }, [columns, data]); + + // Undo 실행 + const handleUndo = useCallback(() => { + if (historyIndex <= 0) return; + + const prevIndex = historyIndex - 1; + const prevState = history[prevIndex]; + if (prevState) { + setIsUndoRedo(true); + setHistoryIndex(prevIndex); + onColumnsChange([...prevState.columns]); + onDataChange(prevState.data.map(row => ({ ...row }))); + } + }, [history, historyIndex, onColumnsChange, onDataChange]); + + // Redo 실행 + const handleRedo = useCallback(() => { + if (historyIndex >= history.length - 1) return; + + const nextIndex = historyIndex + 1; + const nextState = history[nextIndex]; + if (nextState) { + setIsUndoRedo(true); + setHistoryIndex(nextIndex); + onColumnsChange([...nextState.columns]); + onDataChange(nextState.data.map(row => ({ ...row }))); + } + }, [history, historyIndex, onColumnsChange, onDataChange]); + + // 범위 정규화 (시작이 끝보다 크면 교환) + const normalizeRange = (range: CellRange): CellRange => { + return { + startRow: Math.min(range.startRow, range.endRow), + startCol: Math.min(range.startCol, range.endCol), + endRow: Math.max(range.startRow, range.endRow), + endCol: Math.max(range.startCol, range.endCol), + }; + }; + + // 셀이 선택 범위 내에 있는지 확인 + const isCellInSelection = (row: number, col: number): boolean => { + if (!selection) return false; + const norm = normalizeRange(selection); + return ( + row >= norm.startRow && + row <= norm.endRow && + col >= norm.startCol && + col <= norm.endCol + ); + }; + + // 셀이 선택 범위의 끝(우하단)인지 확인 + const isCellSelectionEnd = (row: number, col: number): boolean => { + if (!selection) return false; + const norm = normalizeRange(selection); + return row === norm.endRow && col === norm.endCol; + }; + + // 셀 선택 시작 (클릭) + const handleCellMouseDown = useCallback((row: number, col: number, e: React.MouseEvent) => { + // 편집 중이면 종료 + if (editingCell) { + setEditingCell(null); + setEditValue(""); + } + + // 새 선택 시작 + setSelection({ + startRow: row, + startCol: col, + endRow: row, + endCol: col, + }); + setIsDraggingSelection(true); + + // 테이블에 포커스 (키보드 이벤트 수신용) + tableRef.current?.focus(); + }, [editingCell]); + + // 셀 선택 드래그 중 + const handleCellMouseEnter = useCallback((row: number, col: number) => { + if (isDraggingSelection && selection) { + setSelection((prev) => prev ? { + ...prev, + endRow: row, + endCol: col, + } : null); + } + }, [isDraggingSelection, selection]); + + // 셀 선택 드래그 종료 + useEffect(() => { + const handleMouseUp = () => { + if (isDraggingSelection) { + setIsDraggingSelection(false); + } + }; + + document.addEventListener("mouseup", handleMouseUp); + return () => document.removeEventListener("mouseup", handleMouseUp); + }, [isDraggingSelection]); + + // 셀 편집 시작 (더블클릭) + const startEditing = useCallback( + (row: number, col: number) => { + setEditingCell({ row, col }); + setSelection({ + startRow: row, + startCol: col, + endRow: row, + endCol: col, + }); + if (row === -1) { + // 헤더 편집 + setEditValue(columns[col] || ""); + } else { + // 데이터 셀 편집 + const colName = columns[col]; + setEditValue(String(data[row]?.[colName] ?? "")); + } + }, + [columns, data] + ); + + // 편집 완료 + const finishEditing = useCallback(() => { + if (!editingCell) return; + + const { row, col } = editingCell; + + if (row === -1) { + // 헤더(컬럼명) 변경 + const newColumns = [...columns]; + const oldColName = newColumns[col]; + const newColName = editValue.trim() || `Column${col + 1}`; + + if (oldColName !== newColName) { + newColumns[col] = newColName; + onColumnsChange(newColumns); + + // 데이터의 키도 함께 변경 + const newData = data.map((rowData) => { + const newRowData: Record = {}; + Object.keys(rowData).forEach((key) => { + if (key === oldColName) { + newRowData[newColName] = rowData[key]; + } else { + newRowData[key] = rowData[key]; + } + }); + return newRowData; + }); + onDataChange(newData); + } + } else { + // 데이터 셀 변경 + const colName = columns[col]; + const newData = [...data]; + if (!newData[row]) { + newData[row] = {}; + } + newData[row] = { ...newData[row], [colName]: editValue }; + onDataChange(newData); + } + + setEditingCell(null); + setEditValue(""); + }, [editingCell, editValue, columns, data, onColumnsChange, onDataChange]); + + // 다음 셀로 이동 + const moveToNextCell = useCallback( + (direction: "right" | "down" | "left" | "up") => { + if (!editingCell) return; + + finishEditing(); + + const { row, col } = editingCell; + let nextRow = row; + let nextCol = col; + + switch (direction) { + case "right": + if (col < columns.length - 1) { + nextCol = col + 1; + } else { + // 마지막 열에서 Tab → 새 열 추가 (빈 헤더로) + const tempColId = `__temp_${Date.now()}`; + const newColumns = [...columns, ""]; + onColumnsChange(newColumns); + + // 모든 행에 새 컬럼 추가 (임시 키 사용) + const newData = data.map((rowData) => ({ + ...rowData, + [tempColId]: "", + })); + onDataChange(newData); + + nextCol = columns.length; + } + break; + + case "down": + if (row === -1) { + nextRow = 0; + } else if (row < data.length - 1) { + nextRow = row + 1; + } else { + // 마지막 행에서 Enter → 새 행 추가 + const newRow: Record = {}; + columns.forEach((c) => { + newRow[c] = ""; + }); + onDataChange([...data, newRow]); + nextRow = data.length; + } + break; + + case "left": + if (col > 0) { + nextCol = col - 1; + } + break; + + case "up": + if (row > -1) { + nextRow = row - 1; + } + break; + } + + // 다음 셀 편집 시작 + setTimeout(() => { + startEditing(nextRow, nextCol); + }, 0); + }, + [editingCell, columns, data, onColumnsChange, onDataChange, finishEditing, startEditing] + ); + + // 키보드 이벤트 처리 + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + switch (e.key) { + case "Tab": + e.preventDefault(); + moveToNextCell(e.shiftKey ? "left" : "right"); + break; + case "Enter": + e.preventDefault(); + moveToNextCell("down"); + break; + case "Escape": + setEditingCell(null); + setEditValue(""); + break; + case "ArrowUp": + if (!e.shiftKey) { + e.preventDefault(); + moveToNextCell("up"); + } + break; + case "ArrowDown": + if (!e.shiftKey) { + e.preventDefault(); + moveToNextCell("down"); + } + break; + case "ArrowLeft": + // 커서가 맨 앞이면 왼쪽 셀로 + if (inputRef.current?.selectionStart === 0) { + e.preventDefault(); + moveToNextCell("left"); + } + break; + case "ArrowRight": + // 커서가 맨 뒤면 오른쪽 셀로 + if (inputRef.current?.selectionStart === editValue.length) { + e.preventDefault(); + moveToNextCell("right"); + } + break; + } + }, + [moveToNextCell, editValue] + ); + + // 편집 모드일 때 input에 포커스 + useEffect(() => { + if (editingCell && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [editingCell]); + + // 외부 클릭 시 편집 종료 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (tableRef.current && !tableRef.current.contains(e.target as Node)) { + finishEditing(); + setSelection(null); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [finishEditing]); + + // ============ 복사/붙여넣기 ============ + + // 셀이 복사 범위 내에 있는지 확인 + const isCellInCopiedRange = (row: number, col: number): boolean => { + if (!copiedRange) return false; + const norm = normalizeRange(copiedRange); + return ( + row >= norm.startRow && + row <= norm.endRow && + col >= norm.startCol && + col <= norm.endCol + ); + }; + + // 복사 범위의 테두리 위치 확인 + const getCopiedBorderPosition = (row: number, col: number): { top: boolean; right: boolean; bottom: boolean; left: boolean } => { + if (!copiedRange) return { top: false, right: false, bottom: false, left: false }; + const norm = normalizeRange(copiedRange); + + if (!isCellInCopiedRange(row, col)) { + return { top: false, right: false, bottom: false, left: false }; + } + + return { + top: row === norm.startRow, + right: col === norm.endCol, + bottom: row === norm.endRow, + left: col === norm.startCol, + }; + }; + + // 선택 범위 복사 (Ctrl+C) + const handleCopy = useCallback(async () => { + if (!selection || editingCell) return; + + const norm = normalizeRange(selection); + const rows: string[] = []; + + for (let r = norm.startRow; r <= norm.endRow; r++) { + const rowValues: string[] = []; + for (let c = norm.startCol; c <= norm.endCol; c++) { + if (r === -1) { + // 헤더 복사 + rowValues.push(columns[c] || ""); + } else { + // 데이터 복사 + const colName = columns[c]; + rowValues.push(String(data[r]?.[colName] ?? "")); + } + } + rows.push(rowValues.join("\t")); + } + + const text = rows.join("\n"); + + try { + await navigator.clipboard.writeText(text); + // 복사 범위 저장 (점선 애니메이션 표시) + setCopiedRange({ ...norm }); + } catch (err) { + console.warn("클립보드 복사 실패:", err); + } + }, [selection, editingCell, columns, data]); + + // 붙여넣기 (Ctrl+V) + const handlePaste = useCallback(async () => { + if (!selection || editingCell) return; + + try { + const text = await navigator.clipboard.readText(); + if (!text) return; + + const norm = normalizeRange(selection); + const pasteRows = text.split(/\r?\n/).map((row) => row.split("\t")); + + // 빈 행 제거 + const filteredRows = pasteRows.filter((row) => row.some((cell) => cell.trim() !== "")); + if (filteredRows.length === 0) return; + + const newData = [...data]; + const newColumns = [...columns]; + let columnsChanged = false; + + for (let ri = 0; ri < filteredRows.length; ri++) { + const pasteRow = filteredRows[ri]; + const targetRow = norm.startRow + ri; + + for (let ci = 0; ci < pasteRow.length; ci++) { + const targetCol = norm.startCol + ci; + const value = pasteRow[ci]; + + if (targetRow === -1) { + // 헤더에 붙여넣기 + if (targetCol < newColumns.length) { + newColumns[targetCol] = value; + columnsChanged = true; + } + } else { + // 데이터에 붙여넣기 + if (targetCol < columns.length) { + // 필요시 행 추가 + while (newData.length <= targetRow) { + const emptyRow: Record = {}; + columns.forEach((c) => { + emptyRow[c] = ""; + }); + newData.push(emptyRow); + } + + const colName = columns[targetCol]; + newData[targetRow] = { + ...newData[targetRow], + [colName]: value, + }; + } + } + } + } + + if (columnsChanged) { + onColumnsChange(newColumns); + } + onDataChange(newData); + + // 붙여넣기 범위로 선택 확장 + setSelection({ + startRow: norm.startRow, + startCol: norm.startCol, + endRow: Math.min(norm.startRow + filteredRows.length - 1, data.length - 1), + endCol: Math.min(norm.startCol + (filteredRows[0]?.length || 1) - 1, columns.length - 1), + }); + + // 붙여넣기 후 복사 범위 초기화 + setCopiedRange(null); + } catch (err) { + console.warn("클립보드 붙여넣기 실패:", err); + } + }, [selection, editingCell, columns, data, onColumnsChange, onDataChange]); + + // Delete 키로 선택 범위 삭제 + const handleDelete = useCallback(() => { + if (!selection || editingCell) return; + + const norm = normalizeRange(selection); + const newData = [...data]; + + for (let r = norm.startRow; r <= norm.endRow; r++) { + if (r >= 0 && r < newData.length) { + for (let c = norm.startCol; c <= norm.endCol; c++) { + if (c < columns.length) { + const colName = columns[c]; + newData[r] = { + ...newData[r], + [colName]: "", + }; + } + } + } + } + + onDataChange(newData); + }, [selection, editingCell, columns, data, onDataChange]); + + // 전역 키보드 이벤트 (복사/붙여넣기/삭제) + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + // 편집 중이면 무시 (input에서 자체 처리) + if (editingCell) return; + + // 선택이 없으면 무시 + if (!selection) return; + + // 다른 입력 필드에 포커스가 있으면 무시 + const activeElement = document.activeElement; + const isInputFocused = activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement instanceof HTMLSelectElement; + + // 테이블 내부의 input이 아닌 다른 input에 포커스가 있으면 무시 + if (isInputFocused && !tableRef.current?.contains(activeElement)) { + return; + } + + if ((e.ctrlKey || e.metaKey) && e.key === "z") { + // Ctrl+Z: Undo + e.preventDefault(); + handleUndo(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "y") { + // Ctrl+Y: Redo + e.preventDefault(); + handleRedo(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "c") { + e.preventDefault(); + handleCopy(); + } else if ((e.ctrlKey || e.metaKey) && e.key === "v") { + e.preventDefault(); + handlePaste(); + } else if (e.key === "Delete" || e.key === "Backspace") { + // 다른 곳에 포커스가 있으면 Delete 무시 + if (isInputFocused) return; + e.preventDefault(); + handleDelete(); + } else if (e.key === "Escape") { + // Esc로 복사 범위 표시 취소 + setCopiedRange(null); + } else if (e.key === "F2") { + // F2로 편집 모드 진입 (기존 값 유지) + const norm = normalizeRange(selection); + if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) { + e.preventDefault(); + const colName = columns[norm.startCol]; + setEditingCell({ row: norm.startRow, col: norm.startCol }); + setEditValue(String(data[norm.startRow]?.[colName] ?? "")); + } + } else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { + // 일반 문자 키 입력 시 편집 모드 진입 (엑셀처럼) + const norm = normalizeRange(selection); + if (norm.startRow >= 0 && norm.startRow === norm.endRow && norm.startCol === norm.endCol) { + // 단일 셀 선택 시에만 + e.preventDefault(); + setEditingCell({ row: norm.startRow, col: norm.startCol }); + setEditValue(e.key); // 입력한 문자로 시작 + } + } + }; + + document.addEventListener("keydown", handleGlobalKeyDown); + return () => document.removeEventListener("keydown", handleGlobalKeyDown); + }, [editingCell, selection, handleCopy, handlePaste, handleDelete, handleUndo, handleRedo]); + + // 행 삭제 + const handleDeleteRow = (rowIndex: number) => { + const newData = data.filter((_, i) => i !== rowIndex); + onDataChange(newData); + }; + + // 열 삭제 + const handleDeleteColumn = (colIndex: number) => { + if (columns.length <= 1) return; + + const colName = columns[colIndex]; + const newColumns = columns.filter((_, i) => i !== colIndex); + onColumnsChange(newColumns); + + const newData = data.map((row) => { + const { [colName]: removed, ...rest } = row; + return rest; + }); + onDataChange(newData); + }; + + // 컬럼 문자 (A, B, C, ...) + const getColumnLetter = (index: number): string => { + let letter = ""; + let i = index; + while (i >= 0) { + letter = String.fromCharCode(65 + (i % 26)) + letter; + i = Math.floor(i / 26) - 1; + } + return letter; + }; + + // ============ 자동 채우기 로직 ============ + + // 값에서 마지막 숫자 패턴 추출 + const extractNumberPattern = (value: string): { + prefix: string; + number: number; + suffix: string; + numLength: number; + isZeroPadded: boolean; + } | null => { + if (/^-?\d+(\.\d+)?$/.test(value)) { + const isZeroPadded = value.startsWith("0") && value.length > 1 && !value.includes("."); + return { + prefix: "", + number: parseFloat(value), + suffix: "", + numLength: value.replace("-", "").split(".")[0].length, + isZeroPadded + }; + } + + const match = value.match(/^(.*)(\d+)(\D*)$/); + if (match) { + const numStr = match[2]; + const isZeroPadded = numStr.startsWith("0") && numStr.length > 1; + return { + prefix: match[1], + number: parseInt(numStr, 10), + suffix: match[3], + numLength: numStr.length, + isZeroPadded + }; + } + + return null; + }; + + // 날짜 패턴 인식 + const extractDatePattern = (value: string): Date | null => { + const dateMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (dateMatch) { + const date = new Date(parseInt(dateMatch[1]), parseInt(dateMatch[2]) - 1, parseInt(dateMatch[3])); + if (!isNaN(date.getTime())) { + return date; + } + } + return null; + }; + + // 다음 값 생성 + const generateNextValue = (sourceValue: string, step: number): string => { + if (!sourceValue || sourceValue.trim() === "") { + return ""; + } + + const datePattern = extractDatePattern(sourceValue); + if (datePattern) { + const newDate = new Date(datePattern); + newDate.setDate(newDate.getDate() + step); + const year = newDate.getFullYear(); + const month = String(newDate.getMonth() + 1).padStart(2, "0"); + const day = String(newDate.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } + + const numberPattern = extractNumberPattern(sourceValue); + if (numberPattern) { + const newNumber = numberPattern.number + step; + const absNumber = Math.max(0, newNumber); + + let numStr: string; + if (numberPattern.isZeroPadded) { + numStr = String(absNumber).padStart(numberPattern.numLength, "0"); + } else { + numStr = String(absNumber); + } + + return numberPattern.prefix + numStr + numberPattern.suffix; + } + + return sourceValue; + }; + + // 자동 채우기 드래그 시작 + const handleFillDragStart = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // 편집 중이면 먼저 현재 편집 값을 저장 + if (editingCell) { + const { row, col } = editingCell; + if (row === -1) { + // 헤더 변경 + const newColumns = [...columns]; + const oldColName = newColumns[col]; + const newColName = editValue.trim() || `Column${col + 1}`; + if (oldColName !== newColName) { + newColumns[col] = newColName; + onColumnsChange(newColumns); + } + } else { + // 데이터 셀 변경 + const colName = columns[col]; + const newData = [...data]; + if (!newData[row]) { + newData[row] = {}; + } + newData[row] = { ...newData[row], [colName]: editValue }; + onDataChange(newData); + } + setEditingCell(null); + setEditValue(""); + } + + if (!selection) return; + const norm = normalizeRange(selection); + if (norm.startRow < 0) return; // 헤더는 제외 + + setIsDraggingFill(true); + setFillPreviewEnd(norm.endRow); + }; + + // 자동 채우기 드래그 중 + const handleFillDragMove = useCallback((e: MouseEvent) => { + if (!isDraggingFill || !selection || !tableRef.current) return; + + const rows = tableRef.current.querySelectorAll("tbody tr"); + const mouseY = e.clientY; + + for (let i = 0; i < rows.length - 1; i++) { + const row = rows[i] as HTMLElement; + const rect = row.getBoundingClientRect(); + + if (mouseY >= rect.top && mouseY <= rect.bottom) { + setFillPreviewEnd(i); + break; + } else if (mouseY > rect.bottom && i === rows.length - 2) { + setFillPreviewEnd(i); + } + } + }, [isDraggingFill, selection]); + + // 열의 숫자 패턴 간격 계산 (예: 201, 202 → 간격 1) + const calculateColumnIncrement = (colIndex: number, startRow: number, endRow: number): number | null => { + if (startRow === endRow) return 1; // 단일 행이면 기본 증가 1 + + const colName = columns[colIndex]; + const increments: number[] = []; + + for (let row = startRow; row < endRow; row++) { + const currentValue = String(data[row]?.[colName] ?? ""); + const nextValue = String(data[row + 1]?.[colName] ?? ""); + + const currentPattern = extractNumberPattern(currentValue); + const nextPattern = extractNumberPattern(nextValue); + + if (currentPattern && nextPattern) { + // 접두사와 접미사가 같은지 확인 + if (currentPattern.prefix === nextPattern.prefix && currentPattern.suffix === nextPattern.suffix) { + increments.push(nextPattern.number - currentPattern.number); + } else { + return null; // 패턴이 다르면 복사 모드 + } + } else { + return null; // 숫자 패턴이 없으면 복사 모드 + } + } + + // 모든 간격이 같은지 확인 + if (increments.length > 0 && increments.every(inc => inc === increments[0])) { + return increments[0]; + } + + return null; + }; + + // 자동 채우기 드래그 종료 (다중 셀 지원) + // - 숫자 패턴이 있으면: 패턴 간격을 인식하여 증가 (201, 202 → 203, 204) + // - 숫자 패턴이 없으면: 선택된 패턴 그대로 반복 (복사) + const handleFillDragEnd = useCallback(() => { + if (!isDraggingFill || !selection || fillPreviewEnd === null) { + setIsDraggingFill(false); + setFillPreviewEnd(null); + return; + } + + const norm = normalizeRange(selection); + const endRow = fillPreviewEnd; + const selectionHeight = norm.endRow - norm.startRow + 1; + + if (endRow !== norm.endRow && norm.startRow >= 0) { + const newData = [...data]; + + // 각 열별로 증가 패턴 계산 + const columnIncrements: Map = new Map(); + for (let col = norm.startCol; col <= norm.endCol; col++) { + columnIncrements.set(col, calculateColumnIncrement(col, norm.startRow, norm.endRow)); + } + + if (endRow > norm.endRow) { + // 아래로 채우기 + for (let targetRow = norm.endRow + 1; targetRow <= endRow; targetRow++) { + if (!newData[targetRow]) { + newData[targetRow] = {}; + columns.forEach((c) => { + newData[targetRow][c] = ""; + }); + } + + // 선택된 모든 열에 대해 채우기 + for (let col = norm.startCol; col <= norm.endCol; col++) { + const colName = columns[col]; + const increment = columnIncrements.get(col); + + if (increment !== null) { + // 숫자 패턴 증가 모드 + // 마지막 선택 행의 값을 기준으로 증가 + const lastValue = String(data[norm.endRow]?.[colName] ?? ""); + const step = (targetRow - norm.endRow) * increment; + newData[targetRow] = { + ...newData[targetRow], + [colName]: generateNextValue(lastValue, step), + }; + } else { + // 복사 모드 (패턴 반복) + const sourceRowOffset = (targetRow - norm.endRow - 1) % selectionHeight; + const sourceRow = norm.startRow + sourceRowOffset; + const sourceValue = String(data[sourceRow]?.[colName] ?? ""); + newData[targetRow] = { + ...newData[targetRow], + [colName]: sourceValue, + }; + } + } + } + } else if (endRow < norm.startRow) { + // 위로 채우기 + for (let targetRow = norm.startRow - 1; targetRow >= endRow; targetRow--) { + if (!newData[targetRow]) { + newData[targetRow] = {}; + columns.forEach((c) => { + newData[targetRow][c] = ""; + }); + } + + for (let col = norm.startCol; col <= norm.endCol; col++) { + const colName = columns[col]; + const increment = columnIncrements.get(col); + + if (increment !== null) { + // 숫자 패턴 감소 모드 + const firstValue = String(data[norm.startRow]?.[colName] ?? ""); + const step = (targetRow - norm.startRow) * increment; + newData[targetRow] = { + ...newData[targetRow], + [colName]: generateNextValue(firstValue, step), + }; + } else { + // 복사 모드 (패턴 반복) + const sourceRowOffset = (norm.startRow - targetRow - 1) % selectionHeight; + const sourceRow = norm.endRow - sourceRowOffset; + const sourceValue = String(data[sourceRow]?.[colName] ?? ""); + newData[targetRow] = { + ...newData[targetRow], + [colName]: sourceValue, + }; + } + } + } + } + + onDataChange(newData); + } + + setIsDraggingFill(false); + setFillPreviewEnd(null); + }, [isDraggingFill, selection, fillPreviewEnd, columns, data, onDataChange]); + + // 드래그 이벤트 리스너 + useEffect(() => { + if (isDraggingFill) { + document.addEventListener("mousemove", handleFillDragMove); + document.addEventListener("mouseup", handleFillDragEnd); + return () => { + document.removeEventListener("mousemove", handleFillDragMove); + document.removeEventListener("mouseup", handleFillDragEnd); + }; + } + }, [isDraggingFill, handleFillDragMove, handleFillDragEnd]); + + // 셀이 자동 채우기 미리보기 범위에 있는지 확인 + const isInFillPreview = (rowIndex: number, colIndex: number): boolean => { + if (!isDraggingFill || !selection || fillPreviewEnd === null) return false; + + const norm = normalizeRange(selection); + + // 열이 선택 범위 내에 있어야 함 + if (colIndex < norm.startCol || colIndex > norm.endCol) return false; + + if (fillPreviewEnd > norm.endRow) { + return rowIndex > norm.endRow && rowIndex <= fillPreviewEnd; + } else if (fillPreviewEnd < norm.startRow) { + return rowIndex >= fillPreviewEnd && rowIndex < norm.startRow; + } + + return false; + }; + + return ( +
+ + {/* 열 인덱스 헤더 (A, B, C, ...) */} + + + {/* 빈 코너 셀 */} + + {columns.map((_, colIndex) => ( + + ))} + {/* 새 열 추가 버튼 */} + + + + {/* 컬럼명 헤더 (편집 가능) */} + + + {columns.map((colName, colIndex) => ( + + ))} + + + + + + {data.map((row, rowIndex) => ( + + {/* 행 번호 */} + + + {/* 데이터 셀 */} + {columns.map((colName, colIndex) => { + const isSelected = isCellInSelection(rowIndex, colIndex); + const isEditing = editingCell?.row === rowIndex && editingCell?.col === colIndex; + const inFillPreview = isInFillPreview(rowIndex, colIndex); + const isSelectionEnd = isCellSelectionEnd(rowIndex, colIndex); + const copiedBorder = getCopiedBorderPosition(rowIndex, colIndex); + const isCopied = isCellInCopiedRange(rowIndex, colIndex); + + return ( + + ); + })} + + + ))} + + {/* 새 행 추가 영역 */} + + + + + +
+ + +
+ {getColumnLetter(colIndex)} + {columns.length > 1 && ( + + )} +
+
+ +
+ 1 + handleCellMouseDown(-1, colIndex, e)} + onMouseEnter={() => handleCellMouseEnter(-1, colIndex)} + onDoubleClick={() => startEditing(-1, colIndex)} + > + {editingCell?.row === -1 && editingCell?.col === colIndex ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={finishEditing} + className="w-full bg-white px-2 py-1 text-xs font-medium text-primary outline-none" + /> + ) : ( +
{colName || 빈 헤더}
+ )} +
+
+ {rowIndex + 2} + +
+
handleCellMouseDown(rowIndex, colIndex, e)} + onMouseEnter={() => handleCellMouseEnter(rowIndex, colIndex)} + onDoubleClick={() => startEditing(rowIndex, colIndex)} + > + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={finishEditing} + className="w-full bg-white px-2 py-1 text-xs outline-none" + /> + ) : ( +
+ {String(row[colName] ?? "")} +
+ )} + + {/* 복사 범위 점선 테두리 (Marching Ants) */} + {isCopied && ( + <> + {copiedBorder.top && ( +
+ )} + {copiedBorder.right && ( +
+ )} + {copiedBorder.bottom && ( +
+ )} + {copiedBorder.left && ( +
+ )} + + )} + + {/* 자동 채우기 핸들 - 선택 범위의 우하단에서만 표시 (편집 중에도 표시) */} + {isSelectionEnd && selection && normalizeRange(selection).startRow >= 0 && ( +
+ )} +
+ + { + const newRow: Record = {}; + columns.forEach((c) => { + newRow[c] = ""; + }); + onDataChange([...data, newRow]); + setTimeout(() => { + startEditing(data.length, 0); + }, 0); + }} + > + 클릭하여 새 행 추가... +
+
+ ); +}; diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index a4a17274..867b6f85 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -18,16 +18,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; import { toast } from "sonner"; import { Upload, FileSpreadsheet, AlertCircle, CheckCircle2, - Plus, - Minus, ArrowRight, Zap, } from "lucide-react"; @@ -35,6 +31,8 @@ import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { getTableSchema, TableColumn } from "@/lib/api/tableSchema"; import { cn } from "@/lib/utils"; +import { findMappingByColumns, saveMappingTemplate } from "@/lib/api/excelMapping"; +import { EditableSpreadsheet } from "./EditableSpreadsheet"; export interface ExcelUploadModalProps { open: boolean; @@ -62,34 +60,34 @@ export const ExcelUploadModal: React.FC = ({ }) => { const [currentStep, setCurrentStep] = useState(1); - // 1단계: 파일 선택 + // 1단계: 파일 선택 & 미리보기 const [file, setFile] = useState(null); const [sheetNames, setSheetNames] = useState([]); const [selectedSheet, setSelectedSheet] = useState(""); + const [isDragOver, setIsDragOver] = useState(false); const fileInputRef = useRef(null); - - // 2단계: 범위 지정 - const [autoCreateColumn, setAutoCreateColumn] = useState(false); - const [selectedCompany, setSelectedCompany] = useState(""); - const [selectedDataType, setSelectedDataType] = useState(""); const [detectedRange, setDetectedRange] = useState(""); - const [previewData, setPreviewData] = useState[]>([]); const [allData, setAllData] = useState[]>([]); const [displayData, setDisplayData] = useState[]>([]); - // 3단계: 컬럼 매핑 + // 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용 + const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false); const [excelColumns, setExcelColumns] = useState([]); const [systemColumns, setSystemColumns] = useState([]); const [columnMappings, setColumnMappings] = useState([]); - // 4단계: 확인 + // 3단계: 확인 const [isUploading, setIsUploading] = useState(false); // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; if (!selectedFile) return; + await processFile(selectedFile); + }; + // 파일 처리 공통 함수 (파일 선택 및 드래그 앤 드롭에서 공유) + const processFile = async (selectedFile: File) => { const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase(); if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) { toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)"); @@ -105,7 +103,7 @@ export const ExcelUploadModal: React.FC = ({ const data = await importFromExcel(selectedFile, sheets[0]); setAllData(data); - setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능) + setDisplayData(data); if (data.length > 0) { const columns = Object.keys(data[0]); @@ -122,6 +120,30 @@ export const ExcelUploadModal: React.FC = ({ } }; + // 드래그 앤 드롭 핸들러 + 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); @@ -130,7 +152,7 @@ export const ExcelUploadModal: React.FC = ({ try { const data = await importFromExcel(file, sheetName); setAllData(data); - setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능) + setDisplayData(data); if (data.length > 0) { const columns = Object.keys(data[0]); @@ -144,80 +166,66 @@ export const ExcelUploadModal: React.FC = ({ } }; - // 행 추가 - const handleAddRow = () => { - const newRow: Record = {}; - 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개의 열이 필요합니다."); - } - }; - - // 테이블 스키마 가져오기 + // 테이블 스키마 가져오기 (2단계 진입 시) useEffect(() => { - if (currentStep === 3 && tableName) { + if (currentStep === 2 && tableName) { loadTableSchema(); } }, [currentStep, tableName]); + // 테이블 생성 시 자동 생성되는 시스템 컬럼 (매핑에서 제외) + const AUTO_GENERATED_COLUMNS = [ + "id", + "created_date", + "updated_date", + "writer", + "company_code", + ]; + const loadTableSchema = async () => { try { 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) => ({ - excelColumn: col, - systemColumn: null, - })); - setColumnMappings(initialMappings); + const response = await getTableSchema(tableName); + + console.log("📊 테이블 스키마 응답:", response); + + 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 { console.error("❌ 테이블 스키마 로드 실패:", response); } @@ -231,10 +239,11 @@ export const ExcelUploadModal: React.FC = ({ const handleAutoMapping = () => { const newMappings = excelColumns.map((excelCol) => { const normalizedExcelCol = excelCol.toLowerCase().trim(); - + // 1. 먼저 라벨로 매칭 시도 let matchedSystemCol = systemColumns.find( - (sysCol) => sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol + (sysCol) => + sysCol.label && sysCol.label.toLowerCase().trim() === normalizedExcelCol ); // 2. 라벨로 매칭되지 않으면 컬럼명으로 매칭 시도 @@ -259,9 +268,7 @@ export const ExcelUploadModal: React.FC = ({ const handleMappingChange = (excelColumn: string, systemColumn: string | null) => { setColumnMappings((prev) => prev.map((mapping) => - mapping.excelColumn === excelColumn - ? { ...mapping, systemColumn } - : mapping + mapping.excelColumn === excelColumn ? { ...mapping, systemColumn } : mapping ) ); }; @@ -273,12 +280,48 @@ export const ExcelUploadModal: React.FC = ({ return; } - if (currentStep === 2 && displayData.length === 0) { + if (currentStep === 1 && displayData.length === 0) { toast.error("데이터가 없습니다."); 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 = {}; + 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 = ({ setIsUploading(true); try { - // allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만) + // allData를 사용하여 전체 데이터 업로드 const mappedData = allData.map((row) => { const mappedRow: Record = {}; columnMappings.forEach((mapping) => { @@ -307,10 +350,24 @@ export const ExcelUploadModal: React.FC = ({ 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 failCount = 0; - for (const row of mappedData) { + for (const row of filteredData) { try { if (uploadMode === "insert") { const formData = { screenId: 0, tableName, data: row }; @@ -330,6 +387,34 @@ export const ExcelUploadModal: React.FC = ({ toast.success( `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` ); + + // 매핑 템플릿 저장 (UPSERT - 자동 저장) + try { + const mappingsToSave: Record = {}; + 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?.(); } else { toast.error("업로드에 실패했습니다."); @@ -349,11 +434,8 @@ export const ExcelUploadModal: React.FC = ({ setFile(null); setSheetNames([]); setSelectedSheet(""); - setAutoCreateColumn(false); - setSelectedCompany(""); - setSelectedDataType(""); + setIsAutoMappingLoaded(false); setDetectedRange(""); - setPreviewData([]); setAllData([]); setDisplayData([]); setExcelColumns([]); @@ -381,17 +463,16 @@ export const ExcelUploadModal: React.FC = ({ 엑셀 데이터 업로드 - 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다. + 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. - {/* 스텝 인디케이터 */} + {/* 스텝 인디케이터 (3단계) */}
{[ { num: 1, label: "파일 선택" }, - { num: 2, label: "범위 지정" }, - { num: 3, label: "컬럼 매핑" }, - { num: 4, label: "확인" }, + { num: 2, label: "컬럼 매핑" }, + { num: 3, label: "확인" }, ].map((step, index) => (
@@ -414,15 +495,13 @@ export const ExcelUploadModal: React.FC = ({ {step.label}
- {index < 3 && ( + {index < 2 && (
= ({ {/* 스텝별 컨텐츠 */}
- {/* 1단계: 파일 선택 */} + {/* 1단계: 파일 선택 & 미리보기 (통합) */} {currentStep === 1 && (
+ {/* 파일 선택 영역 */}
-
- +
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 ? ( +
+ +
+

{file.name}

+

+ 클릭하여 다른 파일 선택 +

+
+
+ ) : ( + <> + +

+ {isDragOver + ? "파일을 놓으세요" + : "파일을 드래그하거나 클릭하여 선택"} +

+

+ 지원 형식: .xlsx, .xls, .csv +

+ + )} = ({ className="hidden" />
-

- 지원 형식: .xlsx, .xls, .csv -

- {sheetNames.length > 0 && ( -
- - -
- )} -
- )} - - {/* 2단계: 범위 지정 */} - {currentStep === 2 && ( -
- {/* 상단: 3개 드롭다운 가로 배치 */} -
- - - - - -
- - {/* 중간: 체크박스 + 버튼들 한 줄 배치 */} -
-
- setAutoCreateColumn(checked as boolean)} - /> - -
- -
- - - - -
-
- - {/* 하단: 감지된 범위 + 테이블 */} -
- 감지된 범위: {detectedRange} - - 첫 행이 컬럼명, 데이터는 자동 감지됩니다 - -
- - {displayData.length > 0 && ( -
- - - - - {excelColumns.map((col, index) => ( - - ))} - - - - - - {excelColumns.map((col) => ( - - ))} - - {displayData.map((row, rowIndex) => ( - - - {excelColumns.map((col) => ( - + {sheetName} + ))} - - ))} - -
- - - {String.fromCharCode(65 + index)} -
- 1 - - {col} -
- {rowIndex + 2} - 0 && ( + <> + {/* 시트 선택 */} +
+
+ +
-
+ + +
+ + {displayData.length}개 행 · 셀을 클릭하여 편집, Tab/Enter로 이동 + +
+ + {/* 엑셀처럼 편집 가능한 스프레드시트 */} + { + 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" + /> + )}
)} - {/* 3단계: 컬럼 매핑 */} - {currentStep === 3 && ( + {/* 2단계: 컬럼 매핑 */} + {currentStep === 2 && (
{/* 상단: 제목 + 자동 매핑 버튼 */}
@@ -693,9 +668,12 @@ export const ExcelUploadModal: React.FC = ({
시스템 컬럼
-
+
{columnMappings.map((mapping, index) => ( -
+
{mapping.excelColumn}
@@ -713,7 +691,9 @@ export const ExcelUploadModal: React.FC = ({ {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; })() : "매핑 안함"} @@ -738,11 +718,40 @@ export const ExcelUploadModal: React.FC = ({ ))}
+ + {/* 매핑 자동 저장 안내 */} + {isAutoMappingLoaded ? ( +
+
+ +
+

이전 매핑이 자동 적용됨

+

+ 동일한 엑셀 구조가 감지되어 이전에 저장된 매핑이 적용되었습니다. + 수정하면 업로드 시 자동 저장됩니다. +

+
+
+
+ ) : ( +
+
+ +
+

새로운 엑셀 구조

+

+ 이 엑셀 구조는 처음입니다. 매핑을 설정하면 다음에 같은 구조의 + 엑셀에 자동 적용됩니다. +

+
+
+
+ )}
)} - {/* 4단계: 확인 */} - {currentStep === 4 && ( + {/* 3단계: 확인 */} + {currentStep === 3 && (

업로드 요약

@@ -762,7 +771,7 @@ export const ExcelUploadModal: React.FC = ({

모드:{" "} {uploadMode === "insert" - ? "삽입" + ? "신규 등록" : uploadMode === "update" ? "업데이트" : "Upsert"} @@ -775,12 +784,17 @@ export const ExcelUploadModal: React.FC = ({

{columnMappings .filter((m) => m.systemColumn) - .map((mapping, index) => ( -

- {mapping.excelColumn} →{" "} - {mapping.systemColumn} -

- ))} + .map((mapping, index) => { + const col = systemColumns.find( + (c) => c.name === mapping.systemColumn + ); + return ( +

+ {mapping.excelColumn} →{" "} + {col?.label || mapping.systemColumn} +

+ ); + })} {columnMappings.filter((m) => m.systemColumn).length === 0 && (

매핑된 컬럼이 없습니다.

)} @@ -793,7 +807,8 @@ export const ExcelUploadModal: React.FC = ({

주의사항

- 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. 계속하시겠습니까? + 업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. + 계속하시겠습니까?

@@ -811,10 +826,10 @@ export const ExcelUploadModal: React.FC = ({ > {currentStep === 1 ? "취소" : "이전"} - {currentStep < 4 ? ( + {currentStep < 3 ? ( )} diff --git a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx index 64acd942..67c11171 100644 --- a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx +++ b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx @@ -97,9 +97,13 @@ export const ColumnVisibilityPanel: React.FC = ({ table.onColumnOrderChange(newOrder); } - // 틀고정 컬럼 수 변경 콜백 호출 + // 틀고정 컬럼 수 변경 콜백 호출 (현재 컬럼 상태도 함께 전달) if (table?.onFrozenColumnCountChange) { - table.onFrozenColumnCountChange(frozenColumnCount); + const updatedColumns = localColumns.map((col) => ({ + columnName: col.columnName, + visible: col.visible, + })); + table.onFrozenColumnCountChange(frozenColumnCount, updatedColumns); } onClose(); diff --git a/frontend/lib/api/excelMapping.ts b/frontend/lib/api/excelMapping.ts new file mode 100644 index 00000000..50b046ed --- /dev/null +++ b/frontend/lib/api/excelMapping.ts @@ -0,0 +1,106 @@ +import { apiClient } from "./client"; + +export interface ExcelMappingTemplate { + id?: number; + tableName: string; + excelColumns: string[]; + excelColumnsHash: string; + columnMappings: Record; // { "엑셀컬럼": "시스템컬럼" } + companyCode: string; + createdDate?: string; + updatedDate?: string; +} + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +/** + * 엑셀 컬럼 구조로 매핑 템플릿 조회 + * 동일한 엑셀 컬럼 구조가 있으면 기존 매핑 반환 + */ +export async function findMappingByColumns( + tableName: string, + excelColumns: string[] +): Promise> { + 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 +): Promise> { + 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> { + 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> { + try { + const response = await apiClient.delete(`/excel-mapping/${id}`); + return response.data; + } catch (error: any) { + console.error("매핑 템플릿 삭제 실패:", error); + return { + success: false, + error: error.message || "매핑 템플릿 삭제 실패", + }; + } +} + diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 74cea859..09422164 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1039,14 +1039,16 @@ export const TableListComponent: React.FC = ({ onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정 // 틀고정 컬럼 관련 frozenColumnCount, // 현재 틀고정 컬럼 수 - onFrozenColumnCountChange: (count: number) => { + onFrozenColumnCountChange: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => { setFrozenColumnCount(count); // 체크박스 컬럼은 항상 틀고정에 포함 const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; // 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정 - const visibleCols = columnsToRegister + // updatedColumns가 전달되면 그것을 사용, 아니면 columnsToRegister 사용 + const colsToUse = updatedColumns || columnsToRegister; + const visibleCols = colsToUse .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)]; setFrozenColumns(newFrozenColumns); }, @@ -4754,9 +4756,22 @@ export const TableListComponent: React.FC = ({ }); setColumnWidths(newWidths); - // 틀고정 컬럼 업데이트 - const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName); + // 틀고정 컬럼 업데이트 (보이는 컬럼 기준으로 처음 N개를 틀고정) + // 기존 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); + setFrozenColumnCount(currentFrozenCount); // 그리드선 표시 업데이트 setShowGridLines(config.showGridLines); @@ -5819,13 +5834,18 @@ export const TableListComponent: React.FC = ({ {visibleColumns.map((column, columnIndex) => { const columnWidth = columnWidths[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; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; + const frozenCol = visibleFrozenColumns[i]; // 체크박스 컬럼은 48px 고정 const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; @@ -6131,13 +6151,17 @@ export const TableListComponent: React.FC = ({ const isNumeric = inputType === "number" || inputType === "decimal"; 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; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; + const frozenCol = visibleFrozenColumns[i]; // 체크박스 컬럼은 48px 고정 const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; @@ -6284,7 +6308,12 @@ export const TableListComponent: React.FC = ({ const isNumeric = inputType === "number" || inputType === "decimal"; 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; @@ -6298,11 +6327,10 @@ export const TableListComponent: React.FC = ({ // 🆕 검색 하이라이트 여부 const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`); - // 틀고정된 컬럼의 left 위치 계산 let leftPosition = 0; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; + const frozenCol = visibleFrozenColumns[i]; // 체크박스 컬럼은 48px 고정 const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; @@ -6462,13 +6490,17 @@ export const TableListComponent: React.FC = ({ const summary = summaryData[column.columnName]; const columnWidth = columnWidths[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; if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { - const frozenCol = frozenColumns[i]; + const frozenCol = visibleFrozenColumns[i]; // 체크박스 컬럼은 48px 고정 const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150; leftPosition += frozenColWidth; diff --git a/frontend/types/table-options.ts b/frontend/types/table-options.ts index bfcfccbc..6f9b2644 100644 --- a/frontend/types/table-options.ts +++ b/frontend/types/table-options.ts @@ -66,7 +66,7 @@ export interface TableRegistration { onGroupChange: (groups: string[]) => void; onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경 - onFrozenColumnCountChange?: (count: number) => void; // 틀고정 컬럼 수 변경 + onFrozenColumnCountChange?: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => void; // 틀고정 컬럼 수 변경 // 현재 설정 값 (읽기 전용) frozenColumnCount?: number; // 현재 틀고정 컬럼 수