From f5f7086fbfd08f5dfc043056920b1ec952b9a773 Mon Sep 17 00:00:00 2001 From: choibk Date: Fri, 23 Jan 2026 00:54:26 +0900 Subject: [PATCH] Improve Asset Maintenance UI --- .../asset/components/AssetBasicInfo.tsx | 463 +++++++ src/modules/asset/components/AssetHistory.tsx | 472 +++++++ src/modules/asset/components/AssetManuals.tsx | 133 ++ src/modules/asset/pages/AssetDetailPage.tsx | 1079 +---------------- src/modules/asset/utils/assetUtils.ts | 17 + 5 files changed, 1100 insertions(+), 1064 deletions(-) create mode 100644 src/modules/asset/components/AssetBasicInfo.tsx create mode 100644 src/modules/asset/components/AssetHistory.tsx create mode 100644 src/modules/asset/components/AssetManuals.tsx create mode 100644 src/modules/asset/utils/assetUtils.ts diff --git a/src/modules/asset/components/AssetBasicInfo.tsx b/src/modules/asset/components/AssetBasicInfo.tsx new file mode 100644 index 0000000..1da4646 --- /dev/null +++ b/src/modules/asset/components/AssetBasicInfo.tsx @@ -0,0 +1,463 @@ + +import React, { useState } from 'react'; +import { Card } from '../../../shared/ui/Card'; +import { Button } from '../../../shared/ui/Button'; +import { ArrowLeft, Save, Upload, X, Printer, ZoomIn, Trash2, Plus } from 'lucide-react'; +import { assetApi } from '../../../shared/api/assetApi'; +import type { Asset } from '../../../shared/api/assetApi'; +import { SERVER_URL } from '../../../shared/api/client'; +import { createPortal } from 'react-dom'; + +interface AssetBasicInfoProps { + asset: Asset & { image?: string, consumables?: any[] }; + onRefresh: () => void; +} + +export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) { + const [isEditing, setIsEditing] = useState(false); + const [editData, setEditData] = useState(asset); + const [isZoomed, setIsZoomed] = useState(false); + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + setEditData(prev => ({ ...prev, [name]: value })); + }; + + const handleSave = async () => { + try { + await assetApi.update(asset.id, editData); + setIsEditing(false); + onRefresh(); // Refresh data from server + } catch (error) { + console.error("Failed to update asset:", error); + alert("저장에 실패했습니다."); + } + }; + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const res = await assetApi.uploadImage(file); + setEditData(prev => ({ ...prev, image: res.data.url })); + } catch (error) { + console.error("Failed to upload image:", error); + alert("이미지 업로드에 실패했습니다."); + } + }; + + const handleDeleteImage = () => { + if (confirm("이미지를 삭제하시겠습니까?")) { + setEditData(prev => ({ ...prev, image: '' })); + } + }; + + const handleCancel = () => { + setEditData(asset); + setIsEditing(false); + }; + + return ( + <> +
+ {isEditing ? ( + <> + + + + ) : ( + + )} + +
+ + + +
+ {/* Left: Image Area */} +
!isEditing && editData.image && setIsZoomed(true)} + > + {editData.image ? ( + <> + {asset.name} + {!isEditing && ( +
+
+ +
+
+ )} + + ) : ( +
이미지 없음
+ )} + + {isEditing && ( +
e.stopPropagation()}> + + {editData.image && ( + + )} +
+ )} +
+ + {/* Right: Info Table */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
관리번호 +
+ {editData.id} +
+
구입가격 + {isEditing ? ( + + ) : ( +
+ {editData.purchasePrice ? `${Number(editData.purchasePrice).toLocaleString()} 원` : '-'} +
+ )} +
설비명 + {isEditing ? ( + + ) : ( +
+ {editData.name} +
+ )} +
S/N + {isEditing ? ( + + ) : ( +
+ {editData.serialNumber} +
+ )} +
입고일 + {isEditing ? ( + + ) : ( +
+ {editData.purchaseDate} +
+ )} +
모델명 + {isEditing ? ( + + ) : ( +
+ {editData.model} +
+ )} +
제작사 + {isEditing ? ( + + ) : ( +
+ {editData.manufacturer} +
+ )} +
교정주기 + {isEditing ? ( + + ) : ( +
+ {editData.calibrationCycle} +
+ )} +
스펙/사양 + {isEditing ? ( + + ) : ( +
+ {editData.specs} +
+ )} +
설치위치 + {isEditing ? ( + + ) : ( +
+ {editData.location} +
+ )} +
관리책임자 + {isEditing ? ( + + ) : ( +
+ {editData.manager} +
+ )} +
관리상태 + {isEditing ? ( +
+ +
+ ) : ( +
+ + {editData.status === 'active' ? '정상 가동' : editData.status === 'disposed' ? '폐기 (말소)' : editData.status === 'maintain' ? '점검 중' : '수리 필요'} + +
+ )} +
+
+
+ +
+ + {/* Consumables Section */} +
+
+

관련 소모품 관리

+ +
+ + + + + + + + + + + {asset.consumables?.map(item => ( + + + + + + + ))} + +
품명규격현재고관리
{item.name}{item.spec}{item.qty}개 + +
+
+
+ + {/* Image Zoom Modal - Moved to Portal */} + {isZoomed && editData.image && createPortal( +
setIsZoomed(false)} + > +
e.stopPropagation()} + > + {/* Modal Header */} +
+

{asset.name} - 이미지 상세

+ +
+ + {/* Modal Content */} +
+ {asset.name} +
+
+
, + document.body + )} + + ); +} diff --git a/src/modules/asset/components/AssetHistory.tsx b/src/modules/asset/components/AssetHistory.tsx new file mode 100644 index 0000000..d323b2d --- /dev/null +++ b/src/modules/asset/components/AssetHistory.tsx @@ -0,0 +1,472 @@ + +import React, { useState, useEffect } from 'react'; +import { Card } from '../../../shared/ui/Card'; +import { Save, Upload, X, Plus, Trash2, Edit2, Box, ZoomIn } from 'lucide-react'; +import DatePicker from 'react-datepicker'; +import "react-datepicker/dist/react-datepicker.css"; +import { ko } from 'date-fns/locale'; +import { assetApi } from '../../../shared/api/assetApi'; +import type { Asset, MaintenanceRecord, PartUsage } from '../../../shared/api/assetApi'; +import { SERVER_URL } from '../../../shared/api/client'; +import { getMaintenanceTypes } from '../pages/AssetSettingsPage'; +import { getTypeBadgeColor } from '../utils/assetUtils'; + +interface AssetHistoryProps { + assetId: string; +} + +export function AssetHistory({ assetId }: AssetHistoryProps) { + const [history, setHistory] = useState([]); + const [isWriting, setIsWriting] = useState(true); // Default to open + const [formData, setFormData] = useState({ + maintenance_date: new Date().toISOString().split('T')[0], + type: getMaintenanceTypes()[0]?.name || '정기점검', + content: '', + images: [] as string[] + }); + + const [availableAssets, setAvailableAssets] = useState([]); + const [selectedParts, setSelectedParts] = useState([]); + const [partInput, setPartInput] = useState({ part_id: '', quantity: 1 }); + const [editingId, setEditingId] = useState(null); + + useEffect(() => { + loadHistory(); + loadAssets(); + }, [assetId]); + + const loadAssets = async () => { + try { + const data = await assetApi.getAll(); + setAvailableAssets(data.filter(a => a.id !== assetId)); + } catch (error) { + console.error("Failed to load assets:", error); + } + }; + + const loadHistory = async () => { + try { + const data = await assetApi.getMaintenanceHistory(assetId); + setHistory(data); + } catch (error) { + console.error("Failed to load history:", error); + } + }; + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const res = await assetApi.uploadImage(file); + setFormData(prev => ({ ...prev, images: [...(prev.images || []), res.data.url] })); + // Reset input value to allow same file selection again + e.target.value = ''; + } catch (error) { + console.error("Failed to upload image:", error); + alert("이미지 업로드에 실패했습니다."); + } + }; + + const handleDeleteImage = (index: number) => { + setFormData(prev => ({ + ...prev, + images: prev.images.filter((_, i) => i !== index) + })); + }; + + const handleAddPart = () => { + if (!partInput.part_id) return; + const part = availableAssets.find(a => a.id === partInput.part_id); + if (!part) return; + + // Check if already added + if (selectedParts.find(p => p.part_id === partInput.part_id)) { + alert("이미 추가된 부품입니다."); + return; + } + + setSelectedParts(prev => [...prev, { + part_id: part.id, + part_name: part.name, + model_name: part.model, + quantity: partInput.quantity + }]); + + setPartInput({ part_id: '', quantity: 1 }); + }; + + const handleRemovePart = (partId: string) => { + setSelectedParts(prev => prev.filter(p => p.part_id !== partId)); + }; + + const handleSubmit = async () => { + if (!formData.content) { + alert("정비 내용을 입력해주세요."); + return; + } + try { + if (editingId) { + // Update existing record + await assetApi.updateMaintenance(editingId, { + maintenance_date: formData.maintenance_date, + type: formData.type, + content: formData.content, + images: formData.images, + parts: selectedParts + }); + alert("수정되었습니다."); + } else { + // Create new record + await assetApi.addMaintenance(assetId, { + maintenance_date: formData.maintenance_date, + type: formData.type, + content: formData.content, + images: formData.images, + parts: selectedParts + }); + } + + // Reset Form + setIsWriting(false); + setEditingId(null); + setFormData({ + maintenance_date: new Date().toISOString().split('T')[0], + type: '정기점검', + content: '', + images: [] + }); + setSelectedParts([]); + loadHistory(); + } catch (error) { + console.error("Failed to save maintenance record:", error); + alert("저장에 실패했습니다."); + } + }; + + const handleEdit = (item: MaintenanceRecord) => { + setEditingId(item.id); + setFormData({ + maintenance_date: new Date(item.maintenance_date).toISOString().split('T')[0], + type: item.type, + content: item.content, + images: item.images || [] + }); + setSelectedParts(item.parts || []); + setIsWriting(true); + // Scroll to form + document.querySelector('.maintenance-form')?.scrollIntoView({ behavior: 'smooth' }); + }; + + const handleCancel = () => { + setIsWriting(false); + setEditingId(null); + setFormData({ + maintenance_date: new Date().toISOString().split('T')[0], + type: '정기점검', + content: '', + images: [] + }); + setSelectedParts([]); + // Re-open if list is empty? No, just close form. + }; + + const handleDelete = async (id: number) => { + if (confirm("정비 이력을 삭제하시겠습니까?")) { + try { + await assetApi.deleteMaintenance(id); + loadHistory(); + } catch (error) { + console.error("Failed to delete record:", error); + alert("삭제에 실패했습니다."); + } + } + }; + + return ( + +
+

+ + 정비 및 수리 이력 +

+
+ {isWriting && ( + + )} + +
+
+ + {isWriting && ( +
+
+
+
+ + { + if (date) { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + setFormData({ ...formData, maintenance_date: `${yyyy}-${mm}-${dd}` }); + } + }} + /> +
+
+ + +
+
+ +
+ + +
+ +
+ +
+ + +
+ {formData.images && formData.images.map((imgUrl, idx) => ( +
+
+ {`preview-${idx}`} +
+ +
+ ))} +
+
+
+ + {/* Part Selection UI */} +
+ +
+ + setPartInput({ ...partInput, quantity: parseInt(e.target.value) || 1 })} + /> + +
+ + {/* Selected Parts List */} + {selectedParts.length > 0 && ( +
+ {selectedParts.map(part => ( +
+
+
+ +
+ {part.part_name} + ({part.model_name}) +
+
+ + {part.quantity}개 + + +
+
+ ))} +
+ )} +
+
+
+ )} + +
+ + + + + + + + + + + + {history.length === 0 ? ( + + + + ) : ( + history.map(item => ( + + + + + + + + )) + )} + +
정비일자구분정비내용첨부관리
+
+
+ +
+

