diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 8fba4591..60c4615f 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -39,7 +39,6 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", - "playwright": "^1.58.2", "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", @@ -1051,6 +1050,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -2384,6 +2384,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -3501,6 +3502,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3746,6 +3748,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3974,6 +3977,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4523,6 +4527,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5898,6 +5903,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6176,6 +6182,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -7762,6 +7769,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8731,7 +8739,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9701,6 +9708,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -9920,50 +9928,6 @@ "node": ">=8" } }, - "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.58.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -10668,7 +10632,6 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" } @@ -11562,6 +11525,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11667,6 +11631,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend-node/package.json b/backend-node/package.json index 8154371b..e827da0c 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -53,7 +53,6 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", - "playwright": "^1.58.2", "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 4b40ce6e..0616ba14 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -7,6 +7,7 @@ import { JwtUtils } from "../utils/jwtUtils"; import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; import { sendSmartFactoryLog } from "../utils/smartFactoryLog"; +import { query, queryOne } from "../database/db"; export class AuthController { /** @@ -104,6 +105,14 @@ export class AuthController { popLandingPath = "/pop"; } logger.debug(`POP 랜딩 경로: ${popLandingPath}`); + + // POP 메뉴가 존재하면 해당 회사의 POP 레이아웃 자동 초기화 (비동기) + if (popLandingPath) { + const companyCode = loginResult.userInfo.companyCode || "ILSHIN"; + AuthController.initPopLayoutsForCompany(companyCode).catch((err) => { + logger.warn("POP 레이아웃 자동 초기화 중 오류 (무시):", err); + }); + } } catch (popError) { logger.warn("POP 메뉴 조회 중 오류 (무시):", popError); } @@ -562,4 +571,78 @@ export class AuthController { }); } } + + /** + * POP 레이아웃 자동 초기화 + * 해당 회사의 screen_layouts_pop 레코드가 없으면 + * 템플릿(공통 '*' 또는 COMPANY_7)에서 복제하여 생성 + * + * 기본 POP 화면 ID: 5, 6, 7, 8, 6526, 6527, 6528, 6529 + */ + static async initPopLayoutsForCompany(companyCode: string): Promise { + // SUPER_ADMIN이나 공통(*)은 초기화 불필요 + if (companyCode === "*" || companyCode === "COMPANY_7") return; + + const POP_SCREEN_IDS = [5, 6, 7, 8, 6526, 6527, 6528, 6529]; + + // 이미 해당 회사의 POP 레이아웃이 하나라도 있으면 스킵 (중복 초기화 방지) + const existing = await query<{ cnt: string }>( + `SELECT COUNT(*)::text AS cnt FROM screen_layouts_pop + WHERE company_code = $1 AND screen_id = ANY($2::int[])`, + [companyCode, POP_SCREEN_IDS], + ); + const existingCount = parseInt(existing[0]?.cnt || "0", 10); + if (existingCount > 0) { + logger.debug(`POP 레이아웃 이미 존재 (${companyCode}): ${existingCount}개, 스킵`); + return; + } + + logger.info(`POP 레이아웃 자동 초기화 시작: ${companyCode}`); + + // 회사명 조회 (레이아웃 내 회사명 치환용) + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode], + ); + const companyName = companyInfo?.company_name || companyCode; + + let initCount = 0; + for (const screenId of POP_SCREEN_IDS) { + // 템플릿 조회: 공통(*) 우선, 없으면 COMPANY_7 폴백 + let template = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = '*'`, + [screenId], + ); + if (!template) { + template = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = 'COMPANY_7'`, + [screenId], + ); + } + + if (!template) { + logger.debug(`POP 템플릿 없음 (screen_id=${screenId}), 스킵`); + continue; + } + + // 레이아웃 복제 + 회사명 치환 + const layoutStr = JSON.stringify(template.layout_data); + const replacedStr = layoutStr + .replace(/\(주\)탑씰/g, companyName) + .replace(/탑씰/g, companyName) + .replace(/TOPSEAL/gi, companyName); + + await query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, NOW(), NOW(), 'SYSTEM', 'SYSTEM') + ON CONFLICT (screen_id, company_code) DO NOTHING`, + [screenId, companyCode, replacedStr], + ); + initCount++; + } + + logger.info(`POP 레이아웃 자동 초기화 완료: ${companyCode}, ${initCount}개 화면`); + } } diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index c070c243..8d0af7c5 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -11,6 +11,17 @@ interface DefectDetailItem { disposition: string; } +// 자동 마이그레이션: batch_id 컬럼 추가 (배치/로트 추적용) +let _batchMigrationDone = false; +async function ensureBatchIdColumn() { + if (_batchMigrationDone) return; + try { + const pool = getPool(); + await pool.query("ALTER TABLE work_order_process ADD COLUMN IF NOT EXISTS batch_id VARCHAR(100)"); + _batchMigrationDone = true; + } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } +} + /** * inventory_stock UPSERT 공통 함수 * PC의 receivingController와 동일한 SELECT→INSERT/UPDATE 패턴 사용. @@ -987,14 +998,44 @@ export const saveResult = async ( // 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때) if (currentSeq.rowCount > 0 && defect_detail && Array.isArray(defect_detail)) { let totalReworkQty = 0; + let targetProcessCode: string | null = null; for (const item of defect_detail) { if (item.disposition === "rework") { totalReworkQty += parseInt(item.qty, 10) || 0; + if (item.target_process_code) targetProcessCode = item.target_process_code; } } if (totalReworkQty > 0) { const proc = currentSeq.rows[0]; const masterId = proc.parent_process_id || work_order_process_id; + + // 재작업 대상 공정 결정 + let reworkSeqNo = proc.seq_no; + let reworkProcessCode = proc.process_code; + let reworkProcessName = proc.process_name; + let reworkRoutingDetailId = proc.routing_detail_id; + let reworkMasterId = masterId; + + // target_process_code가 지정되면 해당 공정 정보를 조회 + if (targetProcessCode) { + const targetProc = await pool.query( + `SELECT id, seq_no, process_code, process_name, routing_detail_id + FROM work_order_process + WHERE wo_id = $1 AND process_code = $2 AND company_code = $3 + AND parent_process_id IS NULL + LIMIT 1`, + [proc.wo_id, targetProcessCode, companyCode] + ); + if (targetProc.rowCount > 0) { + const tp = targetProc.rows[0]; + reworkSeqNo = tp.seq_no; + reworkProcessCode = tp.process_code; + reworkProcessName = tp.process_name; + reworkRoutingDetailId = tp.routing_detail_id; + reworkMasterId = tp.id; // 지정 공정의 마스터 ID + } + } + const reworkInsert = await pool.query( `INSERT INTO work_order_process ( id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, @@ -1009,23 +1050,25 @@ export const saveResult = async ( $12, $13, $14 ) RETURNING id`, [ - proc.wo_id, proc.seq_no, proc.process_code, proc.process_name, + proc.wo_id, reworkSeqNo, reworkProcessCode, reworkProcessName, proc.is_required, proc.is_fixed_order, proc.standard_time, - proc.equipment_code, proc.routing_detail_id, + proc.equipment_code, reworkRoutingDetailId, String(totalReworkQty), work_order_process_id, - masterId, companyCode, userId, + reworkMasterId, companyCode, userId, ] ); // 재작업 카드에 체크리스트 복사 const reworkId = reworkInsert.rows[0]?.id; if (reworkId) { const reworkChecklistCount = await copyChecklistToSplit( - pool, masterId, reworkId, proc.routing_detail_id, companyCode, userId + pool, reworkMasterId, reworkId, reworkRoutingDetailId, companyCode, userId ); logger.info("[pop/production] 재작업 카드 자동 생성", { reworkId, sourceId: work_order_process_id, reworkQty: totalReworkQty, + targetProcess: targetProcessCode || "(같은 공정)", + reworkSeqNo, checklistCount: reworkChecklistCount, }); } @@ -1051,7 +1094,8 @@ export const saveResult = async ( if (csSeqNum > 1) { const prev = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as tg - FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, [csWoId, String(csSeqNum - 1), companyCode] ); if (prev.rowCount > 0) csPrevGood = parseInt(prev.rows[0].tg, 10) || 0; @@ -1113,15 +1157,14 @@ export const saveResult = async ( } if (shouldActivateNext) { - // 다음 seq 중 is_fixed_order='N'인 첫 공정 OR 다음 Y 그룹 시작점 + // 다음 seq 활성화 (completed도 재활성화 — 새 양품이 들어오면 추가 접수 가능) const nextSeq = String(seqNum + 1); const nextUpdate = await pool.query( `UPDATE work_order_process - SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END, + SET status = 'acceptable', updated_date = NOW() WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NULL - AND status != 'completed' RETURNING id, process_name, status`, [wo_id, nextSeq, companyCode] ); @@ -1149,9 +1192,22 @@ export const saveResult = async ( [work_order_process_id, companyCode] ); + // 리워크 정보도 응답에 포함 (프론트에서 다음 공정 접수 시 전달 가능) + const responseData = latestData.rows[0] || result.rows[0]; + if (responseData) { + const reworkInfo = await pool.query( + `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, + [work_order_process_id] + ); + if (reworkInfo.rows[0]?.rework_source_id) { + responseData.rework_source_id = reworkInfo.rows[0].rework_source_id; + responseData.is_rework = reworkInfo.rows[0].is_rework; + } + } + return res.json({ success: true, - data: latestData.rows[0] || result.rows[0], + data: responseData, }); } catch (error: any) { logger.error("[pop/production] save-result 오류:", error); @@ -1196,7 +1252,8 @@ const checkAndCompleteWorkInstruction = async ( const totalGoodResult = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, [woId, maxSeq, companyCode] ); @@ -1354,11 +1411,10 @@ export const confirmResult = async ( const nextSeq = String(seqNum + 1); await pool.query( `UPDATE work_order_process - SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END, + SET status = 'acceptable', updated_date = NOW() WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NULL - AND status != 'completed'`, + AND parent_process_id IS NULL`, [wo_id, nextSeq, companyCode] ); } @@ -1394,7 +1450,8 @@ export const confirmResult = async ( const prevProcess = await pool.query( `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, [wo_id, prevSeq, companyCode] ); if (prevProcess.rowCount > 0) { @@ -1585,32 +1642,51 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) const instrQty = parseInt(instruction_qty, 10) || 0; const seqNum = parseInt(seq_no, 10); - // 같은 공정(wo_id + seq_no)의 모든 분할 행 접수량 합산 - const totalAccepted = await pool.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [wo_id, seq_no, companyCode] + // 재작업 카드 여부 확인 + const reworkCheck = await pool.query( + `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, + [work_order_process_id] ); - const myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + const isRework = reworkCheck.rows[0]?.is_rework === "Y"; - // 앞공정 양품+특채 합산 - let prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await pool.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + let myInputQty: number; + let prevGoodQty: number; + let availableQty: number; + + if (isRework) { + // 재작업 카드: 자체 input_qty가 접수 가능 수량 + const reworkInput = parseInt(reworkCheck.rows[0]?.input_qty, 10) || 0; + myInputQty = 0; + prevGoodQty = reworkInput; + availableQty = reworkInput; + } else { + // 일반 카드: 앞공정 양품 - 기접수합계 (재작업 카드 제외) + const totalAccepted = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, - [wo_id, prevSeq, companyCode] + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND (is_rework IS NULL OR is_rework != 'Y')`, + [wo_id, seq_no, companyCode] ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; - } - } + myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0; - const availableQty = Math.max(0, prevGoodQty - myInputQty); + prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } + } + availableQty = Math.max(0, prevGoodQty - myInputQty); + } logger.info("[pop/production] available-qty 조회", { work_order_process_id, @@ -1620,6 +1696,41 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) instructionQty: instrQty, }); + // 앞공정에서 리워크로 완료된 양품 수량 (마크 표시용) + // rework_source_id별로 개별 추적하여 정확한 미소진 리워크 수량 계산 + let reworkAvailableQty = 0; + if (!isRework && seqNum > 1) { + const prevSeq = String(seqNum - 1); + // 앞공정의 리워크 완료 SPLIT들 (rework_source_id별) + const reworkSplits = await pool.query( + `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rg + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND is_rework = 'Y' + AND status = 'completed' + AND good_qty::int > 0 + GROUP BY rework_source_id`, + [wo_id, prevSeq, companyCode] + ); + // 현재 공정에서 각 rework_source_id별로 소비된 수량 + for (const rs of reworkSplits.rows) { + const srcId = rs.rework_source_id; + const srcGood = parseInt(rs.rg, 10) || 0; + const consumedResult = await pool.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as consumed + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND is_rework = 'Y' + AND rework_source_id = $4`, + [wo_id, seq_no, companyCode, srcId] + ); + const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; + reworkAvailableQty += Math.max(0, srcGood - consumed); + } + } + return res.json({ success: true, data: { @@ -1627,6 +1738,7 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) myInputQty, availableQty, instructionQty: instrQty, + reworkAvailableQty, // 리워크 물량 포함 수량 }, }); } catch (error: any) { @@ -1706,32 +1818,52 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => const instrQty = parseInt(row.instruction_qty, 10) || 0; const seqNum = parseInt(row.seq_no, 10); - // 같은 공정의 모든 분할 행 접수량 합산 (트랜잭션 내부 — 정확한 값) - const totalAccepted = await client.query( - `SELECT COALESCE(SUM(input_qty::int), 0) as total_input - FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 - AND parent_process_id IS NOT NULL`, - [row.wo_id, row.seq_no, companyCode] + // 재작업 카드 여부 확인 + const isReworkCard = await client.query( + `SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`, + [work_order_process_id] ); - const currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + const isRework = isReworkCard.rows[0]?.is_rework === "Y"; + const reworkInputQty = parseInt(isReworkCard.rows[0]?.input_qty, 10) || 0; - // 앞공정 양품+특채 합산 - let prevGoodQty = instrQty; - if (seqNum > 1) { - const prevSeq = String(seqNum - 1); - const prevProcess = await client.query( - `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + let prevGoodQty: number; + let currentTotalInput: number; + let availableQty: number; + + if (isRework) { + // 재작업 카드: 자체 input_qty가 접수 가능 수량 (앞공정과 무관) + prevGoodQty = reworkInputQty; + currentTotalInput = 0; // 재작업 카드는 자체가 마스터, 분할 행 없음 + availableQty = reworkInputQty; + } else { + // 일반 카드: 앞공정 양품 - 기접수합계 + const totalAccepted = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total_input FROM work_order_process - WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, - [row.wo_id, prevSeq, companyCode] + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND (is_rework IS NULL OR is_rework != 'Y')`, + [row.wo_id, row.seq_no, companyCode] ); - if (prevProcess.rowCount > 0) { - prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0; + + prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await client.query( + `SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL`, + [row.wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0; + } } + availableQty = prevGoodQty - currentTotalInput; } - const availableQty = prevGoodQty - currentTotalInput; if (qty > availableQty) { await client.query("ROLLBACK"); client.release(); @@ -1741,26 +1873,98 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => }); } - // 분할 행 INSERT (원본 행에서 공정 정보 복사) - const result = await client.query( - `INSERT INTO work_order_process ( - id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, + // batch_id: 컬럼이 있으면 포함, 없으면 제외 + const batchId = req.body.batch_id || `BATCH-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + const hasBatchCol = _batchMigrationDone; + + // 리워크 정보 전달: 리워크 카드 접수 / 프론트 전달 / 자동 감지 + let splitIsRework: string | null = null; + let splitReworkSourceId: string | null = null; + + if (isRework) { + // 케이스 1: 리워크 카드에서 직접 접수 + const parentReworkInfo = await client.query( + `SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`, [work_order_process_id] + ); + splitIsRework = parentReworkInfo?.rows[0]?.is_rework || null; + splitReworkSourceId = parentReworkInfo?.rows[0]?.rework_source_id || null; + } else if (req.body.rework_source_id) { + // 케이스 2: 프론트에서 리워크 추적 정보 전달 + splitIsRework = "Y"; + splitReworkSourceId = req.body.rework_source_id; + } else if (seqNum > 1) { + // 케이스 3: 자동 감지 — 앞공정에서 리워크로 완료된 양품이 있는지 확인 + const prevSeq = String(seqNum - 1); + // rework_source_id별로 개별 추적 + const prevReworkSplits = await client.query( + `SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rework_good + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND is_rework = 'Y' + AND status = 'completed' + AND good_qty::int > 0 + GROUP BY rework_source_id`, + [row.wo_id, prevSeq, companyCode] + ); + + // 각 rework_source별로 미소진 수량 확인 + for (const rs of prevReworkSplits.rows) { + const srcId = rs.rework_source_id; + const srcGood = parseInt(rs.rework_good, 10) || 0; + const consumedResult = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as consumed + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + AND parent_process_id IS NOT NULL + AND is_rework = 'Y' + AND rework_source_id = $4`, + [row.wo_id, row.seq_no, companyCode, srcId] + ); + const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0; + const remaining = srcGood - consumed; + + if (remaining > 0 && qty <= remaining) { + // 합류 판정: 일반 물량이 있으면 합류(마크 없음), 없으면 마크 부착 + const normalAvailable = availableQty - remaining; + if (normalAvailable <= 0) { + // 일반 물량 없음 → 합류 불가 → 리워크 마크 + splitIsRework = "Y"; + splitReworkSourceId = srcId; + } + // normalAvailable > 0 → 합류 가능 → 마크 없음 (splitIsRework = null) + break; + } + } + } + + // 분할 행 INSERT (batch_id는 컬럼 존재 시에만, 리워크 정보 포함) + const reworkCols = splitIsRework ? ", is_rework, rework_source_id" : ""; + const reworkVals = splitIsRework ? `, $${hasBatchCol ? 15 : 14}, $${hasBatchCol ? 16 : 15}` : ""; + const reworkParams = splitIsRework ? [splitIsRework, splitReworkSourceId] : []; + + const insertCols = `id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order, standard_time, equipment_code, routing_detail_id, status, input_qty, good_qty, defect_qty, total_production_qty, result_status, accepted_by, accepted_at, started_at, - parent_process_id, company_code, writer - ) VALUES ( - gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, + parent_process_id, company_code, writer${hasBatchCol ? ", batch_id" : ""}${reworkCols}`; + const insertVals = `gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, 'in_progress', $10, '0', '0', '0', 'draft', $11, NOW()::text, NOW()::text, - $12, $13, $11 - ) RETURNING id, input_qty, status, process_name, result_status, accepted_by`, - [ - row.wo_id, row.seq_no, row.process_code, row.process_name, - row.is_required, row.is_fixed_order, row.standard_time, - row.equipment_code, row.routing_detail_id, - String(qty), userId, masterId, companyCode, - ] + $12, $13, $11${hasBatchCol ? ", $14" : ""}${reworkVals}`; + const insertParams = [ + row.wo_id, row.seq_no, row.process_code, row.process_name, + row.is_required, row.is_fixed_order, row.standard_time, + row.equipment_code, row.routing_detail_id, + String(qty), userId, masterId, companyCode, + ...(hasBatchCol ? [batchId] : []), + ...reworkParams, + ]; + + const result = await client.query( + `INSERT INTO work_order_process (${insertCols}) VALUES (${insertVals}) + RETURNING id, input_qty, status, process_name, result_status, accepted_by`, + insertParams ); // 분할 행에 체크리스트 복사 @@ -1769,13 +1973,33 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => client, masterId, splitId, row.routing_detail_id, companyCode, userId ); - // 마스터 행의 input_qty를 분할 합계로 갱신 - const newTotalInput = currentTotalInput + qty; - await client.query( - `UPDATE work_order_process SET input_qty = $3, updated_date = NOW() - WHERE id = $1 AND company_code = $2`, - [masterId, companyCode, String(newTotalInput)] - ); + // 마스터 행의 input_qty를 분할 합계로 갱신 (리워크 접수 시에는 마스터 input_qty 변경 안 함) + let newTotalInput = currentTotalInput + qty; + if (!isRework) { + await client.query( + `UPDATE work_order_process SET input_qty = $3, updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [masterId, companyCode, String(newTotalInput)] + ); + } else { + newTotalInput = currentTotalInput; // 리워크는 기존 합계 유지 + // 리워크 카드: 전량 접수 시에만 이 카드만 completed로 변경 + // (다른 리워크 카드에 영향 없도록 id 정확히 지정) + const reworkAlreadyAccepted = await client.query( + `SELECT COALESCE(SUM(input_qty::int), 0) as total + FROM work_order_process + WHERE parent_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + const totalReworkAccepted = (parseInt(reworkAlreadyAccepted.rows[0]?.total, 10) || 0) + qty; + if (totalReworkAccepted >= reworkInputQty) { + await client.query( + `UPDATE work_order_process SET status = 'completed', updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + } + } await client.query("COMMIT"); @@ -1788,9 +2012,15 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => checklistCount, }); + const acceptData = result.rows[0] || {}; + if (splitReworkSourceId) { + acceptData.rework_source_id = splitReworkSourceId; + acceptData.is_rework = splitIsRework; + } + return res.json({ success: true, - data: result.rows[0], + data: acceptData, message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`, }); } catch (error: any) { @@ -2199,7 +2429,7 @@ export const inventoryInbound = async ( // 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회 const procResult = await client.query( - `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no + `SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no, is_rework FROM work_order_process WHERE id = $1 AND company_code = $2`, [work_order_process_id, companyCode] @@ -2289,12 +2519,22 @@ export const inventoryInbound = async ( ); } + // 6. 리워크 마크 해제 (창고 입고 = 정상 제품 인정, 이력은 rework_source_id에 영구 보존) + if (proc.is_rework === "Y") { + await client.query( + `UPDATE work_order_process SET is_rework = NULL, updated_date = NOW() + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + } + await client.query("COMMIT"); logger.info("[pop/production] 독립 재고 입고 완료", { companyCode, userId, work_order_process_id, itemCode, warehouse_code, location_code: effectiveLocationCode, qty: goodQty, + reworkCleared: proc.is_rework === "Y", }); return res.json({ @@ -2573,21 +2813,36 @@ export const getBomMaterials = async (req: AuthenticatedRequest, res: Response) ); const baseQty = parseFloat(bomBase.rows[0]?.base_qty || "1") || 1; + // 기존 투입량 조회 (item_code별 합산 — detail_content에 item_code 저장됨) + const inputResult = await pool.query( + `SELECT detail_content as item_code, SUM(CAST(NULLIF(result_value, '') AS numeric)) as total_input + FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2 AND detail_type = 'material_input' + AND result_value IS NOT NULL AND result_value != '' + GROUP BY detail_content`, + [processId, companyCode] + ); + const inputMap = new Map(); + for (const row of inputResult.rows) { + inputMap.set(String(row.item_code), parseFloat(row.total_input) || 0); + } + const materials = bomResult.rows.map((bd: Record) => { const bomQty = parseFloat(String(bd.quantity || "0")) || 0; const lossRate = parseFloat(String(bd.loss_rate || "0")) || 0; const requiredQty = Math.ceil((processQty / baseQty) * bomQty * (1 + lossRate / 100)); + const childItemCode = String(bd.child_item_code || ""); return { id: bd.id, child_item_id: bd.child_item_id, - child_item_code: bd.child_item_code || "", + child_item_code: childItemCode, child_item_name: bd.child_item_name || "", bom_qty: bomQty, unit: bd.unit || bd.item_unit || "", process_type: bd.process_type || "", loss_rate: lossRate, required_qty: requiredQty, - input_qty: 0, + input_qty: inputMap.get(childItemCode) || 0, }; }); @@ -2715,3 +2970,84 @@ export const getMaterialInputs = async (req: AuthenticatedRequest, res: Response return res.status(500).json({ success: false, message: error.message }); } }; + +/** + * 체크리스트 조회 (judgment_criteria 조인 포함) + * + * process_work_result를 조회하면서 inspection_standard.judgment_criteria를 + * LEFT JOIN으로 같이 반환한다. + * + * UI는 프론트의 resolveInputType()에서 + * 1순위: judgment_criteria (CAT_JC_01~04) + * 2순위: detail_type 폴백 + * 으로 입력 UI를 결정한다. + */ +export const getChecklistItems = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res + .status(400) + .json({ success: false, message: "processId 필수" }); + } + + const result = await pool.query( + `SELECT + pwr.id, + pwr.company_code, + pwr.work_order_process_id, + pwr.source_work_item_id, + pwr.source_detail_id, + pwr.work_phase, + pwr.item_title, + pwr.item_sort_order, + pwr.detail_content, + pwr.detail_type, + pwr.detail_sort_order, + pwr.is_required, + pwr.inspection_code, + pwr.inspection_method, + pwr.unit, + pwr.lower_limit, + pwr.upper_limit, + pwr.input_type, + pwr.lookup_target, + pwr.display_fields, + pwr.duration_minutes, + pwr.status, + pwr.result_value, + pwr.is_passed, + pwr.remark, + pwr.recorded_by, + pwr.recorded_at, + pwr.started_at, + pwr.group_started_at, + pwr.group_paused_at, + pwr.group_total_paused_time, + pwr.group_completed_at, + ist.judgment_criteria + FROM process_work_result pwr + LEFT JOIN inspection_standard ist + ON pwr.inspection_code = ist.inspection_code + AND pwr.company_code = ist.company_code + WHERE pwr.work_order_process_id = $1 + AND pwr.company_code = $2 + ORDER BY + COALESCE(NULLIF(pwr.item_sort_order, '')::int, 0), + COALESCE(NULLIF(pwr.detail_sort_order, '')::int, 0)`, + [processId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] checklist-items 조회 오류:", error); + return res + .status(500) + .json({ success: false, message: error.message }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index ff383f23..36821e5b 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -22,6 +22,7 @@ import { getBomMaterials, saveMaterialInput, getMaterialInputs, + getChecklistItems, } from "../controllers/popProductionController"; const router = Router(); @@ -49,5 +50,6 @@ router.get("/rework-history/:woId", getReworkHistory); router.get("/bom-materials/:processId", getBomMaterials); router.post("/material-input", saveMaterialInput); router.get("/material-inputs/:processId", getMaterialInputs); +router.get("/checklist-items/:processId", getChecklistItems); export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index e54b2cfa..471935db 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -5909,7 +5909,8 @@ export class ScreenManagementService { const existingScreen = screens[0]; // SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음 - if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 접근 허용 + if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다."); } @@ -5935,20 +5936,64 @@ export class ScreenManagementService { ); } } else { - // 일반 사용자: 회사별 우선, 없으면 공통(*) 조회 + // 일반 사용자: 회사별 우선, 없으면 템플릿에서 자동 복제 layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`, [screenId, companyCode], ); - // 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회 + // 회사별 레이아웃이 없으면 템플릿에서 자동 복제 if (!layout && companyCode !== "*") { - layout = await queryOne<{ layout_data: any }>( + // 1. 공통(*) 템플릿 조회 + let templateLayout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = '*'`, [screenId], ); + + // 2. 공통 없으면 COMPANY_7(탑씰) 폴백 + if (!templateLayout) { + templateLayout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_pop + WHERE screen_id = $1 AND company_code = 'COMPANY_7'`, + [screenId], + ); + } + + // 3. 템플릿이 있으면 해당 회사용으로 복제 + if (templateLayout) { + console.log(`POP 레이아웃 자동 복제: screen_id=${screenId}, 대상 회사=${companyCode}`); + + // 회사명 조회 (레이아웃 내 회사명 치환용) + const companyInfo = await queryOne<{ company_name: string }>( + `SELECT company_name FROM company_mng WHERE company_code = $1`, + [companyCode], + ); + const companyName = companyInfo?.company_name || companyCode; + + let clonedData = JSON.parse(JSON.stringify(templateLayout.layout_data)); + + // layout_data 내 회사명 텍스트 치환 (탑씰 관련 문자열 → 대상 회사명) + const layoutStr = JSON.stringify(clonedData); + const replacedStr = layoutStr + .replace(/\(주\)탑씰/g, companyName) + .replace(/탑씰/g, companyName) + .replace(/TOPSEAL/gi, companyName); + clonedData = JSON.parse(replacedStr); + + // 해당 회사 코드로 INSERT (UPSERT) + await query( + `INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) + VALUES ($1, $2, $3, NOW(), NOW(), 'SYSTEM', 'SYSTEM') + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = 'SYSTEM'`, + [screenId, companyCode, JSON.stringify(clonedData)], + ); + + console.log(`POP 레이아웃 자동 복제 완료: screen_id=${screenId}, company=${companyCode}`); + layout = { layout_data: clonedData }; + } } } @@ -6041,13 +6086,15 @@ export class ScreenManagementService { const existingScreen = screens[0]; - if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 저장 허용 + if (companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다."); } - // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게) - const targetCompanyCode = companyCode === "*" - ? (existingScreen.company_code || "*") + // SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 + // 공통 화면(*)인 경우: 일반 사용자는 자기 회사 코드로 저장 (회사별 레이아웃 분리) + const targetCompanyCode = companyCode === "*" + ? (existingScreen.company_code || "*") : companyCode; console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`); @@ -6086,10 +6133,11 @@ export class ScreenManagementService { [], ); } else { - // 일반 회사: 해당 회사 레이아웃만 조회 (company_code='*'는 최고관리자 전용) + // 일반 회사: 해당 회사 레이아웃 + 공통(*)/COMPANY_7 템플릿도 포함 + // (getLayoutPop에서 자동 복제하므로 템플릿이 있으면 해당 회사도 사용 가능) result = await query<{ screen_id: number }>( `SELECT DISTINCT screen_id FROM screen_layouts_pop - WHERE company_code = $1`, + WHERE company_code IN ($1, '*', 'COMPANY_7')`, [companyCode], ); } @@ -6121,7 +6169,8 @@ export class ScreenManagementService { const existingScreen = screens[0]; - if (companyCode !== "*" && existingScreen.company_code !== companyCode) { + // screen_definitions.company_code가 '*'(공통 화면)이면 모든 회사에서 삭제 허용 + if (companyCode !== "*" && existingScreen.company_code !== companyCode && existingScreen.company_code !== '*') { throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다."); } diff --git a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx deleted file mode 100644 index be480fcc..00000000 --- a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx +++ /dev/null @@ -1,716 +0,0 @@ -"use client"; - -import React, { useState, useEffect, useCallback } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; -import { - Plus, Trash2, Save, Loader2, Pencil, - ClipboardCheck, AlertTriangle, Wrench, Search, Inbox, Settings2, -} from "lucide-react"; -import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; -import { cn } from "@/lib/utils"; -import { apiClient } from "@/lib/api/client"; -import { useAuth } from "@/hooks/useAuth"; -import { toast } from "sonner"; -import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { useTableSettings } from "@/hooks/useTableSettings"; -import { TableSettingsModal } from "@/components/common/TableSettingsModal"; - -/* ───── 테이블명 ───── */ -const INSPECTION_TABLE = "inspection_standard"; - -const INSPECTION_COLUMNS = [ - { key: "inspection_type", label: "검사유형" }, - { key: "inspection_standard", label: "검사기준" }, - { key: "inspection_item_name", label: "검사항목명" }, - { key: "inspection_method", label: "검사방법" }, - { key: "unit", label: "단위" }, - { key: "apply_type", label: "적용유형" }, - { key: "is_active", label: "사용여부" }, -]; -const DEFECT_TABLE = "defect_standard_mng"; -const EQUIPMENT_TABLE = "inspection_equipment_mng"; - -/* ───── 카테고리 flatten ───── */ -const flattenCategories = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flattenCategories(v.children)); - } - return result; -}; - -export default function InspectionManagementPage() { - const { user } = useAuth(); - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); - const ts = useTableSettings("c16-inspection", INSPECTION_TABLE, INSPECTION_COLUMNS); - - const [activeTab, setActiveTab] = useState("inspection"); - - /* ───── 검사기준 ───── */ - const [inspections, setInspections] = useState([]); - const [inspLoading, setInspLoading] = useState(false); - const [inspCount, setInspCount] = useState(0); - const [inspChecked, setInspChecked] = useState([]); - const [inspModalOpen, setInspModalOpen] = useState(false); - const [inspEditMode, setInspEditMode] = useState(false); - const [inspForm, setInspForm] = useState>({}); - const [inspSaving, setInspSaving] = useState(false); - const [searchFilters, setSearchFilters] = useState([]); - - /* ───── 불량관리 ───── */ - const [defects, setDefects] = useState([]); - const [defLoading, setDefLoading] = useState(false); - const [defCount, setDefCount] = useState(0); - const [defChecked, setDefChecked] = useState([]); - const [defModalOpen, setDefModalOpen] = useState(false); - const [defEditMode, setDefEditMode] = useState(false); - const [defForm, setDefForm] = useState>({}); - const [defSaving, setDefSaving] = useState(false); - const [defKeyword, setDefKeyword] = useState(""); - - /* ───── 검사장비 ───── */ - const [equipments, setEquipments] = useState([]); - const [eqLoading, setEqLoading] = useState(false); - const [eqCount, setEqCount] = useState(0); - const [eqChecked, setEqChecked] = useState([]); - const [eqModalOpen, setEqModalOpen] = useState(false); - const [eqEditMode, setEqEditMode] = useState(false); - const [eqForm, setEqForm] = useState>({}); - const [eqSaving, setEqSaving] = useState(false); - const [eqKeyword, setEqKeyword] = useState(""); - - /* ───── 카테고리 옵션 ───── */ - const [catOptions, setCatOptions] = useState>({}); - - /* ═══════════════════ 카테고리 로드 ═══════════════════ */ - useEffect(() => { - const load = async () => { - const optMap: Record = {}; - const catList = [ - { table: INSPECTION_TABLE, col: "inspection_type" }, - { table: INSPECTION_TABLE, col: "apply_type" }, - { table: DEFECT_TABLE, col: "defect_type" }, - { table: EQUIPMENT_TABLE, col: "equipment_status" }, - ]; - await Promise.all( - catList.map(async ({ table, col }) => { - try { - const res = await apiClient.get(`/table-categories/${table}/${col}/values`); - if (res.data?.data?.length > 0) { - optMap[`${table}.${col}`] = flattenCategories(res.data.data); - } - } catch { /* skip */ } - }) - ); - setCatOptions(optMap); - }; - load(); - }, []); - - const getCatLabel = (table: string, col: string, code: string) => { - const opts = catOptions[`${table}.${col}`]; - if (!opts) return code; - return opts.find(o => o.code === code)?.label || code; - }; - - /* ═══════════════════ 데이터 조회 ═══════════════════ */ - const fetchInspections = useCallback(async () => { - setInspLoading(true); - try { - const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); - const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { - page: 1, size: 500, - dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, - autoFilter: true, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - setInspections(rows); - setInspCount(rows.length); - } catch { - toast.error("검사기준 조회에 실패했어요"); - } finally { - setInspLoading(false); - } - }, [searchFilters]); - - const fetchDefects = useCallback(async () => { - setDefLoading(true); - try { - const res = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, { - page: 1, size: 500, autoFilter: true, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - setDefects(rows); - setDefCount(rows.length); - } catch { - toast.error("불량관리 조회에 실패했어요"); - } finally { - setDefLoading(false); - } - }, []); - - const fetchEquipments = useCallback(async () => { - setEqLoading(true); - try { - const res = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { - page: 1, size: 500, autoFilter: true, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - setEquipments(rows); - setEqCount(rows.length); - } catch { - toast.error("검사장비 조회에 실패했어요"); - } finally { - setEqLoading(false); - } - }, []); - - useEffect(() => { fetchInspections(); }, [fetchInspections]); - useEffect(() => { fetchDefects(); fetchEquipments(); }, []); - - /* ───── 클라이언트 필터 ───── */ - const filteredDefects = defKeyword.trim() - ? defects.filter(r => (r.defect_name || "").toLowerCase().includes(defKeyword.toLowerCase()) || (r.defect_type || "").toLowerCase().includes(defKeyword.toLowerCase())) - : defects; - - const filteredEquipments = eqKeyword.trim() - ? equipments.filter(r => (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase())) - : equipments; - - /* ═══════════════════ 검사기준 CRUD ═══════════════════ */ - const openInspCreate = () => { setInspForm({}); setInspEditMode(false); setInspModalOpen(true); }; - const openInspEdit = (row: any) => { setInspForm({ ...row }); setInspEditMode(true); setInspModalOpen(true); }; - const saveInspection = async () => { - if (!inspForm.inspection_standard) { toast.error("검사기준은 필수 입력이에요"); return; } - setInspSaving(true); - try { - if (inspEditMode) { - await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, { - originalData: { id: inspForm.id }, updatedData: inspForm, - }); - toast.success("검사기준을 수정했어요"); - } else { - await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { id: crypto.randomUUID(), ...inspForm }); - toast.success("검사기준을 등록했어요"); - } - setInspModalOpen(false); - fetchInspections(); - } catch { toast.error("저장에 실패했어요"); } - finally { setInspSaving(false); } - }; - const deleteInspections = async () => { - if (inspChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; } - const ok = await confirm("검사기준 삭제", { description: `선택한 ${inspChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` }); - if (!ok) return; - try { - await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { - data: inspChecked.map(id => ({ id })), - }); - toast.success(`${inspChecked.length}건을 삭제했어요`); - setInspChecked([]); - fetchInspections(); - } catch { toast.error("삭제에 실패했어요"); } - }; - - /* ═══════════════════ 불량관리 CRUD ═══════════════════ */ - const openDefCreate = () => { setDefForm({}); setDefEditMode(false); setDefModalOpen(true); }; - const openDefEdit = (row: any) => { setDefForm({ ...row }); setDefEditMode(true); setDefModalOpen(true); }; - const saveDefect = async () => { - if (!defForm.defect_name) { toast.error("불량명은 필수 입력이에요"); return; } - setDefSaving(true); - try { - if (defEditMode) { - await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, { - originalData: { id: defForm.id }, updatedData: defForm, - }); - toast.success("불량유형을 수정했어요"); - } else { - await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, { id: crypto.randomUUID(), ...defForm }); - toast.success("불량유형을 등록했어요"); - } - setDefModalOpen(false); - fetchDefects(); - } catch { toast.error("저장에 실패했어요"); } - finally { setDefSaving(false); } - }; - const deleteDefects = async () => { - if (defChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; } - const ok = await confirm("불량유형 삭제", { description: `선택한 ${defChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` }); - if (!ok) return; - try { - await apiClient.delete(`/table-management/tables/${DEFECT_TABLE}/delete`, { - data: defChecked.map(id => ({ id })), - }); - toast.success(`${defChecked.length}건을 삭제했어요`); - setDefChecked([]); - fetchDefects(); - } catch { toast.error("삭제에 실패했어요"); } - }; - - /* ═══════════════════ 검사장비 CRUD ═══════════════════ */ - const openEqCreate = () => { setEqForm({}); setEqEditMode(false); setEqModalOpen(true); }; - const openEqEdit = (row: any) => { setEqForm({ ...row }); setEqEditMode(true); setEqModalOpen(true); }; - const saveEquipment = async () => { - if (!eqForm.equipment_name) { toast.error("장비명은 필수 입력이에요"); return; } - setEqSaving(true); - try { - if (eqEditMode) { - await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, { - originalData: { id: eqForm.id }, updatedData: eqForm, - }); - toast.success("검사장비를 수정했어요"); - } else { - await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, { id: crypto.randomUUID(), ...eqForm }); - toast.success("검사장비를 등록했어요"); - } - setEqModalOpen(false); - fetchEquipments(); - } catch { toast.error("저장에 실패했어요"); } - finally { setEqSaving(false); } - }; - const deleteEquipments = async () => { - if (eqChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; } - const ok = await confirm("검사장비 삭제", { description: `선택한 ${eqChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` }); - if (!ok) return; - try { - await apiClient.delete(`/table-management/tables/${EQUIPMENT_TABLE}/delete`, { - data: eqChecked.map(id => ({ id })), - }); - toast.success(`${eqChecked.length}건을 삭제했어요`); - setEqChecked([]); - fetchEquipments(); - } catch { toast.error("삭제에 실패했어요"); } - }; - - /* ═══════════════════ JSX ═══════════════════ */ - return ( -
- {ConfirmDialogComponent} - -
- -
- - - - 검사기준 - {inspCount} - - - - 불량관리 - {defCount} - - - - 검사장비 - {eqCount} - - -
- - {/* ──── 검사기준 탭 ──── */} - -
- - - - - -
- } - /> -
-
- - - - - 0 && inspChecked.length === inspections.length} - onCheckedChange={(v) => setInspChecked(v ? inspections.map(r => r.id) : [])} - /> - - {ts.visibleColumns.map((col) => ( - {col.label} - ))} - - - - {inspLoading ? ( - - ) : inspections.length === 0 ? ( -

등록된 검사기준이 없어요

- ) : inspections.map((row) => ( - setInspChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} - onDoubleClick={() => openInspEdit(row)} - > - e.stopPropagation()}> - setInspChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> - - {ts.visibleColumns.map((col) => { - if (col.key === "inspection_type") return {getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)}; - if (col.key === "apply_type") return {getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)}; - if (col.key === "is_active") return {row.is_active ? "사용" : "미사용"}; - return {row[col.key] ?? ""}; - })} - - ))} -
-
-
- - - {/* ──── 불량관리 탭 ──── */} - -
-
-
- - setDefKeyword(e.target.value)} - /> -
- {filteredDefects.length}건 -
-
- - - -
-
-
- - - - - 0 && defChecked.length === filteredDefects.length} - onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map(r => r.id) : [])} - /> - - 불량유형 - 불량명 - 심각도 - 사용여부 - - - - {defLoading ? ( - - ) : filteredDefects.length === 0 ? ( -

등록된 불량유형이 없어요

- ) : filteredDefects.map((row) => ( - setDefChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} - onDoubleClick={() => openDefEdit(row)} - > - e.stopPropagation()}> - setDefChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> - - {getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)} - {row.defect_name} - - {row.severity} - - - {row.is_active ? "사용" : "미사용"} - - - ))} -
-
-
-
- - {/* ──── 검사장비 탭 ──── */} - -
-
-
- - setEqKeyword(e.target.value)} - /> -
- {filteredEquipments.length}건 -
-
- - - -
-
-
- - - - - 0 && eqChecked.length === filteredEquipments.length} - onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map(r => r.id) : [])} - /> - - 장비명 - 모델명 - 제조사 - 교정주기 - 최종교정일 - 장비상태 - - - - {eqLoading ? ( - - ) : filteredEquipments.length === 0 ? ( -

등록된 검사장비가 없어요

- ) : filteredEquipments.map((row) => ( - setEqChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])} - onDoubleClick={() => openEqEdit(row)} - > - e.stopPropagation()}> - setEqChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} /> - - {row.equipment_name} - {row.model_name} - {row.manufacturer} - {row.calibration_cycle} - {row.last_calibration_date} - {getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status)} - - ))} -
-
-
-
- -
- - {/* ═══════════════════ 검사기준 모달 ═══════════════════ */} - - - - {inspEditMode ? "검사기준 수정" : "검사기준 등록"} - 검사기준 정보를 입력해주세요 - -
-
- - -
-
- - -
-
- - setInspForm(p => ({ ...p, inspection_standard: e.target.value }))} placeholder="검사기준을 입력해주세요" /> -
-
- - setInspForm(p => ({ ...p, inspection_item_name: e.target.value }))} placeholder="검사항목명을 입력해주세요" /> -
-
- - setInspForm(p => ({ ...p, inspection_method: e.target.value }))} placeholder="검사방법" /> -
-
- - setInspForm(p => ({ ...p, unit: e.target.value }))} placeholder="단위" /> -
-
-
- setInspForm(p => ({ ...p, is_active: !!v }))} /> - -
-
-
- - - - -
-
- - {/* ═══════════════════ 불량관리 모달 ═══════════════════ */} - - - - {defEditMode ? "불량유형 수정" : "불량유형 등록"} - 불량유형 정보를 입력해주세요 - -
-
- - -
-
- - -
-
- - setDefForm(p => ({ ...p, defect_name: e.target.value }))} placeholder="불량명을 입력해주세요" /> -
-
-
- setDefForm(p => ({ ...p, is_active: !!v }))} /> - -
-
-
- - - - -
-
- - {/* ═══════════════════ 검사장비 모달 ═══════════════════ */} - - - - {eqEditMode ? "검사장비 수정" : "검사장비 등록"} - 검사장비 정보를 입력해주세요 - -
-
- - setEqForm(p => ({ ...p, equipment_name: e.target.value }))} placeholder="장비명을 입력해주세요" /> -
-
- - setEqForm(p => ({ ...p, model_name: e.target.value }))} placeholder="모델명" /> -
-
- - setEqForm(p => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" /> -
-
- - setEqForm(p => ({ ...p, calibration_cycle: e.target.value }))} placeholder="예: 12개월" /> -
-
- - setEqForm(p => ({ ...p, last_calibration_date: e.target.value }))} /> -
-
- - -
-
- - - - -
-
- - - - ); -} diff --git a/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx b/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx index 7fd1a67f..b0d0b9be 100644 --- a/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx @@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, @@ -25,8 +25,13 @@ import { Trash2, Settings2, Loader2, - Monitor, - MousePointerClick, + ChevronRight, + ChevronDown, + PackageOpen, + Truck, + Factory, + Home, + Cpu, } from "lucide-react"; // ============================================================ @@ -81,737 +86,654 @@ const DEFAULT_SETTINGS: PopSettings = { }; // ============================================================ -// Setting Zone Interface & Zone Map +// Screen Groups & Items // ============================================================ -interface SettingZone { +interface ScreenItem { id: string; - label: string; - description: string; - settingPath: string; - type: "toggle" | "tags" | "select" | "text" | "number" | "array-object"; - top: string; - left: string; - width: string; - height: string; - selectOptions?: { value: string; label: string }[]; - arrayObjectFields?: { key: string; label: string; type: "string" | "number" | "select"; selectOptions?: { value: string; label: string }[] }[]; + name: string; + url: string; + settingsKey: string; + screenId: number; } -const ZONE_MAP: Record = { - "/pop/home": [ +interface ScreenGroup { + id: string; + name: string; + icon: string; + screens: ScreenItem[]; +} + +const SCREEN_GROUPS: ScreenGroup[] = [ + { + id: "inbound", + name: "입고", + icon: "PackageOpen", + screens: [ + { id: "purchase-inbound", name: "구매입고", url: "/pop/inbound/purchase", settingsKey: "inbound", screenId: 6528 }, + { id: "inbound-cart", name: "입고 장바구니", url: "/pop/inbound/cart", settingsKey: "inbound", screenId: 6527 }, + { id: "inbound-type", name: "입고유형선택", url: "/pop/inbound", settingsKey: "inbound", screenId: 6529 }, + ], + }, + { + id: "outbound", + name: "출고", + icon: "Truck", + screens: [ + { id: "sales-outbound", name: "판매출고", url: "/pop/outbound/sales", settingsKey: "outbound", screenId: 5 }, + { id: "outbound-type", name: "출고유형선택", url: "/pop/outbound", settingsKey: "outbound", screenId: 6 }, + ], + }, + { + id: "production", + name: "생산", + icon: "Factory", + screens: [ + { id: "process-execution", name: "공정실행", url: "/pop/production/process", settingsKey: "processExecution", screenId: 7 }, + { id: "production-main", name: "생산관리", url: "/pop/production", settingsKey: "processExecution", screenId: 8 }, + ], + }, + { + id: "home", + name: "홈", + icon: "Home", + screens: [ + { id: "home-screen", name: "홈 화면", url: "/pop/home", settingsKey: "home", screenId: 6526 }, + ], + }, + { + id: "plc", + name: "PLC", + icon: "Cpu", + screens: [ + { id: "plc-settings", name: "PLC 연동", url: "/pop/home", settingsKey: "plc", screenId: 6526 }, + ], + }, +]; + +const ICON_MAP: Record> = { + PackageOpen, + Truck, + Factory, + Home, + Cpu, +}; + +// ============================================================ +// Settings Schema +// ============================================================ +interface SettingField { + key: string; + label: string; + description: string; + type: "toggle" | "text" | "number" | "select" | "color" | "tags" | "array-object"; + defaultValue?: unknown; + options?: { value: string; label: string }[]; + fields?: { key: string; label: string; type: string }[]; +} + +const SETTINGS_SCHEMA: Record = { + inbound: [ + { key: "barcodeEnabled", label: "바코드 스캔", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" }, + { key: "inspectionRequired", label: "검사 필수", description: "입고 시 검사 항목을 필수로 표시합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부", description: "입고 확정 시 사진 첨부를 허용합니다", type: "toggle" }, + { key: "packagingRecord", label: "포장 기록", description: "포장/적재 상세 기록을 사용합니다", type: "toggle" }, + { key: "defectSeparation", label: "불량 분리", description: "양품/불량 수량을 분리 입력합니다", type: "toggle" }, + ], + outbound: [ + { key: "barcodeEnabled", label: "바코드 스캔", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부", description: "출고 시 사진 첨부를 허용합니다", type: "toggle" }, + ], + processExecution: [ + { key: "materialInput", label: "자재 투입", description: "BOM 기반 자재 투입 탭을 표시합니다", type: "toggle" }, + { key: "bomFlexible", label: "BOM 유동 투입", description: "기준과 다른 수량 투입을 허용합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부", description: "실적 입력 시 사진 첨부를 허용합니다", type: "toggle" }, + { key: "groupPhotoEnabled", label: "그룹별 사진", description: "체크리스트 그룹마다 사진을 첨부합니다", type: "toggle" }, + { key: "plcEnabled", label: "PLC 연동", description: "설비 PLC 데이터를 자동 연동합니다", type: "toggle" }, + { key: "reworkTargetSelection", label: "재작업 공정 지정", description: "불량 처리 시 특정 공정을 선택할 수 있습니다", type: "toggle" }, + { key: "dateFilter", label: "날짜 필터", description: "작업지시 목록에 날짜 필터를 표시합니다", type: "toggle" }, { - id: "kpiCarousel", - label: "KPI 캐러셀", - description: "오늘의 현황 캐러셀을 표시합니다", - settingPath: "screens.home.kpiCarousel", - type: "toggle", - top: "12%", - left: "3%", - width: "94%", - height: "32%", + key: "lastProcessInventory", label: "마지막 공정 입고", description: "마지막 공정 완료 시 재고 입고 방식", type: "select", options: [ + { value: "auto", label: "자동 입고" }, + { value: "manual", label: "수동 선택" }, + { value: "button", label: "버튼 활성화" }, + ], }, + { key: "defaultWarehouse", label: "기본 창고 기억", description: "선택한 창고를 다음에도 자동 선택합니다", type: "toggle" }, { - id: "recentActivity", - label: "최근 활동", - description: "최근 입출고 활동을 표시합니다", - settingPath: "screens.home.recentActivity", - type: "toggle", - top: "72%", - left: "3%", - width: "94%", - height: "25%", + key: "inspectionAutoJudge", label: "검사 자동 판정", description: "수치 검사 시 상/하한 초과 처리 방식", type: "select", options: [ + { value: "off", label: "사용 안 함" }, + { value: "warn", label: "경고만 표시" }, + { value: "fail", label: "자동 불량" }, + ], }, + { key: "standardTimeDisplay", label: "표준시간 비교", description: "표준시간 대비 실제시간을 표시합니다", type: "toggle" }, + { key: "progressDisplay", label: "진행률 표시", description: "작업지시 전체 진행률을 표시합니다", type: "toggle" }, + { key: "packagingOptions", label: "포장 옵션", description: "포장 단위 선택지를 관리합니다", type: "tags" }, + { key: "defectTypes", label: "불량 유형", description: "불량 유형 선택지를 관리합니다", type: "tags" }, + ], + home: [ + { key: "kpiCarousel", label: "KPI 캐러셀", description: "오늘의 현황 캐러셀을 표시합니다", type: "toggle" }, + { key: "recentActivity", label: "최근 활동", description: "최근 입출고 활동을 표시합니다", type: "toggle" }, + { key: "bannerEnabled", label: "공지 배너", description: "상단에 공지 배너를 표시합니다", type: "toggle" }, + { key: "bannerText", label: "배너 텍스트", description: "공지 배너에 표시할 텍스트", type: "text" }, + { key: "iconThemeColor", label: "아이콘 테마색", description: "메뉴 아이콘의 테마 색상", type: "color" }, + { key: "iconCustomImages", label: "아이콘 커스텀", description: "메뉴 아이콘 이미지를 커스터마이즈합니다", type: "toggle" }, { - id: "home_banner", - label: "공지 배너", - description: "상단에 공지 배너를 표시합니다", - settingPath: "screens.home.bannerEnabled", - type: "toggle", - top: "2%", - left: "3%", - width: "94%", - height: "8%", - }, - { - id: "home_bannerText", - label: "배너 텍스트", - description: "공지 배너에 표시할 텍스트를 입력합니다", - settingPath: "screens.home.bannerText", - type: "text", - top: "2%", - left: "3%", - width: "47%", - height: "8%", - }, - { - id: "home_iconThemeColor", - label: "아이콘 테마색", - description: "메뉴 아이콘의 테마 색상을 지정합니다 (hex)", - settingPath: "screens.home.iconThemeColor", - type: "text", - top: "46%", - left: "3%", - width: "47%", - height: "8%", - }, - { - id: "home_iconCustomImages", - label: "아이콘 이미지 커스텀", - description: "메뉴 아이콘에 커스텀 이미지를 사용합니다", - settingPath: "screens.home.iconCustomImages", - type: "toggle", - top: "46%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "home_dashboardLayout", - label: "대시보드 구성", - description: "대시보드 레이아웃 모드를 선택합니다", - settingPath: "screens.home.dashboardLayout", - type: "select", - top: "56%", - left: "3%", - width: "94%", - height: "8%", - selectOptions: [ + key: "dashboardLayout", label: "대시보드 구성", description: "홈 대시보드 레이아웃", type: "select", options: [ { value: "default", label: "기본" }, - { value: "compact", label: "간결" }, + { value: "compact", label: "컴팩트" }, { value: "detailed", label: "상세" }, ], }, ], - "/pop/inbound": [ + plc: [ { - id: "inbound_barcode", - label: "바코드 스캔", - description: "거래처/품목 바코드 스캔 기능을 사용합니다", - settingPath: "screens.inbound.barcodeEnabled", - type: "toggle", - top: "8%", - left: "80%", - width: "17%", - height: "15%", - }, - { - id: "inbound_inspection", - label: "검사 필수", - description: "입고 시 검사 항목을 필수로 표시합니다", - settingPath: "screens.inbound.inspectionRequired", - type: "toggle", - top: "28%", - left: "65%", - width: "30%", - height: "8%", - }, - { - id: "inbound_photo", - label: "사진 첨부", - description: "입고 확정 시 사진 첨부를 허용합니다", - settingPath: "screens.inbound.photoUpload", - type: "toggle", - top: "85%", - left: "3%", - width: "94%", - height: "10%", - }, - { - id: "inbound_packagingRecord", - label: "포장/적재 기록", - description: "입고 시 포장 및 적재 기록을 입력합니다", - settingPath: "screens.inbound.packagingRecord", - type: "toggle", - top: "38%", - left: "3%", - width: "60%", - height: "8%", - }, - { - id: "inbound_defectSeparation", - label: "불량 분리 입력", - description: "입고 시 불량 분리 입력을 표시합니다", - settingPath: "screens.inbound.defectSeparation", - type: "toggle", - top: "48%", - left: "3%", - width: "60%", - height: "8%", - }, - ], - "/pop/outbound": [ - { - id: "outbound_barcode", - label: "바코드 스캔", - description: "고객사/품목 바코드 스캔 기능을 사용합니다", - settingPath: "screens.outbound.barcodeEnabled", - type: "toggle", - top: "8%", - left: "80%", - width: "17%", - height: "15%", - }, - { - id: "outbound_photo", - label: "사진 첨부", - description: "출고 시 사진 첨부를 허용합니다", - settingPath: "screens.outbound.photoUpload", - type: "toggle", - top: "85%", - left: "3%", - width: "94%", - height: "10%", - }, - ], - "/pop/production": [ - { - id: "pe_material", - label: "자재 투입", - description: "BOM 기반 자재 투입 탭을 표시합니다", - settingPath: "screens.processExecution.materialInput", - type: "toggle", - top: "65%", - left: "52%", - width: "46%", - height: "12%", - }, - { - id: "pe_bomFlexible", - label: "BOM 유동 투입", - description: "기준과 다른 수량 투입을 허용합니다", - settingPath: "screens.processExecution.bomFlexible", - type: "toggle", - top: "65%", - left: "52%", - width: "23%", - height: "6%", - }, - { - id: "pe_photo", - label: "사진 첨부", - description: "실적 입력 시 사진 첨부를 허용합니다", - settingPath: "screens.processExecution.photoUpload", - type: "toggle", - top: "78%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_groupPhoto", - label: "그룹별 사진", - description: "체크리스트 그룹마다 사진을 첨부합니다", - settingPath: "screens.processExecution.groupPhotoEnabled", - type: "toggle", - top: "40%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_plc", - label: "PLC 연동", - description: "설비 PLC 데이터를 자동 연동합니다", - settingPath: "screens.processExecution.plcEnabled", - type: "toggle", - top: "50%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_rework", - label: "재작업 공정 지정", - description: "불량 처리 시 특정 공정을 선택할 수 있습니다", - settingPath: "screens.processExecution.reworkTargetSelection", - type: "toggle", - top: "88%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_packaging", - label: "포장 옵션", - description: "포장 단위 선택지를 관리합니다", - settingPath: "screens.processExecution.packagingOptions", - type: "tags", - top: "58%", - left: "52%", - width: "23%", - height: "6%", - }, - { - id: "pe_defects", - label: "불량 유형", - description: "불량 유형 선택지를 관리합니다", - settingPath: "screens.processExecution.defectTypes", - type: "tags", - top: "88%", - left: "52%", - width: "23%", - height: "6%", - }, - { - id: "pe_dateFilter", - label: "날짜 필터", - description: "작업지시 날짜 필터를 표시합니다", - settingPath: "screens.processExecution.dateFilter", - type: "toggle", - top: "8%", - left: "3%", - width: "46%", - height: "8%", - }, - { - id: "pe_lastProcessInventory", - label: "마지막 공정 재고 입고", - description: "마지막 공정에서 재고 입고 방식을 선택합니다", - settingPath: "screens.processExecution.lastProcessInventory", - type: "select", - top: "18%", - left: "3%", - width: "46%", - height: "8%", - selectOptions: [ - { value: "auto", label: "자동 입고" }, - { value: "manual", label: "수동 입력" }, - { value: "button", label: "버튼 클릭" }, - ], - }, - { - id: "pe_defaultWarehouse", - label: "기본 창고 기억", - description: "마지막 선택 창고를 기억하여 자동 적용합니다", - settingPath: "screens.processExecution.defaultWarehouse", - type: "toggle", - top: "28%", - left: "3%", - width: "46%", - height: "8%", - }, - { - id: "pe_inspectionAutoJudge", - label: "검사 자동 판정", - description: "검사 결과를 자동으로 판정하는 방식을 선택합니다", - settingPath: "screens.processExecution.inspectionAutoJudge", - type: "select", - top: "38%", - left: "3%", - width: "46%", - height: "8%", - selectOptions: [ - { value: "off", label: "사용 안함" }, - { value: "warn", label: "경고만" }, - { value: "fail", label: "불합격 처리" }, - ], - }, - { - id: "pe_standardTimeDisplay", - label: "표준시간 비교", - description: "표준시간 대비 실제 시간을 비교 표시합니다", - settingPath: "screens.processExecution.standardTimeDisplay", - type: "toggle", - top: "18%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_progressDisplay", - label: "진행률 표시", - description: "작업지시 전체 진행률을 표시합니다", - settingPath: "screens.processExecution.progressDisplay", - type: "toggle", - top: "28%", - left: "52%", - width: "46%", - height: "8%", - }, - ], - "/pop/plc": [ - { - id: "plc_connectionType", - label: "PLC 연결 방식", - description: "PLC와의 연결 방식을 선택합니다", - settingPath: "screens.plc.connectionType", - type: "select", - top: "8%", - left: "3%", - width: "94%", - height: "10%", - selectOptions: [ - { value: "db", label: "DB 연동" }, + key: "connectionType", label: "연결 방식", description: "PLC 데이터 연동 방식", type: "select", options: [ + { value: "db", label: "DB 직접 연결" }, { value: "opcua", label: "OPC-UA" }, { value: "rest", label: "REST API" }, ], }, + { key: "refreshInterval", label: "갱신 주기(초)", description: "PLC 데이터 갱신 주기", type: "number" }, { - id: "plc_refreshInterval", - label: "값 갱신 주기", - description: "PLC 값을 갱신하는 주기(초)를 설정합니다", - settingPath: "screens.plc.refreshInterval", - type: "number", - top: "20%", - left: "3%", - width: "94%", - height: "10%", - }, - { - id: "plc_tagMappings", - label: "PLC 태그 매핑", - description: "PLC 태그와 공정/체크리스트 항목을 매핑합니다", - settingPath: "screens.plc.tagMappings", - type: "array-object", - top: "32%", - left: "3%", - width: "94%", - height: "30%", - arrayObjectFields: [ - { key: "tagName", label: "태그명", type: "string" }, - { key: "processCode", label: "공정코드", type: "string" }, - { key: "checklistItemId", label: "체크리스트 항목 ID", type: "string" }, - { key: "unit", label: "단위", type: "string" }, + key: "tagMappings", label: "태그 매핑", description: "PLC 태그와 공정/체크리스트 연결", type: "array-object", fields: [ + { key: "tagName", label: "태그명", type: "text" }, + { key: "processCode", label: "공정코드", type: "text" }, + { key: "checklistItemId", label: "체크리스트 항목", type: "text" }, + { key: "unit", label: "단위", type: "text" }, ], }, { - id: "plc_alarmThresholds", - label: "임계값 경고", - description: "PLC 태그 값의 임계값과 경고 동작을 설정합니다", - settingPath: "screens.plc.alarmThresholds", - type: "array-object", - top: "64%", - left: "3%", - width: "94%", - height: "30%", - arrayObjectFields: [ - { key: "tagName", label: "태그명", type: "string" }, + key: "alarmThresholds", label: "알람 임계값", description: "PLC 값 임계치 경고 설정", type: "array-object", fields: [ + { key: "tagName", label: "태그명", type: "text" }, { key: "lowerLimit", label: "하한", type: "number" }, { key: "upperLimit", label: "상한", type: "number" }, - { key: "action", label: "동작", type: "select", selectOptions: [{ value: "warn", label: "경고" }, { value: "stop", label: "정지" }] }, + { key: "action", label: "동작", type: "select" }, ], }, ], }; -// Screen path to display name mapping -const SCREEN_LABELS: Record = { - "/pop/home": "홈", - "/pop/inbound": "구매입고", - "/pop/outbound": "판매출고", - "/pop/production": "공정실행", - "/pop/plc": "PLC 설정", -}; +// ============================================================ +// Sub-components: TagEditor, ArrayObjectEditor +// ============================================================ -// ============================================================ -// getSettingValue / setSettingValue utilities -// ============================================================ -function getSettingValue(settings: PopSettings, path: string): unknown { - return path - .split(".") - .reduce( - (obj: Record | unknown, key: string) => - (obj as Record)?.[key], - settings as unknown - ); -} - -function setSettingValue( - settings: PopSettings, - path: string, - value: unknown -): PopSettings { - const keys = path.split("."); - const newSettings = JSON.parse(JSON.stringify(settings)) as Record< - string, - unknown - >; - let obj: Record = newSettings; - for (let i = 0; i < keys.length - 1; i++) { - obj = obj[keys[i]] as Record; - } - obj[keys[keys.length - 1]] = value; - return newSettings as unknown as PopSettings; -} - -// ============================================================ -// Tag Editor Component -// ============================================================ function TagEditor({ + label, + description, tags, onChange, }: { + label: string; + description: string; tags: string[]; onChange: (tags: string[]) => void; }) { const [input, setInput] = useState(""); - const handleAdd = () => { - const value = input.trim(); - if (value && !tags.includes(value)) { - onChange([...tags, value]); - setInput(""); - } - }; - return ( -
-
- {tags.map((tag) => ( - +
+ +

{description}

+
+ {tags.map((tag, idx) => ( + {tag} ))} - {tags.length === 0 && ( - - 항목이 없습니다 - - )}
setInput(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") { + if (e.key === "Enter" && input.trim()) { e.preventDefault(); - handleAdd(); + onChange([...tags, input.trim()]); + setInput(""); } }} - placeholder="입력 후 Enter 또는 추가 버튼" - className="h-8 text-sm" + placeholder="추가 후 Enter" + className="flex-1 h-8 text-sm" />
); } -// ============================================================ -// Array Object Editor Component -// ============================================================ function ArrayObjectEditor({ + field, items, - fields, onChange, }: { + field: SettingField; items: Record[]; - fields: NonNullable; onChange: (items: Record[]) => void; }) { - const handleAdd = () => { - const newItem: Record = {}; - fields.forEach((f) => { - if (f.type === "number") newItem[f.key] = 0; - else if (f.type === "select" && f.selectOptions?.length) - newItem[f.key] = f.selectOptions[0].value; - else newItem[f.key] = ""; + const addRow = () => { + const newRow: Record = {}; + field.fields?.forEach((f) => { + newRow[f.key] = f.type === "number" ? 0 : ""; }); - onChange([...items, newItem]); + onChange([...items, newRow]); }; - const handleRemove = (index: number) => { - onChange(items.filter((_, i) => i !== index)); - }; - - const handleChange = ( - index: number, - key: string, - value: string | number - ) => { - const updated = items.map((item, i) => - i === index ? { ...item, [key]: value } : item - ); + const updateRow = (index: number, key: string, value: unknown) => { + const updated = items.map((item, i) => (i === index ? { ...item, [key]: value } : item)); onChange(updated); }; + const removeRow = (index: number) => { + onChange(items.filter((_, i) => i !== index)); + }; + return ( -
- {items.length === 0 && ( - 항목이 없습니다 - )} - {items.map((item, idx) => ( -
-
- - #{idx + 1} - - -
-
- {fields.map((field) => ( -
- - {field.type === "select" && field.selectOptions ? ( - - ) : field.type === "number" ? ( - - handleChange( - idx, - field.key, - parseFloat(e.target.value) || 0 - ) - } - className="h-7 text-xs" - /> - ) : ( - - handleChange(idx, field.key, e.target.value) - } - className="h-7 text-xs" - /> - )} -
- ))} -
+
+
+
+ +

{field.description}

- ))} - + +
+ {items.length === 0 && ( +

항목이 없습니다. 추가 버튼을 눌러주세요.

+ )} +
+ {items.map((item, index) => ( +
+
+ {field.fields?.map((f) => ( +
+ + {f.type === "number" ? ( + updateRow(index, f.key, Number(e.target.value))} + className="h-7 text-xs" + /> + ) : f.type === "select" ? ( + + ) : ( + updateRow(index, f.key, e.target.value)} + className="h-7 text-xs" + /> + )} +
+ ))} +
+ +
+ ))} +
); } // ============================================================ -// Main Page +// SettingRow — renders a single setting field +// ============================================================ +function SettingRow({ + field, + value, + onChange, +}: { + field: SettingField; + value: unknown; + onChange: (value: unknown) => void; +}) { + switch (field.type) { + case "toggle": + return ( +
+
+ +

