diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 131b9e1a..fd0f1ea8 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -64,8 +64,8 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 -import numberingRuleController from "./controllers/numberingRuleController"; // 채번 규칙 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 +import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -224,8 +224,8 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 -app.use("/api/numbering-rules", numberingRuleController); // 채번 규칙 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 +app.use("/api/code-merge", codeMergeRoutes); // 코드 병합 // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index f79aec69..f2378fe1 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3084,3 +3084,84 @@ export const resetUserPassword = async ( }); } }; + +/** + * 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용) + */ +export async function getTableSchema( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const companyCode = req.user?.companyCode; + + if (!tableName) { + res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + }); + return; + } + + logger.info("테이블 스키마 조회", { tableName, companyCode }); + + // information_schema에서 컬럼 정보 가져오기 + const schemaQuery = ` + SELECT + column_name, + data_type, + is_nullable, + column_default, + character_maximum_length, + numeric_precision, + numeric_scale + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + ORDER BY ordinal_position + `; + + const columns = await query(schemaQuery, [tableName]); + + if (columns.length === 0) { + res.status(404).json({ + success: false, + message: `테이블 '${tableName}'을 찾을 수 없습니다.`, + }); + return; + } + + // 컬럼 정보를 간단한 형태로 변환 + const columnList = columns.map((col: any) => ({ + name: col.column_name, + type: col.data_type, + nullable: col.is_nullable === "YES", + default: col.column_default, + maxLength: col.character_maximum_length, + precision: col.numeric_precision, + scale: col.numeric_scale, + })); + + logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`); + + res.json({ + success: true, + message: "테이블 스키마 조회 성공", + data: { + tableName, + columns: columnList, + }, + }); + } catch (error) { + logger.error("테이블 스키마 조회 중 오류 발생:", error); + res.status(500).json({ + success: false, + message: "테이블 스키마 조회 중 오류가 발생했습니다.", + error: { + code: "TABLE_SCHEMA_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }); + } +} diff --git a/backend-node/src/controllers/codeMergeController.ts b/backend-node/src/controllers/codeMergeController.ts new file mode 100644 index 00000000..e7658253 --- /dev/null +++ b/backend-node/src/controllers/codeMergeController.ts @@ -0,0 +1,282 @@ +import { Request, Response } from "express"; +import pool from "../database/db"; +import { logger } from "../utils/logger"; + +interface AuthenticatedRequest extends Request { + user?: { + userId: string; + userName: string; + companyCode: string; + }; +} + +/** + * 코드 병합 - 모든 관련 테이블에 적용 + * 데이터(레코드)는 삭제하지 않고, 컬럼 값만 변경 + */ +export async function mergeCodeAllTables( + req: AuthenticatedRequest, + res: Response +): Promise { + const { columnName, oldValue, newValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + // 입력값 검증 + if (!columnName || !oldValue || !newValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (columnName, oldValue, newValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + // 같은 값으로 병합 시도 방지 + if (oldValue === newValue) { + res.status(400).json({ + success: false, + message: "기존 값과 새 값이 동일합니다.", + }); + return; + } + + logger.info("코드 병합 시작", { + columnName, + oldValue, + newValue, + companyCode, + userId: req.user?.userId, + }); + + // PostgreSQL 함수 호출 + const result = await pool.query( + "SELECT * FROM merge_code_all_tables($1, $2, $3, $4)", + [columnName, oldValue, newValue, companyCode] + ); + + // 결과 처리 (pool.query 반환 타입 처리) + const affectedTables = Array.isArray(result) ? result : (result.rows || []); + const totalRows = affectedTables.reduce( + (sum, row) => sum + parseInt(row.rows_updated || 0), + 0 + ); + + logger.info("코드 병합 완료", { + columnName, + oldValue, + newValue, + affectedTablesCount: affectedTables.length, + totalRowsUpdated: totalRows, + }); + + res.json({ + success: true, + message: `코드 병합 완료: ${oldValue} → ${newValue}`, + data: { + columnName, + oldValue, + newValue, + affectedTables: affectedTables.map((row) => ({ + tableName: row.table_name, + rowsUpdated: parseInt(row.rows_updated), + })), + totalRowsUpdated: totalRows, + }, + }); + } catch (error: any) { + logger.error("코드 병합 실패:", { + error: error.message, + stack: error.stack, + columnName, + oldValue, + newValue, + }); + + res.status(500).json({ + success: false, + message: "코드 병합 중 오류가 발생했습니다.", + error: { + code: "CODE_MERGE_ERROR", + details: error.message, + }, + }); + } +} + +/** + * 특정 컬럼을 가진 테이블 목록 조회 + */ +export async function getTablesWithColumn( + req: AuthenticatedRequest, + res: Response +): Promise { + const { columnName } = req.params; + + try { + if (!columnName) { + res.status(400).json({ + success: false, + message: "컬럼명이 필요합니다.", + }); + return; + } + + logger.info("컬럼을 가진 테이블 목록 조회", { columnName }); + + const query = ` + SELECT DISTINCT t.table_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name + WHERE c.column_name = $1 + AND t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + AND EXISTS ( + SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_name = t.table_name + AND c2.column_name = 'company_code' + ) + ORDER BY t.table_name + `; + + const result = await pool.query(query, [columnName]); + + logger.info(`컬럼을 가진 테이블 조회 완료: ${result.rows.length}개`); + + res.json({ + success: true, + message: "테이블 목록 조회 성공", + data: { + columnName, + tables: result.rows.map((row) => row.table_name), + count: result.rows.length, + }, + }); + } catch (error: any) { + logger.error("테이블 목록 조회 실패:", error); + + res.status(500).json({ + success: false, + message: "테이블 목록 조회 중 오류가 발생했습니다.", + error: { + code: "TABLE_LIST_ERROR", + details: error.message, + }, + }); + } +} + +/** + * 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인) + */ +export async function previewCodeMerge( + req: AuthenticatedRequest, + res: Response +): Promise { + const { columnName, oldValue } = req.body; + const companyCode = req.user?.companyCode; + + try { + if (!columnName || !oldValue) { + res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다. (columnName, oldValue)", + }); + return; + } + + if (!companyCode) { + res.status(401).json({ + success: false, + message: "인증 정보가 없습니다.", + }); + return; + } + + logger.info("코드 병합 미리보기", { columnName, oldValue, companyCode }); + + // 해당 컬럼을 가진 테이블 찾기 + const tablesQuery = ` + SELECT DISTINCT t.table_name + FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name + WHERE c.column_name = $1 + AND t.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + AND EXISTS ( + SELECT 1 FROM information_schema.columns c2 + WHERE c2.table_name = t.table_name + AND c2.column_name = 'company_code' + ) + `; + + const tablesResult = await pool.query(tablesQuery, [columnName]); + + // 각 테이블에서 영향받을 행 수 계산 + const preview = []; + const tableRows = Array.isArray(tablesResult) ? tablesResult : (tablesResult.rows || []); + + for (const row of tableRows) { + const tableName = row.table_name; + + // 동적 SQL 생성 (테이블명과 컬럼명은 파라미터 바인딩 불가) + // SQL 인젝션 방지: 테이블명과 컬럼명은 information_schema에서 검증된 값 + const countQuery = `SELECT COUNT(*) as count FROM "${tableName}" WHERE "${columnName}" = $1 AND company_code = $2`; + + try { + const countResult = await pool.query(countQuery, [oldValue, companyCode]); + const count = parseInt(countResult.rows[0].count); + + if (count > 0) { + preview.push({ + tableName, + affectedRows: count, + }); + } + } catch (error: any) { + logger.warn(`테이블 ${tableName} 조회 실패:`, error.message); + // 테이블 접근 실패 시 건너뛰기 + continue; + } + } + + const totalRows = preview.reduce((sum, item) => sum + item.affectedRows, 0); + + logger.info("코드 병합 미리보기 완료", { + tablesCount: preview.length, + totalRows, + }); + + res.json({ + success: true, + message: "코드 병합 미리보기 완료", + data: { + columnName, + oldValue, + preview, + totalAffectedRows: totalRows, + }, + }); + } catch (error: any) { + logger.error("코드 병합 미리보기 실패:", error); + + res.status(500).json({ + success: false, + message: "코드 병합 미리보기 중 오류가 발생했습니다.", + error: { + code: "PREVIEW_ERROR", + details: error.message, + }, + }); + } +} + diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 42b6172f..f37bc542 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -2,15 +2,15 @@ * 채번 규칙 관리 컨트롤러 */ -import { Router, Request, Response } from "express"; -import { authenticateToken } from "../middleware/authMiddleware"; +import { Router, Response } from "express"; +import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware"; import { numberingRuleService } from "../services/numberingRuleService"; import { logger } from "../utils/logger"; const router = Router(); -// 규칙 목록 조회 -router.get("/", authenticateToken, async (req: Request, res: Response) => { +// 규칙 목록 조회 (전체) +router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; try { @@ -22,8 +22,25 @@ router.get("/", authenticateToken, async (req: Request, res: Response) => { } }); +// 메뉴별 사용 가능한 규칙 조회 +router.get("/available/:menuObjid?", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; + + try { + const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid); + return res.json({ success: true, data: rules }); + } catch (error: any) { + logger.error("메뉴별 사용 가능한 규칙 조회 실패", { + error: error.message, + menuObjid, + }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + // 특정 규칙 조회 -router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) => { +router.get("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; @@ -40,7 +57,7 @@ router.get("/:ruleId", authenticateToken, async (req: Request, res: Response) => }); // 규칙 생성 -router.post("/", authenticateToken, async (req: Request, res: Response) => { +router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const ruleConfig = req.body; @@ -66,7 +83,7 @@ router.post("/", authenticateToken, async (req: Request, res: Response) => { }); // 규칙 수정 -router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) => { +router.put("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; const updates = req.body; @@ -84,7 +101,7 @@ router.put("/:ruleId", authenticateToken, async (req: Request, res: Response) => }); // 규칙 삭제 -router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response) => { +router.delete("/:ruleId", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; @@ -100,14 +117,42 @@ router.delete("/:ruleId", authenticateToken, async (req: Request, res: Response) } }); -// 코드 생성 -router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Response) => { +// 코드 미리보기 (순번 증가 없음) +router.post("/:ruleId/preview", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + const previewCode = await numberingRuleService.previewCode(ruleId, companyCode); + return res.json({ success: true, data: { generatedCode: previewCode } }); + } catch (error: any) { + logger.error("코드 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 코드 할당 (저장 시점에 실제 순번 증가) +router.post("/:ruleId/allocate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { ruleId } = req.params; + + try { + const allocatedCode = await numberingRuleService.allocateCode(ruleId, companyCode); + return res.json({ success: true, data: { generatedCode: allocatedCode } }); + } catch (error: any) { + logger.error("코드 할당 실패", { error: error.message }); + return res.status(500).json({ success: false, error: error.message }); + } +}); + +// 코드 생성 (기존 호환성 유지, deprecated) +router.post("/:ruleId/generate", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; try { const generatedCode = await numberingRuleService.generateCode(ruleId, companyCode); - return res.json({ success: true, data: { code: generatedCode } }); + return res.json({ success: true, data: { generatedCode } }); } catch (error: any) { logger.error("코드 생성 실패", { error: error.message }); return res.status(500).json({ success: false, error: error.message }); @@ -115,7 +160,7 @@ router.post("/:ruleId/generate", authenticateToken, async (req: Request, res: Re }); // 시퀀스 초기화 -router.post("/:ruleId/reset", authenticateToken, async (req: Request, res: Response) => { +router.post("/:ruleId/reset", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { const companyCode = req.user!.companyCode; const { ruleId } = req.params; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index ccca89b0..c9449e94 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -24,6 +24,7 @@ import { deleteCompany, // 회사 삭제 getUserLocale, setUserLocale, + getTableSchema, // 테이블 스키마 조회 } from "../controllers/adminController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -67,4 +68,7 @@ router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제 router.get("/user-locale", getUserLocale); router.post("/user-locale", setUserLocale); +// 테이블 스키마 API (엑셀 업로드 컬럼 매핑용) +router.get("/tables/:tableName/schema", getTableSchema); + export default router; diff --git a/backend-node/src/routes/codeMergeRoutes.ts b/backend-node/src/routes/codeMergeRoutes.ts new file mode 100644 index 00000000..78cbd3e1 --- /dev/null +++ b/backend-node/src/routes/codeMergeRoutes.ts @@ -0,0 +1,35 @@ +import express from "express"; +import { + mergeCodeAllTables, + getTablesWithColumn, + previewCodeMerge, +} from "../controllers/codeMergeController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = express.Router(); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + +/** + * POST /api/code-merge/merge-all-tables + * 코드 병합 실행 (모든 관련 테이블에 적용) + * Body: { columnName, oldValue, newValue } + */ +router.post("/merge-all-tables", mergeCodeAllTables); + +/** + * GET /api/code-merge/tables-with-column/:columnName + * 특정 컬럼을 가진 테이블 목록 조회 + */ +router.get("/tables-with-column/:columnName", getTablesWithColumn); + +/** + * POST /api/code-merge/preview + * 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인) + * Body: { columnName, oldValue } + */ +router.post("/preview", previewCodeMerge); + +export default router; + diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index c61fce29..cad0727e 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -26,6 +26,8 @@ interface NumberingRuleConfig { tableName?: string; columnName?: string; companyCode?: string; + menuObjid?: number; + scopeType?: string; createdAt?: string; updatedAt?: string; createdBy?: string; @@ -33,7 +35,7 @@ interface NumberingRuleConfig { class NumberingRuleService { /** - * 규칙 목록 조회 + * 규칙 목록 조회 (전체) */ async getRuleList(companyCode: string): Promise { try { @@ -78,11 +80,16 @@ class NumberingRuleService { ORDER BY part_order `; - const partsResult = await pool.query(partsQuery, [rule.ruleId, companyCode]); + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); rule.parts = partsResult.rows; } - logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { companyCode }); + logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { + companyCode, + }); return result.rows; } catch (error: any) { logger.error(`채번 규칙 목록 조회 중 에러: ${error.message}`); @@ -90,10 +97,170 @@ class NumberingRuleService { } } + /** + * 현재 메뉴에서 사용 가능한 규칙 목록 조회 + */ + async getAvailableRulesForMenu( + companyCode: string, + menuObjid?: number + ): Promise { + try { + logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", { + companyCode, + menuObjid, + }); + + const pool = getPool(); + + // menuObjid가 없으면 global 규칙만 반환 + if (!menuObjid) { + const query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE (company_code = $1 OR company_code = '*') + AND scope_type = 'global' + ORDER BY created_at DESC + `; + + const result = await pool.query(query, [companyCode]); + + // 파트 정보 추가 + for (const rule of result.rows) { + const partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY part_order + `; + + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); + rule.parts = partsResult.rows; + } + + return result.rows; + } + + // 현재 메뉴의 상위 계층 조회 (2레벨 메뉴 찾기) + const menuHierarchyQuery = ` + WITH RECURSIVE menu_path AS ( + SELECT objid, objid_parent, menu_level + FROM menu_info + WHERE objid = $1 + + UNION ALL + + SELECT mi.objid, mi.objid_parent, mi.menu_level + FROM menu_info mi + INNER JOIN menu_path mp ON mi.objid = mp.objid_parent + ) + SELECT objid, menu_level + FROM menu_path + WHERE menu_level = 2 + LIMIT 1 + `; + + const hierarchyResult = await pool.query(menuHierarchyQuery, [menuObjid]); + const level2MenuObjid = + hierarchyResult.rowCount > 0 ? hierarchyResult.rows[0].objid : null; + + // 사용 가능한 규칙 조회 + const query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE (company_code = $1 OR company_code = '*') + AND ( + scope_type = 'global' + OR (scope_type = 'menu' AND menu_objid = $2) + ) + ORDER BY scope_type DESC, created_at DESC + `; + + const result = await pool.query(query, [companyCode, level2MenuObjid]); + + // 파트 정보 추가 + for (const rule of result.rows) { + const partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND (company_code = $2 OR company_code = '*') + ORDER BY part_order + `; + + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode, + ]); + rule.parts = partsResult.rows; + } + + logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { + companyCode, + menuObjid, + level2MenuObjid, + count: result.rowCount, + }); + + return result.rows; + } catch (error: any) { + logger.error("메뉴별 채번 규칙 조회 실패", { + error: error.message, + companyCode, + menuObjid, + }); + throw error; + } + } + /** * 특정 규칙 조회 */ - async getRuleById(ruleId: string, companyCode: string): Promise { + async getRuleById( + ruleId: string, + companyCode: string + ): Promise { const pool = getPool(); const query = ` SELECT @@ -106,7 +273,7 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", - menu_id AS "menuId", + menu_objid AS "menuObjid", scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", @@ -223,7 +390,10 @@ class NumberingRuleService { } await client.query("COMMIT"); - logger.info("채번 규칙 생성 완료", { ruleId: config.ruleId, companyCode }); + logger.info("채번 규칙 생성 완료", { + ruleId: config.ruleId, + companyCode, + }); return { ...ruleResult.rows[0], parts }; } catch (error: any) { await client.query("ROLLBACK"); @@ -364,9 +534,63 @@ class NumberingRuleService { } /** - * 코드 생성 + * 코드 미리보기 (순번 증가 없음) */ - async generateCode(ruleId: string, companyCode: string): Promise { + async previewCode(ruleId: string, companyCode: string): Promise { + const rule = await this.getRuleById(ruleId, companyCode); + if (!rule) throw new Error("규칙을 찾을 수 없습니다"); + + const parts = rule.parts + .sort((a: any, b: any) => a.order - b.order) + .map((part: any) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || ""; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "sequence": { + // 순번 (현재 순번으로 미리보기, 증가 안 함) + const length = autoConfig.sequenceLength || 4; + return String(rule.currentSequence || 1).padStart(length, "0"); + } + + case "number": { + // 숫자 (고정 자릿수) + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 1; + return String(value).padStart(length, "0"); + } + + case "date": { + // 날짜 (다양한 날짜 형식) + return this.formatDate( + new Date(), + autoConfig.dateFormat || "YYYYMMDD" + ); + } + + case "text": { + // 텍스트 (고정 문자열) + return autoConfig.textValue || "TEXT"; + } + + default: + logger.warn("알 수 없는 파트 타입", { partType: part.partType }); + return ""; + } + }); + + const previewCode = parts.join(rule.separator || ""); + logger.info("코드 미리보기 생성", { ruleId, previewCode, companyCode }); + return previewCode; + } + + /** + * 코드 할당 (저장 시점에 실제 순번 증가) + */ + async allocateCode(ruleId: string, companyCode: string): Promise { const pool = getPool(); const client = await pool.connect(); @@ -386,37 +610,44 @@ class NumberingRuleService { const autoConfig = part.autoConfig || {}; switch (part.partType) { - case "prefix": - return autoConfig.prefix || "PREFIX"; - case "sequence": { + // 순번 (자동 증가 숫자) const length = autoConfig.sequenceLength || 4; return String(rule.currentSequence || 1).padStart(length, "0"); } - case "date": - return this.formatDate(new Date(), autoConfig.dateFormat || "YYYYMMDD"); - - case "year": { - const format = autoConfig.dateFormat || "YYYY"; - const year = new Date().getFullYear(); - return format === "YY" ? String(year).slice(-2) : String(year); + case "number": { + // 숫자 (고정 자릿수) + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 1; + return String(value).padStart(length, "0"); } - case "month": - return String(new Date().getMonth() + 1).padStart(2, "0"); + case "date": { + // 날짜 (다양한 날짜 형식) + return this.formatDate( + new Date(), + autoConfig.dateFormat || "YYYYMMDD" + ); + } - case "custom": - return autoConfig.value || "CUSTOM"; + case "text": { + // 텍스트 (고정 문자열) + return autoConfig.textValue || "TEXT"; + } default: + logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; } }); - const generatedCode = parts.join(rule.separator || ""); + const allocatedCode = parts.join(rule.separator || ""); - const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); + // 순번이 있는 경우에만 증가 + const hasSequence = rule.parts.some( + (p: any) => p.partType === "sequence" + ); if (hasSequence) { await client.query( "UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2", @@ -425,30 +656,52 @@ class NumberingRuleService { } await client.query("COMMIT"); - logger.info("코드 생성 완료", { ruleId, generatedCode }); - return generatedCode; + logger.info("코드 할당 완료", { ruleId, allocatedCode, companyCode }); + return allocatedCode; } catch (error: any) { await client.query("ROLLBACK"); - logger.error("코드 생성 실패", { error: error.message }); + logger.error("코드 할당 실패", { + ruleId, + companyCode, + error: error.message, + stack: error.stack, + }); throw error; } finally { client.release(); } } + /** + * @deprecated 기존 generateCode는 allocateCode를 사용하세요 + */ + async generateCode(ruleId: string, companyCode: string): Promise { + logger.warn( + "generateCode는 deprecated 되었습니다. previewCode 또는 allocateCode를 사용하세요" + ); + return this.allocateCode(ruleId, companyCode); + } + private formatDate(date: Date, format: string): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); switch (format) { - case "YYYY": return String(year); - case "YY": return String(year).slice(-2); - case "YYYYMM": return `${year}${month}`; - case "YYMM": return `${String(year).slice(-2)}${month}`; - case "YYYYMMDD": return `${year}${month}${day}`; - case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; - default: return `${year}${month}${day}`; + case "YYYY": + return String(year); + case "YY": + return String(year).slice(-2); + case "YYYYMM": + return `${year}${month}`; + case "YYMM": + return `${String(year).slice(-2)}${month}`; + case "YYYYMMDD": + return `${year}${month}${day}`; + case "YYMMDD": + return `${String(year).slice(-2)}${month}${day}`; + default: + return `${year}${month}${day}`; } } diff --git a/docs/품목정보.html b/docs/품목정보.html new file mode 100644 index 00000000..1df8a673 --- /dev/null +++ b/docs/품목정보.html @@ -0,0 +1,3915 @@ + + + + + + 품목 기본정보 + + + + + + + + + + + + +
+ +
+ + +
+
+
+
+ 총 15개 +
+ +
+
+
+ + + + + +
+
+
+
+
+ + +
+
+
+

