Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
df94d73662
|
|
@ -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); // 파일이 존재하지 않음
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
@ -1398,6 +1398,220 @@ class TableCategoryValueService {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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 ===== */
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<ExcelUploadModalProps> = ({
|
|||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
// 1단계: 파일 선택
|
||||
// 1단계: 파일 선택 & 미리보기
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
||||
const [selectedSheet, setSelectedSheet] = useState<string>("");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
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 [previewData, setPreviewData] = useState<Record<string, any>[]>([]);
|
||||
const [allData, setAllData] = useState<Record<string, any>[]>([]);
|
||||
const [displayData, setDisplayData] = useState<Record<string, any>[]>([]);
|
||||
|
||||
// 3단계: 컬럼 매핑
|
||||
// 2단계: 컬럼 매핑 + 매핑 템플릿 자동 적용
|
||||
const [isAutoMappingLoaded, setIsAutoMappingLoaded] = useState(false);
|
||||
const [excelColumns, setExcelColumns] = useState<string[]>([]);
|
||||
const [systemColumns, setSystemColumns] = useState<TableColumn[]>([]);
|
||||
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
||||
|
||||
// 4단계: 확인
|
||||
// 3단계: 확인
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
// 파일 선택 핸들러
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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<ExcelUploadModalProps> = ({
|
|||
|
||||
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<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) => {
|
||||
setSelectedSheet(sheetName);
|
||||
|
|
@ -130,7 +152,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
try {
|
||||
const data = await importFromExcel(file, sheetName);
|
||||
setAllData(data);
|
||||
setDisplayData(data); // 전체 데이터를 미리보기에 표시 (스크롤 가능)
|
||||
setDisplayData(data);
|
||||
|
||||
if (data.length > 0) {
|
||||
const columns = Object.keys(data[0]);
|
||||
|
|
@ -144,63 +166,22 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 행 추가
|
||||
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개의 열이 필요합니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 스키마 가져오기
|
||||
// 테이블 스키마 가져오기 (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 });
|
||||
|
|
@ -210,14 +191,41 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
console.log("📊 테이블 스키마 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns);
|
||||
setSystemColumns(response.data.columns);
|
||||
// 자동 생성 컬럼 제외
|
||||
const filteredColumns = response.data.columns.filter(
|
||||
(col) => !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase())
|
||||
);
|
||||
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", filteredColumns);
|
||||
setSystemColumns(filteredColumns);
|
||||
|
||||
const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({
|
||||
excelColumn: col,
|
||||
systemColumn: null,
|
||||
}));
|
||||
setColumnMappings(initialMappings);
|
||||
// 기존 매핑 템플릿 조회
|
||||
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);
|
||||
}
|
||||
|
|
@ -234,7 +242,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
|
||||
// 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<ExcelUploadModalProps> = ({
|
|||
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<ExcelUploadModalProps> = ({
|
|||
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<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);
|
||||
|
||||
try {
|
||||
// allData를 사용하여 전체 데이터 업로드 (displayData는 미리보기용 10개만)
|
||||
// allData를 사용하여 전체 데이터 업로드
|
||||
const mappedData = allData.map((row) => {
|
||||
const mappedRow: Record<string, any> = {};
|
||||
columnMappings.forEach((mapping) => {
|
||||
|
|
@ -307,10 +350,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
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<ExcelUploadModalProps> = ({
|
|||
toast.success(
|
||||
`${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?.();
|
||||
} else {
|
||||
toast.error("업로드에 실패했습니다.");
|
||||
|
|
@ -349,11 +434,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setFile(null);
|
||||
setSheetNames([]);
|
||||
setSelectedSheet("");
|
||||
setAutoCreateColumn(false);
|
||||
setSelectedCompany("");
|
||||
setSelectedDataType("");
|
||||
setIsAutoMappingLoaded(false);
|
||||
setDetectedRange("");
|
||||
setPreviewData([]);
|
||||
setAllData([]);
|
||||
setDisplayData([]);
|
||||
setExcelColumns([]);
|
||||
|
|
@ -381,17 +463,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
엑셀 데이터 업로드
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. 모달 테두리를 드래그하여 크기를 조절할 수 있습니다.
|
||||
엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 스텝 인디케이터 */}
|
||||
{/* 스텝 인디케이터 (3단계) */}
|
||||
<div className="flex items-center justify-between">
|
||||
{[
|
||||
{ num: 1, label: "파일 선택" },
|
||||
{ num: 2, label: "범위 지정" },
|
||||
{ num: 3, label: "컬럼 매핑" },
|
||||
{ num: 4, label: "확인" },
|
||||
{ num: 2, label: "컬럼 매핑" },
|
||||
{ num: 3, label: "확인" },
|
||||
].map((step, index) => (
|
||||
<React.Fragment key={step.num}>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
|
|
@ -414,15 +495,13 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium sm:text-xs",
|
||||
currentStep === step.num
|
||||
? "text-primary"
|
||||
: "text-muted-foreground"
|
||||
currentStep === step.num ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < 3 && (
|
||||
{index < 2 && (
|
||||
<div
|
||||
className={cn(
|
||||
"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">
|
||||
{/* 1단계: 파일 선택 */}
|
||||
{/* 1단계: 파일 선택 & 미리보기 (통합) */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
{/* 파일 선택 영역 */}
|
||||
<div>
|
||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||
파일 선택 *
|
||||
</Label>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{file ? file.name : "파일 선택"}
|
||||
</Button>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"mt-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors",
|
||||
isDragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: file
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-muted-foreground/25 hover:border-primary hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet className="h-8 w-8 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-700">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
클릭하여 다른 파일 선택
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload
|
||||
className={cn(
|
||||
"mb-2 h-8 w-8",
|
||||
isDragOver ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isDragOver ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isDragOver
|
||||
? "파일을 놓으세요"
|
||||
: "파일을 드래그하거나 클릭하여 선택"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
지원 형식: .xlsx, .xls, .csv
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="file-upload"
|
||||
|
|
@ -462,213 +579,71 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
지원 형식: .xlsx, .xls, .csv
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sheetNames.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="sheet-select" className="text-xs sm:text-sm">
|
||||
시트 선택
|
||||
</Label>
|
||||
<Select value={selectedSheet} onValueChange={handleSheetChange}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="시트를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sheetNames.map((sheetName) => (
|
||||
<SelectItem
|
||||
key={sheetName}
|
||||
value={sheetName}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
<FileSpreadsheet className="mr-2 inline h-4 w-4" />
|
||||
{sheetName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</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])}
|
||||
{/* 파일이 선택된 경우에만 미리보기 표시 */}
|
||||
{file && displayData.length > 0 && (
|
||||
<>
|
||||
{/* 시트 선택 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground sm:text-sm">
|
||||
시트:
|
||||
</Label>
|
||||
<Select value={selectedSheet} onValueChange={handleSheetChange}>
|
||||
<SelectTrigger className="h-8 w-[140px] text-xs sm:h-9 sm:w-[180px] sm:text-sm">
|
||||
<SelectValue placeholder="Sheet1" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sheetNames.map((sheetName) => (
|
||||
<SelectItem
|
||||
key={sheetName}
|
||||
value={sheetName}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{String(row[col] || "")}
|
||||
</td>
|
||||
{sheetName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* 3단계: 컬럼 매핑 */}
|
||||
{currentStep === 3 && (
|
||||
{/* 2단계: 컬럼 매핑 */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* 상단: 제목 + 자동 매핑 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -693,9 +668,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
<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) => (
|
||||
<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">
|
||||
{mapping.excelColumn}
|
||||
</div>
|
||||
|
|
@ -713,7 +691,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
<SelectValue placeholder="매핑 안함">
|
||||
{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<ExcelUploadModalProps> = ({
|
|||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 4단계: 확인 */}
|
||||
{currentStep === 4 && (
|
||||
{/* 3단계: 확인 */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-muted/50 p-4">
|
||||
<h3 className="text-sm font-medium sm:text-base">업로드 요약</h3>
|
||||
|
|
@ -762,7 +771,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
<p>
|
||||
<span className="font-medium">모드:</span>{" "}
|
||||
{uploadMode === "insert"
|
||||
? "삽입"
|
||||
? "신규 등록"
|
||||
: uploadMode === "update"
|
||||
? "업데이트"
|
||||
: "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">
|
||||
{columnMappings
|
||||
.filter((m) => m.systemColumn)
|
||||
.map((mapping, index) => (
|
||||
<p key={index}>
|
||||
<span className="font-medium">{mapping.excelColumn}</span> →{" "}
|
||||
{mapping.systemColumn}
|
||||
</p>
|
||||
))}
|
||||
.map((mapping, index) => {
|
||||
const col = systemColumns.find(
|
||||
(c) => c.name === mapping.systemColumn
|
||||
);
|
||||
return (
|
||||
<p key={index}>
|
||||
<span className="font-medium">{mapping.excelColumn}</span> →{" "}
|
||||
{col?.label || mapping.systemColumn}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
{columnMappings.filter((m) => m.systemColumn).length === 0 && (
|
||||
<p className="text-destructive">매핑된 컬럼이 없습니다.</p>
|
||||
)}
|
||||
|
|
@ -793,7 +807,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
<div className="text-[10px] text-warning sm:text-xs">
|
||||
<p className="font-medium">주의사항</p>
|
||||
<p className="mt-1">
|
||||
업로드를 진행하면 데이터가 데이터베이스에 저장됩니다. 계속하시겠습니까?
|
||||
업로드를 진행하면 데이터가 데이터베이스에 저장됩니다.
|
||||
계속하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -811,10 +826,10 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
>
|
||||
{currentStep === 1 ? "취소" : "이전"}
|
||||
</Button>
|
||||
{currentStep < 4 ? (
|
||||
{currentStep < 3 ? (
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
다음
|
||||
|
|
@ -822,10 +837,12 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
) : (
|
||||
<Button
|
||||
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"
|
||||
>
|
||||
{isUploading ? "업로드 중..." : "다음"}
|
||||
{isUploading ? "업로드 중..." : "업로드"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
isInteractive={true}
|
||||
formData={formData}
|
||||
originalData={originalData || undefined}
|
||||
initialData={originalData || undefined} // 🆕 조건부 컨테이너 등에서 initialData로 전달
|
||||
initialData={(originalData && Object.keys(originalData).length > 0) ? originalData : formData} // 🆕 originalData가 있으면 사용, 없으면 formData 사용 (생성 모드에서 부모 데이터 전달)
|
||||
onFormDataChange={handleFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
|
|
|
|||
|
|
@ -97,9 +97,13 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -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 || "매핑 템플릿 삭제 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -654,7 +654,6 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
},
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
console.log("[SplitPanelLayout2] 우측 추가 모달 열기");
|
||||
}, [
|
||||
config.rightPanel?.addModalScreenId,
|
||||
config.rightPanel?.addButtonLabel,
|
||||
|
|
|
|||
|
|
@ -1039,14 +1039,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
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<TableListComponentProps> = ({
|
|||
});
|
||||
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<TableListComponentProps> = ({
|
|||
{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<TableListComponentProps> = ({
|
|||
const isNumeric = inputType === "number" || inputType === "decimal";
|
||||
|
||||
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;
|
||||
|
|
@ -6284,7 +6308,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
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<TableListComponentProps> = ({
|
|||
// 🆕 검색 하이라이트 여부
|
||||
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<TableListComponentProps> = ({
|
|||
const summary = summaryData[column.columnName];
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { generateNumberingCode, allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
|
||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||
|
||||
|
|
@ -139,6 +139,7 @@ export function UniversalFormModalComponent({
|
|||
}: UniversalFormModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
|
||||
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
|
||||
const initialData = propInitialData || _initialData;
|
||||
|
||||
// 설정 병합
|
||||
const config: UniversalFormModalConfig = useMemo(() => {
|
||||
const componentConfig = component?.config || {};
|
||||
|
|
@ -155,11 +156,6 @@ export function UniversalFormModalComponent({
|
|||
...defaultConfig.saveConfig,
|
||||
...propConfig?.saveConfig,
|
||||
...componentConfig.saveConfig,
|
||||
multiRowSave: {
|
||||
...defaultConfig.saveConfig.multiRowSave,
|
||||
...propConfig?.saveConfig?.multiRowSave,
|
||||
...componentConfig.saveConfig?.multiRowSave,
|
||||
},
|
||||
afterSave: {
|
||||
...defaultConfig.saveConfig.afterSave,
|
||||
...propConfig?.saveConfig?.afterSave,
|
||||
|
|
@ -194,9 +190,6 @@ export function UniversalFormModalComponent({
|
|||
[tableKey: string]: Record<string, any>[];
|
||||
}>({});
|
||||
|
||||
// 로딩 상태
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 채번규칙 원본 값 추적 (수동 모드 감지용)
|
||||
// key: columnName, value: 자동 생성된 원본 값
|
||||
const [numberingOriginalValues, setNumberingOriginalValues] = useState<Record<string, string>>({});
|
||||
|
|
@ -614,7 +607,8 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
|
||||
const tableConfig = section.tableConfig;
|
||||
const editConfig = tableConfig.editConfig;
|
||||
// editConfig는 타입에 정의되지 않았지만 런타임에 존재할 수 있음
|
||||
const editConfig = (tableConfig as any).editConfig;
|
||||
const saveConfig = tableConfig.saveConfig;
|
||||
|
||||
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
|
||||
|
|
@ -1244,378 +1238,6 @@ export function UniversalFormModalComponent({
|
|||
return { valid: missingFields.length === 0, missingFields };
|
||||
}, [config.sections, formData]);
|
||||
|
||||
// 단일 행 저장
|
||||
const saveSingleRow = useCallback(async () => {
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
// 테이블 섹션 데이터 추출 (별도 저장용)
|
||||
const tableSectionData: Record<string, any[]> = {};
|
||||
|
||||
// 메타데이터 필드 제거 (채번 규칙 ID는 유지 - buttonActions.ts에서 사용)
|
||||
Object.keys(dataToSave).forEach((key) => {
|
||||
if (key.startsWith("_tableSection_")) {
|
||||
// 테이블 섹션 데이터는 별도로 저장
|
||||
const sectionId = key.replace("_tableSection_", "");
|
||||
tableSectionData[sectionId] = dataToSave[key] || [];
|
||||
delete dataToSave[key];
|
||||
} else if (key.startsWith("_") && !key.includes("_numberingRuleId")) {
|
||||
delete dataToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 저장 시점 채번규칙 처리
|
||||
for (const section of config.sections) {
|
||||
// 테이블 타입 섹션은 건너뛰기
|
||||
if (section.type === "table") continue;
|
||||
|
||||
for (const field of section.fields || []) {
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
const ruleIdKey = `${field.columnName}_numberingRuleId`;
|
||||
const hasRuleId = dataToSave[ruleIdKey]; // 사용자가 수정하지 않았으면 ruleId 유지됨
|
||||
|
||||
// 채번 규칙 할당 조건
|
||||
const shouldAllocate =
|
||||
// 1. generateOnSave가 ON인 경우: 항상 저장 시점에 할당
|
||||
field.numberingRule.generateOnSave ||
|
||||
// 2. editable이 OFF인 경우: 사용자 입력 무시하고 채번 규칙으로 덮어씌움
|
||||
!field.numberingRule.editable ||
|
||||
// 3. editable이 ON이고 사용자가 수정하지 않은 경우 (ruleId 유지됨): 실제 번호 할당
|
||||
(field.numberingRule.editable && hasRuleId);
|
||||
|
||||
if (shouldAllocate) {
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
dataToSave[field.columnName] = response.data.generatedCode;
|
||||
let reason = "(알 수 없음)";
|
||||
if (field.numberingRule.generateOnSave) {
|
||||
reason = "(generateOnSave)";
|
||||
} else if (!field.numberingRule.editable) {
|
||||
reason = "(editable=OFF, 강제 덮어씌움)";
|
||||
} else if (hasRuleId) {
|
||||
reason = "(editable=ON, 사용자 미수정)";
|
||||
}
|
||||
console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode} ${reason}`);
|
||||
} else {
|
||||
console.error(`[채번 실패] ${field.columnName}:`, response.error);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[채번 스킵] ${field.columnName}: 사용자가 직접 입력한 값 유지 = ${dataToSave[field.columnName]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 별도 테이블에 저장해야 하는 테이블 섹션 목록
|
||||
const tableSectionsForSeparateTable = config.sections.filter(
|
||||
(s) =>
|
||||
s.type === "table" &&
|
||||
s.tableConfig?.saveConfig?.targetTable &&
|
||||
s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName,
|
||||
);
|
||||
|
||||
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
|
||||
// targetTable이 없거나 메인 테이블과 같은 경우
|
||||
const tableSectionsForMainTable = config.sections.filter(
|
||||
(s) =>
|
||||
s.type === "table" &&
|
||||
(!s.tableConfig?.saveConfig?.targetTable ||
|
||||
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName),
|
||||
);
|
||||
|
||||
console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName);
|
||||
console.log(
|
||||
"[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:",
|
||||
tableSectionsForMainTable.map((s) => s.id),
|
||||
);
|
||||
console.log(
|
||||
"[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:",
|
||||
tableSectionsForSeparateTable.map((s) => s.id),
|
||||
);
|
||||
console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData));
|
||||
console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave));
|
||||
|
||||
if (tableSectionsForMainTable.length > 0) {
|
||||
// 공통 저장 필드 수집 (sectionSaveModes 설정에 따라)
|
||||
const commonFieldsData: Record<string, any> = {};
|
||||
const { sectionSaveModes } = config.saveConfig;
|
||||
|
||||
// 필드 타입 섹션에서 공통 저장 필드 수집
|
||||
for (const section of config.sections) {
|
||||
if (section.type === "table") continue;
|
||||
|
||||
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === section.id);
|
||||
const defaultMode = "common"; // 필드 타입 섹션의 기본값은 공통 저장
|
||||
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
|
||||
|
||||
if (section.fields) {
|
||||
for (const field of section.fields) {
|
||||
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
|
||||
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
|
||||
|
||||
if (fieldSaveMode === "common" && dataToSave[field.columnName] !== undefined) {
|
||||
commonFieldsData[field.columnName] = dataToSave[field.columnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 각 테이블 섹션의 품목 데이터에 공통 필드 병합하여 저장
|
||||
for (const tableSection of tableSectionsForMainTable) {
|
||||
const sectionData = tableSectionData[tableSection.id] || [];
|
||||
|
||||
if (sectionData.length > 0) {
|
||||
// 품목별로 행 저장
|
||||
for (const item of sectionData) {
|
||||
const rowToSave = { ...commonFieldsData, ...item };
|
||||
|
||||
// _sourceData 등 내부 메타데이터 제거
|
||||
Object.keys(rowToSave).forEach((key) => {
|
||||
if (key.startsWith("_")) {
|
||||
delete rowToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${config.saveConfig.tableName}/add`,
|
||||
rowToSave,
|
||||
);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "품목 저장 실패");
|
||||
}
|
||||
}
|
||||
|
||||
// 이미 저장했으므로 아래 로직에서 다시 저장하지 않도록 제거
|
||||
delete tableSectionData[tableSection.id];
|
||||
}
|
||||
}
|
||||
|
||||
// 품목이 없으면 공통 데이터만 저장하지 않음 (품목이 필요한 화면이므로)
|
||||
// 다른 테이블 섹션이 있는 경우에만 메인 데이터 저장
|
||||
const hasOtherTableSections = Object.keys(tableSectionData).length > 0;
|
||||
if (!hasOtherTableSections) {
|
||||
return; // 메인 테이블에 저장할 품목이 없으면 종료
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 데이터 저장 (테이블 섹션이 없거나 별도 테이블에 저장하는 경우)
|
||||
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, dataToSave);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "저장 실패");
|
||||
}
|
||||
|
||||
// 테이블 섹션 데이터 저장 (별도 테이블에)
|
||||
for (const section of config.sections) {
|
||||
if (section.type === "table" && section.tableConfig?.saveConfig?.targetTable) {
|
||||
const sectionData = tableSectionData[section.id];
|
||||
if (sectionData && sectionData.length > 0) {
|
||||
// 메인 레코드 ID가 필요한 경우 (response.data에서 가져오기)
|
||||
const mainRecordId = response.data?.data?.id;
|
||||
|
||||
// 공통 저장 필드 수집: 다른 섹션(필드 타입)에서 공통 저장으로 설정된 필드 값
|
||||
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
|
||||
const commonFieldsData: Record<string, any> = {};
|
||||
const { sectionSaveModes } = config.saveConfig;
|
||||
|
||||
// 다른 섹션에서 공통 저장으로 설정된 필드 값 수집
|
||||
for (const otherSection of config.sections) {
|
||||
if (otherSection.id === section.id) continue; // 현재 테이블 섹션은 건너뛰기
|
||||
|
||||
const sectionMode = sectionSaveModes?.find((s) => s.sectionId === otherSection.id);
|
||||
// 기본값: 필드 타입 섹션은 'common', 테이블 타입 섹션은 'individual'
|
||||
const defaultMode = otherSection.type === "table" ? "individual" : "common";
|
||||
const sectionSaveMode = sectionMode?.saveMode || defaultMode;
|
||||
|
||||
// 필드 타입 섹션의 필드들 처리
|
||||
if (otherSection.type !== "table" && otherSection.fields) {
|
||||
for (const field of otherSection.fields) {
|
||||
// 필드별 오버라이드 확인
|
||||
const fieldOverride = sectionMode?.fieldOverrides?.find((f) => f.fieldName === field.columnName);
|
||||
const fieldSaveMode = fieldOverride?.saveMode || sectionSaveMode;
|
||||
|
||||
// 공통 저장이면 formData에서 값을 가져와 모든 품목에 적용
|
||||
if (fieldSaveMode === "common" && formData[field.columnName] !== undefined) {
|
||||
commonFieldsData[field.columnName] = formData[field.columnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 선택적 필드 그룹 (optionalFieldGroups)도 처리
|
||||
if (otherSection.optionalFieldGroups && otherSection.optionalFieldGroups.length > 0) {
|
||||
for (const optGroup of otherSection.optionalFieldGroups) {
|
||||
if (optGroup.fields) {
|
||||
for (const field of optGroup.fields) {
|
||||
// 선택적 필드 그룹은 기본적으로 common 저장
|
||||
if (formData[field.columnName] !== undefined) {
|
||||
commonFieldsData[field.columnName] = formData[field.columnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[saveSingleRow] 별도 테이블 저장 - 공통 필드:", Object.keys(commonFieldsData));
|
||||
|
||||
for (const item of sectionData) {
|
||||
// 공통 필드 병합 + 개별 품목 데이터
|
||||
const itemToSave = { ...commonFieldsData, ...item };
|
||||
|
||||
// saveToTarget: false인 컬럼은 저장에서 제외
|
||||
const columns = section.tableConfig?.columns || [];
|
||||
for (const col of columns) {
|
||||
if (col.saveConfig?.saveToTarget === false && col.field in itemToSave) {
|
||||
delete itemToSave[col.field];
|
||||
}
|
||||
}
|
||||
|
||||
// _sourceData 등 내부 메타데이터 제거
|
||||
Object.keys(itemToSave).forEach((key) => {
|
||||
if (key.startsWith("_")) {
|
||||
delete itemToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 레코드와 연결이 필요한 경우
|
||||
if (mainRecordId && config.saveConfig.primaryKeyColumn) {
|
||||
itemToSave[config.saveConfig.primaryKeyColumn] = mainRecordId;
|
||||
}
|
||||
|
||||
const saveResponse = await apiClient.post(
|
||||
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
|
||||
itemToSave,
|
||||
);
|
||||
|
||||
if (!saveResponse.data?.success) {
|
||||
throw new Error(saveResponse.data?.message || `${section.title || "테이블 섹션"} 저장 실패`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
config.sections,
|
||||
config.saveConfig.tableName,
|
||||
config.saveConfig.primaryKeyColumn,
|
||||
config.saveConfig.sectionSaveModes,
|
||||
formData,
|
||||
]);
|
||||
|
||||
// 다중 행 저장 (겸직 등)
|
||||
const saveMultipleRows = useCallback(async () => {
|
||||
const { multiRowSave } = config.saveConfig;
|
||||
if (!multiRowSave) return;
|
||||
|
||||
let { commonFields = [], repeatSectionId = "" } = multiRowSave;
|
||||
const { typeColumn, mainTypeValue, subTypeValue, mainSectionFields = [] } = multiRowSave;
|
||||
|
||||
// 공통 필드가 설정되지 않은 경우, 기본정보 섹션의 모든 필드를 공통 필드로 사용
|
||||
if (commonFields.length === 0) {
|
||||
const nonRepeatableSections = config.sections.filter((s) => !s.repeatable);
|
||||
commonFields = nonRepeatableSections.flatMap((s) => (s.fields || []).map((f) => f.columnName));
|
||||
}
|
||||
|
||||
// 반복 섹션 ID가 설정되지 않은 경우, 첫 번째 반복 섹션 사용
|
||||
if (!repeatSectionId) {
|
||||
const repeatableSection = config.sections.find((s) => s.repeatable);
|
||||
if (repeatableSection) {
|
||||
repeatSectionId = repeatableSection.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 반복 섹션 데이터
|
||||
const repeatItems = repeatSections[repeatSectionId] || [];
|
||||
|
||||
// 저장할 행들 생성
|
||||
const rowsToSave: any[] = [];
|
||||
|
||||
// 공통 데이터 (모든 행에 적용)
|
||||
const commonData: any = {};
|
||||
commonFields.forEach((fieldName) => {
|
||||
if (formData[fieldName] !== undefined) {
|
||||
commonData[fieldName] = formData[fieldName];
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 섹션 필드 데이터 (메인 행에만 적용되는 부서/직급 등)
|
||||
const mainSectionData: any = {};
|
||||
mainSectionFields.forEach((fieldName) => {
|
||||
if (formData[fieldName] !== undefined) {
|
||||
mainSectionData[fieldName] = formData[fieldName];
|
||||
}
|
||||
});
|
||||
|
||||
// 메인 행 (공통 데이터 + 메인 섹션 필드)
|
||||
const mainRow: any = { ...commonData, ...mainSectionData };
|
||||
if (typeColumn) {
|
||||
mainRow[typeColumn] = mainTypeValue || "main";
|
||||
}
|
||||
rowsToSave.push(mainRow);
|
||||
|
||||
// 반복 섹션 행들 (공통 데이터 + 반복 섹션 필드)
|
||||
for (const item of repeatItems) {
|
||||
const subRow: any = { ...commonData };
|
||||
|
||||
// 반복 섹션의 필드 값 추가
|
||||
const repeatSection = config.sections.find((s) => s.id === repeatSectionId);
|
||||
(repeatSection?.fields || []).forEach((field) => {
|
||||
if (item[field.columnName] !== undefined) {
|
||||
subRow[field.columnName] = item[field.columnName];
|
||||
}
|
||||
});
|
||||
|
||||
if (typeColumn) {
|
||||
subRow[typeColumn] = subTypeValue || "concurrent";
|
||||
}
|
||||
|
||||
rowsToSave.push(subRow);
|
||||
}
|
||||
|
||||
// 저장 시점 채번규칙 처리 (메인 행만)
|
||||
for (const section of config.sections) {
|
||||
if (section.repeatable || section.type === "table") continue;
|
||||
|
||||
for (const field of section.fields || []) {
|
||||
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
|
||||
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
|
||||
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
|
||||
if (shouldAllocate) {
|
||||
const response = await allocateNumberingCode(field.numberingRule.ruleId);
|
||||
if (response.success && response.data?.generatedCode) {
|
||||
// 모든 행에 동일한 채번 값 적용 (공통 필드인 경우)
|
||||
if (commonFields.includes(field.columnName)) {
|
||||
rowsToSave.forEach((row) => {
|
||||
row[field.columnName] = response.data?.generatedCode;
|
||||
});
|
||||
} else {
|
||||
rowsToSave[0][field.columnName] = response.data?.generatedCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 행 저장
|
||||
for (let i = 0; i < rowsToSave.length; i++) {
|
||||
const row = rowsToSave[i];
|
||||
|
||||
// 빈 객체 체크
|
||||
if (Object.keys(row).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const response = await apiClient.post(`/table-management/tables/${config.saveConfig.tableName}/add`, row);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || `${i + 1}번째 행 저장 실패`);
|
||||
}
|
||||
}
|
||||
}, [config.sections, config.saveConfig, formData, repeatSections]);
|
||||
|
||||
// 다중 테이블 저장 (범용)
|
||||
const saveWithMultiTable = useCallback(async () => {
|
||||
const { customApiSave } = config.saveConfig;
|
||||
|
|
@ -1798,134 +1420,6 @@ export function UniversalFormModalComponent({
|
|||
}
|
||||
}, [config.sections, config.saveConfig, formData, repeatSections, initialData]);
|
||||
|
||||
// 커스텀 API 저장
|
||||
const saveWithCustomApi = useCallback(async () => {
|
||||
const { customApiSave } = config.saveConfig;
|
||||
if (!customApiSave) return;
|
||||
|
||||
const saveWithGenericCustomApi = async () => {
|
||||
if (!customApiSave.customEndpoint) {
|
||||
throw new Error("커스텀 API 엔드포인트가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
// 메타데이터 필드 제거
|
||||
Object.keys(dataToSave).forEach((key) => {
|
||||
if (key.startsWith("_")) {
|
||||
delete dataToSave[key];
|
||||
}
|
||||
});
|
||||
|
||||
// 반복 섹션 데이터 포함
|
||||
if (Object.keys(repeatSections).length > 0) {
|
||||
dataToSave._repeatSections = repeatSections;
|
||||
}
|
||||
|
||||
const method = customApiSave.customMethod || "POST";
|
||||
const response =
|
||||
method === "PUT"
|
||||
? await apiClient.put(customApiSave.customEndpoint, dataToSave)
|
||||
: await apiClient.post(customApiSave.customEndpoint, dataToSave);
|
||||
|
||||
if (!response.data?.success) {
|
||||
throw new Error(response.data?.message || "저장 실패");
|
||||
}
|
||||
};
|
||||
|
||||
switch (customApiSave.apiType) {
|
||||
case "multi-table":
|
||||
await saveWithMultiTable();
|
||||
break;
|
||||
case "custom":
|
||||
await saveWithGenericCustomApi();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`지원하지 않는 API 타입: ${customApiSave.apiType}`);
|
||||
}
|
||||
}, [config.saveConfig, formData, repeatSections, saveWithMultiTable]);
|
||||
|
||||
// 저장 처리
|
||||
const handleSave = useCallback(async () => {
|
||||
// 커스텀 API 저장 모드가 아닌 경우에만 테이블명 체크
|
||||
if (!config.saveConfig.customApiSave?.enabled && !config.saveConfig.tableName) {
|
||||
toast.error("저장할 테이블이 설정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 필수 필드 검증
|
||||
const { valid, missingFields } = validateRequiredFields();
|
||||
if (!valid) {
|
||||
toast.error(`필수 항목을 입력해주세요: ${missingFields.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const { multiRowSave, customApiSave } = config.saveConfig;
|
||||
|
||||
// 커스텀 API 저장 모드
|
||||
if (customApiSave?.enabled) {
|
||||
await saveWithCustomApi();
|
||||
} else if (multiRowSave?.enabled) {
|
||||
// 다중 행 저장
|
||||
await saveMultipleRows();
|
||||
} else {
|
||||
// 단일 행 저장
|
||||
await saveSingleRow();
|
||||
}
|
||||
|
||||
// 저장 후 동작
|
||||
if (config.saveConfig.afterSave?.showToast) {
|
||||
toast.success("저장되었습니다.");
|
||||
}
|
||||
|
||||
if (config.saveConfig.afterSave?.refreshParent) {
|
||||
window.dispatchEvent(new CustomEvent("refreshParentData"));
|
||||
}
|
||||
|
||||
// onSave 콜백은 저장 완료 알림용으로만 사용
|
||||
// 실제 저장은 이미 위에서 완료됨 (saveSingleRow 또는 saveMultipleRows)
|
||||
// EditModal 등 부모 컴포넌트의 저장 로직이 다시 실행되지 않도록
|
||||
// _saveCompleted 플래그를 포함하여 전달
|
||||
if (onSave) {
|
||||
onSave({ ...formData, _saveCompleted: true });
|
||||
}
|
||||
|
||||
// 저장 완료 후 모달 닫기 이벤트 발생
|
||||
if (config.saveConfig.afterSave?.closeModal !== false) {
|
||||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("저장 실패:", error);
|
||||
// axios 에러의 경우 서버 응답 메시지 추출
|
||||
const errorMessage =
|
||||
error.response?.data?.message ||
|
||||
error.response?.data?.error?.details ||
|
||||
error.message ||
|
||||
"저장에 실패했습니다.";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
formData,
|
||||
repeatSections,
|
||||
onSave,
|
||||
validateRequiredFields,
|
||||
saveSingleRow,
|
||||
saveMultipleRows,
|
||||
saveWithCustomApi,
|
||||
]);
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = useCallback(() => {
|
||||
initializeForm();
|
||||
toast.info("폼이 초기화되었습니다.");
|
||||
}, [initializeForm]);
|
||||
|
||||
// 필드 요소 렌더링 (입력 컴포넌트만)
|
||||
// repeatContext: 반복 섹션인 경우 { sectionId, itemId }를 전달
|
||||
const renderFieldElement = (
|
||||
|
|
@ -2664,38 +2158,6 @@ export function UniversalFormModalComponent({
|
|||
{/* 섹션들 */}
|
||||
<div className="space-y-4">{config.sections.map((section) => renderSection(section))}</div>
|
||||
|
||||
{/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */}
|
||||
{config.modal.showSaveButton !== false && (
|
||||
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
{config.modal.showResetButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleReset();
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
{config.modal.resetButtonText || "초기화"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
}}
|
||||
disabled={saving || !config.saveConfig.tableName}
|
||||
>
|
||||
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog
|
||||
open={deleteDialog.open}
|
||||
|
|
|
|||
|
|
@ -530,40 +530,6 @@ export function UniversalFormModalConfigPanel({
|
|||
</Select>
|
||||
<HelpText>모달 창의 크기를 선택하세요</HelpText>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 표시 설정 */}
|
||||
<div className="w-full min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="show-save-button"
|
||||
checked={config.modal.showSaveButton !== false}
|
||||
onCheckedChange={(checked) => updateModalConfig({ showSaveButton: checked === true })}
|
||||
/>
|
||||
<Label htmlFor="show-save-button" className="cursor-pointer text-xs font-medium">
|
||||
저장 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
<HelpText>체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="w-full min-w-0 space-y-3">
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="mb-1.5 block text-xs font-medium">저장 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.saveButtonText || "저장"}
|
||||
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
|
||||
className="h-9 w-full max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="mb-1.5 block text-xs font-medium">취소 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.cancelButtonText || "취소"}
|
||||
onChange={(e) => updateModalConfig({ cancelButtonText: e.target.value })}
|
||||
className="h-9 w-full max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
|
@ -885,7 +851,6 @@ export function UniversalFormModalConfigPanel({
|
|||
tableColumns={tableColumns}
|
||||
numberingRules={numberingRules}
|
||||
onLoadTableColumns={loadTableColumns}
|
||||
availableParentFields={availableParentFields}
|
||||
targetTableName={config.saveConfig?.tableName}
|
||||
targetTableColumns={config.saveConfig?.tableName ? tableColumns[config.saveConfig.tableName] || [] : []}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -23,11 +23,6 @@ export const defaultConfig: UniversalFormModalConfig = {
|
|||
size: "lg",
|
||||
closeOnOutsideClick: false,
|
||||
showCloseButton: true,
|
||||
showSaveButton: true,
|
||||
saveButtonText: "저장",
|
||||
cancelButtonText: "취소",
|
||||
showResetButton: false,
|
||||
resetButtonText: "초기화",
|
||||
},
|
||||
sections: [
|
||||
{
|
||||
|
|
@ -45,15 +40,6 @@ export const defaultConfig: UniversalFormModalConfig = {
|
|||
saveConfig: {
|
||||
tableName: "",
|
||||
primaryKeyColumn: "id",
|
||||
multiRowSave: {
|
||||
enabled: false,
|
||||
commonFields: [],
|
||||
repeatSectionId: "",
|
||||
typeColumn: "",
|
||||
mainTypeValue: "main",
|
||||
subTypeValue: "concurrent",
|
||||
mainSectionFields: [],
|
||||
},
|
||||
afterSave: {
|
||||
closeModal: true,
|
||||
refreshParent: true,
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import { defaultConfig } from "./config";
|
|||
/**
|
||||
* 범용 폼 모달 컴포넌트 정의
|
||||
*
|
||||
* 섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는
|
||||
* 섹션 기반 폼 레이아웃, 채번규칙을 지원하는
|
||||
* 범용 모달 컴포넌트입니다.
|
||||
*/
|
||||
export const UniversalFormModalDefinition = createComponentDefinition({
|
||||
id: "universal-form-modal",
|
||||
name: "범용 폼 모달",
|
||||
nameEng: "Universal Form Modal",
|
||||
description: "섹션 기반 폼 레이아웃, 채번규칙, 다중 행 저장을 지원하는 범용 모달 컴포넌트",
|
||||
description: "섹션 기반 폼 레이아웃, 채번규칙을 지원하는 범용 모달 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "form",
|
||||
component: UniversalFormModalComponent,
|
||||
|
|
@ -28,7 +28,7 @@ export const UniversalFormModalDefinition = createComponentDefinition({
|
|||
},
|
||||
configPanel: UniversalFormModalConfigPanel,
|
||||
icon: "FormInput",
|
||||
tags: ["폼", "모달", "입력", "저장", "채번", "겸직", "다중행"],
|
||||
tags: ["폼", "모달", "입력", "저장", "채번"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: `
|
||||
|
|
@ -36,22 +36,22 @@ export const UniversalFormModalDefinition = createComponentDefinition({
|
|||
|
||||
### 주요 기능
|
||||
- **섹션 기반 레이아웃**: 기본 정보, 추가 정보 등 섹션별로 폼 구성
|
||||
- **반복 섹션**: 겸직처럼 동일한 필드 그룹을 여러 개 추가 가능
|
||||
- **반복 섹션**: 동일한 필드 그룹을 여러 개 추가 가능
|
||||
- **채번규칙 연동**: 자동 코드 생성 (모달 열릴 때 또는 저장 시점)
|
||||
- **다중 행 저장**: 공통 필드 + 개별 필드 조합으로 여러 행 동시 저장
|
||||
- **단일/다중 테이블 저장**: 단일 테이블 또는 메인+서브 테이블에 저장
|
||||
- **외부 데이터 수신**: 부모 화면에서 전달받은 값 자동 채움
|
||||
|
||||
### 사용 예시
|
||||
1. 부서관리 사원 추가 + 겸직 등록
|
||||
2. 품목 등록 + 규격 옵션 추가
|
||||
3. 거래처 등록 + 담당자 정보 추가
|
||||
1. 사원 등록, 부서 등록, 거래처 등록 (단일 테이블)
|
||||
2. 주문 등록 + 주문 상세 (다중 테이블)
|
||||
3. 품목 등록 + 규격 옵션 추가
|
||||
|
||||
### 설정 방법
|
||||
1. 저장 테이블 선택
|
||||
2. 섹션 추가 (기본 정보, 겸직 정보 등)
|
||||
2. 섹션 추가 (기본 정보 등)
|
||||
3. 각 섹션에 필드 추가
|
||||
4. 반복 섹션 설정 (필요 시)
|
||||
5. 다중 행 저장 설정 (필요 시)
|
||||
5. 다중 테이블 저장 설정 (필요 시)
|
||||
6. 채번규칙 연동 (필요 시)
|
||||
`,
|
||||
});
|
||||
|
|
@ -69,7 +69,6 @@ export type {
|
|||
FormSectionConfig,
|
||||
FormFieldConfig,
|
||||
SaveConfig,
|
||||
MultiRowSaveConfig,
|
||||
NumberingRuleConfig,
|
||||
SelectOptionConfig,
|
||||
FormDataState,
|
||||
|
|
|
|||
|
|
@ -65,8 +65,6 @@ interface FieldDetailSettingsModalProps {
|
|||
tableColumns: { [tableName: string]: { name: string; type: string; label: string }[] };
|
||||
numberingRules: { id: string; name: string }[];
|
||||
onLoadTableColumns: (tableName: string) => void;
|
||||
// 부모 화면에서 전달 가능한 필드 목록 (선택사항)
|
||||
availableParentFields?: AvailableParentField[];
|
||||
// 저장 테이블 정보 (타겟 컬럼 선택용)
|
||||
targetTableName?: string;
|
||||
targetTableColumns?: { name: string; type: string; label: string }[];
|
||||
|
|
@ -81,7 +79,6 @@ export function FieldDetailSettingsModal({
|
|||
tableColumns,
|
||||
numberingRules,
|
||||
onLoadTableColumns,
|
||||
availableParentFields = [],
|
||||
// targetTableName은 타겟 컬럼 선택 시 참고용으로 전달됨 (현재 targetTableColumns만 사용)
|
||||
targetTableName: _targetTableName,
|
||||
targetTableColumns = [],
|
||||
|
|
@ -330,60 +327,6 @@ export function FieldDetailSettingsModal({
|
|||
/>
|
||||
</div>
|
||||
<HelpText>화면에 표시하지 않지만 값은 저장됩니다</HelpText>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]">부모에서 값 받기</span>
|
||||
<Switch
|
||||
checked={localField.receiveFromParent || false}
|
||||
onCheckedChange={(checked) => updateField({ receiveFromParent: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>부모 화면에서 전달받은 값으로 자동 채워집니다</HelpText>
|
||||
|
||||
{/* 부모에서 값 받기 활성화 시 필드 선택 */}
|
||||
{localField.receiveFromParent && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-blue-50 border border-blue-200">
|
||||
<Label className="text-xs font-medium text-blue-700">부모 필드명 선택</Label>
|
||||
{availableParentFields.length > 0 ? (
|
||||
<Select
|
||||
value={localField.parentFieldName || localField.columnName}
|
||||
onValueChange={(value) => updateField({ parentFieldName: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableParentFields.map((pf) => (
|
||||
<SelectItem key={pf.name} value={pf.name}>
|
||||
<div className="flex flex-col">
|
||||
<span>{pf.label || pf.name}</span>
|
||||
{pf.sourceComponent && (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
{pf.sourceComponent}{pf.sourceTable && ` (${pf.sourceTable})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={localField.parentFieldName || ""}
|
||||
onChange={(e) => updateField({ parentFieldName: e.target.value })}
|
||||
placeholder={`예: ${localField.columnName || "parent_field_name"}`}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
부모 화면에서 전달받을 필드명을 입력하세요. 비워두면 "{localField.columnName}"을 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accordion으로 고급 설정 */}
|
||||
|
|
|
|||
|
|
@ -378,7 +378,11 @@ export function SaveSettingsModal({
|
|||
단일 테이블 저장
|
||||
</Label>
|
||||
</div>
|
||||
<HelpText>모든 필드를 하나의 테이블에 저장합니다 (기본 방식)</HelpText>
|
||||
<HelpText>
|
||||
폼 데이터를 하나의 테이블에 1개 행으로 저장합니다.
|
||||
<br />
|
||||
예: 사원 등록, 부서 등록, 거래처 등록 등 단순 등록 화면
|
||||
</HelpText>
|
||||
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<RadioGroupItem value="multi" id="mode-multi" />
|
||||
|
|
@ -387,9 +391,13 @@ export function SaveSettingsModal({
|
|||
</Label>
|
||||
</div>
|
||||
<HelpText>
|
||||
메인 테이블 + 서브 테이블에 트랜잭션으로 저장합니다
|
||||
하나의 폼으로 여러 테이블에 동시 저장합니다. (트랜잭션으로 묶임)
|
||||
<br />
|
||||
예: 주문(orders) + 주문상세(order_items), 사원(user_info) + 부서(user_dept)
|
||||
메인 테이블: 폼의 모든 필드 중 해당 테이블 컬럼과 일치하는 것 자동 저장
|
||||
<br />
|
||||
서브 테이블: 필드 매핑에서 지정한 필드만 저장 (메인 테이블의 키 값이 자동 연결됨)
|
||||
<br />
|
||||
예: 사원+부서배정(user_info+user_dept), 주문+주문상세(orders+order_items)
|
||||
</HelpText>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
|
@ -691,9 +699,11 @@ export function SaveSettingsModal({
|
|||
</div>
|
||||
|
||||
<HelpText>
|
||||
반복 섹션 데이터를 별도 테이블에 저장합니다.
|
||||
폼에서 입력한 필드를 서브 테이블에 나눠서 저장합니다.
|
||||
<br />
|
||||
예: 주문상세(order_items), 겸직부서(user_dept)
|
||||
메인 테이블의 키 값(예: user_id)이 서브 테이블에 자동으로 연결됩니다.
|
||||
<br />
|
||||
필드 매핑에서 지정한 필드만 서브 테이블에 저장됩니다.
|
||||
</HelpText>
|
||||
|
||||
{(localSaveConfig.customApiSave?.multiTable?.subTables || []).length === 0 ? (
|
||||
|
|
@ -802,13 +812,13 @@ export function SaveSettingsModal({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">연결할 반복 섹션</Label>
|
||||
<Label className="text-[10px]">연결할 반복 섹션 (선택사항)</Label>
|
||||
<Select
|
||||
value={subTable.repeatSectionId || ""}
|
||||
onValueChange={(value) => updateSubTable(subIndex, { repeatSectionId: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="섹션 선택" />
|
||||
<SelectValue placeholder="섹션 선택 (없으면 필드 매핑만 사용)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repeatSections.length === 0 ? (
|
||||
|
|
@ -824,7 +834,13 @@ export function SaveSettingsModal({
|
|||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>이 서브 테이블에 저장할 반복 섹션을 선택하세요</HelpText>
|
||||
<HelpText>
|
||||
반복 섹션: 폼 안에서 동적으로 항목을 추가/삭제할 수 있는 섹션 (예: 주문 품목 목록)
|
||||
<br />
|
||||
반복 섹션이 있으면 해당 섹션의 각 항목이 서브 테이블에 여러 행으로 저장됩니다.
|
||||
<br />
|
||||
반복 섹션 없이 필드 매핑만 사용하면 1개 행만 저장됩니다.
|
||||
</HelpText>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
|
|
|||
|
|
@ -648,19 +648,6 @@ export interface TableCalculationRule {
|
|||
conditionalCalculation?: ConditionalCalculationConfig;
|
||||
}
|
||||
|
||||
// 다중 행 저장 설정
|
||||
export interface MultiRowSaveConfig {
|
||||
enabled?: boolean; // 사용 여부 (기본: false)
|
||||
commonFields?: string[]; // 모든 행에 공통 저장할 필드 (columnName 기준)
|
||||
repeatSectionId?: string; // 반복 섹션 ID
|
||||
typeColumn?: string; // 구분 컬럼명 (예: "employment_type")
|
||||
mainTypeValue?: string; // 메인 행 값 (예: "main")
|
||||
subTypeValue?: string; // 서브 행 값 (예: "concurrent")
|
||||
|
||||
// 메인 섹션 필드 (반복 섹션이 아닌 곳의 부서/직급 등)
|
||||
mainSectionFields?: string[]; // 메인 행에만 저장할 필드
|
||||
}
|
||||
|
||||
/**
|
||||
* 섹션별 저장 방식 설정
|
||||
* 공통 저장: 해당 섹션의 필드 값이 모든 품목 행에 동일하게 저장됩니다 (예: 수주번호, 거래처)
|
||||
|
|
@ -681,9 +668,6 @@ export interface SaveConfig {
|
|||
tableName: string;
|
||||
primaryKeyColumn?: string; // PK 컬럼 (수정 시 사용)
|
||||
|
||||
// 다중 행 저장 설정
|
||||
multiRowSave?: MultiRowSaveConfig;
|
||||
|
||||
// 커스텀 API 저장 설정 (테이블 직접 저장 대신 전용 API 사용)
|
||||
customApiSave?: CustomApiSaveConfig;
|
||||
|
||||
|
|
@ -802,13 +786,6 @@ export interface ModalConfig {
|
|||
size: "sm" | "md" | "lg" | "xl" | "full";
|
||||
closeOnOutsideClick?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
|
||||
// 버튼 설정
|
||||
showSaveButton?: boolean; // 저장 버튼 표시 (기본: true)
|
||||
saveButtonText?: string; // 저장 버튼 텍스트 (기본: "저장")
|
||||
cancelButtonText?: string; // 취소 버튼 텍스트 (기본: "취소")
|
||||
showResetButton?: boolean; // 초기화 버튼 표시
|
||||
resetButtonText?: string; // 초기화 버튼 텍스트
|
||||
}
|
||||
|
||||
// 전체 설정
|
||||
|
|
|
|||
|
|
@ -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; // 현재 틀고정 컬럼 수
|
||||
|
|
|
|||
Loading…
Reference in New Issue