- 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용 - 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축 - 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화 - 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5) - 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화
477 lines
23 KiB
TypeScript
477 lines
23 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useLocation, useNavigate } from 'react-router-dom';
|
||
import { Card } from '../../../shared/ui/Card';
|
||
import { Button } from '../../../shared/ui/Button';
|
||
import { Input } from '../../../shared/ui/Input';
|
||
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
||
import './AssetListPage.css';
|
||
|
||
import { getCategories } from './AssetSettingsPage';
|
||
|
||
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
||
import { SERVER_URL } from '../../../shared/api/client';
|
||
import ExcelJS from 'exceljs';
|
||
import { saveAs } from 'file-saver';
|
||
|
||
// Mock Data Removed - Now using API
|
||
|
||
|
||
export function AssetListPage() {
|
||
const location = useLocation();
|
||
const navigate = useNavigate();
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [assets, setAssets] = useState<Asset[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const itemsPerPage = 8;
|
||
|
||
// Fetch Assets
|
||
useEffect(() => {
|
||
loadAssets();
|
||
}, []);
|
||
|
||
const loadAssets = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
const data = await assetApi.getAll();
|
||
if (Array.isArray(data)) {
|
||
setAssets(data);
|
||
} else {
|
||
console.error("API returned non-array data:", data);
|
||
setAssets([]);
|
||
}
|
||
} catch (error: any) {
|
||
console.error("Failed to fetch assets:", error);
|
||
setError("데이터를 불러오는 중 오류가 발생했습니다. 서버 연결을 확인해 주세요.");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// 1. Determine Category based on URL & Dynamic Settings
|
||
const getCategoryFromPath = () => {
|
||
const categories = getCategories();
|
||
|
||
if (location.pathname.includes('facilities')) return categories.find(c => c.menuLink === 'facilities')?.name || '설비';
|
||
if (location.pathname.includes('tools')) return categories.find(c => c.menuLink === 'tools')?.name || '공구';
|
||
if (location.pathname.includes('instruments')) return categories.find(c => c.menuLink === 'instruments')?.name || '계측기';
|
||
if (location.pathname.includes('vehicles')) return categories.find(c => c.menuLink === 'vehicles')?.name || '차량/운반';
|
||
if (location.pathname.includes('general')) return categories.find(c => c.menuLink === 'general')?.name || '일반 자산';
|
||
if (location.pathname.includes('consumables')) return categories.find(c => c.menuLink === 'consumables')?.name || '소모품';
|
||
|
||
return null; // 'All'
|
||
};
|
||
|
||
const currentCategory = getCategoryFromPath();
|
||
|
||
// 2. Determine Page Title
|
||
const getPageTitle = () => {
|
||
if (!currentCategory) return '전체 자산 조회';
|
||
return `${currentCategory} 현황`;
|
||
};
|
||
|
||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||
const [activeFilters, setActiveFilters] = useState({
|
||
status: [] as string[],
|
||
location: [] as string[]
|
||
});
|
||
|
||
const toggleFilter = (type: 'status' | 'location', value: string) => {
|
||
setActiveFilters(prev => {
|
||
const current = prev[type];
|
||
const updated = current.includes(value)
|
||
? current.filter(item => item !== value)
|
||
: [...current, value];
|
||
return { ...prev, [type]: updated };
|
||
});
|
||
};
|
||
|
||
const clearFilters = () => {
|
||
setActiveFilters({ status: [], location: [] });
|
||
};
|
||
|
||
// 3. Main Filter Logic
|
||
const filteredAssets = assets.filter(asset => {
|
||
// Category Filter
|
||
if (currentCategory) {
|
||
// Strict match for category name
|
||
// Note: If using multiple categories per item, use includes. For now, strict match.
|
||
// If asset.category contains the currentCategory string (e.g. '자산' in '일반 자산'), it might pass loosely.
|
||
// But we want strict filtering for specific menus.
|
||
|
||
// If the asset category is exactly the current category, keep it.
|
||
// Or if the asset category includes the main part (legacy logic).
|
||
// Let's make it strict if possible, or robust for '설비' vs '설비 자산'.
|
||
|
||
if (asset.category !== currentCategory) {
|
||
// partial match fallback (e.g., '시설' in '시설 자산' or vice versa)
|
||
const assetCat = asset.category || '';
|
||
const targetCat = currentCategory || '';
|
||
if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false;
|
||
}
|
||
}
|
||
|
||
// Search Term Filter
|
||
if (searchTerm) {
|
||
const lowerTerm = searchTerm.toLowerCase();
|
||
if (!asset.name.toLowerCase().includes(lowerTerm) &&
|
||
!asset.id.toLowerCase().includes(lowerTerm) &&
|
||
!asset.location.toLowerCase().includes(lowerTerm)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Status Filter
|
||
if (activeFilters.status.length > 0 && !activeFilters.status.includes(asset.status)) {
|
||
return false;
|
||
}
|
||
|
||
// Location Filter
|
||
if (activeFilters.location.length > 0 && !activeFilters.location.includes(asset.location)) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
|
||
// 4. Pagination
|
||
const totalPages = Math.ceil(filteredAssets.length / itemsPerPage);
|
||
const paginatedAssets = filteredAssets.slice(
|
||
(currentPage - 1) * itemsPerPage,
|
||
currentPage * itemsPerPage
|
||
);
|
||
|
||
const getStatusBadge = (status: string) => {
|
||
switch (status) {
|
||
case 'active': return <span className="badge badge-success">정상 가동</span>;
|
||
case 'maintain': return <span className="badge badge-warning">점검 중</span>;
|
||
case 'broken': return <span className="badge badge-danger">수리 필요</span>;
|
||
default: return <span className="badge badge-neutral">미상</span>;
|
||
}
|
||
};
|
||
|
||
const getStatusText = (status: string) => {
|
||
switch (status) {
|
||
case 'active': return '정상 가동';
|
||
case 'maintain': return '점검 중';
|
||
case 'broken': return '수리 필요';
|
||
case 'disposed': return '폐기 (말소)';
|
||
default: return '미상';
|
||
}
|
||
};
|
||
|
||
const handleExcelDownload = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const workbook = new ExcelJS.Workbook();
|
||
const worksheet = workbook.addWorksheet('자산 목록');
|
||
|
||
// Columns
|
||
worksheet.columns = [
|
||
{ header: '이미지', key: 'image', width: 15 },
|
||
{ header: '관리번호', key: 'id', width: 20 },
|
||
{ header: '자산명', key: 'name', width: 30 },
|
||
{ header: '카테고리', key: 'category', width: 15 },
|
||
{ header: '모델명', key: 'model', width: 20 },
|
||
{ header: 'S/N', key: 'serialNumber', width: 20 },
|
||
{ header: '제작사', key: 'manufacturer', width: 20 },
|
||
{ header: '설치 위치', key: 'location', width: 20 },
|
||
{ header: '상태', key: 'status', width: 15 },
|
||
{ header: '관리자', key: 'manager', width: 15 },
|
||
{ header: '도입일', key: 'purchaseDate', width: 15 }
|
||
];
|
||
|
||
// Add rows first (Pass 1) to avoid empty row creation issues
|
||
const rowsWithImages: { row: ExcelJS.Row, image: string | undefined, id: string }[] = [];
|
||
|
||
for (const asset of filteredAssets) {
|
||
const row = worksheet.addRow({
|
||
id: asset.id,
|
||
name: asset.name,
|
||
category: asset.category,
|
||
model: asset.model,
|
||
serialNumber: asset.serialNumber,
|
||
manufacturer: asset.manufacturer,
|
||
location: asset.location,
|
||
status: getStatusText(asset.status),
|
||
manager: asset.manager,
|
||
purchaseDate: asset.purchaseDate
|
||
});
|
||
|
||
// Set row height
|
||
row.height = 60;
|
||
|
||
if (asset.image) {
|
||
rowsWithImages.push({ row, image: asset.image, id: asset.id });
|
||
}
|
||
}
|
||
|
||
// Embed Images (Pass 2)
|
||
for (const { row, image, id } of rowsWithImages) {
|
||
if (!image) continue;
|
||
try {
|
||
const imageUrl = image.startsWith('http') ? image : `${SERVER_URL}${image}`;
|
||
const response = await fetch(imageUrl);
|
||
const buffer = await response.arrayBuffer();
|
||
const imageId = workbook.addImage({
|
||
buffer: buffer,
|
||
extension: 'png',
|
||
});
|
||
|
||
worksheet.addImage(imageId, {
|
||
tl: { col: 0, row: row.number - 1 } as any,
|
||
br: { col: 1, row: row.number } as any,
|
||
editAs: 'oneCell'
|
||
});
|
||
} catch (err) {
|
||
console.warn('Failed to embed image for asset:', id, err);
|
||
}
|
||
}
|
||
|
||
// Headers Styling
|
||
worksheet.getRow(1).font = { bold: true };
|
||
worksheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' };
|
||
|
||
// Generate File
|
||
const buffer = await workbook.xlsx.writeBuffer();
|
||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||
const today = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||
saveAs(blob, `자산목록_${today}.xlsx`);
|
||
|
||
} catch (error) {
|
||
console.error("Excel generation failed:", error);
|
||
alert("엑셀 다운로드 중 오류가 발생했습니다.");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="page-container">
|
||
<div className="page-header-right">
|
||
<h1 className="page-title-text">{getPageTitle()}</h1>
|
||
<p className="page-subtitle">
|
||
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
|
||
</p>
|
||
</div>
|
||
|
||
<Card className="content-card">
|
||
{/* Toolbar */}
|
||
<div className="table-toolbar relative">
|
||
<div className="search-box">
|
||
<Input
|
||
placeholder="자산명, 관리번호, 위치 검색..."
|
||
icon={<Search size={18} />}
|
||
value={searchTerm}
|
||
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||
/>
|
||
</div>
|
||
<div className="filter-actions relative">
|
||
<Button
|
||
variant={isFilterOpen ? 'primary' : 'secondary'}
|
||
icon={<Filter size={18} />}
|
||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||
>
|
||
필터
|
||
</Button>
|
||
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}>엑셀 다운로드</Button>
|
||
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}>자산 등록</Button>
|
||
|
||
{/* Filter Popup */}
|
||
{isFilterOpen && (
|
||
<div className="filter-popup">
|
||
<div className="filter-section">
|
||
<h4 className="filter-title">상태</h4>
|
||
<div className="filter-options">
|
||
{[
|
||
{ label: '정상 가동', value: 'active' },
|
||
{ label: '점검 중', value: 'maintain' },
|
||
{ label: '수리 필요', value: 'broken' },
|
||
{ label: '폐기 (말소)', value: 'disposed' }
|
||
].map(opt => (
|
||
<label key={opt.value} className="checkbox-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={activeFilters.status.includes(opt.value)}
|
||
onChange={() => toggleFilter('status', opt.value)}
|
||
/>
|
||
{opt.label}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="filter-divider" />
|
||
<div className="filter-section">
|
||
<h4 className="filter-title">위치</h4>
|
||
<div className="filter-options">
|
||
{[
|
||
'제1공장 A라인',
|
||
'제1공장 B라인',
|
||
'제2공장 창고',
|
||
'본사 사무실'
|
||
].map(loc => (
|
||
<label key={loc} className="checkbox-label">
|
||
<input
|
||
type="checkbox"
|
||
checked={activeFilters.location.includes(loc)}
|
||
onChange={() => toggleFilter('location', loc)}
|
||
/>
|
||
{loc}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="filter-footer">
|
||
<button className="text-btn text-sm" onClick={clearFilters}>초기화</button>
|
||
<Button size="sm" onClick={() => setIsFilterOpen(false)}>닫기</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div className="table-container">
|
||
<table className="data-table">
|
||
<thead>
|
||
<tr className="text-center">
|
||
<th className="text-center" style={{ width: '50px' }}><input type="checkbox" /></th>
|
||
<th className="text-center">관리번호</th>
|
||
<th className="text-center">자산명</th>
|
||
<th className="text-center">카테고리</th>
|
||
<th className="text-center">모델명</th>
|
||
<th className="text-center">설치 위치</th>
|
||
<th className="text-center">상태</th>
|
||
<th className="text-center">관리자</th>
|
||
<th className="text-center">도입일</th>
|
||
<th className="text-center w-24">관리</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoading ? (
|
||
<tr>
|
||
<td colSpan={10} className="text-center py-8 text-slate-500">
|
||
데이터를 불러오는 중입니다...
|
||
</td>
|
||
</tr>
|
||
) : paginatedAssets.length > 0 ? (
|
||
paginatedAssets.map((asset) => (
|
||
<tr key={asset.id} className="hover-row group text-center">
|
||
<td><input type="checkbox" /></td>
|
||
<td className="text-slate-600">
|
||
<span
|
||
className="hover:text-blue-600 hover:underline cursor-pointer group-hover:text-blue-600 transition-colors"
|
||
onClick={() => navigate(`/asset/detail/${asset.id}`)}
|
||
title="상세 정보 보기"
|
||
>
|
||
{asset.id}
|
||
</span>
|
||
</td>
|
||
<td className="font-medium text-slate-900">
|
||
<span
|
||
className="hover:text-blue-600 hover:underline cursor-pointer group-hover:text-blue-600 transition-colors"
|
||
onClick={() => navigate(`/asset/detail/${asset.id}`)}
|
||
title="상세 정보 보기"
|
||
>
|
||
{asset.name}
|
||
</span>
|
||
</td>
|
||
<td><span className="category-tag">{asset.category}</span></td>
|
||
<td className="text-slate-500">{asset.model}</td>
|
||
<td>{asset.location}</td>
|
||
<td className="status-cell">
|
||
<div>
|
||
{getStatusBadge(asset.status)}
|
||
</div>
|
||
</td>
|
||
<td>{asset.manager}</td>
|
||
<td className="text-slate-500">{asset.purchaseDate}</td>
|
||
<td>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
className="h-8 px-3 text-xs"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
navigate(`/asset/detail/${asset.id}`);
|
||
}}
|
||
>
|
||
관리
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
) : error ? (
|
||
<tr>
|
||
<td colSpan={10} className="empty-state text-red-500">
|
||
<div className="flex flex-col items-center gap-2">
|
||
<span>⚠️ {error}</span>
|
||
<Button size="sm" variant="secondary" onClick={loadAssets}>다시 시도</Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
<tr>
|
||
<td colSpan={10} className="empty-state">
|
||
데이터가 없습니다.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
<div className="pagination-bar">
|
||
<div className="pagination-info">
|
||
총 <span className="font-bold">{filteredAssets.length}</span>개 중 <span className="font-bold">{filteredAssets.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} - {Math.min(currentPage * itemsPerPage, filteredAssets.length)}</span>
|
||
</div>
|
||
<div className="pagination-controls">
|
||
<button
|
||
className="page-btn"
|
||
disabled={currentPage === 1}
|
||
onClick={() => setCurrentPage(1)}
|
||
>
|
||
<ChevronsLeft size={16} />
|
||
</button>
|
||
<button
|
||
className="page-btn"
|
||
disabled={currentPage === 1}
|
||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||
>
|
||
<ChevronLeft size={16} />
|
||
</button>
|
||
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
|
||
<button
|
||
key={page}
|
||
className={`page-number ${currentPage === page ? 'active' : ''}`}
|
||
onClick={() => setCurrentPage(page)}
|
||
>
|
||
{page}
|
||
</button>
|
||
))}
|
||
|
||
<button
|
||
className="page-btn"
|
||
disabled={currentPage === totalPages || totalPages === 0}
|
||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||
>
|
||
<ChevronRight size={16} />
|
||
</button>
|
||
<button
|
||
className="page-btn"
|
||
disabled={currentPage === totalPages || totalPages === 0}
|
||
onClick={() => setCurrentPage(totalPages)}
|
||
>
|
||
<ChevronsRight size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|