464 lines
28 KiB
TypeScript
464 lines
28 KiB
TypeScript
|
|
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
|
|
)}
|
|
</>
|
|
);
|
|
}
|