feat: POP 작업상세 자재투입(material-input) 섹션 UI 구현
- BOM 기반 자재 목록 표시 + 소요량 자동 계산 - 작업자 실제 투입량 입력 (참고 모드 — 유연 입력) - 기준량 ±20% 범위 검증 + 경고 표시 (차단 아님) - 투입 이력 테이블 표시 - ISA-101 터치 기준 (입력 56px+, 버튼 56px+)
This commit is contained in:
@@ -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 카드 (항상 표시)
|
||||
// ========================================
|
||||
|
||||
Reference in New Issue
Block a user