{field.description}

+
+ +
+ ); + case "text": + return ( +
+ +

{field.description}

+ onChange(e.target.value)} + placeholder={field.label} + className="h-9" + /> +
+ ); + case "number": + return ( +
+ +

{field.description}

+ onChange(Number(e.target.value))} + className="h-9 w-32" + /> +
+ ); + case "select": + return ( +
+ +

{field.description}

+ +
+ ); + case "color": + return ( +
+ +

{field.description}

+
+ onChange(e.target.value)} + className="w-9 h-9 rounded-md cursor-pointer border border-input p-0.5" + /> + onChange(e.target.value)} + className="h-9 w-32" + placeholder="#hex" + /> +
+
+ ); + case "tags": + return ( + + ); + case "array-object": + return ( + []) || []} + onChange={onChange} + /> + ); + default: + return null; + } +} + +// ============================================================ +// ScreenNav — top collapsible screen selector (세로 펼침) +// ============================================================ +function ScreenNav({ + groups, + selectedScreen, + onSelect, + collapsed, + onToggleCollapse, +}: { + groups: ScreenGroup[]; + selectedScreen: ScreenItem | null; + onSelect: (screen: ScreenItem) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + const [expandedGroup, setExpandedGroup] = useState(null); + + const handleGroupClick = (groupId: string) => { + setExpandedGroup(expandedGroup === groupId ? null : groupId); + }; + + const handleScreenSelect = (screen: ScreenItem) => { + onSelect(screen); + setExpandedGroup(null); + if (!collapsed) onToggleCollapse(); // 선택 후 자동 접기 + }; + + if (collapsed) { + // 접힌 상태: 현재 선택된 화면명 + 펼치기 버튼 + return ( +
+ + {selectedScreen && ( + + 📍 {selectedScreen.name} + + )} +
+ ); + } + + // 펼친 상태: 메뉴 그룹 가로 나열 + 클릭 시 하위 화면 드롭 + return ( +
+ {/* 상단: 그룹 탭 가로 나열 + 접기 버튼 */} +
+ + {groups.map((group) => { + const Icon = ICON_MAP[group.icon]; + const isExpanded = expandedGroup === group.id; + const hasSelected = group.screens.some((s) => s.id === selectedScreen?.id); + + return ( + + ); + })} +
+ + {/* 하위 화면 목록 (펼쳐진 그룹) */} + {expandedGroup && ( +
+ + {groups.find((g) => g.id === expandedGroup)?.name}: + + {groups + .find((g) => g.id === expandedGroup) + ?.screens.map((screen) => { + const isSelected = selectedScreen?.id === screen.id; + return ( + + ); + })} +
+ )} +
+ ); +} + +// ============================================================ +// SettingsForm — auto-rendered from schema +// ============================================================ +function SettingsForm({ + screenName, + settingsKey, + fields, + values, + onChange, +}: { + screenName: string; + settingsKey: string; + fields: SettingField[]; + values: Record; + onChange: (key: string, value: unknown) => void; +}) { + return ( +
+
+ +

