feat: BLOCK MES-REWORK - mes-process-card 전용 카드 + batch_done 워크플로우 + 실적 관리 강화

MES 카드를 CSS Grid 다중 셀 방식에서 Flexbox 기반 단일 전용 카드(mes-process-card)로
전환하고, batch_done 상태를 도입하여 부분 확정 후 추가접수 워크플로우를 구현한다.
[mes-process-card 전용 카드]
- CardCellType "mes-process-card" 신규: 상태별 좌측 보더+배경, 공정 흐름 스트립, 클릭 모달
- MesAcceptableMetrics / MesInProgressMetrics / MesCompletedMetrics 서브 컴포넌트
- 워크플로우 기반 activeBtn 결정 로직 (상태+잔여량 조합으로 버튼 1개만 표시)
- 하드코딩 취소 버튼 제거, DB 설정 "접수취소" 라벨 인터셉트로 통합
[batch_done 워크플로우]
- confirmResult API: 부분 확정 시 status = 'batch_done' (진행 탭 숨김 + 접수가능 탭 유지)
- acceptProcess API: batch_done -> in_progress 복귀 (추가접수)
- 카드 복제 로직에 batch_done 포함 (잔여량 있으면 접수가능 탭에 클론 카드)
- status 매핑에 batch_done 추가 (semantic: active)
[실적 관리 강화]
- PopWorkDetailComponent: 실적 입력 UI 전면 구현 (차수별 등록, 누적 실적, 이력 표시)
- 모든 실적 저장 시 process_completed 이벤트 발행 (카드 리스트 즉시 갱신)
- 전량접수+전량생산 시 자동 완료 (status=completed, result_status=confirmed)
[버그 수정]
- 서브 필터 변경 시 __process_* 필드 미갱신 -> processFields 재주입
- cancelAccept SQL inconsistent types -> boolean 파라미터 분리
- 접수취소 라벨 매핑 누락 -> taskPreset 조건 확장
This commit is contained in:
SeongHyun Kim 2026-03-17 21:36:43 +09:00
parent 06c52b422f
commit 20fbe85c74
8 changed files with 2382 additions and 138 deletions

View File