등록된 정비 이력이 없습니다.

+
+
+ {new Date(item.maintenance_date).toLocaleDateString()} + + + {item.type} + + +
{item.content}
+ {/* Display Used Parts */} + {item.parts && item.parts.length > 0 && ( +
+ {item.parts.map((part, idx) => ( + + + {part.part_name} ({part.quantity}) + + ))} +
+ )} +
+
+ {(item.images && item.images.length > 0) ? ( + item.images.map((img, i) => ( + + + + )) + ) : ( + item.image_url ? ( + + + + ) : - + )} +
+
+
+ + +
+
+
+
+ ); +} diff --git a/src/modules/asset/components/AssetManuals.tsx b/src/modules/asset/components/AssetManuals.tsx new file mode 100644 index 0000000..c353031 --- /dev/null +++ b/src/modules/asset/components/AssetManuals.tsx @@ -0,0 +1,133 @@ + +import React, { useState, useEffect } from 'react'; +import { Card } from '../../../shared/ui/Card'; +import { Upload, FileText, FileSpreadsheet, File, BookOpen, Download, Trash2 } from 'lucide-react'; +import { assetApi } from '../../../shared/api/assetApi'; +import type { Manual } from '../../../shared/api/assetApi'; +import { SERVER_URL } from '../../../shared/api/client'; + +interface AssetManualsProps { + assetId: string; +} + +export function AssetManuals({ assetId }: AssetManualsProps) { + const [manuals, setManuals] = useState([]); + + useEffect(() => { + loadManuals(); + }, [assetId]); + + const loadManuals = async () => { + try { + const data = await assetApi.getManuals(assetId); + setManuals(data); + } catch (error) { + console.error("Failed to load manuals:", error); + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const res = await assetApi.uploadImage(file); + await assetApi.addManual(assetId, { + file_name: file.name, + file_url: res.data.url + }); + loadManuals(); + e.target.value = ''; + } catch (error) { + console.error("Failed to upload manual:", error); + alert("파일 업로드에 실패했습니다."); + } + }; + + const handleDelete = async (id: number) => { + if (confirm("매뉴얼을 삭제하시겠습니까?")) { + try { + await assetApi.deleteManual(id); + loadManuals(); + } catch (error) { + console.error("Failed to delete manual:", error); + alert("삭제에 실패했습니다."); + } + } + }; + + const getFileIcon = (filename: string) => { + const ext = filename.split('.').pop()?.toLowerCase(); + if (ext === 'pdf') return ; + if (['xls', 'xlsx', 'csv'].includes(ext || '')) return ; + return ; + }; + + return ( + +
+

+ + 매뉴얼 및 지침서 +

+
+ +
+
+ +
+ {manuals.length === 0 ? ( +
+
+ +
+

등록된 매뉴얼이 없습니다.

+
+ ) : ( +
+ {manuals.map(manual => ( +
+
+
+ {getFileIcon(manual.file_name)} +
+
+

window.open(manual.file_url.startsWith('http') ? manual.file_url : `${SERVER_URL}${manual.file_url}`, '_blank')} + > + {manual.file_name} +

+ {new Date(manual.created_at || '').toLocaleDateString()} +
+
+
+ + + + +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/modules/asset/pages/AssetDetailPage.tsx b/src/modules/asset/pages/AssetDetailPage.tsx index bebcc9b..dcd54e9 100644 --- a/src/modules/asset/pages/AssetDetailPage.tsx +++ b/src/modules/asset/pages/AssetDetailPage.tsx @@ -1,22 +1,19 @@ + import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { Card } from '../../../shared/ui/Card'; -import { Button } from '../../../shared/ui/Button'; -import { ArrowLeft, Save, Upload, X, FileText, Wrench, BookOpen, Trash2, ZoomIn, FileSpreadsheet, File, Download, Printer, Plus, Box, Edit2 } from 'lucide-react'; - -import { getMaintenanceTypes } from './AssetSettingsPage'; +import { FileText, Wrench, BookOpen } from 'lucide-react'; import './AssetDetailPage.css'; import { assetApi } from '../../../shared/api/assetApi'; -import type { Asset, MaintenanceRecord, Manual, PartUsage } from '../../../shared/api/assetApi'; +import type { Asset } from '../../../shared/api/assetApi'; import { SERVER_URL } from '../../../shared/api/client'; -import { createPortal } from 'react-dom'; -import DatePicker from 'react-datepicker'; -import "react-datepicker/dist/react-datepicker.css"; -import { ko } from 'date-fns/locale'; + +// Imported Components +import { AssetBasicInfo } from '../components/AssetBasicInfo'; +import { AssetHistory } from '../components/AssetHistory'; +import { AssetManuals } from '../components/AssetManuals'; export function AssetDetailPage() { const { assetId } = useParams<{ assetId: string }>(); - // const navigate = useNavigate(); // Kept for future use const [asset, setAsset] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -41,7 +38,7 @@ export function AssetDetailPage() { }; if (isLoading) return
불러오는 중...
; - if (!asset) return
자산 정보를 찾을 수 없습니다.
; + if (!asset || !assetId) return
자산 정보를 찾을 수 없습니다.
; // Extend asset with mock data for missing DB fields (consumables, history) const fullAsset = { @@ -56,10 +53,10 @@ export function AssetDetailPage() { const renderContent = () => { switch (activeTab) { - case 'basic': return assetId && loadAsset(assetId)} />; - case 'history': return ; - case 'manual': return ; - default: return assetId && loadAsset(assetId)} />; + case 'basic': return assetId && loadAsset(assetId)} />; + case 'history': return ; + case 'manual': return ; + default: return assetId && loadAsset(assetId)} />; } }; @@ -105,1051 +102,5 @@ export function AssetDetailPage() { ); } -function BasicInfoTab({ asset, onRefresh }: { asset: Asset & { image?: string, consumables?: any[] }, onRefresh: () => void }) { - const [isEditing, setIsEditing] = useState(false); - const [editData, setEditData] = useState(asset); - const [isZoomed, setIsZoomed] = useState(false); - - const handleChange = ( - e: React.ChangeEvent - ) => { - const { name, value } = e.target; - setEditData(prev => ({ ...prev, [name]: value })); - }; - - const handleSave = async () => { - try { - await assetApi.update(asset.id, editData); - setIsEditing(false); - onRefresh(); // Refresh data from server - } catch (error) { - console.error("Failed to update asset:", error); - alert("저장에 실패했습니다."); - } - }; - - const handleImageUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - try { - const res = await assetApi.uploadImage(file); - // res.data.url is native path like /uploads/filename. We save this to DB. - setEditData(prev => ({ ...prev, image: res.data.url })); - } catch (error) { - console.error("Failed to upload image:", error); - alert("이미지 업로드에 실패했습니다."); - } - }; - - const handleDeleteImage = () => { - if (confirm("이미지를 삭제하시겠습니까?")) { - setEditData(prev => ({ ...prev, image: '' })); - } - }; - - const handleCancel = () => { - setEditData(asset); - setIsEditing(false); - }; - - return ( - <> -
- {isEditing ? ( - <> - - - - ) : ( - - )} - -
- - - -
- {/* Left: Image Area */} -
!isEditing && editData.image && setIsZoomed(true)} - > - {editData.image ? ( - <> - {asset.name} - {!isEditing && ( -
-
- -
-
- )} - - ) : ( -
이미지 없음
- )} - - {isEditing && ( -
e.stopPropagation()}> - - {editData.image && ( - - )} -
- )} -
- - {/* Right: Info Table */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
관리번호 -
- {editData.id} -
-
구입가격 - {isEditing ? ( - - ) : ( -
- {editData.purchasePrice ? `${Number(editData.purchasePrice).toLocaleString()} 원` : '-'} -
- )} -
설비명 - {isEditing ? ( - - ) : ( -
- {editData.name} -
- )} -
S/N - {isEditing ? ( - - ) : ( -
- {editData.serialNumber} -
- )} -
입고일 - {isEditing ? ( - - ) : ( -
- {editData.purchaseDate} -
- )} -
모델명 - {isEditing ? ( - - ) : ( -
- {editData.model} -
- )} -
제작사 - {isEditing ? ( - - ) : ( -
- {editData.manufacturer} -
- )} -
교정주기 - {isEditing ? ( - - ) : ( -
- {editData.calibrationCycle} -
- )} -
스펙/사양 - {isEditing ? ( - - ) : ( -
- {editData.specs} -
- )} -
설치위치 - {isEditing ? ( - - ) : ( -
- {editData.location} -
- )} -
관리책임자 - {isEditing ? ( - - ) : ( -
- {editData.manager} -
- )} -
관리상태 - {isEditing ? ( -
- -
- ) : ( -
- - {editData.status === 'active' ? '정상 가동' : editData.status === 'disposed' ? '폐기 (말소)' : editData.status === 'maintain' ? '점검 중' : '수리 필요'} - -
- )} -
-
-
- -
- - {/* Consumables Section */} -
-
-

관련 소모품 관리

- -
- - - - - - - - - - - {asset.consumables?.map(item => ( - - - - - - - ))} - -
품명규격현재고관리
{item.name}{item.spec}{item.qty}개 - -
-
-
- - {/* Image Zoom Modal - Moved to Portal */} - {isZoomed && editData.image && createPortal( -
setIsZoomed(false)} - > -
e.stopPropagation()} - > - {/* Modal Header */} -
-

{asset.name} - 이미지 상세

- -
- - {/* Modal Content */} -
- {asset.name} -
-
-
, - document.body - )} - - ); -} - - - -// Helper for badge colors -// Helper for badge colors -const getTypeBadgeColor = (type: string) => { - const types = getMaintenanceTypes(); - const found = types.find(t => t.name === type); - if (!found) return 'bg-slate-100 text-slate-700'; - - switch (found.color) { - case 'success': return 'bg-green-100 text-green-700'; - case 'danger': return 'bg-red-100 text-red-700'; - case 'warning': return 'bg-orange-100 text-orange-700'; - case 'primary': return 'bg-blue-100 text-blue-700'; - default: return 'bg-slate-100 text-slate-700'; // neutral - } -}; - -function HistoryTab({ assetId }: { assetId: string }) { - const [history, setHistory] = useState([]); - const [isWriting, setIsWriting] = useState(true); // Default to open - const [formData, setFormData] = useState({ - maintenance_date: new Date().toISOString().split('T')[0], - type: getMaintenanceTypes()[0]?.name || '정기점검', - content: '', - images: [] as string[] - }); - - const [availableAssets, setAvailableAssets] = useState([]); - const [selectedParts, setSelectedParts] = useState([]); - const [partInput, setPartInput] = useState({ part_id: '', quantity: 1 }); - const [editingId, setEditingId] = useState(null); - - useEffect(() => { - loadHistory(); - loadAssets(); - }, [assetId]); - - const loadAssets = async () => { - try { - const data = await assetApi.getAll(); - setAvailableAssets(data.filter(a => a.id !== assetId)); - } catch (error) { - console.error("Failed to load assets:", error); - } - }; - - const loadHistory = async () => { - try { - const data = await assetApi.getMaintenanceHistory(assetId); - setHistory(data); - } catch (error) { - console.error("Failed to load history:", error); - } - }; - - const handleImageUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - try { - const res = await assetApi.uploadImage(file); - setFormData(prev => ({ ...prev, images: [...(prev.images || []), res.data.url] })); - // Reset input value to allow same file selection again - e.target.value = ''; - } catch (error) { - console.error("Failed to upload image:", error); - alert("이미지 업로드에 실패했습니다."); - } - }; - - const handleDeleteImage = (index: number) => { - setFormData(prev => ({ - ...prev, - images: prev.images.filter((_, i) => i !== index) - })); - }; - - const handleAddPart = () => { - if (!partInput.part_id) return; - const part = availableAssets.find(a => a.id === partInput.part_id); - if (!part) return; - - // Check if already added - if (selectedParts.find(p => p.part_id === partInput.part_id)) { - alert("이미 추가된 부품입니다."); - return; - } - - setSelectedParts(prev => [...prev, { - part_id: part.id, - part_name: part.name, - model_name: part.model, - quantity: partInput.quantity - }]); - - setPartInput({ part_id: '', quantity: 1 }); - }; - - const handleRemovePart = (partId: string) => { - setSelectedParts(prev => prev.filter(p => p.part_id !== partId)); - }; - - - - const handleSubmit = async () => { - if (!formData.content) { - alert("정비 내용을 입력해주세요."); - return; - } - try { - if (editingId) { - // Update existing record - await assetApi.updateMaintenance(editingId, { - maintenance_date: formData.maintenance_date, - type: formData.type, - content: formData.content, - images: formData.images, - parts: selectedParts - }); - alert("수정되었습니다."); - } else { - // Create new record - await assetApi.addMaintenance(assetId, { - maintenance_date: formData.maintenance_date, - type: formData.type, - content: formData.content, - images: formData.images, - parts: selectedParts - }); - } - - // Reset Form - setIsWriting(false); - setEditingId(null); - setFormData({ - maintenance_date: new Date().toISOString().split('T')[0], - type: '정기점검', - content: '', - images: [] - }); - setSelectedParts([]); - loadHistory(); - } catch (error) { - console.error("Failed to save maintenance record:", error); - alert("저장에 실패했습니다."); - } - }; - - const handleEdit = (item: MaintenanceRecord) => { - setEditingId(item.id); - setFormData({ - maintenance_date: new Date(item.maintenance_date).toISOString().split('T')[0], - type: item.type, - content: item.content, - images: item.images || [] - }); - setSelectedParts(item.parts || []); - setIsWriting(true); - // Scroll to form - document.querySelector('.maintenance-form')?.scrollIntoView({ behavior: 'smooth' }); - }; - - const handleCancel = () => { - setIsWriting(false); - setEditingId(null); - setFormData({ - maintenance_date: new Date().toISOString().split('T')[0], - type: '정기점검', - content: '', - images: [] - }); - setSelectedParts([]); - }; - - const handleDelete = async (id: number) => { - if (confirm("정비 이력을 삭제하시겠습니까?")) { - try { - await assetApi.deleteMaintenance(id); - loadHistory(); - } catch (error) { - console.error("Failed to delete record:", error); - alert("삭제에 실패했습니다."); - } - } - }; - - return ( - -
-

- - 정비 및 수리 이력 -

-
- {isWriting && ( - - )} - -
-
- - - - {isWriting && ( -
-
-
-
- - { - if (date) { - const yyyy = date.getFullYear(); - const mm = String(date.getMonth() + 1).padStart(2, '0'); - const dd = String(date.getDate()).padStart(2, '0'); - setFormData({ ...formData, maintenance_date: `${yyyy}-${mm}-${dd}` }); - } - }} - /> -
-
- - -
-
- -
- - -
- -
- -
- - -
- {formData.images && formData.images.map((imgUrl, idx) => ( -
-
- {`preview-${idx}`} -
- -
- ))} -
-
-
- - {/* Part Selection UI */} -
- -
- - setPartInput({ ...partInput, quantity: parseInt(e.target.value) || 1 })} - /> - -
- - {/* Selected Parts List */} - {selectedParts.length > 0 && ( -
- {selectedParts.map(part => ( -
-
-
- -
- {part.part_name} - ({part.model_name}) -
-
- - {part.quantity}개 - - -
-
- ))} -
- )} -
-
-
- )} - -
- - - - - - - - - - - - {history.length === 0 ? ( - - - - ) : ( - history.map(item => ( - - - - - - - - )) - )} - -
정비일자구분정비내용첨부관리
-
-
- -
-

