- 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용 - 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축 - 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화 - 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5) - 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화
341 lines
15 KiB
TypeScript
341 lines
15 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 { Input } from '../../../shared/ui/Input';
|
|
import { Select } from '../../../shared/ui/Select';
|
|
import { ArrowLeft, Save, Upload } from 'lucide-react';
|
|
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
|
|
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
|
import './AssetRegisterPage.css';
|
|
|
|
export function AssetRegisterPage() {
|
|
const navigate = useNavigate();
|
|
|
|
// Load Settings Data
|
|
const categories = getCategories();
|
|
const locations = getLocations();
|
|
const idRule = getIDRule();
|
|
|
|
const [formData, setFormData] = useState({
|
|
id: '', // Asset ID (Auto-generated)
|
|
name: '',
|
|
categoryId: '', // Use ID for selection
|
|
model: '',
|
|
serialNo: '',
|
|
locationId: '', // Use ID for selection
|
|
manager: '',
|
|
status: 'active',
|
|
purchaseDate: new Date().toISOString().split('T')[0], // Default to today
|
|
purchasePrice: '',
|
|
image: null as File | null,
|
|
imagePreview: '' as string,
|
|
manufacturer: ''
|
|
});
|
|
|
|
// Auto-generate Asset ID
|
|
useEffect(() => {
|
|
// If category is required by rule but not selected, can't generate fully
|
|
const hasCategoryRule = idRule.some(r => r.type === 'category');
|
|
if (hasCategoryRule && !formData.categoryId) {
|
|
setFormData(prev => ({ ...prev, id: '' }));
|
|
return;
|
|
}
|
|
|
|
const category = categories.find(c => c.id === formData.categoryId);
|
|
const year = formData.purchaseDate ? new Date(formData.purchaseDate).getFullYear().toString() : new Date().getFullYear().toString();
|
|
|
|
// Build ID string based on Rule
|
|
const generatedId = idRule.map(part => {
|
|
if (part.type === 'company') return part.value; // e.g. HK
|
|
if (part.type === 'custom') return part.value;
|
|
if (part.type === 'separator') return part.value;
|
|
if (part.type === 'year') return year;
|
|
if (part.type === 'category') return category ? category.code : 'UNKNOWN';
|
|
if (part.type === 'sequence') return part.value;
|
|
return '';
|
|
}).join('');
|
|
|
|
const finalId = generatedId.replace('001', '001');
|
|
|
|
setFormData(prev => ({ ...prev, id: finalId }));
|
|
|
|
}, [formData.categoryId, formData.purchaseDate, idRule, categories]);
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
|
const { name, value } = e.target;
|
|
setFormData(prev => ({
|
|
...prev,
|
|
[name]: value
|
|
}));
|
|
};
|
|
|
|
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
image: file,
|
|
imagePreview: reader.result as string
|
|
}));
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
if (e) e.preventDefault();
|
|
|
|
// Validation
|
|
if (!formData.categoryId || !formData.name || !formData.locationId) {
|
|
alert('필수 항목(카테고리, 자산명, 설치위치)을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const selectedCategory = categories.find(c => c.id === formData.categoryId);
|
|
const selectedLocation = locations.find(l => l.id === formData.locationId);
|
|
|
|
let imageUrl = '';
|
|
if (formData.image) {
|
|
const uploadRes = await assetApi.uploadImage(formData.image);
|
|
imageUrl = uploadRes.data.url;
|
|
}
|
|
|
|
const payload: Partial<Asset> = {
|
|
id: formData.id,
|
|
name: formData.name,
|
|
category: selectedCategory ? selectedCategory.name : '미지정',
|
|
model: formData.model,
|
|
serialNumber: formData.serialNo,
|
|
location: selectedLocation ? selectedLocation.name : '미지정',
|
|
manager: formData.manager,
|
|
status: formData.status as Asset['status'],
|
|
purchaseDate: formData.purchaseDate,
|
|
purchasePrice: formData.purchasePrice ? Number(formData.purchasePrice) : 0,
|
|
manufacturer: formData.manufacturer,
|
|
image: imageUrl
|
|
};
|
|
|
|
await assetApi.create(payload);
|
|
|
|
alert(`자산이 성공적으로 등록되었습니다.\n자산번호: ${formData.id}`);
|
|
navigate('/asset/list');
|
|
} catch (error) {
|
|
console.error('Failed to register asset:', error);
|
|
alert('자산 등록에 실패했습니다. 서버 상태를 확인해주세요.');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="page-container">
|
|
<div className="page-header-right">
|
|
<h1 className="page-title-text">자산 등록</h1>
|
|
<p className="page-subtitle">새로운 자산을 시스템에 등록합니다.</p>
|
|
</div>
|
|
|
|
<Card className="w-full shadow-sm border border-slate-200 mb-8">
|
|
<form onSubmit={handleSubmit} className="p-2 sm:p-4 lg:p-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
|
|
{/* Basic Info Section */}
|
|
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2">
|
|
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
|
<h3 className="text-xl font-bold text-slate-800">기본 정보</h3>
|
|
</div>
|
|
|
|
<Select
|
|
label="카테고리 *"
|
|
name="categoryId"
|
|
value={formData.categoryId}
|
|
onChange={handleChange}
|
|
options={categories.map(c => ({ label: c.name, value: c.id }))}
|
|
placeholder="카테고리 선택"
|
|
required
|
|
/>
|
|
|
|
<Input
|
|
label="자산 관리 번호 (자동 생성)"
|
|
name="id"
|
|
value={formData.id}
|
|
disabled
|
|
placeholder="카테고리 선택 시 자동 생성됨"
|
|
className="bg-slate-50 font-mono text-slate-600"
|
|
/>
|
|
|
|
<Input
|
|
label="자산명 *"
|
|
name="name"
|
|
value={formData.name}
|
|
onChange={handleChange}
|
|
placeholder="예: CNC 머시닝 센터"
|
|
required
|
|
/>
|
|
|
|
<Input
|
|
label="제작사"
|
|
name="manufacturer"
|
|
value={formData.manufacturer}
|
|
onChange={handleChange}
|
|
/>
|
|
|
|
<Input
|
|
label="모델명"
|
|
name="model"
|
|
value={formData.model}
|
|
onChange={handleChange}
|
|
/>
|
|
|
|
<Input
|
|
label="시리얼 번호 / 규격"
|
|
name="serialNo"
|
|
value={formData.serialNo}
|
|
onChange={handleChange}
|
|
/>
|
|
|
|
{/* Image Upload Field */}
|
|
<div className="ui-field-container col-span-full">
|
|
<label className="ui-label">자산 이미지</label>
|
|
<div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}>
|
|
<div
|
|
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
|
|
style={{
|
|
width: '400px',
|
|
height: '350px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
}}
|
|
>
|
|
{formData.imagePreview ? (
|
|
<img
|
|
src={formData.imagePreview}
|
|
alt="Preview"
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
) : (
|
|
<div
|
|
className="text-slate-300"
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: '100%',
|
|
height: '100%'
|
|
}}
|
|
>
|
|
<Upload size={32} strokeWidth={1.5} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
|
|
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
|
|
파일 선택
|
|
</label>
|
|
|
|
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
|
|
{formData.image ? formData.image.name : '선택된 파일 없음'}
|
|
</span>
|
|
|
|
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
|
|
지원 형식: JPG, PNG, GIF (최대 5MB)
|
|
</span>
|
|
|
|
<input
|
|
id="image-upload"
|
|
type="file"
|
|
style={{ display: 'none' }}
|
|
accept="image/*"
|
|
onChange={handleImageChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Management Info Section */}
|
|
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
|
|
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
|
<h3 className="text-xl font-bold text-slate-800">관리 정보</h3>
|
|
</div>
|
|
|
|
<Select
|
|
label="설치 위치 / 보관 장소 *"
|
|
name="locationId"
|
|
value={formData.locationId}
|
|
onChange={handleChange}
|
|
options={locations.map(l => ({ label: l.name, value: l.id }))}
|
|
placeholder="위치 선택"
|
|
required
|
|
/>
|
|
|
|
<Input
|
|
label="관리자 / 부서"
|
|
name="manager"
|
|
value={formData.manager}
|
|
onChange={handleChange}
|
|
/>
|
|
|
|
<Select
|
|
label="초기 상태"
|
|
name="status"
|
|
value={formData.status}
|
|
onChange={handleChange}
|
|
options={[
|
|
{ label: '정상 가동 (Active)', value: 'active' },
|
|
{ label: '대기 (Idle)', value: 'idle' },
|
|
{ label: '설치 중 (Installing)', value: 'installing' },
|
|
{ label: '점검 중 (Maintenance)', value: 'maintain' }
|
|
]}
|
|
/>
|
|
|
|
<div className="col-span-1"></div>
|
|
|
|
{/* Purchasing Info Section */}
|
|
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
|
|
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
|
<h3 className="text-xl font-bold text-slate-800">도입 정보</h3>
|
|
</div>
|
|
|
|
<Input
|
|
label="도입일"
|
|
name="purchaseDate"
|
|
type="date"
|
|
value={formData.purchaseDate}
|
|
onChange={handleChange}
|
|
/>
|
|
|
|
<Input
|
|
label="구입 가격 (KRW)"
|
|
name="purchasePrice"
|
|
type="number"
|
|
value={formData.purchasePrice}
|
|
onChange={handleChange}
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
|
|
{/* Bottom Action Buttons */}
|
|
<div className="form-actions-footer">
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => navigate(-1)}
|
|
icon={<ArrowLeft size={16} />}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
icon={<Save size={16} />}
|
|
>
|
|
저장
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|