merge: feature/pop-bugfix-and-settings → main
POP 작업 통합 (2026-04-07): - POP 회사별 자동 세팅 (메뉴 활성화 시 레이아웃 자동 생성) - 작업상세 체크리스트 토글 UI (큰 터치 버튼) - BOM 자재 자동 연동 + 키패드 모달 (소수점 지원) - input_qty 합산 버그 수정 - 리워크/배치/공정실행 다수 버그 수정 - POP UI judgment_criteria 매핑 (judgment_criteria 우선, detail_type 폴백) - 프로필 POP 모드 조건부 표시 - 자재투입 ±20% 범위 검증 + 색상 피드백
This commit is contained in:
Generated
+12
-47
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void> {
|
||||
// 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}개 화면`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, number>();
|
||||
for (const row of inputResult.rows) {
|
||||
inputMap.set(String(row.item_code), parseFloat(row.total_input) || 0);
|
||||
}
|
||||
|
||||
const materials = bomResult.rows.map((bd: Record<string, unknown>) => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 레이아웃을 삭제할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [inspLoading, setInspLoading] = useState(false);
|
||||
const [inspCount, setInspCount] = useState(0);
|
||||
const [inspChecked, setInspChecked] = useState<string[]>([]);
|
||||
const [inspModalOpen, setInspModalOpen] = useState(false);
|
||||
const [inspEditMode, setInspEditMode] = useState(false);
|
||||
const [inspForm, setInspForm] = useState<Record<string, any>>({});
|
||||
const [inspSaving, setInspSaving] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
/* ───── 불량관리 ───── */
|
||||
const [defects, setDefects] = useState<any[]>([]);
|
||||
const [defLoading, setDefLoading] = useState(false);
|
||||
const [defCount, setDefCount] = useState(0);
|
||||
const [defChecked, setDefChecked] = useState<string[]>([]);
|
||||
const [defModalOpen, setDefModalOpen] = useState(false);
|
||||
const [defEditMode, setDefEditMode] = useState(false);
|
||||
const [defForm, setDefForm] = useState<Record<string, any>>({});
|
||||
const [defSaving, setDefSaving] = useState(false);
|
||||
const [defKeyword, setDefKeyword] = useState("");
|
||||
|
||||
/* ───── 검사장비 ───── */
|
||||
const [equipments, setEquipments] = useState<any[]>([]);
|
||||
const [eqLoading, setEqLoading] = useState(false);
|
||||
const [eqCount, setEqCount] = useState(0);
|
||||
const [eqChecked, setEqChecked] = useState<string[]>([]);
|
||||
const [eqModalOpen, setEqModalOpen] = useState(false);
|
||||
const [eqEditMode, setEqEditMode] = useState(false);
|
||||
const [eqForm, setEqForm] = useState<Record<string, any>>({});
|
||||
const [eqSaving, setEqSaving] = useState(false);
|
||||
const [eqKeyword, setEqKeyword] = useState("");
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
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 (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="border-b px-3">
|
||||
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="inspection"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4 mr-2" />
|
||||
검사기준
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{inspCount}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="defect"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
불량관리
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{defCount}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="equipment"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
검사장비
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{eqCount}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ──── 검사기준 탭 ──── */}
|
||||
<TabsContent value="inspection" className="p-3 mt-0">
|
||||
<div className="mb-3">
|
||||
<DynamicSearchFilter
|
||||
tableName={INSPECTION_TABLE}
|
||||
filterId="c16-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={inspCount}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openInspCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = inspections.find(r => inspChecked.includes(r.id));
|
||||
if (sel) openInspEdit(sel);
|
||||
else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteInspections}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={inspections.length > 0 && inspChecked.length === inspections.length}
|
||||
onCheckedChange={(v) => setInspChecked(v ? inspections.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inspLoading ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : inspections.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 검사기준이 없어요</p></TableCell></TableRow>
|
||||
) : inspections.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", inspChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setInspChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openInspEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={inspChecked.includes(row.id)} onCheckedChange={(v) => setInspChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "inspection_type") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)}</TableCell>;
|
||||
if (col.key === "apply_type") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)}</TableCell>;
|
||||
if (col.key === "is_active") return <TableCell key={col.key} className="text-center"><Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge></TableCell>;
|
||||
return <TableCell key={col.key}>{row[col.key] ?? ""}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ──── 불량관리 탭 ──── */}
|
||||
<TabsContent value="defect" className="p-3 mt-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 w-56 text-sm"
|
||||
placeholder="불량명 검색..."
|
||||
value={defKeyword}
|
||||
onChange={(e) => setDefKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{filteredDefects.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openDefCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = defects.find(r => defChecked.includes(r.id));
|
||||
if (sel) openDefEdit(sel);
|
||||
else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteDefects}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={filteredDefects.length > 0 && defChecked.length === filteredDefects.length}
|
||||
onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">불량유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">불량명</TableHead>
|
||||
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">심각도</TableHead>
|
||||
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defLoading ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : filteredDefects.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 불량유형이 없어요</p></TableCell></TableRow>
|
||||
) : filteredDefects.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", defChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setDefChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openDefEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={defChecked.includes(row.id)} onCheckedChange={(v) => setDefChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
<TableCell>{getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)}</TableCell>
|
||||
<TableCell>{row.defect_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={row.severity === "Critical" ? "destructive" : "secondary"} className="text-xs">{row.severity}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ──── 검사장비 탭 ──── */}
|
||||
<TabsContent value="equipment" className="p-3 mt-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 w-56 text-sm"
|
||||
placeholder="장비명 검색..."
|
||||
value={eqKeyword}
|
||||
onChange={(e) => setEqKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{filteredEquipments.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openEqCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = equipments.find(r => eqChecked.includes(r.id));
|
||||
if (sel) openEqEdit(sel);
|
||||
else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteEquipments}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={filteredEquipments.length > 0 && eqChecked.length === filteredEquipments.length}
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">장비명</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">모델명</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교정주기</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">최종교정일</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">장비상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 검사장비가 없어요</p></TableCell></TableRow>
|
||||
) : filteredEquipments.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", eqChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setEqChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openEqEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={eqChecked.includes(row.id)} onCheckedChange={(v) => setEqChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
<TableCell>{row.equipment_name}</TableCell>
|
||||
<TableCell>{row.model_name}</TableCell>
|
||||
<TableCell>{row.manufacturer}</TableCell>
|
||||
<TableCell>{row.calibration_cycle}</TableCell>
|
||||
<TableCell>{row.last_calibration_date}</TableCell>
|
||||
<TableCell>{getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 검사기준 모달 ═══════════════════ */}
|
||||
<Dialog open={inspModalOpen} onOpenChange={setInspModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{inspEditMode ? "검사기준 수정" : "검사기준 등록"}</DialogTitle>
|
||||
<DialogDescription>검사기준 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사유형</Label>
|
||||
<Select value={inspForm.inspection_type || ""} onValueChange={(v) => setInspForm(p => ({ ...p, inspection_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[`${INSPECTION_TABLE}.inspection_type`] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">적용유형</Label>
|
||||
<Select value={inspForm.apply_type || ""} onValueChange={(v) => setInspForm(p => ({ ...p, apply_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[`${INSPECTION_TABLE}.apply_type`] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사기준 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9" value={inspForm.inspection_standard || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_standard: e.target.value }))} placeholder="검사기준을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사항목명</Label>
|
||||
<Input className="h-9" value={inspForm.inspection_item_name || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_item_name: e.target.value }))} placeholder="검사항목명을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사방법</Label>
|
||||
<Input className="h-9" value={inspForm.inspection_method || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_method: e.target.value }))} placeholder="검사방법" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">단위</Label>
|
||||
<Input className="h-9" value={inspForm.unit || ""} onChange={(e) => setInspForm(p => ({ ...p, unit: e.target.value }))} placeholder="단위" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={inspForm.is_active ?? true} onCheckedChange={(v) => setInspForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setInspModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveInspection} disabled={inspSaving}>
|
||||
{inspSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 불량관리 모달 ═══════════════════ */}
|
||||
<Dialog open={defModalOpen} onOpenChange={setDefModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{defEditMode ? "불량유형 수정" : "불량유형 등록"}</DialogTitle>
|
||||
<DialogDescription>불량유형 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">불량유형</Label>
|
||||
<Select value={defForm.defect_type || ""} onValueChange={(v) => setDefForm(p => ({ ...p, defect_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[`${DEFECT_TABLE}.defect_type`] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">심각도 <span className="text-destructive">*</span></Label>
|
||||
<Select value={defForm.severity || ""} onValueChange={(v) => setDefForm(p => ({ ...p, severity: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Critical">Critical</SelectItem>
|
||||
<SelectItem value="Major">Major</SelectItem>
|
||||
<SelectItem value="Minor">Minor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">불량명 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9" value={defForm.defect_name || ""} onChange={(e) => setDefForm(p => ({ ...p, defect_name: e.target.value }))} placeholder="불량명을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={defForm.is_active ?? true} onCheckedChange={(v) => setDefForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDefModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveDefect} disabled={defSaving}>
|
||||
{defSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 검사장비 모달 ═══════════════════ */}
|
||||
<Dialog open={eqModalOpen} onOpenChange={setEqModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{eqEditMode ? "검사장비 수정" : "검사장비 등록"}</DialogTitle>
|
||||
<DialogDescription>검사장비 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">장비명 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9" value={eqForm.equipment_name || ""} onChange={(e) => setEqForm(p => ({ ...p, equipment_name: e.target.value }))} placeholder="장비명을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">모델명</Label>
|
||||
<Input className="h-9" value={eqForm.model_name || ""} onChange={(e) => setEqForm(p => ({ ...p, model_name: e.target.value }))} placeholder="모델명" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">제조사</Label>
|
||||
<Input className="h-9" value={eqForm.manufacturer || ""} onChange={(e) => setEqForm(p => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">교정주기</Label>
|
||||
<Input className="h-9" value={eqForm.calibration_cycle || ""} onChange={(e) => setEqForm(p => ({ ...p, calibration_cycle: e.target.value }))} placeholder="예: 12개월" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">최종교정일</Label>
|
||||
<Input type="date" className="h-9" value={eqForm.last_calibration_date || ""} onChange={(e) => setEqForm(p => ({ ...p, last_calibration_date: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">장비상태</Label>
|
||||
<Select value={eqForm.equipment_status || ""} onValueChange={(v) => setEqForm(p => ({ ...p, equipment_status: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[`${EQUIPMENT_TABLE}.equipment_status`] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEqModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveEquipment} disabled={eqSaving}>
|
||||
{eqSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,259 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
|
||||
interface PopSettings {
|
||||
version: string;
|
||||
screens: {
|
||||
processExecution: {
|
||||
materialInput: boolean;
|
||||
photoUpload: boolean;
|
||||
plcEnabled: boolean;
|
||||
bomFlexible: boolean;
|
||||
packagingOptions: string[];
|
||||
defectTypes: string[];
|
||||
reworkTargetSelection: boolean;
|
||||
groupPhotoEnabled: boolean;
|
||||
};
|
||||
inbound: {
|
||||
inspectionRequired: boolean;
|
||||
photoUpload: boolean;
|
||||
barcodeEnabled: boolean;
|
||||
};
|
||||
outbound: {
|
||||
photoUpload: boolean;
|
||||
barcodeEnabled: boolean;
|
||||
};
|
||||
home: {
|
||||
kpiCarousel: boolean;
|
||||
recentActivity: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PopSettings = {
|
||||
version: "hardcoded-1.0",
|
||||
screens: {
|
||||
processExecution: {
|
||||
materialInput: true,
|
||||
photoUpload: true,
|
||||
plcEnabled: false,
|
||||
bomFlexible: true,
|
||||
packagingOptions: ["낱개", "박스", "파렛트"],
|
||||
defectTypes: ["스크래치", "치수불량", "변색", "크랙", "기포"],
|
||||
reworkTargetSelection: true,
|
||||
groupPhotoEnabled: false,
|
||||
},
|
||||
inbound: {
|
||||
inspectionRequired: false,
|
||||
photoUpload: false,
|
||||
barcodeEnabled: true,
|
||||
},
|
||||
outbound: {
|
||||
photoUpload: false,
|
||||
barcodeEnabled: true,
|
||||
},
|
||||
home: {
|
||||
kpiCarousel: true,
|
||||
recentActivity: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function Toggle({ label, description, value, onChange }: {
|
||||
label: string; description?: string; value: boolean; onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900">{label}</p>
|
||||
{description && <p className="text-xs text-gray-400 mt-0.5">{description}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onChange(!value)}
|
||||
className={`w-12 h-7 rounded-full transition-all ${value ? "bg-blue-500" : "bg-gray-200"}`}
|
||||
>
|
||||
<div className={`w-5 h-5 bg-white rounded-full shadow-sm transition-transform mx-1 ${value ? "translate-x-5" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagEditor({ label, tags, onChange }: {
|
||||
label: string; tags: string[]; onChange: (tags: string[]) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
return (
|
||||
<div className="py-3 border-b border-gray-100">
|
||||
<p className="text-sm font-semibold text-gray-900 mb-2">{label}</p>
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-700 text-xs font-medium rounded-full">
|
||||
{tag}
|
||||
<button onClick={() => onChange(tags.filter((t) => t !== tag))} className="text-blue-400 hover:text-blue-600">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && input.trim()) {
|
||||
onChange([...tags, input.trim()]);
|
||||
setInput("");
|
||||
}
|
||||
}}
|
||||
placeholder="추가 후 Enter"
|
||||
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PopAdminSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const [settings, setSettings] = useState<PopSettings>(DEFAULT_SETTINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"process" | "inbound" | "outbound" | "home">("process");
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
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;
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
|
||||
}
|
||||
} catch {
|
||||
// 테이블 없으면 기본값 사용
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSettings(); }, [fetchSettings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// pop_settings 테이블에 저장 (없으면 generic data API 사용)
|
||||
await apiClient.post("/pop/execute-action", {
|
||||
taskType: "data-save",
|
||||
targetTable: "pop_settings",
|
||||
columnMapping: {
|
||||
id: crypto.randomUUID(),
|
||||
company_code: user?.companyCode || "COMPANY_7",
|
||||
settings_data: JSON.stringify(settings),
|
||||
updated_by: user?.userId,
|
||||
},
|
||||
});
|
||||
alert("설정이 저장되었습니다");
|
||||
} catch {
|
||||
// 테이블이 없을 수 있으므로 localStorage fallback
|
||||
localStorage.setItem("pop_settings", JSON.stringify(settings));
|
||||
alert("설정이 로컬에 저장되었습니다 (DB 테이블 생성 후 동기화 필요)");
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const pe = settings.screens.processExecution;
|
||||
const ib = settings.screens.inbound;
|
||||
const ob = settings.screens.outbound;
|
||||
const hm = settings.screens.home;
|
||||
|
||||
const updatePE = (key: string, value: unknown) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
screens: { ...prev.screens, processExecution: { ...prev.screens.processExecution, [key]: value } },
|
||||
}));
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ key: "process" as const, label: "공정실행" },
|
||||
{ key: "inbound" as const, label: "입고" },
|
||||
{ key: "outbound" as const, label: "출고" },
|
||||
{ key: "home" as const, label: "홈" },
|
||||
];
|
||||
|
||||
return (
|
||||
<PopShell title="POP 설정 관리" showBanner={false} showBack>
|
||||
<div className="max-w-2xl mx-auto p-4">
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-1 mb-4 bg-gray-100 rounded-xl p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition-all ${
|
||||
activeTab === tab.key ? "bg-white text-gray-900 shadow-sm" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 공정실행 설정 */}
|
||||
{activeTab === "process" && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
<h3 className="text-base font-bold text-gray-900 mb-3">공정실행 설정</h3>
|
||||
<Toggle label="자재 투입" description="BOM 기반 자재 투입 탭 표시" value={pe.materialInput} onChange={(v) => updatePE("materialInput", v)} />
|
||||
<Toggle label="BOM 유동 투입" description="기준과 다른 수량 투입 허용" value={pe.bomFlexible} onChange={(v) => updatePE("bomFlexible", v)} />
|
||||
<Toggle label="사진 첨부" description="실적 입력 시 사진 첨부 허용" value={pe.photoUpload} onChange={(v) => updatePE("photoUpload", v)} />
|
||||
<Toggle label="그룹별 사진" description="체크리스트 그룹마다 사진 첨부" value={pe.groupPhotoEnabled} onChange={(v) => updatePE("groupPhotoEnabled", v)} />
|
||||
<Toggle label="PLC 연동" description="설비 PLC 데이터 자동 연동" value={pe.plcEnabled} onChange={(v) => updatePE("plcEnabled", v)} />
|
||||
<Toggle label="재작업 공정 지정" description="불량 처리 시 특정 공정 선택 가능" value={pe.reworkTargetSelection} onChange={(v) => updatePE("reworkTargetSelection", v)} />
|
||||
<TagEditor label="포장 옵션" tags={pe.packagingOptions} onChange={(v) => updatePE("packagingOptions", v)} />
|
||||
<TagEditor label="불량 유형" tags={pe.defectTypes} onChange={(v) => updatePE("defectTypes", v)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 입고 설정 */}
|
||||
{activeTab === "inbound" && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
<h3 className="text-base font-bold text-gray-900 mb-3">입고 설정</h3>
|
||||
<Toggle label="검사 필수" description="입고 시 검사 항목 필수 체크" value={ib.inspectionRequired} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, inspectionRequired: v } } }))} />
|
||||
<Toggle label="사진 첨부" description="입고 확정 시 사진 첨부" value={ib.photoUpload} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, photoUpload: v } } }))} />
|
||||
<Toggle label="바코드 스캔" description="바코드/QR 스캔 기능" value={ib.barcodeEnabled} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, barcodeEnabled: v } } }))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 출고 설정 */}
|
||||
{activeTab === "outbound" && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
<h3 className="text-base font-bold text-gray-900 mb-3">출고 설정</h3>
|
||||
<Toggle label="사진 첨부" value={ob.photoUpload} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, outbound: { ...p.screens.outbound, photoUpload: v } } }))} />
|
||||
<Toggle label="바코드 스캔" value={ob.barcodeEnabled} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, outbound: { ...p.screens.outbound, barcodeEnabled: v } } }))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 홈 설정 */}
|
||||
{activeTab === "home" && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
<h3 className="text-base font-bold text-gray-900 mb-3">홈 화면 설정</h3>
|
||||
<Toggle label="KPI 캐러셀" description="오늘의 현황 캐러셀 표시" value={hm.kpiCarousel} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, home: { ...p.screens.home, kpiCarousel: v } } }))} />
|
||||
<Toggle label="최근 활동" description="최근 입출고 활동 표시" value={hm.recentActivity} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, home: { ...p.screens.home, recentActivity: v } } }))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full mt-4 py-4 rounded-xl text-base font-bold text-white bg-blue-500 active:scale-[0.98] transition-all disabled:opacity-40"
|
||||
>
|
||||
{saving ? "저장중..." : "설정 저장"}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-400 text-center mt-3">
|
||||
설정은 회사({user?.companyCode})별로 저장됩니다. 다른 회사에 복사하려면 관리자에게 문의하세요.
|
||||
</p>
|
||||
</div>
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,6 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_16/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/mold/info": dynamic(() => import("@/app/(main)/COMPANY_16/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/design/project": dynamic(() => import("@/app/(main)/COMPANY_16/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
@@ -249,6 +249,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
||||
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||
const [hasPopMenus, setHasPopMenus] = useState(false);
|
||||
|
||||
// URL 직접 접근 시 탭 자동 열기
|
||||
useEffect(() => {
|
||||
@@ -313,6 +314,26 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
return () => window.removeEventListener("resize", checkIsMobile);
|
||||
}, []);
|
||||
|
||||
// POP 메뉴 존재 여부 확인
|
||||
useEffect(() => {
|
||||
const checkPopMenus = async () => {
|
||||
try {
|
||||
const response = await menuApi.getPopMenus();
|
||||
if (response.success && response.data) {
|
||||
const { childMenus, landingMenu } = response.data;
|
||||
setHasPopMenus(!!(landingMenu?.menu_url || childMenus.length > 0));
|
||||
} else {
|
||||
setHasPopMenus(false);
|
||||
}
|
||||
} catch {
|
||||
setHasPopMenus(false);
|
||||
}
|
||||
};
|
||||
if (user) {
|
||||
checkPopMenus();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 프로필 관련 로직
|
||||
const {
|
||||
isModalOpen,
|
||||
@@ -667,10 +688,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
{hasPopMenus && (
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-1 py-0.5">
|
||||
<ThemeToggle />
|
||||
@@ -843,10 +866,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
<FileCheck className="mr-2 h-4 w-4" />
|
||||
<span>결재함</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
{hasPopMenus && (
|
||||
<DropdownMenuItem onClick={handlePopModeClick}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>POP 모드</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -318,17 +318,7 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* ===== FOOTER ===== */}
|
||||
<footer className="border-t border-gray-200 bg-white px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||
<div className="max-w-[1400px] mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-gray-400">
|
||||
<span>© {new Date().getFullYear()} {user?.companyName || "VEXPLOR"}. All rights reserved.</span>
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<span>Version 1.0.0</span>
|
||||
<span className="hidden sm:inline">|</span>
|
||||
<span>긴급연락: 042-XXX-XXXX</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{/* FOOTER 삭제 — POP 화면에서 불필요 */}
|
||||
|
||||
{/* Marquee keyframes */}
|
||||
<style jsx global>{`
|
||||
|
||||
@@ -150,9 +150,9 @@ export function RecentActivity() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 sm:gap-3">
|
||||
{activities.map((item) => (
|
||||
{activities.map((item, idx) => (
|
||||
<div
|
||||
key={item.id}
|
||||
key={`${item.id}-${idx}`}
|
||||
className="flex items-center gap-3 sm:gap-4 p-3 rounded-xl transition-all duration-150 hover:bg-gray-50 hover:translate-x-1"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -36,18 +36,22 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
}) => {
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [scannedCode, setScannedCode] = useState<string>("");
|
||||
const [manualInput, setManualInput] = useState<string>("");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
|
||||
const webcamRef = useRef<Webcam>(null);
|
||||
const codeReaderRef = useRef<BrowserMultiFormatReader | null>(null);
|
||||
const scanIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const manualInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 바코드 리더 초기화 + 모달 열릴 때 상태 리셋
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setScannedCode("");
|
||||
setManualInput("");
|
||||
setError("");
|
||||
setIsScanning(false);
|
||||
setHasPermission(null);
|
||||
codeReaderRef.current = new BrowserMultiFormatReader();
|
||||
}
|
||||
|
||||
@@ -73,10 +77,15 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
// 후면 카메라 먼저 시도, 실패하면 전면 카메라 fallback
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } });
|
||||
} catch {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
}
|
||||
setHasPermission(true);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
toast.success("카메라 권한이 허용되었습니다.");
|
||||
} catch (err: any) {
|
||||
setHasPermission(false);
|
||||
|
||||
@@ -154,12 +163,14 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 수동 확인 버튼
|
||||
// 수동 확인 버튼 (스캔 결과 또는 직접 입력)
|
||||
const handleConfirm = () => {
|
||||
if (scannedCode) {
|
||||
onScanSuccess(scannedCode);
|
||||
const code = scannedCode || manualInput.trim();
|
||||
if (code) {
|
||||
onScanSuccess(code); // 호출 측에서 검색 필드를 덮어쓰기
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error("스캔된 바코드가 없습니다.");
|
||||
toast.error("바코드를 스캔하거나 직접 입력해주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -254,7 +265,10 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
audio={false}
|
||||
screenshotFormat="image/jpeg"
|
||||
videoConstraints={{
|
||||
facingMode: "environment",
|
||||
facingMode: { ideal: "environment" },
|
||||
}}
|
||||
onUserMediaError={() => {
|
||||
// environment 카메라 실패 시 자동 fallback (Webcam 내부 처리)
|
||||
}}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
@@ -285,6 +299,41 @@ export const BarcodeScanModal: React.FC<BarcodeScanModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수동 입력 (카메라 사용 불가 시 또는 외장 스캐너 사용 시) */}
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">직접 입력 또는 외장 스캐너</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={manualInputRef}
|
||||
type="text"
|
||||
value={manualInput}
|
||||
onChange={(e) => setManualInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && manualInput.trim()) {
|
||||
e.preventDefault();
|
||||
onScanSuccess(manualInput.trim());
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
placeholder="바코드/QR 번호 입력 후 Enter"
|
||||
className="flex-1 h-11 rounded-lg border border-border px-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
autoFocus={hasPermission === false}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (manualInput.trim()) {
|
||||
onScanSuccess(manualInput.trim());
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
disabled={!manualInput.trim()}
|
||||
className="h-11 px-4"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 바코드 포맷 정보 */}
|
||||
<div className="rounded-md border border-border bg-muted/50 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
|
||||
@@ -45,27 +45,89 @@ function QtyInput({
|
||||
onChange: (v: number) => void;
|
||||
max: number;
|
||||
}) {
|
||||
const [padOpen, setPadOpen] = useState(false);
|
||||
const [padValue, setPadValue] = useState(String(value));
|
||||
|
||||
const handlePadOpen = () => {
|
||||
setPadValue("");
|
||||
setPadOpen(true);
|
||||
};
|
||||
|
||||
const handlePadKey = (key: string) => {
|
||||
if (key === "backspace") {
|
||||
setPadValue((prev) => prev.length > 1 ? prev.slice(0, -1) : "0");
|
||||
} else if (key === "clear") {
|
||||
setPadValue("0");
|
||||
} else if (key === "max") {
|
||||
setPadValue(String(max));
|
||||
} else {
|
||||
setPadValue((prev) => prev === "0" ? key : prev + key);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePadConfirm = () => {
|
||||
const num = Math.min(Math.max(0, parseInt(padValue, 10) || 0), max);
|
||||
onChange(num);
|
||||
setPadOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange(Math.max(0, value - 1))}
|
||||
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span
|
||||
className="w-12 text-center text-lg font-bold text-gray-900"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onChange(Math.max(0, value - 1))}
|
||||
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePadOpen}
|
||||
className="w-16 h-10 text-center text-lg font-bold text-gray-900 bg-gray-50 rounded-lg border-2 border-gray-200 hover:border-red-300 active:scale-95 transition-all"
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange(Math.min(max, value + 1))}
|
||||
className="w-10 h-10 rounded-lg bg-gray-100 text-gray-600 text-lg font-bold flex items-center justify-center active:scale-95 transition-all hover:bg-gray-200"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 숫자 키패드 모달 */}
|
||||
{padOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setPadOpen(false)} />
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-4 w-[280px] z-10">
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm text-gray-500">불량 수량 (최대 {max})</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-1" style={{ fontVariantNumeric: "tabular-nums" }}>{padValue || "0"}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{["1","2","3","4","5","6","7","8","9"].map((k) => (
|
||||
<button key={k} onClick={() => handlePadKey(k)}
|
||||
className="h-12 rounded-xl bg-gray-100 text-lg font-bold text-gray-800 active:scale-95 active:bg-gray-200 transition-all">{k}</button>
|
||||
))}
|
||||
<button onClick={() => handlePadKey("clear")}
|
||||
className="h-12 rounded-xl bg-gray-200 text-sm font-bold text-gray-600 active:scale-95 transition-all">C</button>
|
||||
<button onClick={() => handlePadKey("0")}
|
||||
className="h-12 rounded-xl bg-gray-100 text-lg font-bold text-gray-800 active:scale-95 transition-all">0</button>
|
||||
<button onClick={() => handlePadKey("backspace")}
|
||||
className="h-12 rounded-xl bg-gray-200 text-sm font-bold text-gray-600 active:scale-95 transition-all">←</button>
|
||||
</div>
|
||||
<button onClick={() => handlePadKey("max")}
|
||||
className="w-full h-10 rounded-xl bg-red-50 text-red-600 text-sm font-bold mb-2 active:scale-95 transition-all">MAX ({max})</button>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPadOpen(false)}
|
||||
className="flex-1 h-11 rounded-xl bg-gray-100 text-gray-700 font-semibold active:scale-95 transition-all">취소</button>
|
||||
<button onClick={handlePadConfirm}
|
||||
className="flex-1 h-11 rounded-xl bg-red-500 text-white font-bold active:scale-95 transition-all">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -318,6 +318,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
const [wiInfo, setWiInfo] = useState<WorkInstructionInfo | null>(null);
|
||||
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
||||
const [defectTypes, setDefectTypes] = useState<DefectType[]>([]);
|
||||
const [processList, setProcessList] = useState<Array<{ process_code: string; process_name: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -432,7 +433,24 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
setIsLastProcess(false);
|
||||
}
|
||||
|
||||
// 6. Warehouses
|
||||
// 6. 같은 작업지시의 공정 목록 (재작업 공정 지정용)
|
||||
if (procData?.wo_id) {
|
||||
try {
|
||||
const plRes = await dataApi.getTableData("work_order_process", {
|
||||
size: 100,
|
||||
filters: { wo_id: procData.wo_id },
|
||||
});
|
||||
const masters = ((plRes.data ?? []) as ProcessData[])
|
||||
.filter((p) => !p.parent_process_id)
|
||||
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10))
|
||||
.map((p) => ({ process_code: p.process_code, process_name: p.process_name }));
|
||||
// 중복 제거
|
||||
const seen = new Set<string>();
|
||||
setProcessList(masters.filter((m) => { if (seen.has(m.process_code)) return false; seen.add(m.process_code); return true; }));
|
||||
} catch { setProcessList([]); }
|
||||
}
|
||||
|
||||
// 7. Warehouses
|
||||
try {
|
||||
const whRes = await apiClient.get("/pop/production/warehouses");
|
||||
setWarehouses(whRes.data?.data || []);
|
||||
@@ -1754,6 +1772,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
||||
defectTypes={defectTypes}
|
||||
maxQty={productionQty > 0 ? productionQty : 99999}
|
||||
initialEntries={defectEntries}
|
||||
processList={processList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1780,8 +1799,22 @@ function ChecklistRow({
|
||||
|
||||
// Inspection type: check limits
|
||||
const detailType = item.detail_type || "";
|
||||
const isInspection = detailType === "inspection" || detailType === "number" || detailType.startsWith("inspect");
|
||||
const isCheckbox = detailType === "checkbox" || detailType === "check";
|
||||
// 판단기준(judgment_criteria) 우선 → 폴백으로 detail_type 매핑
|
||||
const jc = (item as ChecklistItem & { judgment_criteria?: string }).judgment_criteria || "";
|
||||
const isInspection =
|
||||
jc === "CAT_JC_01" ||
|
||||
detailType === "inspection" ||
|
||||
detailType === "number" ||
|
||||
detailType === "equip_condition" ||
|
||||
detailType === "production_result" ||
|
||||
detailType.startsWith("inspect");
|
||||
const isCheckbox =
|
||||
jc === "CAT_JC_03" ||
|
||||
detailType === "checkbox" ||
|
||||
detailType === "check" ||
|
||||
detailType === "checklist" ||
|
||||
detailType === "procedure" ||
|
||||
detailType === "equip_inspection";
|
||||
const isPlc = item.input_type === "plc" || detailType === "plc_data";
|
||||
const hasLimits = !!(item.lower_limit || item.upper_limit);
|
||||
|
||||
@@ -1998,6 +2031,125 @@ function buildRangeText(item: ChecklistItem): string {
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Material Quantity Keypad (소수점 지원, 자재 투입용) */
|
||||
/* ================================================================== */
|
||||
|
||||
function MaterialQtyKeypad({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
initialValue,
|
||||
unit,
|
||||
requiredQty,
|
||||
itemName,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (val: string) => void;
|
||||
initialValue: string;
|
||||
unit: string;
|
||||
requiredQty: number;
|
||||
itemName: string;
|
||||
}) {
|
||||
const [val, setVal] = useState(initialValue || "0");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) setVal(initialValue || "0");
|
||||
}, [open, initialValue]);
|
||||
|
||||
const press = (k: string) => {
|
||||
setVal((prev) => {
|
||||
if (k === "backspace") return prev.length <= 1 ? "0" : prev.slice(0, -1);
|
||||
if (k === "clear") return "0";
|
||||
if (k === "dot") return prev.includes(".") ? prev : prev + ".";
|
||||
if (k === "ref") return String(requiredQty);
|
||||
if (prev === "0" && k !== ".") return k;
|
||||
return prev + k;
|
||||
});
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const numVal = parseFloat(val);
|
||||
const lower = requiredQty * 0.8;
|
||||
const upper = requiredQty * 1.2;
|
||||
const outOfRange = !isNaN(numVal) && (numVal < lower || numVal > upper);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-4 w-[320px] z-10">
|
||||
<div className="text-center mb-3">
|
||||
<p className="text-sm text-gray-500 truncate">{itemName}</p>
|
||||
<p className="text-xs text-blue-500 mt-0.5">기준 {requiredQty} {unit} (±20%)</p>
|
||||
<p
|
||||
className={`text-3xl font-bold mt-2 ${outOfRange ? "text-amber-600" : "text-gray-900"}`}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
>
|
||||
{val} <span className="text-base text-gray-400">{unit}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{["1","2","3","4","5","6","7","8","9"].map((k) => (
|
||||
<button key={k} onClick={() => press(k)} className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 active:bg-gray-200 transition-all">{k}</button>
|
||||
))}
|
||||
<button onClick={() => press("dot")} className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 transition-all">.</button>
|
||||
<button onClick={() => press("0")} className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 transition-all">0</button>
|
||||
<button onClick={() => press("backspace")} className="h-14 rounded-xl bg-gray-200 text-base font-bold text-gray-600 active:scale-95 transition-all">←</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<button onClick={() => press("clear")} className="flex-1 h-10 rounded-xl bg-gray-100 text-gray-600 text-sm font-bold active:scale-95">초기화</button>
|
||||
<button onClick={() => press("ref")} className="flex-1 h-10 rounded-xl bg-blue-50 text-blue-600 text-sm font-bold active:scale-95">기준값 ({requiredQty})</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="flex-1 h-12 rounded-xl bg-gray-100 text-gray-700 font-semibold active:scale-95">취소</button>
|
||||
<button onClick={() => { onConfirm(val); onClose(); }} className="flex-1 h-12 rounded-xl text-white font-bold active:scale-95" style={{ background: outOfRange ? "linear-gradient(135deg, #f59e0b, #d97706)" : "linear-gradient(135deg, #3b82f6, #1d4ed8)" }}>
|
||||
{outOfRange ? "확인 (범위 외)" : "확인"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Material Qty Input Row (키패드 트리거 버튼) */
|
||||
/* ================================================================== */
|
||||
|
||||
function MaterialQtyInputRow({
|
||||
material,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
material: { id: string; child_item_name: string; required_qty: number; unit: string };
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 text-base font-bold text-left text-gray-900 hover:border-blue-400 active:scale-[0.98] transition-all bg-white"
|
||||
style={{ minHeight: 56 }}
|
||||
>
|
||||
{value || <span className="text-gray-300 font-normal">투입 수량 입력</span>}
|
||||
</button>
|
||||
<span className="text-sm text-gray-500 shrink-0">{material.unit}</span>
|
||||
<MaterialQtyKeypad
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onConfirm={onChange}
|
||||
initialValue={value}
|
||||
unit={material.unit}
|
||||
requiredQty={material.required_qty}
|
||||
itemName={material.child_item_name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================== */
|
||||
/* Material Input Section */
|
||||
/* ================================================================== */
|
||||
@@ -2097,16 +2249,11 @@ function MaterialInputSection({ processId }: { processId: string }) {
|
||||
<p className="text-lg font-bold text-blue-600">{m.required_qty} {m.unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="투입 수량"
|
||||
value={inputValues[m.id] || ""}
|
||||
onChange={(e) => setInputValues((prev) => ({ ...prev, [m.id]: e.target.value }))}
|
||||
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 text-base font-medium focus:outline-none focus:border-blue-400"
|
||||
/>
|
||||
<span className="text-sm text-gray-500 shrink-0">{m.unit}</span>
|
||||
</div>
|
||||
<MaterialQtyInputRow
|
||||
material={m}
|
||||
value={inputValues[m.id] || ""}
|
||||
onChange={(v) => setInputValues((prev) => ({ ...prev, [m.id]: v }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
|
||||
@@ -292,11 +292,15 @@ function CompressedProcessSteps({
|
||||
currentSeqNo,
|
||||
status,
|
||||
onClick,
|
||||
batchId,
|
||||
allProcesses,
|
||||
}: {
|
||||
processes: WorkOrderProcess[];
|
||||
currentSeqNo: string;
|
||||
status: string;
|
||||
onClick?: () => void;
|
||||
batchId?: string;
|
||||
allProcesses?: WorkOrderProcess[];
|
||||
}) {
|
||||
const sorted = [...processes]
|
||||
.filter((p) => !p.parent_process_id)
|
||||
@@ -307,25 +311,51 @@ function CompressedProcessSteps({
|
||||
const currentIdx = sorted.findIndex((p) => p.seq_no === currentSeqNo);
|
||||
if (currentIdx < 0) return null;
|
||||
|
||||
// For completed status: show all as checkmarks
|
||||
// For completed status: batch_id 기반 진행률 표시
|
||||
if (status === "completed") {
|
||||
// 같은 batch_id를 가진 SPLIT들이 어느 seq까지 완료했는지 추적
|
||||
let maxCompletedSeq = parseInt(currentSeqNo, 10); // 최소한 현재 seq까지는 완료
|
||||
|
||||
if (batchId && allProcesses) {
|
||||
const batchSplits = allProcesses.filter(
|
||||
(p) => (p as Record<string, unknown>).batch_id === batchId && p.parent_process_id && p.status === "completed"
|
||||
);
|
||||
for (const s of batchSplits) {
|
||||
const sSeq = parseInt(s.seq_no, 10);
|
||||
if (sSeq > maxCompletedSeq) maxCompletedSeq = sSeq;
|
||||
}
|
||||
}
|
||||
|
||||
const completedCount = sorted.filter((p) => parseInt(p.seq_no, 10) <= maxCompletedSeq).length;
|
||||
const allDone = completedCount === sorted.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center gap-0.5 mb-3 py-2 px-3 bg-green-50 rounded-xl cursor-pointer hover:bg-green-100 transition"
|
||||
onClick={onClick}
|
||||
>
|
||||
{sorted.map((proc, idx) => (
|
||||
<React.Fragment key={proc.id}>
|
||||
{idx > 0 && <span className="w-3 h-0.5 bg-green-400 shrink-0" />}
|
||||
<span
|
||||
className="rounded-full bg-green-500 text-white flex items-center justify-center shrink-0 text-[9px] font-bold"
|
||||
style={{ width: idx === currentIdx ? 28 : 24, height: idx === currentIdx ? 28 : 24, fontSize: idx === currentIdx ? 10 : 9 }}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<span className="text-[10px] text-green-600 font-bold ml-2">전체 완료</span>
|
||||
{sorted.map((proc, idx) => {
|
||||
const seqNum = parseInt(proc.seq_no, 10);
|
||||
const isDone = seqNum <= maxCompletedSeq;
|
||||
return (
|
||||
<React.Fragment key={proc.id}>
|
||||
{idx > 0 && <span className={`w-3 h-0.5 ${isDone ? "bg-green-400" : "bg-gray-300"} shrink-0`} />}
|
||||
<span
|
||||
className={`rounded-full flex items-center justify-center shrink-0 text-[9px] font-bold ${
|
||||
isDone
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-gray-200 text-gray-400"
|
||||
}`}
|
||||
style={{ width: idx === currentIdx ? 28 : 24, height: idx === currentIdx ? 28 : 24, fontSize: idx === currentIdx ? 10 : 9 }}
|
||||
>
|
||||
{isDone ? "\u2713" : idx + 1}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<span className="text-[10px] text-green-600 font-bold ml-2">
|
||||
{allDone ? "전체 완료" : `${completedCount}/${sorted.length} 완료`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -456,30 +486,42 @@ function AcceptableCardBody({
|
||||
planQty,
|
||||
prevGoodQty,
|
||||
availableQty,
|
||||
reworkAvailableQty,
|
||||
}: {
|
||||
planQty: number;
|
||||
prevGoodQty: number | null;
|
||||
availableQty: number;
|
||||
reworkAvailableQty?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="text-center bg-gray-50 rounded-xl py-4">
|
||||
<div className="text-sm text-gray-400">지시수량</div>
|
||||
<div className="text-3xl font-extrabold text-gray-900">{planQty.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-center bg-emerald-50 rounded-xl py-4">
|
||||
<div className="text-sm text-emerald-500">전공정양품</div>
|
||||
<div className="text-3xl font-extrabold text-emerald-600">
|
||||
{prevGoodQty !== null ? prevGoodQty.toLocaleString() : "-"}
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="text-center bg-gray-50 rounded-xl py-4">
|
||||
<div className="text-sm text-gray-400">지시수량</div>
|
||||
<div className="text-3xl font-extrabold text-gray-900">{planQty.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-center bg-emerald-50 rounded-xl py-4">
|
||||
<div className="text-sm text-emerald-500">전공정양품</div>
|
||||
<div className="text-3xl font-extrabold text-emerald-600">
|
||||
{prevGoodQty !== null ? prevGoodQty.toLocaleString() : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center bg-blue-50 rounded-xl py-4">
|
||||
<div className="text-sm text-blue-500">접수가능</div>
|
||||
<div className="font-extrabold text-blue-600" style={{ fontSize: 28 }}>
|
||||
{availableQty.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center bg-blue-50 rounded-xl py-4">
|
||||
<div className="text-sm text-blue-500">접수가능</div>
|
||||
<div className="font-extrabold text-blue-600" style={{ fontSize: 28 }}>
|
||||
{availableQty.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{reworkAvailableQty && reworkAvailableQty > 0 ? (
|
||||
reworkAvailableQty >= availableQty ? null : (
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<span className="text-xs font-bold text-white bg-amber-500 px-2 py-0.5 rounded-full">리워크</span>
|
||||
<span className="text-xs text-amber-700 font-medium">재작업 물량 {reworkAvailableQty}개 포함</span>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -693,6 +735,7 @@ export function WorkOrderList() {
|
||||
processName: string;
|
||||
seqNo: string;
|
||||
maxQty: number;
|
||||
reworkSourceId?: string;
|
||||
}>({ open: false, processId: "", processName: "", seqNo: "", maxQty: 0 });
|
||||
const [acceptLoading, setAcceptLoading] = useState(false);
|
||||
|
||||
@@ -886,7 +929,14 @@ export function WorkOrderList() {
|
||||
const filteredProcesses = useMemo(() => {
|
||||
if (selectedProcess === "__all__") return []; // 공정 미선택 시 빈 목록
|
||||
return masterProcesses.filter((proc) => {
|
||||
if (proc.process_code !== selectedProcess) return false;
|
||||
const isRework = proc.is_rework === "Y" || proc.is_rework === "true" || proc.is_rework === "1";
|
||||
const isMaster = !proc.parent_process_id;
|
||||
// 완료/진행중 탭에서는 SPLIT만 표시 (마스터 제외)
|
||||
if (isMaster && !isRework && (activeTab === "completed" || activeTab === "in_progress")) return false;
|
||||
// 리워크 마스터가 in_progress/completed면 SPLIT이 생성된 것 → 리워크 마스터 숨김 (SPLIT은 표시)
|
||||
if (isRework && !proc.parent_process_id && (proc.status === "in_progress" || proc.status === "completed")) return false;
|
||||
// 재작업 카드는 공정 필터 무시 (모든 공정에서 표시)
|
||||
if (!isRework && proc.process_code !== selectedProcess) return false;
|
||||
if (selectedEquipment !== "__all__") {
|
||||
const wi = instructionMap[proc.wo_id];
|
||||
if (!wi) return false;
|
||||
@@ -902,7 +952,9 @@ export function WorkOrderList() {
|
||||
/* ---- Tab counts ---- */
|
||||
const tabCounts = useMemo(() => {
|
||||
const preFiltered = masterProcesses.filter((proc) => {
|
||||
if (selectedProcess !== "__all__" && proc.process_code !== selectedProcess) return false;
|
||||
const isRework = proc.is_rework === "Y" || proc.is_rework === "true" || proc.is_rework === "1";
|
||||
// 재작업 카드는 공정 필터 무시 (모든 공정에서 표시)
|
||||
if (selectedProcess !== "__all__" && !isRework && proc.process_code !== selectedProcess) return false;
|
||||
if (selectedEquipment !== "__all__") {
|
||||
const wi = instructionMap[proc.wo_id];
|
||||
if (!wi) return false;
|
||||
@@ -920,23 +972,27 @@ export function WorkOrderList() {
|
||||
completed: 0,
|
||||
};
|
||||
for (const proc of preFiltered) {
|
||||
const isMaster = !proc.parent_process_id;
|
||||
const isRw = proc.is_rework === "Y" || proc.is_rework === "true" || proc.is_rework === "1";
|
||||
// 리워크 마스터가 in_progress/completed면 SPLIT이 있으므로 카운트 제외
|
||||
if (isRw && !proc.parent_process_id && (proc.status === "in_progress" || proc.status === "completed")) continue;
|
||||
if (proc.status === "acceptable") counts.acceptable++;
|
||||
else if (proc.status === "in_progress") counts.in_progress++;
|
||||
else if (proc.status === "completed") counts.completed++;
|
||||
else if (proc.status === "in_progress" && (!isMaster || isRw)) counts.in_progress++;
|
||||
else if (proc.status === "completed" && (!isMaster || isRw)) counts.completed++;
|
||||
else counts.waiting++;
|
||||
}
|
||||
return counts;
|
||||
}, [masterProcesses, selectedProcess, selectedEquipment, instructionMap, equipmentMap]);
|
||||
|
||||
/* ---- Accept handler ---- */
|
||||
const openAcceptModal = async (processId: string, processName: string, seqNo: string) => {
|
||||
const openAcceptModal = async (processId: string, processName: string, seqNo: string, reworkSourceId?: string) => {
|
||||
try {
|
||||
const res = await apiClient.get("/pop/production/available-qty", {
|
||||
params: { work_order_process_id: processId },
|
||||
});
|
||||
const data = res.data?.data;
|
||||
const maxQty = data?.availableQty ?? 0;
|
||||
setAcceptModal({ open: true, processId, processName, seqNo, maxQty });
|
||||
setAcceptModal({ open: true, processId, processName, seqNo, maxQty, reworkSourceId });
|
||||
} catch {
|
||||
alert("접수가능량 조회 실패");
|
||||
}
|
||||
@@ -945,10 +1001,15 @@ export function WorkOrderList() {
|
||||
const handleAccept = async (qty: number) => {
|
||||
setAcceptLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post("/pop/production/accept-process", {
|
||||
const body: Record<string, unknown> = {
|
||||
work_order_process_id: acceptModal.processId,
|
||||
accept_qty: qty,
|
||||
});
|
||||
};
|
||||
// 리워크 추적: rework_source_id가 있으면 전달 (원점 복귀 추적)
|
||||
if (acceptModal.reworkSourceId) {
|
||||
body.rework_source_id = acceptModal.reworkSourceId;
|
||||
}
|
||||
const res = await apiClient.post("/pop/production/accept-process", body);
|
||||
if (res.data?.success) {
|
||||
setAcceptModal((m) => ({ ...m, open: false }));
|
||||
await fetchAll();
|
||||
@@ -1013,6 +1074,35 @@ export function WorkOrderList() {
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Helper: get split order label (접수 #N) ---- */
|
||||
const splitOrderMap = useMemo(() => {
|
||||
// 같은 wo_id + seq_no를 가진 SPLIT들을 그룹화하여 순서 부여
|
||||
const groups: Record<string, WorkOrderProcess[]> = {};
|
||||
for (const proc of allProcesses) {
|
||||
if (!proc.parent_process_id) continue; // 마스터 행은 제외
|
||||
if (proc.status !== "in_progress" && proc.status !== "completed") continue;
|
||||
const key = `${proc.wo_id}__${proc.seq_no}`;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(proc);
|
||||
}
|
||||
|
||||
const result: Record<string, { order: number; total: number }> = {};
|
||||
for (const key of Object.keys(groups)) {
|
||||
const splits = groups[key];
|
||||
if (splits.length <= 1) continue; // 1개면 순서 표시 불필요
|
||||
// accepted_at 기준 정렬 (없으면 started_at, 그마저 없으면 id)
|
||||
splits.sort((a, b) => {
|
||||
const ta = a.accepted_at ? new Date(a.accepted_at).getTime() : (a.started_at ? new Date(a.started_at).getTime() : 0);
|
||||
const tb = b.accepted_at ? new Date(b.accepted_at).getTime() : (b.started_at ? new Date(b.started_at).getTime() : 0);
|
||||
return ta - tb || a.id.localeCompare(b.id);
|
||||
});
|
||||
for (let i = 0; i < splits.length; i++) {
|
||||
result[splits[i].id] = { order: i + 1, total: splits.length };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [allProcesses]);
|
||||
|
||||
/* ---- Helper: get previous process info ---- */
|
||||
const getPrevProcessInfo = (proc: WorkOrderProcess) => {
|
||||
const siblings = (processesByWo[proc.wo_id] || [])
|
||||
@@ -1026,10 +1116,27 @@ export function WorkOrderList() {
|
||||
const prevGood = parseInt(prev.good_qty || "0", 10);
|
||||
const prevPlan = parseInt(prev.plan_qty || "0", 10);
|
||||
const prevPct = prevPlan > 0 ? Math.round((prevGood / prevPlan) * 100) : 0;
|
||||
// 앞공정에서 리워크로 완료된 양품 수량
|
||||
const prevSeqNo = prev.seq_no;
|
||||
const reworkGoodFromPrev = allProcesses
|
||||
.filter((p) => p.wo_id === proc.wo_id && p.seq_no === prevSeqNo && p.parent_process_id && p.status === "completed" && (p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"))
|
||||
.reduce((sum, p) => sum + (parseInt(p.good_qty || "0", 10)), 0);
|
||||
// 현재 공정에서 이미 리워크로 접수된 수량
|
||||
const reworkConsumedHere = allProcesses
|
||||
.filter((p) => p.wo_id === proc.wo_id && p.seq_no === proc.seq_no && p.parent_process_id && (p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"))
|
||||
.reduce((sum, p) => sum + (parseInt(p.input_qty || "0", 10)), 0);
|
||||
const reworkAvailableQty = Math.max(0, reworkGoodFromPrev - reworkConsumedHere);
|
||||
|
||||
// 접수가능 수량을 초과하지 않도록 제한
|
||||
const inputQtyNum = parseInt(proc.input_qty || "0", 10);
|
||||
const actualAvailable = Math.max(0, prevGood - inputQtyNum);
|
||||
const clampedReworkAvailable = Math.min(reworkAvailableQty, actualAvailable);
|
||||
|
||||
return {
|
||||
prevGoodQty: prevGood,
|
||||
prevProcessName: prev.process_name || prev.process_code,
|
||||
prevProgressPct: prev.status === "in_progress" ? prevPct : prev.status === "completed" ? 100 : null,
|
||||
reworkAvailableQty: clampedReworkAvailable,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1214,25 +1321,38 @@ export function WorkOrderList() {
|
||||
// Additional available for in_progress
|
||||
const additionalAvailable = Math.max(0, planQty - inputQty);
|
||||
|
||||
// Split order label
|
||||
const splitInfo = splitOrderMap[proc.id];
|
||||
|
||||
// 합류 불가 리워크 감지: 접수가능 물량이 전부 리워크일 때
|
||||
const reworkQtyAvail = prevInfo.reworkAvailableQty || 0;
|
||||
const normalAvail = availableQty - reworkQtyAvail;
|
||||
const isReworkOnly = !isRework && proc.status === "acceptable" && reworkQtyAvail > 0 && normalAvail <= 0 && availableQty > 0;
|
||||
|
||||
// 리워크 표시 여부 (실제 리워크 카드 OR 합류불가 리워크)
|
||||
const showReworkBadge = isRework || isReworkOnly;
|
||||
|
||||
// Rework info: origin process + rework round
|
||||
let reworkRound = 1;
|
||||
let originProcessName = proc.process_name || proc.process_code;
|
||||
let originProcessCode = proc.process_code;
|
||||
let originDefectQty = defectQty;
|
||||
if (isRework) {
|
||||
// Count how many rework processes exist for this wo_id with same process_code
|
||||
const sameWoReworks = allProcesses.filter(
|
||||
// 리워크 마스터 카드만 카운트 (SPLIT 제외 — parent_process_id 없는 것만)
|
||||
const reworkMasters = allProcesses.filter(
|
||||
(p) =>
|
||||
p.wo_id === proc.wo_id &&
|
||||
!p.parent_process_id &&
|
||||
(p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1")
|
||||
);
|
||||
// Find this process's position among reworks (by created_date or id)
|
||||
const sortedReworks = [...sameWoReworks].sort((a, b) => {
|
||||
const sortedReworks = [...reworkMasters].sort((a, b) => {
|
||||
const da = a.created_date ? new Date(a.created_date).getTime() : 0;
|
||||
const db = b.created_date ? new Date(b.created_date).getTime() : 0;
|
||||
return da - db || a.id.localeCompare(b.id);
|
||||
});
|
||||
const myIdx = sortedReworks.findIndex((r) => r.id === proc.id);
|
||||
// 현재 카드가 SPLIT이면 parent(마스터)의 위치로, 마스터면 직접 위치
|
||||
const masterId = proc.parent_process_id || proc.id;
|
||||
const myIdx = sortedReworks.findIndex((r) => r.id === masterId);
|
||||
reworkRound = myIdx >= 0 ? myIdx + 1 : 1;
|
||||
|
||||
// Find origin (source) process
|
||||
@@ -1251,7 +1371,7 @@ export function WorkOrderList() {
|
||||
<div
|
||||
className={`bg-white rounded-2xl border-l-4 ${borderLeft} border border-gray-100 shadow-sm overflow-hidden flex flex-col ${
|
||||
proc.status === "waiting" ? "opacity-75" : ""
|
||||
} ${isRework ? "border-2 border-orange-200" : ""} ${
|
||||
} ${showReworkBadge ? "border-2 border-orange-200" : ""} ${
|
||||
proc.status === "in_progress" ? "cursor-pointer active:scale-[0.99] transition-transform" : ""
|
||||
}`}
|
||||
style={{ height: "100%" }}
|
||||
@@ -1261,13 +1381,18 @@ export function WorkOrderList() {
|
||||
{/* Header: Work instruction number + status badge */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isRework && (
|
||||
{showReworkBadge && (
|
||||
<span className="bg-orange-500 text-white text-[10px] font-bold px-2 py-0.5 rounded shrink-0">
|
||||
🔄 리워크
|
||||
</span>
|
||||
)}
|
||||
<h3 className="text-xl font-extrabold text-gray-900 truncate">
|
||||
{wi?.work_instruction_no || "작업지시"}
|
||||
{splitInfo && (
|
||||
<span className="text-sm font-bold text-blue-500 ml-1">
|
||||
(접수 #{splitInfo.order})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
<span className={`shrink-0 ml-2 text-sm font-bold px-3 py-1.5 rounded-full ${badge.bg} ${badge.text}`}>
|
||||
@@ -1293,6 +1418,8 @@ export function WorkOrderList() {
|
||||
currentSeqNo={proc.seq_no}
|
||||
status={proc.status}
|
||||
onClick={() => openDetailModal(proc)}
|
||||
batchId={(proc as Record<string, unknown>).batch_id as string | undefined}
|
||||
allProcesses={allProcesses}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1312,6 +1439,7 @@ export function WorkOrderList() {
|
||||
planQty={planQty}
|
||||
prevGoodQty={prevInfo.prevGoodQty}
|
||||
availableQty={availableQty}
|
||||
reworkAvailableQty={prevInfo.reworkAvailableQty}
|
||||
/>
|
||||
) : proc.status === "in_progress" ? (
|
||||
<InProgressCardBody
|
||||
@@ -1344,7 +1472,7 @@ export function WorkOrderList() {
|
||||
{/* Action button (full width, bottom) */}
|
||||
{isRework && proc.status === "acceptable" && (
|
||||
<button
|
||||
onClick={() => openAcceptModal(proc.id, proc.process_name, proc.seq_no)}
|
||||
onClick={() => openAcceptModal(proc.id, proc.process_name, proc.seq_no, (proc as Record<string, unknown>).rework_source_id as string | undefined)}
|
||||
className="w-full py-3.5 text-sm font-bold text-white active:scale-[0.98] transition-all"
|
||||
style={{ background: "linear-gradient(to bottom, #fb923c, #ea580c)" }}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
export interface PopSettings {
|
||||
@@ -109,51 +110,110 @@ const DEFAULT_SETTINGS: PopSettings = {
|
||||
},
|
||||
};
|
||||
|
||||
let cachedSettings: PopSettings | null = null;
|
||||
// URL -> screen_id mapping
|
||||
const POP_SCREEN_MAP: Record<string, number> = {
|
||||
"/pop/home": 6526,
|
||||
"/pop/inbound": 6529,
|
||||
"/pop/inbound/purchase": 6528,
|
||||
"/pop/inbound/cart": 6527,
|
||||
"/pop/outbound": 6,
|
||||
"/pop/outbound/sales": 5,
|
||||
"/pop/production": 8,
|
||||
"/pop/production/process": 7,
|
||||
};
|
||||
|
||||
export function usePopSettings() {
|
||||
const [settings, setSettings] = useState<PopSettings>(cachedSettings || DEFAULT_SETTINGS);
|
||||
const [loading, setLoading] = useState(!cachedSettings);
|
||||
// URL -> settingsKey mapping
|
||||
const PATH_TO_SETTINGS_KEY: Record<string, keyof PopSettings["screens"]> = {
|
||||
"/pop/home": "home",
|
||||
"/pop/inbound": "inbound",
|
||||
"/pop/inbound/purchase": "inbound",
|
||||
"/pop/inbound/cart": "inbound",
|
||||
"/pop/outbound": "outbound",
|
||||
"/pop/outbound/sales": "outbound",
|
||||
"/pop/production": "processExecution",
|
||||
"/pop/production/process": "processExecution",
|
||||
};
|
||||
|
||||
function getScreenIdFromPath(pathname: string): number | null {
|
||||
// Exact match first
|
||||
if (POP_SCREEN_MAP[pathname]) return POP_SCREEN_MAP[pathname];
|
||||
// Longest-prefix match (e.g. /pop/production/process/xxx -> 7)
|
||||
const sorted = Object.keys(POP_SCREEN_MAP).sort((a, b) => b.length - a.length);
|
||||
for (const path of sorted) {
|
||||
if (pathname.startsWith(path)) return POP_SCREEN_MAP[path];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSettingsKeyFromPath(pathname: string): keyof PopSettings["screens"] | null {
|
||||
// Exact match first
|
||||
if (PATH_TO_SETTINGS_KEY[pathname]) return PATH_TO_SETTINGS_KEY[pathname];
|
||||
// Longest-prefix match
|
||||
const sorted = Object.keys(PATH_TO_SETTINGS_KEY).sort((a, b) => b.length - a.length);
|
||||
for (const path of sorted) {
|
||||
if (pathname.startsWith(path)) return PATH_TO_SETTINGS_KEY[path];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-screenId cache to avoid redundant fetches
|
||||
const screenCache: Record<number, Record<string, unknown>> = {};
|
||||
|
||||
export function usePopSettings(screenPath?: string) {
|
||||
const autoPathname = usePathname();
|
||||
const pathname = screenPath || autoPathname || "";
|
||||
|
||||
const [settings, setSettings] = useState<PopSettings>(DEFAULT_SETTINGS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
if (cachedSettings) { setSettings(cachedSettings); setLoading(false); return; }
|
||||
const screenId = getScreenIdFromPath(pathname);
|
||||
const settingsKey = getSettingsKeyFromPath(pathname);
|
||||
|
||||
if (!screenId || !settingsKey) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use cache if available
|
||||
if (screenCache[screenId]) {
|
||||
const popConfig = screenCache[screenId];
|
||||
const merged = { ...DEFAULT_SETTINGS };
|
||||
merged.screens = {
|
||||
...merged.screens,
|
||||
[settingsKey]: { ...merged.screens[settingsKey], ...popConfig },
|
||||
};
|
||||
setSettings(merged);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
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 = {
|
||||
...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 },
|
||||
},
|
||||
const res = await apiClient
|
||||
.get(`/screen-management/screens/${screenId}/layout-pop`)
|
||||
.catch(() => null);
|
||||
|
||||
if (res?.data?.data?.settings?.popConfig) {
|
||||
const popConfig = res.data.data.settings.popConfig;
|
||||
screenCache[screenId] = popConfig;
|
||||
|
||||
const merged = { ...DEFAULT_SETTINGS };
|
||||
merged.screens = {
|
||||
...merged.screens,
|
||||
[settingsKey]: { ...merged.screens[settingsKey], ...popConfig },
|
||||
};
|
||||
cachedSettings = merged;
|
||||
setSettings(merged);
|
||||
}
|
||||
} catch {
|
||||
// localStorage fallback
|
||||
const local = localStorage.getItem("pop_settings");
|
||||
if (local) {
|
||||
try {
|
||||
const parsed = JSON.parse(local);
|
||||
cachedSettings = { ...DEFAULT_SETTINGS, ...parsed };
|
||||
setSettings(cachedSettings);
|
||||
} catch { /* use default */ }
|
||||
}
|
||||
// Use default settings on failure
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSettings(); }, [fetchSettings]);
|
||||
setLoading(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
return { settings, loading };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+2
-22
@@ -107,7 +107,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.86.0",
|
||||
"@types/jsbarcode": "^3.11.4",
|
||||
@@ -119,7 +118,6 @@
|
||||
"eslint-config-next": "15.4.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"playwright": "^1.58.2",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"prisma": "^6.14.0",
|
||||
@@ -1419,23 +1417,6 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -10520,7 +10501,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -13055,8 +13035,8 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
@@ -13074,8 +13054,8 @@
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.86.0",
|
||||
"@types/jsbarcode": "^3.11.4",
|
||||
@@ -128,7 +127,6 @@
|
||||
"eslint-config-next": "15.4.4",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"playwright": "^1.58.2",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"prisma": "^6.14.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+2
-50
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ERP-node",
|
||||
"name": "vexplor_dev",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -17,8 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"playwright": "^1.58.2"
|
||||
"@types/pg": "^8.15.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure-rest/core-client": {
|
||||
@@ -1476,21 +1475,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -2079,38 +2063,6 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
|
||||
+1
-2
@@ -12,7 +12,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"playwright": "^1.58.2"
|
||||
"@types/pg": "^8.15.5"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user