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:
parent
06c52b422f
commit
20fbe85c74
|
|
@ -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 || "접수 취소 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">접수가능 </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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{/* 헤더 */}
|
||||
|
|
|
|||
|
|
@ -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 }[] = [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 유틸리티
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue