+ {/* Drawer tab handle (left edge, middle) */}
+
+
+ {/* Drawer overlay */}
+ {drawerOpen && (
+
setDrawerOpen(false)}
+ />
+ )}
+
+ {/* Drawer panel */}
+
+
+
내 접수 목록
+
{myProcesses.length}건
+
+
+ {myProcesses.map((proc) => {
+ const wi = instructionMap[proc.wo_id];
+ const isActive = proc.id === processId;
+ return (
+
+ );
+ })}
+ {myProcesses.length === 0 && (
+
+ 접수한 작업이 없습니다
+
+ )}
+
+
+
+ {/* Close button */}
+
+
+ {/* ProcessWork content */}
+
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Compressed Process Steps (center-aligned) */
+/* ------------------------------------------------------------------ */
+
+function CompressedProcessSteps({
+ processes,
+ currentSeqNo,
+ status,
+ onClick,
+ batchId,
+ allProcesses,
+}: {
+ processes: WorkOrderProcess[];
+ currentSeqNo: string;
+ status: string;
+ onClick?: () => void;
+ batchId?: string;
+ allProcesses?: WorkOrderProcess[];
+}) {
+ const sorted = [...processes]
+ .filter((p) => !p.parent_process_id && (
+ // 같은 batch_id끼리만 표시 (다중 품목 구분)
+ (!batchId && !p.batch_id) ||
+ (batchId && p.batch_id === batchId)
+ ))
+ .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
+
+ if (sorted.length === 0) return null;
+
+ const currentIdx = sorted.findIndex((p) => p.seq_no === currentSeqNo);
+ if (currentIdx < 0) return null;
+
+ // For completed status: batch_id 기반 진행률 표시
+ if (status === "completed") {
+ // 같은 batch_id를 가진 SPLIT들이 어느 seq까지 완료했는지 추적
+ let maxCompletedSeq = parseInt(currentSeqNo, 10); // 최소한 현재 seq까지는 완료
+
+ if (batchId && allProcesses) {
+ const batchSplits = allProcesses.filter(
+ (p) =>
+ p.batch_id === batchId &&
+ p.parent_process_id &&
+ p.status === "completed",
+ );
+ for (const s of batchSplits) {
+ const sSeq = parseInt(s.seq_no, 10);
+ if (sSeq > maxCompletedSeq) maxCompletedSeq = sSeq;
+ }
+ }
+
+ const completedCount = sorted.filter(
+ (p) => parseInt(p.seq_no, 10) <= maxCompletedSeq,
+ ).length;
+ const allDone = completedCount === sorted.length;
+
+ return (
+
+ {sorted.map((proc, idx) => {
+ const seqNum = parseInt(proc.seq_no, 10);
+ const isDone = seqNum <= maxCompletedSeq;
+ return (
+
+ {idx > 0 && (
+
+ )}
+
+ {isDone ? "\u2713" : idx + 1}
+
+
+ );
+ })}
+
+ {allDone ? "전체 완료" : `${completedCount}/${sorted.length} 완료`}
+
+
+ );
+ }
+
+ // Compressed view: show prev, current, next + collapsed counts
+ const prevIdx = currentIdx - 1;
+ const nextIdx = currentIdx + 1;
+ const beforeCollapsed = prevIdx > 0 ? prevIdx : 0;
+ const afterCollapsed =
+ sorted.length - 1 - (nextIdx < sorted.length ? nextIdx : currentIdx);
+
+ // 공정명 축약 (4글자 초과 시 잘라내기)
+ const shortName = (name: string) => {
+ if (!name) return "?";
+ const clean = name.replace(/^제조반_/, "");
+ return clean.length > 4 ? clean.slice(0, 4) : clean;
+ };
+
+ const stepLabel = (proc: WorkOrderProcess, isCurrent: boolean) => {
+ const isCompleted = proc.status === "completed";
+ const isInProgress =
+ proc.status === "in_progress" || proc.status === "acceptable";
+
+ if (isCurrent) {
+ const bgColor = isInProgress
+ ? "bg-blue-500"
+ : isCompleted
+ ? "bg-green-500"
+ : "bg-amber-400";
+ const ringColor = isInProgress
+ ? "ring-blue-200"
+ : isCompleted
+ ? "ring-green-200"
+ : "ring-amber-200";
+ return (
+
+ {shortName(proc.process_name)}
+
+ );
+ }
+
+ if (isCompleted) {
+ return (
+
+ {shortName(proc.process_name)}
+
+ );
+ }
+
+ return (
+
+ {shortName(proc.process_name)}
+
+ );
+ };
+
+ const lineBetween = (
+ fromProc: WorkOrderProcess,
+ toProc: WorkOrderProcess,
+ ) => {
+ const fromDone = fromProc.status === "completed";
+ const toDone = toProc.status === "completed";
+ const color = fromDone || toDone ? "bg-green-400" : "bg-gray-200";
+ return
;
+ };
+
+ // 모든 상태에서 클릭 가능 (공정 상세 모달)
+ const isClickable = !!onClick;
+
+ return (
+
+ {/* Collapsed before */}
+ {beforeCollapsed > 0 && (
+ <>
+
+ +{beforeCollapsed}
+
+
+ >
+ )}
+
+ {/* Previous step */}
+ {prevIdx >= 0 && (
+ <>
+ {stepLabel(sorted[prevIdx], false)}
+
+ >
+ )}
+
+ {/* Current step */}
+ {stepLabel(sorted[currentIdx], true)}
+
+ {/* Next step */}
+ {nextIdx < sorted.length && (
+ <>
+
+ {stepLabel(sorted[nextIdx], false)}
+ >
+ )}
+
+ {/* Collapsed after */}
+ {afterCollapsed > 0 && (
+ <>
+
+
+ +{afterCollapsed}
+
+ >
+ )}
+
+ {/* Chevron for clickable */}
+ {isClickable && (
+
+ )}
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Card sub-components by status */
+/* ------------------------------------------------------------------ */
+
+/** Acceptable card body */
+function AcceptableCardBody({
+ planQty,
+ prevGoodQty,
+ availableQty,
+ reworkAvailableQty,
+}: {
+ planQty: number;
+ prevGoodQty: number | null;
+ availableQty: number;
+ reworkAvailableQty?: number;
+}) {
+ return (
+ <>
+
+
+
지시수량
+
+ {planQty.toLocaleString()}
+
+
+
+
전공정양품
+
+ {prevGoodQty !== null ? prevGoodQty.toLocaleString() : "-"}
+
+
+
+
접수가능
+
+ {availableQty.toLocaleString()}
+
+
+
+ {reworkAvailableQty && reworkAvailableQty > 0 ? (
+ reworkAvailableQty >= availableQty ? null : (
+
+
+ 리워크
+
+
+ 재작업 물량 {reworkAvailableQty}개 포함
+
+
+ )
+ ) : null}
+ >
+ );
+}
+
+/** In-progress card body */
+function InProgressCardBody({
+ inputQty,
+ goodQty,
+ defectQty,
+ remainQty,
+ progressPct,
+ additionalAvailable,
+}: {
+ inputQty: number;
+ goodQty: number;
+ defectQty: number;
+ remainQty: number;
+ progressPct: number;
+ additionalAvailable: number;
+}) {
+ return (
+ <>
+ {/* Progress bar */}
+
+
+ {/* 4-col qty grid */}
+
+
+
접수
+
+ {inputQty.toLocaleString()}
+
+
+
+
양품
+
+ {goodQty.toLocaleString()}
+
+
+
+
불량
+
+ {defectQty.toLocaleString()}
+
+
+
+
잔여
+
+ {remainQty.toLocaleString()}
+
+
+
+ {additionalAvailable > 0 && (
+
+ 추가접수가능 {additionalAvailable.toLocaleString()}
+
+ )}
+ >
+ );
+}
+
+/** Waiting card body */
+function WaitingCardBody({
+ planQty,
+ prevProcessName,
+ prevProgressPct,
+ currentProcessName,
+ currentSeqNo,
+}: {
+ planQty: number;
+ prevProcessName: string | null;
+ prevProgressPct: number | null;
+ currentProcessName: string;
+ currentSeqNo: number;
+}) {
+ return (
+ <>
+
+
+
지시수량
+
+ {planQty.toLocaleString()}
+
+
+
+
이전공정
+
+ {prevProcessName || "-"}
+
+ {prevProgressPct !== null && (
+
+ 진행중 ({prevProgressPct}%)
+
+ )}
+
+
+
현재공정
+
+ {currentProcessName}
+
+
+ {currentSeqNo}번째 공정
+
+
+
+
+ 이전공정 완료 시 접수 가능합니다
+
+ >
+ );
+}
+
+/** Completed card body */
+function CompletedCardBody({
+ goodQty,
+ defectQty,
+ planQty,
+ inputQty,
+}: {
+ goodQty: number;
+ defectQty: number;
+ planQty: number;
+ inputQty: number;
+}) {
+ return (
+
+
+
양품
+
+ {goodQty.toLocaleString()}
+
+
+
+
불량
+
+ {defectQty.toLocaleString()}
+
+
+
+
지시
+
+ {planQty.toLocaleString()}
+
+
+
+
접수
+
+ {inputQty.toLocaleString()}
+
+
+
+ );
+}
+
+/** Rework card body */
+function ReworkCardBody({
+ reworkQty,
+ availableQty,
+ originProcessName,
+ originProcessCode,
+ originDefectQty,
+ reworkRound,
+}: {
+ reworkQty: number;
+ availableQty: number;
+ originProcessName: string;
+ originProcessCode: string;
+ originDefectQty: number;
+ reworkRound: number;
+}) {
+ return (
+ <>
+ {/* Rework info summary */}
+
+
+ {originProcessName || originProcessCode}
+ →
+ 불량 {originDefectQty}개
+ →
+
+ 재작업 {reworkRound}회차
+
+
+
+
+
+
+
재작업수량
+
+ {reworkQty.toLocaleString()}
+
+
+
+
접수가능
+
+ {availableQty.toLocaleString()}
+
+
+
+ >
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Main Component */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Phase A-2b: 데이터/필터/열 상태는 page.tsx의 useProcessData에서 주입.
+ * 내부 fetchAll/syncAndFetch는 제거되고 refetch()로 대체 (sync 없는 재조회).
+ */
+export interface WorkOrderListProps {
+ instructions: WorkInstruction[];
+ allProcesses: WorkOrderProcess[];
+ processList: ProcessMng[];
+ equipmentList: EquipmentMng[];
+ itemNameMap: Record
;
+ itemTypeMap: Record;
+ loading: boolean;
+ selectedProcess: string;
+ selectedEquipment: string;
+ cardCols: 1 | 2 | 3;
+ refetch: () => void;
+}
+
+export function WorkOrderList(props: WorkOrderListProps) {
+ const {
+ instructions,
+ allProcesses,
+ processList,
+ equipmentList,
+ itemNameMap,
+ itemTypeMap,
+ loading,
+ selectedProcess,
+ selectedEquipment,
+ cardCols,
+ refetch,
+ } = props;
+ const router = useRouter();
+ const { user } = useAuth();
+ const currentUserId = user?.userId || "";
+
+ const [activeTab, setActiveTab] = useState("acceptable");
+
+ /* Accept Modal */
+ const [acceptModal, setAcceptModal] = useState<{
+ open: boolean;
+ processId: string;
+ processName: string;
+ seqNo: string;
+ maxQty: number;
+ reworkSourceId?: string;
+ }>({ open: false, processId: "", processName: "", seqNo: "", maxQty: 0 });
+ const [acceptLoading, setAcceptLoading] = useState(false);
+
+ const [cancelConfirm, setCancelConfirm] = useState<{
+ open: boolean;
+ processId: string;
+ }>({ open: false, processId: "" });
+
+ /* Process Detail Modal */
+ const [detailModal, setDetailModal] = useState<{
+ open: boolean;
+ wiNo: string;
+ totalQty: number;
+ steps: ProcessStep[];
+ woId?: string;
+ showReworkHistory?: boolean;
+ }>({ open: false, wiNo: "", totalQty: 0, steps: [] });
+
+ // Phase A-2b: 데이터 패칭은 useProcessData가 담당 — 내부 fetchAll/syncAndFetch 제거
+
+ /* ---- Lookup maps ---- */
+ const instructionMap = useMemo(() => {
+ const map: Record = {};
+ for (const wi of instructions) {
+ map[wi.id] = wi;
+ }
+ return map;
+ }, [instructions]);
+
+ const processesByWo = useMemo(() => {
+ const map: Record = {};
+ for (const proc of allProcesses) {
+ if (!proc.wo_id) continue;
+ if (!map[proc.wo_id]) map[proc.wo_id] = [];
+ map[proc.wo_id].push(proc);
+ }
+ return map;
+ }, [allProcesses]);
+
+ /** 다중품목 판단: wo_id별 DISTINCT batch_id 집합 + 순번 매핑 */
+ const multiBatchInfo = useMemo(() => {
+ // wo_id → 고유 batch_id 목록 (마스터 행 기준)
+ const woBatches: Record = {};
+ for (const proc of allProcesses) {
+ if (proc.parent_process_id) continue; // 마스터만
+ if (!proc.wo_id) continue;
+ if (!woBatches[proc.wo_id]) woBatches[proc.wo_id] = [];
+ const bid = proc.batch_id || "";
+ if (bid && !woBatches[proc.wo_id].includes(bid)) {
+ woBatches[proc.wo_id].push(bid);
+ }
+ }
+ // proc.id → { isMulti, index, total, itemType }
+ const info: Record = {};
+ for (const proc of allProcesses) {
+ if (!proc.wo_id) continue;
+ const batches = woBatches[proc.wo_id] || [];
+ const bid = proc.batch_id || "";
+ const isMulti = batches.length > 1;
+ const index = bid ? batches.indexOf(bid) + 1 : 1;
+ const total = Math.max(batches.length, 1);
+ // item_type: batch_id가 있으면 itemTypeMap에서, 없으면 WI의 item_number로
+ let itemType = "";
+ if (bid) {
+ itemType = itemTypeMap[bid] || "";
+ }
+ if (!itemType) {
+ const wi = instructionMap[proc.wo_id];
+ if (wi?.item_number) {
+ itemType = itemTypeMap[wi.item_number] || "";
+ }
+ }
+ info[proc.id] = { isMulti, index, total, itemType };
+ }
+ return info;
+ }, [allProcesses, itemTypeMap, instructionMap]);
+
+ const masterProcesses = useMemo(() => {
+ // 마스터 행 + 분할 행(진행중/완료/리워크) — 중복 제거
+ const seen = new Set();
+ return allProcesses.filter((p) => {
+ if (seen.has(p.id)) return false;
+ const include =
+ !p.parent_process_id || // 마스터 행
+ p.status === "in_progress" ||
+ p.status === "completed" || // 분할 행
+ p.is_rework === "Y" ||
+ p.is_rework === "true" ||
+ p.is_rework === "1"; // 재작업
+ if (include) seen.add(p.id);
+ return include;
+ });
+ }, [allProcesses]);
+
+ const equipmentMap = useMemo(() => {
+ const map: Record = {};
+ for (const eq of equipmentList) {
+ map[eq.id] = eq;
+ if (eq.equipment_code) map[eq.equipment_code] = eq;
+ }
+ return map;
+ }, [equipmentList]);
+
+ // Phase A-5: 필터 UI는 page.tsx의 SupplierModal/EquipmentModal에서 담당.
+ // WorkOrderList는 selectedProcess/selectedEquipment props만 사용해 filteredProcesses를 계산.
+
+ /* ---- Filtered processes ---- */
+ const filteredProcesses = useMemo(() => {
+ if (selectedProcess === "__all__") return []; // 공정 미선택 시 빈 목록
+ return masterProcesses.filter((proc) => {
+ const isRework =
+ proc.is_rework === "Y" ||
+ proc.is_rework === "true" ||
+ proc.is_rework === "1";
+ const isMaster = !proc.parent_process_id;
+ // 완료/진행중 탭에서는 SPLIT만 표시 (마스터 제외)
+ if (
+ isMaster &&
+ !isRework &&
+ (activeTab === "completed" || activeTab === "in_progress")
+ )
+ return false;
+ // 리워크 마스터가 in_progress/completed면 SPLIT이 생성된 것 → 리워크 마스터 숨김 (SPLIT은 표시)
+ if (
+ isRework &&
+ !proc.parent_process_id &&
+ (proc.status === "in_progress" || proc.status === "completed")
+ )
+ return false;
+ // 재작업 카드는 공정 필터 무시 (모든 공정에서 표시)
+ if (!isRework && proc.process_code !== selectedProcess) return false;
+ if (selectedEquipment !== "__all__") {
+ const wi = instructionMap[proc.wo_id];
+ if (!wi) return false;
+ const eqId = wi.equipment_id;
+ const eq = equipmentMap[eqId];
+ if (!eq || eq.equipment_code !== selectedEquipment) return false;
+ }
+ if (activeTab !== "all" && proc.status !== activeTab) return false;
+ return true;
+ });
+ }, [
+ masterProcesses,
+ selectedProcess,
+ selectedEquipment,
+ activeTab,
+ instructionMap,
+ equipmentMap,
+ currentUserId,
+ allProcesses,
+ ]);
+
+ /* ---- Tab counts ---- */
+ const tabCounts = useMemo(() => {
+ const preFiltered = masterProcesses.filter((proc) => {
+ const isRework =
+ proc.is_rework === "Y" ||
+ proc.is_rework === "true" ||
+ proc.is_rework === "1";
+ // 재작업 카드는 공정 필터 무시 (모든 공정에서 표시)
+ if (
+ selectedProcess !== "__all__" &&
+ !isRework &&
+ proc.process_code !== selectedProcess
+ )
+ return false;
+ if (selectedEquipment !== "__all__") {
+ const wi = instructionMap[proc.wo_id];
+ if (!wi) return false;
+ const eq = equipmentMap[wi.equipment_id];
+ if (!eq || eq.equipment_code !== selectedEquipment) return false;
+ }
+ return true;
+ });
+
+ const counts: Record = {
+ all: preFiltered.length,
+ acceptable: 0,
+ in_progress: 0,
+ waiting: 0,
+ completed: 0,
+ };
+ for (const proc of preFiltered) {
+ const isMaster = !proc.parent_process_id;
+ const isRw =
+ proc.is_rework === "Y" ||
+ proc.is_rework === "true" ||
+ proc.is_rework === "1";
+ // 리워크 마스터가 in_progress/completed면 SPLIT이 있으므로 카운트 제외
+ if (
+ isRw &&
+ !proc.parent_process_id &&
+ (proc.status === "in_progress" || proc.status === "completed")
+ )
+ continue;
+ if (proc.status === "acceptable") counts.acceptable++;
+ else if (proc.status === "in_progress" && (!isMaster || isRw))
+ counts.in_progress++;
+ else if (proc.status === "completed" && (!isMaster || isRw))
+ counts.completed++;
+ else counts.waiting++;
+ }
+ return counts;
+ }, [
+ masterProcesses,
+ selectedProcess,
+ selectedEquipment,
+ instructionMap,
+ equipmentMap,
+ ]);
+
+ /* ---- Accept handler ---- */
+ const openAcceptModal = async (
+ processId: string,
+ processName: string,
+ seqNo: string,
+ reworkSourceId?: string,
+ ) => {
+ try {
+ const res = await apiClient.get("/pop/production/available-qty", {
+ params: { work_order_process_id: processId },
+ });
+ const data = res.data?.data;
+ const maxQty = data?.availableQty ?? 0;
+ setAcceptModal({
+ open: true,
+ processId,
+ processName,
+ seqNo,
+ maxQty,
+ reworkSourceId,
+ });
+ } catch {
+ alert("접수가능량 조회 실패");
+ }
+ };
+
+ const handleAccept = async (qty: number) => {
+ setAcceptLoading(true);
+ try {
+ const body: Record = {
+ work_order_process_id: acceptModal.processId,
+ accept_qty: qty,
+ };
+ // 리워크 추적: rework_source_id가 있으면 전달 (원점 복귀 추적)
+ if (acceptModal.reworkSourceId) {
+ body.rework_source_id = acceptModal.reworkSourceId;
+ }
+ const res = await apiClient.post("/pop/production/accept-process", body);
+ if (res.data?.success) {
+ setAcceptModal((m) => ({ ...m, open: false }));
+ refetch();
+ } else {
+ alert(res.data?.message || "접수 실패");
+ }
+ } catch (error: any) {
+ alert(error.response?.data?.message || "접수 중 오류 발생");
+ } finally {
+ setAcceptLoading(false);
+ }
+ };
+
+ /* ---- Open work detail as fullscreen modal ---- */
+ const [workModalProcessId, setWorkModalProcessId] = useState(
+ null,
+ );
+
+ const goToWork = (processId: string) => {
+ setWorkModalProcessId(processId);
+ };
+
+ /* ---- Open process detail modal ---- */
+ const openDetailModal = (proc: WorkOrderProcess) => {
+ const wi = instructionMap[proc.wo_id];
+ const siblings = (processesByWo[proc.wo_id] || [])
+ .filter((p) => !p.parent_process_id && (
+ // 같은 batch_id끼리만 형제 (다중 품목 구분)
+ (!proc.batch_id && !p.batch_id) ||
+ (proc.batch_id && p.batch_id === proc.batch_id)
+ ))
+ .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
+
+ const totalQty = wi ? wi.qty : parseInt(proc.plan_qty || "0", 10);
+
+ const steps: ProcessStep[] = siblings.map((s) => {
+ const sInput = parseInt(s.input_qty || "0", 10);
+ const sGood = parseInt(s.good_qty || "0", 10);
+ const sDefect = parseInt(s.defect_qty || "0", 10);
+ const sPlan = parseInt(s.plan_qty || "0", 10);
+ // Available = plan - input (simplified)
+ const avail = Math.max(0, sPlan - sInput);
+ return {
+ no: parseInt(s.seq_no, 10),
+ name: s.process_name || s.process_code,
+ code: s.process_code,
+ status: s.status,
+ inputQty: sInput,
+ goodQty: sGood,
+ defectQty: sDefect,
+ planQty: sPlan,
+ availableQty: avail,
+ };
+ });
+
+ // Check if this wo has any rework processes
+ const hasReworks = allProcesses.some(
+ (p) =>
+ p.wo_id === proc.wo_id &&
+ (p.is_rework === "Y" || p.is_rework === "true" || p.is_rework === "1"),
+ );
+
+ setDetailModal({
+ open: true,
+ wiNo: wi?.work_instruction_no || "작업지시",
+ totalQty,
+ steps,
+ woId: proc.wo_id,
+ showReworkHistory: hasReworks,
+ });
+ };
+
+ /* ---- Helper: get split order label (접수 #N) ---- */
+ const splitOrderMap = useMemo(() => {
+ // 같은 wo_id + seq_no를 가진 SPLIT들을 그룹화하여 순서 부여
+ const groups: Record = {};
+ for (const proc of allProcesses) {
+ if (!proc.parent_process_id) continue; // 마스터 행은 제외
+ if (proc.status !== "in_progress" && proc.status !== "completed")
+ continue;
+ const key = `${proc.wo_id}__${proc.seq_no}`;
+ if (!groups[key]) groups[key] = [];
+ groups[key].push(proc);
+ }
+
+ const result: Record = {};
+ for (const key of Object.keys(groups)) {
+ const splits = groups[key];
+ if (splits.length <= 1) continue; // 1개면 순서 표시 불필요
+ // accepted_at 기준 정렬 (없으면 started_at, 그마저 없으면 id)
+ splits.sort((a, b) => {
+ const ta = a.accepted_at
+ ? new Date(a.accepted_at).getTime()
+ : a.started_at
+ ? new Date(a.started_at).getTime()
+ : 0;
+ const tb = b.accepted_at
+ ? new Date(b.accepted_at).getTime()
+ : b.started_at
+ ? new Date(b.started_at).getTime()
+ : 0;
+ return ta - tb || a.id.localeCompare(b.id);
+ });
+ for (let i = 0; i < splits.length; i++) {
+ result[splits[i].id] = { order: i + 1, total: splits.length };
+ }
+ }
+ return result;
+ }, [allProcesses]);
+
+ /* ---- Helper: get previous process info ---- */
+ const getPrevProcessInfo = (proc: WorkOrderProcess) => {
+ const siblings = (processesByWo[proc.wo_id] || [])
+ .filter((p) => !p.parent_process_id && (
+ // 같은 batch_id끼리만 형제 (다중 품목 구분)
+ (!proc.batch_id && !p.batch_id) ||
+ (proc.batch_id && p.batch_id === proc.batch_id)
+ ))
+ .sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
+
+ const currentIdx = siblings.findIndex((p) => p.id === proc.id);
+ if (currentIdx <= 0)
+ return {
+ prevGoodQty: null as number | null,
+ prevProcessName: null as string | null,
+ prevProgressPct: null as number | null,
+ };
+
+ const prev = siblings[currentIdx - 1];
+ const prevGood = parseInt(prev.good_qty || "0", 10);
+ const prevPlan = parseInt(prev.plan_qty || "0", 10);
+ const prevPct = prevPlan > 0 ? Math.round((prevGood / prevPlan) * 100) : 0;
+ // 앞공정에서 리워크로 완료된 양품 수량
+ const prevSeqNo = prev.seq_no;
+ const reworkGoodFromPrev = allProcesses
+ .filter(
+ (p) =>
+ p.wo_id === proc.wo_id &&
+ p.seq_no === prevSeqNo &&
+ p.parent_process_id &&
+ p.status === "completed" &&
+ (p.is_rework === "Y" ||
+ p.is_rework === "true" ||
+ p.is_rework === "1"),
+ )
+ .reduce((sum, p) => sum + parseInt(p.good_qty || "0", 10), 0);
+ // 현재 공정에서 이미 리워크로 접수된 수량
+ const reworkConsumedHere = allProcesses
+ .filter(
+ (p) =>
+ p.wo_id === proc.wo_id &&
+ p.seq_no === proc.seq_no &&
+ p.parent_process_id &&
+ (p.is_rework === "Y" ||
+ p.is_rework === "true" ||
+ p.is_rework === "1"),
+ )
+ .reduce((sum, p) => sum + parseInt(p.input_qty || "0", 10), 0);
+ const reworkAvailableQty = Math.max(
+ 0,
+ reworkGoodFromPrev - reworkConsumedHere,
+ );
+
+ // 접수가능 수량을 초과하지 않도록 제한
+ const inputQtyNum = parseInt(proc.input_qty || "0", 10);
+ const actualAvailable = Math.max(0, prevGood - inputQtyNum);
+ const clampedReworkAvailable = Math.min(
+ reworkAvailableQty,
+ actualAvailable,
+ );
+
+ return {
+ prevGoodQty: prevGood,
+ prevProcessName: prev.process_name || prev.process_code,
+ prevProgressPct:
+ prev.status === "in_progress"
+ ? prevPct
+ : prev.status === "completed"
+ ? 100
+ : null,
+ reworkAvailableQty: clampedReworkAvailable,
+ };
+ };
+
+ /* ---- Helper: get equipment name ---- */
+ const getEquipmentName = (proc: WorkOrderProcess): string => {
+ const wi = instructionMap[proc.wo_id];
+ if (!wi?.equipment_id) return "미배정";
+ const eq = equipmentMap[wi.equipment_id];
+ return eq?.equipment_name || "미배정";
+ };
+
+ /* ---- Render ---- */
+ return (
+
+ {/* Phase A-3/A-5: 카드 열 토글·새로고침·필터 UI는 page.tsx가 담당 */}
+
+ {/* ===== Tab bar ===== */}
+
+ {TABS.map((tab) => {
+ const count = tabCounts[tab.key];
+ const isActive = activeTab === tab.key;
+ return (
+
+ );
+ })}
+
+
+ {/* Loading */}
+ {loading && (
+
+ )}
+
+ {/* Empty state */}
+ {!loading && filteredProcesses.length === 0 && (
+
+
+
+ {selectedProcess === "__all__"
+ ? "공정을 선택해주세요"
+ : `${TABS.find((t) => t.key === activeTab)?.label || ""} 상태의 공정이 없습니다`}
+
+
+ )}
+
+ {/* ===== Process Cards Grid ===== */}
+ {!loading && filteredProcesses.length > 0 && (
+
+ {filteredProcesses
+ .sort((a, b) => {
+ const order: Record
= {
+ acceptable: 0,
+ in_progress: 1,
+ waiting: 2,
+ completed: 3,
+ };
+ const diff = (order[a.status] ?? 2) - (order[b.status] ?? 2);
+ if (diff !== 0) return diff;
+ return parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10);
+ })
+ .map((proc) => {
+ const wi = instructionMap[proc.wo_id];
+ const badge = STATUS_BADGE[proc.status] || STATUS_BADGE.waiting;
+ const siblingProcesses = (processesByWo[proc.wo_id] || []).filter(
+ (p) => !p.parent_process_id && (
+ // 같은 batch_id끼리만 형제 (다중 품목 구분)
+ (!proc.batch_id && !p.batch_id) ||
+ (proc.batch_id && p.batch_id === proc.batch_id)
+ ),
+ );
+ const planQty = parseInt(proc.plan_qty || "0", 10);
+ const goodQty = parseInt(proc.good_qty || "0", 10);
+ const defectQty = parseInt(proc.defect_qty || "0", 10);
+ const inputQty = parseInt(proc.input_qty || "0", 10);
+ const isRework =
+ proc.is_rework === "Y" ||
+ proc.is_rework === "true" ||
+ proc.is_rework === "1";
+ const borderLeft = isRework
+ ? "border-l-orange-500"
+ : BORDER_LEFT_COLOR[proc.status] || "border-l-gray-300";
+ const eqName = getEquipmentName(proc);
+
+ // Split processes (children of this master)
+ const mySplits = allProcesses
+ .filter((s) => s.parent_process_id === proc.id)
+ .sort((a, b) => {
+ const ta = a.started_at
+ ? new Date(a.started_at).getTime()
+ : 0;
+ const tb = b.started_at
+ ? new Date(b.started_at).getTime()
+ : 0;
+ return tb - ta;
+ });
+
+ const progressPct =
+ planQty > 0
+ ? Math.min(100, Math.round((goodQty / planQty) * 100))
+ : 0;
+ const remainQty = Math.max(0, inputQty - goodQty - defectQty);
+ const prevInfo = getPrevProcessInfo(proc);
+
+ // Calculate available qty for acceptable
+ const availableQty = isRework
+ ? inputQty // 리워크 카드는 input_qty 자체가 접수 대상
+ : prevInfo.prevGoodQty !== null
+ ? Math.max(0, prevInfo.prevGoodQty - inputQty)
+ : Math.max(0, planQty - inputQty);
+
+ // Additional available for in_progress
+ const additionalAvailable = Math.max(0, planQty - inputQty);
+
+ // Split order label
+ const splitInfo = splitOrderMap[proc.id];
+
+ // 합류 불가 리워크 감지: 접수가능 물량이 전부 리워크일 때
+ const reworkQtyAvail = prevInfo.reworkAvailableQty || 0;
+ const normalAvail = availableQty - reworkQtyAvail;
+ const isReworkOnly =
+ !isRework &&
+ proc.status === "acceptable" &&
+ reworkQtyAvail > 0 &&
+ normalAvail <= 0 &&
+ availableQty > 0;
+
+ // 리워크 표시 여부 (실제 리워크 카드 OR 합류불가 리워크)
+ const showReworkBadge = isRework || isReworkOnly;
+
+ // Rework info: origin process + rework round
+ let reworkRound = 1;
+ let originProcessName = proc.process_name || proc.process_code;
+ let originProcessCode = proc.process_code;
+ let originDefectQty = defectQty;
+ if (isRework) {
+ // 리워크 마스터 카드만 카운트 (SPLIT 제외 — parent_process_id 없는 것만)
+ const reworkMasters = allProcesses.filter(
+ (p) =>
+ p.wo_id === proc.wo_id &&
+ !p.parent_process_id &&
+ (p.is_rework === "Y" ||
+ p.is_rework === "true" ||
+ p.is_rework === "1"),
+ );
+ const sortedReworks = [...reworkMasters].sort((a, b) => {
+ const da = a.created_date
+ ? new Date(a.created_date).getTime()
+ : 0;
+ const db = b.created_date
+ ? new Date(b.created_date).getTime()
+ : 0;
+ return da - db || a.id.localeCompare(b.id);
+ });
+ // 현재 카드가 SPLIT이면 parent(마스터)의 위치로, 마스터면 직접 위치
+ const masterId = proc.parent_process_id || proc.id;
+ const myIdx = sortedReworks.findIndex((r) => r.id === masterId);
+ reworkRound = myIdx >= 0 ? myIdx + 1 : 1;
+
+ // Find origin (source) process
+ if (proc.rework_source_id) {
+ const origin = allProcesses.find(
+ (p) => p.id === proc.rework_source_id,
+ );
+ if (origin) {
+ originProcessName =
+ origin.process_name || origin.process_code;
+ originProcessCode = origin.process_code;
+ originDefectQty = parseInt(origin.defect_qty || "0", 10);
+ }
+ }
+ }
+
+ return (
+
+
+ goToWork(
+ proc.parent_process_id
+ ? proc.id
+ : mySplits[0]?.id || proc.id,
+ )
+ : undefined
+ }
+ >
+
+ {/* Header: Work instruction number + status badge */}
+
+
+ {showReworkBadge && (
+
+ 🔄 리워크
+
+ )}
+
+ {wi?.work_instruction_no || "작업지시"}
+ {splitInfo && (
+
+ (접수 #{splitInfo.order})
+
+ )}
+
+
+
+ {badge.prefix}
+ {badge.label}
+
+
+
+ {/* 단일/다중품목 뱃지 */}
+ {(() => {
+ const bInfo = multiBatchInfo[proc.id];
+ if (!bInfo) return null;
+ const typeLabel = bInfo.itemType || "";
+ return (
+
+ {bInfo.isMulti ? (
+
+ 다중 {bInfo.index}/{bInfo.total}{typeLabel ? ` · ${typeLabel}` : ""}
+
+ ) : (
+
+ 단일{typeLabel ? ` · ${typeLabel}` : ""}
+
+ )}
+
+ );
+ })()}
+
+ {/* Sub-info: item name + equipment */}
+
+ 📦 {proc.batch_id
+ ? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})`
+ : `${wi?.item_name || "품목"}${wi?.item_code || wi?.item_number ? `(${wi?.item_code || wi?.item_number})` : ""}`}
+ {" · "}
+ {!isRework
+ ? `⚙️ ${eqName}`
+ : `⚙️ ${proc.process_name || proc.process_code}`}
+
+
+ {/* Process steps (compressed) — both normal and rework cards */}
+ {siblingProcesses.length > 1 && (
+
openDetailModal(proc)}
+ batchId={
+ proc.batch_id ?? undefined
+ }
+ allProcesses={allProcesses}
+ />
+ )}
+
+ {/* Status-specific body (pushed to bottom with mt-auto) */}
+
+ {isRework ? (
+
+ ) : proc.status === "acceptable" ? (
+
+ ) : proc.status === "in_progress" ? (
+
+ ) : proc.status === "waiting" ? (
+
+ ) : proc.status === "completed" ? (
+
+ ) : null}
+
+
+
+ {/* Action button (full width, bottom) */}
+ {isRework && proc.status === "acceptable" && (
+
+ )}
+ {!isRework &&
+ proc.status === "acceptable" &&
+ availableQty > 0 && (
+
+ )}
+ {!isRework &&
+ proc.status === "acceptable" &&
+ availableQty <= 0 &&
+ inputQty > 0 && (
+
+ 전량 접수 완료
+
+ )}
+ {proc.status === "in_progress" &&
+ parseInt(proc.total_production_qty || "0", 10) === 0 &&
+ proc.parent_process_id && (
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {/* Phase A-5: FilterSelectorModal 인스턴스는 page.tsx의 SupplierModal/EquipmentModal로 대체 */}
+
+ {/* Accept Process Modal */}
+
setAcceptModal((m) => ({ ...m, open: false }))}
+ onConfirm={handleAccept}
+ maxQty={acceptModal.maxQty}
+ processName={acceptModal.processName}
+ seqNo={acceptModal.seqNo}
+ loading={acceptLoading}
+ />
+
+ {/* Process Detail Modal */}
+ setDetailModal((m) => ({ ...m, open: false }))}
+ workInstructionNo={detailModal.wiNo}
+ totalQty={detailModal.totalQty}
+ steps={detailModal.steps}
+ woId={detailModal.woId}
+ showReworkHistory={detailModal.showReworkHistory}
+ />
+
+ {/* Fullscreen Work Modal */}
+ {workModalProcessId && (
+
+ p.parent_process_id &&
+ p.accepted_by === currentUserId &&
+ p.status === "in_progress",
+ )}
+ instructionMap={instructionMap}
+ itemNameMap={itemNameMap}
+ multiBatchInfo={multiBatchInfo}
+ onSwitch={(id) => setWorkModalProcessId(id)}
+ onClose={() => {
+ setWorkModalProcessId(null);
+ refetch();
+ }}
+ />
+ )}
+
+ {/* Cancel accept confirm modal */}
+ {
+ const pid = cancelConfirm.processId;
+ setCancelConfirm({ open: false, processId: "" });
+ try {
+ const res = await apiClient.post("/pop/production/cancel-accept", {
+ work_order_process_id: pid,
+ });
+ if (res.data?.success) {
+ refetch();
+ } else {
+ alert(res.data?.message || "취소 실패");
+ }
+ } catch (err: unknown) {
+ const e2 = err as { response?: { data?: { message?: string } } };
+ alert(e2.response?.data?.message || "취소 중 오류");
+ }
+ }}
+ onCancel={() => setCancelConfirm({ open: false, processId: "" })}
+ />
+
+ );
+}
diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/sections/MaterialInputSection.tsx b/frontend/app/(main)/COMPANY_7/pop/_components/production/sections/MaterialInputSection.tsx
new file mode 100644
index 00000000..5a070af7
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/sections/MaterialInputSection.tsx
@@ -0,0 +1,365 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import { apiClient } from "@/lib/api/client";
+
+/* ================================================================== */
+/* Material Qty Keypad (±20% 범위 안내 포함) */
+/* ================================================================== */
+
+function MaterialQtyKeypad({
+ open,
+ onClose,
+ onConfirm,
+ initialValue,
+ unit,
+ requiredQty,
+ itemName,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onConfirm: (val: string) => void;
+ initialValue: string;
+ unit: string;
+ requiredQty: number;
+ itemName: string;
+}) {
+ const [val, setVal] = useState(initialValue || "0");
+
+ useEffect(() => {
+ if (open) setVal(initialValue || "0");
+ }, [open, initialValue]);
+
+ const press = (k: string) => {
+ setVal((prev) => {
+ if (k === "backspace") return prev.length <= 1 ? "0" : prev.slice(0, -1);
+ if (k === "clear") return "0";
+ if (k === "dot") return prev.includes(".") ? prev : prev + ".";
+ if (k === "ref") return String(requiredQty);
+ if (prev === "0" && k !== ".") return k;
+ return prev + k;
+ });
+ };
+
+ if (!open) return null;
+ const numVal = parseFloat(val);
+ const lower = requiredQty * 0.8;
+ const upper = requiredQty * 1.2;
+ const outOfRange = !isNaN(numVal) && (numVal < lower || numVal > upper);
+
+ return (
+
+
+
+
+
{itemName}
+
+ 기준 {requiredQty} {unit} (±20%)
+
+
+ {val} {unit}
+
+
+
+ {["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((k) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/* ================================================================== */
+/* Material Qty Input Row (키패드 트리거 버튼) */
+/* ================================================================== */
+
+function MaterialQtyInputRow({
+ material,
+ value,
+ onChange,
+}: {
+ material: {
+ id: string;
+ child_item_name: string;
+ required_qty: number;
+ unit: string;
+ };
+ value: string;
+ onChange: (v: string) => void;
+}) {
+ const [open, setOpen] = useState(false);
+ return (
+
+
+ setOpen(false)}
+ onConfirm={onChange}
+ initialValue={value}
+ unit={material.unit}
+ requiredQty={material.required_qty}
+ itemName={material.child_item_name}
+ />
+
+ );
+}
+
+/* ================================================================== */
+/* Material Input Section */
+/* ================================================================== */
+
+/**
+ * Phase B-1: ProcessWork.tsx L2810-2993에서 분리.
+ * BOM 자재 조회/투입 — processId 외 props 없음 (원본 시그니처 유지).
+ * 원본 동작 그대로: 자체 state + 자체 API 호출, peSettings.materialInput 판별은 상위(ProcessWork)에서 수행.
+ */
+export function MaterialInputSection({ processId }: { processId: string }) {
+ const [bomMaterials, setBomMaterials] = useState<
+ Array<{
+ id: string;
+ child_item_id: string;
+ child_item_code: string;
+ child_item_name: string;
+ bom_qty: number;
+ unit: string;
+ required_qty: number;
+ input_qty: number;
+ }>
+ >([]);
+ const [inputValues, setInputValues] = useState>({});
+ const [inputted, setInputted] = useState<
+ Array<{
+ id: string;
+ item_code: string;
+ item_name: string;
+ input_qty: string;
+ unit: string;
+ recorded_at: string;
+ }>
+ >([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [defaultWarehouseCode, setDefaultWarehouseCode] = useState("");
+
+ useEffect(() => {
+ const fetchData = async () => {
+ setLoading(true);
+ try {
+ const [bomRes, inputRes, whRes] = await Promise.all([
+ apiClient.get(`/pop/production/bom-materials/${processId}`),
+ apiClient.get(`/pop/production/material-inputs/${processId}`),
+ apiClient.get("/pop/production/warehouses"),
+ ]);
+ setBomMaterials(bomRes.data?.data?.materials || []);
+ setInputted(inputRes.data?.data || []);
+ const wh = whRes.data?.data?.[0];
+ if (wh?.warehouse_code) setDefaultWarehouseCode(wh.warehouse_code);
+ } catch {
+ /* non-critical */
+ }
+ setLoading(false);
+ };
+ fetchData();
+ }, [processId]);
+
+ const handleSave = async () => {
+ const inputs = bomMaterials
+ .filter((m) => {
+ const val = parseFloat(inputValues[m.id] || "0");
+ return val > 0;
+ })
+ .map((m) => ({
+ child_item_id: m.child_item_id,
+ child_item_code: m.child_item_code,
+ child_item_name: m.child_item_name,
+ input_qty: parseFloat(inputValues[m.id] || "0"),
+ unit: m.unit,
+ bom_detail_id: m.id,
+ required_qty: m.required_qty,
+ warehouse_code: defaultWarehouseCode || undefined,
+ location_code: defaultWarehouseCode || undefined,
+ }));
+
+ if (inputs.length === 0) {
+ alert("투입 수량을 입력해주세요.");
+ return;
+ }
+
+ setSaving(true);
+ try {
+ const res = await apiClient.post("/pop/production/material-input", {
+ work_order_process_id: processId,
+ inputs,
+ });
+ if (res.data?.success) {
+ alert(res.data.message || "투입 완료");
+ setInputValues({});
+ const inputRes = await apiClient.get(
+ `/pop/production/material-inputs/${processId}`,
+ );
+ setInputted(inputRes.data?.data || []);
+ } else {
+ alert(res.data?.message || "투입 실패");
+ }
+ } catch {
+ alert("투입 중 오류");
+ }
+ setSaving(false);
+ };
+
+ if (loading) {
+ return (
+ 자재 정보 조회중...
+ );
+ }
+
+ return (
+
+ {/* BOM 기준 자재 목록 — 컴팩트 */}
+
+
+
BOM 자재 목록
+ {bomMaterials.length}건
+
+ {bomMaterials.length === 0 ? (
+
+ BOM 자재 정보가 없습니다
+
+ ) : (
+
+
+ {bomMaterials.map((m) => (
+
+
+
+ {m.child_item_name}
+
+
+ ({m.child_item_code})
+
+
+ 소요 {m.required_qty}
+
+
+
+ setInputValues((prev) => ({ ...prev, [m.id]: v }))
+ }
+ />
+
+ {m.unit}
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* 투입 이력 */}
+ {inputted.length > 0 && (
+
+
투입 이력
+ {inputted.map((item) => (
+
+
+
+ {item.item_name}
+
+
{item.item_code}
+
+
+ {item.input_qty} {item.unit}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/app/(main)/COMPANY_7/pop/_components/production/useProcessData.ts b/frontend/app/(main)/COMPANY_7/pop/_components/production/useProcessData.ts
new file mode 100644
index 00000000..c49bd092
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_7/pop/_components/production/useProcessData.ts
@@ -0,0 +1,212 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { apiClient } from "@/lib/api/client";
+import { dataApi } from "@/lib/api/data";
+
+/* ------------------------------------------------------------------ */
+/* Types (WorkOrderList.tsx와 동일 스키마) */
+/* ------------------------------------------------------------------ */
+
+export interface WorkInstruction {
+ id: string;
+ work_instruction_no: string;
+ item_id: string;
+ item_name: string;
+ item_code: string;
+ item_number: string;
+ qty: number;
+ completed_qty: number;
+ status: string;
+ progress_status: string;
+ routing: string | null;
+ start_date: string;
+ end_date: string;
+ equipment_id: string;
+ work_team: string;
+ worker: string;
+}
+
+export interface WorkOrderProcess {
+ id: string;
+ wo_id: string;
+ seq_no: string;
+ process_code: string;
+ process_name: string;
+ status: "acceptable" | "waiting" | "in_progress" | "completed";
+ plan_qty: string;
+ input_qty: string;
+ good_qty: string;
+ defect_qty: string;
+ concession_qty: string;
+ total_production_qty: string;
+ parent_process_id: string | null;
+ is_rework: string;
+ rework_source_id: string | null;
+ result_status: string;
+ started_at: string | null;
+ completed_at: string | null;
+ accepted_by?: string;
+ accepted_at?: string | null;
+ created_date?: string;
+ batch_id?: string | null;
+ equipment_code?: string;
+}
+
+export interface ProcessMng {
+ id: string;
+ process_code: string;
+ process_name: string;
+}
+
+export interface EquipmentMng {
+ id: string;
+ equipment_code: string;
+ equipment_name: string;
+}
+
+const REFRESH_COOLDOWN_MS = 3000;
+
+/**
+ * 공정실행 화면 데이터 훅
+ *
+ * - 진입 시 1회 sync-work-instructions POST + 데이터 로드
+ * - refresh(): 수동 새로고침 — sync 포함, 3초 쿨다운 (연타 차단)
+ * - refetch(): mutation 이후 — sync 없이 데이터만 재조회
+ * - 에러 → sonner toast (silent catch 금지)
+ */
+export function useProcessData() {
+ const [instructions, setInstructions] = useState([]);
+ const [allProcesses, setAllProcesses] = useState([]);
+ const [processList, setProcessList] = useState([]);
+ const [equipmentList, setEquipmentList] = useState([]);
+ const [itemNameMap, setItemNameMap] = useState>({});
+ const [itemTypeMap, setItemTypeMap] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [syncing, setSyncing] = useState(false);
+
+ const lastRefreshAt = useRef(0);
+ const inFlight = useRef | null>(null);
+
+ const fetchData = useCallback(
+ async (opts?: { withSync?: boolean }): Promise => {
+ // 동시 호출 방지 (같은 fetch가 이미 진행 중이면 그 Promise를 공유)
+ if (inFlight.current) return inFlight.current;
+
+ const task = (async () => {
+ setLoading(true);
+ if (opts?.withSync) setSyncing(true);
+ try {
+ if (opts?.withSync) {
+ try {
+ await apiClient.post("/pop/production/sync-work-instructions");
+ } catch {
+ toast.warning(
+ "동기화 실패 — 최신 작업지시가 반영되지 않을 수 있습니다",
+ );
+ }
+ }
+
+ const [wiRes, procRes, pmRes, eqRes] = await Promise.all([
+ apiClient.get("/work-instruction/list"),
+ dataApi.getTableData("work_order_process", { size: 1000 }),
+ dataApi.getTableData("process_mng", { size: 500 }),
+ dataApi.getTableData("equipment_mng", { size: 500 }),
+ ]);
+
+ // work-instruction: header+detail 조인이라 id 중복 → 첫 행만 취함
+ let wiRaw: Record[] = [];
+ if (wiRes.data?.data) {
+ wiRaw = Array.isArray(wiRes.data.data)
+ ? wiRes.data.data
+ : wiRes.data.data.rows || [];
+ } else if (Array.isArray(wiRes.data)) {
+ wiRaw = wiRes.data;
+ }
+ const seen = new Set();
+ const wiData: WorkInstruction[] = [];
+ const newItemNameMap: Record = {};
+ const newItemTypeMap: Record = {};
+ for (const raw of wiRaw) {
+ const wiId = String(raw.wi_id || raw.id || "");
+ const rawItemNumber = String(raw.item_number || "");
+ const rawItemName = String(raw.item_name || "");
+ const rawItemType = String(raw.item_type || "");
+ if (rawItemNumber && rawItemName) {
+ newItemNameMap[rawItemNumber] = rawItemName;
+ }
+ if (rawItemNumber && rawItemType) {
+ newItemTypeMap[rawItemNumber] = rawItemType;
+ }
+ if (!wiId || seen.has(wiId)) continue;
+ seen.add(wiId);
+ wiData.push({
+ ...raw,
+ id: wiId,
+ item_name: rawItemName,
+ item_code: String(raw.item_code || ""),
+ item_number: rawItemNumber,
+ qty: parseInt(String(raw.total_qty || raw.qty || 0), 10),
+ } as unknown as WorkInstruction);
+ }
+ setInstructions(wiData);
+ setItemNameMap(newItemNameMap);
+ setItemTypeMap(newItemTypeMap);
+ setAllProcesses((procRes.data ?? []) as WorkOrderProcess[]);
+ setProcessList((pmRes.data ?? []) as ProcessMng[]);
+ setEquipmentList((eqRes.data ?? []) as EquipmentMng[]);
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("[useProcessData] fetch error:", error);
+ toast.error("데이터 조회 실패");
+ } finally {
+ setLoading(false);
+ setSyncing(false);
+ }
+ })();
+
+ inFlight.current = task;
+ try {
+ await task;
+ } finally {
+ inFlight.current = null;
+ }
+ },
+ [],
+ );
+
+ // 진입 시 1회만 sync + 로드
+ useEffect(() => {
+ fetchData({ withSync: true });
+ // fetchData는 stable — 의존성에 넣으면 useCallback deps 변화 없어도 무관하나,
+ // 명시적으로 빈 배열로 두어 "마운트 시 정확히 1회"를 강제
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ /** 수동 새로고침 — sync 포함, 3초 쿨다운 */
+ const refresh = useCallback(() => {
+ const now = Date.now();
+ if (now - lastRefreshAt.current < REFRESH_COOLDOWN_MS) return;
+ lastRefreshAt.current = now;
+ fetchData({ withSync: true });
+ }, [fetchData]);
+
+ /** Mutation(접수/실적/취소) 직후 — sync 없이 데이터만 재조회 */
+ const refetch = useCallback(() => {
+ fetchData({ withSync: false });
+ }, [fetchData]);
+
+ return {
+ instructions,
+ allProcesses,
+ processList,
+ equipmentList,
+ itemNameMap,
+ itemTypeMap,
+ loading,
+ syncing,
+ refresh,
+ refetch,
+ };
+}
diff --git a/frontend/app/(main)/COMPANY_7/pop/production/process/page.tsx b/frontend/app/(main)/COMPANY_7/pop/production/process/page.tsx
new file mode 100644
index 00000000..8cab5e51
--- /dev/null
+++ b/frontend/app/(main)/COMPANY_7/pop/production/process/page.tsx
@@ -0,0 +1,258 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { SupplierModal, type Supplier, type PartnerSourceConfig } from "../../_components/inbound/SupplierModal";
+import { EquipmentModal, type EquipmentItem } from "../../_components/common/EquipmentModal";
+import { getProcessEquipments } from "@/lib/api/processInfo";
+import { useProcessData } from "../../_components/production/useProcessData";
+import { WorkOrderList } from "../../_components/production/WorkOrderList";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+type ColsKey = 1 | 2 | 3;
+
+/** process_mng 테이블 소스 설정 — SupplierModal에 전달하여 공정 목록 조회 */
+const PROCESS_SOURCE: PartnerSourceConfig = {
+ tableName: "process_mng",
+ fields: {
+ code: "process_code",
+ name: "process_name",
+ },
+};
+
+/* ------------------------------------------------------------------ */
+/* Static Data */
+/* ------------------------------------------------------------------ */
+
+const COLS_OPTIONS: ColsKey[] = [1, 2, 3];
+/** 신규 POP 전용 카드 열 localStorage 키 (구 POP `workorder-card-cols`와 독립) */
+const POP_NEW_COLS_KEY = "pop-new-workorder-cols";
+const DEFAULT_COLS: ColsKey = 2;
+
+/* ------------------------------------------------------------------ */
+/* Page */
+/* ------------------------------------------------------------------ */
+
+export default function ProductionProcessPage() {
+ const router = useRouter();
+
+ /* 카드 열 수 (localStorage 연동) */
+ const [cols, setCols] = useState(DEFAULT_COLS);
+ useEffect(() => {
+ try {
+ const saved = localStorage.getItem(POP_NEW_COLS_KEY);
+ if (saved) {
+ const n = parseInt(saved, 10) as ColsKey;
+ if (COLS_OPTIONS.includes(n)) setCols(n);
+ }
+ } catch {
+ // ignore
+ }
+ }, []);
+ const handleSetCols = (n: ColsKey) => {
+ setCols(n);
+ try {
+ localStorage.setItem(POP_NEW_COLS_KEY, String(n));
+ } catch {
+ // ignore
+ }
+ };
+
+ /* 필터 상태 */
+ const [selectedProcess, setSelectedProcess] = useState(null);
+ const [processModalOpen, setProcessModalOpen] = useState(false);
+ const [selectedEquipment, setSelectedEquipment] = useState(null);
+ const [equipmentModalOpen, setEquipmentModalOpen] = useState(false);
+ const [equipments, setEquipments] = useState([]);
+ const [equipmentLoading, setEquipmentLoading] = useState(false);
+
+ /* 공정 변경 시 해당 공정 등록 설비 조회 */
+ useEffect(() => {
+ if (!selectedProcess?.customer_code) {
+ setEquipments([]);
+ setSelectedEquipment(null);
+ return;
+ }
+ let cancelled = false;
+ setEquipmentLoading(true);
+ getProcessEquipments(selectedProcess.customer_code)
+ .then((res) => {
+ if (cancelled) return;
+ if (res.success && Array.isArray(res.data)) {
+ const list: EquipmentItem[] = res.data.map((e) => ({
+ id: String(e.id ?? ""),
+ equipment_code: String(e.equipment_code ?? ""),
+ equipment_name: String(e.equipment_name ?? e.equipment_code ?? ""),
+ }));
+ setEquipments(list);
+ } else {
+ setEquipments([]);
+ }
+ setSelectedEquipment(null);
+ })
+ .finally(() => {
+ if (!cancelled) setEquipmentLoading(false);
+ });
+ return () => { cancelled = true; };
+ }, [selectedProcess?.customer_code]);
+
+ const equipmentDisabled = !selectedProcess;
+
+ /* 데이터 훅 — 진입 시 1회 sync, refresh/refetch 제공 */
+ const {
+ instructions,
+ allProcesses,
+ processList,
+ equipmentList,
+ itemNameMap,
+ itemTypeMap,
+ loading,
+ syncing,
+ refresh,
+ refetch,
+ } = useProcessData();
+
+ /* WorkOrderList용 문자열 필터값 ("__all__" 또는 code) */
+ const selectedProcessCode = selectedProcess?.customer_code || "__all__";
+ const selectedEquipmentCode = selectedEquipment?.equipment_code || "__all__";
+
+ return (
+
+ {/* ===== Back + Title ===== */}
+
+
+
공정실행
+
+
+ {/* ===== Top Row: Card Cols + Refresh ===== */}
+
+
+
카드 열:
+
+ {COLS_OPTIONS.map((c) => (
+
+ ))}
+
+
+
+
+
+ {/* ===== Filter Row: Process + Equipment ===== */}
+
+
+
+
+
+ {/* ===== Work Order List (복사된 구 POP 컴포넌트, 플랜 A-2b로 props 리팩터됨) ===== */}
+
+
+ {/* ===== Process Selection Modal ===== */}
+
setProcessModalOpen(false)}
+ onSelect={(s) => setSelectedProcess(s)}
+ source={PROCESS_SOURCE}
+ title="공정 선택"
+ searchPlaceholder="공정명 또는 코드 검색..."
+ />
+
+ {/* ===== Equipment Selection Modal ===== */}
+ setEquipmentModalOpen(false)}
+ onSelect={(e) => setSelectedEquipment(e)}
+ items={equipments}
+ loading={equipmentLoading}
+ title="설비 선택"
+ searchPlaceholder="설비명 또는 코드 검색..."
+ />
+
+ );
+}
diff --git a/frontend/hooks/pop/usePopSettings.ts b/frontend/hooks/pop/usePopSettings.ts
index 6eafa9cd..b81ae3d5 100644
--- a/frontend/hooks/pop/usePopSettings.ts
+++ b/frontend/hooks/pop/usePopSettings.ts
@@ -120,6 +120,7 @@ const POP_SCREEN_MAP: Record = {
"/pop/outbound/sales": 5,
"/pop/production": 8,
"/pop/production/process": 7,
+ "/COMPANY_7/pop/production/process": 7,
};
// URL -> settingsKey mapping
@@ -132,6 +133,7 @@ const PATH_TO_SETTINGS_KEY: Record = {
"/pop/outbound/sales": "outbound",
"/pop/production": "processExecution",
"/pop/production/process": "processExecution",
+ "/COMPANY_7/pop/production/process": "processExecution",
};
function getScreenIdFromPath(pathname: string): number | null {