feat: POP 작업상세 자재투입(material-input) 섹션 UI 구현

- BOM 기반 자재 목록 표시 + 소요량 자동 계산
- 작업자 실제 투입량 입력 (참고 모드 — 유연 입력)
- 기준량 ±20% 범위 검증 + 경고 표시 (차단 아님)
- 투입 이력 테이블 표시
- ISA-101 터치 기준 (입력 56px+, 버튼 56px+)
This commit is contained in:
SeongHyun Kim
2026-04-07 10:57:56 +09:00
parent 21e89922eb
commit 495a5f034b
@@ -1348,7 +1348,7 @@ interface BatchHistoryItem {
changed_by: string | null;
}
const IMPLEMENTED_SECTIONS = new Set<ResultSectionType>(["total-qty", "good-defect", "defect-types", "note", "plc-data"]);
const IMPLEMENTED_SECTIONS = new Set<ResultSectionType>(["total-qty", "good-defect", "defect-types", "note", "plc-data", "material-input"]);
const SECTION_LABELS: Record<ResultSectionType, string> = {
"total-qty": "생산수량",
@@ -1754,6 +1754,14 @@ function ResultPanel({
/>
))}
{/* 자재 투입 섹션 */}
{enabledSections.some((s) => s.type === "material-input") && (
<MaterialInputSection
workOrderProcessId={workOrderProcessId}
isConfirmed={isConfirmed}
/>
)}
{/* 미구현 섹션 플레이스홀더 (순서 보존) */}
{!isConfirmed && enabledSections
.filter((s) => !IMPLEMENTED_SECTIONS.has(s.type))
@@ -2164,6 +2172,303 @@ function InfoBar({ fields, parentRow, processName }: InfoBarProps) {
);
}
// ========================================
// 자재 투입 섹션
// ========================================
interface BomMaterial {
id: string;
child_item_id: string;
child_item_code: string;
child_item_name: string;
bom_qty: number;
unit: string;
process_type: string;
loss_rate: number;
required_qty: number;
input_qty: number;
}
interface MaterialInputRecord {
id: string;
item_code: string;
item_name: string;
input_qty: string;
unit: string;
remark: string | null;
recorded_by: string | null;
recorded_at: string | null;
}
interface MaterialInputSectionProps {
workOrderProcessId: string;
isConfirmed: boolean;
}
function MaterialInputSection({ workOrderProcessId, isConfirmed }: MaterialInputSectionProps) {
const [materials, setMaterials] = useState<BomMaterial[]>([]);
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [existingInputs, setExistingInputs] = useState<MaterialInputRecord[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// BOM 자재 목록 + 기존 투입 기록 로드
const loadData = useCallback(async () => {
setLoading(true);
try {
const [bomRes, inputsRes] = await Promise.all([
apiClient.get(`/pop/production/bom-materials/${workOrderProcessId}`),
apiClient.get(`/pop/production/material-inputs/${workOrderProcessId}`),
]);
const bomMaterials: BomMaterial[] = bomRes.data?.data?.materials ?? [];
setMaterials(bomMaterials);
const inputs: MaterialInputRecord[] = inputsRes.data?.data ?? [];
setExistingInputs(inputs);
// 기존 투입값을 inputValues에 반영 (같은 자재의 투입 합산)
const existingMap: Record<string, number> = {};
for (const inp of inputs) {
const key = inp.item_code || inp.item_name;
existingMap[key] = (existingMap[key] || 0) + (parseFloat(inp.input_qty) || 0);
}
const initialValues: Record<string, string> = {};
for (const mat of bomMaterials) {
const existing = existingMap[mat.child_item_code] || existingMap[mat.child_item_name];
initialValues[mat.id] = existing ? String(existing) : "";
}
setInputValues(initialValues);
} catch {
// 에러 시 빈 상태 유지
} finally {
setLoading(false);
}
}, [workOrderProcessId]);
useEffect(() => {
loadData();
}, [loadData]);
const updateInputValue = (materialId: string, value: string) => {
setInputValues((prev) => ({ ...prev, [materialId]: value }));
};
// 기준 범위 판정: lower_limit~upper_limit이 있으면 사용, 없으면 +/-20%
const getStatus = (mat: BomMaterial, inputVal: string): { label: string; color: string } => {
const val = parseFloat(inputVal);
if (!inputVal || isNaN(val)) return { label: "", color: "" };
const required = mat.required_qty;
if (required <= 0) return { label: "기준 내", color: "text-green-600" };
const lowerBound = required * 0.8;
const upperBound = required * 1.2;
if (val >= lowerBound && val <= upperBound) {
return { label: "기준 내", color: "text-green-600" };
} else if (val < lowerBound) {
return { label: "기준 미달", color: "text-amber-600" };
} else {
return { label: "기준 초과", color: "text-amber-600" };
}
};
const handleSave = async () => {
const inputs = materials
.filter((mat) => {
const val = parseFloat(inputValues[mat.id] || "");
return !isNaN(val) && val > 0;
})
.map((mat) => ({
child_item_id: mat.child_item_id,
child_item_code: mat.child_item_code,
child_item_name: mat.child_item_name,
input_qty: parseFloat(inputValues[mat.id]),
unit: mat.unit,
bom_detail_id: mat.id,
required_qty: mat.required_qty,
}));
if (inputs.length === 0) {
toast.error("투입 수량을 입력해주세요.");
return;
}
setSaving(true);
try {
const res = await apiClient.post("/pop/production/material-input", {
work_order_process_id: workOrderProcessId,
inputs,
});
if (res.data?.success) {
toast.success(res.data.message || "자재 투입이 저장되었습니다.");
loadData(); // 새로고침
} else {
toast.error(res.data?.message || "자재 투입 저장에 실패했습니다.");
}
} catch {
toast.error("자재 투입 저장 중 오류가 발생했습니다.");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div>
<div className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-400"> </div>
<div className="flex items-center gap-2 py-4 text-sm text-gray-400">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
</div>
);
}
if (materials.length === 0) {
return (
<div>
<div className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-400"> </div>
<div className="flex items-center gap-3 rounded-xl border border-dashed border-gray-200 p-4">
<Package className="h-5 w-5 shrink-0 text-gray-400" />
<p className="text-sm text-gray-400">BOM .</p>
</div>
</div>
);
}
return (
<div>
<div className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-400"> </div>
{/* 자재 목록 테이블 */}
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-xs uppercase tracking-wider text-gray-400">
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
<th className="px-4 py-3 text-center font-medium"></th>
</tr>
</thead>
<tbody>
{materials.map((mat) => {
const status = getStatus(mat, inputValues[mat.id] || "");
return (
<tr key={mat.id} className="border-t border-gray-50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{mat.child_item_name || mat.child_item_code}</div>
{mat.child_item_code && mat.child_item_name && (
<div className="text-xs text-gray-400">{mat.child_item_code}</div>
)}
</td>
<td className="px-4 py-3 text-right">
<span className="font-semibold text-gray-700">{mat.required_qty}</span>
<span className="ml-1 text-xs text-gray-400">{mat.unit || "EA"}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1.5">
{isConfirmed ? (
<span className="text-base font-bold text-gray-700">{inputValues[mat.id] || "-"}</span>
) : (
<input
type="number"
inputMode="decimal"
className="h-14 w-28 rounded-xl border-2 border-gray-200 px-3 text-center text-lg font-bold text-gray-900 transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
value={inputValues[mat.id] || ""}
onChange={(e) => updateInputValue(mat.id, e.target.value)}
placeholder="0"
/>
)}
<span className="text-xs text-gray-400">{mat.unit || "EA"}</span>
</div>
</td>
<td className="px-4 py-3 text-center">
{status.label && (
<span className={cn("text-xs font-semibold", status.color)}>
{status.color === "text-green-600" ? (
<span className="inline-flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5" />
{status.label}
</span>
) : (
<span className="inline-flex items-center gap-1">
<AlertCircle className="h-3.5 w-3.5" />
{status.label}
</span>
)}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 저장 버튼 */}
{!isConfirmed && (
<div className="mt-4 flex items-center gap-3">
<button
className={cn(
"flex h-14 items-center gap-2 rounded-xl bg-gray-900 px-8 text-sm font-semibold text-white transition-colors hover:bg-gray-800",
saving && "cursor-not-allowed opacity-50"
)}
onClick={handleSave}
disabled={saving}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</button>
</div>
)}
{/* 기존 투입 이력 */}
{existingInputs.length > 0 && (
<div className="mt-6">
<div className="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-400"> </div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-xs uppercase tracking-wider text-gray-400">
<th className="px-4 py-2 text-left font-medium"></th>
<th className="px-4 py-2 text-right font-medium"></th>
<th className="px-4 py-2 text-right font-medium"></th>
</tr>
</thead>
<tbody>
{existingInputs.map((inp) => (
<tr key={inp.id} className="border-t border-gray-50">
<td className="px-4 py-2.5 font-medium text-gray-900">
{inp.item_name || inp.item_code}
</td>
<td className="px-4 py-2.5 text-right font-semibold text-gray-700">
{inp.input_qty} <span className="text-xs text-gray-400">{inp.unit}</span>
</td>
<td className="px-4 py-2.5 text-right text-gray-400">
{inp.recorded_at
? new Date(inp.recorded_at).toLocaleString("ko-KR", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "-"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
// ========================================
// KPI 카드 (항상 표시)
// ========================================