@ -3,6 +3,14 @@ import { getPool } from "../database/db";
import logger from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware";
// 불량 상세 항목 타입
interface DefectDetailItem {
defect_code: string;
defect_name: string;
qty: string;
disposition: string;
}
/**
* D-BE1: 작업지시
* PC에서 . 1 work_order_process + process_work_result .
@ -102,7 +110,7 @@ export const createWorkProcesses = async (
rd.is_fixed_order,
rd.standard_time,
plan_qty || null,
"waiting",
parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting",
rd.id,
userId,
]
@ -465,3 +473,823 @@ export const controlGroupTimer = async (
});
}
};
/**
* (defect_standard_mng)
*/
export const getDefectTypes = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
let query: string;
let params: unknown[];
if (companyCode === "*") {
query = `
SELECT id, defect_code, defect_name, defect_type, severity, company_code
FROM defect_standard_mng
WHERE is_active = 'Y'
ORDER BY defect_code`;
params = [];
} else {
query = `
SELECT id, defect_code, defect_name, defect_type, severity, company_code
FROM defect_standard_mng
WHERE is_active = 'Y' AND company_code = $1
ORDER BY defect_code`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info("[pop/production] defect-types 조회", {
companyCode,
count: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("[pop/production] defect-types 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "불량 유형 조회 중 오류가 발생했습니다.",
});
}
};
/**
* ( )
* .
* result_status는 'draft' ( )
*/
export const saveResult = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
work_order_process_id,
production_qty,
good_qty,
defect_qty,
defect_detail,
result_note,
} = req.body;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
if (!production_qty || parseInt(production_qty, 10) <= 0) {
return res.status(400).json({
success: false,
message: "생산수량을 입력해주세요.",
});
}
const statusCheck = await pool.query(
`SELECT status, result_status, total_production_qty, good_qty, defect_qty, input_qty
FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
if (statusCheck.rowCount === 0) {
return res.status(404).json({
success: false,
message: "공정을 찾을 수 없습니다.",
});
}
const prev = statusCheck.rows[0];
if (prev.result_status === "confirmed") {
return res.status(403).json({
success: false,
message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.",
});
}
// 실적 누적이 접수량을 초과하지 않도록 검증
const prevTotal = parseInt(prev.total_production_qty, 10) || 0;
const acceptedQty = parseInt(prev.input_qty, 10) || 0;
const requestedQty = parseInt(production_qty, 10) || 0;
if (acceptedQty > 0 && (prevTotal + requestedQty) > acceptedQty) {
return res.status(400).json({
success: false,
message: `실적 누적(${prevTotal + requestedQty})이 접수량(${acceptedQty})을 초과합니다. 추가 접수 후 등록해주세요.`,
});
}
let defectDetailStr: string | null = null;
if (defect_detail && Array.isArray(defect_detail)) {
const validated = defect_detail.map((item: DefectDetailItem) => ({
defect_code: item.defect_code || "",
defect_name: item.defect_name || "",
qty: item.qty || "0",
disposition: item.disposition || "scrap",
}));
defectDetailStr = JSON.stringify(validated);
}
const addProduction = parseInt(production_qty, 10) || 0;
const addGood = parseInt(good_qty, 10) || 0;
const addDefect = parseInt(defect_qty, 10) || 0;
const newTotal = (parseInt(prev.total_production_qty, 10) || 0) + addProduction;
const newGood = (parseInt(prev.good_qty, 10) || 0) + addGood;
const newDefect = (parseInt(prev.defect_qty, 10) || 0) + addDefect;
// 기존 defect_detail에 이번 차수 상세를 병합
let mergedDefectDetail: string | null = null;
if (defectDetailStr) {
let existingEntries: DefectDetailItem[] = [];
try {
existingEntries = prev.defect_detail ? JSON.parse(prev.defect_detail) : [];
} catch { /* 파싱 실패 시 빈 배열 */ }
const newEntries: DefectDetailItem[] = JSON.parse(defectDetailStr);
// 같은 불량코드+처리방법 조합은 수량 합산
const merged = [...existingEntries];
for (const ne of newEntries) {
const existing = merged.find(
(e) => e.defect_code === ne.defect_code && e.disposition === ne.disposition
);
if (existing) {
existing.qty = String(
(parseInt(existing.qty, 10) || 0) + (parseInt(ne.qty, 10) || 0)
);
} else {
merged.push(ne);
}
}
mergedDefectDetail = JSON.stringify(merged);
}
const result = await pool.query(
`UPDATE work_order_process
SET total_production_qty = $3,
good_qty = $4,
defect_qty = $5,
defect_detail = COALESCE($6, defect_detail),
result_note = COALESCE($7, result_note),
result_status = 'draft',
status = CASE WHEN status IN ('acceptable', 'waiting') THEN 'in_progress' ELSE status END,
writer = $8,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, total_production_qty, good_qty, defect_qty, defect_detail, result_note, result_status, status`,
[
work_order_process_id,
companyCode,
String(newTotal),
String(newGood),
String(newDefect),
mergedDefectDetail,
result_note || null,
userId,
]
);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "공정을 찾을 수 없거나 권한이 없습니다.",
});
}
// 다음 공정 상태를 acceptable로 전환 (input_qty는 접수 버튼에서만 변경)
const currentSeq = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty,
wi.qty as instruction_qty
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
WHERE wop.id = $1 AND wop.company_code = $2`,
[work_order_process_id, companyCode]
);
if (addGood > 0 && currentSeq.rowCount > 0) {
const { seq_no, wo_id } = currentSeq.rows[0];
const nextSeq = String(parseInt(seq_no, 10) + 1);
const nextUpdate = await pool.query(
`UPDATE work_order_process
SET status = CASE WHEN status = 'waiting' THEN 'acceptable' ELSE status END,
updated_date = NOW()
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
RETURNING id, process_name, status`,
[wo_id, nextSeq, companyCode]
);
if (nextUpdate.rowCount > 0) {
logger.info("[pop/production] 다음 공정 상태 전환", {
nextProcess: nextUpdate.rows[0],
});
}
}
// 자동 완료 체크: 접수가능 잔여 0 + 접수한 수량 전부 완료 시 자동 completed
if (currentSeq.rowCount > 0) {
const { seq_no, wo_id, current_input_qty, instruction_qty } = currentSeq.rows[0];
const seqNum = parseInt(seq_no, 10);
const myInputQty = parseInt(current_input_qty, 10) || 0;
const instrQty = parseInt(instruction_qty, 10) || 0;
// 앞공정 완료량 계산
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(good_qty::int, 0) as good_qty
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].good_qty;
}
}
const remainingAcceptable = prevGoodQty - myInputQty;
const allProduced = newTotal >= myInputQty && myInputQty > 0;
if (remainingAcceptable <= 0 && allProduced) {
await pool.query(
`UPDATE work_order_process
SET status = 'completed',
result_status = 'confirmed',
completed_at = NOW()::text,
completed_by = $3,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
AND status != 'completed'`,
[work_order_process_id, companyCode, userId]
);
logger.info("[pop/production] 자동 완료 처리", {
work_order_process_id,
remainingAcceptable,
newTotal,
myInputQty,
});
}
}
logger.info("[pop/production] save-result 완료 (누적)", {
companyCode,
work_order_process_id,
added: { production_qty: addProduction, good_qty: addGood, defect_qty: addDefect },
accumulated: { total: newTotal, good: newGood, defect: newDefect },
});
// 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음)
const latestData = await pool.query(
`SELECT id, total_production_qty, good_qty, defect_qty, defect_detail, result_note, result_status, status, input_qty
FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
return res.json({
success: true,
data: latestData.rows[0] || result.rows[0],
});
} catch (error: any) {
logger.error("[pop/production] save-result 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "실적 저장 중 오류가 발생했습니다.",
});
}
};
/**
* .
* . save-result에서 .
*/
export const confirmResult = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id } = req.body;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
const statusCheck = await pool.query(
`SELECT status, result_status, total_production_qty FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
if (statusCheck.rowCount === 0) {
return res.status(404).json({
success: false,
message: "공정을 찾을 수 없습니다.",
});
}
const currentProcess = statusCheck.rows[0];
if (!currentProcess.total_production_qty ||
parseInt(currentProcess.total_production_qty, 10) <= 0) {
return res.status(400).json({
success: false,
message: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.",
});
}
// 잔여 접수가능량 계산하여 completed 여부 결정
const seqCheck = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.input_qty,
wi.qty as instruction_qty
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
WHERE wop.id = $1 AND wop.company_code = $2`,
[work_order_process_id, companyCode]
);
let shouldComplete = false;
if (seqCheck.rowCount > 0) {
const { seq_no, wo_id, input_qty: currentInputQty, instruction_qty } = seqCheck.rows[0];
const seqNum = parseInt(seq_no, 10);
const myInputQty = parseInt(currentInputQty, 10) || 0;
const instrQty = parseInt(instruction_qty, 10) || 0;
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(good_qty::int, 0) as good_qty
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].good_qty;
}
}
const remainingAcceptable = prevGoodQty - myInputQty;
const totalProduced = parseInt(currentProcess.total_production_qty, 10) || 0;
shouldComplete = remainingAcceptable <= 0 && totalProduced >= myInputQty && myInputQty > 0;
}
// shouldComplete = true: 전량 완료 -> completed
// shouldComplete = false: 부분 확정 -> batch_done (진행 탭에서 숨김)
const newStatus = shouldComplete ? "completed" : "batch_done";
const result = await pool.query(
`UPDATE work_order_process
SET result_status = 'confirmed',
status = $4,
completed_at = CASE WHEN $4 = 'completed' THEN NOW()::text ELSE completed_at END,
completed_by = CASE WHEN $4 = 'completed' THEN $3 ELSE completed_by END,
writer = $3,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty`,
[work_order_process_id, companyCode, userId, newStatus]
);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "공정을 찾을 수 없습니다.",
});
}
// completed로 전환된 경우에만 다음 공정 활성화
if (shouldComplete && seqCheck.rowCount > 0) {
const { seq_no, wo_id } = seqCheck.rows[0];
const nextSeq = String(parseInt(seq_no, 10) + 1);
await pool.query(
`UPDATE work_order_process
SET status = CASE WHEN status = 'waiting' THEN 'acceptable' ELSE status END,
updated_date = NOW()
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[wo_id, nextSeq, companyCode]
);
}
logger.info("[pop/production] confirm-result 완료", {
companyCode,
work_order_process_id,
userId,
shouldComplete,
newStatus,
finalStatus: result.rows[0].status,
});
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("[pop/production] confirm-result 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "실적 확정 중 오류가 발생했습니다.",
});
}
};
/**
* (work_order_process_log에서 )
* total_production_qty =
*/
export const getResultHistory = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { work_order_process_id } = req.query;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
// 소유권 확인
const ownerCheck = await pool.query(
`SELECT id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
if (ownerCheck.rowCount === 0) {
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
}
// 같은 changed_at 기준으로 그룹핑하여 차수별 이력 추출
// total_production_qty가 증가한(new_value > old_value) 로그만 = 실적 등록 시점
const historyResult = await pool.query(
`WITH grouped AS (
SELECT
changed_at,
MAX(changed_by) as changed_by,
MAX(CASE WHEN changed_column = 'total_production_qty' THEN old_value END) as total_old,
MAX(CASE WHEN changed_column = 'total_production_qty' THEN new_value END) as total_new,
MAX(CASE WHEN changed_column = 'good_qty' THEN old_value END) as good_old,
MAX(CASE WHEN changed_column = 'good_qty' THEN new_value END) as good_new,
MAX(CASE WHEN changed_column = 'defect_qty' THEN old_value END) as defect_old,
MAX(CASE WHEN changed_column = 'defect_qty' THEN new_value END) as defect_new
FROM work_order_process_log
WHERE original_id = $1
AND changed_column IN ('total_production_qty', 'good_qty', 'defect_qty')
AND new_value IS NOT NULL
GROUP BY changed_at
)
SELECT * FROM grouped
WHERE total_new IS NOT NULL
AND (COALESCE(total_new::int, 0) - COALESCE(total_old::int, 0)) > 0
ORDER BY changed_at ASC`,
[work_order_process_id]
);
const batches = historyResult.rows.map((row: any, idx: number) => {
const batchQty = (parseInt(row.total_new, 10) || 0) - (parseInt(row.total_old, 10) || 0);
const batchGood = (parseInt(row.good_new, 10) || 0) - (parseInt(row.good_old, 10) || 0);
const batchDefect = (parseInt(row.defect_new, 10) || 0) - (parseInt(row.defect_old, 10) || 0);
return {
seq: idx + 1,
batch_qty: batchQty,
batch_good: batchGood,
batch_defect: batchDefect,
accumulated_total: parseInt(row.total_new, 10) || 0,
changed_at: row.changed_at,
changed_by: row.changed_by,
};
});
logger.info("[pop/production] result-history 조회", {
work_order_process_id,
batchCount: batches.length,
});
return res.json({
success: true,
data: batches,
});
} catch (error: any) {
logger.error("[pop/production] result-history 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "이력 조회 중 오류가 발생했습니다.",
});
}
};
/**
* +
* GET /api/pop/production/available-qty?work_order_process_id=xxx
* : { prevGoodQty, myInputQty, availableQty, instructionQty }
*/
export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { work_order_process_id } = req.query;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id가 필요합니다.",
});
}
const current = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.input_qty,
wi.qty as instruction_qty
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
WHERE wop.id = $1 AND wop.company_code = $2`,
[work_order_process_id, companyCode]
);
if (current.rowCount === 0) {
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
}
const { seq_no, wo_id, input_qty, instruction_qty } = current.rows[0];
const myInputQty = parseInt(input_qty, 10) || 0;
const instrQty = parseInt(instruction_qty, 10) || 0;
const seqNum = parseInt(seq_no, 10);
let prevGoodQty = instrQty; // 첫 공정이면 지시수량이 상한
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(good_qty::int, 0) as good_qty
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].good_qty;
}
}
const availableQty = Math.max(0, prevGoodQty - myInputQty);
logger.info("[pop/production] available-qty 조회", {
work_order_process_id,
prevGoodQty,
myInputQty,
availableQty,
instructionQty: instrQty,
});
return res.json({
success: true,
data: {
prevGoodQty,
myInputQty,
availableQty,
instructionQty: instrQty,
},
});
} catch (error: any) {
logger.error("[pop/production] available-qty 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "접수가능량 조회 중 오류가 발생했습니다.",
});
}
};
/**
* ( )
* POST /api/pop/production/accept-process
* body: { work_order_process_id, accept_qty }
* - = .good_qty - .input_qty ( - input_qty)
* - (in_progress )
* - status: acceptable/waiting -> in_progress ( in_progress면 )
*/
export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, accept_qty } = req.body;
if (!work_order_process_id || !accept_qty) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 accept_qty가 필요합니다.",
});
}
const qty = parseInt(accept_qty, 10);
if (qty <= 0) {
return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." });
}
const current = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.input_qty, wop.status, wop.accepted_by,
wi.qty as instruction_qty
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
WHERE wop.id = $1 AND wop.company_code = $2`,
[work_order_process_id, companyCode]
);
if (current.rowCount === 0) {
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
}
const { seq_no, wo_id, input_qty, status, instruction_qty } = current.rows[0];
if (status === "completed") {
return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." });
}
if (status !== "acceptable" && status !== "in_progress") {
return res.status(400).json({ success: false, message: `현재 상태(${status})에서는 접수할 수 없습니다.` });
}
const myInputQty = parseInt(input_qty, 10) || 0;
const instrQty = parseInt(instruction_qty, 10) || 0;
const seqNum = parseInt(seq_no, 10);
// 앞공정 완료량 계산
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(good_qty::int, 0) as good_qty
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[wo_id, prevSeq, companyCode]
);
if (prevProcess.rowCount > 0) {
prevGoodQty = prevProcess.rows[0].good_qty;
}
}
const availableQty = prevGoodQty - myInputQty;
if (qty > availableQty) {
return res.status(400).json({
success: false,
message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수: ${myInputQty})`,
});
}
const newInputQty = myInputQty + qty;
const result = await pool.query(
`UPDATE work_order_process
SET input_qty = $3,
status = CASE WHEN status IN ('acceptable', 'waiting', 'batch_done') THEN 'in_progress' ELSE status END,
result_status = CASE WHEN result_status = 'confirmed' THEN 'draft' ELSE result_status END,
accepted_by = $4,
started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END,
updated_date = NOW(),
writer = $4
WHERE id = $1 AND company_code = $2
RETURNING id, input_qty, status, process_name, result_status`,
[work_order_process_id, companyCode, String(newInputQty), userId]
);
logger.info("[pop/production] accept-process 완료", {
companyCode,
userId,
work_order_process_id,
addedQty: qty,
newInputQty,
prevGoodQty,
});
return res.json({
success: true,
data: result.rows[0],
message: `${qty}개 접수 완료 (총 접수: ${newInputQty})`,
});
} catch (error: any) {
logger.error("[pop/production] accept-process 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "접수 중 오류가 발생했습니다.",
});
}
};
/**
* 취소: input_qty를 0 status를 acceptable로
* 조건: 아직 (total_production_qty)
*/
export const cancelAccept = async (
req: AuthenticatedRequest,
res: Response
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id } = req.body;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
const current = await pool.query(
`SELECT id, status, input_qty, total_production_qty, result_status
FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode]
);
if (current.rowCount === 0) {
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
}
const proc = current.rows[0];
if (proc.status !== "in_progress") {
return res.status(400).json({
success: false,
message: `현재 상태(${proc.status})에서는 접수 취소할 수 없습니다. 진행중 상태만 가능합니다.`,
});
}
const totalProduced = parseInt(proc.total_production_qty ?? "0", 10) || 0;
const currentInputQty = parseInt(proc.input_qty ?? "0", 10) || 0;
// 미소진 접수분 = input_qty - total_production_qty
const unproducedQty = currentInputQty - totalProduced;
if (unproducedQty <= 0) {
return res.status(400).json({
success: false,
message: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.",
});
}
// input_qty를 total_production_qty로 되돌림 (실적 있는 분량만 유지)
// 실적이 0이면 완전 초기화, 실적이 있으면 부분 취소
const newInputQty = totalProduced;
const newStatus = totalProduced > 0 ? "in_progress" : "acceptable";
const isFullCancel = newInputQty === 0;
const result = await pool.query(
`UPDATE work_order_process
SET input_qty = $3,
status = $4,
accepted_by = CASE WHEN $6 THEN NULL ELSE accepted_by END,
started_at = CASE WHEN $6 THEN NULL ELSE started_at END,
updated_date = NOW(),
writer = $5
WHERE id = $1 AND company_code = $2
RETURNING id, input_qty, status, process_name`,
[work_order_process_id, companyCode, String(newInputQty), newStatus, userId, isFullCancel]
);
logger.info("[pop/production] cancel-accept 완료", {
companyCode,
userId,
work_order_process_id,
previousInputQty: currentInputQty,
newInputQty,
cancelledQty: unproducedQty,
});
return res.json({
success: true,
data: result.rows[0],
message: `미소진 ${unproducedQty}개 접수가 취소되었습니다. (잔여 접수량: ${newInputQty})`,
});
} catch (error: any) {
logger.error("[pop/production] cancel-accept 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "접수 취소 중 오류가 발생했습니다.",
});
}
};

View File

@ -4,6 +4,13 @@ import {
createWorkProcesses,
controlTimer,
controlGroupTimer,
getDefectTypes,
saveResult,
confirmResult,
getResultHistory,
getAvailableQty,
acceptProcess,
cancelAccept,
} from "../controllers/popProductionController";
const router = Router();
@ -13,5 +20,12 @@ router.use(authenticateToken);
router.post("/create-work-processes", createWorkProcesses);
router.post("/timer", controlTimer);
router.post("/group-timer", controlGroupTimer);
router.get("/defect-types", getDefectTypes);
router.post("/save-result", saveResult);
router.post("/confirm-result", confirmResult);
router.get("/result-history", getResultHistory);
router.get("/available-qty", getAvailableQty);
router.post("/accept-process", acceptProcess);
router.post("/cancel-accept", cancelAccept);
export default router;

View File

@ -116,6 +116,7 @@ function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] {
{ dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const },
{ dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const },
{ dbValue: sv?.inProgress || "in_progress", label: "진행중", semantic: "active" as const },
{ dbValue: "batch_done", label: "접수분완료", semantic: "active" as const },
{ dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const },
];
}
@ -278,6 +279,8 @@ export function PopCardListV2Component({
}, [publish, setSharedData]);
const handleCardSelect = useCallback((row: RowData) => {
// 복제 카드(접수가능 가상)는 클릭 시 모달을 열지 않음 - 접수 버튼으로만 동작
if (row.__isAcceptClone) return;
if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) {
const mc = effectiveConfig.cardClickModalConfig;
@ -285,7 +288,13 @@ export function PopCardListV2Component({
const processFlow = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
if (mc.condition.type === "timeline-status") {
if (currentProcess?.status !== mc.condition.value) return;
const condVal = mc.condition.value;
const curStatus = currentProcess?.status;
if (Array.isArray(condVal)) {
if (!curStatus || !condVal.includes(curStatus)) return;
} else {
if (curStatus !== condVal) return;
}
} else if (mc.condition.type === "column-value") {
if (String(row[mc.condition.column || ""] ?? "") !== mc.condition.value) return;
}
@ -373,25 +382,71 @@ export function PopCardListV2Component({
const timelineSource = useMemo<TimelineDataSource | undefined>(() => {
const cells = cardGrid?.cells || [];
for (const c of cells) {
if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) {
if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons" || c.type === "mes-process-card") && c.timelineSource?.processTable) {
return c.timelineSource;
}
}
return undefined;
}, [cardGrid?.cells]);
// 외부 필터 (메인 테이블 + 하위 테이블 분기)
const filteredRows = useMemo(() => {
if (externalFilters.size === 0) return rows;
// in_progress + 잔여 접수가능량 > 0인 카드를 복제하여 "접수가능" 탭에도 노출
const duplicateAcceptableCards = useCallback((sourceRows: RowData[]): RowData[] => {
const result: RowData[] = [];
for (const row of sourceRows) {
result.push(row);
// 이미 복제된 카드는 다시 복제하지 않음
if (row.__isAcceptClone) continue;
const allFilters = [...externalFilters.values()];
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) continue;
// 1단계: 하위 테이블 필터 → __subStatus__ 주입
const currentStep = processFlow.find((s) => s.isCurrent);
if (!currentStep) continue;
// in_progress 또는 batch_done 공정이면서 잔여 접수가능량 > 0인 경우만 복제
if (currentStep.status !== "in_progress" && currentStep.status !== "batch_done") continue;
const availableQty = Number(currentStep.rawData?.__availableQty ?? 0);
if (availableQty <= 0) continue;
// 복제 카드: isCurrent를 해당 공정의 acceptable 가상 상태로 변경
const clonedFlow = processFlow.map((s) => ({
...s,
isCurrent: s.seqNo === currentStep.seqNo,
status: s.seqNo === currentStep.seqNo ? "acceptable" : s.status,
semantic: s.seqNo === currentStep.seqNo ? ("active" as const) : s.semantic,
}));
const clonedProcessFields: Record<string, unknown> = {};
if (currentStep.rawData) {
for (const [key, val] of Object.entries(currentStep.rawData)) {
clonedProcessFields[`__process_${key}`] = val;
}
}
result.push({
...row,
__processFlow__: clonedFlow,
__isAcceptClone: true,
__cloneSourceId: String(row.id ?? ""),
[VIRTUAL_SUB_STATUS]: "acceptable",
[VIRTUAL_SUB_SEMANTIC]: "active",
[VIRTUAL_SUB_PROCESS]: currentStep.processName,
[VIRTUAL_SUB_SEQ]: currentStep.seqNo,
...clonedProcessFields,
});
}
return result;
}, []);
// 하위 필터 + 카드 복제 적용 (공통 함수)
const applySubFilterAndDuplicate = useCallback((sourceRows: RowData[], subFilters: Array<{
fieldName: string;
value: unknown;
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
}>) => {
const afterSubFilter = subFilters.length === 0
? rows
: rows
? sourceRows
: sourceRows
.map((row) => {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) return null;
@ -416,11 +471,19 @@ export function PopCardListV2Component({
if (matchingSteps.length === 0) return null;
const matched = matchingSteps[0];
// 매칭된 공정을 타임라인에서 강조
const updatedFlow = processFlow.map((s) => ({
...s,
isCurrent: s.seqNo === matched.seqNo,
}));
// 서브 필터로 공정이 바뀌면 __process_* 필드도 재주입
const processFields: Record<string, unknown> = {};
if (matched.rawData) {
for (const [key, val] of Object.entries(matched.rawData)) {
processFields[`__process_${key}`] = val;
}
processFields.__availableQty = matched.rawData.__availableQty ?? 0;
processFields.__prevGoodQty = matched.rawData.__prevGoodQty ?? 0;
}
return {
...row,
__processFlow__: updatedFlow,
@ -428,14 +491,27 @@ export function PopCardListV2Component({
[VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending",
[VIRTUAL_SUB_PROCESS]: matched.processName,
[VIRTUAL_SUB_SEQ]: matched.seqNo,
};
...processFields,
} as RowData;
})
.filter((row): row is RowData => row !== null);
// 2단계: 메인 테이블 필터 (__subStatus__ 주입된 데이터 기반)
if (mainFilters.length === 0) return afterSubFilter;
// 카드 복제: in_progress + 잔여 접수가능량 > 0 → 접수가능 탭에도 노출
return duplicateAcceptableCards(afterSubFilter);
}, [duplicateAcceptableCards]);
return afterSubFilter.filter((row) =>
// 메인 필터 적용 (공통 함수)
const applyMainFilters = useCallback((
sourceRows: RowData[],
mainFilters: Array<{ fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean } }>,
hasSubFilters: boolean,
) => {
if (mainFilters.length === 0) return sourceRows;
const subCol = hasSubFilters ? VIRTUAL_SUB_STATUS : null;
const statusCol = timelineSource?.statusColumn || "status";
return sourceRows.filter((row) =>
mainFilters.every((filter) => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
@ -447,9 +523,6 @@ export function PopCardListV2Component({
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
// 하위 필터 활성 시: 상태 컬럼(status 등)을 __subStatus__로 대체
const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null;
const statusCol = timelineSource?.statusColumn || "status";
const effectiveColumns = subCol
? columns.map((col) => col === statusCol || col === "status" ? subCol : col)
: columns;
@ -464,7 +537,19 @@ export function PopCardListV2Component({
});
}),
);
}, [rows, externalFilters, timelineSource]);
}, [timelineSource]);
// 외부 필터 (메인 테이블 + 하위 테이블 분기)
const filteredRows = useMemo(() => {
if (externalFilters.size === 0) return duplicateAcceptableCards(rows);
const allFilters = [...externalFilters.values()];
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters);
return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0);
}, [rows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters]);
// 하위 필터 활성 여부
const hasActiveSubFilter = useMemo(() => {
@ -550,96 +635,32 @@ export function PopCardListV2Component({
}, [selectedRowIds, filteredRows, exitSelectMode]);
// status-bar 필터를 제외한 rows (카운트 집계용)
// status-bar에서 "접수가능" 등 선택해도 전체 카운트가 유지되어야 함
const rowsForStatusCount = useMemo(() => {
const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar");
if (!hasStatusBarFilter) return filteredRows;
// status-bar 필터를 제외한 필터만 적용
const nonStatusFilters = new Map(
[...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar")
);
if (nonStatusFilters.size === 0) return rows;
if (nonStatusFilters.size === 0) return duplicateAcceptableCards(rows);
const allFilters = [...nonStatusFilters.values()];
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
const afterSubFilter = subFilters.length === 0
? rows
: rows
.map((row) => {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) return null;
const matchingSteps = processFlow.filter((step) =>
subFilters.every((filter) => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const col = fc?.targetColumn || filter.fieldName || "";
if (!col) return true;
const cellValue = String(step.rawData?.[col] ?? "").toLowerCase();
const mode = fc?.filterMode || "contains";
switch (mode) {
case "equals": return cellValue === searchValue;
case "starts_with": return cellValue.startsWith(searchValue);
default: return cellValue.includes(searchValue);
}
}),
);
if (matchingSteps.length === 0) return null;
const matched = matchingSteps[0];
const updatedFlow = processFlow.map((s) => ({
...s,
isCurrent: s.seqNo === matched.seqNo,
}));
return {
...row,
__processFlow__: updatedFlow,
[VIRTUAL_SUB_STATUS]: matched.status,
[VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending",
[VIRTUAL_SUB_PROCESS]: matched.processName,
[VIRTUAL_SUB_SEQ]: matched.seqNo,
};
})
.filter((row): row is RowData => row !== null);
if (mainFilters.length === 0) return afterSubFilter;
return afterSubFilter.filter((row) =>
mainFilters.every((filter) => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const columns: string[] =
fc?.targetColumns?.length ? fc.targetColumns
: fc?.targetColumn ? [fc.targetColumn]
: filter.fieldName ? [filter.fieldName] : [];
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null;
const statusCol = timelineSource?.statusColumn || "status";
const effectiveColumns = subCol
? columns.map((col) => col === statusCol || col === "status" ? subCol : col)
: columns;
return effectiveColumns.some((col) => {
const cellValue = String(row[col] ?? "").toLowerCase();
switch (mode) {
case "equals": return cellValue === searchValue;
case "starts_with": return cellValue.startsWith(searchValue);
default: return cellValue.includes(searchValue);
}
});
}),
);
}, [rows, filteredRows, externalFilters, timelineSource]);
const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters);
return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0);
}, [rows, filteredRows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters]);
// 카운트 집계용 rows 발행 (status-bar 필터 제외)
// originalCount: 복제 카드를 제외한 원본 카드 수
useEffect(() => {
if (!componentId || loading) return;
const originalCount = rowsForStatusCount.filter((r) => !r.__isAcceptClone).length;
publish(`__comp_output__${componentId}__all_rows`, {
rows: rowsForStatusCount,
subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null,
originalCount,
});
}, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]);
@ -809,6 +830,35 @@ export function PopCardListV2Component({
if (firstPending) { firstPending.isCurrent = true; }
}
// 각 공정에 접수가능 잔여량(__availableQty) 주입
for (const [rowId, steps] of processMap) {
steps.sort((a, b) => a.seqNo - b.seqNo);
const parentRow = fetchedRows.find((r) => String(r.id) === rowId);
const instrQty = parseInt(String(parentRow?.qty ?? "0"), 10) || 0;
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const prevStep = i > 0 ? steps[i - 1] : null;
const prevGoodQty = prevStep
? parseInt(String(prevStep.rawData?.good_qty ?? "0"), 10) || 0
: instrQty;
const myInputQty = parseInt(String(step.rawData?.input_qty ?? "0"), 10) || 0;
const availableQty = Math.max(0, prevGoodQty - myInputQty);
if (step.rawData) {
step.rawData.__availableQty = availableQty;
step.rawData.__prevGoodQty = prevGoodQty;
}
// TimelineProcessStep에 수량 필드 직접 주입 (process-qty-summary 셀용)
step.inputQty = myInputQty;
step.totalProductionQty = parseInt(String(step.rawData?.total_production_qty ?? "0"), 10) || 0;
step.goodQty = parseInt(String(step.rawData?.good_qty ?? "0"), 10) || 0;
step.defectQty = parseInt(String(step.rawData?.defect_qty ?? "0"), 10) || 0;
step.yieldRate = step.totalProductionQty > 0
? Math.round((step.goodQty / step.totalProductionQty) * 100)
: 0;
}
}
return fetchedRows.map((row) => {
const steps = processMap.get(String(row.id)) || [];
const current = steps.find((s) => s.isCurrent);
@ -818,6 +868,11 @@ export function PopCardListV2Component({
processFields[`__process_${key}`] = val;
}
}
// row 레벨에 현재 공정의 접수가능/전공정양품 주입 (process-qty-summary 셀용)
if (current?.rawData) {
processFields.__availableQty = current.rawData.__availableQty ?? 0;
processFields.__prevGoodQty = current.rawData.__prevGoodQty ?? 0;
}
return { ...row, __processFlow__: steps, ...processFields };
});
}, []);
@ -1086,9 +1141,12 @@ export function PopCardListV2Component({
const locked = !!ownerSortColumn
&& !!String(row[ownerSortColumn] ?? "")
&& String(row[ownerSortColumn] ?? "") !== (currentUserId ?? "");
const cardKey = row.__isAcceptClone
? `card-clone-${row.__cloneSourceId}-${index}`
: `card-${row.id ?? index}`;
return (
<CardV2
key={`card-${index}`}
key={cardKey}
row={row}
cardGrid={cardGrid}
spec={spec}
@ -1250,17 +1308,61 @@ function CardV2({
row: RowData;
processId?: string | number;
action: ActionButtonClickAction;
dynamicMax?: number;
} | null>(null);
// 수량 모달이 열려 있을 때 카드 클릭 차단 (모달 닫힘 직후 이벤트 전파 방지)
const qtyModalClosedAtRef = useRef<number>(0);
const closeQtyModal = useCallback(() => {
qtyModalClosedAtRef.current = Date.now();
setQtyModalState(null);
}, []);
const handleQtyConfirm = useCallback(async (value: number) => {
if (!qtyModalState) return;
const { row: actionRow, processId: qtyProcessId, action } = qtyModalState;
setQtyModalState(null);
if (!action.targetTable || !action.updates) return;
if (!action.targetTable || !action.updates) { closeQtyModal(); return; }
const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk;
if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; }
// MES 전용: work_order_process 접수는 accept-process API 사용
const isAcceptAction = action.targetTable === "work_order_process"
&& action.updates.some((u) => u.column === "input_qty");
if (isAcceptAction) {
let wopId = qtyProcessId;
if (!wopId) {
const pf = actionRow.__processFlow__ as Array<{ isCurrent: boolean; processId?: string | number }> | undefined;
const cur = pf?.find((p) => p.isCurrent);
wopId = cur?.processId;
}
if (!wopId) {
toast.error("공정 ID를 찾을 수 없습니다.");
closeQtyModal();
return;
}
try {
const result = await apiClient.post("/pop/production/accept-process", {
work_order_process_id: wopId,
accept_qty: value,
});
if (result.data?.success) {
toast.success(result.data.message || "접수 완료");
onRefresh?.();
} else {
toast.error(result.data?.message || "접수 실패");
}
} catch (err: unknown) {
const errMsg = (err as any)?.response?.data?.message;
toast.error(errMsg || "접수 중 오류 발생");
onRefresh?.();
}
closeQtyModal();
return;
}
// 일반 quantity-input (기존 로직)
const lookupValue = action.joinConfig
? String(actionRow[action.joinConfig.sourceColumn] ?? rowId)
: rowId;
@ -1309,7 +1411,8 @@ function CardV2({
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
}
}
}, [qtyModalState, onRefresh]);
closeQtyModal();
}, [qtyModalState, onRefresh, closeQtyModal]);
const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
const isCarted = cart.isItemInCart(rowKey);
@ -1387,6 +1490,9 @@ function CardV2({
? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500"
: isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500";
// mes-process-card 전용 카드일 때 래퍼 스타일 변경
const isMesCard = cardGrid?.cells.some((c) => c.type === "mes-process-card");
if (!cardGrid || cardGrid.cells.length === 0) {
return (
<div className={`flex items-center justify-center rounded-lg border p-4 ${borderClass}`} style={{ minHeight: `${spec.height}px` }}>
@ -1417,15 +1523,18 @@ function CardV2({
return (
<div
className={cn(
"relative flex flex-col rounded-lg border bg-card shadow-sm transition-all duration-150",
"relative flex flex-col rounded-lg border shadow-sm transition-all duration-150",
isMesCard ? "overflow-hidden" : "bg-card",
isLockedByOther
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:shadow-md",
borderClass,
)}
style={{ minHeight: `${spec.height}px` }}
style={{ minHeight: isMesCard ? undefined : `${spec.height}px` }}
onClick={() => {
if (isLockedByOther) return;
if (qtyModalState?.open) return;
if (Date.now() - qtyModalClosedAtRef.current < 500) return;
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
if (!selectMode) onSelect?.(row);
}}
@ -1434,6 +1543,7 @@ function CardV2({
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (isLockedByOther) return;
if (qtyModalState?.open) return;
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
if (!selectMode) onSelect?.(row);
}
@ -1499,9 +1609,30 @@ function CardV2({
onEnterSelectMode,
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
const cfg = buttonConfig as Record<string, unknown> | undefined;
const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || [];
const processId = cfg?.__processId as string | number | undefined;
// 접수 취소 처리 (__cancelAccept 또는 "접수취소" 라벨 버튼)
if ((taskPreset === "__cancelAccept" || taskPreset === "접수취소") && processId) {
if (!window.confirm("접수를 취소하시겠습니까? 실적이 없는 경우에만 가능합니다.")) return;
try {
const result = await apiClient.post("/pop/production/cancel-accept", {
work_order_process_id: processId,
});
if (result.data?.success) {
toast.success(result.data.message || "접수가 취소되었습니다.");
onRefresh?.();
} else {
toast.error(result.data?.message || "접수 취소에 실패했습니다.");
}
} catch (err: unknown) {
const errMsg = (err as any)?.response?.data?.message;
toast.error(errMsg || "접수 취소 중 오류가 발생했습니다.");
}
return;
}
const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || [];
// 단일 액션 폴백 (기존 구조 호환)
const actionsToRun = allActions.length > 0
? allActions
@ -1519,7 +1650,28 @@ function CardV2({
for (const action of actionsToRun) {
if (action.type === "quantity-input" && action.targetTable && action.updates) {
if (action.confirmMessage && !window.confirm(action.confirmMessage)) return;
setQtyModalState({ open: true, row: actionRow, processId, action });
// MES 전용: accept-process API 기반 접수 상한 조회
const isAcceptAction = action.targetTable === "work_order_process"
&& action.updates.some((u) => u.column === "input_qty");
let dynamicMax: number | undefined;
let resolvedProcessId = processId;
if (!resolvedProcessId) {
const pf = actionRow.__processFlow__ as Array<{ isCurrent: boolean; processId?: string | number }> | undefined;
resolvedProcessId = pf?.find((p) => p.isCurrent)?.processId;
}
if (isAcceptAction && resolvedProcessId) {
try {
const aqRes = await apiClient.get("/pop/production/available-qty", {
params: { work_order_process_id: resolvedProcessId },
});
if (aqRes.data?.success) {
dynamicMax = aqRes.data.data.availableQty;
}
} catch { /* fallback to static */ }
}
setQtyModalState({ open: true, row: actionRow, processId: resolvedProcessId ?? processId, action, dynamicMax });
return;
} else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) {
if (action.confirmMessage) {
@ -1603,9 +1755,9 @@ function CardV2({
{qtyModalState?.open && (
<NumberInputModal
open={true}
onOpenChange={(open) => { if (!open) setQtyModalState(null); }}
onOpenChange={(open) => { if (!open) closeQtyModal(); }}
unit={qtyModalState.action.quantityInput?.unit || "EA"}
maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)}
maxValue={qtyModalState.dynamicMax ?? calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)}
showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false}
onConfirm={(value) => handleQtyConfirm(value)}
/>

View File

@ -101,6 +101,10 @@ export function renderCellV2(props: CellRendererProps): React.ReactNode {
return <ActionButtonsCell {...props} />;
case "footer-status":
return <FooterStatusCell {...props} />;
case "process-qty-summary":
return <ProcessQtySummaryCell {...props} />;
case "mes-process-card":
return <MesProcessCardCell {...props} />;
default:
return <span className="text-[10px] text-muted-foreground"> </span>;
}
@ -327,6 +331,7 @@ const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
waiting: { bg: "#94a3b820", text: "#64748b" },
accepted: { bg: "#3b82f620", text: "#2563eb" },
in_progress: { bg: "#f59e0b20", text: "#d97706" },
batch_done: { bg: "#8b5cf620", text: "#7c3aed" },
completed: { bg: "#10b98120", text: "#059669" },
};
@ -349,17 +354,28 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) {
);
}
const defaultColors = STATUS_COLORS[strValue];
// in_progress + 접수분 전부 생산 완료 → "접수분완료" 표시
let displayValue = strValue;
if (strValue === "in_progress") {
const inputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0;
const totalProd = parseInt(String(row.total_production_qty ?? "0"), 10) || 0;
if (inputQty > 0 && totalProd >= inputQty) {
displayValue = "batch_done";
}
}
const defaultColors = STATUS_COLORS[displayValue];
if (defaultColors) {
const labelMap: Record<string, string> = {
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
waiting: "대기", accepted: "접수", in_progress: "진행중",
batch_done: "접수분완료", completed: "완료",
};
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
>
{labelMap[strValue] || strValue}
{labelMap[displayValue] || displayValue}
</span>
);
}
@ -601,7 +617,11 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId
if (cond.type === "timeline-status") {
const subStatus = row[VIRTUAL_SUB_STATUS];
matched = subStatus !== undefined && String(subStatus) === cond.value;
if (Array.isArray(cond.value)) {
matched = subStatus !== undefined && cond.value.includes(String(subStatus));
} else {
matched = subStatus !== undefined && String(subStatus) === cond.value;
}
} else if (cond.type === "column-value" && cond.column) {
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
} else if (cond.type === "owner-match" && cond.column) {
@ -621,10 +641,22 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
const currentProcessId = currentProcess?.processId;
if (cell.actionButtons && cell.actionButtons.length > 0) {
const evaluated = cell.actionButtons.map((btn) => ({
btn,
state: evaluateShowCondition(btn, row, currentUserId),
}));
const evaluated = cell.actionButtons.map((btn) => {
let state = evaluateShowCondition(btn, row, currentUserId);
// 접수가능 조건 버튼이 원본 카드의 in_progress에서 보이지 않도록 차단
// (접수는 접수가능 탭의 클론 카드에서만 가능)
if (state === "visible" && !row.__isAcceptClone) {
const cond = btn.showCondition;
if (cond?.type === "timeline-status") {
const condValues = Array.isArray(cond.value) ? cond.value : [cond.value];
const currentSubStatus = String(row[VIRTUAL_SUB_STATUS] ?? "");
if (condValues.includes("acceptable") && currentSubStatus === "in_progress") {
state = "hidden";
}
}
}
return { btn, state };
});
const activeBtn = evaluated.find((e) => e.state === "visible");
const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled");
@ -633,6 +665,14 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
const { btn, state } = pick;
// in_progress 상태 + 미소진 접수분 존재 시 접수취소 버튼 추가
const subStatus = row[VIRTUAL_SUB_STATUS];
const effectiveStatus = subStatus !== undefined ? String(subStatus) : "";
const rowInputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0;
const totalProduced = parseInt(String(row.total_production_qty ?? "0"), 10) || 0;
const hasUnproduced = rowInputQty > totalProduced;
const showCancelBtn = effectiveStatus === "in_progress" && hasUnproduced && currentProcessId;
return (
<div className="flex items-center gap-1">
<Button
@ -664,6 +704,22 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
>
{btn.label}
</Button>
{showCancelBtn && (
<Button
variant="ghost"
size="sm"
className="h-7 text-[10px] text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onActionButtonClick?.("__cancelAccept", row, {
__processId: currentProcessId,
type: "cancel-accept",
});
}}
>
</Button>
)}
</div>
);
}
@ -703,7 +759,205 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
);
}
// ===== 12. footer-status =====
// ===== 12. process-qty-summary =====
function ProcessQtySummaryCell({ cell, row }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
const status = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? "");
const isClone = !!row.__isAcceptClone;
const instructionQty = parseInt(String(row.instruction_qty ?? "0"), 10) || 0;
const inputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0;
const totalProd = parseInt(String(row.total_production_qty ?? "0"), 10) || 0;
const goodQty = parseInt(String(row.good_qty ?? "0"), 10) || 0;
const defectQty = parseInt(String(row.defect_qty ?? "0"), 10) || 0;
const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0;
const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instructionQty)), 10) || 0;
const currentStep = processFlow?.find((s) => s.isCurrent);
const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1;
const isFirstProcess = currentIdx === 0;
const totalSteps = processFlow?.length ?? 0;
const remainingQty = Math.max(0, inputQty - totalProd);
const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0;
const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0;
// 접수가능 탭 (클론 카드) - 접수 가능 수량 중심
if (isClone || status === "acceptable" || status === "waiting") {
const showQty = isClone ? availableQty : (status === "acceptable" ? availableQty || prevGoodQty : 0);
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 핵심 수량 */}
<div className="flex items-center justify-between gap-1">
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-semibold">{instructionQty.toLocaleString()}</span>
</div>
{!isFirstProcess && (
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-medium text-emerald-600">{prevGoodQty.toLocaleString()}</span>
</div>
)}
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-bold text-primary">{(showQty || prevGoodQty).toLocaleString()}</span>
</div>
</div>
</div>
);
}
// 진행중 / 접수분완료 - 작업 현황 중심
if (status === "in_progress") {
const isBatchDone = inputQty > 0 && totalProd >= inputQty;
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 프로그레스 바 */}
<div className="flex items-center gap-1.5">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full rounded-full transition-all duration-300",
isBatchDone ? "bg-violet-500" : "bg-primary",
)}
style={{ width: `${progressPct}%` }}
/>
</div>
<span className={cn(
"text-[10px] font-bold tabular-nums",
isBatchDone ? "text-violet-600" : "text-primary",
)}>
{progressPct}%
</span>
</div>
{/* 수량 상세 */}
<div className="flex items-center justify-between gap-0.5">
<QtyChip label="접수" value={inputQty} color="#3b82f6" />
<QtyChip label="양품" value={goodQty} color="#10b981" />
<QtyChip label="불량" value={defectQty} color="#ef4444" showZero={false} />
<QtyChip label="잔여" value={remainingQty} color={remainingQty > 0 ? "#f59e0b" : "#10b981"} />
</div>
{/* 추가접수가능 수량 (있을 때만) */}
{availableQty > 0 && (
<div className="flex items-center justify-end gap-1">
<span className="text-[9px] text-muted-foreground"></span>
<span className="text-[10px] font-semibold text-violet-600">{availableQty.toLocaleString()}</span>
</div>
)}
</div>
);
}
// 완료 상태 - 최종 결과 요약
if (status === "completed") {
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 완료 프로그레스 */}
<div className="flex items-center gap-1.5">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div className="h-full rounded-full bg-emerald-500" style={{ width: "100%" }} />
</div>
<span className="text-[10px] font-bold text-emerald-600"></span>
</div>
{/* 최종 수량 */}
<div className="flex items-center justify-between gap-0.5">
<QtyChip label="총생산" value={totalProd} color="#059669" />
<QtyChip label="양품" value={goodQty} color="#10b981" />
<QtyChip label="불량" value={defectQty} color="#ef4444" showZero={false} />
{totalProd > 0 && (
<div className="flex items-baseline gap-0.5">
<span className="text-[9px] text-muted-foreground"></span>
<span className="text-[10px] font-bold text-emerald-600">{yieldRate}%</span>
</div>
)}
</div>
</div>
);
}
// fallback
return (
<div className="flex w-full items-center justify-between px-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-semibold">{instructionQty.toLocaleString()}</span>
</div>
);
}
// --- 미니 공정 흐름 바 ---
function MiniProcessBar({ steps, currentIdx }: { steps: TimelineProcessStep[]; currentIdx: number }) {
return (
<div className="flex items-center gap-px">
{steps.map((step, idx) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const isCurrent = idx === currentIdx;
let bg = "#e2e8f0"; // pending
if (sem === "done") bg = "#10b981";
else if (sem === "active") bg = "#3b82f6";
const pct = step.totalProductionQty && step.inputQty && step.inputQty > 0
? Math.round((step.totalProductionQty / step.inputQty) * 100)
: undefined;
return (
<div
key={step.seqNo}
className={cn(
"relative flex-1 overflow-hidden rounded-sm",
isCurrent ? "h-2.5" : "h-1.5",
)}
style={{ backgroundColor: `${bg}30` }}
title={`${step.processName}: ${step.status}${pct !== undefined ? ` (${pct}%)` : ""}`}
>
<div
className="absolute inset-y-0 left-0 rounded-sm transition-all duration-300"
style={{
backgroundColor: bg,
width: sem === "done" ? "100%" : pct !== undefined ? `${pct}%` : "0%",
}}
/>
</div>
);
})}
</div>
);
}
// --- 수량 칩 ---
function QtyChip({
label, value, color, showZero = true,
}: {
label: string; value: number; color: string; showZero?: boolean;
}) {
if (!showZero && value === 0) return null;
return (
<div className="flex items-baseline gap-0.5">
<span className="text-[9px] text-muted-foreground">{label}</span>
<span
className="text-[10px] font-semibold tabular-nums"
style={{ color }}
>
{value.toLocaleString()}
</span>
</div>
);
}
// ===== 13. footer-status =====
function FooterStatusCell({ cell, row }: CellRendererProps) {
const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : "";
@ -735,3 +989,402 @@ function FooterStatusCell({ cell, row }: CellRendererProps) {
</div>
);
}
// ===== 14. mes-process-card (MES 공정 전용 카드) =====
const MES_STATUS: Record<string, { label: string; color: string; bg: string }> = {
waiting: { label: "대기", color: "#94a3b8", bg: "#f8fafc" },
acceptable: { label: "접수가능", color: "#2563eb", bg: "#eff6ff" },
in_progress: { label: "진행중", color: "#d97706", bg: "#fffbeb" },
batch_done: { label: "접수분완료", color: "#7c3aed", bg: "#f5f3ff" },
completed: { label: "완료", color: "#059669", bg: "#ecfdf5" },
};
function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
const rawStatus = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? "");
const isClone = !!row.__isAcceptClone;
const [flowModalOpen, setFlowModalOpen] = useState(false);
const instrQty = parseInt(String(row.qty ?? row.instruction_qty ?? "0"), 10) || 0;
const inputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0;
const totalProd = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0;
const goodQty = parseInt(String(row.__process_good_qty ?? row.good_qty ?? "0"), 10) || 0;
const defectQty = parseInt(String(row.__process_defect_qty ?? row.defect_qty ?? "0"), 10) || 0;
const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0;
const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instrQty)), 10) || 0;
const resultStatus = String(row.__process_result_status ?? "");
const currentStep = processFlow?.find((s) => s.isCurrent);
const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1;
const isFirstProcess = currentIdx === 0;
const processId = currentStep?.processId;
const remainingQty = Math.max(0, inputQty - totalProd);
const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0;
const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0;
const isBatchDone = rawStatus === "in_progress" && inputQty > 0 && totalProd >= inputQty;
let displayStatus = rawStatus;
if (isBatchDone) displayStatus = "batch_done";
const st = MES_STATUS[displayStatus] || MES_STATUS.waiting;
const processName = currentStep?.processName || String(row.__process_process_name ?? "");
const woNo = String(row.work_instruction_no ?? "");
const itemId = String(row.item_id ?? "");
// MES 워크플로우 상태 기반 버튼 결정
const acceptBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "timeline-status");
const cancelBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "owner-match");
let activeBtn: ActionButtonDef | undefined;
if (isClone) {
activeBtn = acceptBtn;
} else if (rawStatus === "acceptable") {
activeBtn = acceptBtn;
} else if (rawStatus === "batch_done") {
if (availableQty > 0) activeBtn = acceptBtn;
} else if (rawStatus === "in_progress") {
if (isBatchDone || resultStatus === "confirmed") {
if (availableQty > 0) activeBtn = acceptBtn;
} else {
activeBtn = cancelBtn;
}
}
return (
<>
<div
className="flex h-full w-full flex-col overflow-hidden"
style={{ borderLeft: `3px solid ${st.color}`, backgroundColor: st.bg }}
>
{/* ── 헤더 ── */}
<div className="flex items-start justify-between px-3 pt-2.5 pb-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[13px] font-bold leading-tight">{woNo}</span>
{itemId && itemId !== "-" && (
<span className="truncate text-[10px] text-muted-foreground">{itemId}</span>
)}
</div>
{processName && (
<div className="mt-0.5 flex items-center gap-1">
<span className="text-[11px] font-semibold" style={{ color: st.color }}>{processName}</span>
{processFlow && processFlow.length > 1 && (
<span className="text-[9px] text-muted-foreground">
({currentIdx + 1}/{processFlow.length})
</span>
)}
</div>
)}
</div>
<span
className="ml-2 shrink-0 rounded px-2 py-0.5 text-[10px] font-bold"
style={{ backgroundColor: st.color, color: "#fff" }}
>
{st.label}
</span>
</div>
{/* ── 수량 메트릭 (상태별) ── */}
<div className="px-3 py-1.5">
{(isClone || rawStatus === "acceptable" || rawStatus === "waiting") && (
<MesAcceptableMetrics
instrQty={instrQty}
prevGoodQty={prevGoodQty}
availableQty={availableQty}
inputQty={inputQty}
isFirstProcess={isFirstProcess}
isClone={isClone}
/>
)}
{(rawStatus === "in_progress" || rawStatus === "batch_done") && (
<MesInProgressMetrics
inputQty={inputQty}
totalProd={totalProd}
goodQty={goodQty}
defectQty={defectQty}
remainingQty={remainingQty}
progressPct={progressPct}
availableQty={availableQty}
isBatchDone={displayStatus === "batch_done" || rawStatus === "batch_done"}
statusColor={st.color}
/>
)}
{rawStatus === "completed" && (
<MesCompletedMetrics
instrQty={instrQty}
goodQty={goodQty}
defectQty={defectQty}
yieldRate={yieldRate}
/>
)}
</div>
{/* ── 공정 흐름 스트립 (클릭 시 모달) ── */}
{processFlow && processFlow.length > 0 && (
<div
className="cursor-pointer border-t px-3 py-1.5 transition-colors hover:bg-black/2"
style={{ borderColor: `${st.color}20` }}
onClick={(e) => { e.stopPropagation(); setFlowModalOpen(true); }}
title="클릭하여 공정 상세 보기"
>
<ProcessFlowStrip steps={processFlow} currentIdx={currentIdx} instrQty={instrQty} />
</div>
)}
{/* ── 하단: 부가정보 + 액션 ── */}
<div
className="mt-auto flex items-center justify-between border-t px-3 py-1.5"
style={{ borderColor: `${st.color}20` }}
>
<div className="flex items-center gap-2 text-[9px] text-muted-foreground">
{row.end_date && <span> {formatValue(row.end_date)}</span>}
{row.equipment_id && <span>{String(row.equipment_id)}</span>}
{row.work_team && <span>{String(row.work_team)}</span>}
</div>
<div className="flex shrink-0 items-center gap-1">
{activeBtn && (
<Button
variant={activeBtn.variant || "default"}
size="sm"
className="h-7 px-3 text-[11px] font-semibold"
onClick={(e) => {
e.stopPropagation();
const actions = activeBtn.clickActions?.length ? activeBtn.clickActions : [activeBtn.clickAction];
const firstAction = actions[0];
const config: Record<string, unknown> = { ...firstAction, __allActions: actions };
if (processId !== undefined) config.__processId = processId;
onActionButtonClick?.(activeBtn.label, row, config);
}}
>
{activeBtn.label}
</Button>
)}
</div>
</div>
</div>
{/* ── 공정 상세 모달 ── */}
<Dialog open={flowModalOpen} onOpenChange={setFlowModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base">{woNo} </DialogTitle>
<DialogDescription className="text-xs">
{processFlow?.length ?? 0} {processFlow?.filter(s => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length ?? 0}
</DialogDescription>
</DialogHeader>
<div className="space-y-0">
{processFlow?.map((step, idx) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const styles = getTimelineStyle(step);
const sInstr = instrQty;
const sInput = step.inputQty || 0;
const sProd = step.totalProductionQty || 0;
const sGood = step.goodQty || 0;
const sDefect = step.defectQty || 0;
const sYield = step.yieldRate || 0;
const sPct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0);
const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기";
return (
<div key={step.seqNo} className="flex items-center">
<div className="flex w-8 shrink-0 flex-col items-center">
{idx > 0 && <div className="h-3 w-px bg-border" />}
<div
className="flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{step.seqNo}
</div>
{idx < (processFlow?.length ?? 0) - 1 && <div className="h-3 w-px bg-border" />}
</div>
<div className={cn(
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
step.isCurrent && "ring-1 ring-primary/30 bg-primary/5",
)}>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn("text-sm", step.isCurrent ? "font-bold" : "font-medium")}>
{step.processName}
</span>
<span className="rounded px-1.5 py-0.5 text-[9px] font-medium"
style={{ backgroundColor: `${styles.chipBg}30`, color: styles.chipBg }}>
{statusLabel}
</span>
</div>
{(sInput > 0 || sem === "done") && (
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
<span> <b className="text-foreground">{sGood.toLocaleString()}</b></span>
{sDefect > 0 && <span> <b className="text-destructive">{sDefect.toLocaleString()}</b></span>}
<span> <b style={{ color: sYield >= 95 ? "#059669" : sYield >= 80 ? "#d97706" : "#ef4444" }}>{sYield}%</b></span>
</div>
)}
</div>
<div className="ml-3 flex w-16 flex-col items-end">
<span className="text-[11px] font-bold tabular-nums">{sProd}/{sInput || sInstr}</span>
<div className="mt-0.5 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
<div className="h-full rounded-full transition-all" style={{
width: `${sPct}%`,
backgroundColor: styles.chipBg,
}} />
</div>
</div>
</div>
</div>
);
})}
</div>
</DialogContent>
</Dialog>
</>
);
}
// ── 공정 흐름 스트립 (카드 내 표시) ──
function ProcessFlowStrip({ steps, currentIdx, instrQty }: {
steps: TimelineProcessStep[]; currentIdx: number; instrQty: number;
}) {
const maxShow = 5;
const showAll = steps.length <= maxShow;
const visible = showAll ? steps : steps.slice(0, maxShow);
const hiddenCount = steps.length - visible.length;
return (
<div className="flex items-end gap-0.5">
{visible.map((step, idx) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const isCurr = idx === currentIdx;
const sInput = step.inputQty || 0;
const sProd = step.totalProductionQty || 0;
const sGood = step.goodQty || 0;
const pct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0);
let barColor = "#e2e8f0";
if (sem === "done") barColor = "#10b981";
else if (sem === "active") barColor = "#3b82f6";
return (
<React.Fragment key={step.seqNo}>
<div className={cn(
"flex min-w-0 flex-1 flex-col items-center gap-0.5",
isCurr && "relative",
)}>
{isCurr && (
<div className="absolute -top-0.5 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-primary" />
)}
<span className={cn(
"max-w-full truncate text-[9px] leading-tight",
isCurr ? "font-bold text-primary" : sem === "done" ? "font-medium text-emerald-700" : "text-muted-foreground",
)}>
{step.processName}
</span>
<div className="h-1 w-full overflow-hidden rounded-full" style={{ backgroundColor: `${barColor}30` }}>
<div className="h-full rounded-full transition-all" style={{ width: `${pct}%`, backgroundColor: barColor }} />
</div>
<span className={cn(
"text-[8px] tabular-nums",
sem === "done" ? "text-emerald-600" : sem === "active" ? "text-primary" : "text-muted-foreground/60",
)}>
{sem === "pending" && sInput === 0 ? "-" : `${sGood}/${sInput || instrQty}`}
</span>
</div>
{idx < visible.length - 1 && (
<div className="mb-2 h-px w-1.5 shrink-0 bg-border" />
)}
</React.Fragment>
);
})}
{hiddenCount > 0 && (
<div className="flex h-full shrink-0 items-center">
<span className="rounded bg-muted px-1 py-0.5 text-[8px] font-bold text-muted-foreground">+{hiddenCount}</span>
</div>
)}
</div>
);
}
// ── 접수가능 메트릭 ──
function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone }: {
instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean;
}) {
const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty);
return (
<div className="space-y-1.5">
<div className="flex items-center gap-3 text-[10px]">
<span className="text-muted-foreground"> <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
{!isFirstProcess && (
<span className="text-muted-foreground"> <b className="text-emerald-600">{prevGoodQty.toLocaleString()}</b></span>
)}
{inputQty > 0 && (
<span className="text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
)}
</div>
<div className="flex items-center justify-center rounded-md py-2" style={{ backgroundColor: "rgba(37,99,235,0.06)" }}>
<span className="text-[11px] text-muted-foreground">&ensp;</span>
<span className="text-lg font-extrabold tabular-nums text-primary">{displayAvail.toLocaleString()}</span>
</div>
</div>
);
}
// ── 진행중 메트릭 ──
function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: {
inputQty: number; totalProd: number; goodQty: number; defectQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string;
}) {
return (
<div className="space-y-1.5">
{/* 메인 프로그레스 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${progressPct}%`, backgroundColor: statusColor }} />
</div>
<span className="text-[11px] font-extrabold tabular-nums" style={{ color: statusColor }}>{progressPct}%</span>
</div>
{/* 수량 4칸 */}
<div className="grid grid-cols-4 gap-1">
<MesMetricBox label="완료" value={totalProd} color="#3b82f6" />
<MesMetricBox label="양품" value={goodQty} color="#10b981" />
<MesMetricBox label="불량" value={defectQty} color="#ef4444" dimZero />
<MesMetricBox label="잔여" value={remainingQty} color={remainingQty > 0 ? "#f59e0b" : "#10b981"} />
</div>
{availableQty > 0 && (
<div className="text-right text-[9px] text-muted-foreground">
<b className="text-violet-600">{availableQty.toLocaleString()}</b>
</div>
)}
</div>
);
}
// ── 완료 메트릭 ──
function MesCompletedMetrics({ instrQty, goodQty, defectQty, yieldRate }: {
instrQty: number; goodQty: number; defectQty: number; yieldRate: number;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-3 text-[10px]">
<span className="text-muted-foreground"> <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
<span className="text-muted-foreground"> <b className="text-emerald-600">{goodQty.toLocaleString()}</b></span>
<span className="ml-auto text-muted-foreground"> <b style={{ color: yieldRate >= 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444" }}>{yieldRate}%</b></span>
</div>
{defectQty > 0 && (
<div className="text-[9px] text-muted-foreground"> <b className="text-destructive">{defectQty.toLocaleString()}</b></div>
)}
</div>
);
}
// ── 메트릭 박스 ──
function MesMetricBox({ label, value, color, dimZero = false }: {
label: string; value: number; color: string; dimZero?: boolean;
}) {
const isDim = dimZero && value === 0;
return (
<div className={cn("flex flex-col items-center rounded px-1 py-0.5", isDim && "opacity-40")}
style={{ backgroundColor: `${color}08` }}>
<span className="text-[8px] text-muted-foreground">{label}</span>
<span className="text-[11px] font-bold tabular-nums" style={{ color }}>{value.toLocaleString()}</span>
</div>
);
}

View File

@ -229,6 +229,8 @@ export function NumberInputModal({
<DialogOverlay />
<DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<VisuallyHidden><DialogTitle> </DialogTitle></VisuallyHidden>
{/* 헤더 */}

View File

@ -25,6 +25,7 @@ export function PopStatusBarComponent({
const [selectedValue, setSelectedValue] = useState<string>("");
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
const [originalCount, setOriginalCount] = useState<number | null>(null);
// all_rows 이벤트 구독
useEffect(() => {
@ -47,13 +48,16 @@ export function PopStatusBarComponent({
const envelope = inner as {
rows?: unknown;
subStatusColumn?: string | null;
originalCount?: number;
};
if (Array.isArray(envelope.rows))
setAllRows(envelope.rows as Record<string, unknown>[]);
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
setOriginalCount(envelope.originalCount ?? null);
} else if (Array.isArray(inner)) {
setAllRows(inner as Record<string, unknown>[]);
setAutoSubStatusColumn(null);
setOriginalCount(null);
}
}
);
@ -130,7 +134,7 @@ export function PopStatusBarComponent({
return map;
}, [allRows, effectiveCountColumn, showCount]);
const totalCount = allRows.length;
const totalCount = originalCount ?? allRows.length;
const chipItems = useMemo(() => {
const items: { value: string; label: string; count: number }[] = [];

View File

@ -3,19 +3,21 @@
import React, { useEffect, useState, useMemo, useCallback, useRef } from "react";
import {
Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package,
ChevronLeft, ChevronRight, Check, X, CircleDot,
ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList,
Plus, Trash2, Save, FileCheck,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { dataApi } from "@/lib/api/data";
import { apiClient } from "@/lib/api/client";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useAuth } from "@/hooks/useAuth";
import type { PopWorkDetailConfig } from "../types";
import type { PopWorkDetailConfig, ResultSectionConfig } from "../types";
import type { TimelineProcessStep } from "../types";
// ========================================
@ -86,6 +88,26 @@ interface ProcessTimerData {
status: string;
good_qty: string | null;
defect_qty: string | null;
total_production_qty: string | null;
defect_detail: string | null;
result_note: string | null;
result_status: string | null;
input_qty: string | null;
}
interface DefectDetailEntry {
defect_code: string;
defect_name: string;
qty: string;
disposition: string;
}
interface DefectTypeOption {
id: string;
defect_code: string;
defect_name: string;
defect_type: string;
severity: string;
}
const DEFAULT_INFO_FIELDS = [
@ -163,6 +185,13 @@ export function PopWorkDetailComponent({
const [currentItemIdx, setCurrentItemIdx] = useState(0);
const [showQuantityPanel, setShowQuantityPanel] = useState(false);
// 실적 입력 탭 상태
const [resultTabActive, setResultTabActive] = useState(false);
const hasResultSections = !!(cfg.resultSections && cfg.resultSections.some((s) => s.enabled));
// 불량 유형 목록 (부모에서 1회 로드, ResultPanel에 전달)
const [cachedDefectTypes, setCachedDefectTypes] = useState<DefectTypeOption[]>([]);
const contentRef = useRef<HTMLDivElement>(null);
// ========================================
@ -204,6 +233,18 @@ export function PopWorkDetailComponent({
fetchData();
}, [fetchData]);
useEffect(() => {
const loadDefectTypes = async () => {
try {
const res = await apiClient.get("/pop/production/defect-types");
if (res.data?.success) {
setCachedDefectTypes(res.data.data || []);
}
} catch { /* 실패 시 빈 배열 유지 */ }
};
loadDefectTypes();
}, []);
// ========================================
// 좌측 사이드바 - 작업항목 그룹핑
// ========================================
@ -260,8 +301,10 @@ export function PopWorkDetailComponent({
useEffect(() => {
if (groups.length > 0 && !selectedGroupId) {
setSelectedGroupId(groups[0].itemId);
} else if (groups.length === 0 && hasResultSections && !resultTabActive) {
setResultTabActive(true);
}
}, [groups, selectedGroupId]);
}, [groups, selectedGroupId, hasResultSections, resultTabActive]);
// 현재 선택 인덱스
const selectedIndex = useMemo(
@ -446,10 +489,6 @@ export function PopWorkDetailComponent({
// 단계 시작/활성화
// ========================================
const handleStepStart = useCallback((itemId: string) => {
setActiveStepIds((prev) => new Set(prev).add(itemId));
}, []);
const isStepLocked = useMemo(() => {
if (!cfg.stepControl.requireStartBeforeInput) return false;
if (!selectedGroupId) return true;
@ -470,19 +509,7 @@ export function PopWorkDetailComponent({
return () => clearInterval(id);
}, [cfg.showTimer, processData?.started_at, groups]);
const elapsedMs = useMemo(() => {
if (!processData?.started_at) return 0;
const now = tick;
const totalMs = now - new Date(processData.started_at).getTime();
const pausedSec = parseInt(processData.total_paused_time || "0", 10);
const currentPauseMs = processData.paused_at
? now - new Date(processData.paused_at).getTime()
: 0;
return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs);
}, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]);
// formattedTime은 제거 - 그룹별 타이머로 대체됨
// isPaused, isStarted는 프로세스 레벨 (사용하지 않으나 processData 참조용으로 유지)
// 프로세스 레벨 타이머는 그룹별 타이머로 대체됨
// ========================================
// 그룹별 타이머
@ -639,7 +666,9 @@ export function PopWorkDetailComponent({
);
}
if (allResults.length === 0) {
const isProcessCompleted = processData?.status === "completed";
if (allResults.length === 0 && !hasResultSections) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<AlertCircle className="mr-2 h-4 w-4" />
@ -647,8 +676,6 @@ export function PopWorkDetailComponent({
</div>
);
}
const isProcessCompleted = processData?.status === "completed";
const selectedGroup = groups.find((g) => g.itemId === selectedGroupId);
// ========================================
@ -691,6 +718,7 @@ export function PopWorkDetailComponent({
)}
onClick={() => {
setSelectedGroupId(g.itemId);
setResultTabActive(false);
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
>
@ -714,10 +742,59 @@ export function PopWorkDetailComponent({
</div>
);
})}
{/* 실적 입력 탭 (resultSections가 설정된 경우만) */}
{cfg.resultSections && cfg.resultSections.some((s) => s.enabled) && (
<>
<div className="mx-3 my-2 border-t" />
<button
className={cn(
"flex w-full items-center gap-2.5 px-4 py-3 text-left transition-colors",
resultTabActive
? "bg-primary/10 text-primary border-l-2 border-primary"
: "hover:bg-muted/60"
)}
onClick={() => {
setResultTabActive(true);
setSelectedGroupId(null);
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
>
<ClipboardList className="h-4 w-4 shrink-0" />
<div className="flex-1 min-w-0">
<span className="block truncate text-sm font-medium leading-tight">
</span>
<span className="text-xs text-muted-foreground">
{processData?.result_status === "confirmed" ? "확정됨" : "미확정"}
</span>
</div>
</button>
</>
)}
</div>
{/* 우측 콘텐츠 */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* 실적 입력 패널 (hidden으로 상태 유지, unmount 방지) */}
{hasResultSections && (
<div className={cn("flex flex-1 flex-col overflow-hidden", !resultTabActive && "hidden")}>
<ResultPanel
workOrderProcessId={workOrderProcessId!}
processData={processData}
sections={cfg.resultSections ?? []}
isProcessCompleted={isProcessCompleted}
defectTypes={cachedDefectTypes}
onSaved={(updated) => {
setProcessData((prev) => prev ? { ...prev, ...updated } : prev);
publish("process_completed", { workOrderProcessId, status: updated?.status });
}}
/>
</div>
)}
{/* 체크리스트 영역 */}
<div className={cn("flex flex-1 flex-col overflow-hidden", resultTabActive && "hidden")}>
{cfg.displayMode === "step" ? (
/* ======== 스텝 모드 ======== */
<>
@ -727,7 +804,7 @@ export function PopWorkDetailComponent({
<CheckCircle2 className="h-12 w-12 text-green-600" />
<p className="text-base font-medium"> </p>
{cfg.showQuantityInput && !isProcessCompleted && (
{cfg.showQuantityInput && !isProcessCompleted && !hasResultSections && (
<div className="w-full max-w-sm space-y-4 rounded-lg border p-5">
<p className="text-sm font-semibold"> </p>
<div className="space-y-3">
@ -906,7 +983,7 @@ export function PopWorkDetailComponent({
{/* 하단 네비게이션 + 수량/완료 */}
<div className="border-t">
{cfg.showQuantityInput && (
{cfg.showQuantityInput && !hasResultSections && (
<div className="flex items-center gap-3 px-4 py-2.5">
<Package className="h-4.5 w-4.5 shrink-0 text-muted-foreground" />
<div className="flex items-center gap-2">
@ -965,12 +1042,497 @@ export function PopWorkDetailComponent({
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
// ========================================
// 실적 입력 패널 (분할 실적 누적 방식)
// ========================================
const DISPOSITION_OPTIONS = [
{ value: "scrap", label: "폐기" },
{ value: "rework", label: "재작업" },
{ value: "downgrade", label: "등급하향" },
{ value: "return", label: "반품" },
{ value: "accept", label: "특채" },
];
interface BatchHistoryItem {
seq: number;
batch_qty: number;
batch_good: number;
batch_defect: number;
accumulated_total: number;
changed_at: string;
changed_by: string | null;
}
interface ResultPanelProps {
workOrderProcessId: string;
processData: ProcessTimerData | null;
sections: ResultSectionConfig[];
isProcessCompleted: boolean;
defectTypes: DefectTypeOption[];
onSaved: (updated: Partial<ProcessTimerData>) => void;
}
function ResultPanel({
workOrderProcessId,
processData,
sections,
isProcessCompleted,
defectTypes,
onSaved,
}: ResultPanelProps) {
// 이번 차수 입력값 (누적치가 아닌 이번에 생산한 수량)
const [batchQty, setBatchQty] = useState("");
const [batchDefect, setBatchDefect] = useState("");
const [resultNote, setResultNote] = useState("");
const [defectEntries, setDefectEntries] = useState<DefectDetailEntry[]>([]);
const [saving, setSaving] = useState(false);
const [confirming, setConfirming] = useState(false);
const [history, setHistory] = useState<BatchHistoryItem[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [availableInfo, setAvailableInfo] = useState<{
prevGoodQty: number;
availableQty: number;
instructionQty: number;
} | null>(null);
const isConfirmed = processData?.result_status === "confirmed";
const inputQty = parseInt(processData?.input_qty ?? "0", 10) || 0;
const accumulatedTotal = parseInt(processData?.total_production_qty ?? "0", 10) || 0;
const accumulatedGood = parseInt(processData?.good_qty ?? "0", 10) || 0;
const accumulatedDefect = parseInt(processData?.defect_qty ?? "0", 10) || 0;
const remainingQty = Math.max(0, inputQty - accumulatedTotal);
const batchGood = useMemo(() => {
const production = parseInt(batchQty, 10) || 0;
const defect = parseInt(batchDefect, 10) || 0;
return Math.max(0, production - defect);
}, [batchQty, batchDefect]);
const totalDefectFromEntries = useMemo(
() => defectEntries.reduce((sum, e) => sum + (parseInt(e.qty, 10) || 0), 0),
[defectEntries]
);
useEffect(() => {
if (totalDefectFromEntries > 0) {
setBatchDefect(String(totalDefectFromEntries));
}
}, [totalDefectFromEntries]);
const enabledSections = sections.filter((s) => s.enabled);
// 접수가능량 로드
const loadAvailableQty = useCallback(async () => {
try {
const res = await apiClient.get("/pop/production/available-qty", {
params: { work_order_process_id: workOrderProcessId },
});
if (res.data?.success) {
setAvailableInfo(res.data.data);
}
} catch { /* ignore */ }
}, [workOrderProcessId]);
useEffect(() => {
loadAvailableQty();
}, [loadAvailableQty]);
// 이력 로드
const loadHistory = useCallback(async () => {
setHistoryLoading(true);
try {
const res = await apiClient.get("/pop/production/result-history", {
params: { work_order_process_id: workOrderProcessId },
});
if (res.data?.success) {
setHistory(res.data.data || []);
}
} catch { /* 실패 시 빈 배열 유지 */ }
finally { setHistoryLoading(false); }
}, [workOrderProcessId]);
useEffect(() => {
loadHistory();
}, [loadHistory]);
const addDefectEntry = () => {
setDefectEntries((prev) => [
...prev,
{ defect_code: "", defect_name: "", qty: "", disposition: "scrap" },
]);
};
const removeDefectEntry = (idx: number) => {
setDefectEntries((prev) => prev.filter((_, i) => i !== idx));
};
const updateDefectEntry = (idx: number, field: keyof DefectDetailEntry, value: string) => {
setDefectEntries((prev) =>
prev.map((e, i) => {
if (i !== idx) return e;
const updated = { ...e, [field]: value };
if (field === "defect_code") {
const found = defectTypes.find((dt) => dt.defect_code === value);
if (found) updated.defect_name = found.defect_name;
}
return updated;
})
);
};
const resetForm = () => {
setBatchQty("");
setBatchDefect("");
setResultNote("");
setDefectEntries([]);
};
const handleSubmitBatch = async () => {
if (!batchQty || parseInt(batchQty, 10) <= 0) {
toast.error("생산수량을 입력해주세요.");
return;
}
const batchNum = parseInt(batchQty, 10);
if (inputQty > 0 && (accumulatedTotal + batchNum) > inputQty) {
toast.error(`생산수량(${batchNum})이 잔여량(${remainingQty})을 초과합니다.`);
return;
}
setSaving(true);
try {
const res = await apiClient.post("/pop/production/save-result", {
work_order_process_id: workOrderProcessId,
production_qty: batchQty,
good_qty: String(batchGood),
defect_qty: batchDefect || "0",
defect_detail: defectEntries.length > 0 ? defectEntries : null,
result_note: resultNote || null,
});
if (res.data?.success) {
const savedData = res.data.data;
if (savedData?.status === "completed") {
toast.success("모든 수량이 완료되어 자동 확정되었습니다.");
} else {
toast.success(`${batchQty}개 실적이 등록되었습니다.`);
}
onSaved(savedData);
resetForm();
loadHistory();
loadAvailableQty();
} else {
toast.error(res.data?.message || "실적 등록에 실패했습니다.");
}
} catch {
toast.error("실적 등록 중 오류가 발생했습니다.");
} finally {
setSaving(false);
}
};
const handleConfirm = async () => {
if (accumulatedTotal <= 0) {
toast.error("등록된 실적이 없습니다.");
return;
}
setConfirming(true);
try {
const res = await apiClient.post("/pop/production/confirm-result", {
work_order_process_id: workOrderProcessId,
});
if (res.data?.success) {
toast.success("실적이 확정되었습니다.");
onSaved({ ...res.data.data, result_status: "confirmed" });
} else {
toast.error(res.data?.message || "실적 확정에 실패했습니다.");
}
} catch {
toast.error("실적 확정 중 오류가 발생했습니다.");
} finally {
setConfirming(false);
}
};
return (
<div className="flex flex-1 flex-col overflow-y-auto">
<div className="space-y-5 p-4">
{/* 확정 상태 배너 */}
{isConfirmed && (
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-2.5">
<FileCheck className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium text-green-700"> </span>
</div>
)}
{/* 공정 현황: 접수량 / 작업완료 / 잔여 + 앞공정 완료량 */}
<div className="rounded-lg border bg-muted/20 px-4 py-3">
<div className="mb-2 text-xs font-semibold uppercase text-muted-foreground"> </div>
<div className="flex items-center gap-5">
<div className="text-center">
<div className="text-lg font-bold">{inputQty}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-blue-600">{accumulatedTotal}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center">
<div className={`text-lg font-bold ${remainingQty > 0 ? "text-amber-600" : "text-green-600"}`}>
{remainingQty}
</div>
<div className="text-xs text-muted-foreground"></div>
</div>
{availableInfo && availableInfo.availableQty > 0 && (
<div className="text-center">
<div className="text-lg font-bold text-violet-600">{availableInfo.availableQty}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
)}
</div>
{inputQty > 0 && (
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${Math.min(100, (accumulatedTotal / inputQty) * 100)}%` }}
/>
</div>
)}
{availableInfo && (
<div className="mt-2 flex gap-3 text-xs text-muted-foreground">
<span> : {availableInfo.prevGoodQty}</span>
<span>: {availableInfo.instructionQty}</span>
</div>
)}
</div>
{/* 누적 실적 현황 */}
<div className="rounded-lg border bg-muted/20 px-4 py-3">
<div className="mb-2 text-xs font-semibold uppercase text-muted-foreground"> </div>
<div className="flex items-center gap-5">
<div className="text-center">
<div className="text-lg font-bold">{accumulatedTotal}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-green-600">{accumulatedGood}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-red-600">{accumulatedDefect}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
<div className="text-center">
<div className="text-lg font-bold">{history.length}</div>
<div className="text-xs text-muted-foreground"></div>
</div>
</div>
{accumulatedTotal > 0 && (
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${accumulatedTotal > 0 ? (accumulatedGood / accumulatedTotal) * 100 : 0}%` }}
/>
</div>
)}
</div>
{/* 이번 차수 실적 입력 */}
{!isConfirmed && (
<div className="space-y-4">
<div className="text-sm font-semibold"> </div>
{/* 생산수량 */}
{enabledSections.some((s) => s.type === "total-qty") && (
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<div className="flex items-center gap-3">
<Input
type="number"
className="h-11 w-40 text-base"
value={batchQty}
onChange={(e) => setBatchQty(e.target.value)}
placeholder="0"
/>
<span className="text-sm text-muted-foreground">EA</span>
</div>
</div>
)}
{/* 양품/불량 */}
{enabledSections.some((s) => s.type === "good-defect") && (
<div className="space-y-2">
<label className="text-sm font-medium"> / </label>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Input
type="number"
className="h-11 w-28 bg-muted/50 text-base"
value={batchGood > 0 ? String(batchGood) : ""}
readOnly
placeholder="자동"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Input
type="number"
className="h-11 w-28 text-base"
value={batchDefect}
onChange={(e) => setBatchDefect(e.target.value)}
placeholder="0"
/>
</div>
</div>
{(parseInt(batchQty, 10) || 0) > 0 && (
<p className="text-xs text-muted-foreground">
{batchGood} = {batchQty} - {batchDefect || 0}
</p>
)}
</div>
)}
{/* 불량 유형 상세 */}
{enabledSections.some((s) => s.type === "defect-types") && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium"> </label>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={addDefectEntry}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{defectEntries.length === 0 ? (
<p className="text-sm text-muted-foreground"> .</p>
) : (
<div className="space-y-2">
{defectEntries.map((entry, idx) => (
<div key={idx} className="flex items-center gap-2 rounded-lg border p-2.5">
<select
className="h-10 rounded-md border px-2 text-sm"
value={entry.defect_code}
onChange={(e) => updateDefectEntry(idx, "defect_code", e.target.value)}
>
<option value=""> </option>
{defectTypes.map((dt) => (
<option key={dt.defect_code} value={dt.defect_code}>
{dt.defect_name} ({dt.defect_code})
</option>
))}
</select>
<Input
type="number"
className="h-10 w-20 text-sm"
value={entry.qty}
onChange={(e) => updateDefectEntry(idx, "qty", e.target.value)}
placeholder="수량"
/>
<select
className="h-10 rounded-md border px-2 text-sm"
value={entry.disposition}
onChange={(e) => updateDefectEntry(idx, "disposition", e.target.value)}
>
{DISPOSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<Button size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={() => removeDefectEntry(idx)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* 비고 */}
{enabledSections.some((s) => s.type === "note") && (
<div className="space-y-2">
<label className="text-sm font-medium"></label>
<Textarea
className="min-h-[60px] text-sm"
value={resultNote}
onChange={(e) => setResultNote(e.target.value)}
placeholder="작업 내용, 특이사항 등"
/>
</div>
)}
{/* 등록 버튼 */}
<div className="flex items-center gap-3">
<Button
className="h-11 gap-2 px-6 text-sm"
onClick={handleSubmitBatch}
disabled={saving || !batchQty}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
</div>
)}
{/* 이전 실적 이력 */}
<div className="space-y-2">
<div className="text-sm font-semibold"> </div>
{historyLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : history.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground"> .</p>
) : (
<div className="space-y-1.5">
{[...history].reverse().map((h) => (
<div key={h.seq} className="flex items-center justify-between rounded-lg border px-3 py-2">
<div className="flex items-center gap-3">
<Badge variant="secondary" className="text-xs px-2">#{h.seq}</Badge>
<span className="text-sm font-medium">+{h.batch_qty}</span>
<span className="text-xs text-green-600"> +{h.batch_good}</span>
{h.batch_defect > 0 && (
<span className="text-xs text-red-600"> +{h.batch_defect}</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span> {h.accumulated_total}</span>
<span>{new Date(h.changed_at).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 하단: 전체 확정 버튼 - 잔여량이 0일 때만 활성화 */}
{!isConfirmed && accumulatedTotal > 0 && (
<div className="mt-auto flex items-center gap-3 border-t px-4 py-3">
<Button
className="h-11 gap-2 px-6 text-sm"
onClick={handleConfirm}
disabled={confirming || remainingQty > 0}
>
{confirming ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileCheck className="h-4 w-4" />}
( {accumulatedTotal})
</Button>
{remainingQty > 0 && (
<span className="text-xs text-muted-foreground">
{remainingQty}
</span>
)}
</div>
)}
</div>
);
}
// ========================================
// 유틸리티
// ========================================

View File

@ -741,7 +741,9 @@ export type CardCellType =
| "status-badge"
| "timeline"
| "action-buttons"
| "footer-status";
| "footer-status"
| "process-qty-summary"
| "mes-process-card";
// timeline 셀에서 사용하는 하위 단계 데이터
export interface TimelineProcessStep {
@ -752,6 +754,12 @@ export interface TimelineProcessStep {
isCurrent: boolean;
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
// 수량 필드 (process-flow-summary 셀용)
inputQty?: number; // 접수량
totalProductionQty?: number; // 총생산량
goodQty?: number; // 양품
defectQty?: number; // 불량
yieldRate?: number; // 수율 (양품/총생산*100)
}
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
@ -846,6 +854,9 @@ export interface CardCellDefinitionV2 {
footerStatusColumn?: string;
footerStatusMap?: Array<{ value: string; label: string; color: string }>;
showTopBorder?: boolean;
// process-qty-summary 타입 전용 - 공정별 수량 흐름 요약
qtyDisplayMode?: "current" | "flow"; // current: 현재 공정만, flow: 전체 공정 흐름
}
export interface ActionButtonUpdate {
@ -1026,6 +1037,23 @@ export interface WorkDetailNavigationConfig {
showCompleteButton: boolean;
}
export type ResultSectionType =
| "total-qty"
| "good-defect"
| "defect-types"
| "note"
| "box-packing"
| "label-print"
| "photo"
| "document";
export interface ResultSectionConfig {
id: string;
type: ResultSectionType;
enabled: boolean;
showCondition?: { type: "always" | "last-process" };
}
export interface PopWorkDetailConfig {
showTimer: boolean;
/** @deprecated result-input 타입으로 대체 */
@ -1036,4 +1064,5 @@ export interface PopWorkDetailConfig {
infoBar: WorkDetailInfoBarConfig;
stepControl: WorkDetailStepControl;
navigation: WorkDetailNavigationConfig;
resultSections?: ResultSectionConfig[];
}