diff --git a/backend-node/src/controllers/approvalController.ts b/backend-node/src/controllers/approvalController.ts index eabe77ce..194e7be2 100644 --- a/backend-node/src/controllers/approvalController.ts +++ b/backend-node/src/controllers/approvalController.ts @@ -892,6 +892,40 @@ export class ApprovalRequestController { const userName = req.user?.userName || ""; const deptName = req.user?.deptName || ""; + // πŸ”’ 쀑볡 결재 차단: 같은 target에 ν™œμ„±/μ™„λ£Œλœ κ²°μž¬κ°€ 있으면 κ±°λΆ€ + // (rejected, cancelledλŠ” μž¬μƒμ‹  ν—ˆμš©) + if (target_record_id) { + const existing = await queryOne( + `SELECT request_id, status FROM approval_requests + WHERE target_table = $1 AND target_record_id = $2 AND company_code = $3 + AND status IN ('requested', 'in_progress', 'approved', 'post_pending') + ORDER BY request_id DESC LIMIT 1`, + [target_table, safeTargetRecordId, companyCode] + ); + if (existing) { + const statusLabel: Record = { + requested: "μš”μ²­λ¨", in_progress: "κ²°μž¬μ€‘", approved: "μŠΉμΈμ™„λ£Œ", post_pending: "ν›„κ²°λŒ€κΈ°", + }; + return res.status(409).json({ + success: false, + message: `이미 ${statusLabel[existing.status] || existing.status} μƒνƒœμ˜ κ²°μž¬κ°€ μ‘΄μž¬ν•©λ‹ˆλ‹€. (μš”μ²­ ID: ${existing.request_id})`, + error: { code: "DUPLICATE_APPROVAL", details: existing }, + }); + } + } + + // πŸ”’ 자기 μžμ‹  결재 차단: approval_type이 'self'κ°€ μ•„λ‹ˆλ©΄ κ²°μž¬μ„ μ— 본인 포함 λΆˆκ°€ + if (approval_type !== "self" && Array.isArray(approvers)) { + const selfInLine = approvers.find((a: any) => (a.userId || a.user_id) === userId); + if (selfInLine) { + return res.status(400).json({ + success: false, + message: "κ²°μž¬μ„ μ— 본인을 포함할 수 μ—†μŠ΅λ‹ˆλ‹€. 자기결재(μ „κ²°)λŠ” 별도 μœ ν˜•μ„ μ‚¬μš©ν•΄ μ£Όμ„Έμš”.", + error: { code: "SELF_APPROVER_NOT_ALLOWED" }, + }); + } + } + // approval_modeλ₯Ό target_record_data에 병합 μ €μž₯ (ν•˜μœ„ν˜Έν™˜) const mergedRecordData = { ...(target_record_data || {}), diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 7bdaf415..06d13b6e 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -207,6 +207,17 @@ export async function create(req: AuthenticatedRequest, res: Response) { } const insertedDetails: any[] = []; + // κΈ°μ‘΄ λ””ν…ŒμΌμ΄ 있으면 μŠ€ν‚΅ (λ©±λ“±μ„± β€” 같은 inbound_number둜 2번 호좜 λ°©μ§€) + const existingDetails = await client.query( + `SELECT COUNT(*) AS cnt FROM inbound_detail WHERE company_code = $1 AND inbound_id = $2`, + [companyCode, inboundNumber] + ); + if (parseInt(existingDetails.rows[0].cnt, 10) > 0) { + await client.query("COMMIT"); + client.release(); + return res.json({ success: true, data: [], message: "이미 λ“±λ‘λœ μž…κ³ μž…λ‹ˆλ‹€." }); + } + // 2. λ””ν…ŒμΌ INSERT (inbound_detail) + 재고/발주 μ—…λ°μ΄νŠΈ for (let i = 0; i < items.length; i++) { const item = items[i]; diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 2028dbb8..ae795dc1 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -907,6 +907,61 @@ export async function getTableData( } } +/** + * ν…Œμ΄λΈ” 집계 쑰회 (SUM/COUNT) + * POST /api/table-management/tables/:tableName/aggregate + */ +export async function getTableAggregate( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const { columns, autoFilter } = req.body; + const companyCode = req.user?.companyCode; + + if (!tableName || !columns || !Array.isArray(columns)) { + res.status(400).json({ success: false, message: "tableNameκ³Ό columns 배열이 ν•„μš”ν•©λ‹ˆλ‹€." }); + return; + } + + const validCols = columns.filter((c: any) => + c.column && c.func && /^[a-zA-Z0-9_]+$/.test(c.column) && ["sum", "count", "avg", "min", "max"].includes(c.func) + ); + if (validCols.length === 0) { + res.status(400).json({ success: false, message: "μœ νš¨ν•œ 집계 컬럼이 μ—†μŠ΅λ‹ˆλ‹€." }); + return; + } + + const selectParts = validCols.map((c: any) => { + const col = c.column.replace(/[^a-zA-Z0-9_]/g, ""); + return `${c.func}(COALESCE(CAST(NULLIF(${col}, '') AS numeric), 0)) AS "${c.func}_${col}"`; + }); + + let whereClause = ""; + const params: any[] = []; + let paramIdx = 1; + + if (autoFilter !== false && companyCode && companyCode !== "*") { + whereClause = `WHERE company_code = $${paramIdx}`; + params.push(companyCode); + paramIdx++; + } + + const pool = (await import("../database/db")).getPool(); + const safeTable = tableName.replace(/[^a-zA-Z0-9_]/g, ""); + const result = await pool.query( + `SELECT ${selectParts.join(", ")} FROM ${safeTable} ${whereClause}`, + params + ); + + res.json({ success: true, data: result.rows[0] || {} }); + } catch (error: any) { + logger.error("ν…Œμ΄λΈ” 집계 쑰회 μ‹€νŒ¨:", error); + res.status(500).json({ success: false, message: error.message }); + } +} + /** * ν…Œμ΄λΈ” 데이터 μΆ”κ°€ */ diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index b8960006..9d3341d2 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -372,6 +372,69 @@ export async function getRoutingVersions(req: AuthenticatedRequest, res: Respons } } +// ─── ν’ˆλͺ©λ³„ λΌμš°νŒ… 벌크 쑰회 (μ—‘μ…€ μ—…λ‘œλ“œμš©) ─── +export async function getRoutingVersionsBulk(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { itemCodes } = req.body as { itemCodes: string[] }; + + if (!itemCodes || !Array.isArray(itemCodes) || itemCodes.length === 0) { + return res.json({ success: true, data: {} }); + } + + const pool = getPool(); + const result: Record = {}; + + // 청크 λ‹¨μœ„λ‘œ λΆ„ν•  (PostgreSQL placeholder μ œν•œ λŒ€μ‘) + const CHUNK_SIZE = 5000; + for (let ci = 0; ci < itemCodes.length; ci += CHUNK_SIZE) { + const chunk = itemCodes.slice(ci, ci + CHUNK_SIZE); + + // 1. κΈ°λ³Έ λΌμš°νŒ… 버전 쑰회 + const placeholders = chunk.map((_, i) => `$${i + 2}`).join(","); + const versionsResult = await pool.query( + `SELECT DISTINCT ON (item_code) id, item_code, version_name + FROM item_routing_version + WHERE company_code = $1 AND item_code IN (${placeholders}) + ORDER BY item_code, is_default DESC, created_date DESC`, + [companyCode, ...chunk] + ); + + if (versionsResult.rows.length === 0) continue; + + // 2. λΌμš°νŒ… λ””ν…ŒμΌ 쑰회 + const versionIds = versionsResult.rows.map((v: any) => v.id); + const vPlaceholders = versionIds.map((_: any, i: number) => `$${i + 2}`).join(","); + const detailsResult = await pool.query( + `SELECT rd.routing_version_id, rd.process_code, + COALESCE(p.process_name, rd.process_code) AS process_name + FROM item_routing_detail rd + LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code + WHERE rd.company_code = $1 AND rd.routing_version_id IN (${vPlaceholders}) + ORDER BY rd.seq_no::integer`, + [companyCode, ...versionIds] + ); + + // 3. λ§€ν•‘ + const versionToItem: Record = {}; + for (const v of versionsResult.rows) { + versionToItem[v.id] = v.item_code; + } + for (const d of detailsResult.rows) { + const itemCode = versionToItem[d.routing_version_id]; + if (!itemCode) continue; + if (!result[itemCode]) result[itemCode] = []; + result[itemCode].push({ code: d.process_code, name: d.process_name }); + } + } + + return res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("벌크 λΌμš°νŒ… 쑰회 μ‹€νŒ¨", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── μž‘μ—…μ§€μ‹œ λΌμš°νŒ… λ³€κ²½ ─── export async function updateRouting(req: AuthenticatedRequest, res: Response) { try { diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 6a4a8ce8..c0ba1349 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -11,7 +11,8 @@ import { updateColumnInputType, updateTableLabel, getTableData, - getTableRecord, // πŸ†• 단일 λ ˆμ½”λ“œ 쑰회 + getTableRecord, + getTableAggregate, addTableData, editTableData, deleteTableData, @@ -193,6 +194,7 @@ router.get("/health", checkDatabaseConnection); * POST /api/table-management/tables/:tableName/data */ router.post("/tables/:tableName/data", getTableData); +router.post("/tables/:tableName/aggregate", getTableAggregate); /** * 단일 λ ˆμ½”λ“œ 쑰회 (μžλ™ μž…λ ₯용) diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts index a65f6f54..07507b25 100644 --- a/backend-node/src/routes/workInstructionRoutes.ts +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -15,6 +15,9 @@ router.get("/source/production-plan", ctrl.getProductionPlanSource); router.get("/equipment", ctrl.getEquipmentList); router.get("/employees", ctrl.getEmployeeList); +// 벌크 λΌμš°νŒ… 쑰회 (ν’ˆλͺ©λ³„ 곡정 일괄 쑰회) +router.post("/routing-versions-bulk", ctrl.getRoutingVersionsBulk); + // λΌμš°νŒ… & κ³΅μ •μž‘μ—…κΈ°μ€€ router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions); router.put("/:wiNo/routing", ctrl.updateRouting); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 9d63a366..3ad0e3f7 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2367,26 +2367,24 @@ export class TableManagementService { const total = parseInt(countResult[0].count); // 데이터 쑰회 (main 별칭 μΆ”κ°€) - const dataQuery = ` - SELECT main.* FROM ${safeTableName} main - ${whereClause} - ${orderClause} - LIMIT $${paramIndex} OFFSET $${paramIndex + 1} - `; + // size=0 이면 LIMIT 없이 전체 λ°˜ν™˜ (λ§ˆμŠ€ν„° μ°Έμ‘° 데이터 쑰회용) + const usePaging = size > 0; + const dataQuery = usePaging + ? `SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}` + : `SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause}`; logger.info(`πŸ” μ‹€ν–‰ν•  SQL: ${dataQuery}`); - logger.info( - `πŸ” νŒŒλΌλ―Έν„°: ${JSON.stringify([...searchValues, size, offset])}` - ); + const queryParams = usePaging ? [...searchValues, size, offset] : [...searchValues]; + logger.info(`πŸ” νŒŒλΌλ―Έν„°: ${JSON.stringify(queryParams)}`); - let data = await query(dataQuery, [...searchValues, size, offset]); + let data = await query(dataQuery, queryParams); // 🎯 파일 컬럼이 있으면 파일 정보 보강 if (fileColumns.length > 0) { data = await this.enrichFileData(data, fileColumns, safeTableName); } - const totalPages = Math.ceil(total / size); + const totalPages = usePaging ? Math.ceil(total / size) : 1; logger.info( `ν…Œμ΄λΈ” 데이터 쑰회 μ™„λ£Œ: ${tableName}, 총 ${total}건, ${data.length}개 λ°˜ν™˜` diff --git a/docs/smart-excel-upload.md b/docs/smart-excel-upload.md new file mode 100644 index 00000000..ab55c37b --- /dev/null +++ b/docs/smart-excel-upload.md @@ -0,0 +1,337 @@ +# SmartExcelUpload + +μ„€μ •(Config) 기반 μ—‘μ…€ μ—…λ‘œλ“œ 곡톡 λͺ¨λ“ˆ. Config 객체와 데이터λ₯Ό λ„˜κΈ°λ©΄ ν…œν”Œλ¦Ώ 생성, μ—…λ‘œλ“œ, 검증, λ―Έλ¦¬λ³΄κΈ°κΉŒμ§€ μžλ™ μ²˜λ¦¬λœλ‹€. ν™”λ©΄λ³„λ‘œ Config μ •μ˜ + 데이터 쑰회 + μ €μž₯ 콜백만 μž‘μ„±ν•˜λ©΄ μ–΄λ””λ“  적용 κ°€λŠ₯. + +## κΈ°μ‘΄ ExcelUploadModal과의 차이 + +κΈ°μ‘΄ `ExcelUploadModal`은 단일 ν…Œμ΄λΈ”μ˜ λ‹¨μˆœ 데이터λ₯Ό 일괄 μ—…λ‘œλ“œν•˜λŠ” μš©λ„(거래처 λͺ©λ‘ λ“±). + +`SmartExcelUpload`λŠ” μ•„λž˜μ™€ 같이 **κΈ°μ‘΄ μ»΄ν¬λ„ŒνŠΈλ‘œ μ²˜λ¦¬ν•˜κΈ° μ–΄λ €μš΄ λ³΅μž‘ν•œ ꡬ쑰**μ—μ„œ μ‚¬μš©ν•œλ‹€. + +| 상황 | μ˜ˆμ‹œ | +|------|------| +| μ…€ κ°„ 연동이 ν•„μš”ν•  λ•Œ | A 컬럼 선택 β†’ B 컬럼 μžλ™ μž…λ ₯ | +| μ„ νƒν•œ 값에 따라 λ“œλ‘­λ‹€μš΄μ΄ λ‹¬λΌμ§ˆ λ•Œ | λ§ˆμŠ€ν„° λ°μ΄ν„°λ³„λ‘œ 선택 κ°€λŠ₯ν•œ ν•˜μœ„ ν•­λͺ©μ΄ 닀름 | +| 쑰건에 따라 μž…λ ₯ κ°€λŠ₯/λΆˆκ°€κ°€ λ°”λ€” λ•Œ | νŠΉμ • μœ ν˜•μΌ λ•Œλ§Œ νŠΉμ • 컬럼 μž…λ ₯ κ°€λŠ₯ | +| λ©€ν‹° μ‹œνŠΈλ‘œ μœ ν˜•μ„ ꡬ뢄할 λ•Œ | μœ ν˜•λ³„ μ‹œνŠΈ 뢄리 | +| μ°Έμ‘° 데이터 기반 μžλ™ 검증이 ν•„μš”ν•  λ•Œ | κΈ°μ€€ 데이터 λ³€κ²½ μ‹œ ν…œν”Œλ¦Ώ μž¬λ‹€μš΄λ‘œλ“œ μœ λ„ (ν•΄μ‹œ 검증) | +| 1:N κ΄€κ³„μ˜ 데이터λ₯Ό 등둝할 λ•Œ | λ§ˆμŠ€ν„° 1κ°œμ— λ””ν…ŒμΌ N개, μœ ν˜• λ‹€μˆ˜ | + +λ‹¨μˆœ 일괄 등둝은 κΈ°μ‘΄ `ExcelUploadModal`을 μ“°λ©΄ λœλ‹€. + +> **μ°Έκ³ **: μ…€ κ°„ 연동 μˆ˜μ‹(VLOOKUP, INDEX/MATCH λ“±)은 ν™”λ©΄λ§ˆλ‹€ λ‹€λ₯΄λ‹€. SmartExcelUploadκ°€ μ œκ³΅ν•˜λŠ” 것은 μˆ˜μ‹ μžμ²΄κ°€ μ•„λ‹ˆλΌ **μˆ˜μ‹μ„ μ μš©ν•˜λŠ” λ©”μ»€λ‹ˆμ¦˜**(autoFill, customFormula, INDIRECT λ“±)이닀. μ–΄λ–€ μ»¬λŸΌμ— μ–΄λ–€ μˆ˜μ‹μ΄ λ“€μ–΄κ°ˆμ§€λŠ” Configμ—μ„œ ν™”λ©΄λ³„λ‘œ μ •μ˜ν•œλ‹€. + +## 파일 ꡬ쑰 + +``` +SmartExcelUpload/ + index.ts # export + types.ts # Config μΈν„°νŽ˜μ΄μŠ€, 검증 νƒ€μž… + templateGenerator.ts # ExcelJS 기반 μ—‘μ…€ ν…œν”Œλ¦Ώ 생성 + templateParser.ts # μ—…λ‘œλ“œ 파일 νŒŒμ‹± + ν•΄μ‹œ 검증 + 데이터 검증 + SmartExcelUploadModal.tsx # λͺ¨λ‹¬ UI (λ‹€μš΄λ‘œλ“œ β†’ μ—…λ‘œλ“œ β†’ 검증 β†’ 미리보기) +``` + +## 핡심 κ°œλ… + +### Config 기반 λ™μž‘ + +λͺ¨λ“  λ™μž‘μ€ `SmartExcelUploadConfig`둜 κ²°μ •λœλ‹€. + +```typescript +const config: SmartExcelUploadConfig = { + templateName: "파일λͺ…", + sheets: [...], // μ‹œνŠΈ μ •μ˜ (단일/λ©€ν‹°) + referenceSheet: {...}, // μ°Έμ‘° 데이터 μˆ¨κΉ€μ‹œνŠΈ (선택) + conditionalRules: [...], // 쑰건뢀 검증 κ·œμΉ™ (선택) + indirectOptions: {...}, // ACC_ 동적 λ“œλ‘­λ‹€μš΄ μ˜΅μ…˜ μ •μ˜ (선택) +}; +``` + +### μ—‘μ…€ ν…œν”Œλ¦Ώ ꡬ쑰 + +``` +[μ‹œνŠΈ: μ•ˆλ‚΄] ← Config 기반 μžλ™ 생성 (컬럼 μ„€λͺ…, μž…λ ₯ κ·œμΉ™, μ‚¬μš©λ²•) +[μ‹œνŠΈ: 데이터1] ← μ‚¬μš©μžκ°€ μž‘μ„±ν•˜λŠ” μ‹œνŠΈ (μ—¬λŸ¬ 개 κ°€λŠ₯) +[μ‹œνŠΈ: 데이터2] +[μˆ¨κΉ€: μ°Έμ‘°μ‹œνŠΈ] ← VLOOKUP μ°Έμ‘° 데이터 (referenceSheet μ„€μ • μ‹œ) +[μˆ¨κΉ€: _ν’ˆλͺ©κ³΅μ •] ← INDIRECT 이름 λ²”μœ„ (itemProcessMappings μ„€μ • μ‹œ) +[μˆ¨κΉ€: _ν’ˆλͺ©λͺ©λ‘] ← λ§ˆμŠ€ν„° λ“œλ‘­λ‹€μš΄ μ†ŒμŠ€ (itemProcessMappings μ„€μ • μ‹œ) +[μˆ¨κΉ€: _ν•©κ²©κΈ°μ€€μ˜΅μ…˜] ← INDIRECT ACC_ 이름 λ²”μœ„ (indirectOptions μ„€μ • μ‹œ) +[μˆ¨κΉ€: _meta] ← 버전 ν•΄μ‹œ, 생성일 +``` + +μˆ¨κΉ€μ‹œνŠΈλ“€μ€ ν•΄λ‹Ή κΈ°λŠ₯을 μ‚¬μš©ν•˜λŠ” Config일 λ•Œλ§Œ μƒμ„±λœλ‹€. + +### 버전 ν•΄μ‹œ 검증 + +- ν…œν”Œλ¦Ώ 생성 μ‹œ: μ°Έμ‘° 데이터 + λ“œλ‘­λ‹€μš΄ μ˜΅μ…˜ + λ§€ν•‘ 데이터 β†’ ν•΄μ‹œ 생성 β†’ `_meta` μ‹œνŠΈμ— μ €μž₯ +- μ—…λ‘œλ“œ μ‹œ: ν˜„μž¬ DB λ°μ΄ν„°λ‘œ ν•΄μ‹œ μž¬μƒμ„± β†’ 일치 μ—¬λΆ€ 확인 +- 뢈일치 μ‹œ: "κΈ°μ€€ 데이터가 λ³€κ²½λ˜μ—ˆμŠ΅λ‹ˆλ‹€. μ΅œμ‹  ν…œν”Œλ¦Ώμ„ λ‹€μ‹œ λ‹€μš΄λ‘œλ“œν•΄μ£Όμ„Έμš”" κ²½κ³  + +### μ•ˆλ‚΄μ‹œνŠΈ μžλ™ 생성 + +Config의 컬럼 μ •μ˜λ₯Ό 기반으둜 μ•ˆλ‚΄μ‹œνŠΈκ°€ μžλ™ μƒμ„±λœλ‹€. + +- μ»¬λŸΌλ³„ μ„€λͺ… (ν•„μˆ˜ μ—¬λΆ€, μžλ™ μž…λ ₯ μ—¬λΆ€, λ“œλ‘­λ‹€μš΄ μœ ν˜• λ“±) +- 쑰건뢀 κ·œμΉ™ μ„€λͺ… (conditionalRules 기반) +- 잠금 μ…€ λͺ©λ‘ (autoFill/readOnly/customFormula 컬럼) +- μ‚¬μš© 방법 단계 + +--- + +## 컬럼 νƒ€μž… + +### κΈ°λ³Έ νƒ€μž… + +| type | μ„€λͺ… | +|------|------| +| `text` | 자유 ν…μŠ€νŠΈ | +| `number` | 숫자 (μ²œλ‹¨μœ„ μ„œμ‹ μžλ™ 적용) | +| `date` | λ‚ μ§œ | +| `dropdown` | λ“œλ‘­λ‹€μš΄ 선택 | + +### λ“œλ‘­λ‹€μš΄ source μœ ν˜• + +| source | μ„€λͺ… | μ˜ˆμ‹œ | +|--------|------|------| +| `custom` | κ³ μ • κ°’ λͺ©λ‘ | `values: ["Y", "N"]` | +| `category` | μΉ΄ν…Œκ³ λ¦¬ ν…Œμ΄λΈ” 쑰회 | `tableName: "...", columnName: "..."` | +| `indirect` | λ‹€λ₯Έ μ…€ 값에 따라 동적 λ³€κ²½ | `indirectKeyColumn: "...", indirectPrefix: "P_"` | + +### 컬럼 속성 + +| 속성 | μ„€λͺ… | +|------|------| +| `required` | ν•„μˆ˜ μ—¬λΆ€ β€” μ—…λ‘œλ“œ 검증 μ‹œ 빈 κ°’ 체크 | +| `readOnly` | μ½κΈ°μ „μš© β€” μ…€ 잠금 | +| `autoFill` | μ°Έμ‘°μ‹œνŠΈμ—μ„œ VLOOKUP μžλ™ μž…λ ₯ β€” μ…€ 잠금, νšŒμƒ‰ λ°°κ²½ | +| `customFormula` | μ»€μŠ€ν…€ μ—‘μ…€ μˆ˜μ‹ β€” `{col:key}` ν”Œλ ˆμ΄μŠ€ν™€λ”λ‘œ 같은 ν–‰ μ°Έμ‘° | +| `enableWhen` | 쑰건뢀 ν™œμ„±ν™” β€” μ°Έμ‘°μ‹œνŠΈμ—μ„œ 직접 μ‘°νšŒν•˜μ—¬ νŒλ‹¨ (VLOOKUP 미계산 문제 μ—†μŒ) | +| `disableWhen` | 쑰건뢀 λΉ„ν™œμ„±ν™” β€” νŠΉμ • 쑰건일 λ•Œ μž…λ ₯ 차단 | +| `width` | 컬럼 λ„ˆλΉ„ (κΈ°λ³Έ 18) | + +--- + +## μ£Όμš” κΈ°λŠ₯ + +### 1. VLOOKUP μžλ™ μž…λ ₯ (autoFill) + +μ°Έμ‘°μ‹œνŠΈμ˜ 데이터λ₯Ό 기반으둜 λ‹€λ₯Έ μ…€ 값에 μ—°λ™λ˜μ–΄ μžλ™ μž…λ ₯λœλ‹€. + +```typescript +{ + key: "detail_column", + label: "상세정보", + readOnly: true, + autoFill: { + lookupColumn: "master_key", // 같은 ν–‰μ˜ 이 컬럼 값을 κΈ°μ€€μœΌλ‘œ + referenceColumn: "detail", // μ°Έμ‘°μ‹œνŠΈμ—μ„œ 이 컬럼 값을 κ°€μ Έμ˜΄ + } +} +``` + +### 2. μ»€μŠ€ν…€ μˆ˜μ‹ (customFormula) + +`{col:key}` ν”Œλ ˆμ΄μŠ€ν™€λ”λ₯Ό μ‚¬μš©ν•˜μ—¬ 같은 ν–‰μ˜ λ‹€λ₯Έ μ»¬λŸΌμ„ μ°Έμ‘°ν•˜λŠ” μˆ˜μ‹μ„ μ •μ˜ν•œλ‹€. μ ˆλŒ€μ°Έμ‘°(`$`)λŠ” ν–‰ μΉ˜ν™˜μ—μ„œ μžλ™ λ³΄ν˜Έλœλ‹€. + +```typescript +{ + key: "code_column", + readOnly: true, + customFormula: `IFERROR(INDEX('_μ‹œνŠΈλͺ…'!$A$1:$A$9999,MATCH({col:name_column},'_μ‹œνŠΈλͺ…'!$B$1:$B$9999,0)),"")` +} +``` + +### 3. INDIRECT 동적 λ“œλ‘­λ‹€μš΄ + +λ‹€λ₯Έ μ…€ 값에 따라 λ“œλ‘­λ‹€μš΄ μ˜΅μ…˜μ΄ λ™μ μœΌλ‘œ λ³€κ²½λœλ‹€. + +**P_ prefix (이름 λ²”μœ„ 직접 μ°Έμ‘°):** +```typescript +{ + key: "sub_item", + type: "dropdown", + dropdown: { + source: "indirect", + indirectKeyColumn: "master_code", // 이 컬럼 값을 κΈ°μ€€μœΌλ‘œ + indirectPrefix: "P_", // P_{κ°’} 이름 λ²”μœ„ μ°Έμ‘° + } +} +``` + +**ACC_ prefix (MATCH 인덱슀 기반 μ°Έμ‘°):** +```typescript +{ + key: "criteria_value", + type: "dropdown", + dropdown: { + source: "indirect", + indirectKeyColumn: "standard_key", + indirectPrefix: "ACC_", // ACC_{인덱슀} β€” MATCH둜 인덱슀 쑰회 + } +} +``` + +ACC_ prefix μ‚¬μš© μ‹œ Config에 `indirectOptions` μ„€μ • ν•„μš”: +```typescript +indirectOptions: { + conditionColumn: "condition_type", // μ°Έμ‘°μ‹œνŠΈμ—μ„œ 쑰건 νŒλ‹¨ν•  컬럼 + optionsByCondition: { "νƒ€μž…A": ["O", "X"] }, // 쑰건값별 κ³ μ • μ˜΅μ…˜ + selectionOptionsColumn: "options_column", // 동적 μ˜΅μ…˜ (콀마 ꡬ뢄 λ¬Έμžμ—΄) +} +``` + +### 4. 쑰건뢀 ν™œμ„±ν™”/λΉ„ν™œμ„±ν™” + +λ‹€λ₯Έ 컬럼 값에 따라 μ…€ μž…λ ₯ κ°€λŠ₯ μ—¬λΆ€κ°€ κ²°μ •λœλ‹€. μ°Έμ‘°μ‹œνŠΈμ—μ„œ 직접 μ‘°νšŒν•˜λŠ” 방식이라 VLOOKUP 미계산 λ¬Έμ œκ°€ μ—†λ‹€. + +```typescript +{ key: "value_a", type: "number", enableWhen: { column: "condition_col", equals: "νŠΉμ •κ°’" } } +``` + +### 5. 쑰건뢀 검증 κ·œμΉ™ + +μ—…λ‘œλ“œ μ‹œ νŠΉμ • 쑰건에 따라 ν•„μˆ˜/λ¬΄μ‹œ 컬럼이 달라진닀. autoFill 컬럼의 값도 μ°Έμ‘°λ°μ΄ν„°μ—μ„œ 직접 μ‘°νšŒν•˜μ—¬ 쑰건 νŒλ‹¨. + +```typescript +conditionalRules: [ + { + when: { column: "condition_col", equals: "νƒ€μž…A" }, + require: ["required_col"], // ν•„μˆ˜ + ignore: ["optional_col"], // λ¬΄μ‹œ + }, +] +``` + +### 6. ItemProcessMapping (λ§ˆμŠ€ν„°-λ””ν…ŒμΌ λ§€ν•‘) + +λ§ˆμŠ€ν„° ν•­λͺ©λ³„λ‘œ 선택 κ°€λŠ₯ν•œ ν•˜μœ„ ν•­λͺ©μ΄ λ‹€λ₯Ό λ•Œ μ‚¬μš©. 벌크 API둜 전체 데이터λ₯Ό ν•œ λ²ˆμ— μ‘°νšŒν•˜μ—¬ INDIRECT 이름 λ²”μœ„λ‘œ 등둝. + +```typescript +itemProcessMappings: [ + { itemCode: "M-001", itemName: "λ§ˆμŠ€ν„°A", processes: [{ code: "S01", name: "ν•˜μœ„1" }] }, + { itemCode: "M-002", itemName: "λ§ˆμŠ€ν„°B", processes: [{ code: "S02", name: "ν•˜μœ„2" }, { code: "S03", name: "ν•˜μœ„3" }] }, +] +``` + +μ—…λ‘œλ“œ 검증 μ‹œ λ§ˆμŠ€ν„°μ— λ§žμ§€ μ•ŠλŠ” ν•˜μœ„ ν•­λͺ©μ€ μžλ™μœΌλ‘œ μ—λŸ¬ μ²˜λ¦¬λœλ‹€. + +--- + +## μ„±λŠ₯ ꡬ쑰 + +| ν•­λͺ© | 방식 | λ²”μœ„ | +|------|------|------| +| λ“œλ‘­λ‹€μš΄/validation | **컬럼 λ²”μœ„ 1회** μ„€μ • | 65,000ν–‰ | +| μˆ˜μ‹ (VLOOKUP, customFormula) | 행별 κ°œλ³„ μ‚½μž… | 2,000ν–‰ (FORMULA_END) | +| μ…€ 보호 (잠금/ν•΄μ œ) | 행별 κ°œλ³„ μ„€μ • | 2,000ν–‰ | +| μ…€ μŠ€νƒ€μΌ (λ°°κ²½, ν…Œλ‘λ¦¬) | 행별 κ°œλ³„ μ„€μ • | 2,000ν–‰ | +| 데이터 캐싱 | 졜초 λ‘œλ“œ ν›„ μž¬μ‚¬μš© | νŽ˜μ΄μ§€ μ„Έμ…˜ | + +λ“œλ‘­λ‹€μš΄μ€ λ²”μœ„ λ‹¨μœ„λΌ ν–‰ 수 μ œν•œ μ—†μŒ. μˆ˜μ‹/μŠ€νƒ€μΌμ€ `FORMULA_END` μƒμˆ˜λ‘œ 쑰절 κ°€λŠ₯. + +--- + +## μ‚¬μš©λ²• + +### 1. κΈ°λ³Έ μ‚¬μš© (λ‹¨μˆœ λ“œλ‘­λ‹€μš΄λ§Œ) + +```tsx +import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload"; +import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload"; + +const config: SmartExcelUploadConfig = { + templateName: "거래처", + sheets: [{ + name: "거래처", + columns: [ + { key: "name", label: "거래처λͺ…", required: true, type: "text", width: 24 }, + { key: "division", label: "ꡬ뢄", type: "dropdown", + dropdown: { source: "custom", values: ["맀좜처", "λ§€μž…μ²˜"] } }, + ], + }], +}; + +const handleUpload = async (data: ParsedSheetData[]) => { + for (const sheet of data) { + for (const row of sheet.rows) await api.create(row); + } +}; + + +``` + +### 2. κ³ κΈ‰ μ‚¬μš© (μ°Έμ‘°μ‹œνŠΈ + INDIRECT + 쑰건뢀 검증) + +```tsx + +``` + +### 3. Props + +| prop | νƒ€μž… | ν•„μˆ˜ | μ„€λͺ… | +|------|------|------|------| +| `open` | boolean | O | λͺ¨λ‹¬ μ—΄λ¦Ό μƒνƒœ | +| `onOpenChange` | (open: boolean) => void | O | λͺ¨λ‹¬ μƒνƒœ λ³€κ²½ | +| `config` | SmartExcelUploadConfig | O | 전체 μ„€μ • | +| `referenceData` | Record[] | | μ°Έμ‘°μ‹œνŠΈ 데이터 | +| `dropdownOptions` | Record | | λ“œλ‘­λ‹€μš΄ μ˜΅μ…˜ (ν‚€: `μ‹œνŠΈλͺ…:컬럼key` λ˜λŠ” `컬럼key`) | +| `itemProcessMappings` | ItemProcessMapping[] | | λ§ˆμŠ€ν„°-λ””ν…ŒμΌ λ§€ν•‘ 데이터 | +| `labelToCodeMap` | Record> | | λΌλ²¨β†’μ½”λ“œ λ³€ν™˜ | +| `extraMeta` | Record | | _meta μ‹œνŠΈμ— μΆ”κ°€ν•  정보 | +| `onUpload` | (data: ParsedSheetData[]) => Promise | O | μ—…λ‘œλ“œ μ™„λ£Œ 콜백 | +| `subtitle` | string | | 제λͺ© μ•„λž˜ λΆ€κ°€ μ„€λͺ… | +| `dataLoading` | boolean | | μ™ΈλΆ€ 데이터 λ‘œλ”© 쀑 ν‘œμ‹œ | +| `loadProgress` | { loaded, total } | | λ‘œλ”© μ§„ν–‰λ₯  ν‘œμ‹œ | + +### 4. 벌크 쑰회 API (λ°±μ—”λ“œ) + +λ§ˆμŠ€ν„°λ³„ ν•˜μœ„ ν•­λͺ©μ„ ν•œ λ²ˆμ— μ‘°νšŒν•˜λŠ” API. 5,000건 λ‹¨μœ„ 청크 λΆ„ν• λ‘œ λŒ€λŸ‰ 데이터 λŒ€μ‘. + +``` +POST /work-instruction/routing-versions-bulk +Body: { itemCodes: ["M-001", "M-002", ...] } +Response: { success: true, data: { "M-001": [{ code, name }], "M-002": [...] } } +``` + +--- + +## 검증 흐름 + +``` +μ—…λ‘œλ“œ β†’ 메타 ν•΄μ‹œ 검증 β†’ μ‹œνŠΈλ³„ νŒŒμ‹± (μ‚¬μš©μž μž…λ ₯ 컬럼만 빈 ν–‰ 체크) + β†’ ν•„μˆ˜κ°’ 검증 (conditionalRules 적용, autoFill 값은 μ°Έμ‘°λ°μ΄ν„°μ—μ„œ 직접 쑰회) + β†’ λ“œλ‘­λ‹€μš΄ μœ νš¨μ„± 검증 + β†’ INDIRECT λ§€ν•‘ 검증 (λ§ˆμŠ€ν„°μ— λ§žμ§€ μ•ŠλŠ” ν•˜μœ„ ν•­λͺ© μ—λŸ¬) + β†’ μ—λŸ¬ 있으면 μ—λŸ¬ 리포트 / μ—†μœΌλ©΄ 미리보기 β†’ μ €μž₯ +``` + +--- + +## ν™•μž₯ μ‹œ μ°Έκ³  + +- μƒˆ 화면에 μ μš©ν•  λ•Œ: Config μ •μ˜ + 데이터 쑰회 + μ €μž₯ 콜백만 μž‘μ„± +- 단일 μ‹œνŠΈ / λ©€ν‹° μ‹œνŠΈ λͺ¨λ‘ 지원 (`sheets` λ°°μ—΄ 크기둜 κ²°μ •) +- μ°Έμ‘°μ‹œνŠΈ ν•„μš” μ—†μœΌλ©΄ `referenceSheet` μƒλž΅ β†’ μˆ¨κΉ€μ‹œνŠΈ 미생성 +- 쑰건뢀 검증 ν•„μš” μ—†μœΌλ©΄ `conditionalRules` μƒλž΅ β†’ λ‹¨μˆœ ν•„μˆ˜κ°’ 체크만 +- INDIRECT ν•„μš” μ—†μœΌλ©΄ `itemProcessMappings` μƒλž΅ β†’ 일반 λ“œλ‘­λ‹€μš΄λ§Œ +- ACC_ 동적 λ“œλ‘­λ‹€μš΄ ν•„μš” μ—†μœΌλ©΄ `indirectOptions` μƒλž΅ β†’ ν•΄λ‹Ή μ‹œνŠΈ 미생성 +- `FORMULA_END` (κΈ°λ³Έ 2,000) / `VALIDATION_END` (κΈ°λ³Έ 65,000)으둜 λ²”μœ„ 쑰절 κ°€λŠ₯ diff --git a/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx index 587940ce..bbad370b 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx @@ -104,10 +104,10 @@ export default function InboundOutboundPage() { const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))]; if (itemCodes.length > 0) { try { - // λ‹¨μœ„ μΉ΄ν…Œκ³ λ¦¬ μ½”λ“œβ†’λΌλ²¨ λ§€ν•‘ λ‘œλ“œ + // μž¬κ³ λ‹¨μœ„ μΉ΄ν…Œκ³ λ¦¬ μ½”λ“œβ†’λΌλ²¨ λ§€ν•‘ λ‘œλ“œ let unitLabelMap: Record = {}; try { - const catRes = await apiClient.get("/table-categories/item_info/unit/values"); + const catRes = await apiClient.get("/table-categories/item_info/inventory_unit/values"); if (catRes.data?.success && catRes.data.data?.length > 0) { const flatten = (vals: any[]) => { for (const v of vals) { @@ -127,7 +127,7 @@ export default function InboundOutboundPage() { const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; const map: Record = {}; for (const i of items) { - const rawUnit = i.unit || ""; + const rawUnit = i.inventory_unit || ""; if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit }; } setItemMap(map); diff --git a/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx index a9825af5..602afeed 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx @@ -54,6 +54,7 @@ import { } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; +import { toast } from "sonner"; import { apiClient } from "@/lib/api/client"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; @@ -388,7 +389,7 @@ export default function ReceivingPage() { const flatRows = useMemo(() => { return data.map((row) => ({ ...row, - inbound_type: resolveInboundType(row.inbound_type), + inbound_type: resolveInboundType((row as any).detail_inbound_type || row.inbound_type), source_type: row.source_table ? (SOURCE_TABLE_LABEL[row.source_table] || row.source_table) : (row as any).source_type || "", })); }, [data]); @@ -595,7 +596,7 @@ export default function ReceivingPage() { setSelectedItems( grouped.map((g) => ({ key: g.id, - inbound_type: g.inbound_type || "", + inbound_type: (g as any).detail_inbound_type || g.inbound_type || "", reference_number: g.reference_number || "", supplier_code: (g as any).supplier_code || "", supplier_name: g.supplier_name || "", @@ -635,7 +636,7 @@ export default function ReceivingPage() { setPurchaseOrders([]); setShipments([]); setItems([]); - setSelectedItems([]); + // 선택 ν’ˆλͺ©μ€ μœ μ§€ (μ—¬λŸ¬ μœ ν˜• ν˜Όν•© κ°€λŠ₯) setSourcePage(1); setSourceTotalCount(0); loadSourceData(type, undefined, 1); @@ -651,7 +652,7 @@ export default function ReceivingPage() { ...prev, { key, - inbound_type: "κ΅¬λ§€μž…κ³ ", + inbound_type: modalInboundType, reference_number: po.purchase_no, supplier_code: po.supplier_code, supplier_name: po.supplier_name, @@ -677,7 +678,7 @@ export default function ReceivingPage() { ...prev, { key, - inbound_type: "λ°˜ν’ˆμž…κ³ ", + inbound_type: modalInboundType, reference_number: sh.instruction_no, supplier_code: "", supplier_name: sh.partner_id, @@ -695,15 +696,15 @@ export default function ReceivingPage() { ]); }; - // ν’ˆλͺ© μΆ”κ°€ + // ν’ˆλͺ© μΆ”κ°€ (ν˜„μž¬ μ„ νƒλœ μž…κ³ μœ ν˜• μ‚¬μš©) const addItem = (item: ItemSource) => { - const key = `item-${item.id}`; + const key = `item-${item.id}-${modalInboundType}`; if (selectedItems.some((s) => s.key === key)) return; setSelectedItems((prev) => [ ...prev, { key, - inbound_type: "κΈ°νƒ€μž…κ³ ", + inbound_type: modalInboundType, reference_number: item.item_number, supplier_code: "", supplier_name: "", @@ -1009,11 +1010,11 @@ export default function ReceivingPage() { ) : ( - paginatedRows.map((row) => { + paginatedRows.map((row, idx) => { const isChecked = checkedIds.includes(row.id); return ( No + μž…κ³ μœ ν˜• ν’ˆλͺ©λͺ… 참쑰번호 @@ -1421,6 +1423,9 @@ export default function ReceivingPage() { {idx + 1} + + {item.inbound_type || modalInboundType} +
diff --git a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx index 2adf7fcd..9b2e32c8 100644 --- a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx @@ -1986,7 +1986,7 @@ export default function BomManagementPage() { {/* ─── BOM 등둝/μˆ˜μ • λͺ¨λ‹¬ ─────────────────── */} - { if (showItemSearchModal) e.preventDefault(); }}> + { if (showItemSearchModal) e.preventDefault(); }}> {isEditMode ? "BOM μˆ˜μ •" : "BOM 등둝"} {isEditMode ? "BOM 정보λ₯Ό μˆ˜μ •ν•΄μš”" : "μƒˆλ‘œμš΄ BOM을 λ“±λ‘ν•΄μš”"} diff --git a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx index 86f01a94..359a81a7 100644 --- a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx @@ -606,7 +606,7 @@ export default function InspectionManagementPage() { /* ═══════════════════ JSX ═══════════════════ */ return ( -
+
{ConfirmDialogComponent}
diff --git a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx index d3b008d7..becebe8f 100644 --- a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx @@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2, Search, ClipboardList, - ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, FileSpreadsheet, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { cn } from "@/lib/utils"; @@ -22,6 +22,8 @@ import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload"; +import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload"; const TABLE_NAME = "item_inspection_info"; const ITEM_TABLE = "item_info"; @@ -34,12 +36,6 @@ const GRID_COLUMNS = [ { key: "is_active", label: "μ‚¬μš©μ—¬λΆ€" }, ]; -const INSPECTION_TYPES = [ - { key: "incoming_inspection", label: "μˆ˜μž…κ²€μ‚¬", matchLabels: ["μˆ˜μž…κ²€μ‚¬", "μž…κ³ κ²€μ‚¬", "μˆ˜μž…", "μž…κ³ "] }, - { key: "outgoing_inspection", label: "μΆœν•˜κ²€μ‚¬", matchLabels: ["μΆœν•˜κ²€μ‚¬", "μΆœκ³ κ²€μ‚¬", "μΆœν•˜", "좜고"] }, - { key: "process_inspection", label: "곡정검사", matchLabels: ["곡정검사", "곡정"] }, - { key: "final_inspection", label: "μ΅œμ’…κ²€μ‚¬", matchLabels: ["μ΅œμ’…κ²€μ‚¬", "μ΅œμ’…", "μ™„μ œν’ˆκ²€μ‚¬"] }, -] as const; type InspectionRow = { id: string; @@ -79,6 +75,15 @@ export default function ItemInspectionInfoPage() { const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); + + // κ²€μ‚¬μœ ν˜• λͺ©λ‘ (검사기쀀 μΉ΄ν…Œκ³ λ¦¬ 기반) + const INSPECTION_TYPES = useMemo(() => { + return inspTypeCatOptions.map((cat) => ({ + key: cat.code, + label: cat.label, + matchLabels: [cat.code, cat.label], + })); + }, [inspTypeCatOptions]); const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]); const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]); const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]); @@ -96,6 +101,9 @@ export default function ItemInspectionInfoPage() { // κΈ°λ³Έ λΌμš°νŒ… 곡정 λͺ©λ‘ (μ μš©κ³΅μ • Select용) const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]); + // μ—‘μ…€ μ—…λ‘œλ“œ λͺ¨λ‹¬ + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + // ν’ˆλͺ© 선택 λͺ¨λ‹¬ const [itemModalOpen, setItemModalOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -470,6 +478,227 @@ export default function ItemInspectionInfoPage() { } catch { toast.error("μ‚­μ œμ— μ‹€νŒ¨ν–ˆμ–΄μš”"); } }; + /* ═══════════════════ μ—‘μ…€ μ—…λ‘œλ“œ (닀건 ν’ˆλͺ© λͺ¨λ“œ) ═══════════════════ */ + const [excelItemProcessMappings, setExcelItemProcessMappings] = useState([]); + const [excelLoading, setExcelLoading] = useState(false); + const [excelLoadProgress, setExcelLoadProgress] = useState({ loaded: 0, total: 0 }); + + const openExcelUpload = async () => { + setExcelUploadOpen(true); + + // μΊμ‹œ 히트: 이미 λ‘œλ“œλœ 데이터 있으면 μž¬μ‚¬μš© + if (excelItemProcessMappings.length > 0) return; + + setExcelLoading(true); + setExcelLoadProgress({ loaded: 0, total: 0 }); + + try { + // 1. 전체 ν’ˆλͺ© 쑰회 + const itemRes = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 99999, autoFilter: true, + }); + const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; + setExcelLoadProgress({ loaded: items.length / 2, total: items.length }); + + // 2. 벌크 λΌμš°νŒ… 쑰회 (1회 API 호좜) + const itemCodes = items.map((item: any) => item.item_number || item.item_code || "").filter(Boolean); + let processMap: Record = {}; + try { + const bulkRes = await apiClient.post(`/work-instruction/routing-versions-bulk`, { itemCodes }); + if (bulkRes.data?.success) { + processMap = bulkRes.data.data || {}; + } + } catch { /* 벌크 API μ‹€νŒ¨ μ‹œ 빈 κ³΅μ •μœΌλ‘œ μ§„ν–‰ */ } + + // 3. λ§€ν•‘ ꡬ성 + const mappings: import("@/components/common/SmartExcelUpload").ItemProcessMapping[] = items.map((item: any) => { + const code = item.item_number || item.item_code || ""; + return { + itemCode: code, + itemName: item.item_name || "", + processes: processMap[code] || [], + }; + }); + + setExcelLoadProgress({ loaded: items.length, total: items.length }); + setExcelItemProcessMappings(mappings); + toast.success(`${mappings.length}개 ν’ˆλͺ© λ‘œλ“œ μ™„λ£Œ`); + } catch { + toast.error("ν’ˆλͺ© 정보 λ‘œλ“œμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€"); + } finally { + setExcelLoading(false); + } + }; + + // μ—‘μ…€ Config 생성 (닀건 ν’ˆλͺ© λͺ¨λ“œ) + const excelUploadConfig = useMemo((): SmartExcelUploadConfig => { + const itemCount = excelItemProcessMappings.length || 9999; + const makeColumns = () => [ + { key: "item_name", label: "ν’ˆλͺ©λͺ…", required: true, type: "dropdown" as const, dropdown: { source: "custom" as const, values: [] }, width: 22 }, + { key: "item_code", label: "ν’ˆλͺ©μ½”λ“œ", type: "text" as const, readOnly: true, customFormula: `IFERROR(INDEX('_ν’ˆλͺ©λͺ©λ‘'!$A$1:$A$${itemCount},MATCH({col:item_name},'_ν’ˆλͺ©λͺ©λ‘'!$B$1:$B$${itemCount},0)),"")`, width: 16 }, + { key: "inspection_standard", label: "검사기쀀", required: true, type: "dropdown" as const, dropdown: { source: "custom" as const, values: [] }, width: 22 }, + { key: "inspection_detail", label: "검사기쀀 상세", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "detail" }, width: 20 }, + { key: "inspection_method", label: "검사방법", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "method" }, width: 14 }, + { key: "apply_process", label: "μ μš©κ³΅μ •", type: "dropdown" as const, dropdown: { source: "indirect" as const, indirectKeyColumn: "item_code" }, width: 14 }, + { key: "judgment_criteria", label: "νŒλ‹¨κΈ°μ€€", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "judgment_criteria" }, width: 14 }, + { key: "standard_value", label: "κΈ°μ€€κ°’", type: "number" as const, enableWhen: { column: "judgment_criteria", equals: "수치(λ²”μœ„)" }, width: 12 }, + { key: "tolerance", label: "였차", type: "number" as const, enableWhen: { column: "judgment_criteria", equals: "수치(λ²”μœ„)" }, width: 10 }, + { key: "unit", label: "λ‹¨μœ„", type: "text" as const, readOnly: true, autoFill: { lookupColumn: "inspection_standard", referenceColumn: "unit" }, width: 10 }, + { key: "acceptance_criteria", label: "합격기쀀", type: "dropdown" as const, dropdown: { source: "indirect" as const, indirectKeyColumn: "inspection_standard", indirectPrefix: "ACC_" }, width: 18 }, + { key: "is_required", label: "ν•„μˆ˜", type: "dropdown" as const, dropdown: { source: "custom" as const, values: ["Y", "N"] }, width: 8 }, + ]; + + return { + templateName: "ν’ˆλͺ©κ²€μ‚¬μ •보", + sheets: INSPECTION_TYPES.map(t => ({ + name: t.label, + typeKey: t.label, + columns: makeColumns(), + })), + referenceSheet: { + name: "검사기쀀정보", + columns: [ + { key: "label", label: "검사기쀀λͺ…" }, + { key: "detail", label: "검사기쀀 상세" }, + { key: "method", label: "검사방법" }, + { key: "judgment_criteria", label: "νŒλ‹¨κΈ°μ€€" }, + { key: "selection_options", label: "μ„ νƒμ˜΅μ…˜" }, + { key: "unit", label: "λ‹¨μœ„" }, + { key: "types", label: "κ²€μ‚¬μœ ν˜•" }, + ], + }, + conditionalRules: [ + { when: { column: "judgment_criteria", equals: "수치(λ²”μœ„)" }, require: ["standard_value"], ignore: ["acceptance_criteria"] }, + { when: { column: "judgment_criteria", equals: "O/X" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] }, + { when: { column: "judgment_criteria", equals: "μ„ νƒν˜•" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] }, + { when: { column: "judgment_criteria", equals: "ν…μŠ€νŠΈμž…λ ₯" }, require: ["acceptance_criteria"], ignore: ["standard_value", "tolerance"] }, + ], + indirectOptions: { + conditionColumn: "judgment_criteria", + optionsByCondition: { "O/X": ["O", "X"] }, + selectionOptionsColumn: "selection_options", + }, + }; + }, [excelItemProcessMappings]); + + // μ°Έμ‘° 데이터 ꡬ성 + const excelReferenceData = useMemo(() => { + return inspOptions.map(opt => { + const methodLabel = inspMethodCatOptions.find(o => o.code === opt.method)?.label || opt.method; + const jcLabel = judgmentCatOptions.find(c => c.code === opt.judgment_criteria)?.label || opt.judgment_criteria; + const unitLabel = inspUnitCatOptions.find(c => c.code === opt.unit)?.label || opt.unit; + const typeLabels = opt.types.map(t => inspTypeCatOptions.find(c => c.code === t)?.label || t).join(","); + return { label: opt.label, detail: opt.detail, method: methodLabel, judgment_criteria: jcLabel, selection_options: opt.selection_options, unit: unitLabel, types: typeLabels }; + }); + }, [inspOptions, inspMethodCatOptions, judgmentCatOptions, inspUnitCatOptions, inspTypeCatOptions]); + + // μ‹œνŠΈλ³„ λ“œλ‘­λ‹€μš΄ μ˜΅μ…˜ + const excelDropdownOptions = useMemo(() => { + const opts: Record = {}; + for (const t of INSPECTION_TYPES) { + const matchCodes = inspTypeCatOptions.filter(cat => t.matchLabels.some(ml => cat.label.includes(ml))).map(cat => cat.code); + const filtered = matchCodes.length > 0 + ? inspOptions.filter(opt => opt.types.some(tp => matchCodes.includes(tp))) + : inspOptions; + opts[`${t.label}:inspection_standard`] = filtered.map(o => o.label); + } + opts["is_required"] = ["Y", "N"]; + // ν’ˆλͺ©λͺ… λ“œλ‘­λ‹€μš΄ + opts["item_name"] = excelItemProcessMappings.map(m => m.itemName); + return opts; + }, [inspOptions, inspTypeCatOptions, excelItemProcessMappings]); + + // λΌλ²¨β†’μ½”λ“œ λ§€ν•‘ + const excelLabelToCodeMap = useMemo(() => { + const map: Record> = {}; + map["inspection_standard"] = {}; + for (const opt of inspOptions) map["inspection_standard"][opt.label] = opt.code; + // ν’ˆλͺ©λͺ…β†’ν’ˆλͺ©μ½”λ“œ + map["item_name"] = {}; + for (const m of excelItemProcessMappings) map["item_name"][m.itemName] = m.itemCode; + // μ μš©κ³΅μ • μ΄λ¦„β†’μ½”λ“œ (전체 ν’ˆλͺ© κ³΅μ •μ—μ„œ) + map["apply_process"] = {}; + for (const m of excelItemProcessMappings) { + for (const p of m.processes) map["apply_process"][p.name] = p.code; + } + return map; + }, [inspOptions, excelItemProcessMappings]); + + // μ—‘μ…€ μ—…λ‘œλ“œ μ €μž₯ (닀건) + const handleExcelUpload = async (data: ParsedSheetData[]) => { + // ν’ˆλͺ©μ½”λ“œλ³„λ‘œ κ·Έλ£Ήν•‘ + const itemCodeSet = new Set(); + const rows: any[] = []; + + for (const sheet of data) { + for (const row of sheet.rows) { + // ν’ˆλͺ©μ½”λ“œ: μˆ˜μ‹ κ²°κ³Ό λ˜λŠ” ν’ˆλͺ©λͺ…μœΌλ‘œ μ—­λ§€ν•‘ + let itemCode = row.item_code || ""; + const itemName = row.item_name || ""; + if (!itemCode && itemName) { + const mapping = excelItemProcessMappings.find(m => m.itemName === itemName); + if (mapping) itemCode = mapping.itemCode; + } + if (!itemCode) continue; + itemCodeSet.add(itemCode); + + const inspLabel = row.inspection_standard || ""; + const inspId = excelLabelToCodeMap["inspection_standard"]?.[inspLabel] || inspLabel; + const inspOpt = inspOptions.find(o => o.code === inspId); + const itemMapping = excelItemProcessMappings.find(m => m.itemCode === itemCode); + + let passCriteria = ""; + const jcLabel = inspOpt ? (judgmentCatOptions.find(c => c.code === inspOpt.judgment_criteria)?.label || inspOpt.judgment_criteria) : ""; + if (jcLabel === "수치(λ²”μœ„)") { + passCriteria = `${row.standard_value || ""}|${row.tolerance || ""}`; + } else { + passCriteria = row.acceptance_criteria || ""; + } + + // μ μš©κ³΅μ • 검증: ν•΄λ‹Ή ν’ˆλͺ©μ˜ 유효 곡정인지 확인 (ν’ˆλͺ© λ³€κ²½ ν›„ 곡정 λ―Έμ΄ˆκΈ°ν™” λŒ€μ‘) + let applyProcess = row.apply_process || ""; + if (applyProcess && itemMapping) { + const validProcess = itemMapping.processes.find(p => p.code === applyProcess || p.name === applyProcess); + if (!validProcess) { + applyProcess = ""; // μœ νš¨ν•˜μ§€ μ•Šμ€ 곡정은 비움 + } + } + + rows.push({ + id: crypto.randomUUID(), + item_code: itemCode, + item_name: itemMapping?.itemName || itemCode, + inspection_type: sheet.typeKey || sheet.sheetName, + inspection_standard_id: inspId, + inspection_item_name: inspOpt?.detail || row.inspection_detail || "", + inspection_method: inspOpt?.method || "", + apply_process: applyProcess, + pass_criteria: passCriteria, + is_required: row.is_required === "Y" ? "true" : "false", + is_active: "μ‚¬μš©", + }); + } + } + + // ν•΄λ‹Ή ν’ˆλͺ©λ“€μ˜ κΈ°μ‘΄ 데이터 μ‚­μ œ ν›„ μž¬λ“±λ‘ + for (const itemCode of itemCodeSet) { + const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, size: 9999, + dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: itemCode }] }, + autoFilter: true, + }); + const existing = existRes.data?.data?.data || existRes.data?.data?.rows || []; + if (existing.length > 0) { + await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) }); + } + } + + for (const row of rows) { + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, row); + } + fetchData(); + }; + /* ═══════════════════ JSX ═══════════════════ */ return (
@@ -500,6 +729,9 @@ export default function ItemInspectionInfoPage() {
+ @@ -655,7 +887,18 @@ export default function ItemInspectionInfoPage() { {row.inspection_item_name || "-"} {resolveInspLabel(row.inspection_standard_id)} {resolveMethodLabel(row.inspection_method)} - {row.apply_process || "-"} + {(() => { + const code = row.apply_process; + if (!code) return "-"; + // excelItemProcessMappingsμ—μ„œ 곡정λͺ… μ°ΎκΈ° + for (const m of excelItemProcessMappings) { + const proc = m.processes.find(p => p.code === code); + if (proc) return proc.name; + } + // processOptions (λͺ¨λ‹¬μš©)μ—μ„œ μ°ΎκΈ° + const proc = processOptions.find(p => p.code === code); + return proc?.name || code; + })()} {(() => { const insp = inspOptions.find(o => o.code === row.inspection_standard_id); @@ -752,11 +995,11 @@ export default function ItemInspectionInfoPage() { ) : ( <> - + {editMode ? "ν’ˆλͺ©κ²€μ‚¬μ •보 μˆ˜μ •" : "ν’ˆλͺ©κ²€μ‚¬μ •보 등둝"} ν’ˆλͺ©κ²€μ‚¬μ •보λ₯Ό λ“±λ‘ν•©λ‹ˆλ‹€ -
+
{/* ν’ˆλͺ© 정보 */}

ν’ˆλͺ© 정보

@@ -918,7 +1161,7 @@ export default function ItemInspectionInfoPage() {
))}
- +
+ + {/* ═══════ μ—‘μ…€ μ—…λ‘œλ“œ λͺ¨λ‹¬ ═══════ */} +
); } diff --git a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx index 07f00fce..221a28d7 100644 --- a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx @@ -58,6 +58,7 @@ const FLAT_COLUMNS = [ { key: "unit_price", label: "단가", source: "detail" }, { key: "amount", label: "κΈˆμ•‘", source: "detail" }, { key: "due_date", label: "납기일", source: "detail" }, + { key: "approval_status", label: "κ²°μž¬μƒνƒœ", source: "master" }, { key: "memo", label: "λ©”λͺ¨", source: "master" }, ]; @@ -66,8 +67,26 @@ const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail"); // ν•„ν„°μš© 전체 ν‚€ const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label })); -// 총 컬럼 수: μ²΄ν¬λ°•μŠ€(1) + ν”Œλž« 컬럼(14) = 15 -const TOTAL_COLS = 15; +// 총 컬럼 수: μ²΄ν¬λ°•μŠ€(1) + ν”Œλž« 컬럼(15) = 16 +const TOTAL_COLS = 16; + +// κ²°μž¬μƒνƒœ 라벨/색상 +const APPROVAL_STATUS_LABEL: Record = { + requested: "μš”μ²­", + in_progress: "κ²°μž¬μ€‘", + approved: "μŠΉμΈμ™„λ£Œ", + rejected: "반렀", + cancelled: "회수", + post_pending: "ν›„κ²°λŒ€κΈ°", +}; +const APPROVAL_STATUS_CLASS: Record = { + requested: "bg-secondary text-secondary-foreground", + in_progress: "bg-primary/10 text-primary border border-primary/20", + approved: "bg-emerald-500/10 text-emerald-600 border border-emerald-500/20", + rejected: "bg-destructive/10 text-destructive border border-destructive/20", + cancelled: "bg-muted text-muted-foreground", + post_pending: "bg-warning/10 text-warning", +}; // 헀더 ν•„ν„° Popover function HeaderFilterPopover({ @@ -333,6 +352,28 @@ export default function SalesOrderPage() { } catch { /* skip */ } } + // 결재 μƒνƒœ 쑰인 (target_table='sales_order_mng', target_record_id = order_no) + let approvalMap: Record = {}; + if (orderNos.length > 0) { + try { + const apprRes = await apiClient.post(`/table-management/tables/approval_requests/data`, { + page: 1, size: orderNos.length + 10, + dataFilter: { enabled: true, filters: [ + { columnName: "target_table", operator: "equals", value: "sales_order_mng" }, + { columnName: "target_record_id", operator: "in", value: orderNos.map(String) }, + ] }, + autoFilter: true, + sort: { columnName: "request_id", order: "desc" }, + }); + const apprs = apprRes.data?.data?.data || apprRes.data?.data?.rows || []; + // 같은 order_no에 μ—¬λŸ¬ κ²°μž¬κ°€ 있으면 μ΅œμ‹ λ§Œ (sort desc 첫 번째) + for (const a of apprs) { + const rid = String(a.target_record_id); + if (!approvalMap[rid]) approvalMap[rid] = a; + } + } catch { /* skip */ } + } + // part_code β†’ item_info 쑰인 const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))]; let itemMap: Record = {}; @@ -359,6 +400,7 @@ export default function SalesOrderPage() { const item = itemMap[row.part_code]; const master = masterMap[row.order_no]; const rawUnit = row.unit || item?.inventory_unit || ""; + const appr = approvalMap[String(row.order_no)] || null; return { ...row, part_name: row.part_name || item?.item_name || "", @@ -366,6 +408,8 @@ export default function SalesOrderPage() { material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""), unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit, memo: row.memo || master?.memo || "", + approval_status: appr?.status || "", + approval_request_id: appr?.request_id || null, _master: master || {}, }; }); @@ -381,6 +425,13 @@ export default function SalesOrderPage() { useEffect(() => { fetchOrders(); }, [fetchOrders]); + // 결재 처리 μ™„λ£Œ μ‹œ λͺ©λ‘ μƒˆλ‘œκ³ μΉ¨ + useEffect(() => { + const handler = () => fetchOrders(); + window.addEventListener("approval-processed", handler); + return () => window.removeEventListener("approval-processed", handler); + }, [fetchOrders]); + // μΉ΄ν…Œκ³ λ¦¬ μ½”λ“œβ†’λΌλ²¨ λ³€ν™˜ const resolveLabel = useCallback((key: string, code: string) => { if (!code) return ""; @@ -705,19 +756,16 @@ export default function SalesOrderPage() { const filters: any[] = []; if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); - // κ΄€λ¦¬ν’ˆλͺ© ν•„ν„°λ₯Ό μ„œλ²„ 쿼리에 포함 (μ½”λ“œ + 라벨 μ–‘μͺ½ λŒ€μ‘) + // κ΄€λ¦¬ν’ˆλͺ© ν•„ν„°: 닀쀑값(콀마 ꡬ뢄) μ €μž₯된 κ²½μš°λ„ λ§€μΉ­λ˜λ„λ‘ contains μ‚¬μš© if (itemSearchDivision !== "all") { - const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; - // μ½”λ“œ λ˜λŠ” 라벨이 μ €μž₯된 경우 λͺ¨λ‘ μ‘°νšŒν•˜κΈ° μœ„ν•΄ in μ—°μ‚°μž μ‚¬μš© - const divValues = [itemSearchDivision]; - if (divLabel) divValues.push(divLabel); - filters.push({ columnName: "division", operator: "in", value: divValues }); + filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision }); } - // κ±°λž˜μ²˜μš°μ„  단가방식일 λ•Œ κ±°λž˜μ²˜μ— μ—°κ²°λœ ν’ˆλͺ©λ§Œ 필터링 - const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + // κ±°λž˜μ²˜μš°μ„  단가방식일 λ•Œ 거래처 λ§€ν•‘ id μ •κ·œν™” β†’ μ„œλ²„ ν•„ν„° 적용 + // price_mode의 라벨둜 νŒλ‹¨ (μΉ΄ν…Œκ³ λ¦¬ μ½”λ“œλŠ” νšŒμ‚¬λ§ˆλ‹€ λ‹€λ₯Ό 수 있음) + const priceModeLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || ""; + const isCustomerPrice = priceModeLabel.includes("거래처"); const partnerId = masterForm.partner_id; - let customerItemIds: Set | null = null; if (isCustomerPrice && partnerId) { try { @@ -727,7 +775,36 @@ export default function SalesOrderPage() { autoFilter: true, }); const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; - customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); + const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; + if (rawIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + // UUID와 λ¬Έμžμ—΄(item_number) 뢄리 + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const uuidIds = rawIds.filter(v => uuidRegex.test(v)); + const codeIds = rawIds.filter(v => !uuidRegex.test(v)); + + // λ¬Έμžμ—΄(item_number)을 item_infoμ—μ„œ id둜 λ³€ν™˜ + let convertedIds: string[] = []; + if (codeIds.length > 0) { + const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codeIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, + autoFilter: true, + }); + const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; + convertedIds = convRows.map((r: any) => r.id).filter(Boolean); + } + + const finalIds = [...new Set([...uuidIds, ...convertedIds])]; + if (finalIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + filters.push({ columnName: "id", operator: "in", value: finalIds }); } catch { /* skip */ } } @@ -737,14 +814,9 @@ export default function SalesOrderPage() { autoFilter: true, }); const resData = res.data?.data; - let rows = resData?.data || resData?.rows || []; + const rows = resData?.data || resData?.rows || []; const serverTotal = resData?.total || resData?.totalCount || rows.length; - // κ±°λž˜μ²˜μš°μ„ μΌ λ•Œ μ—°κ²°λœ ν’ˆλͺ©λ§Œ ν‘œμ‹œ (ν΄λΌμ΄μ–ΈνŠΈ ν•„ν„°) - if (customerItemIds) { - rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); - } - setItemSearchResults(rows); setItemTotal(serverTotal); setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s))); @@ -778,8 +850,9 @@ export default function SalesOrderPage() { const selected = Array.from(itemSelectedMap.values()); if (selected.length === 0) { toast.error("ν’ˆλͺ©μ„ μ„ νƒν•΄μ£Όμ„Έμš”."); return; } - const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ"; - const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; + const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === masterForm.price_mode)?.label || ""; + const isStandardPrice = pmLabel.includes("κΈ°μ€€"); + const isCustomerPrice = pmLabel.includes("거래처"); const partnerId = masterForm.partner_id; let customerPriceMap: Record = {}; @@ -847,10 +920,10 @@ export default function SalesOrderPage() { // 단가 μž¬κ³„μ‚°: 단가방식/거래처 λ³€κ²½ μ‹œ κΈ°μ‘΄ ν’ˆλͺ© 단가 κ°±μ‹  const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => { if (detailRows.length === 0) return; - const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"]; - const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"]; - const isStandard = STANDARD_CODES.includes(priceMode); - const isCustomer = CUSTOMER_CODES.includes(priceMode); + // price_mode 라벨둜 νŒλ‹¨ (μΉ΄ν…Œκ³ λ¦¬ μ½”λ“œλŠ” νšŒμ‚¬λ§ˆλ‹€ λ‹€λ₯Ό 수 있음) + const pmLabel = (categoryOptions["price_mode"] || []).find((o) => o.code === priceMode)?.label || ""; + const isStandard = pmLabel.includes("κΈ°μ€€"); + const isCustomer = pmLabel.includes("거래처"); if (isStandard) { // ν’ˆλͺ© 기쀀단가 쑰회 @@ -925,9 +998,11 @@ export default function SalesOrderPage() { setDetailRows((prev) => prev.filter((_, i) => i !== idx)); }; - // 쑰건뢀 λ ˆμ΄μ–΄ νŒλ‹¨ - const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W"; - const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO"; + // 쑰건뢀 λ ˆμ΄μ–΄ νŒλ‹¨ (라벨 기반 β€” μΉ΄ν…Œκ³ λ¦¬ μ½”λ“œλŠ” νšŒμ‚¬λ§ˆλ‹€ λ‹€λ₯Ό 수 있음) + const inputModeLabel = (categoryOptions["input_mode"] || []).find((o) => o.code === masterForm.input_mode)?.label || ""; + const sellModeLabel = (categoryOptions["sell_mode"] || []).find((o) => o.code === masterForm.sell_mode)?.label || ""; + const isSupplierFirst = inputModeLabel.includes("곡급") || inputModeLabel.includes("거래처"); + const isOverseas = sellModeLabel.includes("ν•΄μ™Έ") || sellModeLabel.includes("수좜"); const handleExcelDownload = async () => { if (orders.length === 0) { toast.error("λ‹€μš΄λ‘œλ“œν•  데이터가 μ—†μŠ΅λ‹ˆλ‹€."); return; } @@ -994,6 +1069,42 @@ export default function SalesOrderPage() { > μ‚­μ œ{checkedIds.length > 0 && ` (${checkedIds.length})`} +
+ ) : ( + - + )} + {row.memo || ""} ); @@ -1475,6 +1615,9 @@ export default function SalesOrderPage() {