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:
SeongHyun Kim
2026-04-07 12:46:16 +09:00
24 changed files with 4165 additions and 2627 deletions
+12 -47
View File
@@ -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"
-1
View File
@@ -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
-259
View File
@@ -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 }),
+33 -8
View File
@@ -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" />
+1 -11
View File
@@ -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>&copy; {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 }}
>
&#10003;
</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)" }}
>
+95 -35
View File
@@ -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
+2 -22
View File
@@ -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"
},
-2
View File
@@ -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
+2 -50
View File
@@ -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
View File
@@ -12,7 +12,6 @@
},
"devDependencies": {
"@types/oracledb": "^6.9.1",
"@types/pg": "^8.15.5",
"playwright": "^1.58.2"
"@types/pg": "^8.15.5"
}
}