Files
pipeline/frontend/components/pop/hardcoded/production/ProcessWork.tsx
T
SeongHyun Kim e3657b099d feat: 공정실행 단일/다중품목 뱃지 + 품목타입 표시
- 단일품목: 회색 뱃지 [단일 · 제품]
- 다중품목: 파랑 뱃지 [다중 1/2 · 반제품]
- 리워크: 주황 뱃지 유지 (기존)
- item_info.type으로 품목타입(제품/반제품/원재료/부재료) 표시
- workInstructionController: getList에 item_type 추가
- WorkOrderList: multiBatchInfo useMemo로 단일/다중 판단
- ProcessWork: batchBadge로 헤더에 뱃지 표시
2026-04-10 17:30:01 +09:00

2998 lines
95 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { usePopSettings } from "@/hooks/pop/usePopSettings";
import { apiClient } from "@/lib/api/client";
import { dataApi } from "@/lib/api/data";
import { ConfirmModal } from "../common/ConfirmModal";
import {
type DefectEntry,
type DefectType,
DefectTypeModal,
} from "./DefectTypeModal";
import { ProcessTimer, type TimerStatus } from "./ProcessTimer";
/* ================================================================== */
/* Types */
/* ================================================================== */
interface ProcessData {
id: string;
wo_id: string;
seq_no: string;
process_code: string;
process_name: string;
status: string;
plan_qty: string;
input_qty: string;
good_qty: string;
defect_qty: string;
concession_qty: string;
total_production_qty: string;
parent_process_id: string | null;
result_status: string;
result_note: string;
started_at: string | null;
paused_at: string | null;
total_paused_time: string | null;
completed_at: string | null;
actual_work_time: string | null;
accepted_at: string | null;
accepted_by: string | null;
defect_detail: string | null;
target_warehouse_id: string | null;
target_location_code: string | null;
is_rework: string;
routing_detail_id: string | null;
batch_id?: string | null;
}
interface WorkInstructionInfo {
work_instruction_no: string;
item_name: string;
item_code: string;
qty: number;
}
interface ChecklistItem {
id: string;
work_order_process_id: string;
source_work_item_id: string;
source_detail_id: string;
work_phase: string;
item_title: string;
item_sort_order: string;
detail_content: string;
detail_type: string;
detail_label: string | null;
detail_sort_order: string;
is_required: string | null;
result_value: string | null;
is_passed: string | null;
status: string;
spec_value: string | null;
inspection_code: string | null;
inspection_method: string | null;
unit: string | null;
lower_limit: string | null;
upper_limit: string | null;
input_type: string | null;
group_started_at: string | null;
group_paused_at: string | null;
group_total_paused_time: string | null;
group_completed_at: string | null;
recorded_by: string | null;
recorded_at: string | null;
started_at: string | null;
}
interface Warehouse {
id: string;
warehouse_code: string;
warehouse_name: string;
}
interface WarehouseLocation {
id: string;
location_code: string;
location_name: string;
}
interface BatchHistoryItem {
seq: number;
batch_qty: number;
batch_good: number;
batch_defect: number;
accumulated_total: number;
changed_at: string;
changed_by: string | null;
}
type ActiveSection = "checklist" | "result" | "inventory" | "material";
const PHASE_ORDER: Record<string, number> = { PRE: 1, IN: 2, POST: 3 };
const PHASE_LABELS: Record<string, string> = {
PRE: "작업 전",
IN: "작업 중",
POST: "작업 후",
};
/* ================================================================== */
/* ISA-101 Design Tokens */
/* ================================================================== */
const DESIGN = {
bg: {
page: "#F5F5F5",
card: "#FFFFFF",
header: "#1a1a2e",
infoBar: "#1a1a2e",
},
sidebar: { width: 280 },
timer: { fontSize: 48 },
button: { height: 60, touchMin: 48 },
input: { height: 52 },
footer: { height: 64 },
} as const;
/* ================================================================== */
/* Numpad Modal */
/* ================================================================== */
const KEYS = [
{ label: "7", action: "7" },
{ label: "8", action: "8" },
{ label: "9", action: "9" },
{ label: "\u2190", action: "backspace" },
{ label: "4", action: "4" },
{ label: "5", action: "5" },
{ label: "6", action: "6" },
{ label: "C", action: "clear" },
{ label: "1", action: "1" },
{ label: "2", action: "2" },
{ label: "3", action: "3" },
{ label: "MAX", action: "max" },
];
function SimpleNumpadModal({
open,
onClose,
onConfirm,
maxQty,
title,
subtitle,
}: {
open: boolean;
onClose: () => void;
onConfirm: (qty: number) => void;
maxQty: number;
title: string;
subtitle: string;
}) {
const [qty, setQty] = useState("0");
useEffect(() => {
if (open) setQty("0");
}, [open]);
const qtyNum = parseInt(qty, 10) || 0;
const handleKey = useCallback(
(key: string) => {
setQty((prev) => {
switch (key) {
case "backspace":
return prev.length <= 1 ? "0" : prev.slice(0, -1);
case "clear":
return "0";
case "max":
return String(maxQty);
default: {
const next = prev === "0" ? key : prev + key;
const num = parseInt(next, 10);
if (isNaN(num)) return prev;
return next;
}
}
});
},
[maxQty],
);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white w-[90%] max-w-[360px] rounded-2xl shadow-2xl z-10 overflow-hidden">
<div
className="px-4 py-3"
style={{
background: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)",
}}
>
<span className="text-white font-bold text-base">{title}</span>
<p className="text-white/80 text-xs mt-0.5">{subtitle}</p>
</div>
<div className="p-4">
<input
type="text"
readOnly
value={qtyNum.toLocaleString()}
className="w-full px-4 py-3 text-right text-3xl font-bold border-2 border-gray-200 rounded-xl bg-gray-50 text-gray-900 mb-3"
style={{ fontVariantNumeric: "tabular-nums" }}
/>
<div className="grid grid-cols-4 gap-2.5">
{KEYS.map((key) => (
<button
key={key.action}
onClick={() => handleKey(key.action)}
className={`h-14 rounded-xl text-lg font-semibold active:scale-95 transition-all ${
key.action === "backspace" || key.action === "clear"
? "bg-amber-100 text-amber-700 hover:bg-amber-200"
: key.action === "max"
? "bg-blue-100 text-blue-700 hover:bg-blue-200 text-sm"
: "bg-gray-100 text-gray-900 hover:bg-gray-200"
}`}
>
{key.label}
</button>
))}
<button
onClick={() => handleKey("0")}
className="col-span-2 h-14 rounded-xl text-lg font-semibold bg-gray-100 text-gray-900 hover:bg-gray-200 active:scale-95 transition-all"
>
0
</button>
<button
onClick={() => {
if (qtyNum > 0) {
onConfirm(Math.min(qtyNum, maxQty));
onClose();
}
}}
disabled={qtyNum <= 0}
className="col-span-2 h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
background:
qtyNum <= 0
? "#9ca3af"
: "linear-gradient(135deg, #10b981 0%, #059669 100%)",
}}
>
</button>
</div>
</div>
</div>
</div>
);
}
/* ================================================================== */
/* Checklist Group */
/* ================================================================== */
interface ChecklistGroup {
phase: string;
title: string;
itemId: string;
sortOrder: number;
items: ChecklistItem[];
completed: number;
total: number;
timerStarted: boolean;
timerCompleted: boolean;
timerPaused: boolean;
timerState: {
startedAt: string | null;
pausedAt: string | null;
totalPausedTime: number;
completedAt: string | null;
};
}
/* ================================================================== */
/* Helper: formatTime */
/* ================================================================== */
function formatTime(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
function calcGroupWorkSeconds(timer: ChecklistGroup["timerState"]): number {
if (!timer.startedAt) return 0;
const now = Date.now();
const start = new Date(timer.startedAt).getTime();
const end = timer.completedAt ? new Date(timer.completedAt).getTime() : now;
let pausedMs = timer.totalPausedTime * 1000;
if (timer.pausedAt && !timer.completedAt) {
pausedMs += now - new Date(timer.pausedAt).getTime();
}
return Math.max(0, Math.floor((end - start - pausedMs) / 1000));
}
/* ================================================================== */
/* Main Component */
/* ================================================================== */
interface ProcessWorkProps {
processId: string;
}
export function ProcessWork({ processId }: ProcessWorkProps) {
const router = useRouter();
const contentRef = useRef<HTMLDivElement>(null);
/* ---- Core State ---- */
const { settings: popSettings } = usePopSettings();
const peSettings = popSettings.screens.processExecution;
const [process, setProcess] = useState<ProcessData | null>(null);
const [wiInfo, setWiInfo] = useState<WorkInstructionInfo | null>(null);
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
const [defectTypes, setDefectTypes] = useState<DefectType[]>([]);
const [processList, setProcessList] = useState<
Array<{ process_code: string; process_name: string }>
>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
/* ---- Navigation State ---- */
const [activeSection, setActiveSection] =
useState<ActiveSection>("checklist");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
/* ---- Timer tick ---- */
const [tick, setTick] = useState(Date.now());
/* ---- Production Input ---- */
const [productionQty, setProductionQty] = useState(0);
const [defectEntries, setDefectEntries] = useState<DefectEntry[]>([]);
const [resultNote, setResultNote] = useState("");
/* ---- Modals ---- */
const [prodQtyModal, setProdQtyModal] = useState(false);
const [defectModal, setDefectModal] = useState(false);
const [confirmModalState, setConfirmModalState] = useState<{
open: boolean;
message: string;
title?: string;
confirmText?: string;
variant?: "primary" | "danger" | "success";
onConfirm: () => void;
}>({ open: false, message: "", onConfirm: () => {} });
const askConfirm = (
message: string,
onConfirm: () => void,
opts?: {
title?: string;
confirmText?: string;
variant?: "primary" | "danger" | "success";
},
) => {
setConfirmModalState({
open: true,
message,
title: opts?.title,
confirmText: opts?.confirmText,
variant: opts?.variant,
onConfirm: () => {
setConfirmModalState((s) => ({ ...s, open: false }));
onConfirm();
},
});
};
/* ---- Last Process / Warehouse ---- */
const [isLastProcess, setIsLastProcess] = useState(false);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [warehouseLocations, setWarehouseLocations] = useState<
WarehouseLocation[]
>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
const [selectedLocation, setSelectedLocation] = useState<string>("");
const [packageUnit, setPackageUnit] = useState<string>("");
const [inboundDone, setInboundDone] = useState(false);
/* ---- Batch Badge (단일/다중품목) ---- */
const [batchBadge, setBatchBadge] = useState<{
isMulti: boolean;
index: number;
total: number;
itemType: string;
} | null>(null);
/* ---- Batch History ---- */
const [history, setHistory] = useState<BatchHistoryItem[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
/* ================================================================ */
/* Data Fetch */
/* ================================================================ */
const fetchProcess = useCallback(async () => {
setLoading(true);
try {
// 1. Fetch process data
const procRes = await dataApi.getTableData("work_order_process", {
size: 1,
filters: { id: processId },
});
const procData = (procRes.data?.[0] ?? null) as ProcessData | null;
if (procData) {
setProcess(procData);
// 2. Fetch work instruction info
if (procData.wo_id) {
try {
const wiRes = await dataApi.getTableData("work_instruction", {
size: 1,
filters: { id: procData.wo_id },
});
const wi = wiRes.data?.[0] as Record<string, unknown> | undefined;
if (wi) {
let itemName = String(wi.item_name || "");
let itemCode = String(wi.item_code || wi.item_number || "");
// item_name이 비어있고 item_id가 있으면 item_info에서 조회
if (!itemName && wi.item_id) {
try {
const itemRes = await apiClient.get(
`/data/item_info/${wi.item_id}`,
);
const item = itemRes.data?.data;
if (item) {
itemName = String(item.item_name || "");
itemCode = String(item.item_number || item.item_code || "");
}
} catch {
/* non-critical */
}
}
// batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원)
let batchItemType = "";
if (procData.batch_id) {
try {
const batchItemRes = await dataApi.getTableData("item_info", {
size: 1,
filters: { item_number: procData.batch_id },
});
const batchItem = batchItemRes.data?.[0] as Record<string, unknown> | undefined;
if (batchItem) {
itemName = String(batchItem.item_name || procData.batch_id);
itemCode = String(batchItem.item_number || procData.batch_id);
batchItemType = String(batchItem.type || "");
} else {
itemName = procData.batch_id;
itemCode = procData.batch_id;
}
} catch {
itemName = procData.batch_id;
itemCode = procData.batch_id;
}
}
// item_type이 없으면 WI의 item_number로 조회
if (!batchItemType && wi.item_number) {
try {
const wiItemRes = await dataApi.getTableData("item_info", {
size: 1,
filters: { item_number: String(wi.item_number) },
});
const wiItem = wiItemRes.data?.[0] as Record<string, unknown> | undefined;
if (wiItem) {
batchItemType = String(wiItem.type || "");
}
} catch {
/* non-critical */
}
}
// batchItemType을 임시 저장 (step 6에서 사용)
(procData as unknown as Record<string, unknown>)._itemType = batchItemType;
setWiInfo({
work_instruction_no: String(wi.work_instruction_no || ""),
item_name: itemName,
item_code: itemCode,
qty: parseInt(String(wi.qty), 10) || 0,
});
}
} catch {
// Non-critical
}
}
}
// 3. Fetch checklist (process_work_result)
const checkRes = await dataApi.getTableData("process_work_result", {
size: 500,
filters: { work_order_process_id: processId },
});
setChecklist((checkRes.data ?? []) as ChecklistItem[]);
// 4. Defect types
try {
const dtRes = await apiClient.get("/pop/production/defect-types");
setDefectTypes(dtRes.data?.data || []);
} catch {
setDefectTypes([]);
}
// 5. Is last process
try {
const lpRes = await apiClient.get(
`/pop/production/is-last-process/${processId}`,
);
const lpData = lpRes.data?.data;
setIsLastProcess(lpData?.isLast || false);
if (lpData?.targetWarehouseId) {
setSelectedWarehouse(lpData.targetWarehouseId);
setSelectedLocation(lpData.targetLocationCode || "");
setInboundDone(true);
}
} catch {
setIsLastProcess(false);
}
// 6. 같은 작업지시의 공정 목록 (재작업 공정 지정용)
if (procData?.wo_id) {
try {
const plRes = await dataApi.getTableData("work_order_process", {
size: 100,
filters: { wo_id: procData.wo_id },
});
const allSiblings = (plRes.data ?? []) as ProcessData[];
const masters = allSiblings
.filter((p) => !p.parent_process_id)
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
// 중복 제거
const seen = new Set<string>();
setProcessList(
masters.map((p) => ({
process_code: p.process_code,
process_name: p.process_name,
})).filter((m) => {
if (seen.has(m.process_code)) return false;
seen.add(m.process_code);
return true;
}),
);
// 다중품목 판단: 마스터 공정의 DISTINCT batch_id
const uniqueBatches: string[] = [];
for (const p of masters) {
const bid = p.batch_id || "";
if (bid && !uniqueBatches.includes(bid)) {
uniqueBatches.push(bid);
}
}
const currentBid = procData?.batch_id || "";
const isMultiBatch = uniqueBatches.length > 1;
const bIdx = currentBid ? uniqueBatches.indexOf(currentBid) + 1 : 1;
const fetchedItemType = String((procData as unknown as Record<string, unknown>)?._itemType || "");
setBatchBadge({
isMulti: isMultiBatch,
index: Math.max(bIdx, 1),
total: Math.max(uniqueBatches.length, 1),
itemType: fetchedItemType,
});
} catch {
setProcessList([]);
}
}
// 7. Warehouses
try {
const whRes = await apiClient.get("/pop/production/warehouses");
setWarehouses(whRes.data?.data || []);
} catch {
setWarehouses([]);
}
} catch (error) {
console.error("[ProcessWork] fetch error:", error);
} finally {
setLoading(false);
}
}, [processId]);
useEffect(() => {
fetchProcess();
}, [fetchProcess]);
/* ---- Batch History ---- */
const loadHistory = useCallback(async () => {
setHistoryLoading(true);
try {
const res = await apiClient.get("/pop/production/result-history", {
params: { work_order_process_id: processId },
});
if (res.data?.success) {
setHistory(res.data.data || []);
}
} catch {
/* ignore */
} finally {
setHistoryLoading(false);
}
}, [processId]);
useEffect(() => {
loadHistory();
}, [loadHistory]);
/* ---- Timer tick for group timers ---- */
useEffect(() => {
const id = setInterval(() => setTick(Date.now()), 1000);
return () => clearInterval(id);
}, []);
/* ================================================================ */
/* Checklist Groups */
/* ================================================================ */
const groups = useMemo<ChecklistGroup[]>(() => {
const map = new Map<string, ChecklistGroup>();
for (const item of checklist) {
const key = item.source_work_item_id;
if (!map.has(key)) {
map.set(key, {
phase: item.work_phase,
title: item.item_title,
itemId: key,
sortOrder: parseInt(item.item_sort_order || "0", 10),
items: [],
completed: 0,
total: 0,
timerStarted: !!item.group_started_at,
timerCompleted: !!item.group_completed_at,
timerPaused: !!item.group_paused_at,
timerState: {
startedAt: item.group_started_at ?? null,
pausedAt: item.group_paused_at ?? null,
totalPausedTime: parseInt(item.group_total_paused_time || "0", 10),
completedAt: item.group_completed_at ?? null,
},
});
}
const g = map.get(key)!;
g.items.push(item);
g.total++;
if (item.status === "recorded" || item.status === "completed")
g.completed++;
// Update timer state (any row may have timer data)
if (item.group_started_at) {
g.timerStarted = true;
g.timerState.startedAt = item.group_started_at;
}
if (item.group_completed_at) {
g.timerCompleted = true;
g.timerState.completedAt = item.group_completed_at;
}
if (item.group_paused_at) {
g.timerPaused = true;
g.timerState.pausedAt = item.group_paused_at;
}
if (item.group_total_paused_time) {
g.timerState.totalPausedTime =
parseInt(item.group_total_paused_time, 10) || 0;
}
}
return Array.from(map.values()).sort(
(a, b) =>
(PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) ||
a.sortOrder - b.sortOrder,
);
}, [checklist]);
const groupsByPhase = useMemo(() => {
const result: Record<string, ChecklistGroup[]> = {};
for (const g of groups) {
if (!result[g.phase]) result[g.phase] = [];
result[g.phase].push(g);
}
return result;
}, [groups]);
const availablePhases = useMemo(() => {
const phases: string[] = [];
if (groupsByPhase["PRE"]?.length) phases.push("PRE");
if (groupsByPhase["IN"]?.length) phases.push("IN");
if (groupsByPhase["POST"]?.length) phases.push("POST");
return phases;
}, [groupsByPhase]);
// Auto-select first group
useEffect(() => {
if (groups.length > 0 && !selectedGroupId) {
setSelectedGroupId(groups[0].itemId);
}
}, [groups, selectedGroupId]);
const selectedGroup = useMemo(
() => groups.find((g) => g.itemId === selectedGroupId) || null,
[groups, selectedGroupId],
);
const currentItems = useMemo(
() =>
selectedGroup?.items.sort(
(a, b) =>
parseInt(a.detail_sort_order || "0", 10) -
parseInt(b.detail_sort_order || "0", 10),
) || [],
[selectedGroup],
);
/* ================================================================ */
/* Timer Handlers */
/* ================================================================ */
const timerStatus: TimerStatus = (() => {
if (!process) return "idle";
if (process.status === "completed") return "completed";
if (process.paused_at) return "paused";
if (process.started_at) return "running";
return "idle";
})();
const handleTimerAction = async (
action: "start" | "pause" | "resume" | "complete",
) => {
// 낙관적 업데이트: UI 즉시 반영
setProcess((prev) => {
if (!prev) return prev;
const now = new Date().toISOString();
if (action === "start")
return {
...prev,
status: "in_progress",
started_at: now,
paused_at: null,
};
if (action === "pause") return { ...prev, paused_at: now };
if (action === "resume") {
const pausedSec = prev.paused_at
? Math.floor((Date.now() - new Date(prev.paused_at).getTime()) / 1000)
: 0;
return {
...prev,
paused_at: null,
total_paused_time: String(
(parseInt(prev.total_paused_time || "0", 10) || 0) + pausedSec,
),
};
}
if (action === "complete")
return {
...prev,
status: "completed",
completed_at: now,
paused_at: null,
};
return prev;
});
// API 백그라운드 호출
try {
const body: Record<string, unknown> = {
work_order_process_id: processId,
action,
};
if (action === "complete") {
body.good_qty = process?.good_qty || "0";
body.defect_qty = process?.defect_qty || "0";
}
await apiClient.post("/pop/production/timer", body);
// 서버 데이터로 동기화 (조용히)
fetchProcess();
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } };
alert(err.response?.data?.message || "타이머 오류");
fetchProcess(); // 실패 시 서버 상태로 복원
}
};
/* ---- Group Timer (낙관적 업데이트) ---- */
const handleGroupTimerAction = (
action: "start" | "pause" | "resume" | "complete",
groupItemId: string,
) => {
// 로컬 체크리스트 상태 즉시 업데이트
const now = new Date().toISOString();
setChecklist((prev) =>
prev.map((item) => {
if (item.source_work_item_id !== groupItemId) return item;
if (action === "start")
return { ...item, group_started_at: now, group_paused_at: null };
if (action === "pause") return { ...item, group_paused_at: now };
if (action === "resume") {
const pausedSec = item.group_paused_at
? Math.floor(
(Date.now() - new Date(item.group_paused_at).getTime()) / 1000,
)
: 0;
const prev_total =
parseInt(item.group_total_paused_time || "0", 10) || 0;
return {
...item,
group_paused_at: null,
group_total_paused_time: String(prev_total + pausedSec),
};
}
if (action === "complete")
return { ...item, group_completed_at: now, group_paused_at: null };
return item;
}),
);
// API 백그라운드 (결과 무시, 실패 시만 동기화)
apiClient
.post("/pop/production/group-timer", {
work_order_process_id: processId,
source_work_item_id: groupItemId,
action,
})
.catch(() => {
fetchProcess();
});
};
/* ---- Group timer display ---- */
const selectedGroupWorkSec = useMemo(() => {
if (!selectedGroup) return 0;
return calcGroupWorkSeconds(selectedGroup.timerState);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedGroup, tick]);
/* ================================================================ */
/* Checklist Save */
/* ================================================================ */
const handleChecklistSave = async (
itemId: string,
resultValue: string,
isPassed: string | null,
) => {
try {
const now = new Date().toISOString();
await apiClient.post("/pop/execute-action", {
tasks: [
{
type: "data-update",
targetTable: "process_work_result",
targetColumn: "result_value",
operationType: "assign",
valueSource: "fixed",
fixedValue: resultValue,
lookupMode: "manual",
manualItemField: "id",
manualPkColumn: "id",
},
{
type: "data-update",
targetTable: "process_work_result",
targetColumn: "status",
operationType: "assign",
valueSource: "fixed",
fixedValue: "recorded",
lookupMode: "manual",
manualItemField: "id",
manualPkColumn: "id",
},
...(isPassed !== null
? [
{
type: "data-update",
targetTable: "process_work_result",
targetColumn: "is_passed",
operationType: "assign",
valueSource: "fixed",
fixedValue: isPassed,
lookupMode: "manual",
manualItemField: "id",
manualPkColumn: "id",
},
]
: []),
{
type: "data-update",
targetTable: "process_work_result",
targetColumn: "recorded_at",
operationType: "assign",
valueSource: "fixed",
fixedValue: now,
lookupMode: "manual",
manualItemField: "id",
manualPkColumn: "id",
},
],
data: { items: [{ id: itemId }], fieldValues: {} },
});
// Update local state
setChecklist((prev) =>
prev.map((c) =>
c.id === itemId
? {
...c,
result_value: resultValue,
is_passed: isPassed,
status: "recorded",
recorded_at: now,
}
: c,
),
);
} catch {
alert("체크리스트 저장 실패");
}
};
/* ================================================================ */
/* Save Result (batch-cumulative) */
/* ================================================================ */
const handleSaveResult = async () => {
if (productionQty <= 0) {
alert("생산수량을 입력해주세요.");
return;
}
setSaving(true);
try {
const totalDefect = defectEntries.reduce(
(s, e) => s + parseInt(e.qty, 10),
0,
);
const res = await apiClient.post("/pop/production/save-result", {
work_order_process_id: processId,
production_qty: String(productionQty),
good_qty: String(productionQty - totalDefect),
defect_qty: String(totalDefect),
defect_detail: defectEntries.length > 0 ? defectEntries : undefined,
result_note: resultNote || undefined,
});
if (res.data?.success) {
const d = res.data.data;
setProcess((prev) => {
if (!prev) return prev;
return { ...prev, ...d };
});
setProductionQty(0);
setDefectEntries([]);
setResultNote("");
loadHistory();
if (d?.status === "completed") {
alert("모든 수량이 완료되어 자동 확정되었습니다.");
} else {
alert("실적이 저장되었습니다.");
}
} else {
alert(res.data?.message || "저장 실패");
}
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } };
alert(err.response?.data?.message || "실적 저장 중 오류");
} finally {
setSaving(false);
}
};
/* ---- Confirm Result ---- */
const handleConfirmResult = () => {
askConfirm(
"실적을 확정하시겠습니까?\n확정 후에는 추가 등록이 불가합니다.",
async () => {
setSaving(true);
try {
const res = await apiClient.post("/pop/production/confirm-result", {
work_order_process_id: processId,
});
if (res.data?.success) {
await fetchProcess();
alert("실적이 확정되었습니다.");
} else {
alert(res.data?.message || "확정 실패");
}
} catch (error: unknown) {
const err = error as { response?: { data?: { message?: string } } };
alert(err.response?.data?.message || "확정 중 오류");
} finally {
setSaving(false);
}
},
{ title: "실적 확정", confirmText: "확정", variant: "success" },
);
};
/* ================================================================ */
/* Inventory Inbound */
/* ================================================================ */
const fetchLocations = useCallback(async (warehouseId: string) => {
if (!warehouseId) {
setWarehouseLocations([]);
return;
}
try {
const res = await apiClient.get(
`/pop/production/warehouse-locations/${warehouseId}`,
);
setWarehouseLocations(res.data?.data || []);
} catch {
setWarehouseLocations([]);
}
}, []);
const handleInbound = () => {
if (!selectedWarehouse) {
alert("창고를 선택해주세요.");
return;
}
askConfirm(
"생산입고를 진행하시겠습니까?",
async () => {
setSaving(true);
try {
const wh = warehouses.find((w) => w.id === selectedWarehouse);
const warehouseCode = wh?.warehouse_code || selectedWarehouse;
const res = await apiClient.post(
"/pop/production/inventory-inbound",
{
work_order_process_id: processId,
warehouse_code: warehouseCode,
location_code: selectedLocation || undefined,
},
);
if (res.data?.success) {
setInboundDone(true);
alert(`재고 입고 완료: ${res.data.data?.qty || 0}`);
} else {
alert(res.data?.message || "입고 실패");
}
} catch (error: unknown) {
const err = error as {
response?: { data?: { message?: string }; status?: number };
};
const msg = err.response?.data?.message;
if (err.response?.status === 409) {
setInboundDone(true);
alert(msg || "이미 입고 완료");
} else {
alert(msg || "입고 중 오류");
}
} finally {
setSaving(false);
}
},
{ title: "생산 입고", confirmText: "입고", variant: "primary" },
);
};
/* ================================================================ */
/* Computed Values */
/* ================================================================ */
const totalDefectQty = defectEntries.reduce(
(s, e) => s + parseInt(e.qty, 10),
0,
);
const goodQtyThisBatch = productionQty - totalDefectQty;
const inputQty = parseInt(process?.input_qty || "0", 10);
const totalProduced = parseInt(process?.total_production_qty || "0", 10);
const accumulatedGood = parseInt(process?.good_qty || "0", 10);
const accumulatedDefect = parseInt(process?.defect_qty || "0", 10);
const remaining = Math.max(0, inputQty - totalProduced);
const isCompleted = process?.status === "completed";
const isConfirmed = process?.result_status === "confirmed";
const hasChecklist = checklist.length > 0;
/* ================================================================ */
/* Loading / Error */
/* ================================================================ */
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-3 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-gray-400"> ...</span>
</div>
</div>
);
}
if (!process) {
return (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<p className="text-gray-400"> </p>
<button
onClick={() => router.back()}
className="px-4 py-2 rounded-xl bg-gray-100 text-gray-700 text-sm font-semibold active:scale-95 transition-all"
>
</button>
</div>
);
}
/* ================================================================ */
/* Render */
/* ================================================================ */
return (
<div
className="flex flex-col h-full"
style={{ backgroundColor: DESIGN.bg.page }}
>
{/* ============================================================ */}
{/* Info Bar (Dark Header) */}
{/* ============================================================ */}
<div
className="shrink-0 px-4 sm:px-6 py-3"
style={{ backgroundColor: DESIGN.bg.infoBar }}
>
<div className="flex items-center gap-3 sm:gap-6">
<div className="flex items-center gap-4 sm:gap-8 flex-1 min-w-0 overflow-x-auto">
{wiInfo && (
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-white/40 text-sm"></span>
<span className="text-white font-medium text-base">
{wiInfo.work_instruction_no}
</span>
</div>
)}
{wiInfo && (
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-white/40 text-sm"></span>
<span className="text-white font-medium text-base">
{wiInfo.item_name}
</span>
</div>
)}
{batchBadge && (
<div className="flex items-center gap-1.5 shrink-0">
{batchBadge.isMulti ? (
<span className="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded-full">
{batchBadge.index}/{batchBadge.total}{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""}
</span>
) : (
<span className="bg-gray-100 text-gray-600 text-xs font-bold px-2 py-0.5 rounded-full">
{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""}
</span>
)}
</div>
)}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-white/40 text-sm"></span>
<span className="text-white font-medium text-base">
{process.seq_no ? `${process.seq_no}. ` : ""}
{process.process_name || "공정"}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-white/40 text-sm"></span>
<span className="text-white font-medium text-base">
{parseInt(process.plan_qty || "0", 10).toLocaleString()}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-amber-400/80 text-sm"></span>
<span className="text-amber-300 font-bold text-lg">
{inputQty.toLocaleString()}
</span>
</div>
</div>
{/* Status badge */}
<span
className={`text-[13px] font-bold px-2.5 py-1 rounded-full shrink-0 ${
isCompleted
? "bg-green-500/20 text-green-300"
: process.status === "in_progress"
? "bg-blue-500/20 text-blue-300"
: "bg-white/10 text-white/50"
}`}
>
{isCompleted
? "완료"
: process.status === "in_progress"
? "진행중"
: process.status}
</span>
{process.is_rework === "Y" && (
<span className="text-[13px] font-bold px-2 py-0.5 rounded-full bg-amber-500/20 text-amber-300 shrink-0">
</span>
)}
</div>
</div>
{/* ============================================================ */}
{/* Main Layout: Sidebar(left) + Timer+Content(right) */}
{/* ============================================================ */}
{(hasChecklist || !isConfirmed || (isLastProcess && !inboundDone)) && (
<div className="flex flex-1 gap-0 min-h-0 overflow-hidden">
{/* ========================================================= */}
{/* Left Sidebar (always visible) */}
{/* ========================================================= */}
<div
className="shrink-0 flex flex-col overflow-y-auto border-r border-gray-200 bg-gray-50"
style={{
width: `${DESIGN.sidebar.width}px`,
scrollbarWidth: "thin",
}}
>
<div className="py-3">
{/* Checklist groups by phase */}
{availablePhases.map((phase) => {
const phaseGroups = groupsByPhase[phase] || [];
const phaseDone = phaseGroups.reduce(
(s, g) => s + g.completed,
0,
);
const phaseTotal = phaseGroups.reduce((s, g) => s + g.total, 0);
const allDone = phaseDone >= phaseTotal && phaseTotal > 0;
return (
<div key={phase} className="mb-4">
<div className="flex items-center gap-2 px-3 mb-1.5">
<div
className={`w-4 h-4 rounded-full flex items-center justify-center ${
allDone ? "bg-green-500" : "bg-gray-300"
}`}
>
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={3}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</div>
<span className="text-base font-bold text-gray-400 uppercase tracking-wider">
{PHASE_LABELS[phase] || phase}
</span>
<span
className={`ml-auto text-[13px] font-semibold ${allDone ? "text-green-500" : "text-gray-300"}`}
>
{phaseDone}/{phaseTotal}
</span>
</div>
{phaseGroups.map((g) => {
const isSelected =
selectedGroupId === g.itemId &&
activeSection === "checklist";
const isDone = g.completed >= g.total && g.total > 0;
return (
<button
key={g.itemId}
onClick={() => {
setSelectedGroupId(g.itemId);
setActiveSection("checklist");
contentRef.current?.scrollTo({
top: 0,
behavior: "smooth",
});
}}
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
isSelected
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
: isDone
? "bg-green-50/50 border border-green-200"
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
style={{ width: "calc(100% - 16px)" }}
>
<span
className={`w-3 h-3 rounded-full shrink-0 ${
isDone
? "bg-green-500"
: g.timerStarted
? "bg-blue-500 animate-pulse"
: isSelected
? "bg-blue-500"
: "bg-gray-300"
}`}
/>
<span
className={`text-sm flex-1 ${
isSelected
? "font-bold text-blue-800"
: isDone
? "text-green-700 font-medium"
: "text-gray-700 font-medium"
}`}
>
{g.title}
</span>
<span
className={`text-xs font-bold px-2 py-0.5 rounded-full ${
isDone
? "bg-green-100 text-green-600"
: isSelected
? "bg-blue-100 text-blue-600"
: "bg-gray-100 text-gray-400"
}`}
>
{g.completed}/{g.total}
</span>
</button>
);
})}
</div>
);
})}
{/* Result section link */}
{!isConfirmed && (
<div className="mb-4">
{/* 자재 투입 (설정으로 제어) */}
{peSettings.materialInput && (
<button
onClick={() => {
setActiveSection("material");
contentRef.current?.scrollTo({
top: 0,
behavior: "smooth",
});
}}
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
activeSection === "material"
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
style={{ width: "calc(100% - 16px)" }}
>
<span className="text-base">📦</span>
<span
className={`text-sm font-medium ${activeSection === "material" ? "font-bold text-blue-800" : "text-gray-700"}`}
>
</span>
</button>
)}
<button
onClick={() => {
setActiveSection("result");
contentRef.current?.scrollTo({
top: 0,
behavior: "smooth",
});
}}
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
activeSection === "result"
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
style={{ width: "calc(100% - 16px)" }}
>
<span className="text-base">📋</span>
<span
className={`text-sm font-medium ${activeSection === "result" ? "font-bold text-blue-800" : "text-gray-700"}`}
>
</span>
</button>
</div>
)}
{/* Inventory section link */}
{isLastProcess && (
<div>
<button
onClick={() => {
setActiveSection("inventory");
contentRef.current?.scrollTo({
top: 0,
behavior: "smooth",
});
}}
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
activeSection === "inventory"
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
: inboundDone
? "bg-green-50 border border-green-300"
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
style={{ width: "calc(100% - 16px)" }}
>
<span className="text-base">{inboundDone ? "✅" : "🏭"}</span>
<span
className={`text-sm font-medium ${
activeSection === "inventory"
? "font-bold text-blue-800"
: inboundDone
? "text-green-700 font-bold"
: "text-gray-700"
}`}
>
{inboundDone ? " 완료" : ""}
</span>
</button>
</div>
)}
</div>
</div>
{/* ========================================================= */}
{/* Mobile Tabs (hidden — sidebar always visible) */}
{/* ========================================================= */}
<div className="hidden shrink-0 flex overflow-x-auto bg-white border-b border-gray-200 px-2 gap-1">
{availablePhases.map((phase) => {
const phaseGroups = groupsByPhase[phase] || [];
const phaseDone = phaseGroups.reduce(
(s, g) => s + g.completed,
0,
);
const phaseTotal = phaseGroups.reduce((s, g) => s + g.total, 0);
const isActive =
activeSection === "checklist" &&
selectedGroup &&
selectedGroup.phase === phase;
return (
<button
key={phase}
onClick={() => {
const grps = groupsByPhase[phase];
if (grps && grps.length > 0) {
setSelectedGroupId(grps[0].itemId);
setActiveSection("checklist");
}
}}
className={`shrink-0 px-3 py-2 text-xs font-semibold rounded-t-lg transition-all ${
isActive
? "bg-blue-50 text-blue-700 border-b-2 border-blue-500"
: "text-gray-500"
}`}
>
{PHASE_LABELS[phase]} ({phaseDone}/{phaseTotal})
</button>
);
})}
{!isConfirmed && (
<button
onClick={() => setActiveSection("result")}
className={`shrink-0 px-3 py-2 text-xs font-semibold rounded-t-lg transition-all ${
activeSection === "result"
? "bg-amber-50 text-amber-700 border-b-2 border-amber-500"
: "text-gray-500"
}`}
>
</button>
)}
{isLastProcess && (
<button
onClick={() => setActiveSection("inventory")}
className={`shrink-0 px-3 py-2 text-xs font-semibold rounded-t-lg transition-all ${
activeSection === "inventory"
? "bg-green-50 text-green-700 border-b-2 border-green-500"
: "text-gray-500"
}`}
>
{inboundDone && "✓"}
</button>
)}
</div>
{/* ========================================================= */}
{/* Right Column: Timer + Content */}
{/* ========================================================= */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Unified Timer Bar (group-level) */}
<div
className={`shrink-0 px-4 py-3 border-b bg-white ${
selectedGroup?.timerPaused
? "border-amber-200"
: selectedGroup?.timerCompleted
? "border-green-200"
: "border-gray-100"
}`}
>
{(() => {
const g = selectedGroup;
const groupElapsed = g?.timerState.startedAt
? Math.max(
0,
Math.floor(
((g.timerState.completedAt
? new Date(g.timerState.completedAt).getTime()
: Date.now()) -
new Date(g.timerState.startedAt).getTime()) /
1000,
),
)
: 0;
const groupWork = selectedGroupWorkSec;
const isIdle = !g?.timerStarted;
const isPaused = g?.timerPaused;
const isDone = g?.timerCompleted;
const isRunning = g?.timerStarted && !isPaused && !isDone;
const btnClass =
"h-12 min-w-[80px] rounded-xl text-base font-bold text-white active:scale-95 transition-all px-4";
return (
<div className="flex items-center gap-4">
<span
className="text-2xl font-black tracking-wider text-gray-900"
style={{
fontVariantNumeric: "tabular-nums",
fontFamily: "monospace",
}}
>
{formatTime(groupElapsed)}
</span>
<span
className="text-lg font-semibold tracking-wider text-gray-300"
style={{
fontVariantNumeric: "tabular-nums",
fontFamily: "monospace",
}}
>
{formatTime(groupWork)}
</span>
<span
className={`flex-1 text-base font-bold truncate ${
isPaused
? "text-amber-700"
: isDone
? "text-green-700"
: "text-gray-900"
}`}
>
{g?.title || "그룹 선택"}
{isPaused && (
<span className="text-xs text-amber-500 ml-2">
</span>
)}
{isDone && (
<span className="text-xs text-green-500 ml-2">
</span>
)}
</span>
<div className="flex gap-2 shrink-0">
{isIdle && g && (
<button
onClick={() =>
handleGroupTimerAction("start", g.itemId)
}
className={btnClass}
style={{
background:
"linear-gradient(135deg,#3b82f6,#1d4ed8)",
}}
>
</button>
)}
{isRunning && g && (
<>
<button
onClick={() =>
handleGroupTimerAction("pause", g.itemId)
}
className={btnClass}
style={{
background:
"linear-gradient(135deg,#f59e0b,#d97706)",
}}
>
</button>
<button
onClick={() =>
handleGroupTimerAction("complete", g.itemId)
}
className={btnClass}
style={{
background:
"linear-gradient(135deg,#10b981,#059669)",
}}
>
</button>
</>
)}
{isPaused && g && (
<>
<button
onClick={() =>
handleGroupTimerAction("resume", g.itemId)
}
className={btnClass}
style={{
background:
"linear-gradient(135deg,#3b82f6,#1d4ed8)",
}}
>
</button>
<button
onClick={() =>
handleGroupTimerAction("complete", g.itemId)
}
className={btnClass}
style={{
background:
"linear-gradient(135deg,#10b981,#059669)",
}}
>
</button>
</>
)}
</div>
</div>
);
})()}
</div>
{/* Content Area (scrollable) */}
<div ref={contentRef} className="flex-1 overflow-y-auto p-4">
{/* Checklist Content */}
{activeSection === "checklist" && selectedGroup && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
{/* Group header with timer */}
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-xs text-gray-400 font-medium">
{PHASE_LABELS[selectedGroup.phase] ||
selectedGroup.phase}
</p>
<p className="text-base font-bold text-gray-900">
{selectedGroup.title}
</p>
</div>
<span
className={`text-xs font-semibold px-2.5 py-1 rounded-full ${
selectedGroup.completed >= selectedGroup.total &&
selectedGroup.total > 0
? "bg-green-100 text-green-700"
: selectedGroup.timerStarted
? "bg-blue-100 text-blue-700"
: "bg-gray-100 text-gray-500"
}`}
>
{selectedGroup.completed}/{selectedGroup.total}
</span>
</div>
{/* 그룹 타이머는 상단 통합 타이머로 이동 */}
{/* Mobile group navigation (sidebar not visible) */}
<div className="md:hidden flex overflow-x-auto gap-1.5 mb-4 pb-1">
{(groupsByPhase[selectedGroup.phase] || []).map((g) => {
const isSelected = g.itemId === selectedGroupId;
const isDone = g.completed >= g.total && g.total > 0;
return (
<button
key={g.itemId}
onClick={() => setSelectedGroupId(g.itemId)}
className={`shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
isSelected
? "border-blue-400 bg-blue-50 text-blue-700"
: isDone
? "border-green-200 bg-green-50 text-green-600"
: "border-gray-200 bg-white text-gray-600"
}`}
>
{g.title} ({g.completed}/{g.total})
</button>
);
})}
</div>
{/* Checklist items */}
<div className="flex flex-col gap-2">
{currentItems.map((item) => (
<ChecklistRow
key={item.id}
item={item}
disabled={isCompleted || false}
onSave={handleChecklistSave}
showPhoto={peSettings.groupPhotoEnabled}
/>
))}
{currentItems.length === 0 && (
<p className="text-sm text-gray-400 text-center py-6">
</p>
)}
</div>
</div>
)}
{/* ====== Material Input Content (설정) ====== */}
{activeSection === "material" && peSettings.materialInput && (
<MaterialInputSection processId={processId} />
)}
{/* ====== Result Content ====== */}
{activeSection === "result" && !isConfirmed && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<p className="text-sm font-bold text-gray-900 mb-4 flex items-center gap-2">
<svg
className="w-4 h-4 text-blue-500"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{remaining > 0 && (
<span className="text-xs font-normal text-gray-400 ml-auto">
: {remaining.toLocaleString()}
</span>
)}
</p>
<div className="flex flex-col gap-3">
{/* Production Qty */}
<button
onClick={() => setProdQtyModal(true)}
className="flex items-center justify-between p-3.5 rounded-xl border-2 border-gray-200 bg-gray-50 hover:border-blue-300 active:scale-[0.98] transition-all"
>
<span className="text-sm text-gray-500"></span>
<span
className="text-xl font-bold text-gray-900"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{productionQty > 0
? `${productionQty.toLocaleString()} EA`
: "0 EA"}
</span>
</button>
{/* Good Qty (자동 계산) */}
<div className="flex items-center justify-between p-3.5 rounded-xl border-2 border-green-200 bg-green-50">
<span className="text-sm text-green-600 font-medium">
</span>
<span
className="text-xl font-bold text-green-700"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{goodQtyThisBatch > 0
? `${goodQtyThisBatch.toLocaleString()} EA`
: "0 EA"}
</span>
</div>
{/* Defect */}
<button
onClick={() => setDefectModal(true)}
className="flex items-center justify-between p-3.5 rounded-xl border-2 border-gray-200 bg-gray-50 hover:border-red-300 active:scale-[0.98] transition-all"
>
<span className="text-sm text-gray-500"></span>
<span
className={`text-xl font-bold ${totalDefectQty > 0 ? "text-red-600" : "text-gray-400"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{totalDefectQty > 0
? `${totalDefectQty.toLocaleString()} EA`
: "0 EA"}
</span>
</button>
{/* Defect entries summary */}
{defectEntries.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{defectEntries.map((de) => (
<span
key={de.defect_code}
className="text-xs px-2 py-1 rounded-full bg-red-50 text-red-600 border border-red-200 font-medium"
>
{de.defect_name}: {de.qty} (
{de.disposition === "scrap"
? "폐기"
: de.disposition === "rework"
? "재작업"
: "특채"}
)
</span>
))}
</div>
)}
{/* 누적 현황 */}
{totalProduced > 0 && (
<div className="text-xs text-gray-400 text-center">
: {totalProduced}/{inputQty} (
{Math.round((totalProduced / inputQty) * 100)}%)
</div>
)}
{/* Note */}
<textarea
value={resultNote}
onChange={(e) => setResultNote(e.target.value)}
placeholder="비고 (선택)"
className="w-full px-3 py-2.5 rounded-xl border-2 border-gray-200 text-sm text-gray-700 resize-none focus:outline-none focus:border-blue-300"
rows={2}
/>
</div>
{/* 사진 첨부 (설정으로 제어) */}
{peSettings.photoUpload && (
<div className="mt-3">
<label className="flex items-center gap-2 px-4 py-3 rounded-xl bg-gray-50 border-2 border-dashed border-gray-200 text-gray-500 text-sm font-medium cursor-pointer hover:bg-gray-100 active:scale-[0.98] transition-all">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
/>
</svg>
📷 ()
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
formData.append(
"targetTable",
"work_order_process",
);
formData.append("targetObjid", processId);
try {
const res = await fetch("/api/files", {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("accessToken") || localStorage.getItem("token") || ""}`,
},
body: formData,
});
alert(res.ok ? "사진 첨부 완료" : "첨부 실패");
} catch {
alert("첨부 오류");
}
e.target.value = "";
}}
/>
</label>
</div>
)}
{/* Save + Confirm buttons */}
<div className="flex flex-col gap-3 mt-4">
<button
onClick={handleSaveResult}
disabled={productionQty <= 0 || saving}
className="w-full h-12 rounded-xl text-base font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
background:
productionQty <= 0 || saving
? "#9ca3af"
: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)",
}}
>
{saving
? "저장중..."
: totalProduced + productionQty >= inputQty &&
inputQty > 0
? "작업 완료"
: "분할 완료"}
</button>
{/* 공정 확정 버튼 제거 (CEO 결정 2026-04-09): 작업 완료/분할 완료로 충분 */}
</div>
{/* Batch History */}
{history.length > 0 && (
<div className="mt-6">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-400">
<th className="px-2 py-1.5 text-left font-medium">
</th>
<th className="px-2 py-1.5 text-right font-medium">
</th>
<th className="px-2 py-1.5 text-right font-medium">
</th>
<th className="px-2 py-1.5 text-right font-medium">
</th>
<th className="px-2 py-1.5 text-right font-medium">
</th>
<th className="px-2 py-1.5 text-right font-medium">
</th>
</tr>
</thead>
<tbody>
{[...history].reverse().map((h, i) => (
<tr
key={h.seq}
className="border-t border-gray-100"
>
<td className="px-2 py-2">
<span
className={`text-xs font-bold px-1.5 py-0.5 rounded ${i === 0 ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-600"}`}
>
#{h.seq}
</span>
</td>
<td className="px-2 py-2 text-right font-semibold text-gray-900">
+{h.batch_qty}
</td>
<td className="px-2 py-2 text-right text-green-600">
+{h.batch_good}
</td>
<td className="px-2 py-2 text-right text-red-600">
+{h.batch_defect}
</td>
<td className="px-2 py-2 text-right text-gray-500">
{h.accumulated_total}
</td>
<td className="px-2 py-2 text-right text-gray-400 text-xs">
{new Date(h.changed_at).toLocaleTimeString(
"ko-KR",
{ hour: "2-digit", minute: "2-digit" },
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{historyLoading && (
<div className="flex items-center gap-2 py-4 text-sm text-gray-400">
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin" />
...
</div>
)}
</div>
)}
{/* Confirmed badge */}
{activeSection === "result" && isConfirmed && (
<div className="bg-blue-50 border-2 border-blue-200 rounded-2xl p-6 text-center">
<div className="flex items-center justify-center gap-2 text-blue-700 font-bold text-lg">
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z"
/>
</svg>
</div>
<div className="flex items-center justify-center gap-4 mt-3 text-sm text-blue-600">
<span>: {accumulatedGood.toLocaleString()}</span>
<span>: {accumulatedDefect.toLocaleString()}</span>
<span>: {totalProduced.toLocaleString()}</span>
</div>
</div>
)}
{/* ====== Inventory Content ====== */}
{activeSection === "inventory" && isLastProcess && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 sm:p-5">
<p className="text-sm font-bold text-green-700 mb-4 flex items-center gap-2">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
( )
</p>
{/* Current production summary */}
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4 mb-4">
<div className="flex items-center gap-6">
<div className="flex flex-col items-center">
<span
className="text-2xl font-bold text-green-600"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{accumulatedGood}
</span>
<span className="text-xs text-green-500 font-medium mt-0.5">
</span>
</div>
<div className="h-8 w-px bg-gray-200" />
<div className="flex flex-col items-center">
<span
className="text-2xl font-bold text-red-600"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{accumulatedDefect}
</span>
<span className="text-xs text-red-500 font-medium mt-0.5">
</span>
</div>
<div className="h-8 w-px bg-gray-200" />
<div className="flex flex-col items-center">
<span
className="text-2xl font-bold text-gray-900"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{totalProduced}
</span>
<span className="text-xs text-gray-400 font-medium mt-0.5">
</span>
</div>
</div>
</div>
{!inboundDone ? (
accumulatedGood > 0 ? (
<div className="flex flex-col gap-3 mb-4">
{/* Warehouse selection */}
<div>
<label className="text-xs font-semibold text-gray-500 mb-1.5 block">
</label>
<div className="grid grid-cols-2 gap-2">
{warehouses.map((wh) => (
<button
key={wh.id}
onClick={() => {
setSelectedWarehouse(wh.id);
setSelectedLocation("");
fetchLocations(wh.id);
}}
className={`p-3 rounded-xl text-sm font-medium border-2 active:scale-95 transition-all text-left ${
selectedWarehouse === wh.id
? "border-green-400 bg-green-50 text-green-700"
: "border-gray-200 bg-white text-gray-700 hover:border-green-300"
}`}
>
{wh.warehouse_name}
</button>
))}
</div>
</div>
{/* Location selection */}
{warehouseLocations.length > 0 && (
<div>
<label className="text-xs font-semibold text-gray-500 mb-1.5 block">
</label>
<div className="grid grid-cols-3 gap-2">
{warehouseLocations.map((loc) => (
<button
key={loc.id}
onClick={() =>
setSelectedLocation(loc.location_code)
}
className={`p-2.5 rounded-xl text-xs font-medium border-2 active:scale-95 transition-all ${
selectedLocation === loc.location_code
? "border-green-400 bg-green-50 text-green-700"
: "border-gray-200 bg-white text-gray-600 hover:border-green-300"
}`}
>
{loc.location_name || loc.location_code}
</button>
))}
</div>
</div>
)}
{/* 포장 단위 (선택) */}
<div>
<label className="text-xs font-semibold text-gray-500 mb-1.5 block">
()
</label>
<div className="grid grid-cols-3 gap-2">
{["낱개", "박스", "파렛트"].map((pkg) => (
<button
key={pkg}
onClick={() =>
setPackageUnit((prev: string) =>
prev === pkg ? "" : pkg,
)
}
className={`p-2.5 rounded-xl text-sm font-medium border-2 active:scale-95 transition-all ${
packageUnit === pkg
? "border-blue-400 bg-blue-50 text-blue-700"
: "border-gray-200 bg-white text-gray-600"
}`}
>
{pkg}
</button>
))}
</div>
</div>
{/* 사진 첨부 (선택) */}
<label className="flex items-center gap-2 px-4 py-3 rounded-xl bg-gray-50 border-2 border-dashed border-gray-200 text-gray-500 text-sm font-medium cursor-pointer hover:bg-gray-100">
📷 ()
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
formData.append(
"targetTable",
"work_order_process",
);
formData.append("targetObjid", processId);
try {
const res = await fetch("/api/files", {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("accessToken") || localStorage.getItem("token") || ""}`,
},
body: formData,
});
alert(res.ok ? "사진 첨부 완료" : "첨부 실패");
} catch {
alert("첨부 오류");
}
e.target.value = "";
}}
/>
</label>
{/* Inbound button */}
<button
onClick={handleInbound}
disabled={!selectedWarehouse || saving}
className="w-full h-14 rounded-xl text-lg font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
background:
!selectedWarehouse || saving
? "#9ca3af"
: "linear-gradient(135deg, #22c55e 0%, #15803d 100%)",
}}
>
{saving
? "처리중..."
: `생산입고 (양품 ${accumulatedGood}개)`}
</button>
</div>
) : (
<div className="flex flex-col items-center gap-3 py-8 text-amber-600">
<svg
className="w-10 h-10"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<p className="text-sm font-semibold">
</p>
<p className="text-xs text-amber-500">
0
</p>
</div>
)
) : (
<div className="flex flex-col items-center gap-3 py-8 text-green-600">
<svg
className="w-12 h-12"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
<p className="text-lg font-bold"> </p>
<p className="text-sm text-green-500">
{accumulatedGood}
</p>
</div>
)}
</div>
)}
</div>
</div>
{/* end Right Column */}
</div>
)}
{/* ============================================================ */}
{/* Footer Actions */}
{/* ============================================================ */}
<div
className="shrink-0 border-t border-gray-200 bg-white px-4 flex items-center justify-between gap-3"
style={{ height: `${DESIGN.footer.height}px` }}
>
{isCompleted || isConfirmed ? (
<div className="flex items-center justify-center w-full gap-2 text-green-600">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm font-bold">
{isConfirmed ? "실적 확정 완료" : "공정 완료"}
</span>
</div>
) : null}
</div>
{/* ============================================================ */}
{/* Modals */}
{/* ============================================================ */}
<SimpleNumpadModal
open={prodQtyModal}
onClose={() => setProdQtyModal(false)}
onConfirm={(qty) => setProductionQty(qty)}
maxQty={remaining > 0 ? remaining : 99999}
title="생산수량 입력"
subtitle={`잔여: ${remaining.toLocaleString()} EA`}
/>
<DefectTypeModal
open={defectModal}
onClose={() => setDefectModal(false)}
onConfirm={setDefectEntries}
defectTypes={defectTypes}
maxQty={productionQty > 0 ? productionQty : 99999}
initialEntries={defectEntries}
processList={processList}
/>
<ConfirmModal
open={confirmModalState.open}
title={confirmModalState.title}
message={confirmModalState.message}
confirmText={confirmModalState.confirmText}
variant={confirmModalState.variant}
onConfirm={confirmModalState.onConfirm}
onCancel={() => setConfirmModalState((s) => ({ ...s, open: false }))}
/>
</div>
);
}
/* ================================================================== */
/* Checklist Row Component */
/* ================================================================== */
function ChecklistRow({
item,
disabled,
onSave,
showPhoto = true,
}: {
item: ChecklistItem;
disabled: boolean;
onSave: (id: string, value: string, isPassed: string | null) => void;
showPhoto?: boolean;
}) {
const [localValue, setLocalValue] = useState(item.result_value || "");
const isRecorded = item.status === "recorded" || item.status === "completed";
const isRequired = item.is_required === "Y";
// Inspection type: check limits
const detailType = item.detail_type || "";
// 판단기준(judgment_criteria) 우선 → 폴백으로 detail_type 매핑
const jc =
(item as ChecklistItem & { judgment_criteria?: string })
.judgment_criteria || "";
const isInspection =
jc === "CAT_JC_01" ||
detailType === "inspection" ||
detailType === "number" ||
detailType === "equip_condition" ||
detailType === "production_result" ||
detailType.startsWith("inspect");
const isCheckbox =
jc === "CAT_JC_03" ||
detailType === "checkbox" ||
detailType === "check" ||
detailType === "checklist" ||
detailType === "procedure" ||
detailType === "equip_inspection";
const isPlc = item.input_type === "plc" || detailType === "plc_data";
const hasLimits = !!(item.lower_limit || item.upper_limit);
const handleSave = () => {
if (!localValue.trim()) return;
let isPassed: string | null = null;
if (isInspection && hasLimits) {
const numVal = parseFloat(localValue);
const lower = item.lower_limit ? parseFloat(item.lower_limit) : -Infinity;
const upper = item.upper_limit ? parseFloat(item.upper_limit) : Infinity;
isPassed =
!isNaN(numVal) && numVal >= lower && numVal <= upper ? "Y" : "N";
if (isPassed === "N") {
const rangeStr = `${item.lower_limit || ""}~${item.upper_limit || ""}`;
alert(
`⚠️ 기준 초과!\n\n입력값: ${localValue}\n허용 범위: ${rangeStr}\n\n불합격으로 기록됩니다.`,
);
}
}
onSave(item.id, localValue, isPassed);
};
// Build range text
const rangeText = buildRangeText(item);
return (
<div
className={`p-4 rounded-xl border-2 transition-all ${
isRecorded
? "border-green-200 bg-green-50/50"
: isRequired
? "border-amber-300 bg-amber-50/30"
: "border-gray-200 bg-gray-50/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-base font-semibold text-gray-900 truncate flex-1">
{item.detail_label || item.detail_content || item.item_title}
</span>
<div className="flex items-center gap-2 shrink-0 ml-2">
{isRequired && !isRecorded && (
<span className="text-xs font-bold text-red-500 px-2 py-0.5 bg-red-50 rounded-full">
</span>
)}
{isRecorded && (
<svg
className="w-5 h-5 text-green-500"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</div>
</div>
{/* Range/spec info */}
{rangeText && (
<div className="flex items-center gap-2 mb-2 text-sm text-gray-400">
📏 {rangeText}
</div>
)}
{/* Input area */}
{!disabled && !isRecorded && (
<div className="flex items-center gap-2">
{isPlc ? (
<div className="w-full p-3 rounded-xl bg-blue-50 border border-blue-200">
<div className="flex items-center justify-between">
<span className="text-sm text-blue-600 font-medium">
📡 PLC
</span>
<span className="text-xs text-blue-400"> </span>
</div>
<p className="text-xs text-blue-400 mt-1">
POP PLC
</p>
<input
type="number"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
placeholder="수동 입력"
className="mt-2 w-full px-3 py-2 rounded-lg border border-blue-200 text-sm"
/>
{localValue && (
<button
onClick={handleSave}
className="mt-2 w-full py-2 rounded-lg text-sm font-bold text-white bg-blue-500"
>
</button>
)}
</div>
) : isCheckbox ? (
<div className="flex gap-3 w-full">
<button
onClick={() => onSave(item.id, "Y", "Y")}
className="flex-1 py-3 rounded-xl text-base font-bold bg-green-100 text-green-700 border-2 border-green-300 hover:bg-green-200 active:scale-95 transition-all"
>
</button>
<button
onClick={() => onSave(item.id, "N", "N")}
className="flex-1 py-3 rounded-xl text-base font-bold bg-red-100 text-red-700 border-2 border-red-300 hover:bg-red-200 active:scale-95 transition-all"
>
</button>
</div>
) : (
<>
<input
type={isInspection ? "number" : "text"}
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
placeholder={isInspection ? "측정값 입력" : "값 입력"}
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 text-base font-medium focus:outline-none focus:border-blue-400"
style={{ minHeight: 48 }}
/>
<button
onClick={handleSave}
disabled={!localValue.trim()}
className="px-5 py-3 rounded-xl text-base font-bold text-white active:scale-95 transition-all disabled:opacity-40"
style={{
minHeight: DESIGN.button.touchMin,
background: !localValue.trim()
? "#9ca3af"
: "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)",
}}
>
</button>
</>
)}
</div>
)}
{/* Recorded value display */}
{isRecorded && item.result_value && (
<div className="flex items-center gap-2 text-sm mt-1">
<span className="text-gray-500">:</span>
<span
className={`font-bold ${
item.is_passed === "Y"
? "text-green-600"
: item.is_passed === "N"
? "text-red-600"
: "text-gray-700"
}`}
>
{item.result_value === "Y"
? "합격"
: item.result_value === "N"
? "불합격"
: item.result_value}
</span>
{item.is_passed === "Y" && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-bold">
PASS
</span>
)}
{item.is_passed === "N" && (
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-bold">
FAIL
</span>
)}
</div>
)}
{/* 사진 업로드 (설정으로 제어) */}
{!disabled && showPhoto && (
<div className="mt-2 flex items-center gap-2">
<label className="flex items-center gap-2 px-3 py-2 rounded-lg bg-gray-100 text-gray-600 text-sm font-medium cursor-pointer hover:bg-gray-200 active:scale-95 transition-all">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z"
/>
</svg>
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const formData = new FormData();
formData.append("file", file);
formData.append("targetTable", "process_work_result");
formData.append("targetObjid", item.id);
try {
const res = await fetch("/api/files", {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("accessToken") || localStorage.getItem("token") || ""}`,
},
body: formData,
});
if (res.ok) {
alert("사진 업로드 완료");
} else {
alert("업로드 실패");
}
} catch {
alert("업로드 중 오류");
}
e.target.value = "";
}}
/>
</label>
</div>
)}
</div>
);
}
/* ================================================================== */
/* Helper: Build range text for checklist items */
/* ================================================================== */
function buildRangeText(item: ChecklistItem): string {
const parts: string[] = [];
const lower = item.lower_limit;
const upper = item.upper_limit;
const unit = item.unit || "";
if (lower && upper) {
parts.push(`${lower}~${upper}${unit ? " " + unit : ""}`);
} else if (lower) {
parts.push(`하한: ${lower}${unit ? " " + unit : ""}`);
} else if (upper) {
parts.push(`상한: ${upper}${unit ? " " + unit : ""}`);
} else if (item.spec_value) {
parts.push(`기준: ${item.spec_value}`);
}
if (unit && !lower && !upper) {
parts.push(`단위: ${unit}`);
}
return parts.join(" | ");
}
/* ================================================================== */
/* Material Quantity Keypad (소수점 지원, 자재 투입용) */
/* ================================================================== */
function MaterialQtyKeypad({
open,
onClose,
onConfirm,
initialValue,
unit,
requiredQty,
itemName,
}: {
open: boolean;
onClose: () => void;
onConfirm: (val: string) => void;
initialValue: string;
unit: string;
requiredQty: number;
itemName: string;
}) {
const [val, setVal] = useState(initialValue || "0");
React.useEffect(() => {
if (open) setVal(initialValue || "0");
}, [open, initialValue]);
const press = (k: string) => {
setVal((prev) => {
if (k === "backspace") return prev.length <= 1 ? "0" : prev.slice(0, -1);
if (k === "clear") return "0";
if (k === "dot") return prev.includes(".") ? prev : prev + ".";
if (k === "ref") return String(requiredQty);
if (prev === "0" && k !== ".") return k;
return prev + k;
});
};
if (!open) return null;
const numVal = parseFloat(val);
const lower = requiredQty * 0.8;
const upper = requiredQty * 1.2;
const outOfRange = !isNaN(numVal) && (numVal < lower || numVal > upper);
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-2xl shadow-2xl p-4 w-[320px] z-10">
<div className="text-center mb-3">
<p className="text-sm text-gray-500 truncate">{itemName}</p>
<p className="text-xs text-blue-500 mt-0.5">
{requiredQty} {unit} (±20%)
</p>
<p
className={`text-3xl font-bold mt-2 ${outOfRange ? "text-amber-600" : "text-gray-900"}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{val} <span className="text-base text-gray-400">{unit}</span>
</p>
</div>
<div className="grid grid-cols-3 gap-2 mb-3">
{["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((k) => (
<button
key={k}
onClick={() => press(k)}
className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 active:bg-gray-200 transition-all"
>
{k}
</button>
))}
<button
onClick={() => press("dot")}
className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 transition-all"
>
.
</button>
<button
onClick={() => press("0")}
className="h-14 rounded-xl bg-gray-100 text-xl font-bold text-gray-800 active:scale-95 transition-all"
>
0
</button>
<button
onClick={() => press("backspace")}
className="h-14 rounded-xl bg-gray-200 text-base font-bold text-gray-600 active:scale-95 transition-all"
>
</button>
</div>
<div className="flex gap-2 mb-2">
<button
onClick={() => press("clear")}
className="flex-1 h-10 rounded-xl bg-gray-100 text-gray-600 text-sm font-bold active:scale-95"
>
</button>
<button
onClick={() => press("ref")}
className="flex-1 h-10 rounded-xl bg-blue-50 text-blue-600 text-sm font-bold active:scale-95"
>
({requiredQty})
</button>
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="flex-1 h-12 rounded-xl bg-gray-100 text-gray-700 font-semibold active:scale-95"
>
</button>
<button
onClick={() => {
onConfirm(val);
onClose();
}}
className="flex-1 h-12 rounded-xl text-white font-bold active:scale-95"
style={{
background: outOfRange
? "linear-gradient(135deg, #f59e0b, #d97706)"
: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
}}
>
{outOfRange ? "확인 (범위 외)" : "확인"}
</button>
</div>
</div>
</div>
);
}
/* ================================================================== */
/* Material Qty Input Row (키패드 트리거 버튼) */
/* ================================================================== */
function MaterialQtyInputRow({
material,
value,
onChange,
}: {
material: {
id: string;
child_item_name: string;
required_qty: number;
unit: string;
};
value: string;
onChange: (v: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="flex items-center shrink-0">
<button
type="button"
onClick={() => setOpen(true)}
className="px-8 py-4 rounded-xl border-2 border-blue-300 text-xl font-bold text-blue-700 hover:border-blue-500 active:scale-[0.96] transition-all bg-blue-50 min-w-[120px] text-center"
>
{value || (
<span className="text-blue-300 font-semibold"></span>
)}
</button>
<MaterialQtyKeypad
open={open}
onClose={() => setOpen(false)}
onConfirm={onChange}
initialValue={value}
unit={material.unit}
requiredQty={material.required_qty}
itemName={material.child_item_name}
/>
</div>
);
}
/* ================================================================== */
/* Material Input Section */
/* ================================================================== */
function MaterialInputSection({ processId }: { processId: string }) {
const [bomMaterials, setBomMaterials] = React.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] = React.useState<Record<string, string>>(
{},
);
const [inputted, setInputted] = React.useState<
Array<{
id: string;
item_code: string;
item_name: string;
input_qty: string;
unit: string;
recorded_at: string;
}>
>([]);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [defaultWarehouseCode, setDefaultWarehouseCode] =
React.useState<string>("");
React.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 (
<div className="text-center py-8 text-gray-400"> ...</div>
);
}
return (
<div className="space-y-3">
{/* BOM 기준 자재 목록 — 컴팩트 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-bold text-gray-900">BOM </h3>
<span className="text-xs text-gray-400">{bomMaterials.length}</span>
</div>
{bomMaterials.length === 0 ? (
<p className="text-sm text-gray-400 py-4 text-center">
BOM
</p>
) : (
<div>
<div className="divide-y divide-gray-200">
{bomMaterials.map((m) => (
<div key={m.id} className="flex items-center gap-2 py-3">
{/* 자재명(코드) + 소요량 */}
<div className="flex-1 min-w-0">
<span className="text-base font-bold text-gray-900">{m.child_item_name}</span>
<span className="text-sm text-gray-400 ml-1">({m.child_item_code})</span>
<span className="text-base font-bold text-blue-600 ml-3"> {m.required_qty}</span>
</div>
{/* 입력 버튼 + 단위 */}
<MaterialQtyInputRow
material={m}
value={inputValues[m.id] || ""}
onChange={(v) =>
setInputValues((prev) => ({ ...prev, [m.id]: v }))
}
/>
<span className="text-base font-semibold text-gray-500 shrink-0 w-8">{m.unit}</span>
</div>
))}
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full mt-4 py-4 rounded-xl text-lg font-bold text-white active:scale-[0.98] transition-all disabled:opacity-40"
style={{
background: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
}}
>
{saving ? "저장중..." : "자재 투입 확정"}
</button>
</div>
)}
</div>
{/* 투입 이력 */}
{inputted.length > 0 && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
{inputted.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-0"
>
<div>
<p className="text-sm font-semibold text-gray-900">
{item.item_name}
</p>
<p className="text-xs text-gray-400">{item.item_code}</p>
</div>
<p className="text-base font-bold text-green-600">
{item.input_qty} {item.unit}
</p>
</div>
))}
</div>
)}
</div>
);
}