Improve Asset Maintenance UI

This commit is contained in:
choibk 2026-01-23 00:54:26 +09:00
parent af510968ef
commit f5f7086fbf
5 changed files with 1100 additions and 1064 deletions

View 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
)}
</>
);
}

View 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>
);
}

View 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

View 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
}
};