diff --git a/server.js b/server.js
index 103bafb..4772a4c 100644
--- a/server.js
+++ b/server.js
@@ -84,7 +84,8 @@ const hardwareInsertSQL = (table) => `
id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details,
current_org, prev_org, location, manager_main, manager_sub, ip_address,
remote_tool, server_id, server_pw, model_name, os, cpu, ram, gpu,
- storage1, storage2, storage3, monitoring, price, remarks
+ storage1, storage2, storage3, monitoring, price, remarks,
+ storage_location, status
) VALUES ?
`;
@@ -92,7 +93,8 @@ const getHardwareValues = (a) => [
a.id, a.법인||'', a.자산코드||'', a.구매일||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'',
a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'',
- a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||''
+ a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'',
+ a.보관위치||'', a.현재상태||''
];
const mapHardware = (r, defaultType) => ({
@@ -101,7 +103,8 @@ const mapHardware = (r, defaultType) => ({
이전사용조직: r.prev_org, 위치: r.location, 담당자_정: r.manager_main, 담당자_부: r.manager_sub,
IP주소: r.ip_address, 원격접속: r.remote_tool, 서버ID: r.server_id, 서버PW: r.server_pw,
모델명: r.model_name, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1,
- SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks
+ SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks,
+ 보관위치: r.storage_location, 현재상태: r.status
});
// --- API 라우트 정의 ---
@@ -320,6 +323,34 @@ app.post('/api/sw-users/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
+// 자산번호 자동 생성 API
+app.get('/api/generate-asset-code', async (req, res) => {
+ const { prefix } = req.query;
+ if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
+
+ try {
+ const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
+ let maxNum = 0;
+
+ for (const table of tables) {
+ const [rows] = await pool.query(
+ `SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`,
+ [`${prefix}%`]
+ );
+ rows.forEach(r => {
+ const numPart = r.asset_code.replace(prefix, '');
+ const num = parseInt(numPart);
+ if (!isNaN(num) && num > maxNum) maxNum = num;
+ });
+ }
+
+ const nextNum = (maxNum + 1).toString().padStart(3, '0');
+ res.json({ nextCode: `${prefix}${nextNum}` });
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
// 초기화 및 서버 기동
ensureTables().then(() => {
app.listen(PORT, () => {
diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts
index 29049d2..dcc94f2 100644
--- a/src/components/Modal/HWModal.ts
+++ b/src/components/Modal/HWModal.ts
@@ -1,7 +1,7 @@
import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state';
-import { HardwareAsset, MasterAssetData } from '../../core/excelHandler';
+import { HardwareAsset, MasterAssetData, HardwareLog } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal';
-import { createIcons, Paperclip } from 'lucide';
+import { createIcons, Paperclip, History, Plus, X, Save, Edit2, RotateCcw } from 'lucide';
import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA, TYPE_PREFIX_MAP } from './SharedData';
import {
generateOptionsHTML,
@@ -16,6 +16,8 @@ import {
let currentAsset: HardwareAsset | null = null;
let isEditMode = false;
+const STATUS_LIST = ['대여중', '보관중', '수리중', '기타'];
+
const HW_MODAL_HTML = `
@@ -24,23 +26,162 @@ const HW_MODAL_HTML = `
+
+
+
`;
+function renderHwHistory(assetId: string) {
+ const container = document.getElementById('hw-history-list');
+ if (!container) return;
+ const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
+ if (logs.length === 0) {
+ container.innerHTML = '
기록된 이력이 없습니다.
';
+ return;
+ }
+ container.innerHTML = logs.map(l => `
+
+
${l.date}
+
${l.user}
+
${l.details}
+
+ `).join('');
+}
+
+function applyTypeSpecificUI(type: string) {
+ const detailPurpose = getFieldValue('hw-상세용도');
+ const upperType = (type || '').toUpperCase();
+
+ const groups: Record
= {
+ detailPurpose: document.getElementById('hw-상세용도-group'),
+ networkTitle: document.getElementById('hw-network-title'),
+ specTitle: document.getElementById('hw-spec-title'),
+ opTitle: document.getElementById('hw-op-title'),
+ model: document.getElementById('hw-model-group'),
+ os: document.getElementById('hw-os-group'),
+ cpu: document.getElementById('hw-cpu-group'),
+ ram: document.getElementById('hw-ram-group'),
+ ssd1: document.getElementById('hw-ssd1-group'),
+ ssd2: document.getElementById('hw-ssd2-group'),
+ hwSpec: document.getElementById('hw-hwspec-group'),
+ monitoring: document.getElementById('hw-monitoring-group')
+ };
+
+ const serverOnly = document.querySelectorAll('.server-only');
+ const nonServer = document.querySelectorAll('.non-server');
+ const opOnly = document.querySelectorAll('.op-only');
+ const standardLoc = document.querySelectorAll('.loc-standard');
+
+ // 1. 초기화 (모두 숨김 및 라벨 원복)
+ serverOnly.forEach(el => (el as HTMLElement).style.display = 'none');
+ nonServer.forEach(el => (el as HTMLElement).style.display = 'none');
+ opOnly.forEach(el => (el as HTMLElement).style.display = 'none');
+ standardLoc.forEach(el => (el as HTMLElement).style.display = 'flex');
+ Object.values(groups).forEach(g => { if (g) g.style.display = 'none'; });
+
+ const osLabel = document.querySelector('label[for="hw-OS"]') as HTMLElement;
+ const ramLabel = document.querySelector('label[for="hw-RAM"]') as HTMLElement;
+ const modelLabel = document.querySelector('label[for="hw-모델명"]') as HTMLElement;
+ if (osLabel) osLabel.innerText = '운영체제 (OS)';
+ if (ramLabel) ramLabel.innerText = 'RAM 용량';
+ if (modelLabel) modelLabel.innerText = '모델명';
+
+ // 2. 분류 판별
+ const isMobileGroup = ['모바일', '태블릿', '휴대폰'].some(t => upperType.includes(t));
+ const isEquipGroup = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t)) || upperType.includes('비품');
+ const isOpType = isMobileGroup || isEquipGroup;
+ const isPcType = upperType === 'PC' || upperType === '개인PC' || upperType === '노트북';
+
+ // 3. 레이아웃 적용
+ if (groups.opTitle) groups.opTitle.style.display = 'flex';
+
+ if (isOpType) {
+ opOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
+ standardLoc.forEach(el => (el as HTMLElement).style.display = 'none');
+
+ if (groups.specTitle) groups.specTitle.style.display = 'flex';
+ if (groups.model) groups.model.style.display = 'flex';
+
+ // 특정 부품 유형에 따른 라벨 및 필드 제어
+ const isCpuGpu = ['CPU', 'GPU'].some(t => upperType.includes(t));
+ const isRamHdd = ['RAM', 'HDD'].some(t => upperType.includes(t));
+
+ if (isCpuGpu) {
+ if (groups.os && osLabel) {
+ osLabel.innerText = '출시연월';
+ groups.os.style.display = 'flex';
+ }
+ } else if (isRamHdd) {
+ if (groups.ram && ramLabel) {
+ ramLabel.innerText = '용량';
+ groups.ram.style.display = 'flex';
+ }
+ // HDD인 경우 모델명 라벨을 S/N으로 변경
+ if (upperType.includes('HDD') && modelLabel) {
+ modelLabel.innerText = 'S/N';
+ }
+ } else {
+ if (groups.hwSpec) groups.hwSpec.style.display = 'flex';
+ }
+ }
+ else if (isPcType) {
+ if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
+ if (groups.specTitle) groups.specTitle.style.display = 'flex';
+
+ if (detailPurpose === '서버') {
+ serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
+ if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
+ ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => {
+ if (groups[k]) groups[k]!.style.display = 'flex';
+ });
+ } else {
+ nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
+ ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec'].forEach(k => {
+ if (groups[k]) groups[k]!.style.display = 'flex';
+ });
+ }
+ }
+ else if (upperType.includes('서버') || ['스토리지', 'NAS', 'DAS'].includes(upperType)) {
+ serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
+ if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
+ if (groups.specTitle) groups.specTitle.style.display = 'flex';
+ ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => {
+ if (groups[k]) groups[k]!.style.display = 'flex';
+ });
+ }
+}
+
export function openHwModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view') {
currentAsset = asset;
const modal = document.getElementById('hw-asset-modal')!;
- // 1. 잠금 상태 통합 제어 (데이터 유무가 아닌 호출 mode에만 의존)
setEditLock('hw-asset-form', mode, {
saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit',
@@ -205,94 +497,12 @@ export function openHwModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view')
});
isEditMode = (mode === 'add');
-
- // 2. 데이터 바인딩
fillHwFormData(asset);
+ applyTypeSpecificUI(asset.type);
+ renderHwHistory(asset.id);
modal.classList.remove('hidden');
- applyTypeSpecificUI(asset.type);
- createIcons({ icons: { Paperclip } });
-}
-
-function applyTypeSpecificUI(type: string) {
- const detailPurpose = getFieldValue('hw-상세용도');
- const form = document.getElementById('hw-asset-form') as HTMLFormElement;
- if (!form) return;
-
- const serverOnly = document.querySelectorAll('.server-only');
- const nonServer = document.querySelectorAll('.non-server');
- const locationFields = document.querySelectorAll('.hw-location-field');
-
- const groups: Record = {
- detailPurpose: document.getElementById('hw-상세용도-group'),
- model: document.getElementById('hw-model-group'),
- ip: document.getElementById('hw-ip-group'),
- ip2: document.getElementById('hw-ip2-group'),
- remote: document.getElementById('hw-remote-group'),
- os: document.getElementById('hw-os-group'),
- cpu: document.getElementById('hw-cpu-group'),
- ram: document.getElementById('hw-ram-group'),
- ssd1: document.getElementById('hw-ssd1-group'),
- ssd2: document.getElementById('hw-ssd2-group'),
- monitoring: document.getElementById('hw-monitoring-group'),
- serverId: document.getElementById('hw-server-id-group'),
- serverPw: document.getElementById('hw-server-pw-group'),
- hwSpec: document.getElementById('hw-hwspec-group'),
- ipNonServer: document.getElementById('hw-ip-non-server-group'),
- type: document.getElementById('hw-유형-group'),
- networkTitle: document.getElementById('hw-network-title'),
- specTitle: document.getElementById('hw-spec-title'),
- opTitle: document.getElementById('hw-op-title')
- };
-
- // 1. 초기화
- serverOnly.forEach(el => (el as HTMLElement).style.display = 'none');
- nonServer.forEach(el => (el as HTMLElement).style.display = 'none');
- locationFields.forEach(el => (el as HTMLElement).style.display = 'none');
- Object.values(groups).forEach(g => { if (g) g.style.display = 'none'; });
-
- if (groups.type) groups.type.style.display = 'flex';
- if (groups.opTitle) groups.opTitle.style.display = 'flex';
-
- // 2. PC 유형일 때 상세용도 선택창 노출 (복구 핵심)
- if (type === 'PC' || type === '개인PC' || type === '노트북') {
- if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
-
- // 상세용도가 '서버'인 경우 서버용 필드 노출, 아니면 일반 PC용 필드 노출
- if (detailPurpose === '서버') {
- serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
- locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
- if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
- ['ip', 'ip2', 'remote', 'serverId', 'serverPw', 'monitoring', 'model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2'].forEach(k => {
- if (groups[k]) groups[k]!.style.display = 'flex';
- });
- } else {
- nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
- if (groups.specTitle) groups.specTitle.style.display = 'flex';
- ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec', 'ipNonServer'].forEach(k => {
- if (groups[k]) groups[k]!.style.display = 'flex';
- });
- }
- }
- else if (type === '서버') {
- serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
- locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
- Object.values(groups).forEach(g => { if (g) g.style.display = 'flex'; });
- }
- else if (['스토리지', 'NAS', 'DAS'].includes(type)) {
- serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
- locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
- if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
- if (groups.ip) groups.ip.style.display = 'flex';
- if (groups.specTitle) groups.specTitle.style.display = 'flex';
- if (groups.model) groups.model.style.display = 'flex';
- if (groups.ssd1) groups.ssd1.style.display = 'flex';
- }
- else {
- // 기타 유형 (CPU, RAM, 모바일 등)
- if (groups.specTitle) groups.specTitle.style.display = 'flex';
- if (groups.model) groups.model.style.display = 'flex';
- }
+ createIcons({ icons: { X, Save, Edit2, RotateCcw, History, Plus, Paperclip } });
}
function fillHwFormData(asset: HardwareAsset) {
@@ -303,39 +513,29 @@ function fillHwFormData(asset: HardwareAsset) {
setFieldValue('hw-현사용조직', asset.현사용조직);
setFieldValue('hw-이전사용조직', asset.이전사용조직);
setFieldValue('hw-상세용도', (asset as any).상세용도);
-
- parseAndSetLocation(asset.위치, 'hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타');
-
+ setFieldValue('hw-유형', asset.type);
setFieldValue('hw-모델명', asset.모델명);
+ setFieldValue('hw-명칭', asset.명칭 || asset.모델명);
+ setFieldValue('hw-보관위치', asset.보관위치 || '');
+ setFieldValue('hw-현재상태', asset.현재상태 || '보관중');
+ setFieldValue('hw-IP주소', asset.IP주소);
+ setFieldValue('hw-IP2', (asset as any).IP2);
+ setFieldValue('hw-원격접속', (asset as any).원격접속);
+ setFieldValue('hw-서버ID', (asset as any).서버ID);
+ setFieldValue('hw-서버PW', (asset as any).서버PW);
+ setFieldValue('hw-모니터링', (asset as any).모니터링);
setFieldValue('hw-OS', asset.OS);
setFieldValue('hw-CPU', asset.CPU);
setFieldValue('hw-RAM', asset.RAM);
setFieldValue('hw-SSD1', asset.SSD1);
setFieldValue('hw-SSD2', asset.SSD2);
+ setFieldValue('hw-HW사양', asset.HW사양);
setFieldValue('hw-담당자_정', asset.담당자_정 || asset.관리자);
- setFieldValue('hw-담당자_부', asset.담당자_부);
+ setFieldValue('hw-구매일', asset.구매일);
+ setFieldValue('hw-금액', asset.금액);
+ setFieldValue('hw-비고', asset.비고);
- const isServerGrade = asset.type === '서버' || (asset as any).상세용도 === '서버' || asset.type === '스토리지' || ['NAS', 'DAS'].includes(asset.type);
-
- if (isServerGrade) {
- setFieldValue('hw-용도', asset.용도 || (asset as any).purpose);
- setFieldValue('hw-상세', asset.상세 || (asset as any).details);
- setFieldValue('hw-비고', asset.비고 || (asset as any).remarks);
- setFieldValue('hw-구매일', asset.구매일 || (asset as any).purchase_date);
- setFieldValue('hw-유형', asset.storage유형 || asset.type);
- setFieldValue('hw-IP주소', asset.IP주소 || (asset as any).ip_address);
- setFieldValue('hw-IP2', (asset as any).IP2 || (asset as any).ip_address_2);
- setFieldValue('hw-원격접속', asset.원격접속 || (asset as any).remote_tool);
- setFieldValue('hw-서버ID', (asset as any).서버ID || (asset as any).server_id);
- setFieldValue('hw-서버PW', (asset as any).서버PW || (asset as any).server_pw);
- setFieldValue('hw-모니터링', asset.모니터링 || (asset as any).monitoring);
- } else {
- setFieldValue('hw-명칭', asset.명칭 || asset.모델명);
- setFieldValue('hw-구매일', asset.구매일 || (asset as any).purchase_date);
- setFieldValue('hw-금액', asset.금액 || (asset as any).price);
- setFieldValue('hw-HW사양', asset.HW사양 || asset.상세 || (asset as any).details);
- setFieldValue('hw-IP주소-non-server', asset.IP주소 || (asset as any).ip_address);
- }
+ parseAndSetLocation(asset.위치, 'hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타');
}
export function initHwModal(onSave: () => void, closeModals: () => void) {
@@ -349,7 +549,9 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
const typeSelect = document.getElementById('hw-유형') as HTMLSelectElement;
const detailPurposeSelect = document.getElementById('hw-상세용도') as HTMLSelectElement;
-
+ const logAddBtn = document.getElementById('btn-add-hw-log')!;
+ const logModal = document.getElementById('hw-log-modal')!;
+
[typeSelect, detailPurposeSelect].forEach(el => {
el?.addEventListener('change', () => applyTypeSpecificUI(typeSelect.value));
});
@@ -361,11 +563,7 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
document.getElementById('btn-cancel-hw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => {
- setEditLock('hw-asset-form', 'view', {
- saveBtnId: 'btn-save-hw-asset',
- revertBtnId: 'btn-revert-hw-edit',
- generateBtnId: 'btn-generate-hw-code'
- });
+ setEditLock('hw-asset-form', 'view', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' });
isEditMode = false;
if (currentAsset) fillHwFormData(currentAsset);
});
@@ -374,84 +572,95 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
const typeValue = typeSelect.value;
const purchaseDate = getFieldValue('hw-구매일');
const typeCode = TYPE_PREFIX_MAP[typeValue] || 'ETC';
+
+ // 구매일에서 연월(YYMM) 추출 (예: 2026-04-21 -> 2604)
const dateStr = purchaseDate.replace(/[^0-9]/g, '');
- if (dateStr.length < 4) { alert('올바른 구매일(연월)을 입력해주세요.'); return; }
+ if (dateStr.length < 4) {
+ alert('올바른 구매일(연월)을 입력해주세요. (예: 2026-04-21)');
+ return;
+ }
const prefix = `${typeCode}-${dateStr.substring(2, 6)}-`;
+
try {
const res = await fetch(`http://localhost:3000/api/generate-asset-code?prefix=${prefix}`);
const data = await res.json();
- if (data.nextCode) setFieldValue('hw-자산코드', data.nextCode);
- } catch (err) { alert('자산번호 생성에 실패했습니다.'); }
+ if (data.nextCode) {
+ setFieldValue('hw-자산코드', data.nextCode);
+ }
+ } catch (err) {
+ console.error('❌ 자산번호 생성 실패:', err);
+ alert('자산번호 생성에 실패했습니다.');
+ }
});
saveBtn.addEventListener('click', () => {
if (!currentAsset) return;
if (!isEditMode) {
- setEditLock('hw-asset-form', 'edit', {
- saveBtnId: 'btn-save-hw-asset',
- revertBtnId: 'btn-revert-hw-edit'
- });
+ setEditLock('hw-asset-form', 'edit', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' });
isEditMode = true;
+ applyTypeSpecificUI(getFieldValue('hw-유형'));
return;
}
- const type = typeSelect.value;
- const detailPurpose = detailPurposeSelect.value;
+ const type = getFieldValue('hw-유형');
+ const storageLoc = getFieldValue('hw-보관위치');
+ const isOpType = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => type.toUpperCase().includes(t)) || type.includes('비품') || ['모바일', '태블릿', '노트북'].some(t => type.includes(t));
const updated: any = {
...currentAsset,
법인: getFieldValue('hw-법인'),
자산코드: getFieldValue('hw-자산코드'),
현사용조직: getFieldValue('hw-현사용조직'),
- 이전사용조직: getFieldValue('hw-이전사용조직'),
- 위치: getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타'),
- 모델명: getFieldValue('hw-모델명'),
+ type: type,
+ 상세용도: getFieldValue('hw-상세용도'),
+ 명칭: getFieldValue('hw-명칭'),
+ 보관위치: storageLoc,
+ 현재상태: getFieldValue('hw-현재상태'),
OS: getFieldValue('hw-OS'),
CPU: getFieldValue('hw-CPU'),
RAM: getFieldValue('hw-RAM'),
SSD1: getFieldValue('hw-SSD1'),
SSD2: getFieldValue('hw-SSD2'),
+ IP주소: getFieldValue('hw-IP주소') || getFieldValue('hw-IP주소-non-server'),
담당자_정: getFieldValue('hw-담당자_정'),
- 관리자: getFieldValue('hw-담당자_정'),
- 담당자_부: getFieldValue('hw-담당자_부'),
- type: type,
- 상세용도: detailPurpose
+ 구매일: getFieldValue('hw-구매일'),
+ 금액: getFieldValue('hw-금액'),
+ 비고: getFieldValue('hw-비고'),
+ 위치: isOpType ? storageLoc : getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타')
};
- if (type === '서버' || (type === 'PC' && detailPurpose === '서버') || ['스토리지', 'NAS', 'DAS'].includes(type)) {
- updated.용도 = getFieldValue('hw-용도');
- updated.상세 = getFieldValue('hw-상세');
- updated.비고 = getFieldValue('hw-비고');
- updated.storage유형 = type;
- updated.IP주소 = getFieldValue('hw-IP주소');
- updated.IP2 = getFieldValue('hw-IP2');
- updated.원격접속 = getFieldValue('hw-원격접속');
- updated.서버ID = getFieldValue('hw-서버ID');
- updated.서버PW = getFieldValue('hw-서버PW');
- updated.모니터링 = getFieldValue('hw-모니터링');
- } else {
- updated.명칭 = getFieldValue('hw-명칭');
- updated.구매일 = getFieldValue('hw-구매일');
- updated.금액 = getFieldValue('hw-금액');
- updated.HW사양 = getFieldValue('hw-HW사양');
- updated.IP주소 = getFieldValue('hw-IP주소-non-server');
- }
-
saveHardwareAsset(updated);
onSave();
- setEditLock('hw-asset-form', 'view', {
- saveBtnId: 'btn-save-hw-asset',
- revertBtnId: 'btn-revert-hw-edit'
- });
+ setEditLock('hw-asset-form', 'view', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' });
isEditMode = false;
});
deleteBtn.addEventListener('click', () => {
- if (!currentAsset) return;
- if (confirm('정말로 이 자산을 삭제하시겠습니까?')) {
+ if (currentAsset && confirm('정말로 삭제하시겠습니까?')) {
deleteHardwareAsset(currentAsset.id);
onSave();
- closeModals();
+ closeModalAction();
}
});
+
+ logAddBtn.addEventListener('click', () => {
+ logModal.classList.remove('hidden');
+ (document.getElementById('new-hw-log-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
+ (document.getElementById('new-hw-log-details') as HTMLTextAreaElement).value = '';
+ });
+
+ document.getElementById('btn-close-hw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
+ document.getElementById('btn-cancel-hw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
+
+ document.getElementById('btn-confirm-hw-log')?.addEventListener('click', () => {
+ if (!currentAsset) return;
+ const date = (document.getElementById('new-hw-log-date') as HTMLInputElement).value;
+ const details = (document.getElementById('new-hw-log-details') as HTMLTextAreaElement).value;
+ if (!date || !details) return;
+
+ state.masterData.logs = state.masterData.logs || [];
+ state.masterData.logs.push({ id: Math.random().toString(36).substring(2, 9), assetId: currentAsset.id, date, user: '관리자', details });
+ logModal.classList.add('hidden');
+ renderHwHistory(currentAsset.id);
+ });
}
diff --git a/src/core/excelHandler.ts b/src/core/excelHandler.ts
index 2ffdfa6..c59d849 100644
--- a/src/core/excelHandler.ts
+++ b/src/core/excelHandler.ts
@@ -40,6 +40,8 @@ export interface HardwareAsset {
비고?: string;
현사용조직?: string;
이전사용조직?: string;
+ 보관위치?: string;
+ 현재상태?: string;
}
export interface SoftwareAsset {
diff --git a/src/core/state.ts b/src/core/state.ts
index fd5d181..d030bf8 100644
--- a/src/core/state.ts
+++ b/src/core/state.ts
@@ -14,6 +14,7 @@ export interface MasterAssetData {
logs: HardwareLog[];
// 동료 코드 호환용 통합 배열 (프론트엔드 로직용)
+ hw: HardwareAsset[];
sw: SoftwareAsset[];
}
@@ -36,6 +37,7 @@ export const state: AppState = {
subSw: [],
permSw: [],
cloud: [],
+ hw: [], // 호환용
sw: [], // 호환용
swUsers: [],
logs: []
@@ -90,6 +92,15 @@ export async function loadMasterDataFromDB() {
...state.masterData.cloud
];
+ // 하드웨어 통합 배열 생성 (대시보드 등에서 사용)
+ state.masterData.hw = [
+ ...state.masterData.pc,
+ ...state.masterData.server,
+ ...state.masterData.storage,
+ ...state.masterData.equip,
+ ...state.masterData.mobile
+ ];
+
console.log('✅ 모든 DB 데이터 로드 및 통합 완료');
return true;
} catch (err) {
@@ -110,18 +121,25 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) {
const type = updatedAsset.type || '';
const detailPurpose = (updatedAsset as any).상세용도 || updatedAsset.detail_purpose || '';
- // 1. 타겟 카테고리 결정 (유연한 검색)
+ // 1. 타겟 카테고리 결정 (사용자 정의 그룹 기준)
let targetKey: keyof MasterAssetData = 'equip';
- if (type.includes('서버') || detailPurpose.includes('서버')) {
+ const upperType = type.toUpperCase();
+ const isServer = type.includes('서버') || detailPurpose.includes('서버');
+ const isStorage = ['NAS', 'DAS', '스토리지'].some(t => type.includes(t));
+ const isMobileGroup = ['모바일', '태블릿', '노트북', '휴대폰', '핸드폰'].some(t => type.includes(t));
+ const isEquipGroup = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t));
+ const isPc = type === 'PC' || type === '개인PC' || detailPurpose === '개인PC';
+
+ if (isServer) {
targetKey = 'server';
- } else if (['NAS', 'DAS', '스토리지'].some(t => type.includes(t))) {
+ } else if (isStorage) {
targetKey = 'storage';
- } else if (['모바일', '태블릿', '휴대폰', '핸드폰', '노트북'].some(t => type.includes(t))) {
+ } else if (isMobileGroup) {
targetKey = 'mobile';
- } else if (type === 'PC' || type === '개인PC' || detailPurpose === '개인PC') {
+ } else if (isPc) {
targetKey = 'pc';
- } else if (['CPU', 'GPU', 'RAM', 'HDD'].some(t => type.toUpperCase().includes(t))) {
+ } else if (isEquipGroup) {
targetKey = 'equip';
}
@@ -137,6 +155,15 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) {
// 3. 새로운 타겟 카테고리에 추가
(state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset);
+
+ // 4. 통합 hw 배열 동기화
+ state.masterData.hw = [
+ ...state.masterData.pc,
+ ...state.masterData.server,
+ ...state.masterData.storage,
+ ...state.masterData.equip,
+ ...state.masterData.mobile
+ ];
}
/**
@@ -151,4 +178,13 @@ export function deleteHardwareAsset(assetId: string) {
if (idx > -1) arr.splice(idx, 1);
}
});
+
+ // 통합 hw 배열 동기화
+ state.masterData.hw = [
+ ...state.masterData.pc,
+ ...state.masterData.server,
+ ...state.masterData.storage,
+ ...state.masterData.equip,
+ ...state.masterData.mobile
+ ];
}
diff --git a/src/core/utils.ts b/src/core/utils.ts
index aefcd6f..bc999ee 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -33,6 +33,21 @@ export function normalizeDate(dateStr: string): string {
return (dateStr || '').replace(/\./g, '-').trim();
}
+/**
+ * 구매일로부터 현재까지의 경과 연수 계산 (소수점 첫째자리)
+ */
+export function calculateAssetAge(purchaseDate: string): number {
+ const normalized = normalizeDate(purchaseDate);
+ if (!normalized) return 0;
+
+ const purchase = new Date(normalized);
+ if (isNaN(purchase.getTime())) return 0;
+
+ const diffMs = Date.now() - purchase.getTime();
+ const age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
+ return Math.max(0, parseFloat(age.toFixed(1)));
+}
+
/**
* 고유 ID 생성 (7자리 랜덤 문자열)
*/
diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts
index f669e2e..c124dcf 100644
--- a/src/views/Dashboard/HwDashboard.ts
+++ b/src/views/Dashboard/HwDashboard.ts
@@ -1,104 +1,191 @@
import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
-import { openDashboardDetail } from '../../components/Modal/DashboardDetailModal';
-import { normalizeDate } from '../../core/utils';
+import { openHwModal } from '../../components/Modal/HWModal';
+import { calculateAssetAge, normalizeDate } from '../../core/utils';
declare var Chart: any;
export function renderHwDashboard(container: HTMLElement) {
- const types = ['개인PC', '서버', '스토리지', '전산비품'];
- const units = ['대', '대', '대', '개'];
- const groups: any = {};
+ const allHw = state.masterData.hw || [];
- types.forEach(t => { groups[t] = { idle: [], active: [] }; });
+ // 1. 데이터 가공
+ let totalAge = 0;
+ let countWithDate = 0;
+ let over5YearsCount = 0;
+ let latestAsset: HardwareAsset | null = null;
+ let latestYear = 0;
- state.masterData.hw.forEach(a => {
- if (!groups[a.type]) return;
- if (isHwIdle(a)) groups[a.type].idle.push(a);
- else groups[a.type].active.push(a);
+ const ageGroups = { stable: 0, warning: 0, critical: 0 };
+ const yearlyCount: Record = {};
+
+ allHw.forEach(a => {
+ const pDate = a.구매일 || (a as any).purchase_date;
+ if (!pDate) return;
+
+ const age = calculateAssetAge(pDate);
+ totalAge += age;
+ countWithDate++;
+
+ // 노후도 분류
+ if (age >= 5) {
+ over5YearsCount++;
+ ageGroups.critical++;
+ } else if (age >= 3) {
+ ageGroups.warning++;
+ } else {
+ ageGroups.stable++;
+ }
+
+ // 연도별 도입 현황 추출
+ const year = normalizeDate(pDate).split('-')[0];
+ if (year && year.length === 4) {
+ yearlyCount[year] = (yearlyCount[year] || 0) + 1;
+ const yNum = parseInt(year);
+ if (yNum > latestYear) {
+ latestYear = yNum;
+ latestAsset = a;
+ }
+ }
});
- let usageCards = '';
- types.forEach((t, i) => {
- const total = groups[t].idle.length + groups[t].active.length;
- const used = groups[t].active.length;
- const per = total > 0 ? Math.round((used / total) * 100) : 0;
- const barColor = per >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)';
-
- usageCards += `
-
-
${t} 사용현황
-
- ${total}${units[i]} 중 ${used}${units[i]} 사용 중
-
-
${per}%
-
-
`;
- });
+ const avgAge = countWithDate > 0 ? (totalAge / countWithDate).toFixed(1) : '0';
+ const over5Rate = allHw.length > 0 ? Math.round((over5YearsCount / allHw.length) * 100) : 0;
+
+ // 교체 시급 대상 TOP 10 (오래된 순)
+ const criticalList = [...allHw]
+ .filter(a => (a.구매일 || (a as any).purchase_date))
+ .sort((a, b) => {
+ const dateA = new Date(normalizeDate(a.구매일 || (a as any).purchase_date)).getTime();
+ const dateB = new Date(normalizeDate(b.구매일 || (b as any).purchase_date)).getTime();
+ return dateA - dateB;
+ })
+ .slice(0, 10);
+ // 2. UI 렌더링
container.innerHTML = `
-
자산 사용현황 요약
-
${usageCards}
-
-
하드웨어 보유 통계
-
+
+
+
-
자산 유형별 보유 현황
-
+
자산 노후도 분포
+
-
구매법인별 자산 분포
-
+
연도별 자산 도입 추이
+
+
+
⚠️ 교체 검토 대상 (가장 오래된 자산 TOP 10)
+
+
+
+
+ | 순위 |
+ 자산번호 |
+ 유형 |
+ 모델명 |
+ 사용자/담당자 |
+ 구매일 |
+ 연령 |
+
+
+
+ ${criticalList.map((a, i) => `
+
+ | ${i + 1} |
+ ${a.자산코드 || '-'} |
+ ${a.type} |
+ ${a.모델명 || a.명칭 || '-'} |
+ ${a.사용자 || a.담당자_정 || '-'} |
+ ${a.구매일 || (a as any).purchase_date || '-'} |
+ ${calculateAssetAge(a.구매일 || (a as any).purchase_date)}년 |
+
+ `).join('')}
+
+
+
`;
+ // 3. 차트 초기화
setTimeout(() => {
- if (typeof Chart === 'undefined') return;
- const ctxType = (document.getElementById('chart-hw-types') as HTMLCanvasElement)?.getContext('2d');
- const ctxCorp = (document.getElementById('chart-hw-corps') as HTMLCanvasElement)?.getContext('2d');
- if (ctxType) {
- const chart = new Chart(ctxType, {
- type: 'doughnut',
- data: { labels: types, datasets: [{ data: types.map(t => state.masterData.hw.filter(a => a.type === t).length), backgroundColor: ['#1E5149', '#3b82f6', '#10b981', '#f59e0b'] }] },
- options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } }
+ initAgingCharts(ageGroups, yearlyCount);
+
+ // 행 클릭 이벤트 바인딩
+ container.querySelectorAll('.clickable-row').forEach(row => {
+ row.addEventListener('click', () => {
+ const id = row.getAttribute('data-id');
+ const asset = allHw.find(h => h.id === id);
+ if (asset) openHwModal(asset, 'view');
});
- state.activeCharts.push(chart);
- }
- if (ctxCorp) {
- const corps = ['한맥', '삼안', '바론'];
- const chart = new Chart(ctxCorp, {
- type: 'bar',
- data: { labels: corps, datasets: [{ label: '보유 수량', data: corps.map(c => state.masterData.hw.filter(a => a.법인 === c).length), backgroundColor: 'rgba(30, 81, 73, 0.7)', borderRadius: 4 }] },
- options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
- });
- state.activeCharts.push(chart);
- }
- }, 100);
-
- container.querySelectorAll('[data-action="idle"]').forEach(card => {
- card.addEventListener('click', () => {
- const t = card.getAttribute('data-type')!;
- openDashboardDetail(`[${t}] 유휴 자산 목록`, groups[t].idle);
});
- });
+ }, 100);
}
-function isHwIdle(a: HardwareAsset) {
- if (a.type === '개인PC') return !a.사용자 || a.사용자.trim() === '' || a.사용자.trim() === '-';
- if (a.type === '스토리지') return !a.담당자_정 || a.담당자_정.trim() === '' || a.담당자_정.trim() === '-';
- return !a.관리자 || a.관리자.trim() === '' || a.관리자.trim() === '-';
-}
+function initAgingCharts(ageGroups: any, yearlyCount: Record
) {
+ const agingCtx = document.getElementById('chart-aging-dist') as HTMLCanvasElement;
+ if (agingCtx) {
+ new Chart(agingCtx, {
+ type: 'doughnut',
+ data: {
+ labels: ['안정 (3년 미만)', '주의 (3~5년)', '위험 (5년 이상)'],
+ datasets: [{
+ data: [ageGroups.stable, ageGroups.warning, ageGroups.critical],
+ backgroundColor: ['#1E5149', '#9CA3AF', '#E11D48'],
+ borderWidth: 0
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { position: 'right' } },
+ cutout: '70%'
+ }
+ });
+ }
-function getHwAgeYears(a: HardwareAsset) {
- if (!a.구매일) return 0;
- try {
- const buyDate = new Date(normalizeDate(a.구매일));
- if (isNaN(buyDate.getTime())) return 0;
- return (Date.now() - buyDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
- } catch { return 0; }
+ const trendCtx = document.getElementById('chart-purchase-trend') as HTMLCanvasElement;
+ if (trendCtx) {
+ const years = Object.keys(yearlyCount).sort();
+ new Chart(trendCtx, {
+ type: 'bar',
+ data: {
+ labels: years,
+ datasets: [{
+ label: '도입 수량',
+ data: years.map(y => yearlyCount[y]),
+ backgroundColor: '#1E5149',
+ borderRadius: 4
+ }]
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ y: { beginAtZero: true, ticks: { stepSize: 1 } },
+ x: { grid: { display: false } }
+ }
+ }
+ });
+ }
}
diff --git a/src/views/List/EquipmentListView.ts b/src/views/List/EquipmentListView.ts
index 007bb10..812f084 100644
--- a/src/views/List/EquipmentListView.ts
+++ b/src/views/List/EquipmentListView.ts
@@ -28,7 +28,23 @@ export function renderEquipmentList(container: HTMLElement) {
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
- table.innerHTML = `| No | 구매법인 | 현 사용조직 | 유형 | 자산번호 | 모델명 | 관리자 | 구매일 | 금액 | 관리 |
`;
+ table.innerHTML = `
+
+
+ | No. |
+ 상태 |
+ 구매법인 |
+ 유형 |
+ 자산번호 |
+ 모델명 |
+ 보관위치 |
+ 관리자 |
+ 구매일 |
+ 금액 |
+
+
+
+ `;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
@@ -56,19 +72,31 @@ export function renderEquipmentList(container: HTMLElement) {
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
+
+ const statusColors: Record = {
+ '대여중': '#3b82f6',
+ '보관중': '#1E5149',
+ '수리중': '#ef4444',
+ '기타': '#6b7280'
+ };
+ const statusColor = statusColors[asset.현재상태 || '보관중'] || '#6b7280';
+ const statusBadge = `${asset.현재상태 || '보관중'}`;
+
tr.innerHTML = `
- ${idx+1} |
- ${asset.법인} |
- ${asset.현사용조직||''} |
- ${asset.type} |
- ${asset.자산코드} |
- ${formatInline(asset.모델명)} |
- ${formatInline(asset.담당자_정 || asset.관리자)} |
- ${asset.구매일||''} |
- ${asset.금액||''} |
- |
+ ${idx + 1} |
+ ${statusBadge} |
+ ${asset.법인} |
+ ${asset.type} |
+ ${asset.자산코드 || '-'} |
+ ${formatInline(asset.모델명 || asset.명칭)} |
+ ${asset.보관위치 || '-'} |
+ ${formatInline(asset.담당자_정 || asset.관리자)} |
+ ${asset.구매일 || ''} |
+ ${asset.금액 || '0'} |
`;
- tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); });
+ tr.addEventListener('click', (e) => {
+ if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view');
+ });
tbody.appendChild(tr);
});
};
diff --git a/src/views/List/MobileListView.ts b/src/views/List/MobileListView.ts
index 77be378..c991143 100644
--- a/src/views/List/MobileListView.ts
+++ b/src/views/List/MobileListView.ts
@@ -28,7 +28,23 @@ export function renderMobileList(container: HTMLElement) {
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
- table.innerHTML = `| No | 구매법인 | 현 사용조직 | 유형 | 자산번호 | 모델명 | 관리자 | 구매일 | 금액 | 관리 |
`;
+ table.innerHTML = `
+
+
+ | No. |
+ 상태 |
+ 구매법인 |
+ 자산코드 |
+ 명칭 |
+ 보관위치 |
+ 관리자 |
+ 구매일 |
+ 금액 |
+
+
+
+ `;
+
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
@@ -56,19 +72,30 @@ export function renderMobileList(container: HTMLElement) {
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
+
+ const statusColors: Record = {
+ '대여중': '#3b82f6',
+ '보관중': '#1E5149',
+ '수리중': '#ef4444',
+ '기타': '#6b7280'
+ };
+ const statusColor = statusColors[asset.현재상태 || '보관중'] || '#6b7280';
+ const statusBadge = `${asset.현재상태 || '보관중'}`;
+
tr.innerHTML = `
- ${idx+1} |
- ${asset.법인} |
- ${asset.현사용조직||''} |
- ${asset.type} |
- ${asset.자산코드} |
- ${formatInline(asset.모델명)} |
- ${formatInline(asset.담당자_정 || asset.관리자)} |
- ${asset.구매일||''} |
- ${asset.금액||''} |
- |
+ ${idx + 1} |
+ ${statusBadge} |
+ ${asset.법인} |
+ ${asset.자산코드 || '-'} |
+ ${formatInline(asset.명칭 || asset.모델명)} |
+ ${asset.보관위치 || '-'} |
+ ${formatInline(asset.관리자 || asset.담당자_정)} |
+ ${asset.구매일 || ''} |
+ ${asset.금액 || '0'} |
`;
- tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); });
+ tr.addEventListener('click', (e) => {
+ if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view');
+ });
tbody.appendChild(tr);
});
};