feat: BLOCK MES-HARDEN Phase 0~3 + 공정 흐름 스트립 필/칩 UI 개편

MES 불량 처분 체계(disposition 3종)를 구현하고, 공정 카드의 흐름
스트립을 현재 공정 중심 필/칩 윈도우로 전면 재설계한다.
[Phase 0: 기반 안정화]
- confirmResult: SUM 버그 수정 + 마스터 캐스케이드 완료 판정
- checkAndCompleteWorkInstruction: 헬퍼 함수 추출
  (saveResult/confirmResult 양쪽에서 work_instruction 상태 갱신)
- saveResult: 초과 생산 에러 -> 경고 로그로 변경
[Phase 1: UI 정리]
- DISPOSITION_OPTIONS: 5종 -> 3종(폐기/재작업/특채)
- 카드 수동 완료 버튼: in_progress + 생산 있음 + 미완료 시 표시
  (__manualComplete -> confirmResult 호출)
[Phase 2: 양품 계산 서버화]
- concession_qty/is_rework/rework_source_id DB 컬럼 추가
- saveResult: defect_detail disposition별 서버 양품 계산
  (addGood = addProduction - addDefect, addConcession 분리)
- prevGoodQty 5곳: SUM(good_qty) + SUM(concession_qty) 통일
- 프론트 특채 표시: MesInProgressMetrics/MesCompletedMetrics
[Phase 3: 재작업 카드]
- saveResult: disposition=rework 시 동일 공정에 분할행 자동 INSERT
  (is_rework='Y', rework_source_id 연결, status='acceptable')
- 프론트: amber "재작업" 배지 + MesAcceptableMetrics 재작업 전용 UI
- 재작업 카드 접수가능 수량 버그 수정 (마스터 qty -> input_qty)
[공정 흐름 스트립 UI 개편]
- ProcessFlowStrip: 바 형태 -> 필/칩 5슬롯 윈도우
  (+N/이전/현재/다음/+N, 현재 공정 항상 중앙)
- 색상: 지나온=emerald(완료)/slate, 현재=primary,
  완료=emerald, 대기=muted, 남은=amber
This commit is contained in:
SeongHyun Kim 2026-03-18 16:38:22 +09:00
parent fba5390f5a
commit 9d164d08af
5 changed files with 371 additions and 144 deletions

1
.gitignore vendored
View File

@ -182,3 +182,4 @@ scripts/browser-test-*.js
# 개인 작업 문서
popdocs/
.cursor/rules/popdocs-safety.mdc
.cursor/rules/overtime-registration.mdc

View File

