Improve Asset Maintenance UI
This commit is contained in:
parent
af510968ef
commit
f5f7086fbf
463
src/modules/asset/components/AssetBasicInfo.tsx
Normal file
463
src/modules/asset/components/AssetBasicInfo.tsx
Normal file
@ -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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center mb-4 gap-1" style={{ justifyContent: 'flex-end' }}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" onClick={handleCancel} icon={<ArrowLeft size={16} />}>취소</Button>
|
||||
<Button size="sm" onClick={handleSave} icon={<Save size={16} />}>저장</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>수정</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" icon={<Printer size={16} />}>출력</Button>
|
||||
</div>
|
||||
|
||||
<Card className="content-card print-friendly">
|
||||
|
||||
<div className="document-layout flex gap-6 items-start">
|
||||
{/* Left: Image Area */}
|
||||
<div
|
||||
className="doc-image-area flex-none bg-white border border-slate-300 rounded flex flex-col items-center justify-center overflow-hidden relative group cursor-pointer p-2"
|
||||
style={{ width: '400px', height: '350px' }}
|
||||
onClick={() => !isEditing && editData.image && setIsZoomed(true)}
|
||||
>
|
||||
{editData.image ? (
|
||||
<>
|
||||
<img
|
||||
src={
|
||||
editData.image.startsWith('http')
|
||||
? editData.image
|
||||
: `${SERVER_URL}${!SERVER_URL && !editData.image.startsWith('/') ? '/' : ''}${editData.image}`
|
||||
}
|
||||
alt={asset.name}
|
||||
className="w-full h-full"
|
||||
style={{ objectFit: 'contain', maxWidth: '100%', maxHeight: '100%' }}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="bg-black/50 text-white p-2 rounded-full">
|
||||
<ZoomIn size={24} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-slate-400">이미지 없음</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4" onClick={(e) => e.stopPropagation()}>
|
||||
<label className="cursor-pointer bg-white p-2 rounded-full hover:bg-slate-100 text-slate-700">
|
||||
<Upload size={20} />
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
|
||||
</label>
|
||||
{editData.image && (
|
||||
<button onClick={handleDeleteImage} className="bg-white p-2 rounded-full hover:bg-red-50 text-red-500">
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Info Table */}
|
||||
<div className="doc-info-area flex-1">
|
||||
|
||||
<table className="doc-table w-full border-collapse border border-slate-300 table-fixed">
|
||||
<colgroup>
|
||||
<col className="w-[10%] bg-slate-50" />
|
||||
<col className="w-[23%]" />
|
||||
<col className="w-[10%] bg-slate-50" />
|
||||
<col className="w-[23%]" />
|
||||
<col className="w-[10%] bg-slate-50" />
|
||||
<col className="w-[24%]" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">관리번호</th>
|
||||
<td colSpan={3} className="border border-slate-300 p-2 text-lg bg-slate-50">
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium text-left cursor-default">
|
||||
{editData.id}
|
||||
</div>
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">구입가격</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
name="purchasePrice"
|
||||
value={editData.purchasePrice || ''}
|
||||
onChange={handleChange}
|
||||
placeholder="0"
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.purchasePrice ? `${Number(editData.purchasePrice).toLocaleString()} 원` : '-'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">설비명</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="name"
|
||||
value={editData.name}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.name}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">S/N</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="serialNumber"
|
||||
value={editData.serialNumber}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.serialNumber}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">입고일</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="date"
|
||||
name="purchaseDate"
|
||||
value={editData.purchaseDate}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none font-sans border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium font-sans cursor-default">
|
||||
{editData.purchaseDate}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">모델명</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="model"
|
||||
value={editData.model}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.model}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">제작사</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="manufacturer"
|
||||
value={editData.manufacturer}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.manufacturer}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">교정주기</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="calibrationCycle"
|
||||
value={editData.calibrationCycle}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.calibrationCycle}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">스펙/사양</th>
|
||||
<td colSpan={5} className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="specs"
|
||||
value={editData.specs}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.specs}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">설치위치</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="location"
|
||||
value={editData.location}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.location}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">관리책임자</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="manager"
|
||||
value={editData.manager}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.manager}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">관리상태</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<div className="relative w-full">
|
||||
<select
|
||||
name="status"
|
||||
value={editData.status}
|
||||
onChange={e => setEditData({ ...editData, status: e.target.value as any })}
|
||||
className="block px-3 py-1 bg-transparent border border-slate-300 rounded-none !text-lg !font-medium text-slate-700 outline-none focus:bg-slate-50 transition-colors appearance-none shadow-sm"
|
||||
style={{ WebkitAppearance: 'none', MozAppearance: 'none', appearance: 'none', backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, backgroundPosition: 'right 0.5rem center', backgroundRepeat: 'no-repeat', backgroundSize: '1.5em 1.5em', paddingRight: '2.5rem' }}
|
||||
>
|
||||
<option value="active">정상 가동</option>
|
||||
<option value="maintain">점검 중</option>
|
||||
<option value="broken">수리 필요</option>
|
||||
<option value="disposed">폐기 (말소)</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full !h-14 flex items-center px-2 border-0 !border-none">
|
||||
<span className={`badge ${editData.status === 'active' ? 'badge-success' : editData.status === 'disposed' ? 'badge-neutral' : 'badge-warning'} !text - lg!font - medium px - 4 py - 2`}>
|
||||
{editData.status === 'active' ? '정상 가동' : editData.status === 'disposed' ? '폐기 (말소)' : editData.status === 'maintain' ? '점검 중' : '수리 필요'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-divider my-6 border-b border-slate-200"></div>
|
||||
|
||||
{/* Consumables Section */}
|
||||
<div className="consumables-section">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="section-title text-lg font-bold">관련 소모품 관리</h3>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />}>소모품 추가</Button>
|
||||
</div>
|
||||
<table className="doc-table w-full text-center border-collapse border border-slate-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50">품명</th>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50">규격</th>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50">현재고</th>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{asset.consumables?.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-slate-300 p-2">{item.name}</td>
|
||||
<td className="border border-slate-300 p-2">{item.spec}</td>
|
||||
<td className="border border-slate-300 p-2">{item.qty}개</td>
|
||||
<td className="border border-slate-300 p-2">
|
||||
<button className="text-slate-400 hover:text-red-500 text-sm underline">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Image Zoom Modal - Moved to Portal */}
|
||||
{isZoomed && editData.image && createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
onClick={() => setIsZoomed(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
maxWidth: '56rem', // max-w-4xl
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
margin: '1rem'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc'
|
||||
}}>
|
||||
<h3 style={{ fontWeight: 'bold', fontSize: '1.125rem', color: '#1e293b' }}>{asset.name} - 이미지 상세</h3>
|
||||
<button
|
||||
onClick={() => setIsZoomed(false)}
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
borderRadius: '9999px',
|
||||
color: '#64748b',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#e2e8f0'}
|
||||
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f1f5f9'
|
||||
}}>
|
||||
<img
|
||||
src={editData.image.startsWith('http') ? editData.image : `${SERVER_URL}${editData.image} `}
|
||||
alt={asset.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '75vh',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
472
src/modules/asset/components/AssetHistory.tsx
Normal file
472
src/modules/asset/components/AssetHistory.tsx
Normal file
@ -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<MaintenanceRecord[]>([]);
|
||||
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<Asset[]>([]);
|
||||
const [selectedParts, setSelectedParts] = useState<PartUsage[]>([]);
|
||||
const [partInput, setPartInput] = useState({ part_id: '', quantity: 1 });
|
||||
const [editingId, setEditingId] = useState<number | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Card className="content-card shadow-sm border border-slate-200">
|
||||
<div className="card-header flex justify-between items-center p-4 border-b border-slate-100 bg-white">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<span className="w-1 h-6 bg-blue-600 rounded-full inline-block"></span>
|
||||
정비 및 수리 이력
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWriting && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium transition-colors bg-blue-600 text-white hover:bg-blue-700 shadow-sm"
|
||||
>
|
||||
<Save size={16} /> {editingId ? '수정완료' : '저장하기'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => isWriting ? handleCancel() : setIsWriting(true)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium transition-colors ${isWriting
|
||||
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{isWriting ? (
|
||||
<>
|
||||
<X size={16} /> 작성 취소
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus size={16} /> 정비등록
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isWriting && (
|
||||
<div className="maintenance-form bg-slate-50 p-6 border-b border-slate-200 rounded-lg mb-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">정비일자</label>
|
||||
<DatePicker
|
||||
locale={ko}
|
||||
dateFormat="yyyy-MM-dd"
|
||||
className="w-full h-14 text-lg px-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
selected={formData.maintenance_date ? new Date(formData.maintenance_date) : null}
|
||||
onChange={(date: Date | null) => {
|
||||
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}` });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">구분</label>
|
||||
<select
|
||||
className="w-full h-14 text-lg px-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
||||
>
|
||||
{getMaintenanceTypes().map(t => (
|
||||
<option key={t.id} value={t.name}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">정비내용</label>
|
||||
<textarea
|
||||
className="w-full p-4 text-lg border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white min-h-[300px] resize-y"
|
||||
placeholder="상세 정비 내용을 입력하세요..."
|
||||
value={formData.content}
|
||||
onChange={e => setFormData({ ...formData, content: e.target.value })}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">사진 첨부</label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="cursor-pointer group relative flex items-center justify-center px-4 py-2 border border-slate-300 rounded-lg bg-white hover:bg-slate-50 transition-all h-14 w-full md:w-auto self-start">
|
||||
<input type="file" accept="image/*" className="hidden" style={{ display: 'none' }} onChange={handleImageUpload} />
|
||||
<div className="flex items-center gap-2 text-slate-600 group-hover:text-slate-800">
|
||||
<Upload size={20} />
|
||||
<span className="text-base font-medium">사진 선택</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{formData.images && formData.images.map((imgUrl, idx) => (
|
||||
<div key={idx} className="relative group w-fit">
|
||||
<div className="w-[150px] h-[150px] rounded-lg bg-white border border-slate-200 overflow-hidden shadow-sm flex items-center justify-center">
|
||||
<img
|
||||
src={imgUrl.startsWith('http') ? imgUrl : `${SERVER_URL}${imgUrl}`}
|
||||
alt={`preview-${idx}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteImage(idx)}
|
||||
className="absolute top-1 right-1 p-1.5 bg-white/90 hover:bg-red-50 rounded-full text-slate-500 hover:text-red-500 shadow-md transition-colors border border-slate-200"
|
||||
title="이미지 삭제"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Part Selection UI */}
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">사용 부품/소모품</label>
|
||||
<div className="flex gap-2 mb-2 p-3 bg-white border border-slate-200 rounded-lg">
|
||||
<select
|
||||
className="flex-1 p-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={partInput.part_id}
|
||||
onChange={e => setPartInput({ ...partInput, part_id: e.target.value })}
|
||||
>
|
||||
<option value="">부품 선택 (자재)</option>
|
||||
{availableAssets.map(asset => (
|
||||
<option key={asset.id} value={asset.id}>
|
||||
{asset.name} ({asset.model || '-'}) - {asset.location}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className="w-20 p-2 border border-slate-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none text-right"
|
||||
value={partInput.quantity}
|
||||
onChange={e => setPartInput({ ...partInput, quantity: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddPart}
|
||||
className="px-4 py-2 bg-slate-800 text-white rounded hover:bg-slate-700 transition-colors font-medium whitespace-nowrap"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Selected Parts List */}
|
||||
{selectedParts.length > 0 && (
|
||||
<div className="space-y-2 bg-white border border-slate-200 rounded-lg p-3">
|
||||
{selectedParts.map(part => (
|
||||
<div key={part.part_id} className="flex items-center justify-between p-2 bg-slate-50 rounded border border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 flex items-center justify-center bg-blue-100 text-blue-600 rounded-full">
|
||||
<Box size={16} />
|
||||
</div>
|
||||
<span className="font-medium text-slate-700">{part.part_name}</span>
|
||||
<span className="text-xs text-slate-400">({part.model_name})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="bg-slate-200 text-slate-700 px-2 py-0.5 rounded text-sm font-bold">
|
||||
{part.quantity}개
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemovePart(part.part_id)}
|
||||
className="text-slate-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="history-list">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50 text-slate-500 font-bold border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-center w-40 text-base">정비일자</th>
|
||||
<th className="px-6 py-4 text-center w-32 text-base">구분</th>
|
||||
<th className="px-6 py-4 text-center text-base">정비내용</th>
|
||||
<th className="px-6 py-4 text-center w-24 text-base">첨부</th>
|
||||
<th className="px-6 py-4 text-center w-24 text-base">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{history.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-16 text-slate-400">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center text-slate-300">
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<p className="text-lg">등록된 정비 이력이 없습니다.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
history.map(item => (
|
||||
<tr key={item.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="px-6 py-5 text-center whitespace-nowrap text-slate-700 text-lg">
|
||||
{new Date(item.maintenance_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${getTypeBadgeColor(item.type)}`}>
|
||||
{item.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center text-slate-600 max-w-xs">
|
||||
<div className="line-clamp-2">{item.content}</div>
|
||||
{/* Display Used Parts */}
|
||||
{item.parts && item.parts.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1 justify-center">
|
||||
{item.parts.map((part, idx) => (
|
||||
<span key={idx} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-slate-100 text-slate-600 border border-slate-200">
|
||||
<Box size={10} />
|
||||
{part.part_name} ({part.quantity})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center">
|
||||
<div className="flex gap-1 justify-center flex-wrap max-w-[200px] mx-auto">
|
||||
{(item.images && item.images.length > 0) ? (
|
||||
item.images.map((img, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={img.startsWith('http') ? img : `${SERVER_URL}${img}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded bg-slate-100 text-blue-600 hover:bg-blue-50 border border-slate-200 overflow-hidden"
|
||||
title="이미지 보기"
|
||||
>
|
||||
<img
|
||||
src={img.startsWith('http') ? img : `${SERVER_URL}${img}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
item.image_url ? (
|
||||
<a
|
||||
href={item.image_url.startsWith('http') ? item.image_url : `${SERVER_URL}${item.image_url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 transition-colors"
|
||||
title="이미지 보기"
|
||||
>
|
||||
<ZoomIn size={20} />
|
||||
</a>
|
||||
) : <span className="text-slate-300">-</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center">
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
className="text-slate-400 hover:text-blue-600 hover:bg-blue-50 p-2 rounded-md transition-all opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={() => handleEdit(item)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 size={20} />
|
||||
</button>
|
||||
<button
|
||||
className="text-slate-400 hover:text-red-600 hover:bg-red-50 p-2 rounded-md transition-all opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
133
src/modules/asset/components/AssetManuals.tsx
Normal file
133
src/modules/asset/components/AssetManuals.tsx
Normal file
@ -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<Manual[]>([]);
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
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 <FileText size={24} className="text-red-500" />;
|
||||
if (['xls', 'xlsx', 'csv'].includes(ext || '')) return <FileSpreadsheet size={24} className="text-green-600" />;
|
||||
return <File size={24} className="text-slate-400" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="content-card shadow-sm border border-slate-200">
|
||||
<div className="card-header flex justify-between items-center p-4 border-b border-slate-100 bg-white">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<span className="w-1 h-6 bg-blue-600 rounded-full inline-block"></span>
|
||||
매뉴얼 및 지침서
|
||||
</h2>
|
||||
<div>
|
||||
<label className="cursor-pointer flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium transition-colors bg-blue-600 text-white hover:bg-blue-700 shadow-sm">
|
||||
<Upload size={16} />
|
||||
<span>파일 업로드</span>
|
||||
<input type="file" className="hidden" onChange={handleFileUpload} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{manuals.length === 0 ? (
|
||||
<div className="empty-state py-12 flex flex-col items-center gap-3 text-slate-400">
|
||||
<div className="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center">
|
||||
<BookOpen size={32} className="text-slate-300" />
|
||||
</div>
|
||||
<p>등록된 매뉴얼이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{manuals.map(manual => (
|
||||
<div key={manual.id} className="flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-white rounded border border-slate-100 shadow-sm">
|
||||
{getFileIcon(manual.file_name)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-medium text-slate-700 group-hover:text-blue-600 transition-colors cursor-pointer"
|
||||
onClick={() => window.open(manual.file_url.startsWith('http') ? manual.file_url : `${SERVER_URL}${manual.file_url}`, '_blank')}
|
||||
>
|
||||
{manual.file_name}
|
||||
</p>
|
||||
<span className="text-xs text-slate-400">{new Date(manual.created_at || '').toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={manual.file_url.startsWith('http') ? manual.file_url : `${SERVER_URL}${manual.file_url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download
|
||||
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors"
|
||||
title="다운로드/미리보기"
|
||||
>
|
||||
<Download size={18} />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleDelete(manual.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
17
src/modules/asset/utils/assetUtils.ts
Normal file
17
src/modules/asset/utils/assetUtils.ts
Normal file
@ -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
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user