diff --git a/dashboard/app/[tenant]/machines/[id]/page.tsx b/dashboard/app/[tenant]/machines/[id]/page.tsx index 123f71c..860a43e 100644 --- a/dashboard/app/[tenant]/machines/[id]/page.tsx +++ b/dashboard/app/[tenant]/machines/[id]/page.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useParams, useRouter } from 'next/navigation'; -import { useMachine, useEquipmentParts } from '@/lib/hooks'; +import { useMachine, useEquipmentParts, useReplacements } from '@/lib/hooks'; import { useToast } from '@/lib/toast-context'; import { api } from '@/lib/api'; -import type { EquipmentPart } from '@/lib/types'; +import type { EquipmentPart, PartReplacementLog } from '@/lib/types'; const LIFECYCLE_TYPES = [ { value: 'hours', label: '시간 (Hours)' }, @@ -56,6 +56,16 @@ export default function MachineDetailPage() { const [submitting, setSubmitting] = useState(false); const [editPart, setEditPart] = useState(null); + // Phase 5: 카운터, 교체, 교체이력 모달 + const [counterPart, setCounterPart] = useState(null); + const [counterValue, setCounterValue] = useState(''); + const [replacePart, setReplacePart] = useState(null); + const [replaceReason, setReplaceReason] = useState(''); + const [replaceNotes, setReplaceNotes] = useState(''); + const [historyPart, setHistoryPart] = useState(null); + const [replacements, setReplacements] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const openAddPart = useCallback(() => { setEditPart(null); setPartForm(INITIAL_PART_FORM); @@ -117,6 +127,87 @@ export default function MachineDetailPage() { } }, [partForm, tenantId, machineId, editPart, mutateParts, mutateMachine, closePartModal, addToast]); + const openCounterModal = useCallback((part: EquipmentPart) => { + setCounterPart(part); + setCounterValue(String(part.counter?.current_value ?? 0)); + }, []); + + const closeCounterModal = useCallback(() => { + setCounterPart(null); + setCounterValue(''); + }, []); + + const handleCounterSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!counterPart) return; + const val = parseFloat(counterValue); + if (isNaN(val) || val < 0) { + addToast('0 이상의 숫자를 입력해주세요.', 'error'); + return; + } + setSubmitting(true); + try { + await api.put(`/api/${tenantId}/parts/${counterPart.id}/counter`, { value: val }); + addToast('카운터가 업데이트되었습니다.', 'success'); + mutateParts(); + closeCounterModal(); + } catch { + addToast('카운터 업데이트에 실패했습니다.', 'error'); + } finally { + setSubmitting(false); + } + }, [counterPart, counterValue, tenantId, mutateParts, closeCounterModal, addToast]); + + const openReplaceModal = useCallback((part: EquipmentPart) => { + setReplacePart(part); + setReplaceReason(''); + setReplaceNotes(''); + }, []); + + const closeReplaceModal = useCallback(() => { + setReplacePart(null); + setReplaceReason(''); + setReplaceNotes(''); + }, []); + + const handleReplaceSubmit = useCallback(async (e: React.FormEvent) => { + e.preventDefault(); + if (!replacePart) return; + setSubmitting(true); + try { + await api.post<{ status: string }>(`/api/${tenantId}/parts/${replacePart.id}/replace`, { + reason: replaceReason.trim() || null, + notes: replaceNotes.trim() || null, + }); + addToast('부품이 교체되었습니다. 카운터가 초기화되었습니다.', 'success'); + mutateParts(); + closeReplaceModal(); + } catch { + addToast('부품 교체에 실패했습니다.', 'error'); + } finally { + setSubmitting(false); + } + }, [replacePart, replaceReason, replaceNotes, tenantId, mutateParts, closeReplaceModal, addToast]); + + const openHistoryModal = useCallback(async (part: EquipmentPart) => { + setHistoryPart(part); + setHistoryLoading(true); + try { + const data = await api.get<{ replacements: PartReplacementLog[] }>(`/api/${tenantId}/parts/${part.id}/replacements`); + setReplacements(data.replacements); + } catch { + addToast('교체 이력을 불러오지 못했습니다.', 'error'); + setReplacements([]); + } finally { + setHistoryLoading(false); + } + }, [tenantId, addToast]); + + const closeHistoryModal = useCallback(() => { + setHistoryPart(null); + setReplacements([]); + }, []); + const handleDeletePart = useCallback(async (part: EquipmentPart) => { if (!confirm(`"${part.name}" 부품을 삭제하시겠습니까?`)) return; try { @@ -229,6 +320,15 @@ export default function MachineDetailPage() { {COUNTER_SOURCES.find((s) => s.value === part.counter_source)?.label || part.counter_source}
+ + + @@ -364,6 +464,172 @@ export default function MachineDetailPage() {
)} + {counterPart && ( +
+
e.stopPropagation()}> +
+

카운터 업데이트

+ +
+
+
+

{counterPart.name}

+
+ 수명 유형: + {LIFECYCLE_TYPES.find((t) => t.value === counterPart.lifecycle_type)?.label} +
+
+ 한계값: + {counterPart.lifecycle_limit} +
+
+ 현재값: + {counterPart.counter?.current_value ?? 0} +
+
+ 수명 %: + {(counterPart.counter?.lifecycle_pct ?? 0).toFixed(1)}% +
+
+
+ + setCounterValue(e.target.value)} + disabled={submitting} + min="0" + step="any" + autoFocus + /> +
+
+ + +
+
+
+
+ )} + + {replacePart && ( +
+
e.stopPropagation()}> +
+

부품 교체

+ +
+
+
+ warning +

{replacePart.name} 부품을 교체합니다. 카운터가 0으로 초기화됩니다.

+
+
+ + setReplaceReason(e.target.value)} + disabled={submitting} + autoFocus + /> +
+
+ +