@ -565,7 +565,8 @@ export const saveResult = async (
const statusCheck = await pool.query(
`SELECT wop.status, wop.result_status, wop.total_production_qty, wop.good_qty,
wop.defect_qty, wop.input_qty, wop.parent_process_id, wop.wo_id, wop.seq_no
wop.defect_qty, wop.concession_qty, wop.defect_detail,
wop.input_qty, wop.parent_process_id, wop.wo_id, wop.seq_no
FROM work_order_process wop
WHERE wop.id = $1 AND wop.company_code = $2`,
[work_order_process_id, companyCode]
@ -602,17 +603,23 @@ export const saveResult = async (
});
}
// 실적 누적이 접수량을 초과하지 않도록 검증
// 초과 생산 경고 (차단하지 않음 - 현장 유연성)
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})을 초과합니다. 추가 접수 후 등록해주세요.`,
logger.warn("[pop/production] 초과 생산 감지", {
work_order_process_id,
prevTotal, requestedQty, acceptedQty,
overAmount: (prevTotal + requestedQty) - acceptedQty,
});
}
// 서버 측 양품/불량/특채 계산 (클라이언트 good_qty는 참고만)
const addProduction = parseInt(production_qty, 10) || 0;
let addDefect = 0;
let addConcession = 0;
let defectDetailStr: string | null = null;
if (defect_detail && Array.isArray(defect_detail)) {
const validated = defect_detail.map((item: DefectDetailItem) => ({
@ -622,15 +629,23 @@ export const saveResult = async (
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;
for (const item of validated) {
const itemQty = parseInt(item.qty, 10) || 0;
addDefect += itemQty;
if (item.disposition === "accept") {
addConcession += itemQty;
}
}
} else {
addDefect = parseInt(defect_qty, 10) || 0;
}
const addGood = addProduction - addDefect;
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;
const newConcession = (parseInt(prev.concession_qty, 10) || 0) + addConcession;
// 기존 defect_detail에 이번 차수 상세를 병합
let mergedDefectDetail: string | null = null;
@ -640,7 +655,6 @@ export const saveResult = async (
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(
@ -662,6 +676,7 @@ export const saveResult = async (
SET total_production_qty = $3,
good_qty = $4,
defect_qty = $5,
concession_qty = $9,
defect_detail = COALESCE($6, defect_detail),
result_note = COALESCE($7, result_note),
result_status = 'draft',
@ -669,7 +684,7 @@ export const saveResult = async (
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`,
RETURNING id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status`,
[
work_order_process_id,
companyCode,
@ -679,6 +694,7 @@ export const saveResult = async (
mergedDefectDetail,
result_note || null,
userId,
String(newConcession),
]
);
@ -692,7 +708,9 @@ export const saveResult = async (
// 현재 분할 행의 공정 정보 조회
const currentSeq = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty,
wop.parent_process_id,
wop.parent_process_id, wop.process_code, wop.process_name,
wop.is_required, wop.is_fixed_order, wop.standard_time,
wop.equipment_code, wop.routing_detail_id,
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
@ -700,6 +718,46 @@ export const saveResult = async (
[work_order_process_id, companyCode]
);
// 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때)
if (currentSeq.rowCount > 0 && defect_detail && Array.isArray(defect_detail)) {
let totalReworkQty = 0;
for (const item of defect_detail) {
if (item.disposition === "rework") {
totalReworkQty += parseInt(item.qty, 10) || 0;
}
}
if (totalReworkQty > 0) {
const proc = currentSeq.rows[0];
const masterId = proc.parent_process_id || work_order_process_id;
const reworkInsert = await pool.query(
`INSERT INTO work_order_process (
wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
standard_time, equipment_code, routing_detail_id,
status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
result_status, is_rework, rework_source_id,
parent_process_id, company_code, writer
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
'acceptable', $10, '0', '0', '0', '0',
'draft', 'Y', $11,
$12, $13, $14
) RETURNING id`,
[
proc.wo_id, proc.seq_no, proc.process_code, proc.process_name,
proc.is_required, proc.is_fixed_order, proc.standard_time,
proc.equipment_code, proc.routing_detail_id,
String(totalReworkQty), work_order_process_id,
masterId, companyCode, userId,
]
);
logger.info("[pop/production] 재작업 카드 자동 생성", {
reworkId: reworkInsert.rows[0]?.id,
sourceId: work_order_process_id,
reworkQty: totalReworkQty,
});
}
}
// 다음 공정 활성화: 양품 발생 시 다음 seq_no의 원본(마스터) 행을 acceptable로
// waiting -> acceptable (최초 활성화)
// in_progress -> acceptable (앞공정 추가양품으로 접수가능량 복원)
@ -753,7 +811,7 @@ export const saveResult = async (
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) as total_good
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[wo_id, prevSeq, companyCode]
@ -799,6 +857,10 @@ export const saveResult = async (
}
}
}
// 작업지시 전체 완료 판정
const { wo_id: woIdForWi } = currentSeq.rows[0];
await checkAndCompleteWorkInstruction(pool, woIdForWi, companyCode, userId);
}
logger.info("[pop/production] save-result 완료 (누적)", {
@ -810,7 +872,7 @@ export const saveResult = async (
// 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음)
const latestData = await pool.query(
`SELECT id, total_production_qty, good_qty, defect_qty, defect_detail, result_note, result_status, status, input_qty
`SELECT id, total_production_qty, good_qty, defect_qty, concession_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]
);
@ -828,6 +890,63 @@ export const saveResult = async (
}
};
/**
* (work_instruction)
* completed이면
*/
const checkAndCompleteWorkInstruction = async (
pool: any,
woId: string,
companyCode: string,
userId: string
) => {
const maxSeqResult = await pool.query(
`SELECT MAX(seq_no::int) as max_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[woId, companyCode]
);
if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return;
const maxSeq = String(maxSeqResult.rows[0].max_seq);
const incompleteCheck = await pool.query(
`SELECT COUNT(*) as cnt
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND status != 'completed'`,
[woId, maxSeq, companyCode]
);
if (parseInt(incompleteCheck.rows[0].cnt, 10) > 0) return;
const totalGoodResult = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[woId, maxSeq, companyCode]
);
const completedQty = totalGoodResult.rows[0].total_good;
await pool.query(
`UPDATE work_instruction
SET status = 'completed',
progress_status = 'completed',
completed_qty = $3,
writer = $4,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
AND status != 'completed'`,
[woId, companyCode, String(completedQty), userId]
);
logger.info("[pop/production] 작업지시 전체 완료", {
woId, completedQty, companyCode,
});
};
/**
* .
* . save-result에서 .
@ -874,58 +993,18 @@ export const confirmResult = async (
});
}
// 잔여 접수가능량 계산하여 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 여부와 관계없이 completed 처리
// 자동 완료가 안 된 경우 관리자가 강제 완료할 때 사용
const newStatus = "completed";
const isCompleted = shouldComplete;
// 수동 확정: 무조건 completed 처리 (수동 완료 용도)
const result = await pool.query(
`UPDATE work_order_process
SET result_status = 'confirmed',
status = $4,
completed_at = CASE WHEN $5 THEN NOW()::text ELSE completed_at END,
completed_by = CASE WHEN $5 THEN $3 ELSE completed_by END,
status = 'completed',
completed_at = NOW()::text,
completed_by = $3,
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, isCompleted]
[work_order_process_id, companyCode, userId]
);
if (result.rowCount === 0) {
@ -935,25 +1014,92 @@ export const confirmResult = async (
});
}
// 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]
);
// 공정 정보 조회 (다음 공정 활성화 + 마스터 캐스케이드용)
const seqCheck = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.parent_process_id,
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 (seqCheck.rowCount > 0) {
const { seq_no, wo_id, parent_process_id, instruction_qty } = seqCheck.rows[0];
const seqNum = parseInt(seq_no, 10);
const instrQty = parseInt(instruction_qty, 10) || 0;
// 다음 공정 활성화 (양품이 있으면)
const goodQty = parseInt(result.rows[0].good_qty, 10) || 0;
if (goodQty > 0) {
const nextSeq = String(seqNum + 1);
await pool.query(
`UPDATE work_order_process
SET status = CASE WHEN status IN ('waiting', 'in_progress') THEN 'acceptable' ELSE status END,
updated_date = NOW()
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NULL
AND status != 'completed'`,
[wo_id, nextSeq, companyCode]
);
}
// 마스터 자동완료 캐스케이드 (분할 행인 경우)
if (parent_process_id) {
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
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].total_good;
}
}
const siblingCheck = await pool.query(
`SELECT
COALESCE(SUM(input_qty::int), 0) as total_input,
COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[wo_id, seq_no, companyCode]
);
const totalInput = siblingCheck.rows[0].total_input;
const incompleteCount = parseInt(siblingCheck.rows[0].incomplete_count, 10);
const remainingAcceptable = prevGoodQty - totalInput;
if (incompleteCount === 0 && remainingAcceptable <= 0) {
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'`,
[parent_process_id, companyCode, userId]
);
logger.info("[pop/production] confirmResult: 마스터 자동 완료", {
masterId: parent_process_id, totalInput, prevGoodQty,
});
}
}
// 작업지시 전체 완료 판정
await checkAndCompleteWorkInstruction(pool, wo_id, companyCode, userId);
}
logger.info("[pop/production] confirm-result 완료", {
companyCode,
work_order_process_id,
userId,
shouldComplete,
newStatus,
finalStatus: result.rows[0].status,
});
@ -1105,12 +1251,12 @@ export const getAvailableQty = async (req: AuthenticatedRequest, res: Response)
);
const myInputQty = totalAccepted.rows[0].total_input;
// 앞공정 양품 합산
// 앞공정 양품+특채 합산
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) as total_good
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[wo_id, prevSeq, companyCode]
@ -1215,12 +1361,12 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
);
const currentTotalInput = totalAccepted.rows[0].total_input;
// 앞공정 양품 합산
// 앞공정 양품+특채 합산
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) as total_good
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`,
[row.wo_id, prevSeq, companyCode]

View File

@ -977,6 +977,9 @@ export function PopCardListV2Component({
__process_total_production_qty: splitRow.total_production_qty,
__process_good_qty: splitRow.good_qty,
__process_defect_qty: splitRow.defect_qty,
__process_concession_qty: splitRow.concession_qty,
__process_is_rework: splitRow.is_rework,
__process_rework_source_id: splitRow.rework_source_id,
__process_result_status: splitRow.result_status,
__availableQty: current?.rawData?.__availableQty ?? 0,
__prevGoodQty: current?.rawData?.__prevGoodQty ?? 0,
@ -1748,6 +1751,26 @@ function CardV2({
const cfg = buttonConfig as Record<string, unknown> | undefined;
const processId = cfg?.__processId as string | number | undefined;
// 수동 완료 처리
if (taskPreset === "__manualComplete" && processId) {
if (!window.confirm("이 공정을 수동으로 완료 처리하시겠습니까? 현재 생산량으로 확정됩니다.")) return;
try {
const result = await apiClient.post("/pop/production/confirm-result", {
work_order_process_id: processId,
});
if (result.data?.success) {
toast.success("공정이 완료 처리되었습니다.");
onRefresh?.();
} else {
toast.error(result.data?.message || "완료 처리에 실패했습니다.");
}
} catch (err: unknown) {
const errMsg = (err as any)?.response?.data?.message;
toast.error(errMsg || "완료 처리 중 오류가 발생했습니다.");
}
return;
}
// 접수 취소 처리 (__cancelAccept 또는 "접수취소" 라벨 버튼)
if ((taskPreset === "__cancelAccept" || taskPreset === "접수취소") && processId) {
if (!window.confirm("접수를 취소하시겠습니까? 실적이 없는 경우에만 가능합니다.")) return;

View File

@ -10,7 +10,7 @@
import React, { useMemo, useState } from "react";
import {
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Loader2, CheckCircle2, CircleDot, Clock,
Loader2, CheckCircle2, CircleDot, Clock, Check, ChevronRight,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -1002,6 +1002,8 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
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 concessionQty = parseInt(String(row.__process_concession_qty ?? row.concession_qty ?? "0"), 10) || 0;
const isRework = String(row.__process_is_rework ?? row.is_rework ?? "N") === "Y";
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 ?? "");
@ -1027,6 +1029,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
const cancelBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "owner-match");
let activeBtn: ActionButtonDef | undefined;
let showManualComplete = false;
const isFullyProduced = inputQty > 0 && totalProd >= inputQty;
if (isClone) {
activeBtn = acceptBtn;
@ -1035,6 +1038,8 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
} else if (rawStatus === "in_progress") {
if (isFullyProduced) {
if (availableQty > 0) activeBtn = acceptBtn;
} else if (totalProd > 0) {
showManualComplete = true;
} else {
activeBtn = cancelBtn;
}
@ -1065,12 +1070,19 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
</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 className="ml-2 flex shrink-0 items-center gap-1">
{isRework && (
<span className="rounded bg-amber-500 px-1.5 py-0.5 text-[9px] font-bold text-white">
</span>
)}
<span
className="rounded px-2 py-0.5 text-[10px] font-bold"
style={{ backgroundColor: st.color, color: "#fff" }}
>
{st.label}
</span>
</div>
</div>
{/* ── 수량 메트릭 (상태별) ── */}
@ -1083,6 +1095,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
inputQty={inputQty}
isFirstProcess={isFirstProcess}
isClone={isClone}
isRework={isRework}
/>
)}
{rawStatus === "in_progress" && (
@ -1091,6 +1104,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
totalProd={totalProd}
goodQty={goodQty}
defectQty={defectQty}
concessionQty={concessionQty}
remainingQty={remainingQty}
progressPct={progressPct}
availableQty={availableQty}
@ -1103,6 +1117,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
instrQty={instrQty}
goodQty={goodQty}
defectQty={defectQty}
concessionQty={concessionQty}
yieldRate={yieldRate}
/>
)}
@ -1148,6 +1163,19 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
{activeBtn.label}
</Button>
)}
{showManualComplete && (
<Button
variant="outline"
size="sm"
className="h-7 px-3 text-[11px] font-semibold"
onClick={(e) => {
e.stopPropagation();
onActionButtonClick?.("__manualComplete", row, { __processId: processId });
}}
>
</Button>
)}
</div>
</div>
</div>
@ -1228,73 +1256,100 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
);
}
// ── 공정 흐름 스트립 (카드 내 표시) ──
// ── 공정 흐름 스트립 (5슬롯: 지나온 + 이전 + 현재 + 다음 + 남은) ──
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;
const prevStep = currentIdx > 0 ? steps[currentIdx - 1] : null;
const currStep = steps[currentIdx];
const nextStep = currentIdx < steps.length - 1 ? steps[currentIdx + 1] : null;
const hiddenBefore = currentIdx > 1 ? currentIdx - 1 : 0;
const hiddenAfter = currentIdx < steps.length - 2 ? steps.length - currentIdx - 2 : 0;
const allBeforeDone = hiddenBefore > 0 && steps.slice(0, currentIdx - 1).every(s => {
const sem = s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status] || "pending";
return sem === "done";
});
const renderChip = (step: TimelineProcessStep, isCurrent: boolean) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
return (
<span className={cn(
"inline-flex shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-[10px] font-medium whitespace-nowrap",
isCurrent
? "bg-primary text-primary-foreground shadow-sm"
: sem === "done"
? "bg-emerald-50 text-emerald-700"
: "bg-muted text-muted-foreground",
)}>
{sem === "done" && !isCurrent && <Check className="h-2.5 w-2.5" />}
{step.seqNo} {step.processName}
</span>
);
};
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);
<div className="flex items-center gap-1">
{hiddenBefore > 0 && (
<>
<span className={cn(
"inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 text-[10px] font-bold tabular-nums",
allBeforeDone
? "bg-emerald-100 text-emerald-600"
: "bg-slate-100 text-slate-500",
)}>
+{hiddenBefore}
</span>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
</>
)}
let barColor = "#e2e8f0";
if (sem === "done") barColor = "#10b981";
else if (sem === "active") barColor = "#3b82f6";
{prevStep && (
<>
{renderChip(prevStep, false)}
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
</>
)}
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>
{currStep && renderChip(currStep, true)}
{nextStep && (
<>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
{renderChip(nextStep, false)}
</>
)}
{hiddenAfter > 0 && (
<>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
<span className="inline-flex shrink-0 items-center rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold tabular-nums text-amber-600">
+{hiddenAfter}
</span>
</>
)}
</div>
);
}
// ── 접수가능 메트릭 ──
function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone }: {
instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean;
function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone, isRework }: {
instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean; isRework?: boolean;
}) {
if (isRework) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-3 text-[10px]">
<span className="text-amber-600 font-medium"> </span>
</div>
<div className="flex items-center justify-center rounded-md py-2" style={{ backgroundColor: "rgba(245,158,11,0.08)" }}>
<span className="text-[11px] text-muted-foreground"> &ensp;</span>
<span className="text-lg font-extrabold tabular-nums text-amber-600">{inputQty.toLocaleString()}</span>
</div>
</div>
);
}
const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty);
return (
<div className="space-y-1.5">
@ -1316,8 +1371,8 @@ function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, i
}
// ── 진행중 메트릭 ──
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;
function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, concessionQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: {
inputQty: number; totalProd: number; goodQty: number; defectQty: number; concessionQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string;
}) {
return (
<div className="space-y-1.5">
@ -1329,11 +1384,12 @@ function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remaini
</div>
<span className="text-[11px] font-extrabold tabular-nums" style={{ color: statusColor }}>{progressPct}%</span>
</div>
{/* 수량 4칸 */}
<div className="grid grid-cols-4 gap-1">
{/* 수량 메트릭 */}
<div className={cn("grid gap-1", concessionQty > 0 ? "grid-cols-5" : "grid-cols-4")}>
<MesMetricBox label="완료" value={totalProd} color="#3b82f6" />
<MesMetricBox label="양품" value={goodQty} color="#10b981" />
<MesMetricBox label="불량" value={defectQty} color="#ef4444" dimZero />
{concessionQty > 0 && <MesMetricBox label="특채" value={concessionQty} color="#8b5cf6" />}
<MesMetricBox label="잔여" value={remainingQty} color={remainingQty > 0 ? "#f59e0b" : "#10b981"} />
</div>
{availableQty > 0 && (
@ -1346,14 +1402,17 @@ function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remaini
}
// ── 완료 메트릭 ──
function MesCompletedMetrics({ instrQty, goodQty, defectQty, yieldRate }: {
instrQty: number; goodQty: number; defectQty: number; yieldRate: number;
function MesCompletedMetrics({ instrQty, goodQty, defectQty, concessionQty, yieldRate }: {
instrQty: number; goodQty: number; defectQty: number; concessionQty: 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>
{concessionQty > 0 && (
<span className="text-muted-foreground"> <b className="text-violet-600">{concessionQty.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 && (

View File

@ -1060,8 +1060,6 @@ export function PopWorkDetailComponent({
const DISPOSITION_OPTIONS = [
{ value: "scrap", label: "폐기" },
{ value: "rework", label: "재작업" },
{ value: "downgrade", label: "등급하향" },
{ value: "return", label: "반품" },
{ value: "accept", label: "특채" },
];