smart_ims/src/modules/asset/pages/AssetListPage.tsx
choibk 8b2589b6fa feat: 플랫폼 보안 강화, 권한 계층 시스템 도입 및 버전 관리 통합 (v0.2.5)
- 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용
- 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축
- 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화
- 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5)
- 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화
2026-01-24 17:17:33 +09:00

477 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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