등록된 정비 이력이 없습니다.

-
-
- {new Date(item.maintenance_date).toLocaleDateString()} - - - {item.type} - - -
{item.content}
- {/* Display Used Parts */} - {item.parts && item.parts.length > 0 && ( -
- {item.parts.map((part, idx) => ( - - - {part.part_name} ({part.quantity}) - - ))} -
- )} -
-
- {(item.images && item.images.length > 0) ? ( - item.images.map((img, i) => ( - - - - )) - ) : ( - item.image_url ? ( - - - - ) : - - )} -
-
-
- - -
-
-
-
- ); -} - -function ManualTab({ assetId }: { assetId: string }) { - const [manuals, setManuals] = useState([]); - - useEffect(() => { - loadManuals(); - }, [assetId]); - - const loadManuals = async () => { - try { - const data = await assetApi.getManuals(assetId); - setManuals(data); - } catch (error) { - console.error("Failed to load manuals:", error); - } - }; - - const handleFileUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - try { - const res = await assetApi.uploadImage(file); - await assetApi.addManual(assetId, { - file_name: file.name, - file_url: res.data.url - }); - loadManuals(); - e.target.value = ''; - } catch (error) { - console.error("Failed to upload manual:", error); - alert("파일 업로드에 실패했습니다."); - } - }; - - const handleDelete = async (id: number) => { - if (confirm("매뉴얼을 삭제하시겠습니까?")) { - try { - await assetApi.deleteManual(id); - loadManuals(); - } catch (error) { - console.error("Failed to delete manual:", error); - alert("삭제에 실패했습니다."); - } - } - }; - - const getFileIcon = (filename: string) => { - const ext = filename.split('.').pop()?.toLowerCase(); - if (ext === 'pdf') return ; - if (['xls', 'xlsx', 'csv'].includes(ext || '')) return ; - return ; - }; - - return ( - -
-

- - 매뉴얼 및 지침서 -

-
- -
-
- -
- {manuals.length === 0 ? ( -
-
- -
-

등록된 매뉴얼이 없습니다.

-
- ) : ( -
- {manuals.map(manual => ( -
-
-
- {getFileIcon(manual.file_name)} -
-
-

window.open(manual.file_url.startsWith('http') ? manual.file_url : `${SERVER_URL}${manual.file_url}`, '_blank')} - > - {manual.file_name} -

- {new Date(manual.created_at || '').toLocaleDateString()} -
-
-
- - - - -
-
- ))} -
- )} -
-
- ); -} +// Ensure type badge color helper is available if still needed by other files? +// No, it's moved to assetUtils and Settings page exports. diff --git a/src/modules/asset/utils/assetUtils.ts b/src/modules/asset/utils/assetUtils.ts new file mode 100644 index 0000000..ace9937 --- /dev/null +++ b/src/modules/asset/utils/assetUtils.ts @@ -0,0 +1,17 @@ + +import { getMaintenanceTypes } from '../pages/AssetSettingsPage'; + +// Helper for badge colors +export const getTypeBadgeColor = (type: string) => { + const types = getMaintenanceTypes(); + const found = types.find(t => t.name === type); + if (!found) return 'bg-slate-100 text-slate-700'; + + switch (found.color) { + case 'success': return 'bg-green-100 text-green-700'; + case 'danger': return 'bg-red-100 text-red-700'; + case 'warning': return 'bg-orange-100 text-orange-700'; + case 'primary': return 'bg-blue-100 text-blue-700'; + default: return 'bg-slate-100 text-slate-700'; // neutral + } +};