327b4d01c2
- 구매입고: 검사기준 API 수정, 검사결과 DB 저장, 검사 미완료 확정 차단 - 판매출고: 재고 부족 사전 검증, 수주상세 ship_qty 반영, 에러 메시지 개선 - 공정실행: seq_no 비순차 대응(3곳), 자재투입 자동 창고 매칭 재고차감, 불필요 버튼 제거 - 검사관리+입출고관리: 신규 화면 (quality, inventory) - 공통: ConfirmModal 커스텀 모달 (native confirm 대체)
2985 lines
95 KiB
TypeScript
2985 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;
|
|
}
|
|
|
|
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 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 */
|
|
}
|
|
}
|
|
|
|
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 masters = ((plRes.data ?? []) as ProcessData[])
|
|
.filter((p) => !p.parent_process_id)
|
|
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10))
|
|
.map((p) => ({
|
|
process_code: p.process_code,
|
|
process_name: p.process_name,
|
|
}));
|
|
// 중복 제거
|
|
const seen = new Set<string>();
|
|
setProcessList(
|
|
masters.filter((m) => {
|
|
if (seen.has(m.process_code)) return false;
|
|
seen.add(m.process_code);
|
|
return true;
|
|
}),
|
|
);
|
|
} 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>
|
|
)}
|
|
<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-2 px-3 py-2 text-left transition-all ${
|
|
isSelected
|
|
? "border-l-[3px] border-l-gray-900 bg-white"
|
|
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`w-2 h-2 rounded-full shrink-0 ${
|
|
isDone
|
|
? "bg-green-500"
|
|
: g.timerStarted
|
|
? "bg-blue-500 animate-pulse"
|
|
: "bg-gray-300"
|
|
}`}
|
|
/>
|
|
<span
|
|
className={`text-sm truncate flex-1 ${
|
|
isSelected
|
|
? "font-semibold text-blue-700"
|
|
: isDone
|
|
? "text-gray-400"
|
|
: "text-gray-700"
|
|
}`}
|
|
>
|
|
{g.title}
|
|
</span>
|
|
<span
|
|
className={`text-[13px] shrink-0 ${isDone ? "text-green-500" : "text-gray-300"}`}
|
|
>
|
|
{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-2 px-3 py-2.5 mb-2 text-left transition-all ${
|
|
activeSection === "material"
|
|
? "border-l-[3px] border-l-gray-900 bg-white"
|
|
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<span className="text-sm">📦</span>
|
|
<span
|
|
className={`text-sm ${activeSection === "material" ? "font-semibold text-blue-700" : "text-gray-600 font-medium"}`}
|
|
>
|
|
자재 투입
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 px-3 mb-1.5">
|
|
<div className="w-4 h-4 rounded-full bg-amber-500 flex items-center justify-center">
|
|
<svg
|
|
className="w-2.5 h-2.5 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2.5}
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V19.5a2.25 2.25 0 002.25 2.25h.75"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<span className="text-base font-bold text-gray-400 uppercase tracking-wider">
|
|
실적
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setActiveSection("result");
|
|
contentRef.current?.scrollTo({
|
|
top: 0,
|
|
behavior: "smooth",
|
|
});
|
|
}}
|
|
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${
|
|
activeSection === "result"
|
|
? "border-l-[3px] border-l-gray-900 bg-white"
|
|
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<svg
|
|
className={`w-3.5 h-3.5 ${activeSection === "result" ? "text-blue-500" : "text-amber-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>
|
|
<span
|
|
className={`text-sm ${activeSection === "result" ? "font-semibold text-blue-700" : "text-amber-700 font-medium"}`}
|
|
>
|
|
실적 입력
|
|
</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Inventory section link */}
|
|
{isLastProcess && (
|
|
<div>
|
|
<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 ${inboundDone ? "bg-green-500" : "bg-amber-500"}`}
|
|
>
|
|
<svg
|
|
className="w-2.5 h-2.5 text-white"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={2.5}
|
|
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>
|
|
</div>
|
|
<span className="text-base font-bold text-gray-400 uppercase tracking-wider">
|
|
입고
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setActiveSection("inventory");
|
|
contentRef.current?.scrollTo({
|
|
top: 0,
|
|
behavior: "smooth",
|
|
});
|
|
}}
|
|
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${
|
|
activeSection === "inventory"
|
|
? "border-l-[3px] border-l-gray-900 bg-white"
|
|
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
|
|
}`}
|
|
>
|
|
<svg
|
|
className={`w-3.5 h-3.5 ${activeSection === "inventory" ? "text-blue-500" : inboundDone ? "text-green-500" : "text-amber-500"}`}
|
|
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>
|
|
<span
|
|
className={`text-sm ${activeSection === "inventory" ? "font-semibold text-blue-700" : inboundDone ? "text-green-700 font-medium" : "text-amber-700 font-medium"}`}
|
|
>
|
|
재고 입고
|
|
</span>
|
|
{inboundDone && (
|
|
<svg
|
|
className="w-3.5 h-3.5 ml-auto 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>
|
|
)}
|
|
</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 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 text-base font-bold text-left text-gray-900 hover:border-blue-400 active:scale-[0.98] transition-all bg-white"
|
|
style={{ minHeight: 56 }}
|
|
>
|
|
{value || (
|
|
<span className="text-gray-300 font-normal">투입 수량 입력</span>
|
|
)}
|
|
</button>
|
|
<span className="text-sm text-gray-500 shrink-0">{material.unit}</span>
|
|
<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-4">
|
|
{/* BOM 기준 자재 목록 */}
|
|
<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">
|
|
BOM 자재 목록
|
|
</h3>
|
|
{bomMaterials.length === 0 ? (
|
|
<p className="text-sm text-gray-400 py-4 text-center">
|
|
BOM 자재 정보가 없습니다
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{bomMaterials.map((m) => (
|
|
<div
|
|
key={m.id}
|
|
className="p-3 rounded-xl border border-gray-200 bg-gray-50"
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div>
|
|
<p className="text-base font-semibold text-gray-900">
|
|
{m.child_item_name}
|
|
</p>
|
|
<p className="text-sm text-gray-400">{m.child_item_code}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm text-gray-500">소요량</p>
|
|
<p className="text-lg font-bold text-blue-600">
|
|
{m.required_qty} {m.unit}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<MaterialQtyInputRow
|
|
material={m}
|
|
value={inputValues[m.id] || ""}
|
|
onChange={(v) =>
|
|
setInputValues((prev) => ({ ...prev, [m.id]: v }))
|
|
}
|
|
/>
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="w-full py-4 rounded-xl text-base 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>
|
|
);
|
|
}
|