diff --git a/src/components/Modal/PCModal.ts b/src/components/Modal/PCModal.ts
index 5185f62..29a78ed 100644
--- a/src/components/Modal/PCModal.ts
+++ b/src/components/Modal/PCModal.ts
@@ -1,12 +1,11 @@
import { state } from '../../state';
-import { HardwareAsset } from '../../excelHandler';
+import { HardwareAsset, HardwareLog } from '../../excelHandler';
import { openModal } from './BaseModal';
/**
* 개인PC 모달 초기화 및 로직 제어
*/
export function initPCModal(renderContent: () => void, closeModals: () => void) {
- const pcModal = document.getElementById('pc-asset-modal') as HTMLDivElement;
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const btnSavePc = document.getElementById('btn-save-pc-asset') as HTMLButtonElement;
const btnDeletePc = document.getElementById('btn-delete-pc-asset') as HTMLButtonElement;
@@ -48,7 +47,27 @@ export function initPCModal(renderContent: () => void, closeModals: () => void)
if (id) {
const idx = state.masterData.hw.findIndex(a => a.id === id);
- if(idx !== -1) state.masterData.hw[idx] = newAsset;
+ if(idx !== -1) {
+ const oldAsset = state.masterData.hw[idx];
+ const changes = getChangeDetails(oldAsset, newAsset);
+
+ if (changes) {
+ // 로그인 기능이 없으므로 '관리자'가 로그인한 것으로 가정
+ const modifier = '관리자';
+
+ const log: HardwareLog = {
+ id: Math.random().toString(36).substring(2, 9),
+ assetId: id,
+ date: new Date().toLocaleString(),
+ details: changes,
+ user: modifier
+ };
+
+ state.masterData.logs.push(log);
+ }
+
+ state.masterData.hw[idx] = newAsset;
+ }
} else {
state.masterData.hw.push(newAsset);
}
@@ -63,6 +82,7 @@ export function initPCModal(renderContent: () => void, closeModals: () => void)
const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value;
if (confirm('삭제하시겠습니까?')) {
state.masterData.hw = state.masterData.hw.filter(a => a.id !== id);
+ // 관련 로그도 삭제할지 여부는 정책에 따라 (여기서는 유지)
closeModals();
renderContent();
}
@@ -71,12 +91,11 @@ export function initPCModal(renderContent: () => void, closeModals: () => void)
/**
* 개인PC 상세 모달 열기
- * @param asset 수정 시 자산 데이터, 신규 시 undefined
*/
export function openPcModal(asset?: HardwareAsset) {
- const pcModal = document.getElementById('pc-asset-modal') as HTMLDivElement;
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-pc-asset')!;
+ const historyArea = document.querySelector('.modal-history-area') as HTMLElement;
openModal('pc-asset-modal');
pcForm.reset();
@@ -84,6 +103,7 @@ export function openPcModal(asset?: HardwareAsset) {
if (asset) {
document.getElementById('pc-modal-title')!.textContent = '개인PC 상세 정보 수정';
deleteBtn.style.display = 'block';
+ if (historyArea) historyArea.style.display = 'flex';
(document.getElementById('pc-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('pc-법인') as HTMLSelectElement).value = asset.법인;
@@ -98,14 +118,78 @@ export function openPcModal(asset?: HardwareAsset) {
(document.getElementById('pc-HDD1') as HTMLInputElement).value = asset.HDD1 || '';
(document.getElementById('pc-HDD2') as HTMLInputElement).value = asset.HDD2 || '';
(document.getElementById('pc-구매일') as HTMLInputElement).value = asset.구매일 || '';
- (document.getElementById('pc-금액') as HTMLInputElement).value = asset.금액 ? Number(asset.금액.replace(/,/g, '')).toLocaleString() : '';
+ (document.getElementById('pc-금액') as HTMLInputElement).value = asset.금액 ? asset.금액.replace(/,/g, '').replace(/\B(?=(\d{3})+(?!\d))/g, ',') : '';
(document.getElementById('pc-납품업체') as HTMLInputElement).value = asset.납품업체 || '';
(document.getElementById('pc-품의서명') as HTMLElement).innerText = asset.품의서명 ? `📎${asset.품의서명}` : '';
+
+ renderHistory(asset.id);
} else {
document.getElementById('pc-modal-title')!.textContent = '새 개인PC 자산 추가';
deleteBtn.style.display = 'none';
+ if (historyArea) historyArea.style.display = 'none'; // 신규 시 이력 숨김
+
(document.getElementById('pc-asset-id') as HTMLInputElement).value = '';
(document.getElementById('pc-법인') as HTMLSelectElement).value = '한맥';
(document.getElementById('pc-품의서명') as HTMLElement).innerText = '';
}
}
+
+/**
+ * 변경 사항 감지 및 문자열 생성
+ */
+function getChangeDetails(oldAsset: HardwareAsset, newAsset: HardwareAsset): string {
+ const changes: string[] = [];
+ const fields = [
+ { key: '법인', label: '법인' },
+ { key: '자산코드', label: '자산코드' },
+ { key: '사용자', label: '사용자' },
+ { key: '위치', label: '위치' },
+ { key: 'CPU', label: 'CPU' },
+ { key: 'GPU', label: 'GPU' },
+ { key: 'RAM', label: 'RAM' },
+ { key: 'SSD1', label: 'SSD1' },
+ { key: 'SSD2', label: 'SSD2' },
+ { key: 'HDD1', label: 'HDD1' },
+ { key: 'HDD2', label: 'HDD2' },
+ { key: '구매일', label: '구매일' },
+ { key: '금액', label: '금액' },
+ { key: '납품업체', label: '납품업체' },
+ { key: '품의서명', label: '품의서' },
+ ];
+
+ fields.forEach(field => {
+ const oldVal = (oldAsset as any)[field.key] || '';
+ const newVal = (newAsset as any)[field.key] || '';
+
+ if (oldVal !== newVal) {
+ changes.push(`${field.label}: ${oldVal || '없음'} → ${newVal || '없음'}`);
+ }
+ });
+
+ return changes.join('\n');
+}
+
+/**
+ * 이력 리스트 렌더링
+ */
+function renderHistory(assetId: string) {
+ const historyList = document.getElementById('pc-history-list');
+ if (!historyList) return;
+
+ const logs = state.masterData.logs
+ .filter(l => l.assetId === assetId)
+ .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
+
+ if (logs.length === 0) {
+ historyList.innerHTML = '
이력이 없습니다.
';
+ return;
+ }
+
+ historyList.innerHTML = logs.map(log => `
+
+
${log.date}
+
수정자: ${log.user}
+
${log.details.replace(/\n/g, '
')}
+
+ `).join('');
+}
diff --git a/src/excelHandler.ts b/src/excelHandler.ts
index 6b9bc1a..55eb7be 100644
--- a/src/excelHandler.ts
+++ b/src/excelHandler.ts
@@ -61,10 +61,19 @@ export interface SWUser {
신청서명: string;
}
+export interface HardwareLog {
+ id: string;
+ assetId: string;
+ date: string;
+ details: string;
+ user: string;
+}
+
export interface MasterAssetData {
hw: HardwareAsset[];
sw: SoftwareAsset[];
swUsers: SWUser[];
+ logs: HardwareLog[];
}
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품'];
@@ -76,6 +85,7 @@ const STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치',
const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고'];
const SW_USER_HEADERS = ['id', 'swId', '법인', '부서', '팀', '직위', '이름', '사용기간', '신청서명'];
+const HISTORY_HEADERS = ['id', 'assetId', 'date', 'details', 'user'];
/**
* 템플릿 엑셀 다중 시트로 다운로드
@@ -112,6 +122,11 @@ export function downloadTemplate() {
swUserWs['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
+ // History 시트 (템플릿에도 포함)
+ const historyWs = XLSX.utils.aoa_to_sheet([HISTORY_HEADERS]);
+ historyWs['!cols'] = [{wch:15}, {wch:20}, {wch:20}, {wch:50}, {wch:15}];
+ XLSX.utils.book_append_sheet(wb, historyWs, 'History');
+
XLSX.writeFile(wb, 'itam_assets_template.xlsx');
}
@@ -180,6 +195,15 @@ export function exportToExcel(masterData: MasterAssetData) {
const swUserWs = XLSX.utils.aoa_to_sheet(swUserWsData);
swUserWs['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
+
+ // History
+ const historyWsData = [
+ HISTORY_HEADERS,
+ ...masterData.logs.map(l => [l.id, l.assetId, l.date, l.details, l.user])
+ ];
+ const historyWs = XLSX.utils.aoa_to_sheet(historyWsData);
+ historyWs['!cols'] = [{wch:15}, {wch:20}, {wch:20}, {wch:50}, {wch:15}];
+ XLSX.utils.book_append_sheet(wb, historyWs, 'History');
const dateStr = new Date().toISOString().split('T')[0];
XLSX.writeFile(wb, `itam_assets_master_${dateStr}.xlsx`);
@@ -200,6 +224,7 @@ export async function parseExcel(file: File): Promise
{
const hwAssets: HardwareAsset[] = [];
const swAssets: SoftwareAsset[] = [];
const swUsers: SWUser[] = [];
+ const logs: HardwareLog[] = [];
workbook.SheetNames.forEach(sheetName => {
const worksheet = workbook.Sheets[sheetName];
@@ -314,9 +339,21 @@ export async function parseExcel(file: File): Promise {
});
});
}
+
+ if (sheetName === 'History') {
+ json.forEach(row => {
+ logs.push({
+ id: row['id'] ? String(row['id']) : Math.random().toString(36).substring(2, 9),
+ assetId: row['assetId'] ? String(row['assetId']) : '',
+ date: row['date'] || '',
+ details: row['details'] || '',
+ user: row['user'] || '',
+ });
+ });
+ }
});
- resolve({ hw: hwAssets, sw: swAssets, swUsers });
+ resolve({ hw: hwAssets, sw: swAssets, swUsers, logs });
} catch (err) {
reject(err);
}
diff --git a/src/main.ts b/src/main.ts
index 9dceb60..4ccb26e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,4 +1,4 @@
-import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 } from 'lucide';
+import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History } from 'lucide';
import { downloadTemplate, exportToExcel, parseExcel } from './excelHandler';
import { state } from './state';
import { initSidebar } from './components/Sidebar';
@@ -22,7 +22,7 @@ const btnExport = document.getElementById('btn-export-excel') as HTMLButtonEleme
// Initialize Icons
createIcons({
- icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 }
+ icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History }
});
// Initialize Components
diff --git a/src/state.ts b/src/state.ts
index 9f72589..6d95323 100644
--- a/src/state.ts
+++ b/src/state.ts
@@ -11,7 +11,10 @@ export interface AppState {
// --- Initial State ---
export const state: AppState = {
- masterData: generateDummyData(),
+ masterData: {
+ ...generateDummyData(),
+ logs: [] // 초기 로그 배열 추가
+ },
activeCategory: 'hw',
activeSubTab: '대시보드',
activeCharts: []
diff --git a/src/styles/modal.css b/src/styles/modal.css
index 83e142c..11e42a3 100644
--- a/src/styles/modal.css
+++ b/src/styles/modal.css
@@ -63,3 +63,99 @@
}
.footer-actions { display: flex; gap: 0.5rem; }
+
+/* Wide Modal for History */
+.modal-content.wide {
+ max-width: 950px;
+}
+
+.modal-body-split {
+ display: flex;
+ gap: 2rem;
+ min-height: 480px;
+}
+
+.modal-form-area {
+ flex: 1.2;
+}
+
+.modal-history-area {
+ flex: 0.8;
+ border-left: 1px solid var(--border-color);
+ padding-left: 1.5rem;
+ display: flex;
+ flex-direction: column;
+}
+
+.history-header {
+ margin-bottom: 1rem;
+}
+
+.history-header h3 {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--text-main);
+}
+
+.history-timeline {
+ flex: 1;
+ overflow-y: auto;
+ max-height: 500px;
+ padding-right: 0.5rem;
+}
+
+.history-item {
+ position: relative;
+ padding-left: 1.25rem;
+ padding-bottom: 1.5rem;
+ border-left: 2px solid var(--border-color);
+}
+
+.history-item::before {
+ content: '';
+ position: absolute;
+ left: -7px;
+ top: 0;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background-color: var(--white);
+ border: 2px solid var(--primary-color);
+}
+
+.history-item:last-child {
+ border-left: 2px solid transparent;
+}
+
+.history-date {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-weight: 500;
+ margin-bottom: 0.25rem;
+}
+
+.history-user {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--primary-color);
+ margin-bottom: 0.25rem;
+}
+
+.history-details {
+ font-size: 0.8125rem;
+ color: var(--text-main);
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.empty-history {
+ padding: 2rem 0;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.8125rem;
+}
+