{screenName} 설정

+ + {settingsKey} + +
+ {fields.map((field) => ( + onChange(field.key, v)} + /> + ))} +
+ ); +} + +// ============================================================ +// Main Page Component // ============================================================ export default function PopSettingsMngPage() { const { user } = useAuth(); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [selectedScreen, setSelectedScreen] = useState( + SCREEN_GROUPS[0].screens[0], + ); + const [navCollapsed, setNavCollapsed] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [syncToAll, setSyncToAll] = useState(false); + const [lastPath, setLastPath] = useState(""); const iframeRef = useRef(null); - // Settings state - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [originalSettings, setOriginalSettings] = - useState(DEFAULT_SETTINGS); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [existingId, setExistingId] = useState(null); - const [hasChanges, setHasChanges] = useState(false); - - // Overlay state - const [currentPath, setCurrentPath] = useState("/pop/home"); - const [selectedZone, setSelectedZone] = useState(null); - const [hoveredZone, setHoveredZone] = useState(null); - - // Current zones based on detected path - const currentZones = ZONE_MAP[currentPath] || []; - - // ---- Fetch settings ---- + // ---- Load settings from screen_layouts_pop per screen ---- const fetchSettings = useCallback(async () => { setLoading(true); try { - const res = await apiClient.get("/data/pop_settings?pageSize=1"); - const rows = res.data?.data?.data || res.data?.data || []; - if (rows.length > 0 && rows[0].settings_data) { - const parsed = - typeof rows[0].settings_data === "string" - ? JSON.parse(rows[0].settings_data) - : rows[0].settings_data; - const merged: PopSettings = { - ...DEFAULT_SETTINGS, - ...parsed, - screens: { - ...DEFAULT_SETTINGS.screens, - ...parsed.screens, - processExecution: { - ...DEFAULT_SETTINGS.screens.processExecution, - ...parsed.screens?.processExecution, - }, - inbound: { - ...DEFAULT_SETTINGS.screens.inbound, - ...parsed.screens?.inbound, - }, - outbound: { - ...DEFAULT_SETTINGS.screens.outbound, - ...parsed.screens?.outbound, - }, - home: { - ...DEFAULT_SETTINGS.screens.home, - ...parsed.screens?.home, - }, - plc: { - ...DEFAULT_SETTINGS.screens.plc, - ...parsed.screens?.plc, - }, - }, - }; - setSettings(merged); - setOriginalSettings(merged); - if (rows[0].id) { - setExistingId(rows[0].id); + // Collect all unique screenIds from SCREEN_GROUPS + const allScreens: { screenId: number; settingsKey: string }[] = []; + for (const group of SCREEN_GROUPS) { + for (const screen of group.screens) { + if (!allScreens.some((s) => s.screenId === screen.screenId && s.settingsKey === screen.settingsKey)) { + allScreens.push({ screenId: screen.screenId, settingsKey: screen.settingsKey }); + } } } + + // Fetch popConfig from each screen's layout-pop + const merged: PopSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS)); + + await Promise.all( + allScreens.map(async ({ screenId, settingsKey }) => { + try { + const res = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`); + const popConfig = res.data?.data?.settings?.popConfig; + if (popConfig) { + const key = settingsKey as keyof PopSettings["screens"]; + (merged.screens as Record>)[key] = { + ...(merged.screens as Record>)[key], + ...popConfig, + }; + } + } catch { + // Screen may not have layout-pop yet, use defaults + } + }), + ); + + setSettings(merged); } catch { - const local = localStorage.getItem("pop_settings"); - if (local) { - try { - const parsed = JSON.parse(local); - const merged = { ...DEFAULT_SETTINGS, ...parsed }; - setSettings(merged); - setOriginalSettings(merged); - } catch { - /* use default */ - } - } + // Fallback: use defaults } setLoading(false); }, []); @@ -820,613 +742,212 @@ export default function PopSettingsMngPage() { fetchSettings(); }, [fetchSettings]); - // Track changes - useEffect(() => { - setHasChanges( - JSON.stringify(settings) !== JSON.stringify(originalSettings) - ); - }, [settings, originalSettings]); - - // ---- iframe URL change detection ---- + // ---- iframe navigation sync ---- useEffect(() => { const timer = setInterval(() => { try { - const path = - iframeRef.current?.contentWindow?.location.pathname || ""; - if (path && path !== currentPath) { - setCurrentPath(path); - setSelectedZone(null); + const path = iframeRef.current?.contentWindow?.location.pathname; + if (path && path !== lastPath) { + setLastPath(path); + for (const group of SCREEN_GROUPS) { + const found = group.screens.find((s) => path === s.url || path.startsWith(s.url + "/")); + if (found) { + setSelectedScreen(found); + break; + } + } } } catch { - /* cross-origin: ignore */ + // cross-origin: silently ignore } - }, 500); + }, 1000); return () => clearInterval(timer); - }, [currentPath]); + }, [lastPath]); - // ---- Zone selection ---- - const selectZone = (zone: SettingZone) => { - setSelectedZone(zone); + // ---- Screen select handler ---- + const handleScreenSelect = (screen: ScreenItem) => { + setSelectedScreen(screen); + if (iframeRef.current) { + iframeRef.current.src = screen.url; + } }; - // ---- Save ---- - const handleSave = async () => { - setSaving(true); - try { - await apiClient.post("/pop/execute-action", { - taskType: "data-save", - targetTable: "pop_settings", - columnMapping: { - id: existingId || crypto.randomUUID(), - company_code: user?.companyCode || user?.company_code || "COMPANY_7", - settings_data: JSON.stringify(settings), - updated_by: user?.userId, + // ---- Settings update helper ---- + const updateScreenSetting = (settingsKey: string, fieldKey: string, value: unknown) => { + setSettings((prev) => ({ + ...prev, + screens: { + ...prev.screens, + [settingsKey]: { + ...(prev.screens as Record>)[settingsKey], + [fieldKey]: value, }, - }); - setOriginalSettings(settings); - setExistingId(existingId || null); - // Reload iframe so POP picks up new settings - try { - iframeRef.current?.contentWindow?.location.reload(); - } catch { - /* cross-origin fallback */ - if (iframeRef.current) { - iframeRef.current.src = iframeRef.current.src; + }, + })); + setHasChanges(true); + }; + + // ---- Save to screen_layouts_pop per screen ---- + const handleSave = async () => { + if (!selectedScreen) return; + setSaving(true); + + try { + const currentKey = selectedScreen.settingsKey as keyof PopSettings["screens"]; + const popConfigToSave = (settings.screens as Record>)[currentKey]; + + // Helper: save popConfig to a single screen's layout-pop + const saveToScreen = async (screenId: number, popConfig: Record) => { + // 1. Get current layout + const layoutRes = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`).catch(() => null); + const layoutData = layoutRes?.data?.data || { version: "pop-5.0", components: {}, settings: {}, gridConfig: {} }; + + // 2. Update settings.popConfig + layoutData.settings = { + ...(layoutData.settings || {}), + popConfig, + }; + + // 3. Save + await apiClient.post(`/screen-management/screens/${screenId}/layout-pop`, layoutData); + }; + + // Save to the selected screen + await saveToScreen(selectedScreen.screenId, popConfigToSave); + + // If syncToAll is on, save to all other screens with the same settingsKey + if (syncToAll) { + const otherScreens: number[] = []; + for (const group of SCREEN_GROUPS) { + for (const screen of group.screens) { + if (screen.settingsKey === selectedScreen.settingsKey && screen.screenId !== selectedScreen.screenId) { + if (!otherScreens.includes(screen.screenId)) { + otherScreens.push(screen.screenId); + } + } + } } + await Promise.all(otherScreens.map((sid) => saveToScreen(sid, popConfigToSave))); } - alert("POP 설정이 저장되었습니다."); + + setHasChanges(false); + // Reload iframe to apply settings + if (iframeRef.current) { + iframeRef.current.contentWindow?.location.reload(); + } + alert("설정이 저장되었습니다."); } catch { - localStorage.setItem("pop_settings", JSON.stringify(settings)); - alert( - "설정이 로컬에 저장되었습니다. (DB 테이블 생성 후 자동 동기화됩니다)" - ); + alert("저장에 실패했습니다."); } setSaving(false); }; - // ---- Reset to default ---- + // ---- Reset to defaults ---- const handleReset = () => { - if ( - confirm( - "모든 설정을 기본값으로 초기화하시겠습니까?\n저장 버튼을 눌러야 DB에 반영됩니다." - ) - ) { + if (window.confirm("모든 설정을 기본값으로 초기화하시겠습니까?")) { setSettings(DEFAULT_SETTINGS); - setSelectedZone(null); + setHasChanges(true); } }; - // ---- Setting value update from panel ---- - const handleSettingChange = (zone: SettingZone, value: unknown) => { - setSettings((prev) => setSettingValue(prev, zone.settingPath, value)); - }; - - // ---- Loading state ---- - if (loading) { - return ( -
-
- - 설정을 불러오는 중... -
-
- ); - } - - // Collect all zones for current screen as summary list - const summaryZones = currentZones; + // ---- Current screen schema values ---- + const currentSettingsKey = selectedScreen?.settingsKey || "inbound"; + const currentFields = SETTINGS_SCHEMA[currentSettingsKey] || []; + const currentValues = (settings.screens as Record>)[currentSettingsKey] || {}; return ( -
-
- {/* ---- Page Header ---- */} -
-
-
- -

- POP 화면 설정 -

+
+ {/* ---- Header ---- */} +
+
+ +

POP 화면 설정

+ + {user?.companyCode || "COMPANY_7"} + + {hasChanges && ( + + 미저장 변경 + + )} +
+
+ + +
+
+ + {/* ---- Screen Nav (top, collapsible vertically) ---- */} + setNavCollapsed((prev) => !prev)} + /> + + {/* ---- Body: iframe (left) + settings (right) ---- */} +
+ {/* Left: iframe (POP preview) */} +
+ {loading ? ( +
+
-

- 왼쪽 POP 화면에서 설정할 영역을 클릭하면 오른쪽에 설정 패널이 열립니다. - {user?.companyCode && ( - - {user.companyCode} - - )} -

-
-
- - -
+ ) : ( +