smart_ims/src/modules/asset/components/AssetBasicInfo.tsx

674 lines
41 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
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';
import { useAuth } from '../../../shared/auth/AuthContext';
interface AssetBasicInfoProps {
asset: Asset & { image?: string, accessories?: any[] };
onRefresh: () => void;
}
export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
const navigate = useNavigate();
const { user } = useAuth();
// User role is now allowed to edit assets
const canEdit = user?.role === 'admin' || user?.role === 'supervisor' || user?.role === 'user';
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState(asset);
const [isZoomed, setIsZoomed] = useState(false);
const [allAssets, setAllAssets] = useState<Asset[]>([]);
const [accessories, setAccessories] = useState<any[]>([]);
const [showAccModal, setShowAccModal] = useState(false);
const [newAcc, setNewAcc] = useState({ name: '', spec: '', quantity: 1 });
useEffect(() => {
loadAccessories();
}, [asset.id]);
const loadAccessories = async () => {
try {
const data = await assetApi.getAccessories(asset.id);
setAccessories(data);
} catch (err) {
console.error("Failed to load accessories", err);
}
};
useEffect(() => {
if (isEditing) {
const loadAllAssets = async () => {
try {
const data = await assetApi.getAll();
setAllAssets(data);
} catch (err) {
console.error("Failed to load assets", err);
}
};
loadAllAssets();
}
}, [isEditing]);
const isFacility = asset.category === '설비';
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);
};
const handleAddAccessory = async () => {
if (!newAcc.name) return alert('품명을 입력해주세요.');
try {
await assetApi.addAccessory(asset.id, newAcc);
setNewAcc({ name: '', spec: '', quantity: 1 });
setShowAccModal(false);
loadAccessories();
} catch (err) {
alert('등록 실패');
}
};
const handleDeleteAccessory = async (id: number) => {
if (!confirm('삭제하시겠습니까?')) return;
try {
await assetApi.deleteAccessory(id);
loadAccessories();
} catch (err) {
alert('삭제 실패');
}
};
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>
</>
) : (
canEdit && <Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}></Button>
)}
<Button variant="secondary" size="sm" icon={<Printer size={16} />} onClick={() => window.print()}></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>
{isFacility && (
<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 ? (
<div className="relative w-full">
<select
name="parentId"
value={editData.parentId || ''}
onChange={handleChange}
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="">( - )</option>
{allAssets
.filter(a => a.category === '설비' && a.id !== asset.id)
.map(a => (
<option key={a.id} value={a.id}>[{a.id}] {a.name}</option>
))
}
</select>
</div>
) : (
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
{asset.parentId ? (
<a href={`/asset/detail/${asset.parentId}`} className="text-blue-600 hover:underline">
[{asset.parentId}] {asset.parentName || '상위 설비'}
</a>
) : (
<span className="text-slate-400"> ( )</span>
)}
</div>
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Sub-Equipment Section (Only for Facilities) */}
{isFacility && (
<div className="sub-equipment-section mt-8">
<div className="flex justify-between items-center mb-2">
<h3 className="section-title text-lg font-bold"> </h3>
{canEdit && (
<Button
size="sm"
variant="secondary"
icon={<Plus size={14} />}
onClick={() => {
// Navigate to register with pre-filled parent
navigate(`/asset/register?parentId=${asset.id}`);
}}
>
</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>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
</tr>
</thead>
<tbody>
{asset.children && asset.children.length > 0 ? (
asset.children.map(child => (
<tr key={child.id}>
<td className="border border-slate-300 p-2 font-mono">{child.id}</td>
<td className="border border-slate-300 p-2 font-medium">{child.name}</td>
<td className="border border-slate-300 p-2">{child.location}</td>
<td className="border border-slate-300 p-2">
<span className={`badge ${child.status === 'active' ? 'badge-success' : 'badge-warning'}`}>
{child.status === 'active' ? '정상' : '점검/이동'}
</span>
</td>
<td className="border border-slate-300 p-2">
<a href={`/asset/detail/${child.id}`} className="text-blue-600 underline"></a>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="p-8 text-slate-400"> .</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<div className="section-divider my-6 border-b border-slate-200"></div>
{/* Accessories Section (For non-facility or all?) - User said skip hierarchical for others, use simple accessories */}
{!isFacility && (
<div className="accessories-section">
<div className="flex justify-between items-center mb-2">
<h3 className="section-title text-lg font-bold"> </h3>
{canEdit && (
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => setShowAccModal(true)}>
</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>
{accessories.length > 0 ? (
accessories.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.quantity}</td>
<td className="border border-slate-300 p-2">
<button
onClick={() => handleDeleteAccessory(item.id)}
className="text-slate-400 hover:text-red-500 text-sm underline"
>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="p-8 text-slate-400"> .</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</Card>
{/* Accessory Add Modal */}
{showAccModal && createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-xl w-[400px] p-6">
<h3 className="text-xl font-bold mb-4"> </h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> *</label>
<input
className="w-full border p-2 rounded"
value={newAcc.name}
onChange={e => setNewAcc({ ...newAcc, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> / </label>
<input
className="w-full border p-2 rounded"
value={newAcc.spec}
onChange={e => setNewAcc({ ...newAcc, spec: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<input
type="number"
className="w-full border p-2 rounded"
value={newAcc.quantity}
onChange={e => setNewAcc({ ...newAcc, quantity: Number(e.target.value) })}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="secondary" onClick={() => setShowAccModal(false)}></Button>
<Button onClick={handleAddAccessory}></Button>
</div>
</div>
</div>,
document.body
)}
{/* 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
)}
</>
);
}