⚙️ 옵션 설정

+ +
+
+ + + +
+
+ +
+ +
+
+ + + + + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 5a4b3352..0c9a681b 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -40,6 +40,11 @@ export default function ScreenViewPage() { // 테이블에서 선택된 행 데이터 (버튼 액션에 전달) const [selectedRowsData, setSelectedRowsData] = useState([]); + // 테이블 정렬 정보 (엑셀 다운로드용) + const [tableSortBy, setTableSortBy] = useState(); + const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc"); + const [tableColumnOrder, setTableColumnOrder] = useState(); + // 플로우에서 선택된 데이터 (버튼 액션에 전달) const [flowSelectedData, setFlowSelectedData] = useState([]); const [flowSelectedStepId, setFlowSelectedStepId] = useState(null); @@ -425,9 +430,16 @@ export default function ScreenViewPage() { userName={userName} companyCode={companyCode} selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); }} flowSelectedData={flowSelectedData} flowSelectedStepId={flowSelectedStepId} @@ -479,9 +491,16 @@ export default function ScreenViewPage() { userName={userName} companyCode={companyCode} selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); }} refreshKey={tableRefreshKey} onRefresh={() => { @@ -613,8 +632,14 @@ export default function ScreenViewPage() { userName={userName} companyCode={companyCode} selectedRowsData={selectedRowsData} - onSelectedRowsChange={(_, selectedData) => { + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); }} flowSelectedData={flowSelectedData} flowSelectedStepId={flowSelectedStepId} diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 6f909357..9c28e28c 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useRef } from "react"; +import React, { useState, useRef, useEffect } from "react"; import { Dialog, DialogContent, @@ -19,10 +19,23 @@ import { 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 } from "lucide-react"; +import { + Upload, + FileSpreadsheet, + AlertCircle, + CheckCircle2, + Plus, + Minus, + ArrowRight, + Save, + Zap, +} from "lucide-react"; 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"; export interface ExcelUploadModalProps { open: boolean; @@ -33,6 +46,17 @@ export interface ExcelUploadModalProps { onSuccess?: () => void; } +interface ColumnMapping { + excelColumn: string; + systemColumn: string | null; +} + +interface UploadConfig { + name: string; + type: string; + mappings: ColumnMapping[]; +} + export const ExcelUploadModal: React.FC = ({ open, onOpenChange, @@ -41,19 +65,38 @@ export const ExcelUploadModal: React.FC = ({ keyColumn, onSuccess, }) => { + const [currentStep, setCurrentStep] = useState(1); + + // 1단계: 파일 선택 const [file, setFile] = useState(null); const [sheetNames, setSheetNames] = useState([]); const [selectedSheet, setSelectedSheet] = useState(""); - const [isUploading, setIsUploading] = useState(false); - const [previewData, setPreviewData] = useState[]>([]); const fileInputRef = useRef(null); + // 2단계: 범위 지정 + const [autoCreateColumn, setAutoCreateColumn] = useState(false); + const [selectedCompany, setSelectedCompany] = useState(""); + const [selectedDataType, setSelectedDataType] = useState(""); + const [detectedRange, setDetectedRange] = useState(""); + const [previewData, setPreviewData] = useState[]>([]); + const [allData, setAllData] = useState[]>([]); + const [displayData, setDisplayData] = useState[]>([]); + + // 3단계: 컬럼 매핑 + const [excelColumns, setExcelColumns] = useState([]); + const [systemColumns, setSystemColumns] = useState([]); + const [columnMappings, setColumnMappings] = useState([]); + const [configName, setConfigName] = useState(""); + const [configType, setConfigType] = useState(""); + + // 4단계: 확인 + const [isUploading, setIsUploading] = useState(false); + // 파일 선택 핸들러 const handleFileChange = async (e: React.ChangeEvent) => { const selectedFile = e.target.files?.[0]; if (!selectedFile) return; - // 파일 확장자 검증 const fileExtension = selectedFile.name.split(".").pop()?.toLowerCase(); if (!["xlsx", "xls", "csv"].includes(fileExtension || "")) { toast.error("엑셀 파일만 업로드 가능합니다. (.xlsx, .xls, .csv)"); @@ -63,14 +106,20 @@ export const ExcelUploadModal: React.FC = ({ setFile(selectedFile); try { - // 시트 목록 가져오기 const sheets = await getExcelSheetNames(selectedFile); setSheetNames(sheets); setSelectedSheet(sheets[0] || ""); - // 미리보기 데이터 로드 (첫 5행) const data = await importFromExcel(selectedFile, sheets[0]); - setPreviewData(data.slice(0, 5)); + setAllData(data); + setDisplayData(data.slice(0, 10)); + + if (data.length > 0) { + const columns = Object.keys(data[0]); + const lastCol = String.fromCharCode(64 + columns.length); + setDetectedRange(`A1:${lastCol}${data.length + 1}`); + setExcelColumns(columns); + } toast.success(`파일이 선택되었습니다: ${selectedFile.name}`); } catch (error) { @@ -83,124 +132,223 @@ export const ExcelUploadModal: React.FC = ({ // 시트 변경 핸들러 const handleSheetChange = async (sheetName: string) => { setSelectedSheet(sheetName); - if (!file) return; try { const data = await importFromExcel(file, sheetName); - setPreviewData(data.slice(0, 5)); + setAllData(data); + setDisplayData(data.slice(0, 10)); + + if (data.length > 0) { + const columns = Object.keys(data[0]); + const lastCol = String.fromCharCode(64 + columns.length); + setDetectedRange(`A1:${lastCol}${data.length + 1}`); + setExcelColumns(columns); + } } catch (error) { console.error("시트 읽기 오류:", error); toast.error("시트를 읽는 중 오류가 발생했습니다."); } }; - // 업로드 핸들러 - const handleUpload = async () => { - if (!file) { + // 행 추가 + const handleAddRow = () => { + const newRow: Record = {}; + excelColumns.forEach((col) => { + newRow[col] = ""; + }); + setDisplayData([...displayData, newRow]); + toast.success("행이 추가되었습니다."); + }; + + // 행 삭제 + const handleRemoveRow = () => { + if (displayData.length > 1) { + setDisplayData(displayData.slice(0, -1)); + toast.success("마지막 행이 삭제되었습니다."); + } else { + toast.error("최소 1개의 행이 필요합니다."); + } + }; + + // 열 추가 + const handleAddColumn = () => { + const newColName = `Column${excelColumns.length + 1}`; + setExcelColumns([...excelColumns, newColName]); + setDisplayData( + displayData.map((row) => ({ + ...row, + [newColName]: "", + })) + ); + toast.success("열이 추가되었습니다."); + }; + + // 열 삭제 + const handleRemoveColumn = () => { + if (excelColumns.length > 1) { + const lastCol = excelColumns[excelColumns.length - 1]; + setExcelColumns(excelColumns.slice(0, -1)); + setDisplayData( + displayData.map((row) => { + const { [lastCol]: removed, ...rest } = row; + return rest; + }) + ); + toast.success("마지막 열이 삭제되었습니다."); + } else { + toast.error("최소 1개의 열이 필요합니다."); + } + }; + + // 테이블 스키마 가져오기 + useEffect(() => { + if (currentStep === 3 && tableName) { + loadTableSchema(); + } + }, [currentStep, tableName]); + + const loadTableSchema = async () => { + try { + console.log("🔍 테이블 스키마 로드 시작:", { tableName }); + + const response = await getTableSchema(tableName); + + console.log("📊 테이블 스키마 응답:", response); + + if (response.success && response.data) { + console.log("✅ 시스템 컬럼 로드 완료:", response.data.columns); + setSystemColumns(response.data.columns); + + const initialMappings: ColumnMapping[] = excelColumns.map((col) => ({ + excelColumn: col, + systemColumn: null, + })); + setColumnMappings(initialMappings); + } else { + console.error("❌ 테이블 스키마 로드 실패:", response); + } + } catch (error) { + console.error("❌ 테이블 스키마 로드 실패:", error); + toast.error("테이블 스키마를 불러올 수 없습니다."); + } + }; + + // 자동 매핑 + const handleAutoMapping = () => { + const newMappings = excelColumns.map((excelCol) => { + const matchedSystemCol = systemColumns.find( + (sysCol) => sysCol.name.toLowerCase() === excelCol.toLowerCase() + ); + + return { + excelColumn: excelCol, + systemColumn: matchedSystemCol ? matchedSystemCol.name : null, + }; + }); + + setColumnMappings(newMappings); + const matchedCount = newMappings.filter((m) => m.systemColumn).length; + toast.success(`${matchedCount}개 컬럼이 자동 매핑되었습니다.`); + }; + + // 컬럼 매핑 변경 + const handleMappingChange = (excelColumn: string, systemColumn: string | null) => { + setColumnMappings((prev) => + prev.map((mapping) => + mapping.excelColumn === excelColumn + ? { ...mapping, systemColumn } + : mapping + ) + ); + }; + + // 설정 저장 + const handleSaveConfig = () => { + if (!configName.trim()) { + toast.error("거래처명을 입력해주세요."); + return; + } + + const config: UploadConfig = { + name: configName, + type: configType, + mappings: columnMappings, + }; + + const savedConfigs = JSON.parse( + localStorage.getItem("excelUploadConfigs") || "[]" + ); + savedConfigs.push(config); + localStorage.setItem("excelUploadConfigs", JSON.stringify(savedConfigs)); + + toast.success("설정이 저장되었습니다."); + }; + + // 다음 단계 + const handleNext = () => { + if (currentStep === 1 && !file) { toast.error("파일을 선택해주세요."); return; } - if (!tableName) { - toast.error("테이블명이 지정되지 않았습니다."); + if (currentStep === 2 && displayData.length === 0) { + toast.error("데이터가 없습니다."); + return; + } + + setCurrentStep((prev) => Math.min(prev + 1, 4)); + }; + + // 이전 단계 + const handlePrevious = () => { + setCurrentStep((prev) => Math.max(prev - 1, 1)); + }; + + // 업로드 핸들러 + const handleUpload = async () => { + if (!file || !tableName) { + toast.error("필수 정보가 누락되었습니다."); return; } setIsUploading(true); try { - // 엑셀 데이터 읽기 - const data = await importFromExcel(file, selectedSheet); - - console.log("📤 엑셀 업로드 시작:", { - tableName, - uploadMode, - rowCount: data.length, + const mappedData = displayData.map((row) => { + const mappedRow: Record = {}; + columnMappings.forEach((mapping) => { + if (mapping.systemColumn) { + mappedRow[mapping.systemColumn] = row[mapping.excelColumn]; + } + }); + return mappedRow; }); - // 업로드 모드에 따라 처리 let successCount = 0; let failCount = 0; - for (const row of data) { + for (const row of mappedData) { try { if (uploadMode === "insert") { - // 삽입 모드 const formData = { screenId: 0, tableName, data: row }; const result = await DynamicFormApi.saveFormData(formData); if (result.success) { successCount++; } else { - console.error("저장 실패:", result.message, row); failCount++; } - } else if (uploadMode === "update" && keyColumn) { - // 업데이트 모드 - const keyValue = row[keyColumn]; - if (keyValue) { - await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row); - successCount++; - } else { - failCount++; - } - } else if (uploadMode === "upsert" && keyColumn) { - // Upsert 모드 (있으면 업데이트, 없으면 삽입) - const keyValue = row[keyColumn]; - if (keyValue) { - try { - const updateResult = await DynamicFormApi.updateFormDataPartial(tableName, keyValue, row); - if (!updateResult.success) { - // 업데이트 실패 시 삽입 시도 - const formData = { screenId: 0, tableName, data: row }; - const insertResult = await DynamicFormApi.saveFormData(formData); - if (insertResult.success) { - successCount++; - } else { - console.error("Upsert 실패:", insertResult.message, row); - failCount++; - } - } else { - successCount++; - } - } catch { - const formData = { screenId: 0, tableName, data: row }; - const insertResult = await DynamicFormApi.saveFormData(formData); - if (insertResult.success) { - successCount++; - } else { - console.error("Upsert 실패:", insertResult.message, row); - failCount++; - } - } - } else { - const formData = { screenId: 0, tableName, data: row }; - const result = await DynamicFormApi.saveFormData(formData); - if (result.success) { - successCount++; - } else { - console.error("저장 실패:", result.message, row); - failCount++; - } - } } } catch (error) { - console.error("행 처리 오류:", row, error); failCount++; } } - console.log("✅ 엑셀 업로드 완료:", { - successCount, - failCount, - totalCount: data.length, - }); - if (successCount > 0) { - toast.success(`${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}`); - // onSuccess 내부에서 closeModal이 호출되므로 여기서는 호출하지 않음 + toast.success( + `${successCount}개 행이 업로드되었습니다.${failCount > 0 ? ` (실패: ${failCount}개)` : ""}` + ); onSuccess?.(); - // onOpenChange(false); // 제거: onSuccess에서 이미 모달을 닫음 } else { toast.error("업로드에 실패했습니다."); } @@ -212,114 +360,492 @@ export const ExcelUploadModal: React.FC = ({ } }; + // 모달 닫기 시 초기화 + useEffect(() => { + if (!open) { + setCurrentStep(1); + setFile(null); + setSheetNames([]); + setSelectedSheet(""); + setAutoCreateColumn(false); + setSelectedCompany(""); + setSelectedDataType(""); + setDetectedRange(""); + setPreviewData([]); + setAllData([]); + setDisplayData([]); + setExcelColumns([]); + setSystemColumns([]); + setColumnMappings([]); + setConfigName(""); + setConfigType(""); + } + }, [open]); + return ( - + - 엑셀 파일 업로드 + + + 엑셀 데이터 업로드 + - 엑셀 파일을 선택하여 데이터를 업로드하세요. + 엑셀 파일을 선택하고 컬럼을 매핑하여 데이터를 업로드하세요. -
- {/* 파일 선택 */} -
- -
- - -
-

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

-
+ {/* 스텝 인디케이터 */} +
+ {[ + { num: 1, label: "파일 선택" }, + { num: 2, label: "범위 지정" }, + { num: 3, label: "컬럼 매핑" }, + { num: 4, label: "확인" }, + ].map((step, index) => ( + +
+
step.num + ? "bg-success text-white" + : "bg-muted text-muted-foreground" + )} + > + {currentStep > step.num ? ( + + ) : ( + step.num + )} +
+ + {step.label} + +
+ {index < 3 && ( +
step.num ? "bg-success" : "bg-muted" + )} + /> + )} + + ))} +
- {/* 시트 선택 */} - {sheetNames.length > 0 && ( -
- - + {/* 스텝별 컨텐츠 */} +
+ {/* 1단계: 파일 선택 */} + {currentStep === 1 && ( +
+
+ +
+ + +
+

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

+
+ + {sheetNames.length > 0 && ( +
+ + +
+ )}
)} - {/* 업로드 모드 정보 */} -
-
- -
-

업로드 모드: {uploadMode === "insert" ? "삽입" : uploadMode === "update" ? "업데이트" : "Upsert"}

-

- {uploadMode === "insert" && "새로운 데이터로 삽입됩니다."} - {uploadMode === "update" && `기존 데이터를 업데이트합니다. (키: ${keyColumn})`} - {uploadMode === "upsert" && `있으면 업데이트, 없으면 삽입합니다. (키: ${keyColumn})`} -

-
-
-
+ {/* 2단계: 범위 지정 */} + {currentStep === 2 && ( +
+ {/* 상단: 3개 드롭다운 가로 배치 */} +
+ - {/* 미리보기 */} - {previewData.length > 0 && ( -
- -
- - - - {Object.keys(previewData[0]).map((key) => ( - + + {excelColumns.map((col) => ( + + ))} + + ))} + +
- {key} + + + + + + {/* 중간: 체크박스 + 버튼들 한 줄 배치 */} +
+
+ setAutoCreateColumn(checked as boolean)} + /> + +
+ +
+ + + + +
+
+ + {/* 하단: 감지된 범위 + 테이블 */} +
+ 감지된 범위: {detectedRange} + + 첫 행이 컬럼명, 데이터는 자동 감지됩니다 + +
+ + {displayData.length > 0 && ( +
+ + + + - ))} - - - - {previewData.map((row, index) => ( - - {Object.values(row).map((value, i) => ( - + ))} + + + + + + {excelColumns.map((col) => ( + ))} - ))} - -
+
- {String(value)} + {excelColumns.map((col, index) => ( + + {String.fromCharCode(65 + index)} +
+ 1 + + {col}
+ {displayData.map((row, rowIndex) => ( +
+ {rowIndex + 2} + + {String(row[col] || "")} +
+
+ )} +
+ )} + + {/* 3단계: 컬럼 매핑 - 3단 레이아웃 */} + {currentStep === 3 && ( +
+ {/* 왼쪽: 컬럼 매핑 설정 제목 + 자동 매핑 버튼 */} +
+
+

컬럼 매핑 설정

+ +
-
- - 총 {previewData.length}개 행 (미리보기) + + {/* 중앙: 매핑 리스트 */} +
+
+
엑셀 컬럼
+
+
시스템 컬럼
+
+ +
+ {columnMappings.map((mapping, index) => ( +
+
+ {mapping.excelColumn} +
+ + +
+ ))} +
+
+ + {/* 오른쪽: 현재 설정 저장 */} +
+
+ +

현재 설정 저장

+
+
+
+ + setConfigName(e.target.value)} + placeholder="거래처 선택" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setConfigType(e.target.value)} + placeholder="유형을 입력하세요 (예: 원자재)" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+
+
+ )} + + {/* 4단계: 확인 */} + {currentStep === 4 && ( +
+
+

업로드 요약

+
+

+ 파일: {file?.name} +

+

+ 시트: {selectedSheet} +

+

+ 데이터 행: {displayData.length}개 +

+

+ 테이블: {tableName} +

+

+ 모드:{" "} + {uploadMode === "insert" + ? "삽입" + : uploadMode === "update" + ? "업데이트" + : "Upsert"} +

+
+
+ +
+

컬럼 매핑

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

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

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

매핑된 컬럼이 없습니다.

+ )} +
+
+ +
+
+ +
+

주의사항

+

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

+
+
)} @@ -328,22 +854,31 @@ export const ExcelUploadModal: React.FC = ({ - + {currentStep < 4 ? ( + + ) : ( + + )}
); }; - diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 96c88201..3886ea36 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -109,9 +109,7 @@ export const NumberingRuleDesigner: React.FC = ({ if (!prev) return null; return { ...prev, - parts: prev.parts - .filter((part) => part.id !== partId) - .map((part, index) => ({ ...part, order: index + 1 })), + parts: prev.parts.filter((part) => part.id !== partId).map((part, index) => ({ ...part, order: index + 1 })), }; }); @@ -132,7 +130,7 @@ export const NumberingRuleDesigner: React.FC = ({ setLoading(true); try { const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId); - + let response; if (existing) { response = await updateNumberingRule(currentRule.ruleId, currentRule); @@ -170,29 +168,32 @@ export const NumberingRuleDesigner: React.FC = ({ toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`); }, []); - const handleDeleteSavedRule = useCallback(async (ruleId: string) => { - setLoading(true); - try { - const response = await deleteNumberingRule(ruleId); - - if (response.success) { - setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId)); - - if (selectedRuleId === ruleId) { - setSelectedRuleId(null); - setCurrentRule(null); + const handleDeleteSavedRule = useCallback( + async (ruleId: string) => { + setLoading(true); + try { + const response = await deleteNumberingRule(ruleId); + + if (response.success) { + setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId)); + + if (selectedRuleId === ruleId) { + setSelectedRuleId(null); + setCurrentRule(null); + } + + toast.success("규칙이 삭제되었습니다"); + } else { + toast.error(response.error || "삭제 실패"); } - - toast.success("규칙이 삭제되었습니다"); - } else { - toast.error(response.error || "삭제 실패"); + } catch (error: any) { + toast.error(`삭제 실패: ${error.message}`); + } finally { + setLoading(false); } - } catch (error: any) { - toast.error(`삭제 실패: ${error.message}`); - } finally { - setLoading(false); - } - }, [selectedRuleId]); + }, + [selectedRuleId], + ); const handleNewRule = useCallback(() => { const newRule: NumberingRuleConfig = { @@ -207,7 +208,7 @@ export const NumberingRuleDesigner: React.FC = ({ setSelectedRuleId(newRule.ruleId); setCurrentRule(newRule); - + toast.success("새 규칙이 생성되었습니다"); }, []); @@ -228,35 +229,29 @@ export const NumberingRuleDesigner: React.FC = ({ ) : (

{leftTitle}

)} -
{loading ? (
-

로딩 중...

+

로딩 중...

) : savedRules.length === 0 ? ( -
-

저장된 규칙이 없습니다

+
+

저장된 규칙이 없습니다

) : ( savedRules.map((rule) => ( handleSelectRule(rule)} @@ -265,9 +260,7 @@ export const NumberingRuleDesigner: React.FC = ({
{rule.ruleName} -

- 규칙 {rule.parts.length}개 -

+

규칙 {rule.parts.length}개

@@ -292,19 +285,15 @@ export const NumberingRuleDesigner: React.FC = ({
{/* 구분선 */} -
+
{/* 우측: 편집 영역 */}
{!currentRule ? (
-

- 규칙을 선택해주세요 -

-

- 좌측에서 규칙을 선택하거나 새로 생성하세요 -

+

규칙을 선택해주세요

+

좌측에서 규칙을 선택하거나 새로 생성하세요

) : ( @@ -322,12 +311,7 @@ export const NumberingRuleDesigner: React.FC = ({ ) : (

{rightTitle}

)} -
@@ -336,9 +320,7 @@ export const NumberingRuleDesigner: React.FC = ({ - setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value })) - } + onChange={(e) => setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))} className="h-9" placeholder="예: 프로젝트 코드" /> @@ -348,9 +330,7 @@ export const NumberingRuleDesigner: React.FC = ({ -

- {currentRule.scopeType === "menu" - ? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다" +

+ {currentRule.scopeType === "menu" + ? "⚠️ 현재 화면이 속한 2레벨 메뉴와 그 하위 메뉴(3레벨 이상)에서만 사용됩니다. 형제 메뉴와 구분하여 채번 규칙을 관리할 때 유용합니다." : "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"}

@@ -380,16 +360,14 @@ export const NumberingRuleDesigner: React.FC = ({

코드 구성

- + {currentRule.parts.length}/{maxRules}
{currentRule.parts.length === 0 ? ( -
-

- 규칙을 추가하여 코드를 구성하세요 -

+
+

규칙을 추가하여 코드를 구성하세요

) : (
@@ -416,11 +394,7 @@ export const NumberingRuleDesigner: React.FC = ({ 규칙 추가 - diff --git a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx index 1d43b550..f1acae0b 100644 --- a/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx +++ b/frontend/components/screen/EnhancedInteractiveScreenViewer.tsx @@ -84,7 +84,7 @@ export const EnhancedInteractiveScreenViewer: React.FC { + async (autoValueType: string, ruleId?: string): Promise => { const now = new Date(); switch (autoValueType) { case "current_datetime": @@ -99,6 +99,20 @@ export const EnhancedInteractiveScreenViewer: React.FC { const widgetComponents = allComponents.filter((c) => c.type === "widget") as WidgetComponent[]; - const autoValueUpdates: Record = {}; + + const loadAutoValues = async () => { + const autoValueUpdates: Record = {}; - for (const widget of widgetComponents) { - const fieldName = widget.columnName || widget.id; - const currentValue = finalFormData[fieldName]; + for (const widget of widgetComponents) { + const fieldName = widget.columnName || widget.id; + const currentValue = finalFormData[fieldName]; - // 자동값이 설정되어 있고 현재 값이 없는 경우 - if (widget.inputType === "auto" && widget.autoValueType && !currentValue) { - const autoValue = generateAutoValue(widget.autoValueType); - if (autoValue) { - autoValueUpdates[fieldName] = autoValue; + // 자동값이 설정되어 있고 현재 값이 없는 경우 + if (widget.inputType === "auto" && widget.autoValueType && !currentValue) { + const autoValue = await generateAutoValue( + widget.autoValueType, + (widget as any).numberingRuleId // 채번 규칙 ID + ); + if (autoValue) { + autoValueUpdates[fieldName] = autoValue; + } } } - } - if (Object.keys(autoValueUpdates).length > 0) { - setLocalFormData((prev) => ({ ...prev, ...autoValueUpdates })); - } + if (Object.keys(autoValueUpdates).length > 0) { + setLocalFormData((prev) => ({ ...prev, ...autoValueUpdates })); + } + }; + + loadAutoValues(); }, [allComponents, finalFormData, generateAutoValue]); // 향상된 저장 핸들러 diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4b292541..a9cc663d 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -136,7 +136,7 @@ export const InteractiveScreenViewer: React.FC = ( : null; // 자동값 생성 함수 - const generateAutoValue = useCallback((autoValueType: string): string => { + const generateAutoValue = useCallback(async (autoValueType: string, ruleId?: string): Promise => { const now = new Date(); switch (autoValueType) { case "current_datetime": @@ -152,6 +152,20 @@ export const InteractiveScreenViewer: React.FC = ( return crypto.randomUUID(); case "sequence": return `SEQ_${Date.now()}`; + case "numbering_rule": + // 채번 규칙 사용 + if (ruleId) { + try { + const { generateNumberingCode } = await import("@/lib/api/numberingRule"); + const response = await generateNumberingCode(ruleId); + if (response.success && response.data) { + return response.data.generatedCode; + } + } catch (error) { + console.error("채번 규칙 코드 생성 실패:", error); + } + } + return ""; default: return ""; } diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 906d5ad6..777facef 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -63,6 +63,10 @@ interface RealtimePreviewProps { children?: React.ReactNode; // 그룹 내 자식 컴포넌트들 // 플로우 선택 데이터 전달용 onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; + // 테이블 정렬 정보 전달용 + sortBy?: string; + sortOrder?: "asc" | "desc"; + [key: string]: any; // 추가 props 허용 } // 영역 레이아웃에 따른 아이콘 반환 @@ -225,6 +229,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onGroupToggle, children, onFlowSelectedDataChange, + sortBy, + sortOrder, + ...restProps }) => { const { user } = useAuth(); const { type, id, position, size, style = {} } = component; @@ -545,7 +552,13 @@ export const RealtimePreviewDynamic: React.FC = ({ {/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */} {type === "widget" && !isFileComponent(component) && (
- +
)} diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 592d3048..ace56096 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -54,6 +54,11 @@ interface RealtimePreviewProps { // 폼 데이터 관련 props formData?: Record; onFormDataChange?: (fieldName: string, value: any) => void; + + // 테이블 정렬 정보 + sortBy?: string; + sortOrder?: "asc" | "desc"; + columnOrder?: string[]; } // 동적 위젯 타입 아이콘 (레지스트리에서 조회) @@ -109,6 +114,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onFlowSelectedDataChange, refreshKey, onRefresh, + sortBy, + sortOrder, + columnOrder, flowRefreshKey, onFlowRefresh, formData, @@ -404,6 +412,9 @@ export const RealtimePreviewDynamic: React.FC = ({ onFlowRefresh={onFlowRefresh} formData={formData} onFormDataChange={onFormDataChange} + sortBy={sortBy} + sortOrder={sortOrder} + columnOrder={columnOrder} />
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index ed180314..d057930f 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -610,16 +610,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const columnIndex = Math.round(effectiveX / (columnWidth + (gap || 16))); const snappedX = padding + columnIndex * (columnWidth + (gap || 16)); - // Y 좌표는 20px 단위로 스냅 + // Y 좌표는 10px 단위로 스냅 const effectiveY = newComp.position.y - padding; - const rowIndex = Math.round(effectiveY / 20); - const snappedY = padding + rowIndex * 20; + const rowIndex = Math.round(effectiveY / 10); + const snappedY = padding + rowIndex * 10; // 크기도 외부 격자와 동일하게 스냅 const fullColumnWidth = columnWidth + (gap || 16); // 외부 격자와 동일한 크기 const widthInColumns = Math.max(1, Math.round(newComp.size.width / fullColumnWidth)); const snappedWidth = widthInColumns * fullColumnWidth - (gap || 16); // gap 제거하여 실제 컴포넌트 크기 - const snappedHeight = Math.max(40, Math.round(newComp.size.height / 20) * 20); + const snappedHeight = Math.max(10, Math.round(newComp.size.height / 10) * 10); newComp.position = { x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 5e1471ca..359063eb 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -270,6 +270,7 @@ export const ButtonConfigPanel: React.FC = ({ 엑셀 다운로드 엑셀 업로드 바코드 스캔 + 코드 병합
@@ -838,6 +839,53 @@ export const ButtonConfigPanel: React.FC = ({
)} + {/* 코드 병합 액션 설정 */} + {(component.componentConfig?.action?.type || "save") === "code_merge" && ( +
+

🔀 코드 병합 설정

+ +
+ + onUpdateProperty("componentConfig.action.mergeColumnName", e.target.value)} + className="h-8 text-xs" + /> +

+ 병합할 컬럼명 (예: item_code). 이 컬럼이 있는 모든 테이블에 병합이 적용됩니다. +

+
+ +
+
+ +

영향받을 테이블과 행 수를 미리 확인합니다

+
+ onUpdateProperty("componentConfig.action.mergeShowPreview", checked)} + /> +
+ +
+

+ 사용 방법: +
+ 1. 테이블에서 병합할 두 개의 행을 선택합니다 +
+ 2. 이 버튼을 클릭하면 병합 방향을 선택할 수 있습니다 +
+ 3. 데이터는 삭제되지 않고, 컬럼 값만 변경됩니다 +

+
+
+ )} + {/* 제어 기능 섹션 */}
diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index 88643c60..277c6e9c 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -961,27 +961,27 @@ const PropertiesPanelComponent: React.FC = ({
{ - const rows = Math.max(1, Math.min(20, Number(e.target.value))); - const newHeight = rows * 40; + const units = Math.max(1, Math.min(100, Number(e.target.value))); + const newHeight = units * 10; setLocalInputs((prev) => ({ ...prev, height: newHeight.toString() })); onUpdateProperty("size.height", newHeight); }} className="flex-1" /> - 행 = {localInputs.height || 40}px + 단위 = {localInputs.height || 10}px

- 1행 = 40px (현재 {Math.round((localInputs.height || 40) / 40)}행) - 내부 콘텐츠에 맞춰 늘어남 + 1단위 = 10px (현재 {Math.round((localInputs.height || 10) / 10)}단위) - 내부 콘텐츠에 맞춰 늘어남

diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 01716bb0..f2e50db8 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -364,11 +364,11 @@ export const UnifiedPropertiesPanel: React.FC = ({ value={selectedComponent.size?.height || 0} onChange={(e) => { const value = parseInt(e.target.value) || 0; - const roundedValue = Math.max(40, Math.round(value / 40) * 40); + const roundedValue = Math.max(10, Math.round(value / 10) * 10); handleUpdate("size.height", roundedValue); }} - step={40} - placeholder="40" + step={10} + placeholder="10" className="h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }} style={{ fontSize: "12px" }} diff --git a/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx index a8eee28d..cb46ec50 100644 --- a/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx @@ -7,6 +7,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { TextTypeConfig } from "@/types/screen"; +import { getAvailableNumberingRules } from "@/lib/api/numberingRule"; +import { NumberingRuleConfig } from "@/types/numbering-rule"; interface TextTypeConfigPanelProps { config: TextTypeConfig; @@ -26,9 +28,14 @@ export const TextTypeConfigPanel: React.FC = ({ config autoInput: false, autoValueType: "current_datetime" as const, customValue: "", + numberingRuleId: "", ...config, }; + // 채번 규칙 목록 상태 + const [numberingRules, setNumberingRules] = useState([]); + const [loadingRules, setLoadingRules] = useState(false); + // 로컬 상태로 실시간 입력 관리 const [localValues, setLocalValues] = useState({ minLength: safeConfig.minLength?.toString() || "", @@ -41,8 +48,33 @@ export const TextTypeConfigPanel: React.FC = ({ config autoInput: safeConfig.autoInput, autoValueType: safeConfig.autoValueType, customValue: safeConfig.customValue, + numberingRuleId: safeConfig.numberingRuleId, }); + // 채번 규칙 목록 로드 + useEffect(() => { + const loadRules = async () => { + setLoadingRules(true); + try { + // TODO: 현재 메뉴 objid를 화면 정보에서 가져와야 함 + // 지금은 menuObjid 없이 호출 (global 규칙만 조회) + const response = await getAvailableNumberingRules(); + if (response.success && response.data) { + setNumberingRules(response.data); + } + } catch (error) { + console.error("채번 규칙 목록 로드 실패:", error); + } finally { + setLoadingRules(false); + } + }; + + // autoValueType이 numbering_rule일 때만 로드 + if (localValues.autoValueType === "numbering_rule") { + loadRules(); + } + }, [localValues.autoValueType]); + // config가 변경될 때 로컬 상태 동기화 useEffect(() => { setLocalValues({ @@ -56,6 +88,7 @@ export const TextTypeConfigPanel: React.FC = ({ config autoInput: safeConfig.autoInput, autoValueType: safeConfig.autoValueType, customValue: safeConfig.customValue, + numberingRuleId: safeConfig.numberingRuleId, }); }, [ safeConfig.minLength, @@ -68,6 +101,7 @@ export const TextTypeConfigPanel: React.FC = ({ config safeConfig.autoInput, safeConfig.autoValueType, safeConfig.customValue, + safeConfig.numberingRuleId, ]); const updateConfig = (key: keyof TextTypeConfig, value: any) => { @@ -90,16 +124,10 @@ export const TextTypeConfigPanel: React.FC = ({ config autoInput: key === "autoInput" ? value : localValues.autoInput, autoValueType: key === "autoValueType" ? value : localValues.autoValueType, customValue: key === "customValue" ? value : localValues.customValue, + numberingRuleId: key === "numberingRuleId" ? value : localValues.numberingRuleId, }; const newConfig = JSON.parse(JSON.stringify(currentValues)); - // console.log("📝 TextTypeConfig 업데이트:", { - // key, - // value, - // oldConfig: safeConfig, - // newConfig, - // localValues, - // }); setTimeout(() => { onConfigChange(newConfig); @@ -236,11 +264,45 @@ export const TextTypeConfigPanel: React.FC = ({ config 현재 사용자 고유 ID (UUID) 순번 + 채번 규칙 사용자 정의
+ {localValues.autoValueType === "numbering_rule" && ( +
+ + +

+ 현재 메뉴에서 사용 가능한 채번 규칙만 표시됩니다 +

+
+ )} + {localValues.autoValueType === "custom" && (